From 4a779ccb6139d0986d7314ec95549214c0f79149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20S=C3=A1nchez?= Date: Fri, 22 May 2026 14:07:42 -0500 Subject: [PATCH 1/8] Add Discord bot with CDN support for TikTok videos Implemented a Discord bot to interact with TikTok videos, including metadata retrieval and video downloads. Added multiple CDN providers (Cloudflare R2, 0x0.st, Litterbox) for video uploads exceeding Discord's size limit. Key changes include: - Added `CloudflareR2CdnProvider`, `LitterboxCdnProvider`, and `ZeroXZeroCdnProvider` for CDN support. - Introduced `CompositeCdnProvider` to handle fallback between multiple CDNs. - Created `DownloadModule` and `VideoModule` for Discord interactions. - Added `BotService` and `InteractionHandlerService` for bot lifecycle and command handling. - Configured `appsettings.example.json` for bot and CDN settings. - Updated solution to include the new `TiktokExplode.Bot` project. --- .gitignore | 2 + .../CDN/CloudflareR2CdnProvider.cs | 63 ++++++++++ TiktokExplode.Bot/CDN/CloudflareR2Options.cs | 12 ++ TiktokExplode.Bot/CDN/CompositeCdnProvider.cs | 31 +++++ TiktokExplode.Bot/CDN/ICdnProvider.cs | 7 ++ TiktokExplode.Bot/CDN/LitterboxCdnProvider.cs | 30 +++++ TiktokExplode.Bot/CDN/ZeroXZeroCdnProvider.cs | 25 ++++ .../Configuration/BotSettings.cs | 6 + TiktokExplode.Bot/Models/VideoResult.cs | 9 ++ TiktokExplode.Bot/Modules/DownloadModule.cs | 110 ++++++++++++++++++ TiktokExplode.Bot/Modules/VideoModule.cs | 56 +++++++++ TiktokExplode.Bot/Program.cs | 52 +++++++++ TiktokExplode.Bot/Services/BotService.cs | 38 ++++++ .../Services/InteractionHandlerService.cs | 38 ++++++ TiktokExplode.Bot/TiktokExplode.Bot.csproj | 32 +++++ TiktokExplode.Bot/appsettings.example.json | 18 +++ TiktokExplode.NET.slnx | 11 +- 17 files changed, 536 insertions(+), 4 deletions(-) create mode 100644 TiktokExplode.Bot/CDN/CloudflareR2CdnProvider.cs create mode 100644 TiktokExplode.Bot/CDN/CloudflareR2Options.cs create mode 100644 TiktokExplode.Bot/CDN/CompositeCdnProvider.cs create mode 100644 TiktokExplode.Bot/CDN/ICdnProvider.cs create mode 100644 TiktokExplode.Bot/CDN/LitterboxCdnProvider.cs create mode 100644 TiktokExplode.Bot/CDN/ZeroXZeroCdnProvider.cs create mode 100644 TiktokExplode.Bot/Configuration/BotSettings.cs create mode 100644 TiktokExplode.Bot/Models/VideoResult.cs create mode 100644 TiktokExplode.Bot/Modules/DownloadModule.cs create mode 100644 TiktokExplode.Bot/Modules/VideoModule.cs create mode 100644 TiktokExplode.Bot/Program.cs create mode 100644 TiktokExplode.Bot/Services/BotService.cs create mode 100644 TiktokExplode.Bot/Services/InteractionHandlerService.cs create mode 100644 TiktokExplode.Bot/TiktokExplode.Bot.csproj create mode 100644 TiktokExplode.Bot/appsettings.example.json diff --git a/.gitignore b/.gitignore index 8c19392..e441125 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ Generated\ Files/ # NUnit *.VisualState.xml TestResult.xml + +TiktokExplode.Bot/appsettings.json nunit-*.xml # Build Results of an ATL Project diff --git a/TiktokExplode.Bot/CDN/CloudflareR2CdnProvider.cs b/TiktokExplode.Bot/CDN/CloudflareR2CdnProvider.cs new file mode 100644 index 0000000..a69735a --- /dev/null +++ b/TiktokExplode.Bot/CDN/CloudflareR2CdnProvider.cs @@ -0,0 +1,63 @@ +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace TiktokExplode.Bot.CDN; + +public sealed class CloudflareR2CdnProvider : ICdnProvider +{ + public string Name => "Cloudflare R2"; + + private readonly AmazonS3Client _s3; + private readonly CloudflareR2Options _options; + private readonly ILogger _logger; + + public CloudflareR2CdnProvider(IOptions options, ILogger logger) + { + _options = options.Value; + _logger = logger; + + var config = new AmazonS3Config + { + ServiceURL = $"https://{_options.AccountId}.r2.cloudflarestorage.com", + ForcePathStyle = true, + AuthenticationRegion = "auto" + }; + + _s3 = new AmazonS3Client( + new BasicAWSCredentials(_options.AccessKeyId, _options.SecretAccessKey), + config); + } + + public async Task UploadAsync(byte[] data, string filename, CancellationToken ct = default) + { + var publicUrl = $"{_options.PublicBaseUrl.TrimEnd('/')}/{filename}"; + + try + { + await _s3.GetObjectMetadataAsync(_options.BucketName, filename, ct); + return publicUrl; + } + catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _logger.LogInformation("Archivo {Filename} no existe en R2, procediendo a subirlo", filename); + } + + using var stream = new MemoryStream(data, writable: false); + + var request = new PutObjectRequest + { + BucketName = _options.BucketName, + Key = filename, + InputStream = stream, + ContentType = "video/mp4", + DisablePayloadSigning = true + }; + + await _s3.PutObjectAsync(request, ct); + + return publicUrl; + } +} diff --git a/TiktokExplode.Bot/CDN/CloudflareR2Options.cs b/TiktokExplode.Bot/CDN/CloudflareR2Options.cs new file mode 100644 index 0000000..8d267f8 --- /dev/null +++ b/TiktokExplode.Bot/CDN/CloudflareR2Options.cs @@ -0,0 +1,12 @@ +namespace TiktokExplode.Bot.CDN; + +public sealed class CloudflareR2Options +{ + public const string Section = "CloudflareR2"; + + public string AccountId { get; set; } = string.Empty; + public string AccessKeyId { get; set; } = string.Empty; + public string SecretAccessKey { get; set; } = string.Empty; + public string BucketName { get; set; } = string.Empty; + public string PublicBaseUrl { get; set; } = string.Empty; +} diff --git a/TiktokExplode.Bot/CDN/CompositeCdnProvider.cs b/TiktokExplode.Bot/CDN/CompositeCdnProvider.cs new file mode 100644 index 0000000..b17485c --- /dev/null +++ b/TiktokExplode.Bot/CDN/CompositeCdnProvider.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; + +namespace TiktokExplode.Bot.CDN; + +/// +/// Itera los registrados en orden y devuelve la primera URL exitosa. +/// Si todos fallan, lanza . +/// +public sealed class CompositeCdnProvider( + IEnumerable providers, + ILogger logger) +{ + public async Task UploadAsync(byte[] data, string filename, CancellationToken ct = default) + { + foreach (var provider in providers) + { + try + { + var url = await provider.UploadAsync(data, filename, ct); + logger.LogInformation("Upload exitoso via {Provider}: {Url}", provider.Name, url); + return url; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Provider {Provider} falló ({Msg}), probando el siguiente", provider.Name, ex.Message); + } + } + + throw new InvalidOperationException("Todos los proveedores CDN fallaron."); + } +} diff --git a/TiktokExplode.Bot/CDN/ICdnProvider.cs b/TiktokExplode.Bot/CDN/ICdnProvider.cs new file mode 100644 index 0000000..cbbec7f --- /dev/null +++ b/TiktokExplode.Bot/CDN/ICdnProvider.cs @@ -0,0 +1,7 @@ +namespace TiktokExplode.Bot.CDN; + +public interface ICdnProvider +{ + string Name { get; } + Task UploadAsync(byte[] data, string filename, CancellationToken ct = default); +} diff --git a/TiktokExplode.Bot/CDN/LitterboxCdnProvider.cs b/TiktokExplode.Bot/CDN/LitterboxCdnProvider.cs new file mode 100644 index 0000000..74af559 --- /dev/null +++ b/TiktokExplode.Bot/CDN/LitterboxCdnProvider.cs @@ -0,0 +1,30 @@ +using System.Net.Http.Headers; + +namespace TiktokExplode.Bot.CDN; + +public sealed class LitterboxCdnProvider(IHttpClientFactory httpClientFactory) : ICdnProvider +{ + public string Name => "Litterbox (catbox.moe)"; + + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + + public async Task UploadAsync(byte[] data, string filename, CancellationToken ct = default) + { + var http = _httpClientFactory.CreateClient(); + http.DefaultRequestHeaders.UserAgent.ParseAdd("TiktokExplodeBot/1.1"); + + using var form = new MultipartFormDataContent(); + form.Add(new StringContent("fileupload"), "reqtype"); + form.Add(new StringContent("72h"), "time"); + var fc = new ByteArrayContent(data); + fc.Headers.ContentType = new MediaTypeHeaderValue("video/mp4"); + form.Add(fc, "fileToUpload", filename); + + var req = new HttpRequestMessage(HttpMethod.Post, + "https://litterbox.catbox.moe/resources/internals/api.php") { Content = form }; + using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + resp.EnsureSuccessStatusCode(); + + return (await resp.Content.ReadAsStringAsync(ct)).Trim(); + } +} diff --git a/TiktokExplode.Bot/CDN/ZeroXZeroCdnProvider.cs b/TiktokExplode.Bot/CDN/ZeroXZeroCdnProvider.cs new file mode 100644 index 0000000..765c37c --- /dev/null +++ b/TiktokExplode.Bot/CDN/ZeroXZeroCdnProvider.cs @@ -0,0 +1,25 @@ +using System.Net.Http.Headers; + +namespace TiktokExplode.Bot.CDN; + +public sealed class ZeroXZeroCdnProvider(IHttpClientFactory httpClientFactory) : ICdnProvider +{ + public string Name => "0x0.st"; + + public async Task UploadAsync(byte[] data, string filename, CancellationToken ct = default) + { + var http = httpClientFactory.CreateClient(); + http.DefaultRequestHeaders.UserAgent.ParseAdd("TiktokExplodeBot/1.1"); + + using var form = new MultipartFormDataContent(); + var fc = new ByteArrayContent(data); + fc.Headers.ContentType = new MediaTypeHeaderValue("video/mp4"); + form.Add(fc, "file", filename); + + var req = new HttpRequestMessage(HttpMethod.Post, "https://0x0.st") { Content = form }; + using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + resp.EnsureSuccessStatusCode(); + + return (await resp.Content.ReadAsStringAsync(ct)).Trim(); + } +} diff --git a/TiktokExplode.Bot/Configuration/BotSettings.cs b/TiktokExplode.Bot/Configuration/BotSettings.cs new file mode 100644 index 0000000..066dc6f --- /dev/null +++ b/TiktokExplode.Bot/Configuration/BotSettings.cs @@ -0,0 +1,6 @@ +namespace TiktokExplode.Bot.Configuration; + +public class BotSettings +{ + public required string Token { get; init; } +} diff --git a/TiktokExplode.Bot/Models/VideoResult.cs b/TiktokExplode.Bot/Models/VideoResult.cs new file mode 100644 index 0000000..ef823b5 --- /dev/null +++ b/TiktokExplode.Bot/Models/VideoResult.cs @@ -0,0 +1,9 @@ +using TiktokExplode.Domain.Entities; + +namespace TiktokExplode.Bot.Models; + +public sealed class VideoResult +{ + public required string Url { get; set; } + public required Video Video { get; set; } +} diff --git a/TiktokExplode.Bot/Modules/DownloadModule.cs b/TiktokExplode.Bot/Modules/DownloadModule.cs new file mode 100644 index 0000000..a5241a5 --- /dev/null +++ b/TiktokExplode.Bot/Modules/DownloadModule.cs @@ -0,0 +1,110 @@ +using Discord.Interactions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using TiktokExplode.Bot.CDN; +using TiktokExplode.Domain.Abstractions; +using TiktokExplode.Domain.Entities; + +namespace TiktokExplode.Bot.Modules; + +public sealed class DownloadModule( + IVideoClient tiktok, + IMemoryCache cache, + ILogger logger, + CompositeCdnProvider cdnProvider) : InteractionModuleBase +{ + [ComponentInteraction("download:original:*")] + public async Task DownloadOriginalAsync(string videoId) + { + await DeferAsync(); + + if (!cache.TryGetValue(videoId, out Video? video)) + { + await FollowupAsync("El enlace expiro, usa el comando nuevamente.", ephemeral: true); + return; + } + + try + { + await using var streamInfo = await tiktok.DownloadAsync(video!); + await SendVideoAsync(ms => + Context.Channel.SendFileAsync(ms, $"{video!.Id}.mp4"), streamInfo, $"{video!.Id}.mp4"); + } + catch (Exception ex) + { + logger.LogError(ex, "Error descargando original {VideoId}", videoId); + await FollowupAsync($"Error al descargar: {ex.Message}", ephemeral: true); + } + } + + [ComponentInteraction("download:watermark:*")] + public async Task DownloadWatermarkedAsync(string videoId) + { + await DeferAsync(); + + if (!cache.TryGetValue(videoId, out Video? video)) + { + await FollowupAsync("El enlace expiro, usa el comando nuevamente.", ephemeral: true); + return; + } + + try + { + await using var streamInfo = await tiktok.DownloadWatermarkedAsync(video!); + await SendVideoAsync(ms => + Context.Channel.SendFileAsync(ms, $"{video!.Id}_watermark.mp4"), streamInfo, $"{video!.Id}_watermark.mp4"); + } + catch (Exception ex) + { + logger.LogError(ex, "Error descargando watermark {VideoId}", videoId); + await FollowupAsync($"Error al descargar: {ex.Message}", ephemeral: true); + } + } + + private const int DiscordMaxBytes = 8 * 1024 * 1024; // 8 MB — límite oficial para bots + + private async Task SendVideoAsync( + Func discordUpload, + Domain.ValueObjects.StreamInfo streamInfo, + string filename) + { + byte[] data; + using (var tmp = new MemoryStream()) + { + await streamInfo.Stream.CopyToAsync(tmp); + data = tmp.ToArray(); + } + + logger.LogInformation("Video listo: {Size} bytes ({SizeMb:F1} MB)", data.Length, data.Length / (1024.0 * 1024.0)); + + if (data.Length <= DiscordMaxBytes) + { + try + { + using var discordStream = new MemoryStream(data, writable: false); + await discordUpload(discordStream); + await DeleteOriginalResponseAsync(); + return; + } + catch (Exception discordEx) + { + logger.LogWarning(discordEx, "Discord upload falló ({Size} bytes), usando CDN", data.Length); + } + } + else + { + logger.LogInformation("Video supera el límite de Discord ({SizeMb:F1} MB), subiendo directo al CDN", data.Length / (1024.0 * 1024.0)); + } + + try + { + var url = await cdnProvider.UploadAsync(data, filename); + await FollowupAsync(url); + } + catch (Exception cdnEx) + { + logger.LogError(cdnEx, "Todos los providers CDN fallaron"); + await FollowupAsync("No se pudo subir el video. Intenta de nuevo más tarde.", ephemeral: true); + } + } +} \ No newline at end of file diff --git a/TiktokExplode.Bot/Modules/VideoModule.cs b/TiktokExplode.Bot/Modules/VideoModule.cs new file mode 100644 index 0000000..75dcd03 --- /dev/null +++ b/TiktokExplode.Bot/Modules/VideoModule.cs @@ -0,0 +1,56 @@ +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.Caching.Memory; +using TiktokExplode.Domain.Abstractions; +using TiktokExplode.Domain.Exceptions; + +namespace TiktokExplode.Bot.Modules; + +[Group("tiktok", "Comandos relacionados con TikTok")] +public sealed class VideoModule(IMemoryCache cache, IVideoClient tiktok) : InteractionModuleBase +{ + [SlashCommand("video", "Obten metadatos de un video de TikTok")] + public async Task VideoAsync([Summary("url", "URL del video")] string url) + { + await DeferAsync(); + + try + { + var video = await cache.GetOrCreateAsync(url, async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15); + return await tiktok.GetVideoAsync(url); + }); + + cache.Set(video!.Id, video, TimeSpan.FromMinutes(15)); + + var duration = TimeSpan.FromSeconds(video.Duration.Seconds).ToString(@"mm\:ss"); + var builder = new EmbedBuilder() + .WithTitle("Video de tiktok") + .WithDescription(video.Description) + .WithUrl(url) + .WithThumbnailUrl(video.Cover.StaticUrl) + .AddField("Autor", video.Author.Name, true) + .AddField("Duración", duration, true) + .AddField("Vistas", video.Stats.Views.ToString("N0"), true) + .AddField("Likes", video.Stats.Likes.ToString("N0"), true) + .AddField("Compartidos", video.Stats.Shares.ToString("N0"), true) + .WithCurrentTimestamp(); + + var components = new ComponentBuilder() + .WithButton("Descargar original", customId: $"download:original:{video.Id}", style: ButtonStyle.Primary) + .WithButton("Descargar con marca de agua", customId: $"download:watermark:{video.Id}", style: ButtonStyle.Secondary) + .Build(); + + await FollowupAsync(embed: builder.Build(), components: components); + } + catch (TiktokParsingException ex) when (ex.Message.Contains("429")) + { + await FollowupAsync("TikTok está limitando las solicitudes (429). Espera unos segundos e intenta de nuevo.", ephemeral: true); + } + catch (Exception ex) + { + await FollowupAsync($"Error al obtener el video: {ex.Message}", ephemeral: true); + } + } +} \ No newline at end of file diff --git a/TiktokExplode.Bot/Program.cs b/TiktokExplode.Bot/Program.cs new file mode 100644 index 0000000..7c2669b --- /dev/null +++ b/TiktokExplode.Bot/Program.cs @@ -0,0 +1,52 @@ +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using TiktokExplode.Bot.CDN; +using TiktokExplode.Bot.Configuration; +using TiktokExplode.Bot.Services; +using TiktokExplode.Extensions.DependencyInjection; + +namespace TiktokExplode.Bot; + +public static class Program +{ + public static async Task Main(string[] args) + { + var host = Host.CreateDefaultBuilder(args) + .ConfigureServices(ConfigureServices) + .Build(); + + await host.RunAsync(); + } + + private static void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + services.Configure(context.Configuration.GetSection("BotSettings")); + + services.AddSingleton(new DiscordSocketClient(new DiscordSocketConfig + { + GatewayIntents = GatewayIntents.Guilds + })); + + services.AddSingleton(provider => + new InteractionService(provider.GetRequiredService())); + + services.AddTiktokExplode(t => + t.UsePlaywrightFetcher(ops => ops.BrowserChannel = "msedge")); + + services.AddMemoryCache(); + services.AddHttpClient(); + + // CDN providers — se prueban en orden: R2 → 0x0.st → Litterbox + services.Configure(context.Configuration.GetSection(CloudflareR2Options.Section)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddHostedService(); + services.AddHostedService(); + } +} diff --git a/TiktokExplode.Bot/Services/BotService.cs b/TiktokExplode.Bot/Services/BotService.cs new file mode 100644 index 0000000..dc340aa --- /dev/null +++ b/TiktokExplode.Bot/Services/BotService.cs @@ -0,0 +1,38 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TiktokExplode.Bot.Configuration; + +namespace TiktokExplode.Bot.Services; + +public sealed class BotService( + DiscordSocketClient client, + IOptions settings, + ILogger logger) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + client.Log += OnLog; + + await client.LoginAsync(TokenType.Bot, settings.Value.Token); + await client.StartAsync(); + } + + private async Task OnLog(LogMessage msg) + { + logger.LogInformation("[{Severity}] {Source}: {Message}", msg.Severity, msg.Source, msg.Message); + await Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await client.StopAsync(); + } + + public async Task LogoutAsync() + { + await client.LogoutAsync(); + } +} diff --git a/TiktokExplode.Bot/Services/InteractionHandlerService.cs b/TiktokExplode.Bot/Services/InteractionHandlerService.cs new file mode 100644 index 0000000..5120dbe --- /dev/null +++ b/TiktokExplode.Bot/Services/InteractionHandlerService.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using Discord.Interactions; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; + +namespace TiktokExplode.Bot.Services; + +public sealed class InteractionHandlerService( + DiscordSocketClient client, + InteractionService interactionService, + IServiceProvider serviceProvider) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + client.Ready += OnReadyAsync; + client.InteractionCreated += OnInteractionCreatedAsync; + + await interactionService.AddModulesAsync(Assembly.GetEntryAssembly(), serviceProvider); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + client.Ready -= OnReadyAsync; + client.InteractionCreated -= OnInteractionCreatedAsync; + return Task.CompletedTask; + } + + private async Task OnReadyAsync() + { + await interactionService.RegisterCommandsGloballyAsync(); + } + + private async Task OnInteractionCreatedAsync(SocketInteraction interaction) + { + var context = new SocketInteractionContext(client, interaction); + await interactionService.ExecuteCommandAsync(context, serviceProvider); + } +} diff --git a/TiktokExplode.Bot/TiktokExplode.Bot.csproj b/TiktokExplode.Bot/TiktokExplode.Bot.csproj new file mode 100644 index 0000000..b141800 --- /dev/null +++ b/TiktokExplode.Bot/TiktokExplode.Bot.csproj @@ -0,0 +1,32 @@ + + + + Exe + net9.0 + enable + enable + latest + TiktokExplode.Bot + TiktokExplode.Bot + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/TiktokExplode.Bot/appsettings.example.json b/TiktokExplode.Bot/appsettings.example.json new file mode 100644 index 0000000..6d07cc2 --- /dev/null +++ b/TiktokExplode.Bot/appsettings.example.json @@ -0,0 +1,18 @@ +{ + "BotSettings": { + "Token": "YOUR_DISCORD_BOT_TOKEN" + }, + "CloudflareR2": { + "AccountId": "YOUR_CLOUDFLARE_ACCOUNT_ID", + "AccessKeyId": "YOUR_R2_ACCESS_KEY_ID", + "SecretAccessKey": "YOUR_R2_SECRET_ACCESS_KEY", + "BucketName": "YOUR_BUCKET_NAME", + "PublicBaseUrl": "https://pub-XXXXXXXXXXXX.r2.dev" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/TiktokExplode.NET.slnx b/TiktokExplode.NET.slnx index c36b719..5c45657 100644 --- a/TiktokExplode.NET.slnx +++ b/TiktokExplode.NET.slnx @@ -1,8 +1,11 @@ - - - - + + + + + + + From 8ff305faa4e9625f88e854d93c4d4cfef0211bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20S=C3=A1nchez?= Date: Fri, 22 May 2026 23:55:36 -0500 Subject: [PATCH 2/8] Add audio playback for TikTok videos in voice channels - Added `GuildId` property in `BotSettings` to enable instant command registration in specific servers for development. - Introduced `/audio` command in `VideoModule` to play TikTok video audio in voice channels using FFmpeg. - Updated `Program.cs` to enable `GuildVoiceStates` intent and added `ConcurrentDictionary` for managing audio clients. - Modified `InteractionHandlerService` to support guild-specific or global command registration based on `GuildId`. - Created `FFmpegAudioStream` class to handle FFmpeg processes for audio streaming with proper resource disposal. - Updated `appsettings.example.json` to include `GuildId` configuration with a default value of `0`. --- .../Configuration/BotSettings.cs | 6 + .../Handlers/FFmpegAudioStream.cs | 134 ++++++++++++++++++ TiktokExplode.Bot/Modules/AudioModule.cs | 5 + TiktokExplode.Bot/Modules/VideoModule.cs | 45 +++++- TiktokExplode.Bot/Program.cs | 6 +- .../Services/InteractionHandlerService.cs | 19 ++- TiktokExplode.Bot/appsettings.example.json | 3 +- 7 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs create mode 100644 TiktokExplode.Bot/Modules/AudioModule.cs diff --git a/TiktokExplode.Bot/Configuration/BotSettings.cs b/TiktokExplode.Bot/Configuration/BotSettings.cs index 066dc6f..272ec6d 100644 --- a/TiktokExplode.Bot/Configuration/BotSettings.cs +++ b/TiktokExplode.Bot/Configuration/BotSettings.cs @@ -3,4 +3,10 @@ public class BotSettings { public required string Token { get; init; } + + /// + /// ID del servidor donde registrar los comandos al instante (desarrollo). + /// Si es 0 o no está configurado, se registran globalmente (hasta 1 hora de propagación). + /// + public ulong GuildId { get; init; } } diff --git a/TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs b/TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs new file mode 100644 index 0000000..4f3895e --- /dev/null +++ b/TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs @@ -0,0 +1,134 @@ +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace TiktokExplode.Bot.Handlers; + +public sealed class FFmpegAudioStream( + string url, + ILogger logger) : IAsyncDisposable +{ + + private Process? _process; + private Stream? _output; + private Task? _stderrTask; + private readonly ILogger _logger = logger; + + private bool _started; + private bool _disposed; + + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + if (_started) + throw new InvalidOperationException("FFmpeg session already started."); + + var startInfo = new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = + $"-hide_banner " + + $"-loglevel error " + + $"-i \"{url}\" " + + "-ac 2 " + + "-f s16le " + + "-ar 48000 " + + "pipe:1", + + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + _process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start ffmpeg."); + + _output = _process.StandardOutput.BaseStream; + + _stderrTask = ConsumeErrorsAsync(_process, cancellationToken); + + _started = true; + + await Task.CompletedTask; + } + + public async Task PipeToAsync( + Stream destination, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + if (!_started) + throw new InvalidOperationException("FFmpeg session not started."); + + if (_output is null) + throw new InvalidOperationException("FFmpeg output stream not available."); + + await _output.CopyToAsync(destination, cancellationToken); + + await destination.FlushAsync(cancellationToken); + } + + private async Task ConsumeErrorsAsync( + Process process, + CancellationToken cancellationToken) + { + while (!process.StandardError.EndOfStream && + !cancellationToken.IsCancellationRequested) + { + var line = await process.StandardError.ReadLineAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(line)) + continue; + + + _logger.LogInformation("[FFmpeg] {Line}", line); + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + + try + { + if (_process is not null && !_process.HasExited) + { + _process.Kill(true); + + await _process.WaitForExitAsync(); + } + } + catch + { + _logger.LogWarning("Failed to kill ffmpeg process. It may have already exited."); + } + + if (_output is not null) + await _output.DisposeAsync(); + + if (_stderrTask is not null) + { + try + { + await _stderrTask; + } + catch + { + _logger.LogWarning("Error while consuming ffmpeg stderr. It may have been killed."); + } + } + + _process?.Dispose(); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } +} diff --git a/TiktokExplode.Bot/Modules/AudioModule.cs b/TiktokExplode.Bot/Modules/AudioModule.cs new file mode 100644 index 0000000..cf35add --- /dev/null +++ b/TiktokExplode.Bot/Modules/AudioModule.cs @@ -0,0 +1,5 @@ +namespace TiktokExplode.Bot.Modules; + +// Comandos movidos a VideoModule para evitar el conflicto de [Group("tiktok")]. +// Discord.Net no fusiona dos clases con el mismo grupo — solo registra una. +internal static class AudioModule { } diff --git a/TiktokExplode.Bot/Modules/VideoModule.cs b/TiktokExplode.Bot/Modules/VideoModule.cs index 75dcd03..55e4b98 100644 --- a/TiktokExplode.Bot/Modules/VideoModule.cs +++ b/TiktokExplode.Bot/Modules/VideoModule.cs @@ -1,13 +1,21 @@ using Discord; +using Discord.Audio; using Discord.Interactions; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using TiktokExplode.Bot.Handlers; using TiktokExplode.Domain.Abstractions; using TiktokExplode.Domain.Exceptions; namespace TiktokExplode.Bot.Modules; [Group("tiktok", "Comandos relacionados con TikTok")] -public sealed class VideoModule(IMemoryCache cache, IVideoClient tiktok) : InteractionModuleBase +public sealed class VideoModule( + IVideoClient tiktok, + IMemoryCache cache, + ConcurrentDictionary audioClients, + ILogger logger) : InteractionModuleBase { [SlashCommand("video", "Obten metadatos de un video de TikTok")] public async Task VideoAsync([Summary("url", "URL del video")] string url) @@ -53,4 +61,39 @@ public async Task VideoAsync([Summary("url", "URL del video")] string url) await FollowupAsync($"Error al obtener el video: {ex.Message}", ephemeral: true); } } + + [SlashCommand("audio", "Reproduce el audio de un video de TikTok en el canal de voz")] + public async Task AudioAsync([Summary("url", "URL del video")] string url) + { + await DeferAsync(); + try + { + var video = await tiktok.GetVideoAsync(url); + + if (Context.User is not IVoiceState voiceState || voiceState.VoiceChannel == null) + { + await FollowupAsync("Debes estar en un canal de voz para usar este comando.", ephemeral: true); + return; + } + + var audioClient = await voiceState.VoiceChannel.ConnectAsync(); + audioClients[Context.Guild.Id] = audioClient; + + var ffmpeg = new FFmpegAudioStream(video.Info.DownloadLinks.OriginalUrl, logger); + await ffmpeg.StartAsync(); + + await using var discordStream = audioClient.CreatePCMStream(AudioApplication.Mixed); + await ffmpeg.PipeToAsync(discordStream); + + await FollowupAsync($"Reproduciendo el audio de {video.Description} en {voiceState.VoiceChannel.Name}."); + } + catch (TiktokParsingException ex) when (ex.Message.Contains("429")) + { + await FollowupAsync("TikTok está limitando las solicitudes (429). Espera unos segundos e intenta de nuevo.", ephemeral: true); + } + catch (Exception ex) + { + await FollowupAsync($"Error al reproducir el audio: {ex.Message}", ephemeral: true); + } + } } \ No newline at end of file diff --git a/TiktokExplode.Bot/Program.cs b/TiktokExplode.Bot/Program.cs index 7c2669b..7273132 100644 --- a/TiktokExplode.Bot/Program.cs +++ b/TiktokExplode.Bot/Program.cs @@ -1,8 +1,10 @@ using Discord; +using Discord.Audio; using Discord.Interactions; using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using System.Collections.Concurrent; using TiktokExplode.Bot.CDN; using TiktokExplode.Bot.Configuration; using TiktokExplode.Bot.Services; @@ -27,7 +29,7 @@ private static void ConfigureServices(HostBuilderContext context, IServiceCollec services.AddSingleton(new DiscordSocketClient(new DiscordSocketConfig { - GatewayIntents = GatewayIntents.Guilds + GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildVoiceStates })); services.AddSingleton(provider => @@ -39,6 +41,8 @@ private static void ConfigureServices(HostBuilderContext context, IServiceCollec services.AddMemoryCache(); services.AddHttpClient(); + services.AddSingleton>(_ => new ConcurrentDictionary()); + // CDN providers — se prueban en orden: R2 → 0x0.st → Litterbox services.Configure(context.Configuration.GetSection(CloudflareR2Options.Section)); services.AddSingleton(); diff --git a/TiktokExplode.Bot/Services/InteractionHandlerService.cs b/TiktokExplode.Bot/Services/InteractionHandlerService.cs index 5120dbe..79778aa 100644 --- a/TiktokExplode.Bot/Services/InteractionHandlerService.cs +++ b/TiktokExplode.Bot/Services/InteractionHandlerService.cs @@ -1,21 +1,25 @@ -using System.Reflection; -using Discord.Interactions; +using Discord.Interactions; using Discord.WebSocket; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using System.Reflection; +using TiktokExplode.Bot.Configuration; +using TiktokExplode.Bot.Modules; namespace TiktokExplode.Bot.Services; public sealed class InteractionHandlerService( DiscordSocketClient client, InteractionService interactionService, - IServiceProvider serviceProvider) : IHostedService + IServiceProvider serviceProvider, + IOptions settings) : IHostedService { public async Task StartAsync(CancellationToken cancellationToken) { client.Ready += OnReadyAsync; client.InteractionCreated += OnInteractionCreatedAsync; - await interactionService.AddModulesAsync(Assembly.GetEntryAssembly(), serviceProvider); + await interactionService.AddModulesAsync(typeof(VideoModule).Assembly, serviceProvider); } public Task StopAsync(CancellationToken cancellationToken) @@ -27,7 +31,12 @@ public Task StopAsync(CancellationToken cancellationToken) private async Task OnReadyAsync() { - await interactionService.RegisterCommandsGloballyAsync(); + if (settings.Value.GuildId != 0) + // Registro instantáneo — ideal para desarrollo y pruebas. + await interactionService.RegisterCommandsToGuildAsync(settings.Value.GuildId); + else + // Registro global — puede tardar hasta 1 hora en propagarse. + await interactionService.RegisterCommandsGloballyAsync(); } private async Task OnInteractionCreatedAsync(SocketInteraction interaction) diff --git a/TiktokExplode.Bot/appsettings.example.json b/TiktokExplode.Bot/appsettings.example.json index 6d07cc2..174407a 100644 --- a/TiktokExplode.Bot/appsettings.example.json +++ b/TiktokExplode.Bot/appsettings.example.json @@ -1,6 +1,7 @@ { "BotSettings": { - "Token": "YOUR_DISCORD_BOT_TOKEN" + "Token": "YOUR_DISCORD_BOT_TOKEN", + "GuildId": 0 }, "CloudflareR2": { "AccountId": "YOUR_CLOUDFLARE_ACCOUNT_ID", From 787a98d8e0e9ddaaa5ebcb15582135e5b84d3e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20S=C3=A1nchez?= Date: Fri, 29 May 2026 12:38:45 -0500 Subject: [PATCH 3/8] Implement support for fetching and uploading videos via multiple CDN providers --- .../CDN/CloudflareR2CdnProvider.cs | 34 +++-- TiktokExplode.Bot/CDN/CompositeCdnProvider.cs | 23 ++++ TiktokExplode.Bot/CDN/ICdnProvider.cs | 1 + TiktokExplode.Bot/CDN/LitterboxCdnProvider.cs | 8 +- TiktokExplode.Bot/CDN/ZeroXZeroCdnProvider.cs | 5 +- .../CloudflareR2Options.cs | 2 +- .../Handlers/FFmpegAudioStream.cs | 17 +-- TiktokExplode.Bot/Modules/DownloadModule.cs | 8 +- TiktokExplode.Bot/Modules/VideoModule.cs | 125 ++++++++++++++++-- TiktokExplode.Bot/Program.cs | 30 ++++- 10 files changed, 212 insertions(+), 41 deletions(-) rename TiktokExplode.Bot/{CDN => Configuration}/CloudflareR2Options.cs (90%) diff --git a/TiktokExplode.Bot/CDN/CloudflareR2CdnProvider.cs b/TiktokExplode.Bot/CDN/CloudflareR2CdnProvider.cs index a69735a..053f750 100644 --- a/TiktokExplode.Bot/CDN/CloudflareR2CdnProvider.cs +++ b/TiktokExplode.Bot/CDN/CloudflareR2CdnProvider.cs @@ -3,6 +3,7 @@ using Amazon.S3.Model; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using TiktokExplode.Bot.Configuration; namespace TiktokExplode.Bot.CDN; @@ -33,24 +34,20 @@ public CloudflareR2CdnProvider(IOptions options, ILogger UploadAsync(byte[] data, string filename, CancellationToken ct = default) { - var publicUrl = $"{_options.PublicBaseUrl.TrimEnd('/')}/{filename}"; - - try - { - await _s3.GetObjectMetadataAsync(_options.BucketName, filename, ct); - return publicUrl; - } - catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + if (await GetUrlAsync(filename, ct) is string existingUrl && !string.IsNullOrWhiteSpace(existingUrl)) { - _logger.LogInformation("Archivo {Filename} no existe en R2, procediendo a subirlo", filename); + return existingUrl; } + var key = $"{filename}.mp4"; + _logger.LogInformation("Subiendo archivo {Filename} a R2", key); + using var stream = new MemoryStream(data, writable: false); var request = new PutObjectRequest { BucketName = _options.BucketName, - Key = filename, + Key = key, InputStream = stream, ContentType = "video/mp4", DisablePayloadSigning = true @@ -58,6 +55,23 @@ public async Task UploadAsync(byte[] data, string filename, Cancellation await _s3.PutObjectAsync(request, ct); + return $"{_options.PublicBaseUrl.TrimEnd('/')}/{key}"; + } + + public async Task GetUrlAsync(string filename, CancellationToken ct = default) + { + var key = $"{filename}.mp4"; + var publicUrl = $"{_options.PublicBaseUrl.TrimEnd('/')}/{key}"; + try + { + await _s3.GetObjectMetadataAsync(_options.BucketName, key, ct); + } + catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _logger.LogInformation("Archivo {Filename} no encontrado en R2", key); + return string.Empty; + } + return publicUrl; } } diff --git a/TiktokExplode.Bot/CDN/CompositeCdnProvider.cs b/TiktokExplode.Bot/CDN/CompositeCdnProvider.cs index b17485c..5de3618 100644 --- a/TiktokExplode.Bot/CDN/CompositeCdnProvider.cs +++ b/TiktokExplode.Bot/CDN/CompositeCdnProvider.cs @@ -28,4 +28,27 @@ public async Task UploadAsync(byte[] data, string filename, Cancellation throw new InvalidOperationException("Todos los proveedores CDN fallaron."); } + + public async Task GetUrlAsync(string filename, CancellationToken ct = default) + { + foreach (var provider in providers) + { + try + { + var url = await provider.GetUrlAsync(filename, ct); + + if (string.IsNullOrWhiteSpace(url)) + continue; + + logger.LogInformation("GetUrl exitoso via {Provider}: {Url}", provider.Name, url); + return url; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Provider {Provider} falló en GetUrl ({Msg}), probando el siguiente", provider.Name, ex.Message); + } + } + + return string.Empty; + } } diff --git a/TiktokExplode.Bot/CDN/ICdnProvider.cs b/TiktokExplode.Bot/CDN/ICdnProvider.cs index cbbec7f..b60fe63 100644 --- a/TiktokExplode.Bot/CDN/ICdnProvider.cs +++ b/TiktokExplode.Bot/CDN/ICdnProvider.cs @@ -4,4 +4,5 @@ public interface ICdnProvider { string Name { get; } Task UploadAsync(byte[] data, string filename, CancellationToken ct = default); + Task GetUrlAsync(string filename, CancellationToken ct = default); } diff --git a/TiktokExplode.Bot/CDN/LitterboxCdnProvider.cs b/TiktokExplode.Bot/CDN/LitterboxCdnProvider.cs index 74af559..e4dad87 100644 --- a/TiktokExplode.Bot/CDN/LitterboxCdnProvider.cs +++ b/TiktokExplode.Bot/CDN/LitterboxCdnProvider.cs @@ -18,13 +18,17 @@ public async Task UploadAsync(byte[] data, string filename, Cancellation form.Add(new StringContent("72h"), "time"); var fc = new ByteArrayContent(data); fc.Headers.ContentType = new MediaTypeHeaderValue("video/mp4"); - form.Add(fc, "fileToUpload", filename); + form.Add(fc, "fileToUpload", $"{filename}.mp4"); var req = new HttpRequestMessage(HttpMethod.Post, - "https://litterbox.catbox.moe/resources/internals/api.php") { Content = form }; + "https://litterbox.catbox.moe/resources/internals/api.php") + { Content = form }; using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); resp.EnsureSuccessStatusCode(); return (await resp.Content.ReadAsStringAsync(ct)).Trim(); } + + public Task GetUrlAsync(string filename, CancellationToken ct = default) + => Task.FromResult($"https://litterbox.catbox.moe/{filename}.mp4"); } diff --git a/TiktokExplode.Bot/CDN/ZeroXZeroCdnProvider.cs b/TiktokExplode.Bot/CDN/ZeroXZeroCdnProvider.cs index 765c37c..5dfa711 100644 --- a/TiktokExplode.Bot/CDN/ZeroXZeroCdnProvider.cs +++ b/TiktokExplode.Bot/CDN/ZeroXZeroCdnProvider.cs @@ -14,7 +14,7 @@ public async Task UploadAsync(byte[] data, string filename, Cancellation using var form = new MultipartFormDataContent(); var fc = new ByteArrayContent(data); fc.Headers.ContentType = new MediaTypeHeaderValue("video/mp4"); - form.Add(fc, "file", filename); + form.Add(fc, "file", $"{filename}.mp4"); var req = new HttpRequestMessage(HttpMethod.Post, "https://0x0.st") { Content = form }; using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); @@ -22,4 +22,7 @@ public async Task UploadAsync(byte[] data, string filename, Cancellation return (await resp.Content.ReadAsStringAsync(ct)).Trim(); } + + public Task GetUrlAsync(string filename, CancellationToken ct = default) + => Task.FromResult($"https://0x0.st/{filename}.mp4"); } diff --git a/TiktokExplode.Bot/CDN/CloudflareR2Options.cs b/TiktokExplode.Bot/Configuration/CloudflareR2Options.cs similarity index 90% rename from TiktokExplode.Bot/CDN/CloudflareR2Options.cs rename to TiktokExplode.Bot/Configuration/CloudflareR2Options.cs index 8d267f8..5ff4628 100644 --- a/TiktokExplode.Bot/CDN/CloudflareR2Options.cs +++ b/TiktokExplode.Bot/Configuration/CloudflareR2Options.cs @@ -1,4 +1,4 @@ -namespace TiktokExplode.Bot.CDN; +namespace TiktokExplode.Bot.Configuration; public sealed class CloudflareR2Options { diff --git a/TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs b/TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs index 4f3895e..6813924 100644 --- a/TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs +++ b/TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs @@ -75,17 +75,18 @@ private async Task ConsumeErrorsAsync( Process process, CancellationToken cancellationToken) { - while (!process.StandardError.EndOfStream && - !cancellationToken.IsCancellationRequested) + _ = Task.Run(async () => { - var line = await process.StandardError.ReadLineAsync(cancellationToken); - - if (string.IsNullOrWhiteSpace(line)) - continue; + while (!cancellationToken.IsCancellationRequested) + { + var line = await process.StandardError.ReadLineAsync(); + if (line is null) + break; - _logger.LogInformation("[FFmpeg] {Line}", line); - } + _logger.LogInformation("[FFmpeg] {Line}", line); + } + }, cancellationToken); } public async ValueTask DisposeAsync() diff --git a/TiktokExplode.Bot/Modules/DownloadModule.cs b/TiktokExplode.Bot/Modules/DownloadModule.cs index a5241a5..9b05e0b 100644 --- a/TiktokExplode.Bot/Modules/DownloadModule.cs +++ b/TiktokExplode.Bot/Modules/DownloadModule.cs @@ -28,7 +28,7 @@ public async Task DownloadOriginalAsync(string videoId) { await using var streamInfo = await tiktok.DownloadAsync(video!); await SendVideoAsync(ms => - Context.Channel.SendFileAsync(ms, $"{video!.Id}.mp4"), streamInfo, $"{video!.Id}.mp4"); + Context.Channel.SendFileAsync(ms, $"{video!.Id}.mp4"), streamInfo, video!.Id); } catch (Exception ex) { @@ -52,7 +52,7 @@ public async Task DownloadWatermarkedAsync(string videoId) { await using var streamInfo = await tiktok.DownloadWatermarkedAsync(video!); await SendVideoAsync(ms => - Context.Channel.SendFileAsync(ms, $"{video!.Id}_watermark.mp4"), streamInfo, $"{video!.Id}_watermark.mp4"); + Context.Channel.SendFileAsync(ms, $"{video!.Id}_watermark.mp4"), streamInfo, $"{video!.Id}_watermark"); } catch (Exception ex) { @@ -64,8 +64,8 @@ await SendVideoAsync(ms => private const int DiscordMaxBytes = 8 * 1024 * 1024; // 8 MB — límite oficial para bots private async Task SendVideoAsync( - Func discordUpload, - Domain.ValueObjects.StreamInfo streamInfo, + Func discordUpload, + Domain.ValueObjects.StreamInfo streamInfo, string filename) { byte[] data; diff --git a/TiktokExplode.Bot/Modules/VideoModule.cs b/TiktokExplode.Bot/Modules/VideoModule.cs index 55e4b98..9275c77 100644 --- a/TiktokExplode.Bot/Modules/VideoModule.cs +++ b/TiktokExplode.Bot/Modules/VideoModule.cs @@ -4,9 +4,12 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; +using TiktokExplode.Bot.CDN; using TiktokExplode.Bot.Handlers; using TiktokExplode.Domain.Abstractions; +using TiktokExplode.Domain.Entities; using TiktokExplode.Domain.Exceptions; +using TiktokExplode.Domain.ValueObjects; namespace TiktokExplode.Bot.Modules; @@ -15,8 +18,12 @@ public sealed class VideoModule( IVideoClient tiktok, IMemoryCache cache, ConcurrentDictionary audioClients, - ILogger logger) : InteractionModuleBase + ConcurrentDictionary playerMessages, + ConcurrentDictionary pipeTasks, + ILogger logger, + CloudflareR2CdnProvider provider) : InteractionModuleBase { + [SlashCommand("video", "Obten metadatos de un video de TikTok")] public async Task VideoAsync([Summary("url", "URL del video")] string url) { @@ -68,24 +75,93 @@ public async Task AudioAsync([Summary("url", "URL del video")] string url) await DeferAsync(); try { - var video = await tiktok.GetVideoAsync(url); - if (Context.User is not IVoiceState voiceState || voiceState.VoiceChannel == null) { await FollowupAsync("Debes estar en un canal de voz para usar este comando.", ephemeral: true); return; } - var audioClient = await voiceState.VoiceChannel.ConnectAsync(); - audioClients[Context.Guild.Id] = audioClient; + var video = await tiktok.GetVideoAsync(url); + var cdnUrl = await provider.GetUrlAsync(video.Id); + + if (cdnUrl == string.Empty) + { + cdnUrl = await SendVideoAsync(video, video.Id); + } + + var guildId = Context.Guild.Id; - var ffmpeg = new FFmpegAudioStream(video.Info.DownloadLinks.OriginalUrl, logger); + // Esperar a que el piping anterior libere el stream de Discord + if (pipeTasks.TryGetValue(guildId, out var previousTask)) + { + try { await previousTask; } catch { } + } + + // Conectar solo si el cliente no existe en el diccionario. + // El evento Disconnected se encarga de removerlo cuando Discord + // cierra la sesión por inactividad, garantizando una reconexión real. + if (!audioClients.TryGetValue(guildId, out var audioClient)) + { + audioClient = await voiceState.VoiceChannel.ConnectAsync(); + audioClient.Disconnected += ex => + { + audioClients.TryRemove(guildId, out var removed); + return Task.CompletedTask; + }; + audioClients[guildId] = audioClient; + // Dar tiempo a libDave para completar el handshake E2EE inicial + await Task.Delay(1000); + } + + var ffmpeg = new FFmpegAudioStream(cdnUrl, logger); await ffmpeg.StartAsync(); - await using var discordStream = audioClient.CreatePCMStream(AudioApplication.Mixed); - await ffmpeg.PipeToAsync(discordStream); + var discordStream = audioClient.CreatePCMStream(AudioApplication.Mixed); + + var pipeTask = Task.Run(async () => + { + try + { + // Primar el stream con silencio: mantiene el estado "speaking" + // activo mientras FFmpeg arranca y empieza a producir datos. + // 1 frame PCM s16le = 20ms @ 48kHz stereo = 3840 bytes + var silence = new byte[3840]; + for (int i = 0; i < 25; i++) // 25 frames = 500ms + await discordStream.WriteAsync(silence); + + await ffmpeg.PipeToAsync(discordStream); + } + finally + { + await discordStream.FlushAsync(); + discordStream.Dispose(); + await ffmpeg.DisposeAsync(); + } + }); + + pipeTasks[guildId] = pipeTask; - await FollowupAsync($"Reproduciendo el audio de {video.Description} en {voiceState.VoiceChannel.Name}."); + var duration = TimeSpan.FromSeconds(video.Duration.Seconds).ToString(@"mm\:ss"); + + var embed = BuildEmbed(video, voiceState, url, duration); + + if (!playerMessages.TryGetValue(guildId, out var message)) + { + await ModifyOriginalResponseAsync(msg => { msg.Embed = embed.Build(); }); + + message = await GetOriginalResponseAsync(); + + playerMessages[guildId] = message; + } + else + { + await message.ModifyAsync(msg => + { + msg.Embed = embed.Build(); + }); + + await DeleteOriginalResponseAsync(); + } } catch (TiktokParsingException ex) when (ex.Message.Contains("429")) { @@ -96,4 +172,35 @@ public async Task AudioAsync([Summary("url", "URL del video")] string url) await FollowupAsync($"Error al reproducir el audio: {ex.Message}", ephemeral: true); } } + + private async Task SendVideoAsync( + Video video, + string filename) + { + var streamInfo = await tiktok.DownloadAsync(video); + + using var ms = new MemoryStream(); + await streamInfo.Stream.CopyToAsync(ms); + + return await provider.UploadAsync(ms.ToArray(), filename); + } + + private static EmbedBuilder BuildEmbed( + Video video, + IVoiceState voiceState, + string url, + string duration) + { + return new EmbedBuilder() + .WithTitle($"Reproduciendo video de TikTok - Canal {voiceState.VoiceChannel.Name}") + .WithDescription(video.Description) + .WithUrl(url) + .WithThumbnailUrl(video.Cover.StaticUrl) + .AddField("Autor", video.Author.Name, true) + .AddField("Duración", duration, true) + .AddField("Vistas", video.Stats.Views.ToString("N0"), true) + .AddField("Likes", video.Stats.Likes.ToString("N0"), true) + .AddField("Compartidos", video.Stats.Shares.ToString("N0"), true) + .WithCurrentTimestamp(); + } } \ No newline at end of file diff --git a/TiktokExplode.Bot/Program.cs b/TiktokExplode.Bot/Program.cs index 7273132..1f78a53 100644 --- a/TiktokExplode.Bot/Program.cs +++ b/TiktokExplode.Bot/Program.cs @@ -29,7 +29,13 @@ private static void ConfigureServices(HostBuilderContext context, IServiceCollec services.AddSingleton(new DiscordSocketClient(new DiscordSocketConfig { - GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildVoiceStates + GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildVoiceStates, + EnableVoiceDaveEncryption = true, + })); + + Discord.LibDave.Dave.SetLogSink(new Discord.LibDave.DaveLogSinkDelegate((severity, file, line, message) => + { + Console.WriteLine($"[{severity} | LIBDAVE @ {file}#{line}]: {message}"); })); services.AddSingleton(provider => @@ -41,13 +47,25 @@ private static void ConfigureServices(HostBuilderContext context, IServiceCollec services.AddMemoryCache(); services.AddHttpClient(); - services.AddSingleton>(_ => new ConcurrentDictionary()); + services.AddSingleton(_ => new ConcurrentDictionary()); + services.AddSingleton(_ => new ConcurrentDictionary()); + services.AddSingleton(_ => new ConcurrentDictionary()); - // CDN providers — se prueban en orden: R2 → 0x0.st → Litterbox services.Configure(context.Configuration.GetSection(CloudflareR2Options.Section)); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(x => + x.GetRequiredService()); + + services.AddSingleton(x => + x.GetRequiredService()); + + services.AddSingleton(x => + x.GetRequiredService()); + services.AddSingleton(); services.AddHostedService(); From 1bf7d12efb8eb21ae7ead66ec2f8071c99d33d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20S=C3=A1nchez?= Date: Mon, 1 Jun 2026 11:56:11 -0500 Subject: [PATCH 4/8] Feat: Implement audio playback and queue management for TikTok videos in Discord bot --- .../Handlers/FFmpegAudioStream.cs | 43 +++- TiktokExplode.Bot/Models/QueueItem.cs | 5 + TiktokExplode.Bot/Modules/AudioModule.cs | 210 +++++++++++++++- .../Modules/PlayerButtonModule.cs | 63 +++++ TiktokExplode.Bot/Modules/VideoModule.cs | 206 ---------------- TiktokExplode.Bot/Program.cs | 5 +- TiktokExplode.Bot/Services/BotService.cs | 36 ++- .../Services/InteractionHandlerService.cs | 37 ++- TiktokExplode.Bot/Services/MusicPlayer.cs | 230 ++++++++++++++++++ .../Services/MusicPlayerManager.cs | 105 ++++++++ 10 files changed, 717 insertions(+), 223 deletions(-) create mode 100644 TiktokExplode.Bot/Models/QueueItem.cs create mode 100644 TiktokExplode.Bot/Modules/PlayerButtonModule.cs delete mode 100644 TiktokExplode.Bot/Modules/VideoModule.cs create mode 100644 TiktokExplode.Bot/Services/MusicPlayer.cs create mode 100644 TiktokExplode.Bot/Services/MusicPlayerManager.cs diff --git a/TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs b/TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs index 6813924..9eee898 100644 --- a/TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs +++ b/TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs @@ -4,15 +4,15 @@ namespace TiktokExplode.Bot.Handlers; public sealed class FFmpegAudioStream( - string url, - ILogger logger) : IAsyncDisposable + string url, + ILogger logger) : IAsyncDisposable { private Process? _process; private Stream? _output; private Task? _stderrTask; private readonly ILogger _logger = logger; - + private readonly SemaphoreSlim _semaphore = new(1, 1); private bool _started; private bool _disposed; @@ -66,9 +66,25 @@ public async Task PipeToAsync( if (_output is null) throw new InvalidOperationException("FFmpeg output stream not available."); - await _output.CopyToAsync(destination, cancellationToken); + // Discord.Net necesita frames completos de 3840 bytes (20 ms de PCM s16le 48 kHz 2ch). + // ReadAsync puede devolver menos bytes; los acumulamos hasta completar el frame. + var buffer = new byte[3840]; + int offset = 0; + + while (!cancellationToken.IsCancellationRequested) + { + int bytesRead = await _output.ReadAsync(buffer.AsMemory(offset), cancellationToken); + if (bytesRead == 0) break; + + offset += bytesRead; + if (offset < buffer.Length) continue; // esperar frame completo - await destination.FlushAsync(cancellationToken); + await _semaphore.WaitAsync(cancellationToken); + _semaphore.Release(); + + await destination.WriteAsync(buffer, cancellationToken); + offset = 0; + } } private async Task ConsumeErrorsAsync( @@ -89,6 +105,18 @@ private async Task ConsumeErrorsAsync( }, cancellationToken); } + public void Pause() + { + if (_semaphore.CurrentCount == 1) + _semaphore.Wait(0); + } + + public void Resume() + { + if (_semaphore.CurrentCount == 0) + _semaphore.Release(); + } + public async ValueTask DisposeAsync() { if (_disposed) @@ -126,6 +154,11 @@ public async ValueTask DisposeAsync() } _process?.Dispose(); + + if (_semaphore.CurrentCount == 0) + _semaphore.Release(); + + _semaphore.Dispose(); } private void ThrowIfDisposed() diff --git a/TiktokExplode.Bot/Models/QueueItem.cs b/TiktokExplode.Bot/Models/QueueItem.cs new file mode 100644 index 0000000..d044116 --- /dev/null +++ b/TiktokExplode.Bot/Models/QueueItem.cs @@ -0,0 +1,5 @@ +using TiktokExplode.Domain.Entities; + +namespace TiktokExplode.Bot.Models; + +public sealed record QueueItem(string Url, string OriginalUrl, Video Video); \ No newline at end of file diff --git a/TiktokExplode.Bot/Modules/AudioModule.cs b/TiktokExplode.Bot/Modules/AudioModule.cs index cf35add..d286472 100644 --- a/TiktokExplode.Bot/Modules/AudioModule.cs +++ b/TiktokExplode.Bot/Modules/AudioModule.cs @@ -1,5 +1,209 @@ +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.Caching.Memory; +using System.Collections.Concurrent; +using TiktokExplode.Bot.Models; +using TiktokExplode.Bot.Services; +using TiktokExplode.Domain.Abstractions; +using TiktokExplode.Domain.Exceptions; + namespace TiktokExplode.Bot.Modules; -// Comandos movidos a VideoModule para evitar el conflicto de [Group("tiktok")]. -// Discord.Net no fusiona dos clases con el mismo grupo — solo registra una. -internal static class AudioModule { } +[Group("tiktok", "Comandos relacionados con TikTok")] +public sealed class AudioModule( + MusicPlayerManager playerManager, + ConcurrentDictionary playerMessages, + IVideoClient tiktok, + IMemoryCache cache) : InteractionModuleBase +{ + [SlashCommand("video", "Obten metadatos de un video de TikTok")] + public async Task VideoAsync([Summary("url", "URL del video")] string url) + { + await DeferAsync(); + try + { + var video = await cache.GetOrCreateAsync(url, async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15); + return await tiktok.GetVideoAsync(url); + }); + + cache.Set(video!.Id, video, TimeSpan.FromMinutes(15)); + + var duration = TimeSpan.FromSeconds(video.Duration.Seconds).ToString(@"mm\:ss"); + var builder = new EmbedBuilder() + .WithTitle("Video de tiktok") + .WithDescription(video.Description) + .WithUrl(url) + .WithThumbnailUrl(video.Cover.StaticUrl) + .AddField("Autor", video.Author.Name, true) + .AddField("Duración", duration, true) + .AddField("Vistas", video.Stats.Views.ToString("N0"), true) + .AddField("Likes", video.Stats.Likes.ToString("N0"), true) + .AddField("Compartidos", video.Stats.Shares.ToString("N0"), true) + .WithCurrentTimestamp(); + + var components = new ComponentBuilder() + .WithButton("Descargar original", customId: $"download:original:{video.Id}", style: ButtonStyle.Primary) + .WithButton("Descargar con marca de agua", customId: $"download:watermark:{video.Id}", style: ButtonStyle.Secondary) + .Build(); + + await FollowupAsync(embed: builder.Build(), components: components); + } + catch (TiktokParsingException ex) when (ex.Message.Contains("429")) + { + await FollowupAsync("TikTok está limitando las solicitudes (429). Espera unos segundos e intenta de nuevo.", ephemeral: true); + } + catch (Exception ex) + { + await FollowupAsync($"Error al obtener el video: {ex.Message}", ephemeral: true); + } + } + + [SlashCommand("play", "Reproduce el audio de un video de TikTok en el canal de voz")] + public async Task AudioAsync([Summary("url")] string url) + { + await DeferAsync(); + try + { + if (Context.User is not IVoiceState { VoiceChannel: not null } voiceState) + { + await FollowupAsync("Debes estar en un canal de voz.", ephemeral: true); + return; + } + + var guildId = Context.Guild.Id; + var textChannel = Context.Channel; + var channelName = voiceState.VoiceChannel.Name; + + var item = await playerManager.EnqueueAsync( + guildId, + voiceState.VoiceChannel, + url, + onTrackStarted: async trackItem => + { + var components = new ComponentBuilder() + .WithButton("⏸ Pausa", customId: $"player:pause:{guildId}", style: ButtonStyle.Secondary) + .WithButton("▶ Reanudar", customId: $"player:resume:{guildId}", style: ButtonStyle.Success) + .WithButton("⏭ Skip", customId: $"player:skip:{guildId}", style: ButtonStyle.Primary) + .WithButton("⏹ Stop", customId: $"player:stop:{guildId}", style: ButtonStyle.Danger) + .WithButton("📋 Cola", customId: $"player:queue:{guildId}", style: ButtonStyle.Secondary) + .Build(); + + var dur = TimeSpan.FromSeconds(trackItem.Video.Duration.Seconds).ToString(@"mm\:ss"); + var embed = BuildEmbed(trackItem, channelName, dur); + if (!playerMessages.TryGetValue(guildId, out var msg)) + { + msg = await textChannel.SendMessageAsync( + embed: embed.Build(), + components: components); + playerMessages[guildId] = msg; + } + else + await msg.ModifyAsync(m => + { + m.Embed = embed.Build(); + m.Components = components; + }); + }, + onQueueEmpty: () => + { + playerMessages.TryRemove(guildId, out _); + return Task.CompletedTask; + }); + + var player = playerManager.GetPlayer(guildId); + var queue = player is not null ? await player.GetQueueAsync() : []; + var position = queue.Count; + + var desc = item.Video.Description.Length > 0 + ? item.Video.Description[..Math.Min(40, item.Video.Description.Length)] + : string.Empty; + var response = position == 0 + ? $"▶ Reproduciendo: **{item.Video.Author.Name}** — {desc}" + : $"🎵 Añadido a la cola (posición **{position}**): **{item.Video.Author.Name}** — {desc}"; + + await FollowupAsync(response, ephemeral: true); + } + catch (TiktokParsingException ex) when (ex.Message.Contains("429")) + { + await FollowupAsync("TikTok está limitando las solicitudes (429).", ephemeral: true); + } + catch (Exception ex) + { + await FollowupAsync($"Error: {ex.Message}", ephemeral: true); + } + } + + [SlashCommand("skip", "Salta la canción actual")] + public async Task SkipAsync() + { + var player = playerManager.GetPlayer(Context.Guild.Id); + if (player is null) { await RespondAsync("No hay nada reproduciéndose.", ephemeral: true); return; } + await player.SkipAsync(); + await RespondAsync("Saltando...", ephemeral: true); + } + + [SlashCommand("stop", "Detiene la reproducción y vacía la cola")] + public async Task StopAsync() + { + var player = playerManager.GetPlayer(Context.Guild.Id); + if (player is null) { await RespondAsync("No hay nada reproduciéndose.", ephemeral: true); return; } + await player.StopAsync(); + await RespondAsync("Detenido.", ephemeral: true); + } + + [SlashCommand("pause", "Pausa la reproducción")] + public async Task PauseAsync() + { + var player = playerManager.GetPlayer(Context.Guild.Id); + if (player is null) { await RespondAsync("No hay nada reproduciéndose.", ephemeral: true); return; } + player.Pause(); + await RespondAsync("Pausado.", ephemeral: true); + } + + [SlashCommand("resume", "Reanuda la reproducción")] + public async Task ResumeAsync() + { + var player = playerManager.GetPlayer(Context.Guild.Id); + if (player is null) { await RespondAsync("No hay nada reproduciéndose.", ephemeral: true); return; } + player.Resume(); + await RespondAsync("Reanudado.", ephemeral: true); + } + + [SlashCommand("queue", "Muestra la cola de reproducción")] + public async Task QueueAsync() + { + var player = playerManager.GetPlayer(Context.Guild.Id); + if (player is null) { await RespondAsync("La cola está vacía.", ephemeral: true); return; } + + var queue = await player.GetQueueAsync(); + if (queue.Count == 0) { await RespondAsync("La cola está vacía.", ephemeral: true); return; } + + var desc = string.Join("\n", queue.Select((q, i) => $"{i + 1}. {q.Video.Author.Name} — {q.Video.Description[..Math.Min(50, q.Video.Description.Length)]}")); + var embed = new EmbedBuilder() + .WithTitle("Cola de reproducción") + .WithDescription(desc) + .Build(); + + await RespondAsync(embed: embed); + } + + private static EmbedBuilder BuildEmbed( + QueueItem item, + string channelName, + string duration) + { + return new EmbedBuilder() + .WithTitle($"Reproduciendo video de TikTok - Canal {channelName}") + .WithDescription(item.Video.Description) + .WithUrl(item.OriginalUrl) + .WithThumbnailUrl(item.Video.Cover.StaticUrl) + .AddField("Autor", item.Video.Author.Name, true) + .AddField("Duración", duration, true) + .AddField("Vistas", item.Video.Stats.Views.ToString("N0"), true) + .AddField("Likes", item.Video.Stats.Likes.ToString("N0"), true) + .AddField("Compartidos", item.Video.Stats.Shares.ToString("N0"), true) + .WithCurrentTimestamp(); + } +} \ No newline at end of file diff --git a/TiktokExplode.Bot/Modules/PlayerButtonModule.cs b/TiktokExplode.Bot/Modules/PlayerButtonModule.cs new file mode 100644 index 0000000..1b03638 --- /dev/null +++ b/TiktokExplode.Bot/Modules/PlayerButtonModule.cs @@ -0,0 +1,63 @@ +using Discord; +using Discord.Interactions; +using TiktokExplode.Bot.Services; + +namespace TiktokExplode.Bot.Modules; + +// Sin [Group] para que los customIds coincidan exactamente: "player:pause:123", etc. +public sealed class PlayerButtonModule(MusicPlayerManager playerManager) + : InteractionModuleBase +{ + [ComponentInteraction("player:pause:*")] + public async Task OnPauseAsync(string guildId) + { + await DeferAsync(); + var player = playerManager.GetPlayer(ulong.Parse(guildId)); + player?.Pause(); + } + + [ComponentInteraction("player:resume:*")] + public async Task OnResumeAsync(string guildId) + { + await DeferAsync(); + var player = playerManager.GetPlayer(ulong.Parse(guildId)); + player?.Resume(); + } + + [ComponentInteraction("player:skip:*")] + public async Task OnSkipAsync(string guildId) + { + await DeferAsync(); + var player = playerManager.GetPlayer(ulong.Parse(guildId)); + if (player is not null) await player.SkipAsync(); + } + + [ComponentInteraction("player:stop:*")] + public async Task OnStopAsync(string guildId) + { + await DeferAsync(); + var player = playerManager.GetPlayer(ulong.Parse(guildId)); + if (player is not null) await player.StopAsync(); + } + + [ComponentInteraction("player:queue:*")] + public async Task QueueAsync(string guildId) + { + var player = playerManager.GetPlayer(ulong.Parse(guildId)); + + var queue = player is not null ? await player.GetQueueAsync() : []; + if (queue.Count == 0) + { + await RespondAsync("La cola está vacía.", ephemeral: true); + return; + } + + var desc = string.Join("\n", queue.Select((q, i) => $"{i + 1}. {q.Video.Author.Name} — {q.Video.Description[..Math.Min(50, q.Video.Description.Length)]}")); + var embed = new EmbedBuilder() + .WithTitle("Cola de reproducción") + .WithDescription(desc) + .Build(); + + await RespondAsync(embed: embed, ephemeral: true); + } +} diff --git a/TiktokExplode.Bot/Modules/VideoModule.cs b/TiktokExplode.Bot/Modules/VideoModule.cs deleted file mode 100644 index 9275c77..0000000 --- a/TiktokExplode.Bot/Modules/VideoModule.cs +++ /dev/null @@ -1,206 +0,0 @@ -using Discord; -using Discord.Audio; -using Discord.Interactions; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; -using TiktokExplode.Bot.CDN; -using TiktokExplode.Bot.Handlers; -using TiktokExplode.Domain.Abstractions; -using TiktokExplode.Domain.Entities; -using TiktokExplode.Domain.Exceptions; -using TiktokExplode.Domain.ValueObjects; - -namespace TiktokExplode.Bot.Modules; - -[Group("tiktok", "Comandos relacionados con TikTok")] -public sealed class VideoModule( - IVideoClient tiktok, - IMemoryCache cache, - ConcurrentDictionary audioClients, - ConcurrentDictionary playerMessages, - ConcurrentDictionary pipeTasks, - ILogger logger, - CloudflareR2CdnProvider provider) : InteractionModuleBase -{ - - [SlashCommand("video", "Obten metadatos de un video de TikTok")] - public async Task VideoAsync([Summary("url", "URL del video")] string url) - { - await DeferAsync(); - - try - { - var video = await cache.GetOrCreateAsync(url, async entry => - { - entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15); - return await tiktok.GetVideoAsync(url); - }); - - cache.Set(video!.Id, video, TimeSpan.FromMinutes(15)); - - var duration = TimeSpan.FromSeconds(video.Duration.Seconds).ToString(@"mm\:ss"); - var builder = new EmbedBuilder() - .WithTitle("Video de tiktok") - .WithDescription(video.Description) - .WithUrl(url) - .WithThumbnailUrl(video.Cover.StaticUrl) - .AddField("Autor", video.Author.Name, true) - .AddField("Duración", duration, true) - .AddField("Vistas", video.Stats.Views.ToString("N0"), true) - .AddField("Likes", video.Stats.Likes.ToString("N0"), true) - .AddField("Compartidos", video.Stats.Shares.ToString("N0"), true) - .WithCurrentTimestamp(); - - var components = new ComponentBuilder() - .WithButton("Descargar original", customId: $"download:original:{video.Id}", style: ButtonStyle.Primary) - .WithButton("Descargar con marca de agua", customId: $"download:watermark:{video.Id}", style: ButtonStyle.Secondary) - .Build(); - - await FollowupAsync(embed: builder.Build(), components: components); - } - catch (TiktokParsingException ex) when (ex.Message.Contains("429")) - { - await FollowupAsync("TikTok está limitando las solicitudes (429). Espera unos segundos e intenta de nuevo.", ephemeral: true); - } - catch (Exception ex) - { - await FollowupAsync($"Error al obtener el video: {ex.Message}", ephemeral: true); - } - } - - [SlashCommand("audio", "Reproduce el audio de un video de TikTok en el canal de voz")] - public async Task AudioAsync([Summary("url", "URL del video")] string url) - { - await DeferAsync(); - try - { - if (Context.User is not IVoiceState voiceState || voiceState.VoiceChannel == null) - { - await FollowupAsync("Debes estar en un canal de voz para usar este comando.", ephemeral: true); - return; - } - - var video = await tiktok.GetVideoAsync(url); - var cdnUrl = await provider.GetUrlAsync(video.Id); - - if (cdnUrl == string.Empty) - { - cdnUrl = await SendVideoAsync(video, video.Id); - } - - var guildId = Context.Guild.Id; - - // Esperar a que el piping anterior libere el stream de Discord - if (pipeTasks.TryGetValue(guildId, out var previousTask)) - { - try { await previousTask; } catch { } - } - - // Conectar solo si el cliente no existe en el diccionario. - // El evento Disconnected se encarga de removerlo cuando Discord - // cierra la sesión por inactividad, garantizando una reconexión real. - if (!audioClients.TryGetValue(guildId, out var audioClient)) - { - audioClient = await voiceState.VoiceChannel.ConnectAsync(); - audioClient.Disconnected += ex => - { - audioClients.TryRemove(guildId, out var removed); - return Task.CompletedTask; - }; - audioClients[guildId] = audioClient; - // Dar tiempo a libDave para completar el handshake E2EE inicial - await Task.Delay(1000); - } - - var ffmpeg = new FFmpegAudioStream(cdnUrl, logger); - await ffmpeg.StartAsync(); - - var discordStream = audioClient.CreatePCMStream(AudioApplication.Mixed); - - var pipeTask = Task.Run(async () => - { - try - { - // Primar el stream con silencio: mantiene el estado "speaking" - // activo mientras FFmpeg arranca y empieza a producir datos. - // 1 frame PCM s16le = 20ms @ 48kHz stereo = 3840 bytes - var silence = new byte[3840]; - for (int i = 0; i < 25; i++) // 25 frames = 500ms - await discordStream.WriteAsync(silence); - - await ffmpeg.PipeToAsync(discordStream); - } - finally - { - await discordStream.FlushAsync(); - discordStream.Dispose(); - await ffmpeg.DisposeAsync(); - } - }); - - pipeTasks[guildId] = pipeTask; - - var duration = TimeSpan.FromSeconds(video.Duration.Seconds).ToString(@"mm\:ss"); - - var embed = BuildEmbed(video, voiceState, url, duration); - - if (!playerMessages.TryGetValue(guildId, out var message)) - { - await ModifyOriginalResponseAsync(msg => { msg.Embed = embed.Build(); }); - - message = await GetOriginalResponseAsync(); - - playerMessages[guildId] = message; - } - else - { - await message.ModifyAsync(msg => - { - msg.Embed = embed.Build(); - }); - - await DeleteOriginalResponseAsync(); - } - } - catch (TiktokParsingException ex) when (ex.Message.Contains("429")) - { - await FollowupAsync("TikTok está limitando las solicitudes (429). Espera unos segundos e intenta de nuevo.", ephemeral: true); - } - catch (Exception ex) - { - await FollowupAsync($"Error al reproducir el audio: {ex.Message}", ephemeral: true); - } - } - - private async Task SendVideoAsync( - Video video, - string filename) - { - var streamInfo = await tiktok.DownloadAsync(video); - - using var ms = new MemoryStream(); - await streamInfo.Stream.CopyToAsync(ms); - - return await provider.UploadAsync(ms.ToArray(), filename); - } - - private static EmbedBuilder BuildEmbed( - Video video, - IVoiceState voiceState, - string url, - string duration) - { - return new EmbedBuilder() - .WithTitle($"Reproduciendo video de TikTok - Canal {voiceState.VoiceChannel.Name}") - .WithDescription(video.Description) - .WithUrl(url) - .WithThumbnailUrl(video.Cover.StaticUrl) - .AddField("Autor", video.Author.Name, true) - .AddField("Duración", duration, true) - .AddField("Vistas", video.Stats.Views.ToString("N0"), true) - .AddField("Likes", video.Stats.Likes.ToString("N0"), true) - .AddField("Compartidos", video.Stats.Shares.ToString("N0"), true) - .WithCurrentTimestamp(); - } -} \ No newline at end of file diff --git a/TiktokExplode.Bot/Program.cs b/TiktokExplode.Bot/Program.cs index 1f78a53..4439e10 100644 --- a/TiktokExplode.Bot/Program.cs +++ b/TiktokExplode.Bot/Program.cs @@ -1,5 +1,4 @@ using Discord; -using Discord.Audio; using Discord.Interactions; using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; @@ -47,9 +46,9 @@ private static void ConfigureServices(HostBuilderContext context, IServiceCollec services.AddMemoryCache(); services.AddHttpClient(); - services.AddSingleton(_ => new ConcurrentDictionary()); services.AddSingleton(_ => new ConcurrentDictionary()); - services.AddSingleton(_ => new ConcurrentDictionary()); + + services.AddSingleton(); services.Configure(context.Configuration.GetSection(CloudflareR2Options.Section)); diff --git a/TiktokExplode.Bot/Services/BotService.cs b/TiktokExplode.Bot/Services/BotService.cs index dc340aa..a5f6adc 100644 --- a/TiktokExplode.Bot/Services/BotService.cs +++ b/TiktokExplode.Bot/Services/BotService.cs @@ -8,13 +8,37 @@ namespace TiktokExplode.Bot.Services; public sealed class BotService( - DiscordSocketClient client, - IOptions settings, + DiscordSocketClient client, + IOptions settings, + MusicPlayerManager manager, ILogger logger) : IHostedService { public async Task StartAsync(CancellationToken cancellationToken) { client.Log += OnLog; + client.UserVoiceStateUpdated += async (user, before, after) => + { + if (before.VoiceChannel is null) + return; + + var channel = before.VoiceChannel; + var guild = channel.Guild; + + var botUser = guild.GetUser(client.CurrentUser.Id); + if (botUser?.VoiceChannel?.Id != channel.Id) + return; + + var humanUsers = channel.Users.Count(u => !u.IsBot); + if (humanUsers == 0) + { + logger.LogInformation("Bot solo en {Channel}, iniciando desconexión en 10s", channel.Name); + await Task.Delay(TimeSpan.FromSeconds(10)); + + humanUsers = channel.Users.Count(u => !u.IsBot); + if (humanUsers == 0) + await manager.HandleVoiceStateAsync(guild.Id); + } + }; await client.LoginAsync(TokenType.Bot, settings.Value.Token); await client.StartAsync(); @@ -22,6 +46,14 @@ public async Task StartAsync(CancellationToken cancellationToken) private async Task OnLog(LogMessage msg) { + // Ignorar warnings de descifrado DAVE — el bot no necesita descifrar + // audio entrante de usuarios; estos warnings son inofensivos. + if (msg.Source is "Dave" || (msg.Message?.Contains("LIBDAVE", StringComparison.OrdinalIgnoreCase) ?? false)) + { + await Task.CompletedTask; + return; + } + logger.LogInformation("[{Severity}] {Source}: {Message}", msg.Severity, msg.Source, msg.Message); await Task.CompletedTask; } diff --git a/TiktokExplode.Bot/Services/InteractionHandlerService.cs b/TiktokExplode.Bot/Services/InteractionHandlerService.cs index 79778aa..b571d32 100644 --- a/TiktokExplode.Bot/Services/InteractionHandlerService.cs +++ b/TiktokExplode.Bot/Services/InteractionHandlerService.cs @@ -1,4 +1,5 @@ -using Discord.Interactions; +using Discord; +using Discord.Interactions; using Discord.WebSocket; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; @@ -18,14 +19,16 @@ public async Task StartAsync(CancellationToken cancellationToken) { client.Ready += OnReadyAsync; client.InteractionCreated += OnInteractionCreatedAsync; + interactionService.InteractionExecuted += OnInteractionExecutedAsync; - await interactionService.AddModulesAsync(typeof(VideoModule).Assembly, serviceProvider); + await interactionService.AddModulesAsync(typeof(AudioModule).Assembly, serviceProvider); } public Task StopAsync(CancellationToken cancellationToken) { client.Ready -= OnReadyAsync; client.InteractionCreated -= OnInteractionCreatedAsync; + interactionService.InteractionExecuted -= OnInteractionExecutedAsync; return Task.CompletedTask; } @@ -41,7 +44,33 @@ private async Task OnReadyAsync() private async Task OnInteractionCreatedAsync(SocketInteraction interaction) { - var context = new SocketInteractionContext(client, interaction); - await interactionService.ExecuteCommandAsync(context, serviceProvider); + try + { + var context = new SocketInteractionContext(client, interaction); + await interactionService.ExecuteCommandAsync(context, serviceProvider); + } + catch (Exception) + { + // Si la excepción ocurre antes de que el handler haga DeferAsync/RespondAsync, + // el interaction quedará sin acknowledgment — intentar responder con error. + if (!interaction.HasResponded) + await interaction.RespondAsync("Error interno al procesar la interacción.", ephemeral: true); + } + } + + private async Task OnInteractionExecutedAsync(ICommandInfo? command, IInteractionContext context, IResult result) + { + if (result.IsSuccess) return; + + var errorMsg = result.Error switch + { + InteractionCommandError.UnknownCommand => "Comando no encontrado.", + InteractionCommandError.BadArgs => "Argumentos inválidos.", + InteractionCommandError.Exception => $"Error: {result.ErrorReason}", + _ => result.ErrorReason + }; + + if (!context.Interaction.HasResponded) + await context.Interaction.RespondAsync(errorMsg, ephemeral: true); } } diff --git a/TiktokExplode.Bot/Services/MusicPlayer.cs b/TiktokExplode.Bot/Services/MusicPlayer.cs new file mode 100644 index 0000000..77192ff --- /dev/null +++ b/TiktokExplode.Bot/Services/MusicPlayer.cs @@ -0,0 +1,230 @@ +using Discord; +using Discord.Audio; +using Microsoft.Extensions.Logging; +using System.Threading.Channels; +using TiktokExplode.Bot.Handlers; +using TiktokExplode.Bot.Models; +using TiktokExplode.Domain.Abstractions; + +namespace TiktokExplode.Bot.Services; + +public sealed class MusicPlayer : IAsyncDisposable +{ + private readonly ILogger _logger; + private readonly Channel _queue; + private readonly List _queueSnapshot; // para mostrar la queue + private readonly SemaphoreSlim _queueLock = new(1, 1); + + private IAudioClient? _audioClient; + private FFmpegAudioStream? _currentStream; + private CancellationTokenSource _skipCts = new(); + private CancellationTokenSource _stopCts = new(); + private readonly IVoiceChannel _voiceChannel; + private readonly ulong _guildId; + private Task? _loopTask; + + private readonly Timer _inactivityTimer; + private static readonly TimeSpan InactivityTimeout = TimeSpan.FromMinutes(5); + + public event Func? OnTrackStarted; + public event Func? OnQueueEmpty; + public event Func? OnDisposed; + + public MusicPlayer( + IVoiceChannel voiceChannel, + ulong guildId, + ILogger logger) + { + _voiceChannel = voiceChannel; + _guildId = guildId; + _logger = logger; + _queue = Channel.CreateUnbounded(); + _queueSnapshot = []; + _inactivityTimer = new Timer(_ => _ = DisposeAsync().AsTask(), + null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + + public async Task StartAsync() + { + _audioClient = await _voiceChannel.ConnectAsync(); + _audioClient.Disconnected += eventArgs => + { + _audioClient = null; + return Task.CompletedTask; + }; + + _stopCts = new CancellationTokenSource(); + _loopTask = RunLoopAsync(_stopCts.Token); + _inactivityTimer.Change(InactivityTimeout, Timeout.InfiniteTimeSpan); + } + + public async Task EnqueueAsync(QueueItem item) + { + await _queueLock.WaitAsync(); + _queueSnapshot.Add(item); + _queueLock.Release(); + await _queue.Writer.WriteAsync(item); + // Cancelar timer de inactividad mientras hay canciones pendientes + _inactivityTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + + public Task SkipAsync() + { + _skipCts.Cancel(); + return Task.CompletedTask; + } + + // Para la canción actual y vacía la cola, pero el player sigue vivo + // para aceptar nuevas canciones. El shutdown total ocurre en DisposeAsync. + public async Task StopAsync() + { + // Vaciar el snapshot + await _queueLock.WaitAsync(); + _queueSnapshot.Clear(); + _queueLock.Release(); + + // Drenar el channel sin sellarlo + while (_queue.Reader.TryRead(out _)) { } + + // Cancelar la canción actual + await _skipCts.CancelAsync(); + } + + public void Pause() => _currentStream?.Pause(); + public void Resume() => _currentStream?.Resume(); + + public async Task> GetQueueAsync() + { + await _queueLock.WaitAsync(); + try { return _queueSnapshot.AsReadOnly(); } + finally { _queueLock.Release(); } + } + + private async Task EnsureConnectedAsync(CancellationToken ct) + { + if (_audioClient is not null) return; + + _logger.LogInformation("Conectando al canal de voz..."); + _audioClient = await _voiceChannel.ConnectAsync(); + _audioClient.Disconnected += _ => + { + _audioClient = null; + return Task.CompletedTask; + }; + // Dar tiempo a libDave para el handshake E2EE + await Task.Delay(1000, ct); + } + + private async Task RunLoopAsync(CancellationToken stopToken) + { + // Mantener el PCM stream vivo entre canciones del mismo audio client + // evita el ciclo speaking=false/true que produce glitches en la transición. + AudioOutStream? discordStream = null; + IAudioClient? pcmOwner = null; + + async ValueTask ClosePcmStreamAsync() + { + if (discordStream is null) return; + try { await discordStream.FlushAsync(CancellationToken.None); } catch { } + discordStream.Dispose(); + discordStream = null; + pcmOwner = null; + } + + try + { + await foreach (var item in _queue.Reader.ReadAllAsync(stopToken)) + { + _inactivityTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _skipCts = new CancellationTokenSource(); + using var linkedCts = CancellationTokenSource + .CreateLinkedTokenSource(stopToken, _skipCts.Token); + + try + { + await _queueLock.WaitAsync(stopToken); + _queueSnapshot.Remove(item); + _queueLock.Release(); + + if (OnTrackStarted is not null) + await OnTrackStarted(item); + + _currentStream = new FFmpegAudioStream(item.Url, _logger); + await _currentStream.StartAsync(linkedCts.Token); + + // Si el audio client cambió (reconexión o primera vez), cerrar PCM stream anterior + if (!ReferenceEquals(_audioClient, pcmOwner)) + await ClosePcmStreamAsync(); + + await EnsureConnectedAsync(linkedCts.Token); + + if (discordStream is null) + { + discordStream = _audioClient!.CreatePCMStream(AudioApplication.Mixed); + pcmOwner = _audioClient; + + // Solo al crear el stream: primar estado "hablando" en Discord + var silence = new byte[3840]; + for (int i = 0; i < 25; i++) + await discordStream.WriteAsync(silence, linkedCts.Token); + } + + await _currentStream.PipeToAsync(discordStream, linkedCts.Token); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.LogError(ex, "Error reproduciendo {Url}", item.Url); + await ClosePcmStreamAsync(); // forzar recreación del stream en error + } + finally + { + if (_currentStream is not null) + { + await _currentStream.DisposeAsync(); + _currentStream = null; + } + } + + if (_queue.Reader.Count == 0) + { + await ClosePcmStreamAsync(); + if (OnQueueEmpty is not null) + await OnQueueEmpty(); + _inactivityTimer.Change(InactivityTimeout, Timeout.InfiniteTimeSpan); + } + } + } + finally + { + await ClosePcmStreamAsync(); + } + } + + public async ValueTask DisposeAsync() + { + // Parar canción actual y vaciar cola + await StopAsync(); + + // Cerrar el loop de reproducción + _queue.Writer.TryComplete(); + await _stopCts.CancelAsync(); + if (_loopTask is not null) + try { await _loopTask; } catch { } + + _inactivityTimer.Dispose(); + _queueLock.Dispose(); + _skipCts.Dispose(); + _stopCts.Dispose(); + + if (_audioClient is not null) + { + await _audioClient.StopAsync(); + _audioClient.Dispose(); + _audioClient = null; + } + + if (OnDisposed is not null) + await OnDisposed(); + } +} \ No newline at end of file diff --git a/TiktokExplode.Bot/Services/MusicPlayerManager.cs b/TiktokExplode.Bot/Services/MusicPlayerManager.cs new file mode 100644 index 0000000..2a4efec --- /dev/null +++ b/TiktokExplode.Bot/Services/MusicPlayerManager.cs @@ -0,0 +1,105 @@ +using Discord; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using TiktokExplode.Bot.CDN; +using TiktokExplode.Bot.Models; +using TiktokExplode.Domain.Abstractions; +using TiktokExplode.Domain.Entities; + +namespace TiktokExplode.Bot.Services; + +public sealed class MusicPlayerManager( + IVideoClient tiktok, + ILogger playerLogger, + CloudflareR2CdnProvider provider) +{ + private readonly ConcurrentDictionary _players = new(); + + public async Task EnqueueAsync( + ulong guildId, + IVoiceChannel voiceChannel, + string url, + Func? onTrackStarted = null, + Func? onQueueEmpty = null) + { + var video = await tiktok.GetVideoAsync(url); + + var cdnUrl = await provider.GetUrlAsync(video.Id); + + if (cdnUrl == string.Empty) + { + cdnUrl = await SendVideoAsync(video, video.Id); + } + + var item = new QueueItem(cdnUrl, url, video); + + var player = await GetOrCreateAsync(guildId, voiceChannel, onTrackStarted, onQueueEmpty); + await player.EnqueueAsync(item); + + return item; + } + + public MusicPlayer? GetPlayer(ulong guildId) + => _players.GetValueOrDefault(guildId); + + private readonly SemaphoreSlim _createLock = new(1, 1); + + private async Task GetOrCreateAsync( + ulong guildId, + IVoiceChannel voiceChannel, + Func? onTrackStarted = null, + Func? onQueueEmpty = null) + { + if (_players.TryGetValue(guildId, out var existingPlayer)) + return existingPlayer; + + await _createLock.WaitAsync(); + try + { + if (_players.TryGetValue(guildId, out existingPlayer)) + return existingPlayer; + + var player = new MusicPlayer(voiceChannel, guildId, playerLogger); + + if (onTrackStarted is not null) + player.OnTrackStarted += onTrackStarted; + + if (onQueueEmpty is not null) + player.OnQueueEmpty += onQueueEmpty; + + // Eliminar de _players solo cuando el player se destruya completamente + player.OnDisposed += () => + { + _players.TryRemove(guildId, out _); + return Task.CompletedTask; + }; + + await player.StartAsync(); + _players[guildId] = player; + + return player; + } + finally + { + _createLock.Release(); + } + } + + public async Task HandleVoiceStateAsync(ulong guildId) + { + if (_players.TryGetValue(guildId, out var player)) + await player.DisposeAsync(); + } + + private async Task SendVideoAsync( + Video video, + string filename) + { + var streamInfo = await tiktok.DownloadAsync(video); + + using var ms = new MemoryStream(); + await streamInfo.Stream.CopyToAsync(ms); + + return await provider.UploadAsync(ms.ToArray(), filename); + } +} \ No newline at end of file From a5ffc31ed5512271cde2942647a99a57902814a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20S=C3=A1nchez?= Date: Fri, 19 Jun 2026 00:01:06 -0500 Subject: [PATCH 5/8] updated Discord.net --- TiktokExplode.Bot/TiktokExplode.Bot.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TiktokExplode.Bot/TiktokExplode.Bot.csproj b/TiktokExplode.Bot/TiktokExplode.Bot.csproj index b141800..8a9bef2 100644 --- a/TiktokExplode.Bot/TiktokExplode.Bot.csproj +++ b/TiktokExplode.Bot/TiktokExplode.Bot.csproj @@ -12,7 +12,7 @@ - + From 76274a1ad0952c6237f4cd6682acc5044b5a5a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20S=C3=A1nchez?= Date: Fri, 19 Jun 2026 00:02:26 -0500 Subject: [PATCH 6/8] fix: _audioclient null in DisposeAsync --- TiktokExplode.Bot/Services/MusicPlayer.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/TiktokExplode.Bot/Services/MusicPlayer.cs b/TiktokExplode.Bot/Services/MusicPlayer.cs index 77192ff..06956dd 100644 --- a/TiktokExplode.Bot/Services/MusicPlayer.cs +++ b/TiktokExplode.Bot/Services/MusicPlayer.cs @@ -217,11 +217,11 @@ public async ValueTask DisposeAsync() _skipCts.Dispose(); _stopCts.Dispose(); - if (_audioClient is not null) + var client = _audioClient; + if (client is not null) { - await _audioClient.StopAsync(); - _audioClient.Dispose(); - _audioClient = null; + await client.StopAsync(); + client.Dispose(); } if (OnDisposed is not null) From 75fc947ea545c12a604996b679ea4d627464342b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20S=C3=A1nchez?= Date: Fri, 19 Jun 2026 00:05:58 -0500 Subject: [PATCH 7/8] delete unnecesary comments --- TiktokExplode.Bot/Services/MusicPlayer.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/TiktokExplode.Bot/Services/MusicPlayer.cs b/TiktokExplode.Bot/Services/MusicPlayer.cs index 06956dd..3e57137 100644 --- a/TiktokExplode.Bot/Services/MusicPlayer.cs +++ b/TiktokExplode.Bot/Services/MusicPlayer.cs @@ -64,7 +64,6 @@ public async Task EnqueueAsync(QueueItem item) _queueSnapshot.Add(item); _queueLock.Release(); await _queue.Writer.WriteAsync(item); - // Cancelar timer de inactividad mientras hay canciones pendientes _inactivityTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); } @@ -74,19 +73,14 @@ public Task SkipAsync() return Task.CompletedTask; } - // Para la canción actual y vacía la cola, pero el player sigue vivo - // para aceptar nuevas canciones. El shutdown total ocurre en DisposeAsync. public async Task StopAsync() { - // Vaciar el snapshot await _queueLock.WaitAsync(); _queueSnapshot.Clear(); _queueLock.Release(); - // Drenar el channel sin sellarlo while (_queue.Reader.TryRead(out _)) { } - // Cancelar la canción actual await _skipCts.CancelAsync(); } @@ -203,10 +197,8 @@ async ValueTask ClosePcmStreamAsync() public async ValueTask DisposeAsync() { - // Parar canción actual y vaciar cola await StopAsync(); - // Cerrar el loop de reproducción _queue.Writer.TryComplete(); await _stopCts.CancelAsync(); if (_loopTask is not null) From 2365a94ad77251ea280839c2a2cd091527cefbd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20S=C3=A1nchez?= Date: Sat, 20 Jun 2026 12:52:40 -0500 Subject: [PATCH 8/8] DI Improvements: Optional Search and Shared Session Allows you to register an ISearchClient with UsePlaywrightSearch independently of the page fetcher, even when using HTTP. Added TiktokDownloadClient as a singleton to share cookies and avoid 403 errors. Improvements to search error handling, package version updates, added an icon to the metapackage, and internal visibility for testing. Minor refactoring for greater clarity. --- TiktokExplode.All/TiktokExplode.All.csproj | 5 +- .../README.md | 50 +++++++++------- ...lode.Extensions.DependencyInjection.csproj | 2 +- .../TiktokExplodeBuilder.cs | 58 ++++++++++++++----- TiktokExplode.Infrastructure/AssemblyInfo.cs | 3 + .../Browser/TikTokBrowser.cs | 5 +- .../Clients/TiktokClient.cs | 16 ++++- .../Clients/TiktokSearchClient.cs | 7 +++ .../Search/PlaywrightSearchFetcher.cs | 2 +- .../TiktokExplode.Infrastructure.csproj | 2 +- TiktokExplode.NET.slnx | 5 +- TiktokExplode/TiktokExplode.csproj | 2 +- 12 files changed, 109 insertions(+), 48 deletions(-) create mode 100644 TiktokExplode.Infrastructure/AssemblyInfo.cs diff --git a/TiktokExplode.All/TiktokExplode.All.csproj b/TiktokExplode.All/TiktokExplode.All.csproj index 52be5ad..df1a301 100644 --- a/TiktokExplode.All/TiktokExplode.All.csproj +++ b/TiktokExplode.All/TiktokExplode.All.csproj @@ -1,4 +1,4 @@ - + net8.0;net9.0 @@ -10,7 +10,7 @@ TiktokExplode.All - 1.2.1 + 1.2.2 Ts-Pytham Ts-Pytham Meta-package that installs TiktokExplode (domain layer) and TiktokExplode.Infrastructure (HTTP/browser fetchers, parser, download client) in a single command. @@ -24,6 +24,7 @@ + diff --git a/TiktokExplode.Extensions.DependencyInjection/README.md b/TiktokExplode.Extensions.DependencyInjection/README.md index a048578..22e4d1e 100644 --- a/TiktokExplode.Extensions.DependencyInjection/README.md +++ b/TiktokExplode.Extensions.DependencyInjection/README.md @@ -6,7 +6,7 @@ `Microsoft.Extensions.DependencyInjection` integration for [TiktokExplode](https://github.com/Ts-Pytham/TiktokExplode). -Provides `AddTiktokExplode()` — a fluent extension method on `IServiceCollection` that registers `IVideoClient` and `ISearchClient`, and lets you choose between the Playwright (default) or HTTP fetcher strategy. +Provides `AddTiktokExplode()` — a fluent extension method on `IServiceCollection` that registers `IVideoClient` and optionally `ISearchClient`, and lets you choose independently between the Playwright or HTTP fetcher strategy for page fetching and search. --- @@ -21,15 +21,21 @@ dotnet add package TiktokExplode.Extensions.DependencyInjection ## Usage ```csharp -// Default — Playwright fetcher, all defaults +// Default — Playwright fetcher for page fetching, all defaults services.AddTiktokExplode(); -// Playwright with a visible browser window (useful for debugging) +// Playwright fetcher with a visible browser window (useful for debugging) services.AddTiktokExplode(b => b .UsePlaywrightFetcher(o => o.Headless = false)); -// HTTP fetcher — no browser dependency, lighter footprint -// Note: ISearchClient is NOT registered with this strategy (requires Playwright) +// HTTP fetcher for page fetching (faster, no browser dependency) +// with Playwright search support — recommended setup +services.AddTiktokExplode(b => b + .UseHttpFetcher() + .UsePlaywrightSearch(o => o.BrowserChannel = "msedge")); + +// HTTP fetcher only — no search support +// Note: ISearchClient is NOT registered without UsePlaywrightSearch services.AddTiktokExplode(b => b .UseHttpFetcher(o => o.WarmupDelay = TimeSpan.Zero) .ConfigureTiktok(o => o.MaxWafRetries = 5)); @@ -58,28 +64,32 @@ public class MyService(IVideoClient client, ISearchClient search) ## Registered services -| Service | Implementation | Lifetime | Fetcher | -| -------------------------------------------------- | ------------------------------------ | --------- | ------------------- | -| `IVideoClient` | `TiktokClient` | Singleton | Both | -| `ISearchClient` | `TiktokSearchClient` | Singleton | **Playwright only** | -| `IPageFetcher` | `PlaywrightFetcher` or `HttpFetcher` | Singleton | Both | -| `ISearchFetcher` | `PlaywrightSearchFetcher` | Singleton | **Playwright only** | -| `TikTokOptions` | — | Singleton | Both | -| `PlaywrightFetcherOptions` or `HttpFetcherOptions` | — | Singleton | Both | +| Service | Implementation | Lifetime | Condition | +| -------------------------------------------------- | ------------------------------------ | --------- | ---------------------------- | +| `IVideoClient` | `TiktokClient` | Singleton | Always | +| `ISearchClient` | `TiktokSearchClient` | Singleton | `UsePlaywrightSearch` only | +| `IPageFetcher` | `PlaywrightFetcher` or `HttpFetcher` | Singleton | Always | +| `ISearchFetcher` | `PlaywrightSearchFetcher` | Singleton | `UsePlaywrightSearch` only | +| `TiktokDownloadClient` | — | Singleton | Always (shared HTTP session) | +| `TiktokOptions` | — | Singleton | Always | +| `PlaywrightFetcherOptions` or `HttpFetcherOptions` | — | Singleton | Always | + +> **Important:** `ISearchClient` and `ISearchFetcher` are only registered when `UsePlaywrightSearch` is called. TikTok's search API requires a real browser to sign requests — it cannot be replicated with plain HTTP. If you omit `UsePlaywrightSearch` and attempt to inject `ISearchClient`, the DI container will throw a resolution error at runtime. -> **Important:** `ISearchClient` and `ISearchFetcher` are only registered when using the Playwright fetcher. TikTok's search API requires a real browser to sign requests — it cannot be replicated with plain HTTP. If you call `UseHttpFetcher()` and attempt to inject `ISearchClient`, the DI container will throw a resolution error at runtime. +> **Cookie sharing:** `TiktokDownloadClient` is registered as a singleton shared between `IVideoClient` and `ISearchClient`. Cookies obtained during a search session are automatically available when downloading, avoiding `403 Forbidden` errors. --- ## Builder API -| Method | Description | -| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `ConfigureTiktok(Action?)` | Configures WAF retry count and base delay | -| `UsePlaywrightFetcher(Action?)` | Uses a real Chromium browser via Playwright (default). Also registers `ISearchClient` and `ISearchFetcher` | -| `UseHttpFetcher(Action?)` | Uses a plain `HttpClient` — faster start, may be WAF-blocked. `ISearchClient` is **not** available | +| Method | Description | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `ConfigureTiktok(Action?)` | Configures WAF retry count and base delay | +| `UsePlaywrightFetcher(Action?)` | Uses a real Chromium browser as `IPageFetcher` (default). Does **not** register search services | +| `UseHttpFetcher(Action?)` | Uses a plain `HttpClient` as `IPageFetcher` — faster start, no browser dependency, may be WAF-blocked | +| `UsePlaywrightSearch(Action?)` | Registers `ISearchFetcher` and `ISearchClient` using Playwright. Independent of the page fetcher choice | -> **Note:** `UsePlaywrightFetcher` and `UseHttpFetcher` are mutually exclusive — the last one called wins. +> **Note:** `UsePlaywrightFetcher` and `UseHttpFetcher` are mutually exclusive — the last one called wins. `UsePlaywrightSearch` is independent and can be combined with either. --- diff --git a/TiktokExplode.Extensions.DependencyInjection/TiktokExplode.Extensions.DependencyInjection.csproj b/TiktokExplode.Extensions.DependencyInjection/TiktokExplode.Extensions.DependencyInjection.csproj index af76ab9..c77ab8e 100644 --- a/TiktokExplode.Extensions.DependencyInjection/TiktokExplode.Extensions.DependencyInjection.csproj +++ b/TiktokExplode.Extensions.DependencyInjection/TiktokExplode.Extensions.DependencyInjection.csproj @@ -12,7 +12,7 @@ TiktokExplode.Extensions.DependencyInjection - 1.1.0 + 1.1.2 Ts-Pytham Ts-Pytham Microsoft.Extensions.DependencyInjection integration for TiktokExplode. Provides AddTiktokExplode() with a fluent builder to register IVideoClient and choose between Playwright or HTTP fetcher strategies. diff --git a/TiktokExplode.Extensions.DependencyInjection/TiktokExplodeBuilder.cs b/TiktokExplode.Extensions.DependencyInjection/TiktokExplodeBuilder.cs index ffbb2c4..d53ff6e 100644 --- a/TiktokExplode.Extensions.DependencyInjection/TiktokExplodeBuilder.cs +++ b/TiktokExplode.Extensions.DependencyInjection/TiktokExplodeBuilder.cs @@ -3,6 +3,7 @@ using TiktokExplode.Infrastructure.Clients; using TiktokExplode.Infrastructure.Fetchers; using TiktokExplode.Infrastructure.Fetchers.Search; +using TiktokExplode.Infrastructure.Http; using TiktokExplode.Infrastructure.Options; namespace TiktokExplode.Extensions.DependencyInjection; @@ -15,7 +16,8 @@ namespace TiktokExplode.Extensions.DependencyInjection; public sealed class TiktokExplodeBuilder(IServiceCollection services) { private readonly TiktokOptions _tiktokOptions = new(); - private Action _fetcherRegistration = RegisterPlaywright(new()); + private Action _pageFetcherRegistration = RegisterPlaywright(new()); + private Action? _searchRegistration; /// /// Configures the WAF-retry behaviour of TiktokClient. @@ -30,21 +32,16 @@ public TiktokExplodeBuilder ConfigureTiktok(Action? options = nul /// /// Configures the Playwright-based page fetcher as the active . - /// This is the default strategy — call this only when you need to customise the options. + /// Use this only when you need a real browser to fetch video pages. + /// For search support, chain independently. /// - /// - /// Registering this fetcher also makes and - /// available in the container, because TikTok's search API requires a real browser session - /// to generate signed requests. These services are not registered when - /// is used instead. - /// /// Delegate that mutates a instance. /// The same builder for chaining. public TiktokExplodeBuilder UsePlaywrightFetcher(Action? options = null) { var playwrightOptions = new PlaywrightFetcherOptions(); options?.Invoke(playwrightOptions); - _fetcherRegistration = RegisterPlaywright(playwrightOptions); + _pageFetcherRegistration = RegisterPlaywright(playwrightOptions); return this; } @@ -58,21 +55,42 @@ public TiktokExplodeBuilder UseHttpFetcher(Action? options = { var httpFetcherOptions = new HttpFetcherOptions(); options?.Invoke(httpFetcherOptions); - _fetcherRegistration = RegisterHttp(httpFetcherOptions); + _pageFetcherRegistration = RegisterHttp(httpFetcherOptions); + return this; + } + + /// + /// Enables TikTok search support using a Playwright-based browser session. + /// Registers and as singletons. + /// Can be combined with any strategy. + /// + /// Delegate that mutates a instance. + /// The same builder for chaining. + public TiktokExplodeBuilder UsePlaywrightSearch(Action? options = null) + { + var playwrightOptions = new PlaywrightFetcherOptions(); + options?.Invoke(playwrightOptions); + _searchRegistration = RegisterPlaywrightSearch(playwrightOptions); return this; } /// /// Applies all pending registrations to the underlying . /// Registers , the chosen , - /// and as singletons. + /// (shared session), and as singletons. /// /// The service collection for further chaining. public IServiceCollection Build() { services.AddSingleton(_tiktokOptions); - _fetcherRegistration(services); - services.AddSingleton(); + services.AddSingleton(); + _pageFetcherRegistration(services); + _searchRegistration?.Invoke(services); + + services.AddSingleton(sp => new TiktokClient( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); return services; } @@ -82,8 +100,6 @@ private static Action RegisterPlaywright(PlaywrightFetcherOp { services.AddSingleton(options); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); }; } @@ -95,4 +111,16 @@ private static Action RegisterHttp(HttpFetcherOptions option services.AddSingleton(); }; } + + private static Action RegisterPlaywrightSearch(PlaywrightFetcherOptions options) + { + return services => + { + services.AddSingleton(options); + services.AddSingleton(); + services.AddSingleton(sp => new TiktokSearchClient( + sp.GetRequiredService(), + sp.GetRequiredService())); + }; + } } diff --git a/TiktokExplode.Infrastructure/AssemblyInfo.cs b/TiktokExplode.Infrastructure/AssemblyInfo.cs new file mode 100644 index 0000000..8d8d760 --- /dev/null +++ b/TiktokExplode.Infrastructure/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("TiktokExplode.Extensions.DependencyInjection")] diff --git a/TiktokExplode.Infrastructure/Browser/TikTokBrowser.cs b/TiktokExplode.Infrastructure/Browser/TikTokBrowser.cs index b18f4a3..f00e73a 100644 --- a/TiktokExplode.Infrastructure/Browser/TikTokBrowser.cs +++ b/TiktokExplode.Infrastructure/Browser/TikTokBrowser.cs @@ -199,7 +199,7 @@ await page.WaitForFunctionAsync( result = string.Empty; } - if (string.IsNullOrWhiteSpace(result)) + if (string.IsNullOrWhiteSpace(result) || !result.Contains("\"data\":")) { result = await page.EvaluateAsync( """ @@ -217,6 +217,9 @@ await page.WaitForFunctionAsync( response.Url); } + if(!result.Contains("\"data\":")) + throw new TiktokException("Failed to retrieve search results from API response."); + return new SearchPageResult { JsonContent = result, diff --git a/TiktokExplode.Infrastructure/Clients/TiktokClient.cs b/TiktokExplode.Infrastructure/Clients/TiktokClient.cs index 087d04c..28e644d 100644 --- a/TiktokExplode.Infrastructure/Clients/TiktokClient.cs +++ b/TiktokExplode.Infrastructure/Clients/TiktokClient.cs @@ -31,7 +31,17 @@ public sealed class TiktokClient(IPageFetcher fetcher, TiktokOptions options) : /// Initializes a new with a Playwright-based page fetcher /// and default retry options. /// - public TiktokClient() : this(new PlaywrightFetcher(), new TiktokOptions()) { } + public TiktokClient() + : this(new PlaywrightFetcher(), new TiktokOptions()) { } + + internal TiktokClient( + TiktokDownloadClient downloadClient, + IPageFetcher fetcher, + TiktokOptions options) + : this(fetcher, options) + { + _downloadClient = downloadClient; + } /// /// @@ -74,7 +84,7 @@ internal Task DownloadCoreAsync( /// public async Task DownloadAsync(Video video, CancellationToken cancellationToken = default) { - if(string.IsNullOrEmpty(video.Info.DownloadLinks.OriginalUrl)) + if (string.IsNullOrEmpty(video.Info.DownloadLinks.OriginalUrl)) throw new TiktokException("Video does not contain a valid original download URL."); return await DownloadCoreAsync(video.Info.DownloadLinks.OriginalUrl, cancellationToken); @@ -92,7 +102,7 @@ public async Task DownloadWatermarkedAsync(Video video, Cancellation /// public Task DownloadImageAsync(CarouselImage image, CancellationToken cancellationToken = default) { - var url = image.Urls.FirstOrDefault(u => !string.IsNullOrEmpty(u)) + var url = image.Urls.FirstOrDefault(u => !string.IsNullOrEmpty(u)) ?? throw new TiktokException("Image does not contain a valid download URL."); return DownloadCoreAsync(url, cancellationToken); diff --git a/TiktokExplode.Infrastructure/Clients/TiktokSearchClient.cs b/TiktokExplode.Infrastructure/Clients/TiktokSearchClient.cs index b52c5e9..799bd5e 100644 --- a/TiktokExplode.Infrastructure/Clients/TiktokSearchClient.cs +++ b/TiktokExplode.Infrastructure/Clients/TiktokSearchClient.cs @@ -23,6 +23,13 @@ public sealed class TiktokSearchClient( /// Initializes a new with default options. public TiktokSearchClient() : this(new PlaywrightSearchFetcher()) { } + internal TiktokSearchClient( + TiktokDownloadClient downloadClient, + ISearchFetcher fetcher) : this(fetcher) + { + _downloadClient = downloadClient; + } + /// public async IAsyncEnumerable SearchAsync( string keyword, diff --git a/TiktokExplode.Infrastructure/Fetchers/Search/PlaywrightSearchFetcher.cs b/TiktokExplode.Infrastructure/Fetchers/Search/PlaywrightSearchFetcher.cs index bbd028d..fa69a74 100644 --- a/TiktokExplode.Infrastructure/Fetchers/Search/PlaywrightSearchFetcher.cs +++ b/TiktokExplode.Infrastructure/Fetchers/Search/PlaywrightSearchFetcher.cs @@ -99,7 +99,7 @@ private async Task FetchFirstPageWithRetryAsync( { return await _browser!.GetSearchPageAsync(keyword); } - catch (TiktokWafException) when (attempt < tikTokOptions.MaxWafRetries) + catch (TiktokException) when (attempt < tikTokOptions.MaxWafRetries) { await Task.Delay(tikTokOptions.RetryBaseDelay * (attempt + 1), cancellationToken); } diff --git a/TiktokExplode.Infrastructure/TiktokExplode.Infrastructure.csproj b/TiktokExplode.Infrastructure/TiktokExplode.Infrastructure.csproj index 33f596a..a5db619 100644 --- a/TiktokExplode.Infrastructure/TiktokExplode.Infrastructure.csproj +++ b/TiktokExplode.Infrastructure/TiktokExplode.Infrastructure.csproj @@ -12,7 +12,7 @@ TiktokExplode.Infrastructure - 1.2.1 + 1.2.2 Ts-Pytham Ts-Pytham Infrastructure layer for TiktokExplode: HTTP and Playwright-based page fetchers, TikTok video parser, CDN download client, and WAF-retry logic. diff --git a/TiktokExplode.NET.slnx b/TiktokExplode.NET.slnx index 6d9623c..1f0e339 100644 --- a/TiktokExplode.NET.slnx +++ b/TiktokExplode.NET.slnx @@ -1,8 +1,7 @@ - - - + + diff --git a/TiktokExplode/TiktokExplode.csproj b/TiktokExplode/TiktokExplode.csproj index f723a82..a0d89fa 100644 --- a/TiktokExplode/TiktokExplode.csproj +++ b/TiktokExplode/TiktokExplode.csproj @@ -11,7 +11,7 @@ TiktokExplode - 1.2.1 + 1.2.2 Ts-Pytham Ts-Pytham Domain layer for TiktokExplode: strongly-typed models, interfaces, and utilities for working with TikTok video metadata. Zero external dependencies.