From 2da367b2352445540bca7d9491f968a0e3ce7d6b Mon Sep 17 00:00:00 2001 From: Chase Redmon Date: Sat, 29 Nov 2025 22:32:32 -0500 Subject: [PATCH 1/8] feat: add Native AOT compatibility support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enable IsAotCompatible flag in project file - Add JSON source generation context for AOT scenarios - Add CatBoxApiErrorResponse model for structured error handling - Configure ReSharper to enforce ConfigureAwait in library code - Bump version to 1.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/CatBox.NET/CatBox.NET.csproj | 3 +- src/CatBox.NET/CatBox.NET.csproj.DotSettings | 2 ++ .../Responses/CatBoxApiErrorResponse.cs | 30 +++++++++++++++++++ src/CatBox.NET/Responses/CatBoxJsonContext.cs | 8 +++++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/CatBox.NET/CatBox.NET.csproj.DotSettings create mode 100644 src/CatBox.NET/Responses/CatBoxApiErrorResponse.cs create mode 100644 src/CatBox.NET/Responses/CatBoxJsonContext.cs diff --git a/src/CatBox.NET/CatBox.NET.csproj b/src/CatBox.NET/CatBox.NET.csproj index e510aed..b9dd03c 100644 --- a/src/CatBox.NET/CatBox.NET.csproj +++ b/src/CatBox.NET/CatBox.NET.csproj @@ -4,7 +4,7 @@ enable enable latest - 1.0 + 1.1 Chase Redmon, Kuinox, Adam Sears CatBox.NET is a .NET Library for uploading files, URLs, and modifying albums on CatBox.moe https://github.com/ChaseDRedmon/CatBox.NET @@ -15,6 +15,7 @@ https://github.com/ChaseDRedmon/CatBox.NET/blob/main/license.txt Fix required description field on create album endpoint. Description is optional when creating an endpoint. net10.0 + true diff --git a/src/CatBox.NET/CatBox.NET.csproj.DotSettings b/src/CatBox.NET/CatBox.NET.csproj.DotSettings new file mode 100644 index 0000000..89316e4 --- /dev/null +++ b/src/CatBox.NET/CatBox.NET.csproj.DotSettings @@ -0,0 +1,2 @@ + + Library \ No newline at end of file diff --git a/src/CatBox.NET/Responses/CatBoxApiErrorResponse.cs b/src/CatBox.NET/Responses/CatBoxApiErrorResponse.cs new file mode 100644 index 0000000..f64440d --- /dev/null +++ b/src/CatBox.NET/Responses/CatBoxApiErrorResponse.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace CatBox.NET.Responses; + +/// +/// Generic CatBox API error response format +/// +internal sealed record CatBoxApiErrorResponse +{ + [JsonPropertyName("data")] + public CatBoxApiErrorData? Data { get; init; } + + [JsonPropertyName("success")] + public bool Success { get; init; } + + [JsonPropertyName("status")] + public int Status { get; init; } +} + +internal sealed record CatBoxApiErrorData +{ + [JsonPropertyName("error")] + public string? Error { get; init; } + + [JsonPropertyName("endpoint")] + public string? Endpoint { get; init; } + + [JsonPropertyName("method")] + public string? Method { get; init; } +} diff --git a/src/CatBox.NET/Responses/CatBoxJsonContext.cs b/src/CatBox.NET/Responses/CatBoxJsonContext.cs new file mode 100644 index 0000000..e7ea572 --- /dev/null +++ b/src/CatBox.NET/Responses/CatBoxJsonContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; +using CatBox.NET.Responses.Album; + +namespace CatBox.NET.Responses; + +[JsonSerializable(typeof(GetAlbumApiResponse))] +[JsonSerializable(typeof(CatBoxApiErrorResponse))] +internal sealed partial class CatBoxJsonContext : JsonSerializerContext; From 6c3871b92ed350503b13e9a13d8f3d19a358de5f Mon Sep 17 00:00:00 2001 From: Chase Redmon Date: Sat, 29 Nov 2025 22:37:30 -0500 Subject: [PATCH 2/8] feat: add album file limit enforcement and safe upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MaxAlbumFiles constant (500) to Common class - Add CatBoxAlbumFileLimitExceededException for limit violations - Add Throw.IfAlbumFileLimitExceeds() validation helper - Add AlbumUploadResult record for capacity-aware upload results - Add URL parsing extensions (ToCatboxImageName, ToAlbumShortCode) - Add ToListAsync extension for IAsyncEnumerable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/CatBox.NET/Client/Common.cs | 77 ++++++++++--- src/CatBox.NET/Client/Throw.cs | 31 ++++- .../Exceptions/CatBoxAPIExceptions.cs | 107 +++++++++++++++--- .../Responses/Album/AlbumUploadResult.cs | 22 ++++ 4 files changed, 206 insertions(+), 31 deletions(-) create mode 100644 src/CatBox.NET/Responses/Album/AlbumUploadResult.cs diff --git a/src/CatBox.NET/Client/Common.cs b/src/CatBox.NET/Client/Common.cs index 889be27..bc53b5d 100644 --- a/src/CatBox.NET/Client/Common.cs +++ b/src/CatBox.NET/Client/Common.cs @@ -1,12 +1,16 @@ -using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using CatBox.NET.Enums; -using CatBox.NET.Requests.Album.Create; using CatBox.NET.Requests.Album.Modify; namespace CatBox.NET.Client; internal static class Common { + /// + /// Maximum number of files allowed in a CatBox album + /// + public const int MaxAlbumFiles = 500; + /// /// These file extensions are not allowed by the API, so filter them out /// @@ -23,20 +27,6 @@ _ when extension.Contains(".doc") => false, }; } - /// - /// Validates an Album Creation Request - /// - /// The album creation requestBase to validate - /// when the requestBase is null - /// when the description is null - /// when the title is null - public static void ThrowIfAlbumCreationRequestIsInvalid(AlbumCreationRequestBase requestBase) - { - ArgumentNullException.ThrowIfNull(requestBase); - ArgumentException.ThrowIfNullOrWhiteSpace(requestBase.Description); - ArgumentException.ThrowIfNullOrWhiteSpace(requestBase.Title); - } - /// /// 1. Filter Invalid Request Types on the Album Endpoint
/// 2. Check that the user hash is not null, empty, or whitespace when attempting to modify or delete an album. User hash is required for those operations @@ -56,4 +46,59 @@ public static bool IsAlbumRequestTypeValid(ModifyAlbumImagesRequest imagesReques request == RequestType.RemoveFromAlbum || request == RequestType.DeleteAlbum) && hasUserHash; } + + /// The URL or file name + extension(string? url) + { + /// + /// Extracts the file name from a CatBox file URL (files.catbox.moe) or returns the original string. + /// + /// "https://files.catbox.moe/abc123.jpg" → "abc123.jpg" + /// The extracted file name or original string + public string? ToCatboxImageName() + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + if (url.Contains("files.catbox.moe")) + return url.Split('/')[^1]; + + return url; + } + + /// + /// Extracts the album short code from a CatBox album URL (catbox.moe/c/) or returns the original string. + /// + /// "https://catbox.moe/c/abc123" → "abc123" + /// The extracted album short code or original string + public string? ToAlbumShortCode() + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + if (url.Contains("catbox.moe/c/")) + return url.Split('/')[^1]; + + return url; + } + } + + /// The async enumerable source + extension(IAsyncEnumerable source) + { + /// + /// Asynchronously collects all elements from an IAsyncEnumerable into a List + /// + /// Cancellation token + /// A list containing all elements + public async Task> ToListAsync(CancellationToken ct = default) + { + var list = new List(); + await foreach (var item in source.WithCancellation(ct).ConfigureAwait(false)) + { + list.Add(item); + } + return list; + } + } } \ No newline at end of file diff --git a/src/CatBox.NET/Client/Throw.cs b/src/CatBox.NET/Client/Throw.cs index c103d16..619abfb 100644 --- a/src/CatBox.NET/Client/Throw.cs +++ b/src/CatBox.NET/Client/Throw.cs @@ -1,5 +1,6 @@ +using System.Diagnostics.CodeAnalysis; using CatBox.NET.Enums; -using CatBox.NET.Requests.Album.Modify; +using CatBox.NET.Requests.Album.Create; namespace CatBox.NET.Client; @@ -38,7 +39,7 @@ public static void IfLitterboxFileSizeExceeds(long fileSize, long maxSize) /// Whether the request type is valid /// The name of the parameter that is invalid /// When the request type is invalid for the album endpoint - public static void IfAlbumRequestTypeInvalid(bool isValid, string paramName) + public static void IfAlbumRequestTypeInvalid([DoesNotReturnIf(false)] bool isValid, string paramName) { if (!isValid) throw new ArgumentException("Invalid Request Type for album endpoint", paramName); @@ -59,4 +60,30 @@ public static void IfAlbumOperationInvalid(RequestType request, params RequestTy throw new InvalidOperationException("Invalid Request Type for album endpoint"); } + + /// + /// Validates an Album Creation Request + /// + /// The album creation requestBase to validate + /// when the requestBase is null + /// when the description is null + /// when the title is null + public static void IfAlbumCreationRequestIsInvalid(AlbumCreationRequestBase requestBase) + { + ArgumentNullException.ThrowIfNull(requestBase); + ArgumentException.ThrowIfNullOrWhiteSpace(requestBase.Description); + ArgumentException.ThrowIfNullOrWhiteSpace(requestBase.Title); + } + + /// + /// Throws if the file count exceeds the album limit + /// + /// The number of files being added to the album + /// The maximum allowed files (default: ) + /// When file count exceeds the maximum + public static void IfAlbumFileLimitExceeds(int fileCount, int maxFiles = Common.MaxAlbumFiles) + { + if (fileCount > maxFiles) + throw new Exceptions.CatBoxAlbumFileLimitExceededException(fileCount); + } } diff --git a/src/CatBox.NET/Exceptions/CatBoxAPIExceptions.cs b/src/CatBox.NET/Exceptions/CatBoxAPIExceptions.cs index 71f2a30..0716298 100644 --- a/src/CatBox.NET/Exceptions/CatBoxAPIExceptions.cs +++ b/src/CatBox.NET/Exceptions/CatBoxAPIExceptions.cs @@ -1,7 +1,9 @@ using System.Diagnostics; using System.Net; +using System.Text.Json; using CatBox.NET.Client; using CatBox.NET.Logging; +using CatBox.NET.Responses; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -29,6 +31,24 @@ internal sealed class CatBoxMissingFileException : Exception public override string Message { get; } = "The FileToUpload parameter was not specified or is missing content. Did you miss an API parameter?"; } +// API Response Message: No userhash provided! +internal sealed class CatBoxMissingUserHashException : Exception +{ + public override string Message { get; } = "The UserHash parameter was not provided. UserHash is required for album modification and deletion operations."; +} + +// API Response Message: No valid link given. +internal sealed class CatBoxMissingUrlException : Exception +{ + public override string Message { get; } = "The URL parameter was not provided or is invalid. A valid URL is required for URL upload operations."; +} + +// API Response Message: Tried to delete a file that didn't belong to that userhash. +internal sealed class CatBoxFileOwnershipException : Exception +{ + public override string Message { get; } = "Attempted to delete a file that does not belong to the provided userhash. You can only delete files you own."; +} + //API Response Message: No expire time specified. internal sealed class LitterboxInvalidExpiry : Exception { @@ -47,36 +67,97 @@ internal sealed class CatBoxFileSizeLimitExceededException(long fileSize) : Exce public override string Message { get; } = $"File size exceeds CatBox's 200 MB upload limit. File size: {fileSize:N0} bytes ({fileSize / 1024.0 / 1024.0:F2} MB)"; } +// Album exceeds CatBox's file limit +internal sealed class CatBoxAlbumFileLimitExceededException(int fileCount) : Exception +{ + public override string Message { get; } = $"Album exceeds CatBox's {Common.MaxAlbumFiles} file limit. Attempted to add {fileCount} files."; +} + internal sealed class ExceptionHandler(ILogger? logger = null) : DelegatingHandler { + // Plain-text error messages (HTTP 412) private const string FileNotFound = "File doesn't exist?"; private const string AlbumNotFound = "No album found for user specified."; - private const string MissingRequestType = "No requestBase type given."; + private const string MissingRequestType = "No request type given?"; private const string MissingFileParameter = "No files given."; + private const string MissingUserHash = "No userhash provided!"; + private const string MissingUrl = "No valid link given."; + private const string FileOwnershipMismatch = "Tried to delete a file that didn't belong to that userhash."; private const string InvalidExpiry = "No expire time specified."; - + + // JSON error messages (HTTP 400) + private const string JsonAlbumNotFound = "An album was not found. Either the album never existed, or was deleted."; + private readonly ILogger _logger = logger ?? NullLogger.Instance; protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - var response = await base.SendAsync(request, cancellationToken); - if (response.StatusCode != HttpStatusCode.PreconditionFailed) + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + // Only process error status codes + if (response.IsSuccessStatusCode) return response; - - var content = response.Content; - var apiErrorMessage = await content.ReadAsStringAsync(cancellationToken); - _logger.LogCatBoxAPIException(response.StatusCode, apiErrorMessage); - - throw apiErrorMessage switch + + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + // Try JSON parsing first (HTTP 400 pattern) + if (response.StatusCode == HttpStatusCode.BadRequest) + { + var exception = TryParseJsonError(content); + if (exception is not null) + { + _logger.LogCatBoxAPIException(response.StatusCode, content); + throw exception; + } + } + + // Fall back to plain-text matching (HTTP 412 pattern) + if (response.StatusCode == HttpStatusCode.PreconditionFailed) + { + _logger.LogCatBoxAPIException(response.StatusCode, content); + throw MatchPlainTextError(content, response.StatusCode); + } + + // Return response for unhandled status codes (let caller handle) + return response; + } + + private static Exception? TryParseJsonError(string content) + { + try + { + var errorResponse = JsonSerializer.Deserialize(content, CatBoxJsonContext.Default.CatBoxApiErrorResponse); + if (errorResponse is { Success: false, Data.Error: not null }) + { + return errorResponse.Data.Error switch + { + var e when e.Equals(JsonAlbumNotFound, StringComparison.OrdinalIgnoreCase) => new CatBoxAlbumNotFoundException(), + _ => new HttpRequestException($"CatBox API Error: {errorResponse.Data.Error}") + }; + } + } + catch (JsonException) + { + // Not valid JSON, return null to try other parsing + } + return null; + } + + private static Exception MatchPlainTextError(string content, HttpStatusCode statusCode) + { + return content switch { AlbumNotFound => new CatBoxAlbumNotFoundException(), FileNotFound => new CatBoxFileNotFoundException(), InvalidExpiry => new LitterboxInvalidExpiry(), MissingFileParameter => new CatBoxMissingFileException(), + MissingUserHash => new CatBoxMissingUserHashException(), + MissingUrl => new CatBoxMissingUrlException(), + FileOwnershipMismatch => new CatBoxFileOwnershipException(), MissingRequestType => new CatBoxMissingRequestTypeException(), - _ when response.StatusCode is >= HttpStatusCode.BadRequest and < HttpStatusCode.InternalServerError => new HttpRequestException($"Generic Request Failure: {apiErrorMessage}"), - _ when response.StatusCode >= HttpStatusCode.InternalServerError => new HttpRequestException($"Generic Internal Server Error: {apiErrorMessage}"), - _ => new UnreachableException($"I don't know how you got here, but please create an issue on our GitHub (https://github.com/ChaseDRedmon/CatBox.NET): {apiErrorMessage}") + _ when statusCode is >= HttpStatusCode.BadRequest and < HttpStatusCode.InternalServerError => new HttpRequestException($"Generic Request Failure: {content}"), + _ when statusCode >= HttpStatusCode.InternalServerError => new HttpRequestException($"Generic Internal Server Error: {content}"), + _ => new UnreachableException($"Unexpected error: {content}") }; } } \ No newline at end of file diff --git a/src/CatBox.NET/Responses/Album/AlbumUploadResult.cs b/src/CatBox.NET/Responses/Album/AlbumUploadResult.cs new file mode 100644 index 0000000..bc038d4 --- /dev/null +++ b/src/CatBox.NET/Responses/Album/AlbumUploadResult.cs @@ -0,0 +1,22 @@ +namespace CatBox.NET.Responses.Album; + +/// +/// Result of uploading files to an album with capacity awareness +/// +public sealed record AlbumUploadResult +{ + /// The album URL after successful modification, or null if no files were uploaded + public required string? AlbumUrl { get; init; } + + /// Number of files successfully uploaded and added to the album + public required int FilesUploaded { get; init; } + + /// Number of files that were skipped due to album capacity limit + public required int FilesSkipped { get; init; } + + /// Remaining capacity in the album after this operation + public required int RemainingCapacity { get; init; } + + /// Whether the album has reached its maximum capacity of 500 files + public bool ReachedLimit => RemainingCapacity == 0; +} From d0e3f15e8e34b398081712b90ea8fda9bb87e60e Mon Sep 17 00:00:00 2001 From: Chase Redmon Date: Sat, 29 Nov 2025 22:37:49 -0500 Subject: [PATCH 3/8] feat: add GetAlbum API and download support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add RequestType.GetAlbum enum value - Add GetAlbumRequest and response models (AlbumInfo, GetAlbumApiResponse) - Add ICatBoxClient.GetAlbumAsync() for retrieving album information - Add file download support with DownloadFileAsync methods - Add album download with DownloadAlbumAsync (yields FileInfo per file) - Add UploadImagesToAlbumSafeAsync for capacity-aware uploads - Improve retry policy to not retry client errors (4xx) - Add ConfigureAwait(false) throughout for library best practices - Replace ToBlockingEnumerable with async/await patterns 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/CatBox.NET/CatBoxServices.cs | 27 +- src/CatBox.NET/CatboxOptions.cs | 7 +- src/CatBox.NET/Client/CatBox/CatBox.cs | 264 +++++++++++++++--- src/CatBox.NET/Client/CatBox/CatBoxClient.cs | 151 ++++++++-- .../Client/Litterbox/LitterboxClient.cs | 8 +- src/CatBox.NET/Enums/RequestType.cs | 1 + .../Requests/Album/GetAlbumRequest.cs | 12 + .../Album/Modify/ModifyAlbumImagesRequest.cs | 2 +- src/CatBox.NET/Responses/Album/AlbumInfo.cs | 22 ++ .../Responses/Album/GetAlbumApiResponse.cs | 36 +++ 10 files changed, 465 insertions(+), 65 deletions(-) create mode 100644 src/CatBox.NET/Requests/Album/GetAlbumRequest.cs create mode 100644 src/CatBox.NET/Responses/Album/AlbumInfo.cs create mode 100644 src/CatBox.NET/Responses/Album/GetAlbumApiResponse.cs diff --git a/src/CatBox.NET/CatBoxServices.cs b/src/CatBox.NET/CatBoxServices.cs index b532b1f..6e0840a 100644 --- a/src/CatBox.NET/CatBoxServices.cs +++ b/src/CatBox.NET/CatBoxServices.cs @@ -1,4 +1,6 @@ -using CatBox.NET.Client; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using CatBox.NET.Client; using CatBox.NET.Exceptions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http.Resilience; @@ -30,7 +32,7 @@ public IServiceCollection AddCatBoxServices(Action setupAction) return services; } - private IServiceCollection AddHttpClientWithMessageHandler() + private IServiceCollection AddHttpClientWithMessageHandler() where TInterface : class where TImplementation : class, TInterface { @@ -48,8 +50,27 @@ private IServiceCollection AddHttpClientWithMessageHandler + { + // Don't retry custom CatBox exceptions - these are client errors (4xx) + // Our exceptions inherit from Exception, not HttpRequestException + if (args.Outcome.Exception is not null and not HttpRequestException) + return ValueTask.FromResult(false); + + // Retry HttpRequestException (network failures, timeouts) + if (args.Outcome.Exception is HttpRequestException) + return ValueTask.FromResult(true); + + // Retry 5xx server errors + if (args.Outcome.Result is { } response) + return ValueTask.FromResult(response.StatusCode >= HttpStatusCode.InternalServerError); + + return ValueTask.FromResult(false); + } }; + + }); return services; diff --git a/src/CatBox.NET/CatboxOptions.cs b/src/CatBox.NET/CatboxOptions.cs index 45a6aea..de37222 100644 --- a/src/CatBox.NET/CatboxOptions.cs +++ b/src/CatBox.NET/CatboxOptions.cs @@ -9,7 +9,12 @@ public sealed record CatboxOptions /// URL for the catbox.moe domain ///
public Uri? CatBoxUrl { get; set; } - + + /// + /// Base URL for downloading CatBox files (default: https://files.catbox.moe/) + /// + public Uri CatBoxFilesUrl { get; set; } = new("https://files.catbox.moe/"); + /// /// URL for the litterbox.moe domain /// diff --git a/src/CatBox.NET/Client/CatBox/CatBox.cs b/src/CatBox.NET/Client/CatBox/CatBox.cs index c6746d0..6fb3f00 100644 --- a/src/CatBox.NET/Client/CatBox/CatBox.cs +++ b/src/CatBox.NET/Client/CatBox/CatBox.cs @@ -1,6 +1,11 @@ -using CatBox.NET.Requests.Album; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using CatBox.NET.Requests.Album; using CatBox.NET.Requests.Album.Create; using CatBox.NET.Requests.Album.Modify; +using CatBox.NET.Requests.File; +using CatBox.NET.Requests.URL; +using CatBox.NET.Responses.Album; namespace CatBox.NET.Client; @@ -24,56 +29,187 @@ public interface ICatBox /// Cancellation Token. /// Task UploadImagesToAlbumAsync(UploadToAlbumRequest request, CancellationToken ct = default); -} -/// -public sealed class Catbox : ICatBox -{ - private readonly ICatBoxClient _client; + /// + /// Uploads files to an existing album, respecting the 500-file limit. + /// Fetches album first to determine available capacity and uploads only what fits. + /// + /// Album upload request + /// Cancellation Token + /// Result containing upload counts and remaining capacity + Task UploadImagesToAlbumSafeAsync(UploadToAlbumRequest request, CancellationToken ct = default); /// - /// Instantiate a new catbox class + /// Gets album information including the list of files /// - /// The CatBox Api Client () - public Catbox(ICatBoxClient client) - { - _client = client; - } - + /// The album ID to retrieve + /// Cancellation Token + /// Album information with parsed file list + Task GetAlbumAsync(string albumId, CancellationToken ct = default); + + /// + /// Downloads a file from CatBox to the specified directory + /// + /// The file name (e.g., "abc123.png") + /// Directory to save the file + /// Cancellation Token + /// Skips download if file already exists at destination + Task DownloadFileAsync(string fileName, DirectoryInfo destination, CancellationToken ct = default); + + /// + /// Downloads a file from CatBox to the specified path + /// + /// The file name (e.g., "abc123.png") + /// Directory path to save the file + /// Cancellation Token + /// Skips download if file already exists at destination + Task DownloadFileAsync(string fileName, [StringSyntax(StringSyntaxAttribute.Uri)] string destinationPath, CancellationToken ct = default); + + /// + /// Downloads all files from an album to the specified directory + /// + /// The album ID to download + /// Directory to save the files + /// Cancellation Token + /// Yields FileInfo for each downloaded file + IAsyncEnumerable DownloadAlbumAsync(string albumId, DirectoryInfo destination, CancellationToken ct = default); + + /// + /// Downloads all files from an album to the specified path + /// + /// The album ID to download + /// Directory path to save the files + /// Cancellation Token + /// Yields FileInfo for each downloaded file + IAsyncEnumerable DownloadAlbumAsync(string albumId, [StringSyntax(StringSyntaxAttribute.Uri)] string destinationPath, CancellationToken ct = default); +} + +/// +/// +/// Instantiate a new catbox class +/// +/// The CatBox Api Client () +public sealed class Catbox(ICatBoxClient client) : ICatBox +{ /// - public Task CreateAlbumFromFilesAsync(CreateAlbumRequest requestFromFiles, CancellationToken ct = default) + public async Task CreateAlbumFromFilesAsync(CreateAlbumRequest requestFromFiles, CancellationToken ct = default) { - var enumerable = Upload(requestFromFiles, ct); + var uploadedFiles = await Upload(requestFromFiles, ct).ToListAsync(ct).ConfigureAwait(false); var createAlbumRequest = new RemoteCreateAlbumRequest { Title = requestFromFiles.Title, Description = requestFromFiles.Description, UserHash = requestFromFiles.UserHash, - Files = enumerable.ToBlockingEnumerable(cancellationToken: ct) + Files = uploadedFiles }; - return _client.CreateAlbumAsync(createAlbumRequest, ct); + return await client.CreateAlbumAsync(createAlbumRequest, ct).ConfigureAwait(false); } /// - public Task UploadImagesToAlbumAsync(UploadToAlbumRequest request, CancellationToken ct = default) + public async Task UploadImagesToAlbumAsync(UploadToAlbumRequest request, CancellationToken ct = default) { - var requestType = request.Request; - var userHash = request.UserHash; - var albumId = request.AlbumId; + var uploadedFiles = await Upload(request, ct).ToListAsync(ct).ConfigureAwait(false); - var enumerable = Upload(request, ct); + return await client.ModifyAlbumAsync(new ModifyAlbumImagesRequest + { + Request = request.Request, + UserHash = request.UserHash, + AlbumId = request.AlbumId, + Files = uploadedFiles + }, ct).ConfigureAwait(false); + } - return _client.ModifyAlbumAsync(new ModifyAlbumImagesRequest + /// + public async Task UploadImagesToAlbumSafeAsync(UploadToAlbumRequest request, CancellationToken ct = default) + { + // Get album to determine current capacity + var album = await GetAlbumAsync(request.AlbumId, ct).ConfigureAwait(false); + var currentCount = album.Files.Length; + var remainingCapacity = Common.MaxAlbumFiles - currentCount; + + // Count total files in request (materializes lazy enumerables) + var (totalFiles, materializedRequest) = MaterializeAndCountRequest(request); + + // If album is full, return early + if (remainingCapacity <= 0) { - Request = requestType, - UserHash = userHash, - AlbumId = albumId, - Files = enumerable.ToBlockingEnumerable() - }, ct); + return new AlbumUploadResult + { + AlbumUrl = null, + FilesUploaded = 0, + FilesSkipped = totalFiles, + RemainingCapacity = 0 + }; + } + + // Calculate how many to upload + var filesToUpload = Math.Min(remainingCapacity, totalFiles); + var filesToSkip = totalFiles - filesToUpload; + + // If we need to upload all files, use the original request + // Otherwise, create a sliced request + var requestToUse = filesToUpload == totalFiles + ? materializedRequest + : TakeFilesFromRequest(materializedRequest, filesToUpload); + + // Upload and add to album + var result = await UploadImagesToAlbumAsync(requestToUse, ct).ConfigureAwait(false); + + return new AlbumUploadResult + { + AlbumUrl = result, + FilesUploaded = filesToUpload, + FilesSkipped = filesToSkip, + RemainingCapacity = remainingCapacity - filesToUpload + }; } - + + /// + public async Task GetAlbumAsync(string albumId, CancellationToken ct = default) + { + return await client.GetAlbumAsync(new GetAlbumRequest { AlbumId = albumId }, ct).ConfigureAwait(false); + } + + /// + public async Task DownloadFileAsync(string fileName, DirectoryInfo destination, CancellationToken ct = default) + { + await client.DownloadFileAsync(fileName, destination, ct).ConfigureAwait(false); + } + + /// + public async Task DownloadFileAsync(string fileName, [StringSyntax(StringSyntaxAttribute.Uri)] string destinationPath, CancellationToken ct = default) + { + await client.DownloadFileAsync(fileName, destinationPath, ct).ConfigureAwait(false); + } + + /// + public async IAsyncEnumerable DownloadAlbumAsync(string albumId, DirectoryInfo destination, [EnumeratorCancellation] CancellationToken ct = default) + { + var album = await GetAlbumAsync(albumId, ct).ConfigureAwait(false); + + if (!destination.Exists) + destination.Create(); + + foreach (var fileName in album.Files) + { + ct.ThrowIfCancellationRequested(); + + var filePath = Path.Combine(destination.FullName, fileName); + + await client.DownloadFileAsync(fileName, destination, ct).ConfigureAwait(false); + + yield return new FileInfo(filePath); + } + } + + /// + public IAsyncEnumerable DownloadAlbumAsync(string albumId, [StringSyntax(StringSyntaxAttribute.Uri)] string destinationPath, CancellationToken ct = default) + { + return DownloadAlbumAsync(albumId, new DirectoryInfo(destinationPath), ct); + } + /// /// Upload files based on the requestBase type /// @@ -85,10 +221,74 @@ public Catbox(ICatBoxClient client) { return request.UploadRequest switch { - { IsFirst: true } => _client.UploadFilesAsync(request.UploadRequest, ct), - { IsSecond: true } => _client.UploadFilesAsStreamAsync(request.UploadRequest.Second, ct), - { IsThird: true } => _client.UploadFilesAsUrlAsync(request.UploadRequest, ct), + { IsFirst: true } => client.UploadFilesAsync(request.UploadRequest, ct), + { IsSecond: true } => client.UploadFilesAsStreamAsync(request.UploadRequest.Second, ct), + { IsThird: true } => client.UploadFilesAsUrlAsync(request.UploadRequest, ct), _ => throw new InvalidOperationException("Invalid requestBase type") }; } + + /// + /// Materializes lazy enumerables and counts total files in the request + /// + private static (int Count, UploadToAlbumRequest MaterializedRequest) MaterializeAndCountRequest(UploadToAlbumRequest request) + { + return request.UploadRequest switch + { + { IsFirst: true } => MaterializeFileUploadRequest(request), + { IsSecond: true } => MaterializeStreamUploadRequest(request), + { IsThird: true } => MaterializeUrlUploadRequest(request), + _ => throw new InvalidOperationException("Invalid request type") + }; + + static (int, UploadToAlbumRequest) MaterializeFileUploadRequest(UploadToAlbumRequest req) + { + var files = req.UploadRequest.First.Files.ToList(); + var materialized = req with + { + UploadRequest = req.UploadRequest.First with { Files = files } + }; + return (files.Count, materialized); + } + + static (int, UploadToAlbumRequest) MaterializeStreamUploadRequest(UploadToAlbumRequest req) + { + var streams = req.UploadRequest.Second.ToList(); + var materialized = req with { UploadRequest = streams }; + return (streams.Count, materialized); + } + + static (int, UploadToAlbumRequest) MaterializeUrlUploadRequest(UploadToAlbumRequest req) + { + var urls = req.UploadRequest.Third.Files.ToList(); + var materialized = req with + { + UploadRequest = req.UploadRequest.Third with { Files = urls } + }; + return (urls.Count, materialized); + } + } + + /// + /// Creates a new request with only the first N files + /// + private static UploadToAlbumRequest TakeFilesFromRequest(UploadToAlbumRequest request, int count) + { + return request.UploadRequest switch + { + { IsFirst: true } => request with + { + UploadRequest = request.UploadRequest.First with { Files = request.UploadRequest.First.Files.Take(count) } + }, + { IsSecond: true } => request with + { + UploadRequest = request.UploadRequest.Second.Take(count).ToList() + }, + { IsThird: true } => request with + { + UploadRequest = request.UploadRequest.Third with { Files = request.UploadRequest.Third.Files.Take(count) } + }, + _ => throw new InvalidOperationException("Invalid request type") + }; + } } \ No newline at end of file diff --git a/src/CatBox.NET/Client/CatBox/CatBoxClient.cs b/src/CatBox.NET/Client/CatBox/CatBoxClient.cs index 888adf3..042b3bf 100644 --- a/src/CatBox.NET/Client/CatBox/CatBoxClient.cs +++ b/src/CatBox.NET/Client/CatBox/CatBoxClient.cs @@ -1,10 +1,13 @@ using System.Runtime.CompilerServices; +using System.Text.Json; using CatBox.NET.Enums; using CatBox.NET.Requests.Album; using CatBox.NET.Requests.Album.Create; using CatBox.NET.Requests.Album.Modify; using CatBox.NET.Requests.File; using CatBox.NET.Requests.URL; +using CatBox.NET.Responses; +using CatBox.NET.Responses.Album; using Microsoft.Extensions.Options; using static CatBox.NET.Client.Common; @@ -105,6 +108,41 @@ public interface ICatBoxClient /// .

/// Use to edit an album Task ModifyAlbumAsync(ModifyAlbumImagesRequest modifyAlbumImagesRequest, CancellationToken ct = default); + + /// + /// Gets album information including the list of files + /// + /// Request containing the album ID + /// Cancellation Token + /// When is null + /// When is null or whitespace + /// When something bad happens when talking to the API + /// Album information with parsed file list + Task GetAlbumAsync(GetAlbumRequest getAlbumRequest, CancellationToken ct = default); + + /// + /// Downloads a file from CatBox to the specified directory + /// + /// The file name (e.g., "abc123.png") + /// Directory to save the file + /// Cancellation Token + /// When fileName is null or whitespace + /// When destination is null + /// When something bad happens when talking to the API + /// Skips download if file already exists at destination + Task DownloadFileAsync(string fileName, DirectoryInfo destination, CancellationToken ct = default); + + /// + /// Downloads a file from CatBox to the specified path + /// + /// The file name (e.g., "abc123.png") + /// Directory path to save the file + /// Cancellation Token + /// When fileName is null or whitespace + /// When destinationPath is null or whitespace + /// When something bad happens when talking to the API + /// Skips download if file already exists at destination + Task DownloadFileAsync(string fileName, string destinationPath, CancellationToken ct = default); } public sealed class CatBoxClient : ICatBoxClient @@ -152,8 +190,8 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) if (!string.IsNullOrWhiteSpace(fileUploadRequest.UserHash)) content.Add(new StringContent(fileUploadRequest.UserHash), RequestParameters.UserHash); - using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct); - yield return await response.Content.ReadAsStringAsync(ct); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + yield return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } } @@ -178,8 +216,8 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) if (!string.IsNullOrWhiteSpace(uploadRequest.UserHash)) content.Add(new StringContent(uploadRequest.UserHash), RequestParameters.UserHash); - using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct); - yield return await response.Content.ReadAsStringAsync(ct); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + yield return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } } @@ -200,8 +238,8 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) if (!string.IsNullOrWhiteSpace(urlUploadRequest.UserHash)) content.Add(new StringContent(urlUploadRequest.UserHash), RequestParameters.UserHash); - using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct); - yield return await response.Content.ReadAsStringAsync(ct); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + yield return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } } @@ -221,24 +259,18 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) { new StringContent(fileNames), RequestParameters.Files } }; - using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct); - return await response.Content.ReadAsStringAsync(ct); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } /// public async Task CreateAlbumAsync(RemoteCreateAlbumRequest remoteCreateAlbumRequest, CancellationToken ct = default) { - ThrowIfAlbumCreationRequestIsInvalid(remoteCreateAlbumRequest); + Throw.IfAlbumCreationRequestIsInvalid(remoteCreateAlbumRequest); - var links = remoteCreateAlbumRequest.Files.Select(link => - { - if (link?.Contains(_catboxOptions.CatBoxUrl!.Host) is true) - { - return new Uri(link).PathAndQuery[1..]; - } + var links = remoteCreateAlbumRequest.Files.Select(link => link.ToCatboxImageName()).ToList(); - return link; - }); + Throw.IfAlbumFileLimitExceeds(links.Count); var fileNames = string.Join(" ", links); ArgumentException.ThrowIfNullOrWhiteSpace(fileNames); @@ -256,8 +288,8 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) if (!string.IsNullOrWhiteSpace(remoteCreateAlbumRequest.Description)) content.Add(new StringContent(remoteCreateAlbumRequest.Description), RequestParameters.Description); - using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct); - return await response.Content.ReadAsStringAsync(ct); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } /// @@ -284,8 +316,8 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) { new StringContent(fileNames), RequestParameters.Files } }; - using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct); - return await response.Content.ReadAsStringAsync(ct); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } /// @@ -297,7 +329,12 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) Throw.IfAlbumRequestTypeInvalid(IsAlbumRequestTypeValid(modifyAlbumImagesRequest), nameof(modifyAlbumImagesRequest.Request)); Throw.IfAlbumOperationInvalid(modifyAlbumImagesRequest.Request, RequestType.AddToAlbum, RequestType.RemoveFromAlbum, RequestType.DeleteAlbum); - var fileNames = string.Join(" ", modifyAlbumImagesRequest.Files); + var files = modifyAlbumImagesRequest.Files.ToList(); + + if (modifyAlbumImagesRequest.Request == RequestType.AddToAlbum) + Throw.IfAlbumFileLimitExceeds(files.Count); + + var fileNames = string.Join(" ", files); using var content = new MultipartFormDataContent { @@ -313,7 +350,73 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) content.Add(new StringContent(fileNames), RequestParameters.Files); } - using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct); - return await response.Content.ReadAsStringAsync(ct); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + } + + /// + public async Task GetAlbumAsync(GetAlbumRequest getAlbumRequest, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(getAlbumRequest); + ArgumentException.ThrowIfNullOrWhiteSpace(getAlbumRequest.AlbumId); + + using var content = new MultipartFormDataContent + { + { new StringContent(RequestType.GetAlbum), RequestParameters.Request }, + { new StringContent(getAlbumRequest.AlbumId), RequestParameters.AlbumIdShort } + }; + + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + + var apiResponse = JsonSerializer.Deserialize(json, CatBoxJsonContext.Default.GetAlbumApiResponse); + + // ExceptionHandler will throw CatBoxAlbumNotFoundException for HTTP 400 errors + // This check is only for unexpected response format + if (apiResponse?.Data is null) + throw new HttpRequestException($"Unexpected response format: {json}"); + + var files = string.IsNullOrWhiteSpace(apiResponse.Data.Files) + ? [] + : apiResponse.Data.Files.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + return new AlbumInfo + { + Title = apiResponse.Data.Title, + Description = apiResponse.Data.Description, + AlbumId = apiResponse.Data.Short, + DateCreated = apiResponse.Data.DateCreated, + Files = files + }; + } + + /// + public async Task DownloadFileAsync(string fileName, DirectoryInfo destination, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + ArgumentNullException.ThrowIfNull(destination); + + if (!destination.Exists) + destination.Create(); + + var filePath = Path.Combine(destination.FullName, fileName); + + // Skip if file exists + if (File.Exists(filePath)) + return; + + var fileUrl = new Uri(_catboxOptions.CatBoxFilesUrl, fileName); + + using var response = await _client.GetAsync(fileUrl, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + await using var fileStream = File.Create(filePath); + await response.Content.CopyToAsync(fileStream, ct).ConfigureAwait(false); + } + + /// + public Task DownloadFileAsync(string fileName, string destinationPath, CancellationToken ct = default) + { + return DownloadFileAsync(fileName, new DirectoryInfo(destinationPath), ct); } } \ No newline at end of file diff --git a/src/CatBox.NET/Client/Litterbox/LitterboxClient.cs b/src/CatBox.NET/Client/Litterbox/LitterboxClient.cs index 6a8dc4c..2aafaf3 100644 --- a/src/CatBox.NET/Client/Litterbox/LitterboxClient.cs +++ b/src/CatBox.NET/Client/Litterbox/LitterboxClient.cs @@ -71,9 +71,9 @@ public LitterboxClient(HttpClient client, IOptions catboxOptions) { new StringContent(RequestType.UploadFile), RequestParameters.Request }, { new StringContent(temporaryFileUploadRequest.Expiry), RequestParameters.Expiry }, { new StreamContent(fileStream), RequestParameters.FileToUpload, imageFile.Name } - }, ct); + }, ct).ConfigureAwait(false); - yield return await response.Content.ReadAsStringAsync(ct); + yield return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } } @@ -94,8 +94,8 @@ public LitterboxClient(HttpClient client, IOptions catboxOptions) new StreamContent(temporaryStreamUploadRequest.Stream), RequestParameters.FileToUpload, temporaryStreamUploadRequest.FileName } - }, ct); + }, ct).ConfigureAwait(false); - return await response.Content.ReadAsStringAsync(ct); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/CatBox.NET/Enums/RequestType.cs b/src/CatBox.NET/Enums/RequestType.cs index 84f554a..82a0f37 100644 --- a/src/CatBox.NET/Enums/RequestType.cs +++ b/src/CatBox.NET/Enums/RequestType.cs @@ -14,4 +14,5 @@ namespace CatBox.NET.Enums; [Member("AddToAlbum", "addtoalbum")] [Member("RemoveFromAlbum", "removefromalbum")] [Member("DeleteAlbum", "deletealbum")] +[Member("GetAlbum", "getalbum")] public sealed partial class RequestType; diff --git a/src/CatBox.NET/Requests/Album/GetAlbumRequest.cs b/src/CatBox.NET/Requests/Album/GetAlbumRequest.cs new file mode 100644 index 0000000..20ed8b0 --- /dev/null +++ b/src/CatBox.NET/Requests/Album/GetAlbumRequest.cs @@ -0,0 +1,12 @@ +namespace CatBox.NET.Requests.Album; + +/// +/// Request to retrieve the list of files in an album +/// +public sealed record GetAlbumRequest +{ + /// + /// The unique identifier for the album (API value: "short") + /// + public required string AlbumId { get; init; } +} diff --git a/src/CatBox.NET/Requests/Album/Modify/ModifyAlbumImagesRequest.cs b/src/CatBox.NET/Requests/Album/Modify/ModifyAlbumImagesRequest.cs index f1f3a33..560dac1 100644 --- a/src/CatBox.NET/Requests/Album/Modify/ModifyAlbumImagesRequest.cs +++ b/src/CatBox.NET/Requests/Album/Modify/ModifyAlbumImagesRequest.cs @@ -9,5 +9,5 @@ public sealed record ModifyAlbumImagesRequest : AlbumBase /// The list of files associated with the album /// /// may alter the significance of this collection - public required IEnumerable Files { get; init; } + public required IEnumerable Files { get; init; } } \ No newline at end of file diff --git a/src/CatBox.NET/Responses/Album/AlbumInfo.cs b/src/CatBox.NET/Responses/Album/AlbumInfo.cs new file mode 100644 index 0000000..8623326 --- /dev/null +++ b/src/CatBox.NET/Responses/Album/AlbumInfo.cs @@ -0,0 +1,22 @@ +namespace CatBox.NET.Responses.Album; + +/// +/// Processed album information with parsed file list +/// +public sealed record AlbumInfo +{ + /// Album title + public required string Title { get; init; } + + /// Album description + public required string Description { get; init; } + + /// Album ID (short code) + public required string AlbumId { get; init; } + + /// Date the album was created + public required DateOnly DateCreated { get; init; } + + /// List of file names in the album + public required string[] Files { get; init; } +} diff --git a/src/CatBox.NET/Responses/Album/GetAlbumApiResponse.cs b/src/CatBox.NET/Responses/Album/GetAlbumApiResponse.cs new file mode 100644 index 0000000..2a2b7eb --- /dev/null +++ b/src/CatBox.NET/Responses/Album/GetAlbumApiResponse.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace CatBox.NET.Responses.Album; + +/// +/// Raw API response from getalbum endpoint +/// +internal sealed record GetAlbumApiResponse +{ + [JsonPropertyName("data")] + public required AlbumApiData? Data { get; init; } + + [JsonPropertyName("success")] + public bool Success { get; init; } + + [JsonPropertyName("status")] + public int Status { get; init; } +} + +internal sealed record AlbumApiData +{ + [JsonPropertyName("files")] + public required string Files { get; init; } + + [JsonPropertyName("title")] + public required string Title { get; init; } + + [JsonPropertyName("description")] + public required string Description { get; init; } + + [JsonPropertyName("short")] + public required string Short { get; init; } + + [JsonPropertyName("datecreated")] + public required DateOnly DateCreated { get; init; } +} From 08ad8862b673a6f06499438639adaadab7c7b5a6 Mon Sep 17 00:00:00 2001 From: Chase Redmon Date: Sat, 29 Nov 2025 22:38:06 -0500 Subject: [PATCH 4/8] test: add and update unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tests for album file limit validation (IfAlbumFileLimitExceeds) - Add tests for URL parsing extensions (ToCatboxImageName, ToAlbumShortCode) - Update integration tests to use proper async/await patterns - Improve test coverage for CatBox and Litterbox clients 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../CatBoxClientIntegrationTests.cs | 30 ++-- tests/CatBox.Tests/CatBoxClientTests.cs | 4 +- tests/CatBox.Tests/CommonTests.cs | 160 ++++++++++++------ .../LitterboxClientIntegrationTests.cs | 14 +- tests/CatBox.Tests/LitterboxClientTests.cs | 4 +- 5 files changed, 132 insertions(+), 80 deletions(-) diff --git a/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs b/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs index a323e4b..3792f99 100644 --- a/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs +++ b/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs @@ -59,7 +59,7 @@ public async Task OneTimeTearDown() // Delete albums first (they reference files) if (_createdAlbums.Count > 0) { - TestContext.WriteLine($"Cleaning up {_createdAlbums.Count} album(s)..."); + await TestContext.Out.WriteLineAsync($"Cleaning up {_createdAlbums.Count} album(s)..."); foreach (var albumId in _createdAlbums) { try @@ -72,11 +72,11 @@ public async Task OneTimeTearDown() Files = [] }; await _client.ModifyAlbumAsync(deleteAlbumRequest); - TestContext.WriteLine($"Deleted album: {albumId}"); + await TestContext.Out.WriteLineAsync($"Deleted album: {albumId}"); } catch (Exception ex) { - TestContext.WriteLine($"Failed to delete album {albumId}: {ex.Message}"); + await TestContext.Out.WriteLineAsync($"Failed to delete album {albumId}: {ex.Message}"); } } } @@ -84,7 +84,7 @@ public async Task OneTimeTearDown() // Then delete individual files if (_uploadedFiles.Count > 0) { - TestContext.WriteLine($"Cleaning up {_uploadedFiles.Count} file(s)..."); + await TestContext.Out.WriteLineAsync($"Cleaning up {_uploadedFiles.Count} file(s)..."); var deleteRequest = new DeleteFileRequest { UserHash = IntegrationTestConfig.UserHash!, @@ -92,12 +92,12 @@ public async Task OneTimeTearDown() }; var result = await _client.DeleteMultipleFilesAsync(deleteRequest); - TestContext.WriteLine($"Delete result: {result}"); + await TestContext.Out.WriteLineAsync($"Delete result: {result}"); } } catch (Exception ex) { - TestContext.WriteLine($"Cleanup error: {ex.Message}"); + await TestContext.Out.WriteLineAsync($"Cleanup error: {ex.Message}"); } } @@ -113,7 +113,7 @@ private void TrackUploadedFile(string? url) if (!_uploadedFiles.Contains(fileName)) { _uploadedFiles.Add(fileName); - TestContext.WriteLine($"Tracked file for cleanup: {fileName}"); + TestContext.Out.WriteLine($"Tracked file for cleanup: {fileName}"); } } } @@ -130,7 +130,7 @@ private void TrackCreatedAlbum(string? albumUrl) if (!_createdAlbums.Contains(albumId)) { _createdAlbums.Add(albumId); - TestContext.WriteLine($"Tracked album for cleanup: {albumId}"); + TestContext.Out.WriteLine($"Tracked album for cleanup: {albumId}"); } } } @@ -161,7 +161,7 @@ public async Task UploadFilesAsync_WithFileFromDisk_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://files.catbox.moe/"); - TestContext.WriteLine($"Uploaded file URL: {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded file URL: {results[0]}"); } [Test] @@ -195,7 +195,7 @@ public async Task UploadFilesAsStreamAsync_WithMemoryStream_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://files.catbox.moe/"); - TestContext.WriteLine($"Uploaded stream URL: {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded stream URL: {results[0]}"); } [Test] @@ -221,7 +221,7 @@ public async Task UploadFilesAsUrlAsync_WithPublicUrl_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://files.catbox.moe/"); - TestContext.WriteLine($"Uploaded from URL: {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded from URL: {results[0]}"); } [Test] @@ -264,7 +264,7 @@ public async Task CreateAlbumAsync_WithUploadedFiles_Succeeds() // Assert albumUrl.ShouldNotBeNullOrWhiteSpace(); albumUrl.ShouldStartWith("https://catbox.moe/c/"); - TestContext.WriteLine($"Created album: {albumUrl}"); + await TestContext.Out.WriteLineAsync($"Created album: {albumUrl}"); } [Test] @@ -320,7 +320,7 @@ public async Task ModifyAlbumAsync_AddAndRemoveFiles_Succeeds() }; var addResult = await _client.ModifyAlbumAsync(addRequest); - TestContext.WriteLine($"Add to album result: {addResult}"); + await TestContext.Out.WriteLineAsync($"Add to album result: {addResult}"); // Act - Remove file from album var removeRequest = new ModifyAlbumImagesRequest @@ -332,7 +332,7 @@ public async Task ModifyAlbumAsync_AddAndRemoveFiles_Succeeds() }; var removeResult = await _client.ModifyAlbumAsync(removeRequest); - TestContext.WriteLine($"Remove from album result: {removeResult}"); + await TestContext.Out.WriteLineAsync($"Remove from album result: {removeResult}"); // Assert addResult.ShouldNotBeNullOrWhiteSpace(); @@ -371,6 +371,6 @@ public async Task DeleteMultipleFilesAsync_WithUploadedFiles_Succeeds() // Assert result.ShouldNotBeNullOrWhiteSpace(); - TestContext.WriteLine($"Delete result: {result}"); + await TestContext.Out.WriteLineAsync($"Delete result: {result}"); } } diff --git a/tests/CatBox.Tests/CatBoxClientTests.cs b/tests/CatBox.Tests/CatBoxClientTests.cs index 6e1bd09..e7ee725 100644 --- a/tests/CatBox.Tests/CatBoxClientTests.cs +++ b/tests/CatBox.Tests/CatBoxClientTests.cs @@ -250,7 +250,7 @@ public async Task UploadFilesAsync_CancellationToken_CancelsOperation() }; var cts = new CancellationTokenSource(); - cts.Cancel(); + await cts.CancelAsync(); // Act & Assert await Should.ThrowAsync(async () => @@ -509,7 +509,7 @@ public async Task UploadFilesAsUrlAsync_CancellationToken_CancelsOperation() }; var cts = new CancellationTokenSource(); - cts.Cancel(); + await cts.CancelAsync(); // Act & Assert await Should.ThrowAsync(async () => diff --git a/tests/CatBox.Tests/CommonTests.cs b/tests/CatBox.Tests/CommonTests.cs index 1951ab5..bba3ae9 100644 --- a/tests/CatBox.Tests/CommonTests.cs +++ b/tests/CatBox.Tests/CommonTests.cs @@ -45,78 +45,39 @@ public void IsFileExtensionValid_WithValidExtensions_ReturnsTrue(string extensio } [Test] - public void ThrowIfAlbumCreationRequestIsInvalid_WithNullRequest_ThrowsArgumentNullException() + public void IfAlbumCreationRequestIsInvalid_WithNullRequest_ThrowsArgumentNullException() { // Act & Assert - Should.Throw(() => Common.ThrowIfAlbumCreationRequestIsInvalid(null!)); + Should.Throw(() => Throw.IfAlbumCreationRequestIsInvalid(null!)); } - [Test] - public void ThrowIfAlbumCreationRequestIsInvalid_WithNullTitle_ThrowsArgumentException() + private static IEnumerable InvalidAlbumCreationRequestCases() { - // Arrange - var request = new RemoteCreateAlbumRequest - { - Title = null!, - Description = "Test Description", - UserHash = "test-hash", - Files = ["file1.jpg"] - }; - - // Act & Assert - Should.Throw(() => Common.ThrowIfAlbumCreationRequestIsInvalid(request)); + yield return new TestCaseData(null, "Test Description").SetName("Null Title"); + yield return new TestCaseData(" ", "Test Description").SetName("Whitespace Title"); + yield return new TestCaseData("Test Title", null).SetName("Null Description"); + yield return new TestCaseData("Test Title", " ").SetName("Whitespace Description"); } - [Test] - public void ThrowIfAlbumCreationRequestIsInvalid_WithWhitespaceTitle_ThrowsArgumentException() + [TestCaseSource(nameof(InvalidAlbumCreationRequestCases))] + public void IfAlbumCreationRequestIsInvalid_WithInvalidRequest_ThrowsArgumentException( + string? title, string? description) { // Arrange var request = new RemoteCreateAlbumRequest { - Title = " ", - Description = "Test Description", + Title = title!, + Description = description!, UserHash = "test-hash", Files = ["file1.jpg"] }; // Act & Assert - Should.Throw(() => Common.ThrowIfAlbumCreationRequestIsInvalid(request)); + Should.Throw(() => Throw.IfAlbumCreationRequestIsInvalid(request)); } [Test] - public void ThrowIfAlbumCreationRequestIsInvalid_WithNullDescription_ThrowsArgumentException() - { - // Arrange - var request = new RemoteCreateAlbumRequest - { - Title = "Test Title", - Description = null!, - UserHash = "test-hash", - Files = ["file1.jpg"] - }; - - // Act & Assert - Should.Throw(() => Common.ThrowIfAlbumCreationRequestIsInvalid(request)); - } - - [Test] - public void ThrowIfAlbumCreationRequestIsInvalid_WithWhitespaceDescription_ThrowsArgumentException() - { - // Arrange - var request = new RemoteCreateAlbumRequest - { - Title = "Test Title", - Description = " ", - UserHash = "test-hash", - Files = ["file1.jpg"] - }; - - // Act & Assert - Should.Throw(() => Common.ThrowIfAlbumCreationRequestIsInvalid(request)); - } - - [Test] - public void ThrowIfAlbumCreationRequestIsInvalid_WithValidRequest_DoesNotThrow() + public void IfAlbumCreationRequestIsInvalid_WithValidRequest_DoesNotThrow() { // Arrange var request = new RemoteCreateAlbumRequest @@ -128,7 +89,7 @@ public void ThrowIfAlbumCreationRequestIsInvalid_WithValidRequest_DoesNotThrow() }; // Act & Assert - Should.NotThrow(() => Common.ThrowIfAlbumCreationRequestIsInvalid(request)); + Should.NotThrow(() => Throw.IfAlbumCreationRequestIsInvalid(request)); } private static IEnumerable ValidAlbumRequestCases() @@ -215,4 +176,95 @@ public void IsAlbumRequestTypeValid_WithInvalidRequestType_ReturnsFalse(RequestT // Assert result.ShouldBeFalse(); } + + [TestCase("https://files.catbox.moe/abc123.jpg", "abc123.jpg")] + [TestCase("http://files.catbox.moe/xyz789.png", "xyz789.png")] + [TestCase("https://files.catbox.moe/test.gif", "test.gif")] + public void ToCatboxImageName_WithFullCatboxUrl_ExtractsFilename(string url, string expected) + { + url.ToCatboxImageName().ShouldBe(expected); + } + + [TestCase("abc123.jpg", "abc123.jpg")] + [TestCase("myfile.png", "myfile.png")] + public void ToCatboxImageName_WithPlainFilename_ReturnsOriginal(string input, string expected) + { + input.ToCatboxImageName().ShouldBe(expected); + } + + [TestCase("https://example.com/image.jpg")] + [TestCase("https://otherdomain.moe/file.png")] + public void ToCatboxImageName_WithNonCatboxUrl_ReturnsOriginal(string url) + { + url.ToCatboxImageName().ShouldBe(url); + } + + [TestCase(null, null)] + [TestCase("", "")] + [TestCase(" ", " ")] + public void ToCatboxImageName_WithNullOrWhitespace_ReturnsInput(string? input, string? expected) + { + input.ToCatboxImageName().ShouldBe(expected); + } + + [TestCase("https://catbox.moe/c/abc123", "abc123")] + [TestCase("http://catbox.moe/c/xyz789", "xyz789")] + [TestCase("https://catbox.moe/c/test", "test")] + public void ToAlbumShortCode_WithFullAlbumUrl_ExtractsShortCode(string url, string expected) + { + url.ToAlbumShortCode().ShouldBe(expected); + } + + [TestCase("abc123", "abc123")] + [TestCase("xyz789", "xyz789")] + public void ToAlbumShortCode_WithPlainShortCode_ReturnsOriginal(string input, string expected) + { + input.ToAlbumShortCode().ShouldBe(expected); + } + + [TestCase("https://catbox.moe/other/abc123")] + [TestCase("https://files.catbox.moe/abc123.jpg")] + [TestCase("https://example.com/c/abc123")] + public void ToAlbumShortCode_WithNonAlbumUrl_ReturnsOriginal(string url) + { + url.ToAlbumShortCode().ShouldBe(url); + } + + [TestCase(null, null)] + [TestCase("", "")] + [TestCase(" ", " ")] + public void ToAlbumShortCode_WithNullOrWhitespace_ReturnsInput(string? input, string? expected) + { + input.ToAlbumShortCode().ShouldBe(expected); + } + + [Test] + public void IfAlbumFileLimitExceeds_At501Files_Throws() + { + Should.Throw(() => Throw.IfAlbumFileLimitExceeds(501)); + } + + [Test] + public void IfAlbumFileLimitExceeds_At500Files_DoesNotThrow() + { + Should.NotThrow(() => Throw.IfAlbumFileLimitExceeds(500)); + } + + [Test] + public void IfAlbumFileLimitExceeds_At1File_DoesNotThrow() + { + Should.NotThrow(() => Throw.IfAlbumFileLimitExceeds(1)); + } + + [Test] + public void IfAlbumFileLimitExceeds_WithCustomLimit_ThrowsAtExceedingLimit() + { + Should.Throw(() => Throw.IfAlbumFileLimitExceeds(11, maxFiles: 10)); + } + + [Test] + public void IfAlbumFileLimitExceeds_WithCustomLimit_DoesNotThrowAtLimit() + { + Should.NotThrow(() => Throw.IfAlbumFileLimitExceeds(10, maxFiles: 10)); + } } diff --git a/tests/CatBox.Tests/LitterboxClientIntegrationTests.cs b/tests/CatBox.Tests/LitterboxClientIntegrationTests.cs index 4228559..63738d3 100644 --- a/tests/CatBox.Tests/LitterboxClientIntegrationTests.cs +++ b/tests/CatBox.Tests/LitterboxClientIntegrationTests.cs @@ -66,7 +66,7 @@ public async Task UploadMultipleImagesAsync_WithOneHourExpiry_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://litter.catbox.moe/"); - TestContext.WriteLine($"Uploaded temporary file (1h expiry): {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded temporary file (1h expiry): {results[0]}"); } [Test] @@ -91,7 +91,7 @@ public async Task UploadMultipleImagesAsync_WithTwelveHourExpiry_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://litter.catbox.moe/"); - TestContext.WriteLine($"Uploaded temporary file (12h expiry): {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded temporary file (12h expiry): {results[0]}"); } [Test] @@ -116,7 +116,7 @@ public async Task UploadMultipleImagesAsync_WithOneDayExpiry_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://litter.catbox.moe/"); - TestContext.WriteLine($"Uploaded temporary file (1d expiry): {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded temporary file (1d expiry): {results[0]}"); } [Test] @@ -141,7 +141,7 @@ public async Task UploadMultipleImagesAsync_WithThreeDaysExpiry_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://litter.catbox.moe/"); - TestContext.WriteLine($"Uploaded temporary file (3d expiry): {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded temporary file (3d expiry): {results[0]}"); } [Test] @@ -165,7 +165,7 @@ public async Task UploadImageAsync_WithMemoryStream_Succeeds() // Assert result.ShouldNotBeNullOrWhiteSpace(); result.ShouldStartWith("https://litter.catbox.moe/"); - TestContext.WriteLine($"Uploaded temporary stream: {result}"); + await TestContext.Out.WriteLineAsync($"Uploaded temporary stream: {result}"); } [Test] @@ -189,10 +189,10 @@ public async Task UploadMultipleImagesAsync_WithMultipleFiles_YieldsMultipleUrls // Assert results.Count.ShouldBe(3); results.ShouldAllBe(r => !string.IsNullOrWhiteSpace(r) && r!.StartsWith("https://litter.catbox.moe/")); - TestContext.WriteLine($"Uploaded {results.Count} temporary files"); + await TestContext.Out.WriteLineAsync($"Uploaded {results.Count} temporary files"); foreach (var url in results) { - TestContext.WriteLine($" - {url}"); + await TestContext.Out.WriteLineAsync($" - {url}"); } } } diff --git a/tests/CatBox.Tests/LitterboxClientTests.cs b/tests/CatBox.Tests/LitterboxClientTests.cs index 4d3c06e..78b0e53 100644 --- a/tests/CatBox.Tests/LitterboxClientTests.cs +++ b/tests/CatBox.Tests/LitterboxClientTests.cs @@ -272,7 +272,7 @@ public async Task UploadMultipleImagesAsync_CancellationToken_CancelsOperation() }; var cts = new CancellationTokenSource(); - cts.Cancel(); + await cts.CancelAsync(); // Act & Assert await Should.ThrowAsync(async () => @@ -373,7 +373,7 @@ public async Task UploadImageAsync_CancellationToken_CancelsOperation() }; var cts = new CancellationTokenSource(); - cts.Cancel(); + await cts.CancelAsync(); // Act & Assert await Should.ThrowAsync(async () => await client.UploadImageAsync(request, cts.Token)); From 85dee8c5f8fa60d7e13806a569f4a55f750d1760 Mon Sep 17 00:00:00 2001 From: Chase Redmon Date: Sat, 29 Nov 2025 22:38:21 -0500 Subject: [PATCH 5/8] chore: update IDE configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update VCS settings for branch protection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .idea/.idea.CatBox.NET/.idea/vcs.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.idea/.idea.CatBox.NET/.idea/vcs.xml b/.idea/.idea.CatBox.NET/.idea/vcs.xml index 35eb1dd..e75ad0f 100644 --- a/.idea/.idea.CatBox.NET/.idea/vcs.xml +++ b/.idea/.idea.CatBox.NET/.idea/vcs.xml @@ -1,5 +1,13 @@ + + + From d02cdddb127ad2c060ba40f5a832760ef3b8345a Mon Sep 17 00:00:00 2001 From: Chase Redmon Date: Thu, 19 Feb 2026 14:07:20 -0500 Subject: [PATCH 6/8] Change test file location. --- {tests/CatBox.Tests/Images => assets}/test-file.png | Bin samples/SampleApp/SampleApp.csproj | 6 ++++++ tests/CatBox.Tests/CatBox.Tests.csproj | 2 +- tests/CatBox.Tests/Helpers/IntegrationTestConfig.cs | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) rename {tests/CatBox.Tests/Images => assets}/test-file.png (100%) diff --git a/tests/CatBox.Tests/Images/test-file.png b/assets/test-file.png similarity index 100% rename from tests/CatBox.Tests/Images/test-file.png rename to assets/test-file.png diff --git a/samples/SampleApp/SampleApp.csproj b/samples/SampleApp/SampleApp.csproj index ae05101..8ec181a 100644 --- a/samples/SampleApp/SampleApp.csproj +++ b/samples/SampleApp/SampleApp.csproj @@ -20,4 +20,10 @@
+ + + PreserveNewest + + + diff --git a/tests/CatBox.Tests/CatBox.Tests.csproj b/tests/CatBox.Tests/CatBox.Tests.csproj index 0921c23..c8256d3 100644 --- a/tests/CatBox.Tests/CatBox.Tests.csproj +++ b/tests/CatBox.Tests/CatBox.Tests.csproj @@ -34,7 +34,7 @@ - + PreserveNewest diff --git a/tests/CatBox.Tests/Helpers/IntegrationTestConfig.cs b/tests/CatBox.Tests/Helpers/IntegrationTestConfig.cs index cdf3a33..57e30f6 100644 --- a/tests/CatBox.Tests/Helpers/IntegrationTestConfig.cs +++ b/tests/CatBox.Tests/Helpers/IntegrationTestConfig.cs @@ -53,6 +53,6 @@ public static class IntegrationTestConfig public static string GetTestFilePath() { var testDirectory = NUnit.Framework.TestContext.CurrentContext.TestDirectory; - return Path.Combine(testDirectory, "Images", "test-file.png"); + return Path.Combine(testDirectory, "assets", "test-file.png"); } } From 1a6c9ebcc0df084193af2a8778a92dc37affd215 Mon Sep 17 00:00:00 2001 From: Chase Redmon Date: Thu, 19 Feb 2026 14:08:18 -0500 Subject: [PATCH 7/8] Add integration tests. Update integration tests to use ToCatBoxImage name helper. --- .../CatBoxClientIntegrationTests.cs | 160 +++++++++++++++++- 1 file changed, 151 insertions(+), 9 deletions(-) diff --git a/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs b/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs index 3792f99..85d2e82 100644 --- a/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs +++ b/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs @@ -1,10 +1,12 @@ using CatBox.NET; using CatBox.NET.Client; using CatBox.NET.Enums; +using CatBox.NET.Requests.Album; using CatBox.NET.Requests.Album.Create; using CatBox.NET.Requests.Album.Modify; using CatBox.NET.Requests.File; using CatBox.NET.Requests.URL; +using CatBox.NET.Responses.Album; using CatBox.Tests.Helpers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -106,8 +108,7 @@ private void TrackUploadedFile(string? url) if (string.IsNullOrWhiteSpace(url)) return; - // Extract filename from URL (e.g., "abc123.png" from "https://files.catbox.moe/abc123.png") - var fileName = new Uri(url).Segments.Last(); + var fileName = url.ToCatboxImageName()!; using (_lock.EnterScope()) { if (!_uploadedFiles.Contains(fileName)) @@ -123,8 +124,7 @@ private void TrackCreatedAlbum(string? albumUrl) if (string.IsNullOrWhiteSpace(albumUrl)) return; - // Extract album ID from URL (e.g., "abc123" from "https://catbox.moe/c/abc123") - var albumId = new Uri(albumUrl).Segments.Last(); + var albumId = albumUrl.ToAlbumShortCode()!; using (_lock.EnterScope()) { if (!_createdAlbums.Contains(albumId)) @@ -246,7 +246,7 @@ public async Task CreateAlbumAsync_WithUploadedFiles_Succeeds() // Extract filenames from URLs var fileNames = uploadedFileUrls .Where(url => !string.IsNullOrWhiteSpace(url)) - .Select(url => new Uri(url!).Segments.Last()) + .Select(url => url.ToCatboxImageName()!) .ToList(); var albumRequest = new RemoteCreateAlbumRequest @@ -286,7 +286,7 @@ public async Task ModifyAlbumAsync_AddAndRemoveFiles_Succeeds() break; } TrackUploadedFile(uploadedUrl); - var fileName = new Uri(uploadedUrl!).Segments.Last(); + var fileName = uploadedUrl!.ToCatboxImageName()!; var createAlbumRequest = new RemoteCreateAlbumRequest { @@ -298,7 +298,7 @@ public async Task ModifyAlbumAsync_AddAndRemoveFiles_Succeeds() var albumUrl = await _client.CreateAlbumAsync(createAlbumRequest); TrackCreatedAlbum(albumUrl); - var albumId = new Uri(albumUrl!).Segments.Last(); + var albumId = albumUrl!.ToAlbumShortCode()!; // Upload another file to add to the album string? secondUploadUrl = null; @@ -308,7 +308,7 @@ public async Task ModifyAlbumAsync_AddAndRemoveFiles_Succeeds() break; } TrackUploadedFile(secondUploadUrl); - var secondFileName = new Uri(secondUploadUrl!).Segments.Last(); + var secondFileName = secondUploadUrl!.ToCatboxImageName()!; // Act - Add file to album var addRequest = new ModifyAlbumImagesRequest @@ -358,7 +358,7 @@ public async Task DeleteMultipleFilesAsync_WithUploadedFiles_Succeeds() break; } uploadedUrl.ShouldNotBeNullOrWhiteSpace(); - var fileName = new Uri(uploadedUrl!).Segments.Last(); + var fileName = uploadedUrl!.ToCatboxImageName()!; var deleteRequest = new DeleteFileRequest { @@ -373,4 +373,146 @@ public async Task DeleteMultipleFilesAsync_WithUploadedFiles_Succeeds() result.ShouldNotBeNullOrWhiteSpace(); await TestContext.Out.WriteLineAsync($"Delete result: {result}"); } + + [Test] + [Order(7)] + public async Task GetAlbumAsync_WithCreatedAlbum_ReturnsAlbumInfo() + { + // Arrange - Upload a file and create an album + var testFilePath = IntegrationTestConfig.GetTestFilePath(); + var uploadRequest = new FileUploadRequest + { + Files = [new FileInfo(testFilePath)], + UserHash = IntegrationTestConfig.UserHash + }; + + string? uploadedUrl = null; + await foreach (var url in _client!.UploadFilesAsync(uploadRequest)) + { + uploadedUrl = url; + break; + } + TrackUploadedFile(uploadedUrl); + var fileName = uploadedUrl!.ToCatboxImageName()!; + + var createAlbumRequest = new RemoteCreateAlbumRequest + { + Title = "GetAlbum Integration Test", + Description = "Testing GetAlbumAsync", + UserHash = IntegrationTestConfig.UserHash, + Files = [fileName] + }; + + var albumUrl = await _client.CreateAlbumAsync(createAlbumRequest); + TrackCreatedAlbum(albumUrl); + var albumId = albumUrl!.ToAlbumShortCode()!; + + // Act + var albumInfo = await _client.GetAlbumAsync(new GetAlbumRequest { AlbumId = albumId }); + + // Assert + albumInfo.ShouldNotBeNull(); + albumInfo.Title.ShouldBe("GetAlbum Integration Test"); + albumInfo.Description.ShouldBe("Testing GetAlbumAsync"); + albumInfo.AlbumId.ShouldBe(albumId); + albumInfo.Files.ShouldContain(fileName); + await TestContext.Out.WriteLineAsync($"Album info: {albumInfo.Title} ({albumInfo.AlbumId}), {albumInfo.Files.Length} file(s)"); + } + + [Test] + [Order(8)] + public async Task DownloadFileAsync_WithUploadedFile_DownloadsSuccessfully() + { + // Arrange - Upload a file + var testFilePath = IntegrationTestConfig.GetTestFilePath(); + var uploadRequest = new FileUploadRequest + { + Files = [new FileInfo(testFilePath)], + UserHash = IntegrationTestConfig.UserHash + }; + + string? uploadedUrl = null; + await foreach (var url in _client!.UploadFilesAsync(uploadRequest)) + { + uploadedUrl = url; + break; + } + TrackUploadedFile(uploadedUrl); + var fileName = uploadedUrl!.ToCatboxImageName()!; + + var tempDir = Path.Combine(Path.GetTempPath(), $"catbox-test-{Guid.NewGuid():N}"); + try + { + // Act + await _client.DownloadFileAsync(fileName, tempDir); + + // Assert + var downloadedFile = Path.Combine(tempDir, fileName); + File.Exists(downloadedFile).ShouldBeTrue($"Downloaded file not found: {downloadedFile}"); + new FileInfo(downloadedFile).Length.ShouldBeGreaterThan(0); + await TestContext.Out.WriteLineAsync($"Downloaded file: {downloadedFile} ({new FileInfo(downloadedFile).Length} bytes)"); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + [Order(9)] + public async Task EditAlbumAsync_WithCreatedAlbum_EditsSuccessfully() + { + // Arrange - Upload 2 files and create an album + var testFilePath = IntegrationTestConfig.GetTestFilePath(); + var uploadRequest = new FileUploadRequest + { + Files = [new FileInfo(testFilePath), new FileInfo(testFilePath)], + UserHash = IntegrationTestConfig.UserHash + }; + + var uploadedFileNames = new List(); + await foreach (var url in _client!.UploadFilesAsync(uploadRequest)) + { + TrackUploadedFile(url); + uploadedFileNames.Add(url!.ToCatboxImageName()!); + } + uploadedFileNames.Count.ShouldBe(2); + + var createAlbumRequest = new RemoteCreateAlbumRequest + { + Title = "EditAlbum Integration Test", + Description = "Before edit", + UserHash = IntegrationTestConfig.UserHash, + Files = uploadedFileNames + }; + + var albumUrl = await _client.CreateAlbumAsync(createAlbumRequest); + TrackCreatedAlbum(albumUrl); + var albumId = albumUrl!.ToAlbumShortCode()!; + + // Act - Edit the album with new title, description, and only 1 file +#pragma warning disable CS0618 // EditAlbumAsync is marked Obsolete as a safety warning + var editResult = await _client.EditAlbumAsync(new EditAlbumRequest + { + UserHash = IntegrationTestConfig.UserHash!, + AlbumId = albumId, + Title = "Edited Title", + Description = "Edited description", + Files = [uploadedFileNames[0]] + }); +#pragma warning restore CS0618 + + // Assert + editResult.ShouldNotBeNullOrWhiteSpace(); + await TestContext.Out.WriteLineAsync($"Edit result: {editResult}"); + + // Verify changes via GetAlbum + var albumInfo = await _client.GetAlbumAsync(new GetAlbumRequest { AlbumId = albumId }); + albumInfo.Title.ShouldBe("Edited Title"); + albumInfo.Description.ShouldBe("Edited description"); + albumInfo.Files.Length.ShouldBe(1); + albumInfo.Files.ShouldContain(uploadedFileNames[0]); + await TestContext.Out.WriteLineAsync($"Verified album after edit: {albumInfo.Title}, {albumInfo.Files.Length} file(s)"); + } } From da66c808473f49a73ca897c005db1072e5d42927 Mon Sep 17 00:00:00 2001 From: Chase Redmon Date: Thu, 19 Feb 2026 14:08:50 -0500 Subject: [PATCH 8/8] Convert sample program to include a menu with api actions --- samples/SampleApp/Program.cs | 669 +++++++++++++++++++++++++++++------ 1 file changed, 551 insertions(+), 118 deletions(-) diff --git a/samples/SampleApp/Program.cs b/samples/SampleApp/Program.cs index d0a6169..50ca235 100644 --- a/samples/SampleApp/Program.cs +++ b/samples/SampleApp/Program.cs @@ -1,37 +1,31 @@ using CatBox.NET; using CatBox.NET.Client; using CatBox.NET.Enums; +using CatBox.NET.Requests.Album; using CatBox.NET.Requests.Album.Create; using CatBox.NET.Requests.Album.Modify; using CatBox.NET.Requests.File; +using CatBox.NET.Requests.Litterbox; +using CatBox.NET.Requests.URL; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -// Load configuration from user secrets +// Load configuration from user secrets (optional — anonymous uploads work without it) // To set your UserHash, run: dotnet user-secrets set "CatBox:UserHash" "your-hash-here" --project samples/SampleApp // Get your UserHash from: https://catbox.moe/user/manage.php -var configuration = new ConfigurationBuilder() - .AddUserSecrets() - .Build(); - -var userHash = configuration["CatBox:UserHash"] - ?? throw new InvalidOperationException( - "CatBox UserHash not configured. Run: dotnet user-secrets set \"CatBox:UserHash\" \"your-hash-here\" --project samples/SampleApp"); - -// Compute path to test image file within the project directory -string testImagePath = Path.Combine( - AppDomain.CurrentDomain.BaseDirectory, - "..", "..", "..", "..", "..", // Navigate up from bin/Debug/net7.0 - "tests", "CatBox.Tests", "Images", "test-file.png" -); -testImagePath = Path.GetFullPath(testImagePath); // Normalize the path -string testImageFileName = Path.GetFileName(testImagePath); +string? userHash; +try +{ + var configuration = new ConfigurationBuilder() + .AddUserSecrets() + .Build(); -// Verify the test image file exists -if (!File.Exists(testImagePath)) + userHash = configuration["CatBox:UserHash"]; +} +catch { - throw new FileNotFoundException($"Test image not found at: {testImagePath}"); + userHash = null; } var collection = new ServiceCollection() @@ -40,152 +34,591 @@ f.CatBoxUrl = new Uri("https://catbox.moe/user/api.php"); f.LitterboxUrl = new Uri("https://litterbox.catbox.moe/resources/internals/api.php"); }) - .AddLogging(f => f.AddConsole()) + .AddLogging(f => f.AddConsole().SetMinimumLevel(LogLevel.Warning)) .BuildServiceProvider(); -// Store uploaded file URLs and album URL for cleanup -var uploadedFiles = new List(); -string? albumUrl = null; +// Track uploaded file URLs and album URLs across operations +var trackedFiles = new List(); +var trackedAlbums = new List(); + +Console.WriteLine("=== CatBox.NET Interactive Sample ==="); +if (string.IsNullOrWhiteSpace(userHash)) + Console.WriteLine("[Warning] UserHash not configured. Some operations will be unavailable."); +else + Console.WriteLine($"[OK] UserHash configured."); +Console.WriteLine(); + +while (true) +{ + PrintMenu(); + Console.Write("> "); + var input = Console.ReadLine()?.Trim(); + + if (!int.TryParse(input, out var choice)) + { + Console.WriteLine("Invalid input. Enter a number.\n"); + continue; + } + + try + { + switch (choice) + { + case 0: + Console.WriteLine("Goodbye!"); + return; + case 1: + await UploadFilesFromDisk(); + break; + case 2: + await UploadFilesAsStream(); + break; + case 3: + await UploadFilesByUrl(); + break; + case 4: + await DeleteFiles(); + break; + case 5: + await CreateAlbumFromTracked(); + break; + case 6: + await CreateAlbumAndUpload(); + break; + case 7: + await EditAlbum(); + break; + case 8: + await ModifyAlbum(); + break; + case 9: + await GetAlbumInfo(); + break; + case 10: + await DownloadFile(); + break; + case 11: + await DownloadAlbum(); + break; + case 12: + await LitterboxUploadFromDisk(); + break; + case 13: + await LitterboxUploadAsStream(); + break; + case 14: + ShowTrackedItems(); + break; + default: + Console.WriteLine("Unknown option.\n"); + break; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}\n"); + } +} + +// --- Helpers --- + +static void PrintMenu() +{ + Console.WriteLine(""" + --- File Operations --- + [1] Upload file(s) from disk + [2] Upload file(s) as stream + [3] Upload file(s) by URL + [4] Delete file(s) + + --- Album Operations --- + [5] Create album (from tracked files) + [6] Create album + upload files (one step) + [7] Edit album + [8] Add/Remove files or Delete album + [9] Get album info -// Upload a single image via stream -using (var scope = collection.CreateScope()) + --- Download --- + [10] Download a file + [11] Download entire album + + --- Litterbox (Temporary) --- + [12] Upload temporary file(s) from disk + [13] Upload temporary file as stream + + --- Tracked Items --- + [14] Show tracked files & albums + [0] Exit + + """); +} + +static string ExtractFileName(string url) +{ + if (url.Contains("files.catbox.moe")) + return url.Split('/')[^1]; + return url; +} + +static string ExtractAlbumShortCode(string url) +{ + if (url.Contains("catbox.moe/c/")) + return url.Split('/')[^1]; + return url; +} + +static ExpireAfter PromptExpiry() +{ + Console.WriteLine("Select expiry:"); + Console.WriteLine(" [1] 1 hour"); + Console.WriteLine(" [2] 12 hours"); + Console.WriteLine(" [3] 24 hours (1 day)"); + Console.WriteLine(" [4] 72 hours (3 days)"); + Console.Write("Choice: "); + var expiryInput = Console.ReadLine()?.Trim(); + return expiryInput switch + { + "2" => ExpireAfter.TwelveHours, + "3" => ExpireAfter.OneDay, + "4" => ExpireAfter.ThreeDays, + _ => ExpireAfter.OneHour, + }; +} + +bool RequireUserHash() { + if (!string.IsNullOrWhiteSpace(userHash)) + return true; + + Console.WriteLine("This operation requires a UserHash. Configure one first."); + Console.WriteLine("Run: dotnet user-secrets set \"CatBox:UserHash\" \"your-hash-here\" --project samples/SampleApp\n"); + return false; +} + +// --- Operations --- + +async Task UploadFilesFromDisk() +{ + Console.Write("Enter file path(s) (comma-separated): "); + var paths = Console.ReadLine()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (paths is null || paths.Length == 0) return; + + var files = paths.Select(p => new FileInfo(Path.GetFullPath(p))).ToList(); + foreach (var f in files.Where(f => !f.Exists)) + Console.WriteLine($" Warning: file not found: {f.FullName}"); + + using var scope = collection.CreateScope(); var client = scope.ServiceProvider.GetRequiredService(); - var responses = client.UploadFilesAsStreamAsync([new StreamUploadRequest + var request = new FileUploadRequest { Files = files, UserHash = userHash }; + + await foreach (var result in client.UploadFilesAsync(request)) { - Stream = File.OpenRead(testImagePath), - FileName = testImageFileName, - UserHash = userHash - }]); + Console.WriteLine($" Uploaded: {result}"); + if (!string.IsNullOrWhiteSpace(result)) + trackedFiles.Add(result); + } + Console.WriteLine(); +} + +async Task UploadFilesAsStream() +{ + Console.Write("Enter file path: "); + var path = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(path)) return; + path = Path.GetFullPath(path); + + if (!File.Exists(path)) + { + Console.WriteLine($" File not found: {path}\n"); + return; + } + + using var scope = collection.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + var stream = File.OpenRead(path); - await foreach (var response in responses) + var requests = new[] { - Console.WriteLine(response); - if (!string.IsNullOrWhiteSpace(response)) - uploadedFiles.Add(response); + new StreamUploadRequest + { + Stream = stream, + FileName = Path.GetFileName(path), + UserHash = userHash + } + }; + + await foreach (var result in client.UploadFilesAsStreamAsync(requests)) + { + Console.WriteLine($" Uploaded: {result}"); + if (!string.IsNullOrWhiteSpace(result)) + trackedFiles.Add(result); } + Console.WriteLine(); } -// Create an album of images already on Catbox -using (var scope = collection.CreateScope()) +async Task UploadFilesByUrl() { + Console.Write("Enter URL(s) (comma-separated): "); + var urls = Console.ReadLine()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (urls is null || urls.Length == 0) return; + + using var scope = collection.CreateScope(); var client = scope.ServiceProvider.GetRequiredService(); - var response = await client.CreateAlbumAsync(new RemoteCreateAlbumRequest + var request = new UrlUploadRequest { - Title = "Album Title", - Description = "Album Description", - Files = uploadedFiles, // Use the actual uploaded file(s) from previous step + Files = urls.Select(u => new Uri(u)), UserHash = userHash - }); + }; - Console.WriteLine(response); - albumUrl = response; + await foreach (var result in client.UploadFilesAsUrlAsync(request)) + { + Console.WriteLine($" Uploaded: {result}"); + if (!string.IsNullOrWhiteSpace(result)) + trackedFiles.Add(result); + } + Console.WriteLine(); } -// Cleanup: Delete the album and uploaded files -using (var scope = collection.CreateScope()) +async Task DeleteFiles() { + if (!RequireUserHash()) return; + + if (trackedFiles.Count > 0) + { + Console.WriteLine("Tracked files:"); + for (var i = 0; i < trackedFiles.Count; i++) + Console.WriteLine($" [{i}] {trackedFiles[i]}"); + } + + Console.Write("Enter CatBox file name(s) to delete (comma-separated, e.g., abc123.png): "); + var names = Console.ReadLine()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (names is null || names.Length == 0) return; + + var fileNames = names.Select(ExtractFileName).ToList(); + + using var scope = collection.CreateScope(); var client = scope.ServiceProvider.GetRequiredService(); - await CleanupAsync(client, albumUrl, uploadedFiles, userHash); + var result = await client.DeleteMultipleFilesAsync(new DeleteFileRequest + { + UserHash = userHash!, + FileNames = fileNames + }); + + Console.WriteLine($" Delete result: {result}"); + + // Remove deleted files from tracking + trackedFiles.RemoveAll(f => fileNames.Contains(ExtractFileName(f))); + Console.WriteLine(); } -return; +async Task CreateAlbumFromTracked() +{ + if (trackedFiles.Count == 0) + { + Console.WriteLine("No tracked files. Upload some files first.\n"); + return; + } + + Console.WriteLine("Tracked files:"); + for (var i = 0; i < trackedFiles.Count; i++) + Console.WriteLine($" [{i}] {trackedFiles[i]}"); + + Console.Write("Enter indices to include (comma-separated, or 'all'): "); + var selection = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(selection)) return; + + List selectedFiles; + if (selection.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + selectedFiles = trackedFiles.ToList(); + } + else + { + var indices = selection.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(int.Parse) + .Where(i => i >= 0 && i < trackedFiles.Count); + selectedFiles = indices.Select(i => trackedFiles[i]).ToList(); + } + + if (selectedFiles.Count == 0) + { + Console.WriteLine("No valid files selected.\n"); + return; + } + + Console.Write("Album title: "); + var title = Console.ReadLine()?.Trim() ?? "Untitled"; + Console.Write("Album description (optional): "); + var description = Console.ReadLine()?.Trim(); + + // Extract just the file names from URLs + var fileNames = selectedFiles.Select(ExtractFileName).ToList(); + + using var scope = collection.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + var result = await client.CreateAlbumAsync(new RemoteCreateAlbumRequest + { + Title = title, + Description = description, + UserHash = userHash, + Files = fileNames + }); + + Console.WriteLine($" Album created: {result}"); + if (!string.IsNullOrWhiteSpace(result)) + trackedAlbums.Add(result); + Console.WriteLine(); +} -/*// Upload images to CatBox, then create an album on CatBox, then place the uploaded images into the newly created album -using (var scope = collection.CreateScope()) +async Task CreateAlbumAndUpload() { + Console.Write("Enter file path(s) to upload (comma-separated): "); + var paths = Console.ReadLine()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (paths is null || paths.Length == 0) return; + + Console.Write("Album title: "); + var title = Console.ReadLine()?.Trim() ?? "Untitled"; + Console.Write("Album description (optional): "); + var description = Console.ReadLine()?.Trim(); + + var files = paths.Select(p => new FileInfo(Path.GetFullPath(p))).ToList(); + + using var scope = collection.CreateScope(); var client = scope.ServiceProvider.GetRequiredService(); - var response = await client.CreateAlbumFromFilesAsync(new CreateAlbumRequest + var result = await client.CreateAlbumFromFilesAsync(new CreateAlbumRequest { - Title = "Album Title", - Description = "Album Description", - UserHash = null, - UploadRequest = new FileUploadRequest - { - Files = [new FileInfo(testImagePath)] - } + Title = title, + Description = description, + UserHash = userHash, + UploadRequest = new FileUploadRequest { Files = files, UserHash = userHash } }); - Console.WriteLine(response); + Console.WriteLine($" Album created: {result}"); + if (!string.IsNullOrWhiteSpace(result)) + trackedAlbums.Add(result); + Console.WriteLine(); } -// Temporarily upload an image to litterbox -using (var scope = collection.CreateScope()) +async Task EditAlbum() { - var client = scope.ServiceProvider.GetRequiredService(); - var response = await client.UploadImageAsync(new TemporaryStreamUploadRequest + if (!RequireUserHash()) return; + + Console.Write("Enter album ID or URL: "); + var albumInput = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(albumInput)) return; + var albumId = ExtractAlbumShortCode(albumInput); + + Console.Write("New title: "); + var title = Console.ReadLine()?.Trim() ?? "Untitled"; + Console.Write("New description: "); + var description = Console.ReadLine()?.Trim() ?? ""; + + Console.Write("Enter file name(s) for the album (comma-separated): "); + var fileInput = Console.ReadLine()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (fileInput is null || fileInput.Length == 0) + { + Console.WriteLine("No files specified.\n"); + return; + } + + var fileNames = fileInput.Select(ExtractFileName).ToList(); + + using var scope = collection.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + +#pragma warning disable CS0618 // EditAlbumAsync is marked Obsolete as a safety warning + var result = await client.EditAlbumAsync(new EditAlbumRequest + { + UserHash = userHash!, + AlbumId = albumId, + Title = title, + Description = description, + Files = fileNames + }); +#pragma warning restore CS0618 + + Console.WriteLine($" Edit result: {result}\n"); +} + +async Task ModifyAlbum() +{ + if (!RequireUserHash()) return; + + Console.WriteLine(" [1] Add files to album"); + Console.WriteLine(" [2] Remove files from album"); + Console.WriteLine(" [3] Delete album"); + Console.Write("Choice: "); + var subChoice = Console.ReadLine()?.Trim(); + + Console.Write("Enter album ID or URL: "); + var albumInput = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(albumInput)) return; + var albumId = ExtractAlbumShortCode(albumInput); + + RequestType requestType; + List fileNames = []; + + switch (subChoice) { - Expiry = ExpireAfter.OneHour, - FileName = testImageFileName, - Stream = File.OpenRead(testImagePath) + case "1": + requestType = RequestType.AddToAlbum; + Console.Write("Enter file name(s) to add (comma-separated): "); + var addInput = Console.ReadLine()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (addInput is null || addInput.Length == 0) return; + fileNames = addInput.Select(ExtractFileName).ToList(); + break; + case "2": + requestType = RequestType.RemoveFromAlbum; + Console.Write("Enter file name(s) to remove (comma-separated): "); + var removeInput = Console.ReadLine()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (removeInput is null || removeInput.Length == 0) return; + fileNames = removeInput.Select(ExtractFileName).ToList(); + break; + case "3": + requestType = RequestType.DeleteAlbum; + break; + default: + Console.WriteLine("Invalid choice.\n"); + return; + } + + using var scope = collection.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + var result = await client.ModifyAlbumAsync(new ModifyAlbumImagesRequest + { + Request = requestType, + UserHash = userHash!, + AlbumId = albumId, + Files = fileNames }); - Console.WriteLine(response); + Console.WriteLine($" Result: {result}"); + if (requestType == RequestType.DeleteAlbum) + trackedAlbums.RemoveAll(a => ExtractAlbumShortCode(a) == albumId); Console.WriteLine(); } -Console.ReadLine();*/ +async Task GetAlbumInfo() +{ + Console.Write("Enter album ID or URL: "); + var albumInput = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(albumInput)) return; + var albumId = ExtractAlbumShortCode(albumInput); + + using var scope = collection.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + var info = await client.GetAlbumAsync(albumId); -// Cleanup method to delete album and uploaded files -static async Task CleanupAsync(ICatBoxClient client, string? albumUrl, List uploadedFiles, string userHash) + Console.WriteLine($" Title: {info.Title}"); + Console.WriteLine($" Description: {info.Description}"); + Console.WriteLine($" Album ID: {info.AlbumId}"); + Console.WriteLine($" Created: {info.DateCreated}"); + Console.WriteLine($" Files ({info.Files.Length}):"); + foreach (var file in info.Files) + Console.WriteLine($" - {file}"); + Console.WriteLine(); +} + +async Task DownloadFile() { - Console.WriteLine("\n--- Starting Cleanup ---"); + Console.Write("Enter CatBox file name (e.g., abc123.png): "); + var fileName = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(fileName)) return; + fileName = ExtractFileName(fileName); + + Console.Write("Enter destination directory: "); + var destDir = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(destDir)) return; + + using var scope = collection.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + await client.DownloadFileAsync(fileName, destDir); + + var filePath = Path.Combine(destDir, fileName); + Console.WriteLine($" Downloaded: {filePath}\n"); +} + +async Task DownloadAlbum() +{ + Console.Write("Enter album ID or URL: "); + var albumInput = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(albumInput)) return; + var albumId = ExtractAlbumShortCode(albumInput); + + Console.Write("Enter destination directory: "); + var destDir = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(destDir)) return; - // Delete the album first - if (!string.IsNullOrWhiteSpace(albumUrl)) + using var scope = collection.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + Console.WriteLine(" Downloading album files..."); + await foreach (var fileInfo in client.DownloadAlbumAsync(albumId, destDir)) { - try - { - // Extract album short ID from URL (e.g., "pd412w" from "https://catbox.moe/c/pd412w") - var albumUri = new Uri(albumUrl); - var albumId = albumUri.AbsolutePath.TrimStart('/').Replace("c/", ""); + Console.WriteLine($" Downloaded: {fileInfo.FullName}"); + } + Console.WriteLine(); +} - Console.WriteLine($"Deleting album: {albumId}"); +async Task LitterboxUploadFromDisk() +{ + Console.Write("Enter file path(s) (comma-separated): "); + var paths = Console.ReadLine()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (paths is null || paths.Length == 0) return; - var albumDeleteResponse = await client.ModifyAlbumAsync(new ModifyAlbumImagesRequest - { - Request = RequestType.DeleteAlbum, - UserHash = userHash, - AlbumId = albumId, - Files = [] // Empty for DeleteAlbum operation - }); + var expiry = PromptExpiry(); + var files = paths.Select(p => new FileInfo(Path.GetFullPath(p))).ToList(); - Console.WriteLine($"Album deletion response: {albumDeleteResponse}"); - } - catch (Exception ex) - { - Console.WriteLine($"Error deleting album: {ex.Message}"); - } + using var scope = collection.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + await foreach (var result in client.UploadMultipleImagesAsync(new TemporaryFileUploadRequest + { + Files = files, + Expiry = expiry + })) + { + Console.WriteLine($" Uploaded (temporary): {result}"); } + Console.WriteLine(); +} - // Delete the uploaded files - if (uploadedFiles.Count > 0) +async Task LitterboxUploadAsStream() +{ + Console.Write("Enter file path: "); + var path = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(path)) return; + path = Path.GetFullPath(path); + if (!File.Exists(path)) { - try - { - // Extract filenames from URLs (e.g., "8ce67f.jpg" from "https://files.catbox.moe/8ce67f.jpg") - var fileNames = uploadedFiles.Select(url => - { - var uri = new Uri(url); - return Path.GetFileName(uri.AbsolutePath); - }).ToList(); - - Console.WriteLine($"Deleting {fileNames.Count} file(s): {string.Join(", ", fileNames)}"); - - var fileDeleteResponse = await client.DeleteMultipleFilesAsync(new DeleteFileRequest - { - UserHash = userHash, - FileNames = fileNames - }); - - Console.WriteLine($"File deletion response: {fileDeleteResponse}"); - } - catch (Exception ex) - { - Console.WriteLine($"Error deleting files: {ex.Message}"); - } + Console.WriteLine(" File not found.\n"); + return; } - Console.WriteLine("--- Cleanup Complete ---\n"); + var expiry = PromptExpiry(); + + using var scope = collection.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + var result = await client.UploadImageAsync(new TemporaryStreamUploadRequest + { + FileName = Path.GetFileName(path), + Stream = File.OpenRead(path), + Expiry = expiry + }); + + Console.WriteLine($" Uploaded (temporary): {result}\n"); +} + +void ShowTrackedItems() +{ + Console.WriteLine($"--- Tracked Files ({trackedFiles.Count}) ---"); + for (var i = 0; i < trackedFiles.Count; i++) + Console.WriteLine($" [{i}] {trackedFiles[i]}"); + + Console.WriteLine($"--- Tracked Albums ({trackedAlbums.Count}) ---"); + for (var i = 0; i < trackedAlbums.Count; i++) + Console.WriteLine($" [{i}] {trackedAlbums[i]}"); + + Console.WriteLine(); }