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.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.Bot/CDN/CloudflareR2CdnProvider.cs b/TiktokExplode.Bot/CDN/CloudflareR2CdnProvider.cs new file mode 100644 index 0000000..053f750 --- /dev/null +++ b/TiktokExplode.Bot/CDN/CloudflareR2CdnProvider.cs @@ -0,0 +1,77 @@ +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TiktokExplode.Bot.Configuration; + +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) + { + if (await GetUrlAsync(filename, ct) is string existingUrl && !string.IsNullOrWhiteSpace(existingUrl)) + { + 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 = key, + InputStream = stream, + ContentType = "video/mp4", + DisablePayloadSigning = true + }; + + 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 new file mode 100644 index 0000000..5de3618 --- /dev/null +++ b/TiktokExplode.Bot/CDN/CompositeCdnProvider.cs @@ -0,0 +1,54 @@ +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."); + } + + 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 new file mode 100644 index 0000000..b60fe63 --- /dev/null +++ b/TiktokExplode.Bot/CDN/ICdnProvider.cs @@ -0,0 +1,8 @@ +namespace TiktokExplode.Bot.CDN; + +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 new file mode 100644 index 0000000..e4dad87 --- /dev/null +++ b/TiktokExplode.Bot/CDN/LitterboxCdnProvider.cs @@ -0,0 +1,34 @@ +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}.mp4"); + + 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(); + } + + 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 new file mode 100644 index 0000000..5dfa711 --- /dev/null +++ b/TiktokExplode.Bot/CDN/ZeroXZeroCdnProvider.cs @@ -0,0 +1,28 @@ +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}.mp4"); + + 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(); + } + + public Task GetUrlAsync(string filename, CancellationToken ct = default) + => Task.FromResult($"https://0x0.st/{filename}.mp4"); +} diff --git a/TiktokExplode.Bot/Configuration/BotSettings.cs b/TiktokExplode.Bot/Configuration/BotSettings.cs new file mode 100644 index 0000000..272ec6d --- /dev/null +++ b/TiktokExplode.Bot/Configuration/BotSettings.cs @@ -0,0 +1,12 @@ +namespace TiktokExplode.Bot.Configuration; + +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/Configuration/CloudflareR2Options.cs b/TiktokExplode.Bot/Configuration/CloudflareR2Options.cs new file mode 100644 index 0000000..5ff4628 --- /dev/null +++ b/TiktokExplode.Bot/Configuration/CloudflareR2Options.cs @@ -0,0 +1,12 @@ +namespace TiktokExplode.Bot.Configuration; + +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/Handlers/FFmpegAudioStream.cs b/TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs new file mode 100644 index 0000000..9eee898 --- /dev/null +++ b/TiktokExplode.Bot/Handlers/FFmpegAudioStream.cs @@ -0,0 +1,168 @@ +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 readonly SemaphoreSlim _semaphore = new(1, 1); + 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."); + + // 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 _semaphore.WaitAsync(cancellationToken); + _semaphore.Release(); + + await destination.WriteAsync(buffer, cancellationToken); + offset = 0; + } + } + + private async Task ConsumeErrorsAsync( + Process process, + CancellationToken cancellationToken) + { + _ = Task.Run(async () => + { + while (!cancellationToken.IsCancellationRequested) + { + var line = await process.StandardError.ReadLineAsync(); + + if (line is null) + break; + + _logger.LogInformation("[FFmpeg] {Line}", line); + } + }, 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) + 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(); + + if (_semaphore.CurrentCount == 0) + _semaphore.Release(); + + _semaphore.Dispose(); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } +} 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/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/AudioModule.cs b/TiktokExplode.Bot/Modules/AudioModule.cs new file mode 100644 index 0000000..d286472 --- /dev/null +++ b/TiktokExplode.Bot/Modules/AudioModule.cs @@ -0,0 +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; + +[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/DownloadModule.cs b/TiktokExplode.Bot/Modules/DownloadModule.cs new file mode 100644 index 0000000..9b05e0b --- /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); + } + 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"); + } + 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/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/Program.cs b/TiktokExplode.Bot/Program.cs new file mode 100644 index 0000000..4439e10 --- /dev/null +++ b/TiktokExplode.Bot/Program.cs @@ -0,0 +1,73 @@ +using Discord; +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; +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 | 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 => + new InteractionService(provider.GetRequiredService())); + + services.AddTiktokExplode(t => + t.UsePlaywrightFetcher(ops => ops.BrowserChannel = "msedge")); + + services.AddMemoryCache(); + services.AddHttpClient(); + + services.AddSingleton(_ => new ConcurrentDictionary()); + + services.AddSingleton(); + + services.Configure(context.Configuration.GetSection(CloudflareR2Options.Section)); + + 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(); + services.AddHostedService(); + } +} diff --git a/TiktokExplode.Bot/Services/BotService.cs b/TiktokExplode.Bot/Services/BotService.cs new file mode 100644 index 0000000..a5f6adc --- /dev/null +++ b/TiktokExplode.Bot/Services/BotService.cs @@ -0,0 +1,70 @@ +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, + 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(); + } + + 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; + } + + 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..b571d32 --- /dev/null +++ b/TiktokExplode.Bot/Services/InteractionHandlerService.cs @@ -0,0 +1,76 @@ +using Discord; +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, + IOptions settings) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + client.Ready += OnReadyAsync; + client.InteractionCreated += OnInteractionCreatedAsync; + interactionService.InteractionExecuted += OnInteractionExecutedAsync; + + await interactionService.AddModulesAsync(typeof(AudioModule).Assembly, serviceProvider); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + client.Ready -= OnReadyAsync; + client.InteractionCreated -= OnInteractionCreatedAsync; + interactionService.InteractionExecuted -= OnInteractionExecutedAsync; + return Task.CompletedTask; + } + + private async Task OnReadyAsync() + { + 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) + { + 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..3e57137 --- /dev/null +++ b/TiktokExplode.Bot/Services/MusicPlayer.cs @@ -0,0 +1,222 @@ +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); + _inactivityTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + + public Task SkipAsync() + { + _skipCts.Cancel(); + return Task.CompletedTask; + } + + public async Task StopAsync() + { + await _queueLock.WaitAsync(); + _queueSnapshot.Clear(); + _queueLock.Release(); + + while (_queue.Reader.TryRead(out _)) { } + + 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() + { + await StopAsync(); + + _queue.Writer.TryComplete(); + await _stopCts.CancelAsync(); + if (_loopTask is not null) + try { await _loopTask; } catch { } + + _inactivityTimer.Dispose(); + _queueLock.Dispose(); + _skipCts.Dispose(); + _stopCts.Dispose(); + + var client = _audioClient; + if (client is not null) + { + await client.StopAsync(); + client.Dispose(); + } + + 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 diff --git a/TiktokExplode.Bot/TiktokExplode.Bot.csproj b/TiktokExplode.Bot/TiktokExplode.Bot.csproj new file mode 100644 index 0000000..8a9bef2 --- /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..174407a --- /dev/null +++ b/TiktokExplode.Bot/appsettings.example.json @@ -0,0 +1,19 @@ +{ + "BotSettings": { + "Token": "YOUR_DISCORD_BOT_TOKEN", + "GuildId": 0 + }, + "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.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 2241353..b25bad4 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.2.1 + 1.2.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 d4d0750..1f0e339 100644 --- a/TiktokExplode.NET.slnx +++ b/TiktokExplode.NET.slnx @@ -1,5 +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.