diff --git a/src/AStar.Dev.FunctionalParadigm/Exceptional{T}.cs b/src/AStar.Dev.FunctionalParadigm/Exceptional{T}.cs
index 5bfa75c..653410c 100644
--- a/src/AStar.Dev.FunctionalParadigm/Exceptional{T}.cs
+++ b/src/AStar.Dev.FunctionalParadigm/Exceptional{T}.cs
@@ -8,6 +8,14 @@ namespace AStar.Dev.FunctionalParadigm;
/// The type of the success value.
public abstract record Exceptional
{
+ ///
+ /// Restricts derivation of to and
+ /// , both declared in this assembly.
+ ///
+ private protected Exceptional()
+ {
+ }
+
///
/// Implicitly lifts a success value into an .
///
diff --git a/src/AStar.Dev.FunctionalParadigm/Result{TResult,TError}.cs b/src/AStar.Dev.FunctionalParadigm/Result{TResult,TError}.cs
index 7006ad5..7e71b1a 100644
--- a/src/AStar.Dev.FunctionalParadigm/Result{TResult,TError}.cs
+++ b/src/AStar.Dev.FunctionalParadigm/Result{TResult,TError}.cs
@@ -9,6 +9,14 @@ namespace AStar.Dev.FunctionalParadigm;
/// The type of the failure error.
public abstract record Result
{
+ ///
+ /// Restricts derivation of to and
+ /// , both declared in this assembly.
+ ///
+ private protected Result()
+ {
+ }
+
///
/// Implicitly lifts a success value into a .
///
diff --git a/src/AStar.Dev.FunctionalParadigm/RetryExtensions.cs b/src/AStar.Dev.FunctionalParadigm/RetryExtensions.cs
new file mode 100644
index 0000000..a64232e
--- /dev/null
+++ b/src/AStar.Dev.FunctionalParadigm/RetryExtensions.cs
@@ -0,0 +1,31 @@
+namespace AStar.Dev.FunctionalParadigm;
+
+///
+/// Retry helpers for -returning operations.
+///
+public static class RetryExtensions
+{
+ ///
+ /// Runs once. If it fails, runs and then runs
+ /// a second and final time, returning whichever attempt's result is current
+ /// at that point (the second attempt's result when both fail).
+ ///
+ /// The operation to run, at most twice.
+ /// The side effect (typically a delay) to run between the first and second attempts.
+ public static async Task> RetryOnceAsync(Func>> operation, Func onRetry)
+ {
+ ArgumentNullException.ThrowIfNull(operation);
+ ArgumentNullException.ThrowIfNull(onRetry);
+
+ var firstAttempt = await operation().ConfigureAwait(false);
+
+ return await firstAttempt.MatchAsync(
+ value => Task.FromResult(Result.Success(value)),
+ async _ =>
+ {
+ await onRetry().ConfigureAwait(false);
+
+ return await operation().ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs b/src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs
index 30dfbed..2167ade 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs
@@ -98,14 +98,14 @@ public override void OnFrameworkInitializationCompleted()
.AddTransient()
.AddSingleton()
.AddSingleton()
- .AddTransient()
- .AddHttpClient(client => client.Timeout = TimeSpan.FromMinutes(2))
- .Services
- .AddTransient(_ => TimeProvider.System)
- .AddTransient>(sp => () => sp.GetRequiredService())
- .AddTransient>(sp => () => sp.GetRequiredService())
.AddTransient>(sp => () => sp.GetRequiredService())
- .AddTransient();
+ .AddTransient>(sp => () => sp.GetRequiredService())
+ .AddTransient()
+ .AddTransient>(sp => () => sp.GetRequiredService())
+ .AddTransient(_ => TimeProvider.System)
+ .AddTransient()
+ .AddTransient()
+ .AddHttpClient(client => client.Timeout = TimeSpan.FromMinutes(2));
_host = builder.Build();
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Classifications/ClassificationsView.axaml.cs b/src/AStar.Dev.Wallpaper.Scrapper/Classifications/ClassificationsView.axaml.cs
index ecd7afc..ee11cbd 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Classifications/ClassificationsView.axaml.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Classifications/ClassificationsView.axaml.cs
@@ -1,4 +1,5 @@
using AStar.Dev.FunctionalParadigm;
+using AStar.Dev.Wallpaper.Scrapper.Models;
using AStar.Dev.Wallpaper.Scrapper.Services;
using AStar.Dev.Wallpaper.Scrapper.Support;
using Avalonia.Controls;
@@ -55,7 +56,7 @@ private async void OnImportClicked(object? sender, RoutedEventArgs e)
}
)
.Tap(_ => logger.Information("Importing classifications..."))
- .Bind(_ => importExportService.ImportFileClassificationsFromFile())
+ .Bind(_ => importExportService.ImportFileClassificationsFromFile().ToStringError())
.MapAsync(classifications => fileClassificationService.ImportClassificationsAsync(classifications, cts!.Token))
.TapAsync(_ => logger.Information("Import completed..."))
.EnsureAsync(() => ResetUI());
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/DTOs/FileClassificationDtoValidator.cs b/src/AStar.Dev.Wallpaper.Scrapper/DTOs/FileClassificationDtoValidator.cs
new file mode 100644
index 0000000..7bbb818
--- /dev/null
+++ b/src/AStar.Dev.Wallpaper.Scrapper/DTOs/FileClassificationDtoValidator.cs
@@ -0,0 +1,41 @@
+using AStar.Dev.FunctionalParadigm;
+using AStar.Dev.Guard.Clauses;
+
+namespace AStar.Dev.Wallpaper.Scrapper.DTOs;
+
+/// Validates an imported row, accumulating every problem found instead of stopping at the first.
+public static class FileClassificationDtoValidator
+{
+ private const int MinimumLevel = 1;
+ private const int MaximumLevel = 3;
+
+ /// Validates , tagging any errors with its for reporting.
+ public static Validation Validate(FileClassification dto, int rowIndex)
+ {
+ GuardAgainst.Null(dto);
+
+ Validation>> constructor = Validation.Valid>>(_ => _ => dto);
+
+ return constructor
+ .Apply(ValidateName(dto, rowIndex))
+ .Apply(ValidateLevel(dto, rowIndex));
+ }
+
+ /// Validates every row in , accumulating every invalid row's errors, in row order.
+ public static Validation> ValidateAll(IReadOnlyList dtos)
+ {
+ GuardAgainst.Null(dtos);
+
+ return dtos.Select(Validate).Combine();
+ }
+
+ private static Validation ValidateName(FileClassification dto, int rowIndex)
+ => string.IsNullOrWhiteSpace(dto.Name)
+ ? Validation.Invalid(ValidationErrorFactory.Create($"Categories[{rowIndex}].Name", "Name is required."))
+ : Validation.Valid(dto.Name);
+
+ private static Validation ValidateLevel(FileClassification dto, int rowIndex)
+ => dto.Level is < MinimumLevel or > MaximumLevel
+ ? Validation.Invalid(ValidationErrorFactory.Create($"Categories[{rowIndex}].Level", $"Level must be between {MinimumLevel} and {MaximumLevel}."))
+ : Validation.Valid(dto.Level);
+}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml.cs b/src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml.cs
index 76426a4..a6e653c 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml.cs
@@ -1,6 +1,7 @@
using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Wallpaper.Scrapper.Classifications;
using AStar.Dev.Wallpaper.Scrapper.Dialogs;
+using AStar.Dev.Wallpaper.Scrapper.Models;
using AStar.Dev.Wallpaper.Scrapper.ScrapeConfigurationEditor;
using AStar.Dev.Wallpaper.Scrapper.Services;
using AStar.Dev.Wallpaper.Scrapper.Support;
@@ -146,8 +147,9 @@ private Task> RunScrapeWorkflowAsync(CancellationToken ct)
logger.Information("Configuring Playwright...");
logger.Information("Starting scrape...");
- return searchWorkflow.RunAsync(logger, ct)
- .TapAsync(_ => logger.Information("Scrape completed..."));
+ return searchWorkflow.RunAsync(ct)
+ .TapAsync(_ => logger.Information("Scrape completed..."))
+ .ToStringError();
}
private void ResetUI()
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Models/ImagePageOutcome.cs b/src/AStar.Dev.Wallpaper.Scrapper/Models/ImagePageOutcome.cs
new file mode 100644
index 0000000..e8873ea
--- /dev/null
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Models/ImagePageOutcome.cs
@@ -0,0 +1,19 @@
+using AStar.Dev.Wallpaper.Scrapper.Repositories;
+
+namespace AStar.Dev.Wallpaper.Scrapper.Models;
+
+/// The outcome of scraping an image page, replacing the historical ImagePageResult.
+public abstract record ImagePageOutcome;
+
+/// The image page's tags were accepted; the image should be downloaded and saved.
+/// The URL of the image to download.
+/// The directory segments the image should be saved under.
+/// The prefix to apply to the saved file name.
+/// The tag text scraped from the image page.
+/// The raw tag data scraped from the image page.
+public sealed record ScrapedImage(string ImageUrl, IReadOnlyList DirectorySegments, string FilePrefix, IReadOnlyList Tags, IReadOnlyList RawTags) : ImagePageOutcome;
+
+/// The image page's tags matched an ignore rule; the image should not be downloaded.
+/// The tag text scraped from the image page before the skip was detected.
+/// The raw tag data scraped from the image page before the skip was detected.
+public sealed record SkippedImage(IReadOnlyList Tags, IReadOnlyList RawTags) : ImagePageOutcome;
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Models/ImagePageOutcomeFactory.cs b/src/AStar.Dev.Wallpaper.Scrapper/Models/ImagePageOutcomeFactory.cs
new file mode 100644
index 0000000..425096b
--- /dev/null
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Models/ImagePageOutcomeFactory.cs
@@ -0,0 +1,29 @@
+using AStar.Dev.Guard.Clauses;
+using AStar.Dev.Wallpaper.Scrapper.Repositories;
+
+namespace AStar.Dev.Wallpaper.Scrapper.Models;
+
+/// Factory methods for creating instances of the discriminated union.
+public static class ImagePageOutcomeFactory
+{
+ /// Creates a outcome.
+ public static ScrapedImage CreateScrapedImage(string imageUrl, IReadOnlyList directorySegments, string filePrefix, IReadOnlyList tags, IReadOnlyList rawTags)
+ {
+ GuardAgainst.Null(imageUrl);
+ GuardAgainst.Null(directorySegments);
+ GuardAgainst.Null(filePrefix);
+ GuardAgainst.Null(tags);
+ GuardAgainst.Null(rawTags);
+
+ return new(imageUrl, directorySegments, filePrefix, tags, rawTags);
+ }
+
+ /// Creates a outcome.
+ public static SkippedImage CreateSkippedImage(IReadOnlyList tags, IReadOnlyList rawTags)
+ {
+ GuardAgainst.Null(tags);
+ GuardAgainst.Null(rawTags);
+
+ return new(tags, rawTags);
+ }
+}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Models/ImagePageResult.cs b/src/AStar.Dev.Wallpaper.Scrapper/Models/ImagePageResult.cs
deleted file mode 100644
index 90f7d11..0000000
--- a/src/AStar.Dev.Wallpaper.Scrapper/Models/ImagePageResult.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace AStar.Dev.Wallpaper.Scrapper.Models;
-
-public record ImagePageResult(string? ImageUrl, List DirectoryName, string FilePrefix, bool Skip, IReadOnlyList Tags);
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeError.cs b/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeError.cs
index 1fe4332..2a204e8 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeError.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeError.cs
@@ -1,3 +1,5 @@
+using AStar.Dev.FunctionalParadigm;
+
namespace AStar.Dev.Wallpaper.Scrapper.Models;
/// Base type for every error that a scrape pipeline operation can fail with.
@@ -36,3 +38,18 @@ public sealed record ClassificationFailed(string FileName, string Message) : Scr
/// An unanticipated exception was raised while running the scrape pipeline.
/// The exception that was raised.
public sealed record UnexpectedError(Exception Exception) : ScrapeError(Exception.Message);
+
+/// The dimensions of a downloaded image could not be read.
+/// The path of the image whose dimensions failed to read.
+/// A human-readable description of the failure.
+public sealed record ImageDimensionReadFailed(string Path, string Message) : ScrapeError(Message);
+
+/// An export file could not be imported.
+/// The path of the file that failed to import.
+/// A human-readable description of the failure.
+public sealed record ImportFailed(string FilePath, string Message) : ScrapeError(Message);
+
+/// An imported payload failed validation.
+/// The accumulated validation errors.
+/// A human-readable description of the failure.
+public sealed record ValidationFailed(IReadOnlyList Errors, string Message) : ScrapeError(Message);
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeErrorFactory.cs b/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeErrorFactory.cs
index 8191d23..f0e4488 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeErrorFactory.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeErrorFactory.cs
@@ -1,3 +1,4 @@
+using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Guard.Clauses;
namespace AStar.Dev.Wallpaper.Scrapper.Models;
@@ -64,4 +65,31 @@ public static UnexpectedError CreateUnexpectedError(Exception exception)
return new(exception);
}
+
+ /// Creates an error.
+ public static ImageDimensionReadFailed CreateImageDimensionReadFailed(string path, string message)
+ {
+ GuardAgainst.Null(path);
+ GuardAgainst.Null(message);
+
+ return new(path, message);
+ }
+
+ /// Creates an error.
+ public static ImportFailed CreateImportFailed(string filePath, string message)
+ {
+ GuardAgainst.Null(filePath);
+ GuardAgainst.Null(message);
+
+ return new(filePath, message);
+ }
+
+ /// Creates a error.
+ public static ValidationFailed CreateValidationFailed(IReadOnlyList errors, string message)
+ {
+ GuardAgainst.Null(errors);
+ GuardAgainst.Null(message);
+
+ return new(errors, message);
+ }
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeErrorLoggingExtensions.cs b/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeErrorLoggingExtensions.cs
index fd49d78..f865087 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeErrorLoggingExtensions.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeErrorLoggingExtensions.cs
@@ -8,5 +8,18 @@ public static class ScrapeErrorLoggingExtensions
{
/// Logs an error when is a failure, then returns unchanged.
public static Result LogFailure(this Result result, ILogger logger)
- => result.Tap(_ => { }, error => logger.Error("Scrape pipeline failure: {Message}", error.Message));
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ return result.Tap(_ => { }, error => logger.Error("Scrape pipeline failure: {Message}", error.Message));
+ }
+
+ /// Logs an error when the awaited result is a failure, then returns the result unchanged.
+ public static Task> LogFailure(this Task> resultTask, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(resultTask);
+
+ return resultTask.TapAsync(_ => { }, error => logger.Error("Scrape pipeline failure: {Message}", error.Message));
+ }
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeErrorResultExtensions.cs b/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeErrorResultExtensions.cs
new file mode 100644
index 0000000..2f6691e
--- /dev/null
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeErrorResultExtensions.cs
@@ -0,0 +1,26 @@
+using AStar.Dev.FunctionalParadigm;
+
+namespace AStar.Dev.Wallpaper.Scrapper.Models;
+
+///
+/// Bridges pipelines into call sites that have not yet migrated off
+/// string-typed errors, by mapping the down to its .
+///
+public static class ScrapeErrorResultExtensions
+{
+ /// Maps a failure down to its message, preserving a success value unchanged.
+ public static Result ToStringError(this Result result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+
+ return result.Match(value => Result.Success(value), error => Result.Failure(error.Message));
+ }
+
+ /// Maps a failure down to its message, preserving a success value unchanged.
+ public static async Task> ToStringError(this Task> resultTask)
+ {
+ ArgumentNullException.ThrowIfNull(resultTask);
+
+ return (await resultTask.ConfigureAwait(false)).ToStringError();
+ }
+}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Pages/ITopWallpapersPage.cs b/src/AStar.Dev.Wallpaper.Scrapper/Pages/ITopWallpapersPage.cs
index 234ca1a..02825e7 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Pages/ITopWallpapersPage.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Pages/ITopWallpapersPage.cs
@@ -1,12 +1,13 @@
-using Microsoft.Playwright;
+using AStar.Dev.FunctionalParadigm;
+using AStar.Dev.Wallpaper.Scrapper.Models;
namespace AStar.Dev.Wallpaper.Scrapper.Pages;
public interface ITopWallpapersPage
{
- Task LoadTopWallpapersPageAsync(int pageNumber);
+ Task> LoadTopWallpapersPageAsync(int pageNumber);
- Task PageInfoAsync();
+ Task> PageInfoAsync();
- Task> GetImagePageLinksAsync();
+ Task, ScrapeError>> GetImagePageLinksAsync();
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Pages/ImagePage.cs b/src/AStar.Dev.Wallpaper.Scrapper/Pages/ImagePage.cs
index 3d9ea0a..d04cc18 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Pages/ImagePage.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Pages/ImagePage.cs
@@ -1,3 +1,4 @@
+using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Utilities;
using AStar.Dev.Wallpaper.Scrapper.DTOs;
using AStar.Dev.Wallpaper.Scrapper.Models;
@@ -8,33 +9,35 @@
namespace AStar.Dev.Wallpaper.Scrapper.Pages;
-public sealed class ImagePage(IPlaywrightService playwrightService, ScrapeConfiguration scrapeConfiguration, TagsToIgnoreCompletely tagsToIgnoreCompletely, TagsTextToIgnore tagsTextToIgnore, IScrapedTagRepository scrapedTagRepository)
+public sealed class ImagePage(IPlaywrightService playwrightService, ScrapeConfiguration scrapeConfiguration, TagsToIgnoreCompletely tagsToIgnoreCompletely, TagsTextToIgnore tagsTextToIgnore)
{
private IPage page = null!;
- public async Task GetImageFromPageAsync(string link, string categoryName)
- {
- page ??= await playwrightService.ConfigurePlaywrightAsync();
- _ = await page.GotoAsync(link);
+ public async Task> GetImageFromPageAsync(string link, string categoryName)
+ => (await Try.RunAsync(() => LoadOutcomeAsync(link, categoryName)).ConfigureAwait(false))
+ .ToResult(exception => ScrapeErrorFactory.CreatePageLoadFailed(link, exception.Message));
- var tagLocators = await page.Locator(".tagname").AllAsync();
- var tagData = await Task.WhenAll(tagLocators.Select(GetTagsAsync));
+ private async Task LoadOutcomeAsync(string link, string categoryName)
+ {
+ await EnsurePageAsync().ConfigureAwait(false);
+ _ = await page.GotoAsync(link).ConfigureAwait(false);
- await scrapedTagRepository.SaveAsync([.. tagData.Where(t => !string.IsNullOrWhiteSpace(t.Category))]);
+ var tagLocators = await page.Locator(".tagname").AllAsync().ConfigureAwait(false);
+ var tagData = await Task.WhenAll(tagLocators.Select(GetTagsAsync)).ConfigureAwait(false);
string initialDirectory = scrapeConfiguration.ScrapeDirectories.BaseSaveDirectory.CombinePath(categoryName.Replace(' ', '-'));
var context = TagRuleContextFactory.Create(initialDirectory, scrapeConfiguration.ScrapeDirectories.BaseDirectoryFamous, tagsToIgnoreCompletely, tagsTextToIgnore);
var outcome = TagRules.Evaluate(tagData, context);
- return await MapOutcomeToResultAsync(outcome);
+ return await MapOutcomeAsync(outcome, tagData).ConfigureAwait(false);
}
- private async Task MapOutcomeToResultAsync(TagOutcome outcome)
+ private async Task MapOutcomeAsync(TagOutcome outcome, IReadOnlyList rawTags)
=> outcome switch
{
- SkipImage skip => new ImagePageResult(null, [], string.Empty, true, skip.Tags),
- Accept accept => new ImagePageResult(await GetImageSourceAsync(), [.. accept.DirectorySegments], accept.FilePrefix, false, accept.Tags),
+ SkipImage skip => ImagePageOutcomeFactory.CreateSkippedImage(skip.Tags, rawTags),
+ Accept accept => ImagePageOutcomeFactory.CreateScrapedImage(await GetImageSourceAsync().ConfigureAwait(false) ?? string.Empty, accept.DirectorySegments, accept.FilePrefix, accept.Tags, rawTags),
_ => throw new InvalidOperationException("Unexpected tag outcome."),
};
@@ -42,13 +45,16 @@ private async Task MapOutcomeToResultAsync(TagOutcome outcome)
{
var imageTag = page.Locator("#wallpaper");
- return await imageTag.GetAttributeAsync("src");
+ return await imageTag.GetAttributeAsync("src").ConfigureAwait(false);
}
+ private async Task EnsurePageAsync() => page ??= await playwrightService.ConfigurePlaywrightAsync().ConfigureAwait(false);
+
private static async Task GetTagsAsync(ILocator tag)
{
- string textTask = await tag.InnerTextAsync();
- string? attrTask = await tag.GetAttributeAsync("original-title");
+ string textTask = await tag.InnerTextAsync().ConfigureAwait(false);
+ string? attrTask = await tag.GetAttributeAsync("original-title").ConfigureAwait(false);
+
return new TagData(textTask, attrTask);
}
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Pages/SearchResultsPage.cs b/src/AStar.Dev.Wallpaper.Scrapper/Pages/SearchResultsPage.cs
index 5a07089..e279208 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Pages/SearchResultsPage.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Pages/SearchResultsPage.cs
@@ -1,12 +1,15 @@
using AStar.Dev.FunctionalParadigm;
+using AStar.Dev.Wallpaper.Scrapper.Models;
using AStar.Dev.Wallpaper.Scrapper.Services;
using Microsoft.Playwright;
-using Serilog.Core;
namespace AStar.Dev.Wallpaper.Scrapper.Pages;
-public sealed class SearchResultsPage(IPlaywrightService playwrightService, Logger logger)
+public sealed class SearchResultsPage(IPlaywrightService playwrightService)
{
+ private const string PageHeaderErrorSource = "search-results-page-header";
+ private const string ImagePageLinksErrorSource = "search-results-image-page-links";
+
private IPage page = null!;
private ILocator NewSubscriptionWallpapersHeader => page.GetByText("New Subscription Wallpapers", new PageGetByTextOptions { Exact = false, });
@@ -17,60 +20,44 @@ public sealed class SearchResultsPage(IPlaywrightService playwrightService, Logg
private ILocator ImagePreviews => page.GetByRole(AriaRole.Link);
- public async Task LoadSearchPageAsync(string searchString, int pageNumber)
- {
- try
- {
- page ??= await playwrightService.ConfigurePlaywrightAsync();
- return await GotoSearchPageAsync(searchString, pageNumber);
- }
- catch (Exception exception)
- {
- logger.Error(exception.GetBaseException().Message);
+ public async Task> LoadSearchPageAsync(string searchString, int pageNumber)
+ => (await Try.RunAsync(() => AttemptGotoSearchPageAsync(searchString, pageNumber)).ConfigureAwait(false))
+ .ToResult(exception => ScrapeErrorFactory.CreatePageLoadFailed($"{searchString}{pageNumber}", exception.Message))
+ .Bind(succeeded => ToSearchPageResult(succeeded, searchString, pageNumber));
- throw;
- }
- }
+ public async Task> PageInfoAsync()
+ => (await Try.RunAsync(GetPageHeaderAsync).ConfigureAwait(false))
+ .ToResult(exception => ScrapeErrorFactory.CreatePageLoadFailed(PageHeaderErrorSource, exception.Message))
+ .Bind(PageHeaderParser.Parse);
- public async Task<(int pageCount, int imageCount, string subDirectoryName)> PageInfoAsync()
+ public async Task, ScrapeError>> ImagePageLinksAsync()
+ => (await Try.RunAsync(GetTheImagePageLinksAsync).ConfigureAwait(false))
+ .ToResult, ScrapeError>(exception => ScrapeErrorFactory.CreatePageLoadFailed(ImagePageLinksErrorSource, exception.Message));
+
+ private static Result ToSearchPageResult(bool succeeded, string searchString, int pageNumber)
{
- try
- {
- page ??= await playwrightService.ConfigurePlaywrightAsync();
- return await GetPageInfoAsync();
- }
- catch (Exception exception)
- {
- logger.Error(exception.GetBaseException().Message);
+ if (succeeded) return Unit.Value;
- throw;
- }
+ return ScrapeErrorFactory.CreatePageLoadFailed($"{searchString}{pageNumber}", $"Could not load the search results page for {searchString}{pageNumber} after retry.");
}
- public async Task> ImagePageLinksAsync()
+ private async Task AttemptGotoSearchPageAsync(string searchString, int pageNumber)
{
- try
- {
- page ??= await playwrightService.ConfigurePlaywrightAsync();
- return await GetTheImagePageLinksAsync();
- }
- catch (Exception exception)
- {
- logger.Error(exception.GetBaseException().Message);
+ await EnsurePageAsync().ConfigureAwait(false);
- throw;
- }
- }
+ var firstAttempt = await GotoPageAsync(searchString, pageNumber).ConfigureAwait(false);
- private async Task GotoSearchPageAsync(string searchString, int pageNumber)
- {
- var searchPage = await GotoPageAsync(searchString, pageNumber);
+ if (firstAttempt is { Ok: true, }) return true;
- return searchPage is { Ok: true, } ? searchPage : await GotoPageAsync(searchString, pageNumber);
+ var secondAttempt = await GotoPageAsync(searchString, pageNumber).ConfigureAwait(false);
+
+ return secondAttempt is { Ok: true, };
}
private async Task> GetTheImagePageLinksAsync()
{
+ await EnsurePageAsync().ConfigureAwait(false);
+
var imagePreviews = await ImagePreviews.AllAsync().ConfigureAwait(false);
List hrefs = [];
foreach (var imagePreview in imagePreviews) hrefs.Add(await imagePreview.GetAttributeAsync("href").ConfigureAwait(false));
@@ -78,35 +65,28 @@ private async Task> GetTheImagePageLinksAsync()
return ImageLinkSelector.SelectWanted(hrefs);
}
- private async Task<(int pageCount, int imageCount, string subDirectoryName)> GetPageInfoAsync()
- {
- string? text = await GetPageHeaderAsync();
-
- if (text is null) return (0, 0, string.Empty);
-
- return PageHeaderParser.Parse(text).Match(
- pageInfo => (pageInfo.PageCount, pageInfo.ImageCount, pageInfo.SubDirectoryName),
- error => throw new InvalidOperationException(error.Message));
- }
-
private async Task GotoPageAsync(string searchString, int pageNumber)
- => await page.GotoAsync($"{searchString}{pageNumber}", new PageGotoOptions { Timeout = 60000, });
+ => await page.GotoAsync($"{searchString}{pageNumber}", new PageGotoOptions { Timeout = 60000, }).ConfigureAwait(false);
private async Task GetPageHeaderAsync()
{
+ await EnsurePageAsync().ConfigureAwait(false);
+
string? text;
try
{
- text = await WallpaperSearchHeader.TextContentAsync();
+ text = await WallpaperSearchHeader.TextContentAsync().ConfigureAwait(false);
}
catch
{
- text = await WallpaperSearchHeaderGeneral.TextContentAsync();
+ text = await WallpaperSearchHeaderGeneral.TextContentAsync().ConfigureAwait(false);
}
- if (text?.Length == 0) text = await NewSubscriptionWallpapersHeader.TextContentAsync();
+ if (text?.Length == 0) text = await NewSubscriptionWallpapersHeader.TextContentAsync().ConfigureAwait(false);
return text;
}
+
+ private async Task EnsurePageAsync() => page ??= await playwrightService.ConfigurePlaywrightAsync().ConfigureAwait(false);
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Pages/SubscriptionsImagesListPage.cs b/src/AStar.Dev.Wallpaper.Scrapper/Pages/SubscriptionsImagesListPage.cs
index 7ab74fe..7bee89c 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Pages/SubscriptionsImagesListPage.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Pages/SubscriptionsImagesListPage.cs
@@ -7,33 +7,52 @@ namespace AStar.Dev.Wallpaper.Scrapper.Pages;
public sealed class SubscriptionsImagesListPage(IPlaywrightService playwrightService, SearchConfiguration searchConfiguration)
{
+ private const string PageHeaderErrorSource = "subscriptions-page-header";
+ private const string ImagePageLinksErrorSource = "subscriptions-image-page-links";
+ private const string ClearAllErrorSource = "subscriptions-clear-all";
+
private IPage page = null!;
private ILocator ImagePreviews => page.GetByRole(AriaRole.Link);
private ILocator NewSubscriptionWallpapersHeader => page.GetByText("New Subscription Wallpapers", new PageGetByTextOptions { Exact = false, });
- public async Task LoadSubscriptionResultsPageAsync(int pageNumber)
+ public async Task> LoadSubscriptionResultsPageAsync(int pageNumber)
+ => (await Try.RunAsync(() => GotoSubscriptionsPageAsync(pageNumber)).ConfigureAwait(false))
+ .ToResult(exception => ScrapeErrorFactory.CreatePageLoadFailed($"{searchConfiguration.Subscriptions}{pageNumber}", exception.Message));
+
+ public async Task> PageInfoAsync()
+ => (await Try.RunAsync(GetPageHeaderAsync).ConfigureAwait(false))
+ .ToResult(exception => ScrapeErrorFactory.CreatePageLoadFailed(PageHeaderErrorSource, exception.Message))
+ .Bind(SubscriptionHeaderParser.Parse);
+
+ public async Task, ScrapeError>> GetImagePageLinksAsync()
+ => (await Try.RunAsync(GetTheImagePageLinksAsync).ConfigureAwait(false))
+ .ToResult, ScrapeError>(exception => ScrapeErrorFactory.CreatePageLoadFailed(ImagePageLinksErrorSource, exception.Message));
+
+ public async Task> ClearAsync()
+ => (await Try.RunAsync(ClickClearAllSubscriptionsAsync).ConfigureAwait(false))
+ .ToResult(exception => ScrapeErrorFactory.CreatePageLoadFailed(ClearAllErrorSource, exception.Message));
+
+ private async Task GotoSubscriptionsPageAsync(int pageNumber)
{
- page ??= await playwrightService.ConfigurePlaywrightAsync();
- return await page.GotoAsync($"{searchConfiguration.Subscriptions}{pageNumber}");
+ await EnsurePageAsync().ConfigureAwait(false);
+ _ = await page.GotoAsync($"{searchConfiguration.Subscriptions}{pageNumber}").ConfigureAwait(false);
+
+ return Unit.Value;
}
- public async Task<(int pageCount, string subDirectoryName)> PageInfoAsync()
+ private async Task GetPageHeaderAsync()
{
- page ??= await playwrightService.ConfigurePlaywrightAsync();
- string? text = await NewSubscriptionWallpapersHeader.TextContentAsync();
-
- if (text is null) return (0, string.Empty);
+ await EnsurePageAsync().ConfigureAwait(false);
- return SubscriptionHeaderParser.Parse(text).Match(
- pageInfo => (pageInfo.PageCount, pageInfo.SubDirectoryName),
- error => throw new InvalidOperationException(error.Message));
+ return await NewSubscriptionWallpapersHeader.TextContentAsync().ConfigureAwait(false);
}
- public async Task> GetImagePageLinksAsync()
+ private async Task> GetTheImagePageLinksAsync()
{
- page ??= await playwrightService.ConfigurePlaywrightAsync();
+ await EnsurePageAsync().ConfigureAwait(false);
+
var imagePreviews = await ImagePreviews.AllAsync().ConfigureAwait(false);
List hrefs = [];
foreach (var imagePreview in imagePreviews) hrefs.Add(await imagePreview.GetAttributeAsync("href").ConfigureAwait(false));
@@ -41,9 +60,17 @@ public async Task> GetImagePageLinksAsync()
return ImageLinkSelector.SelectWanted(hrefs);
}
- public async Task ClearAsync()
- => await page.Locator("div")
- .Filter(new LocatorFilterOptions { HasText = " Clear All Subscriptions", })
- .GetByRole(AriaRole.Link, new LocatorGetByRoleOptions { Name = " Clear All Subscriptions", })
- .ClickAsync();
+ private async Task ClickClearAllSubscriptionsAsync()
+ {
+ await EnsurePageAsync().ConfigureAwait(false);
+
+ await page.Locator("div")
+ .Filter(new LocatorFilterOptions { HasText = " Clear All Subscriptions", })
+ .GetByRole(AriaRole.Link, new LocatorGetByRoleOptions { Name = " Clear All Subscriptions", })
+ .ClickAsync().ConfigureAwait(false);
+
+ return Unit.Value;
+ }
+
+ private async Task EnsurePageAsync() => page ??= await playwrightService.ConfigurePlaywrightAsync().ConfigureAwait(false);
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Pages/TopWallpapersPage.cs b/src/AStar.Dev.Wallpaper.Scrapper/Pages/TopWallpapersPage.cs
index 7393ee8..4635a48 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Pages/TopWallpapersPage.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Pages/TopWallpapersPage.cs
@@ -7,37 +7,53 @@ namespace AStar.Dev.Wallpaper.Scrapper.Pages;
public sealed class TopWallpapersPage(IPlaywrightService playwrightService, SearchConfiguration searchConfiguration) : ITopWallpapersPage
{
+ private const string PageHeaderErrorSource = "top-wallpapers-page-header";
+ private const string ImagePageLinksErrorSource = "top-wallpapers-image-page-links";
+
private IPage page = null!;
private ILocator PageCount => page.GetByText("Page ", new PageGetByTextOptions { Exact = false, });
private ILocator ImagePreviews => page.GetByRole(AriaRole.Link);
- public async Task LoadTopWallpapersPageAsync(int pageNumber)
+ public async Task> LoadTopWallpapersPageAsync(int pageNumber)
+ => (await Try.RunAsync(() => GotoTopWallpapersPageAsync(pageNumber)).ConfigureAwait(false))
+ .ToResult(exception => ScrapeErrorFactory.CreatePageLoadFailed($"{searchConfiguration.TopWallpapers}{pageNumber}", exception.Message));
+
+ public async Task> PageInfoAsync()
+ => (await Try.RunAsync(GetPageHeaderAsync).ConfigureAwait(false))
+ .ToResult(exception => ScrapeErrorFactory.CreatePageLoadFailed(PageHeaderErrorSource, exception.Message))
+ .Bind(TopWallpapersHeaderParser.Parse);
+
+ public async Task, ScrapeError>> GetImagePageLinksAsync()
+ => (await Try.RunAsync(GetTheImagePageLinksAsync).ConfigureAwait(false))
+ .ToResult, ScrapeError>(exception => ScrapeErrorFactory.CreatePageLoadFailed(ImagePageLinksErrorSource, exception.Message));
+
+ private async Task GotoTopWallpapersPageAsync(int pageNumber)
{
- page ??= await playwrightService.ConfigurePlaywrightAsync();
- return _ = await page.GotoAsync($"{searchConfiguration.TopWallpapers}{pageNumber}");
+ await EnsurePageAsync().ConfigureAwait(false);
+ _ = await page.GotoAsync($"{searchConfiguration.TopWallpapers}{pageNumber}").ConfigureAwait(false);
+
+ return Unit.Value;
}
- public async Task PageInfoAsync()
+ private async Task GetPageHeaderAsync()
{
- page ??= await playwrightService.ConfigurePlaywrightAsync();
- string? text = await PageCount.First.TextContentAsync();
-
- if (text is null) return 0;
+ await EnsurePageAsync().ConfigureAwait(false);
- return TopWallpapersHeaderParser.Parse(text).Match(
- pageCount => pageCount,
- error => throw new InvalidOperationException(error.Message));
+ return await PageCount.First.TextContentAsync().ConfigureAwait(false);
}
- public async Task> GetImagePageLinksAsync()
+ private async Task> GetTheImagePageLinksAsync()
{
- page ??= await playwrightService.ConfigurePlaywrightAsync();
+ await EnsurePageAsync().ConfigureAwait(false);
+
var imagePreviews = await ImagePreviews.AllAsync().ConfigureAwait(false);
List hrefs = [];
foreach (var imagePreview in imagePreviews) hrefs.Add(await imagePreview.GetAttributeAsync("href").ConfigureAwait(false));
return ImageLinkSelector.SelectWanted(hrefs);
}
+
+ private async Task EnsurePageAsync() => page ??= await playwrightService.ConfigurePlaywrightAsync().ConfigureAwait(false);
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/ScrapeConfiguration/ScrapeConfigurationView.axaml.cs b/src/AStar.Dev.Wallpaper.Scrapper/ScrapeConfiguration/ScrapeConfigurationView.axaml.cs
index df4df96..f63c031 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/ScrapeConfiguration/ScrapeConfigurationView.axaml.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/ScrapeConfiguration/ScrapeConfigurationView.axaml.cs
@@ -1,4 +1,5 @@
using AStar.Dev.FunctionalParadigm;
+using AStar.Dev.Wallpaper.Scrapper.Models;
using AStar.Dev.Wallpaper.Scrapper.Services;
using Avalonia.Controls;
using Avalonia.Interactivity;
@@ -68,7 +69,7 @@ private async void OnImportScrapeConfigClicked(object? sender, RoutedEventArgs e
}
)
.Tap(_ => { logger.Information("Importing scrape configuration..."); ViewModel.UpdateStatus("Importing scrape configuration..."); })
- .Bind(_ => importExportService.ImportScrapeConfigurationFromFile())
+ .Bind(_ => importExportService.ImportScrapeConfigurationFromFile().ToStringError())
.MapAsync(entity => scrapeConfigurationService.ImportScrapeConfigurationAsync(entity, cts!.Token))
.TapAsync(_ => { logger.Information("Scrape configuration import completed..."); ViewModel.UpdateStatus("Import completed."); })
.EnsureAsync(() => ResetUI());
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Services/ClassificationMatcher.cs b/src/AStar.Dev.Wallpaper.Scrapper/Services/ClassificationMatcher.cs
new file mode 100644
index 0000000..6547e67
--- /dev/null
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Services/ClassificationMatcher.cs
@@ -0,0 +1,27 @@
+using AStar.Dev.Guard.Clauses;
+using AStar.Dev.Infrastructure.AppDb.Entities;
+using AStar.Dev.Wallpaper.Scrapper.Support;
+
+namespace AStar.Dev.Wallpaper.Scrapper.Services;
+
+///
+/// Pure classification matching, frozen from the historical FileClassificationService.CollectFileNameMatches
+/// and category-classification steps. Tag matches are excluded because resolving them requires a database lookup.
+///
+public static class ClassificationMatcher
+{
+ /// Matches 's searchable classifications and category classification against 's name.
+ public static IReadOnlyList Match(PageClassificationData pageData, FileDetailEntity fileDetail)
+ {
+ GuardAgainst.Null(pageData);
+ GuardAgainst.Null(fileDetail);
+
+ var fileNameMatches = pageData.SearchableClassifications
+ .Where(entry => entry.Keywords.Any(keyword => fileDetail.FullNameWithPath().Contains(keyword, StringComparison.OrdinalIgnoreCase)))
+ .Select(entry => entry.Category);
+
+ return pageData.CategoryClassification is null
+ ? [.. fileNameMatches,]
+ : [.. fileNameMatches, pageData.CategoryClassification,];
+ }
+}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Services/FileClassificationService.cs b/src/AStar.Dev.Wallpaper.Scrapper/Services/FileClassificationService.cs
index a66dae2..7384276 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Services/FileClassificationService.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Services/FileClassificationService.cs
@@ -2,6 +2,7 @@
using AStar.Dev.Infrastructure.AppDb;
using AStar.Dev.Infrastructure.AppDb.Entities;
using AStar.Dev.Utilities;
+using AStar.Dev.Wallpaper.Scrapper.Models;
using AStar.Dev.Wallpaper.Scrapper.Support;
using Microsoft.EntityFrameworkCore;
@@ -38,33 +39,9 @@ public async Task LoadPageClassificationDataAsync(string
return PageClassificationDataFactory.Create(searchable, categoryClassification, includedTags);
}
- public async Task ClassifyAsync(FileDetailEntity fileDetail, PageClassificationData pageData, IReadOnlyList imageTags, CancellationToken token)
- {
- await using var context = await contextFactory.CreateDbContextAsync(token).ConfigureAwait(false);
-
- if (await context.FileClassifications.AnyAsync(classification => classification.FileDetailId == fileDetail.Id, token).ConfigureAwait(false))
- return;
-
- var matched = new List();
-
- CollectFileNameMatches(pageData.SearchableClassifications, fileDetail, matched);
- if (pageData.CategoryClassification is not null)
- matched.Add(pageData.CategoryClassification);
- await CollectTagMatchesAsync(context, pageData.IncludedTags, imageTags, matched, token).ConfigureAwait(false);
-
- var distinct = matched.DistinctBy(c => c.Name).ToList();
-
- await context.SaveChangesAsync(token).ConfigureAwait(false);
-
- foreach (var classification in distinct)
- context.FileClassifications.Add(new FileClassificationEntity
- {
- FileDetailId = fileDetail.Id,
- CategoryId = classification.Id
- });
-
- await context.SaveChangesAsync(token).ConfigureAwait(false);
- }
+ public async Task> ClassifyAsync(FileDetailEntity fileDetail, PageClassificationData pageData, IReadOnlyList imageTags, CancellationToken token)
+ => (await Try.RunAsync(() => ClassifyInternalAsync(fileDetail, pageData, imageTags, token)).ConfigureAwait(false))
+ .ToResult(exception => ScrapeErrorFactory.CreateClassificationFailed(fileDetail.FileName.Value, exception.Message));
internal async Task<(List Categories, List Keywords)> ExportClassificationsAsync(CancellationToken token)
{
@@ -121,6 +98,37 @@ internal async Task ImportClassificationsAsync((List ClassifyInternalAsync(FileDetailEntity fileDetail, PageClassificationData pageData, IReadOnlyList imageTags, CancellationToken token)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync(token).ConfigureAwait(false);
+
+ if (await context.FileClassifications.AnyAsync(classification => classification.FileDetailId == fileDetail.Id, token).ConfigureAwait(false))
+ return Unit.Value;
+
+ var matched = new List(ClassificationMatcher.Match(pageData, fileDetail));
+ await CollectTagMatchesAsync(context, pageData.IncludedTags, imageTags, matched, token).ConfigureAwait(false);
+
+ var distinct = matched.DistinctBy(c => c.Name).ToList();
+
+ foreach (var classification in distinct)
+ {
+ EnsureTracked(context, classification);
+ context.FileClassifications.Add(new FileClassificationEntity { FileDetailId = fileDetail.Id, Category = classification });
+ }
+
+ await context.SaveChangesAsync(token).ConfigureAwait(false);
+
+ return Unit.Value;
+ }
+
+ private static void EnsureTracked(AppDbContext context, FileClassificationCategoryEntity classification)
+ {
+ var entry = context.Entry(classification);
+
+ if (entry.State == EntityState.Detached && classification.Id != 0)
+ entry.State = EntityState.Unchanged;
+ }
+
private static async Task ResolveCategoryClassificationAsync(AppDbContext context, string categoryId, CancellationToken token)
{
if (string.IsNullOrEmpty(categoryId)) return null;
@@ -142,11 +150,6 @@ internal async Task ImportClassificationsAsync((List Keywords)> searchable, FileDetailEntity fileDetail, List matched)
- => matched.AddRange(searchable
- .Where(entry => entry.Keywords.Any(keyword => fileDetail.FullNameWithPath().Contains(keyword, StringComparison.OrdinalIgnoreCase)))
- .Select(entry => entry.Category));
-
private static async Task CollectTagMatchesAsync(AppDbContext context, IReadOnlyList includedTags, IReadOnlyList imageTags, List matched, CancellationToken token)
{
if (imageTags.Count == 0) return;
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Services/IImportExportService.cs b/src/AStar.Dev.Wallpaper.Scrapper/Services/IImportExportService.cs
index 097a715..dc3c893 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Services/IImportExportService.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Services/IImportExportService.cs
@@ -1,5 +1,6 @@
using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Infrastructure.AppDb.Entities;
+using AStar.Dev.Wallpaper.Scrapper.Models;
using FileClassificationDomain = AStar.Dev.Infrastructure.AppDb.Entities.FileClassificationCategoryEntity;
using FileClassificationKeywordDomain = AStar.Dev.Infrastructure.AppDb.Entities.FileClassificationKeywordEntity;
using ScrapedTagDomain = AStar.Dev.Infrastructure.AppDb.Entities.ScrapedTagEntity;
@@ -9,10 +10,10 @@ namespace AStar.Dev.Wallpaper.Scrapper.Services;
public interface IImportExportService
{
void ExportFileClassificationsToFile((List Categories, List Keywords) classifications);
- Result<(List Categories, List Keywords), string> ImportFileClassificationsFromFile();
+ Result<(List Categories, List Keywords), ScrapeError> ImportFileClassificationsFromFile();
void ExportScrapeConfigurationToFile(ScrapeConfigurationEntity entity);
- Result ImportScrapeConfigurationFromFile();
+ Result ImportScrapeConfigurationFromFile();
void ExportScrapedTagsToFile(List tags);
- Result, string> ImportScrapedTagsFromFile();
+ Result, ScrapeError> ImportScrapedTagsFromFile();
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Services/ImagePageService.cs b/src/AStar.Dev.Wallpaper.Scrapper/Services/ImagePageService.cs
index 20ac6c4..367b958 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Services/ImagePageService.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Services/ImagePageService.cs
@@ -7,110 +7,149 @@
using AStar.Dev.Wallpaper.Scrapper.Repositories;
using AStar.Dev.Wallpaper.Scrapper.Support;
using Serilog.Core;
-using SkiaSharp;
namespace AStar.Dev.Wallpaper.Scrapper.Services;
-public sealed class ImagePageService(ImagePage imagePage, IFileDetailRepository fileDetailRepository, FileClassificationService fileClassificationService, ScrapeConfiguration scrapeConfiguration, TimeProvider timeProvider, Logger logger, IDirectoryHelper directoryHelper, ImageBroadcaster imageBroadcaster, IDelayStrategy delayStrategy, IImageRetriever imageRetriever, IImageSaver imageSaver, IFileSystem fileSystem)
+public sealed class ImagePageService(
+ ImagePage imagePage,
+ IFileDetailRepository fileDetailRepository,
+ FileClassificationService fileClassificationService,
+ ScrapeConfiguration scrapeConfiguration,
+ TimeProvider timeProvider,
+ Logger logger,
+ IDirectoryHelper directoryHelper,
+ ImageBroadcaster imageBroadcaster,
+ IDelayStrategy delayStrategy,
+ IImageRetriever imageRetriever,
+ IImageSaver imageSaver,
+ IFileSystem fileSystem,
+ IScrapedTagRepository scrapedTagRepository,
+ IImageDimensionReader imageDimensionReader)
{
private const int LoggedPathTailLength = 50;
/// Retained for constructor-signature stability. The image-pause delay it previously drove now lives in , which is configured with its own .
private readonly ScrapeConfiguration scrapeConfiguration = scrapeConfiguration;
- public async Task GetTheImagePagesAsync(IReadOnlyCollection imagePageLinks, string categoryId, string name, CancellationToken ct = default)
+ /// Retained for constructor-signature stability. File size is now computed from the downloaded bytes directly, so a saved image's entry no longer needs to be re-read.
+ private readonly IFileSystem fileSystem = fileSystem;
+
+ public async Task> GetTheImagePagesAsync(IReadOnlyCollection imagePageLinks, string categoryId, string name, CancellationToken ct = default)
{
var pageData = await fileClassificationService.LoadPageClassificationDataAsync(categoryId, ct).ConfigureAwait(false);
foreach (string pageLink in imagePageLinks)
{
ct.ThrowIfCancellationRequested();
- try
- {
- string fileName = Path.GetFileName(pageLink);
-
- if (await fileDetailRepository.ExistsAsync(fileName).ConfigureAwait(false))
- {
- logger.Information("Not downloading {fileName} as we already have it...{Timestamp:HH:mm:ss:fff} (UTC)", fileName, timeProvider.GetUtcNow());
- await delayStrategy.DelayAsync(DelayKind.ImageAlreadyDownloaded, ct).ConfigureAwait(false);
- continue;
- }
+ string fileName = Path.GetFileName(pageLink);
- await ProcessImagePageAsync(pageLink, name, pageData, ct).ConfigureAwait(false);
- }
- catch (Exception ex) when (ex is not OperationCanceledException)
+ if (await fileDetailRepository.ExistsAsync(fileName).ConfigureAwait(false))
{
- logger.Warning(ex, "Failed to process {pageLink}, retrying after delay.", pageLink);
- await delayStrategy.DelayAsync(DelayKind.Retry, ct).ConfigureAwait(false);
- await ProcessImagePageAsync(pageLink, name, pageData, ct).ConfigureAwait(false);
+ logger.Information("Not downloading {fileName} as we already have it...{Timestamp:HH:mm:ss:fff} (UTC)", fileName, timeProvider.GetUtcNow());
+ await delayStrategy.DelayAsync(DelayKind.ImageAlreadyDownloaded, ct).ConfigureAwait(false);
+ continue;
}
+
+ var pageResult = await ProcessImagePageAsync(pageLink, name, pageData, ct).ConfigureAwait(false);
+ var pageFailed = pageResult.Match(_ => false, _ => true);
+
+ if (pageFailed) return pageResult;
}
+
+ return Unit.Value;
}
- private async Task ProcessImagePageAsync(string pageLink, string name, PageClassificationData pageData, CancellationToken ct)
+ public async Task> ProcessImagePageAsync(string pageLink, string categoryName, PageClassificationData pageData, CancellationToken ct)
{
- try
+ await delayStrategy.DelayAsync(DelayKind.BeforeImage, ct).ConfigureAwait(false);
+
+ return await imagePage.GetImageFromPageAsync(pageLink, categoryName)
+ .BindAsync(outcome => HandleOutcomeAsync(outcome, categoryName, pageData, ct))
+ .ConfigureAwait(false);
+ }
+
+ private async Task> HandleOutcomeAsync(ImagePageOutcome outcome, string categoryName, PageClassificationData pageData, CancellationToken ct)
+ {
+ await SaveScrapedTagsAsync(outcome).ConfigureAwait(false);
+
+ return outcome switch
{
- await delayStrategy.DelayAsync(DelayKind.BeforeImage, ct).ConfigureAwait(false);
+ SkippedImage skipped => LogSkippedImage(categoryName, skipped),
+ ScrapedImage scraped => await DownloadAndPersistAsync(scraped, pageData, ct).ConfigureAwait(false),
+ _ => throw new InvalidOperationException("Unexpected image page outcome."),
+ };
+ }
- var result = await imagePage.GetImageFromPageAsync(pageLink, name).ConfigureAwait(false);
- if (result.Skip || result.ImageUrl is null)
- {
- logger.Information("Skipping {Name} with Tags: {Tags}", name, string.Join(", ", result.Tags));
- return;
- }
+ private Task SaveScrapedTagsAsync(ImagePageOutcome outcome)
+ {
+ var rawTags = outcome switch
+ {
+ ScrapedImage scraped => scraped.RawTags,
+ SkippedImage skipped => skipped.RawTags,
+ _ => throw new InvalidOperationException("Unexpected image page outcome."),
+ };
- var directoryName = directoryHelper.CreateDirectoryIfRequired(result.DirectoryName);
+ return scrapedTagRepository.SaveAsync([.. rawTags.Where(tag => !string.IsNullOrWhiteSpace(tag.Category)),]);
+ }
+
+ private Result LogSkippedImage(string categoryName, SkippedImage skipped)
+ {
+ logger.Information("Skipping {Name} with Tags: {Tags}", categoryName, string.Join(", ", skipped.Tags));
- string filename = ScrapedFileNameFactory.Create(result.FilePrefix, result.ImageUrl);
+ return Unit.Value;
+ }
- string imageNameWithPath = directoryName.Value.CombinePath(filename);
- byte[] image = (await imageRetriever.GetImageAsync(result.ImageUrl, ct).ConfigureAwait(false))
- .Match(bytes => bytes, error => throw new InvalidOperationException(error.Message));
- logger.Information("About to save {filename} to ...{imageNameWithPath} as we don't appear to have it.", filename, TruncatedForLogging(imageNameWithPath));
- await imageSaver.SaveAsync(image, imageNameWithPath).ConfigureAwait(false);
- imageBroadcaster.Broadcast(imageNameWithPath);
+ private async Task> DownloadAndPersistAsync(ScrapedImage scraped, PageClassificationData pageData, CancellationToken ct)
+ {
+ var directoryName = directoryHelper.CreateDirectoryIfRequired([.. scraped.DirectorySegments,]);
+ string filename = ScrapedFileNameFactory.Create(scraped.FilePrefix, scraped.ImageUrl);
+ string imageNameWithPath = directoryName.Value.CombinePath(filename);
+
+ return await RetryExtensions.RetryOnceAsync(
+ () => imageRetriever.GetImageAsync(scraped.ImageUrl, ct),
+ () => delayStrategy.DelayAsync(DelayKind.Retry, ct))
+ .BindAsync(image => SaveAndPersistAsync(image, imageNameWithPath, filename, directoryName, scraped, pageData, ct))
+ .ConfigureAwait(false);
+ }
- var fileInfo = fileSystem.FileInfo.New(imageNameWithPath);
- var fileDetail = new FileDetailEntity
- {
- DirectoryName = directoryName,
- FileName = new FileName(filename),
- FileSize = fileInfo.Length,
- IsImage = filename.IsImage()
- };
+ private async Task> SaveAndPersistAsync(byte[] image, string imageNameWithPath, string filename, DirectoryName directoryName, ScrapedImage scraped, PageClassificationData pageData, CancellationToken ct)
+ {
+ logger.Information("About to save {filename} to ...{imageNameWithPath} as we don't appear to have it.", filename, TruncatedForLogging(imageNameWithPath));
- if (fileDetail.IsImage)
- {
- var imageDetail = SKImage.FromEncodedData(imageNameWithPath);
- if (imageDetail is not null)
- {
- fileDetail.Height = imageDetail.Height;
- fileDetail.Width = imageDetail.Width;
- fileDetail.ImageDetail = new ImageDetailEntity { Width = imageDetail.Width, Height = imageDetail.Height };
- }
- }
+ return await imageSaver.SaveAsync(image, imageNameWithPath)
+ .TapAsync(_ => imageBroadcaster.Broadcast(imageNameWithPath))
+ .BindAsync(_ => PersistFileDetailAsync(image, imageNameWithPath, filename, directoryName, scraped, pageData, ct))
+ .ConfigureAwait(false);
+ }
- await fileDetailRepository.AddAsync(fileDetail).ConfigureAwait(false);
- await fileClassificationService.ClassifyAsync(fileDetail, pageData, result.Tags, ct).ConfigureAwait(false);
- }
- catch(TaskCanceledException)
- {
- logger.Warning("Task was cancelled while processing {pageLink}.", pageLink);
- throw;
- }
- catch (OperationCanceledException)
- {
- logger.Warning("Operation was cancelled while processing {pageLink}.", pageLink);
- throw;
- }
- catch (Exception ex) when (ex is not OperationCanceledException)
+ private async Task> PersistFileDetailAsync(byte[] image, string imageNameWithPath, string filename, DirectoryName directoryName, ScrapedImage scraped, PageClassificationData pageData, CancellationToken ct)
+ {
+ var fileDetail = new FileDetailEntity
{
- logger.Error(ex, "Error processing image page {pageLink}: {Message}", pageLink, ex.Message);
- throw;
- }
+ DirectoryName = directoryName,
+ FileName = new FileName(filename),
+ FileSize = image.Length,
+ IsImage = filename.IsImage()
+ };
+
+ ApplyImageDimensions(fileDetail, image, imageNameWithPath);
+
+ await fileDetailRepository.AddAsync(fileDetail).ConfigureAwait(false);
+
+ return await fileClassificationService.ClassifyAsync(fileDetail, pageData, scraped.Tags, ct).ConfigureAwait(false);
}
+ private void ApplyImageDimensions(FileDetailEntity fileDetail, byte[] image, string imageNameWithPath)
+ => imageDimensionReader.Read(image, imageNameWithPath)
+ .Tap(
+ dimensions =>
+ {
+ fileDetail.Width = dimensions.Width;
+ fileDetail.Height = dimensions.Height;
+ fileDetail.ImageDetail = new ImageDetailEntity { Width = dimensions.Width, Height = dimensions.Height };
+ },
+ error => logger.Warning("Could not read image dimensions for {imageNameWithPath}: {Message}", TruncatedForLogging(imageNameWithPath), error.Message));
+
private static string TruncatedForLogging(string imageNameWithPath)
=> imageNameWithPath.Length > LoggedPathTailLength ? imageNameWithPath[^LoggedPathTailLength..] : imageNameWithPath;
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Services/ImportExportService.cs b/src/AStar.Dev.Wallpaper.Scrapper/Services/ImportExportService.cs
index bccb358..0497859 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Services/ImportExportService.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Services/ImportExportService.cs
@@ -3,6 +3,7 @@
using AStar.Dev.Infrastructure.AppDb.Entities;
using AStar.Dev.Utilities;
using AStar.Dev.Wallpaper.Scrapper.DTOs;
+using AStar.Dev.Wallpaper.Scrapper.Models;
using Serilog;
using FileClassificationDomain = AStar.Dev.Infrastructure.AppDb.Entities.FileClassificationCategoryEntity;
using FileClassificationDto = AStar.Dev.Wallpaper.Scrapper.DTOs.FileClassification;
@@ -29,13 +30,13 @@ public void ExportFileClassificationsToFile((List Cate
}
}
- public Result<(List Categories, List Keywords), string> ImportFileClassificationsFromFile()
+ public Result<(List Categories, List Keywords), ScrapeError> ImportFileClassificationsFromFile()
{
if (!fileSystem.File.Exists(ApplicationMetadata.FileClassificationsExportFilePath))
{
logger.Error("File not found: {FilePath}", ApplicationMetadata.FileClassificationsExportFilePath);
- return $"Error: File not found - {ApplicationMetadata.FileClassificationsExportFilePath}";
+ return ScrapeErrorFactory.CreateImportFailed(ApplicationMetadata.FileClassificationsExportFilePath, $"Error: File not found - {ApplicationMetadata.FileClassificationsExportFilePath}");
}
string classificationsJson = fileSystem.File.ReadAllText(ApplicationMetadata.FileClassificationsExportFilePath);
@@ -45,10 +46,12 @@ public void ExportFileClassificationsToFile((List Cate
{
logger.Error("Failed to deserialize classifications from file: {FilePath}", ApplicationMetadata.FileClassificationsExportFilePath);
- return $"Error: Failed to deserialize classifications from file - {ApplicationMetadata.FileClassificationsExportFilePath}";
+ return ScrapeErrorFactory.CreateImportFailed(ApplicationMetadata.FileClassificationsExportFilePath, $"Error: Failed to deserialize classifications from file - {ApplicationMetadata.FileClassificationsExportFilePath}");
}
- return classifications.ToDomain();
+ return FileClassificationDtoValidator.ValidateAll(classifications)
+ .ToResult, ScrapeError>(errors => ScrapeErrorFactory.CreateValidationFailed(errors, "Classification import validation failed."))
+ .Map(validDtos => validDtos.ToList().ToDomain());
}
public void ExportScrapeConfigurationToFile(ScrapeConfigurationEntity entity)
@@ -81,13 +84,13 @@ public void ExportScrapedTagsToFile(List tags)
}
}
- public Result ImportScrapeConfigurationFromFile()
+ public Result ImportScrapeConfigurationFromFile()
{
if (!fileSystem.File.Exists(ApplicationMetadata.ScrapeConfigurationExportFilePath))
{
logger.Error("File not found: {FilePath}", ApplicationMetadata.ScrapeConfigurationExportFilePath);
- return $"Error: File not found - {ApplicationMetadata.ScrapeConfigurationExportFilePath}";
+ return ScrapeErrorFactory.CreateImportFailed(ApplicationMetadata.ScrapeConfigurationExportFilePath, $"Error: File not found - {ApplicationMetadata.ScrapeConfigurationExportFilePath}");
}
string json = fileSystem.File.ReadAllText(ApplicationMetadata.ScrapeConfigurationExportFilePath);
@@ -97,19 +100,19 @@ public Result ImportScrapeConfigurationFromFi
{
logger.Error("Failed to deserialize scrape configuration from file: {FilePath}", ApplicationMetadata.ScrapeConfigurationExportFilePath);
- return $"Error: Failed to deserialize scrape configuration from file - {ApplicationMetadata.ScrapeConfigurationExportFilePath}";
+ return ScrapeErrorFactory.CreateImportFailed(ApplicationMetadata.ScrapeConfigurationExportFilePath, $"Error: Failed to deserialize scrape configuration from file - {ApplicationMetadata.ScrapeConfigurationExportFilePath}");
}
return dto.ToDomain();
}
- public Result, string> ImportScrapedTagsFromFile()
+ public Result, ScrapeError> ImportScrapedTagsFromFile()
{
if (!fileSystem.File.Exists(ApplicationMetadata.ScrapedTagsExportFilePath))
{
logger.Error("File not found: {FilePath}", ApplicationMetadata.ScrapedTagsExportFilePath);
- return $"Error: File not found - {ApplicationMetadata.ScrapedTagsExportFilePath}";
+ return ScrapeErrorFactory.CreateImportFailed(ApplicationMetadata.ScrapedTagsExportFilePath, $"Error: File not found - {ApplicationMetadata.ScrapedTagsExportFilePath}");
}
string tagsJson = fileSystem.File.ReadAllText(ApplicationMetadata.ScrapedTagsExportFilePath);
@@ -119,7 +122,7 @@ public Result, string> ImportScrapedTagsFromFile()
{
logger.Error("Failed to deserialize tags from file: {FilePath}", ApplicationMetadata.ScrapedTagsExportFilePath);
- return $"Error: Failed to deserialize tags from file - {ApplicationMetadata.ScrapedTagsExportFilePath}";
+ return ScrapeErrorFactory.CreateImportFailed(ApplicationMetadata.ScrapedTagsExportFilePath, $"Error: Failed to deserialize tags from file - {ApplicationMetadata.ScrapedTagsExportFilePath}");
}
return tags.ToDomain(timeProvider);
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Support/CategoryMerger.cs b/src/AStar.Dev.Wallpaper.Scrapper/Support/CategoryMerger.cs
new file mode 100644
index 0000000..21d54ab
--- /dev/null
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Support/CategoryMerger.cs
@@ -0,0 +1,29 @@
+using AStar.Dev.Guard.Clauses;
+using AStar.Dev.Wallpaper.Scrapper.Models;
+
+namespace AStar.Dev.Wallpaper.Scrapper.Support;
+
+/// Pure category-merge logic, frozen from the historical ConfigurationSaver.UpdateAndSaveTheConfigurationAsync upsert loop.
+public static class CategoryMerger
+{
+ ///
+ /// Partitions (de-duplicated by , keeping the first
+ /// occurrence) into categories that match an id already in and categories that
+ /// do not.
+ ///
+ public static (IReadOnlyList ToUpdate, IReadOnlyList ToAdd) MergeCategories(
+ IEnumerable<(string Id, string Name, int LastKnownImageCount, int LastPageVisited, int TotalPages)> existing,
+ IReadOnlyList updated)
+ {
+ GuardAgainst.Null(existing);
+ GuardAgainst.Null(updated);
+
+ var existingIds = existing.Select(category => category.Id).ToHashSet();
+ var dedupedUpdated = updated.DistinctBy(category => category.Id).ToList();
+
+ IReadOnlyList toUpdate = [.. dedupedUpdated.Where(category => existingIds.Contains(category.Id)),];
+ IReadOnlyList toAdd = [.. dedupedUpdated.Where(category => !existingIds.Contains(category.Id)),];
+
+ return (toUpdate, toAdd);
+ }
+}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Support/ConfigurationSaver.cs b/src/AStar.Dev.Wallpaper.Scrapper/Support/ConfigurationSaver.cs
index d0a3128..7f6e905 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Support/ConfigurationSaver.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Support/ConfigurationSaver.cs
@@ -1,3 +1,4 @@
+using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Infrastructure.AppDb;
using AStar.Dev.Infrastructure.AppDb.Entities;
using AStar.Dev.Wallpaper.Scrapper.Models;
@@ -8,55 +9,54 @@ namespace AStar.Dev.Wallpaper.Scrapper.Support;
public sealed class ConfigurationSaver(ScrapeConfiguration scrapeConfiguration, Logger logger, IDbContextFactory contextFactory)
{
- public async Task SaveUpdatedConfigurationAsync()
- {
- try
- {
- await UpdateAndSaveTheConfigurationAsync();
- }
- catch (Exception exception)
- {
- logger.Error(exception.GetBaseException().Message);
- throw;
- }
- }
+ public async Task> SaveUpdatedConfigurationAsync()
+ => (await Try.RunAsync(UpdateAndSaveTheConfigurationAsync).ConfigureAwait(false))
+ .Tap(_ => { }, exception => logger.Error(exception.GetBaseException().Message))
+ .ToResult(exception => ScrapeErrorFactory.CreateConfigurationSaveFailed(exception.Message));
- private async Task UpdateAndSaveTheConfigurationAsync()
+ private async Task UpdateAndSaveTheConfigurationAsync()
{
- await using var dbContext = await contextFactory.CreateDbContextAsync();
+ await using var dbContext = await contextFactory.CreateDbContextAsync().ConfigureAwait(false);
var entity = await dbContext.ScrapeConfiguration
.Include(e => e.SearchConfiguration)
.ThenInclude(sc => sc.SearchCategories)
- .SingleAsync();
+ .SingleAsync().ConfigureAwait(false);
+
+ var existingCategories = entity.SearchConfiguration.SearchCategories
+ .Select(existing => (existing.Id, existing.Name, existing.LastKnownImageCount, existing.LastPageVisited, existing.TotalPages));
+
+ var (toUpdate, toAdd) = CategoryMerger.MergeCategories(existingCategories, scrapeConfiguration.SearchConfiguration.SearchCategories);
+
+ ApplyUpdates(entity, toUpdate);
+ ApplyAdditions(entity, toAdd);
- var dedupedCategories = scrapeConfiguration.SearchConfiguration.SearchCategories
- .DistinctBy(c => c.Id)
- .ToList();
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
- foreach (var cat in dedupedCategories)
+ return Unit.Value;
+ }
+
+ private static void ApplyUpdates(ScrapeConfigurationEntity entity, IReadOnlyList toUpdate)
+ {
+ foreach (var category in toUpdate)
{
- var existing = entity.SearchConfiguration.SearchCategories
- .FirstOrDefault(ec => ec.Id == cat.Id);
- if (existing != null)
- {
- existing.LastKnownImageCount = cat.LastKnownImageCount;
- existing.LastPageVisited = cat.LastPageVisited;
- existing.TotalPages = cat.TotalPages;
- }
- else
- {
- entity.SearchConfiguration.SearchCategories.Add(new SearchCategoryEntity
- {
- SearchConfigurationId = entity.SearchConfiguration.Id,
- Id = cat.Id,
- Name = cat.Name,
- LastKnownImageCount = cat.LastKnownImageCount,
- LastPageVisited = cat.LastPageVisited,
- TotalPages = cat.TotalPages,
- });
- }
+ var existing = entity.SearchConfiguration.SearchCategories.First(ec => ec.Id == category.Id);
+ existing.LastKnownImageCount = category.LastKnownImageCount;
+ existing.LastPageVisited = category.LastPageVisited;
+ existing.TotalPages = category.TotalPages;
}
+ }
- await dbContext.SaveChangesAsync();
+ private static void ApplyAdditions(ScrapeConfigurationEntity entity, IReadOnlyList toAdd)
+ {
+ foreach (var category in toAdd)
+ entity.SearchConfiguration.SearchCategories.Add(new SearchCategoryEntity
+ {
+ SearchConfigurationId = entity.SearchConfiguration.Id,
+ Id = category.Id,
+ Name = category.Name,
+ LastKnownImageCount = category.LastKnownImageCount,
+ LastPageVisited = category.LastPageVisited,
+ TotalPages = category.TotalPages,
+ });
}
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Support/IImageDimensionReader.cs b/src/AStar.Dev.Wallpaper.Scrapper/Support/IImageDimensionReader.cs
new file mode 100644
index 0000000..bc59748
--- /dev/null
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Support/IImageDimensionReader.cs
@@ -0,0 +1,11 @@
+using AStar.Dev.FunctionalParadigm;
+using AStar.Dev.Wallpaper.Scrapper.Models;
+
+namespace AStar.Dev.Wallpaper.Scrapper.Support;
+
+/// Reads the pixel dimensions of a downloaded image's bytes.
+public interface IImageDimensionReader
+{
+ /// Reads the dimensions of , previously saved to .
+ Result Read(byte[] image, string path);
+}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Support/IImageSaver.cs b/src/AStar.Dev.Wallpaper.Scrapper/Support/IImageSaver.cs
index 09eb5ea..f13828c 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Support/IImageSaver.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Support/IImageSaver.cs
@@ -1,8 +1,11 @@
+using AStar.Dev.FunctionalParadigm;
+using AStar.Dev.Wallpaper.Scrapper.Models;
+
namespace AStar.Dev.Wallpaper.Scrapper.Support;
/// Saves a scraped image's bytes to disk.
public interface IImageSaver
{
/// Saves to , cleaning the path first.
- Task SaveAsync(byte[] image, string path);
+ Task> SaveAsync(byte[] image, string path);
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Support/ImageDimensionReader.cs b/src/AStar.Dev.Wallpaper.Scrapper/Support/ImageDimensionReader.cs
new file mode 100644
index 0000000..1dbd6c6
--- /dev/null
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Support/ImageDimensionReader.cs
@@ -0,0 +1,25 @@
+using AStar.Dev.FunctionalParadigm;
+using AStar.Dev.Wallpaper.Scrapper.Models;
+using SkiaSharp;
+
+namespace AStar.Dev.Wallpaper.Scrapper.Support;
+
+/// The production , backed by , frozen from the historical ImagePageService dimension-probing behaviour.
+public sealed class ImageDimensionReader : IImageDimensionReader
+{
+ ///
+ public Result Read(byte[] image, string path)
+ {
+ ArgumentNullException.ThrowIfNull(image);
+ ArgumentNullException.ThrowIfNull(path);
+
+ if (image.Length == 0)
+ return ScrapeErrorFactory.CreateImageDimensionReadFailed(path, "The image bytes were empty.");
+
+ using var decoded = SKImage.FromEncodedData(image);
+
+ return decoded is null
+ ? ScrapeErrorFactory.CreateImageDimensionReadFailed(path, "The image bytes could not be decoded.")
+ : ImageDimensionsFactory.Create(decoded.Width, decoded.Height);
+ }
+}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Support/ImageDimensions.cs b/src/AStar.Dev.Wallpaper.Scrapper/Support/ImageDimensions.cs
new file mode 100644
index 0000000..5781566
--- /dev/null
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Support/ImageDimensions.cs
@@ -0,0 +1,6 @@
+namespace AStar.Dev.Wallpaper.Scrapper.Support;
+
+/// The pixel dimensions of a decoded image.
+/// The image width, in pixels.
+/// The image height, in pixels.
+public sealed record ImageDimensions(int Width, int Height);
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Support/ImageDimensionsFactory.cs b/src/AStar.Dev.Wallpaper.Scrapper/Support/ImageDimensionsFactory.cs
new file mode 100644
index 0000000..af8dcbf
--- /dev/null
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Support/ImageDimensionsFactory.cs
@@ -0,0 +1,14 @@
+namespace AStar.Dev.Wallpaper.Scrapper.Support;
+
+/// Factory methods for creating instances of .
+public static class ImageDimensionsFactory
+{
+ /// Creates an for the given and .
+ public static ImageDimensions Create(int width, int height)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(width);
+ ArgumentOutOfRangeException.ThrowIfNegative(height);
+
+ return new(width, height);
+ }
+}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Support/ImageSaver.cs b/src/AStar.Dev.Wallpaper.Scrapper/Support/ImageSaver.cs
index 2b5931c..3423be9 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Support/ImageSaver.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Support/ImageSaver.cs
@@ -1,5 +1,7 @@
using System.IO.Abstractions;
+using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Utilities;
+using AStar.Dev.Wallpaper.Scrapper.Models;
namespace AStar.Dev.Wallpaper.Scrapper.Support;
@@ -7,12 +9,23 @@ namespace AStar.Dev.Wallpaper.Scrapper.Support;
public sealed class ImageSaver(IFileSystem fileSystem) : IImageSaver
{
///
- public async Task SaveAsync(byte[] image, string path)
+ public async Task> SaveAsync(byte[] image, string path)
{
+ ArgumentNullException.ThrowIfNull(image);
+ ArgumentNullException.ThrowIfNull(path);
+
string cleanedPath = path.CleanPath();
if (cleanedPath.LastIndexOf(':') > 2) cleanedPath = cleanedPath[..2] + cleanedPath[2..].Replace(":", "_");
+ return (await Try.RunAsync(() => WriteImageAsync(cleanedPath, image)).ConfigureAwait(false))
+ .ToResult(exception => ScrapeErrorFactory.CreateImageSaveFailed(path, exception.Message));
+ }
+
+ private async Task WriteImageAsync(string cleanedPath, byte[] image)
+ {
if (image.Length > 0) await fileSystem.File.WriteAllBytesAsync(cleanedPath, image).ConfigureAwait(false);
+
+ return Unit.Value;
}
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Support/SqliteConnectionStringProvider.cs b/src/AStar.Dev.Wallpaper.Scrapper/Support/SqliteConnectionStringProvider.cs
index 85be6d1..5a6bfa6 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Support/SqliteConnectionStringProvider.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Support/SqliteConnectionStringProvider.cs
@@ -1,3 +1,5 @@
+using System;
+using System.IO;
using AStar.Dev.Guard.Clauses;
using Microsoft.Extensions.Configuration;
@@ -11,13 +13,14 @@ namespace AStar.Dev.Wallpaper.Scrapper.Support;
public static class SqliteConnectionStringProvider
{
///
- /// The connection string used when configuration does not supply ConnectionStrings:Sqlite.
+ /// The connection string used when configuration does not supply ScrapeConfiguration:ConnectionStrings:Sqlite.
///
- public const string DefaultConnectionString = "Data Source=/home/jbarden/Documents/Scrapper/scrapper.db";
+ public static readonly string DefaultConnectionString =
+ $"Data Source={Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Documents", "Scrapper", "scrapper.db")}";
///
/// Resolves the Sqlite connection string from , falling back to
/// when not configured.
///
- public static string Get(IConfiguration configuration) => GuardAgainst.Null(configuration)["ConnectionStrings:Sqlite"] ?? DefaultConnectionString;
+ public static string Get(IConfiguration configuration) => GuardAgainst.Null(configuration)["ScrapeConfiguration:ConnectionStrings:Sqlite"] ?? DefaultConnectionString;
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Tags/TagsView.axaml.cs b/src/AStar.Dev.Wallpaper.Scrapper/Tags/TagsView.axaml.cs
index d2caf05..51b38a1 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Tags/TagsView.axaml.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Tags/TagsView.axaml.cs
@@ -1,4 +1,5 @@
using AStar.Dev.FunctionalParadigm;
+using AStar.Dev.Wallpaper.Scrapper.Models;
using AStar.Dev.Wallpaper.Scrapper.Services;
using AStar.Dev.Wallpaper.Scrapper.Support;
using Avalonia.Controls;
@@ -55,7 +56,7 @@ private async void OnImportTagsClicked(object? sender, RoutedEventArgs e)
}
)
.Tap(_ => logger.Information("Importing tags..."))
- .Bind(_ => importExportService.ImportScrapedTagsFromFile())
+ .Bind(_ => importExportService.ImportScrapedTagsFromFile().ToStringError())
.MapAsync(tags => scrapedTagService.ImportScrapedTagsAsync(tags, cts!.Token))
.TapAsync(_ => logger.Information("Tag import completed..."))
.EnsureAsync(() => ResetUI());
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Workflows/SearchWorkflow.cs b/src/AStar.Dev.Wallpaper.Scrapper/Workflows/SearchWorkflow.cs
index e062582..94a5231 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Workflows/SearchWorkflow.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Workflows/SearchWorkflow.cs
@@ -12,81 +12,98 @@ public sealed class SearchWorkflow(SearchResultsPage searchResultsPage, ScrapeCo
{
private SearchProgress progress = null!;
- public async Task> RunAsync(ILogger scrapeLogger, CancellationToken ct = default)
+ public Task> RunAsync(CancellationToken ct = default)
{
- try
- {
- progress = SearchProgressFactory.Create(injectedScrapeConfiguration.SearchConfiguration, injectedScrapeConfiguration.ScrapeDirectories);
- var searchCategories = SearchProgressFunctions.FilterSearchCategories(progress.SearchConfiguration, progress.SearchConfiguration.SearchCategories);
- await ProcessSearchCategoriesAsync(searchCategories, scrapeLogger, ct);
+ progress = SearchProgressFactory.Create(injectedScrapeConfiguration.SearchConfiguration, injectedScrapeConfiguration.ScrapeDirectories);
+ var searchCategories = SearchProgressFunctions.FilterSearchCategories(progress.SearchConfiguration, progress.SearchConfiguration.SearchCategories);
- return Unit.Value;
- }
- catch (Exception exception) when (exception is not OperationCanceledException)
- {
- scrapeLogger.Error(exception.GetBaseException().Message);
- throw;
- }
+ return ProcessSearchCategoriesAsync(searchCategories, ct).LogFailure(logger);
}
- private async Task ProcessSearchCategoriesAsync(IReadOnlyList searchCategories, ILogger scrapeLogger, CancellationToken ct)
+ private async Task> ProcessSearchCategoriesAsync(IReadOnlyList searchCategories, CancellationToken ct)
{
foreach (var searchCategory in searchCategories)
{
ct.ThrowIfCancellationRequested();
- string combinedSearchString = $"{progress.SearchConfiguration.SearchStringPrefix}{searchCategory.Id}{progress.SearchConfiguration.SearchStringSuffix}";
- progress = SearchProgressFunctions.UpdateSearchDetails(progress, combinedSearchString);
+ var categoryResult = await ProcessSearchCategoryAsync(searchCategory, ct).ConfigureAwait(false);
+ var categoryFailed = categoryResult.Match(_ => false, _ => true);
- var pageDetails = await searchResultsPage.LoadSearchPageAsync(combinedSearchString, progress.SearchConfiguration.StartingPageNumber);
+ if (categoryFailed) return categoryResult;
+ }
- if (pageDetails is { Ok: false, }) throw new InvalidOperationException("Could not get the image page after retry...");
+ return Unit.Value;
+ }
- var (pageCount, imageCount, subDirectoryName) = await searchResultsPage.PageInfoAsync();
- progress = SearchProgressFunctions.UpdateTotalPages(progress, pageCount);
+ private Task> ProcessSearchCategoryAsync(Category searchCategory, CancellationToken ct)
+ {
+ string combinedSearchString = $"{progress.SearchConfiguration.SearchStringPrefix}{searchCategory.Id}{progress.SearchConfiguration.SearchStringSuffix}";
+ progress = SearchProgressFunctions.UpdateSearchDetails(progress, combinedSearchString);
- if (searchCategory.IsUpToDate(imageCount, pageCount))
- {
- logger.Information("{Category} is up to date (same image/page count), skipping...", searchCategory.Name);
- await delayStrategy.DelayAsync(DelayKind.CategoryUpToDate, ct).ConfigureAwait(false);
- continue;
- }
+ return searchResultsPage.LoadSearchPageAsync(combinedSearchString, progress.SearchConfiguration.StartingPageNumber)
+ .BindAsync(_ => searchResultsPage.PageInfoAsync())
+ .BindAsync(pageInfo => ProcessCategoryPageInfoAsync(searchCategory, combinedSearchString, pageInfo, ct));
+ }
- int startingPage = searchCategory.LastPageVisited > 0 ? searchCategory.LastPageVisited : 1;
- progress = progress with { SearchConfiguration = progress.SearchConfiguration with { StartingPageNumber = startingPage, }, };
+ private async Task> ProcessCategoryPageInfoAsync(Category searchCategory, string combinedSearchString, PageInfo pageInfo, CancellationToken ct)
+ {
+ progress = SearchProgressFunctions.UpdateTotalPages(progress, pageInfo.PageCount);
- logger.Debug("Visiting {Category} from page {StartingPage} now...", searchCategory.Name, startingPage);
- progress = SearchProgressFunctions.UpdateSubDirectory(progress, subDirectoryName);
+ if (searchCategory.IsUpToDate(pageInfo.ImageCount, pageInfo.PageCount))
+ {
+ logger.Information("{Category} is up to date (same image/page count), skipping...", searchCategory.Name);
+ await delayStrategy.DelayAsync(DelayKind.CategoryUpToDate, ct).ConfigureAwait(false);
- _ = directoryHelper.CreateDirectoryIfRequired([progress.ScrapeDirectories.RootDirectory.CombinePath(progress.ScrapeDirectories.BaseDirectory, subDirectoryName),]);
+ return Unit.Value;
+ }
- await ProcessAllCategoryPagesAsync(searchCategory, combinedSearchString, scrapeLogger, ct);
+ int startingPage = searchCategory.LastPageVisited > 0 ? searchCategory.LastPageVisited : 1;
+ progress = progress with { SearchConfiguration = progress.SearchConfiguration with { StartingPageNumber = startingPage, }, };
- searchCategory.LastKnownImageCount = imageCount;
- searchCategory.TotalPages = pageCount;
- searchCategory.LastPageVisited = 0;
- await configurationSaver.SaveUpdatedConfigurationAsync();
- }
+ logger.Debug("Visiting {Category} from page {StartingPage} now...", searchCategory.Name, startingPage);
+ progress = SearchProgressFunctions.UpdateSubDirectory(progress, pageInfo.SubDirectoryName);
+
+ _ = directoryHelper.CreateDirectoryIfRequired([progress.ScrapeDirectories.RootDirectory.CombinePath(progress.ScrapeDirectories.BaseDirectory, pageInfo.SubDirectoryName),]);
+
+ return await ProcessAllCategoryPagesAsync(searchCategory, combinedSearchString, ct)
+ .BindAsync(_ => SaveCategoryProgressAsync(searchCategory, pageInfo))
+ .ConfigureAwait(false);
}
- private async Task ProcessAllCategoryPagesAsync(Category searchCategory, string combinedSearchString, ILogger scrapeLogger, CancellationToken ct)
+ private Task> SaveCategoryProgressAsync(Category searchCategory, PageInfo pageInfo)
+ {
+ searchCategory.LastKnownImageCount = pageInfo.ImageCount;
+ searchCategory.TotalPages = pageInfo.PageCount;
+ searchCategory.LastPageVisited = 0;
+
+ return configurationSaver.SaveUpdatedConfigurationAsync();
+ }
+
+ private async Task> ProcessAllCategoryPagesAsync(Category searchCategory, string combinedSearchString, CancellationToken ct)
{
long startTimestamp = timeProvider.GetTimestamp();
- scrapeLogger.Debug("About to visit the specific {Category} pages now...", searchCategory.Name);
+ logger.Debug("About to visit the specific {Category} pages now...", searchCategory.Name);
for (int currentPageNumber = progress.SearchConfiguration.StartingPageNumber; currentPageNumber <= progress.SearchConfiguration.TotalPages; currentPageNumber++)
{
await delayStrategy.DelayAsync(DelayKind.PageNavigation, ct).ConfigureAwait(false);
- scrapeLogger.Debug("About to visit page {page} (of {totalPages}) for {Category} now...", currentPageNumber, progress.SearchConfiguration.TotalPages, searchCategory.Name);
+ logger.Debug("About to visit page {page} (of {totalPages}) for {Category} now...", currentPageNumber, progress.SearchConfiguration.TotalPages, searchCategory.Name);
progress = progress with { SearchConfiguration = progress.SearchConfiguration with { StartingPageNumber = currentPageNumber, }, };
searchCategory.LastPageVisited = currentPageNumber;
- await configurationSaver.SaveUpdatedConfigurationAsync();
- _ = await searchResultsPage.LoadSearchPageAsync(combinedSearchString, currentPageNumber);
- var imagePageLinks = await searchResultsPage.ImagePageLinksAsync();
- await imagePageService.GetTheImagePagesAsync(imagePageLinks, searchCategory.Id, searchCategory.Name, ct);
+ var pageResult = await configurationSaver.SaveUpdatedConfigurationAsync()
+ .BindAsync(_ => searchResultsPage.LoadSearchPageAsync(combinedSearchString, currentPageNumber))
+ .BindAsync(_ => searchResultsPage.ImagePageLinksAsync())
+ .BindAsync(links => imagePageService.GetTheImagePagesAsync(links, searchCategory.Id, searchCategory.Name, ct))
+ .ConfigureAwait(false);
+
+ var pageFailed = pageResult.Match(_ => false, _ => true);
+
+ if (pageFailed) return pageResult;
}
- scrapeLogger.Information("Completed visiting the {Category}. Total time: {CategoryVisitDuration}", searchCategory.Name, timeProvider.GetElapsedTime(startTimestamp));
+ logger.Information("Completed visiting the {Category}. Total time: {CategoryVisitDuration}", searchCategory.Name, timeProvider.GetElapsedTime(startTimestamp));
+
+ return Unit.Value;
}
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Workflows/SubscriptionsWorkflow.cs b/src/AStar.Dev.Wallpaper.Scrapper/Workflows/SubscriptionsWorkflow.cs
index 8985963..31c542d 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Workflows/SubscriptionsWorkflow.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Workflows/SubscriptionsWorkflow.cs
@@ -1,8 +1,8 @@
+using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Wallpaper.Scrapper.Models;
using AStar.Dev.Wallpaper.Scrapper.Pages;
using AStar.Dev.Wallpaper.Scrapper.Services;
using AStar.Dev.Wallpaper.Scrapper.Support;
-using Microsoft.Playwright;
using Serilog.Core;
namespace AStar.Dev.Wallpaper.Scrapper.Workflows;
@@ -22,7 +22,7 @@ public async Task RunAsync(CancellationToken ct = default)
{
try
{
- await GetTheNewSubscriptionImagesAsync(ct);
+ await GetTheNewSubscriptionImagesAsync(ct).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
@@ -34,17 +34,19 @@ public async Task RunAsync(CancellationToken ct = default)
private async Task GetTheNewSubscriptionImagesAsync(CancellationToken ct)
{
_searchConfiguration = _searchConfiguration with { SubscriptionsStartingPageNumber = 1 };
- var pageDetails = await subscriptionsImagesListPage.LoadSubscriptionResultsPageAsync(_searchConfiguration.SubscriptionsStartingPageNumber);
+ var pageDetails = await subscriptionsImagesListPage.LoadSubscriptionResultsPageAsync(_searchConfiguration.SubscriptionsStartingPageNumber).ConfigureAwait(false);
+ var loadedSuccessfully = pageDetails.Match(_ => true, _ => false);
- if (pageDetails is { Ok: false, }) _ = await subscriptionsImagesListPage.LoadSubscriptionResultsPageAsync(1);
+ if (!loadedSuccessfully) _ = await subscriptionsImagesListPage.LoadSubscriptionResultsPageAsync(1).ConfigureAwait(false);
- var (pageCount, subDirectoryName) = await subscriptionsImagesListPage.PageInfoAsync();
+ var pageInfo = (await subscriptionsImagesListPage.PageInfoAsync().ConfigureAwait(false))
+ .Match(info => info, error => throw new InvalidOperationException(error.Message));
- if (subDirectoryName.Length > 0) _scrapeDirectories = _scrapeDirectories with { SubDirectoryName = subDirectoryName };
+ if (pageInfo.SubDirectoryName.Length > 0) _scrapeDirectories = _scrapeDirectories with { SubDirectoryName = pageInfo.SubDirectoryName };
- UpdateSearchTotalPagesIfRequired(pageCount);
+ UpdateSearchTotalPagesIfRequired(pageInfo.PageCount);
- await configurationSaver.SaveUpdatedConfigurationAsync();
+ await configurationSaver.SaveUpdatedConfigurationAsync().ConfigureAwait(false);
for (int currentPageNumber = _searchConfiguration.SubscriptionsStartingPageNumber;
currentPageNumber <= _searchConfiguration.SubscriptionsTotalPages;
@@ -52,20 +54,23 @@ private async Task GetTheNewSubscriptionImagesAsync(CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
int delay = Random.Shared.Next(_searchConfiguration.ImagePauseInSeconds, _searchConfiguration.ImagePauseInSeconds + 4);
- await Task.Delay(TimeSpan.FromSeconds(delay), ct);
+ await Task.Delay(TimeSpan.FromSeconds(delay), ct).ConfigureAwait(false);
_searchConfiguration = _searchConfiguration with { SubscriptionsStartingPageNumber = currentPageNumber };
- await configurationSaver.SaveUpdatedConfigurationAsync();
+ await configurationSaver.SaveUpdatedConfigurationAsync().ConfigureAwait(false);
logger.Information("Getting page {subscriptionPage} (of {totalPagesForSubscriptions}) now.", currentPageNumber, _searchConfiguration.SubscriptionsTotalPages);
- _ = await subscriptionsImagesListPage.LoadSubscriptionResultsPageAsync(currentPageNumber);
- var imagePageLinks = await subscriptionsImagesListPage.GetImagePageLinksAsync();
+ _ = await subscriptionsImagesListPage.LoadSubscriptionResultsPageAsync(currentPageNumber).ConfigureAwait(false);
- await imagePageService.GetTheImagePagesAsync(imagePageLinks, "", subDirectoryName, ct: ct);
+ var imagePageLinks = (await subscriptionsImagesListPage.GetImagePageLinksAsync().ConfigureAwait(false))
+ .Match(links => links, error => throw new InvalidOperationException(error.Message));
+
+ _ = (await imagePageService.GetTheImagePagesAsync(imagePageLinks, "", pageInfo.SubDirectoryName, ct: ct).ConfigureAwait(false))
+ .Match(unit => unit, error => throw new InvalidOperationException(error.Message));
}
- if (pageCount > 0)
+ if (pageInfo.PageCount > 0)
{
- _ = await subscriptionsImagesListPage.LoadSubscriptionResultsPageAsync(1);
- await subscriptionsImagesListPage.ClearAsync();
+ _ = await subscriptionsImagesListPage.LoadSubscriptionResultsPageAsync(1).ConfigureAwait(false);
+ _ = await subscriptionsImagesListPage.ClearAsync().ConfigureAwait(false);
}
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Workflows/TopWallpapersWorkflow.cs b/src/AStar.Dev.Wallpaper.Scrapper/Workflows/TopWallpapersWorkflow.cs
index 1956b5f..5498e15 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/Workflows/TopWallpapersWorkflow.cs
+++ b/src/AStar.Dev.Wallpaper.Scrapper/Workflows/TopWallpapersWorkflow.cs
@@ -1,3 +1,4 @@
+using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Wallpaper.Scrapper.Models;
using AStar.Dev.Wallpaper.Scrapper.Pages;
using AStar.Dev.Wallpaper.Scrapper.Services;
@@ -19,7 +20,7 @@ public async Task RunAsync(CancellationToken ct = default)
{
try
{
- await GetTheNewTopWallpapersAsync(ct);
+ await GetTheNewTopWallpapersAsync(ct).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
@@ -30,15 +31,17 @@ public async Task RunAsync(CancellationToken ct = default)
private async Task GetTheNewTopWallpapersAsync(CancellationToken ct)
{
- var pageDetails = await topWallpapersPage.LoadTopWallpapersPageAsync(_searchConfiguration.TopWallpapersStartingPageNumber);
+ var pageDetails = await topWallpapersPage.LoadTopWallpapersPageAsync(_searchConfiguration.TopWallpapersStartingPageNumber).ConfigureAwait(false);
+ var loadedSuccessfully = pageDetails.Match(_ => true, _ => false);
- if (pageDetails is { Ok: false, }) _ = await topWallpapersPage.LoadTopWallpapersPageAsync(1);
+ if (!loadedSuccessfully) _ = await topWallpapersPage.LoadTopWallpapersPageAsync(1).ConfigureAwait(false);
- int pageCount = await topWallpapersPage.PageInfoAsync();
+ int pageCount = (await topWallpapersPage.PageInfoAsync().ConfigureAwait(false))
+ .Match(count => count, error => throw new InvalidOperationException(error.Message));
logger.Information("There are a total of {TopWallpapersPageCount} pages for the Top Wallpapers.", pageCount);
_searchConfiguration = _searchConfiguration with { TopWallpapersTotalPages = pageCount };
- await configurationSaver.SaveUpdatedConfigurationAsync();
+ await configurationSaver.SaveUpdatedConfigurationAsync().ConfigureAwait(false);
for (int currentPageNumber = _searchConfiguration.TopWallpapersStartingPageNumber;
currentPageNumber <= _searchConfiguration.TopWallpapersTotalPages;
@@ -46,13 +49,16 @@ private async Task GetTheNewTopWallpapersAsync(CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
int delay = Random.Shared.Next(_searchConfiguration.ImagePauseInSeconds, _searchConfiguration.ImagePauseInSeconds + 4);
- await Task.Delay(TimeSpan.FromSeconds(delay), ct);
+ await Task.Delay(TimeSpan.FromSeconds(delay), ct).ConfigureAwait(false);
_searchConfiguration = _searchConfiguration with { TopWallpapersStartingPageNumber = currentPageNumber };
- await configurationSaver.SaveUpdatedConfigurationAsync();
- _ = await topWallpapersPage.LoadTopWallpapersPageAsync(_searchConfiguration.TopWallpapersStartingPageNumber);
- var imagePageLinks = await topWallpapersPage.GetImagePageLinksAsync();
+ await configurationSaver.SaveUpdatedConfigurationAsync().ConfigureAwait(false);
+ _ = await topWallpapersPage.LoadTopWallpapersPageAsync(_searchConfiguration.TopWallpapersStartingPageNumber).ConfigureAwait(false);
- await imagePageService.GetTheImagePagesAsync(imagePageLinks, "", "", ct: ct);
+ var imagePageLinks = (await topWallpapersPage.GetImagePageLinksAsync().ConfigureAwait(false))
+ .Match(links => links, error => throw new InvalidOperationException(error.Message));
+
+ _ = (await imagePageService.GetTheImagePagesAsync(imagePageLinks, "", "", ct: ct).ConfigureAwait(false))
+ .Match(unit => unit, error => throw new InvalidOperationException(error.Message));
}
}
}
diff --git a/src/AStar.Dev.Wallpaper.Scrapper/appsettings.json b/src/AStar.Dev.Wallpaper.Scrapper/appsettings.json
index b1502d0..8ed5d86 100644
--- a/src/AStar.Dev.Wallpaper.Scrapper/appsettings.json
+++ b/src/AStar.Dev.Wallpaper.Scrapper/appsettings.json
@@ -1 +1,15 @@
-{"Logging":{"LogLevel":null,"Console":null,"Serilog":null}}
+{
+ "Logging":
+ {
+ "LogLevel":null,
+ "Console":null,
+ "Serilog":null
+ },
+ "scrapeConfiguration":
+ {
+ "connectionStrings":
+ {
+ "sqlite": "Data Source=/some/source/folder/some-database-file.db"
+ }
+ }
+}
diff --git a/test/AStar.Dev.FunctionalParadigm.Tests.Unit/GivenRetryOnceAsync.cs b/test/AStar.Dev.FunctionalParadigm.Tests.Unit/GivenRetryOnceAsync.cs
new file mode 100644
index 0000000..2e48554
--- /dev/null
+++ b/test/AStar.Dev.FunctionalParadigm.Tests.Unit/GivenRetryOnceAsync.cs
@@ -0,0 +1,159 @@
+using Shouldly;
+using Xunit;
+
+namespace AStar.Dev.FunctionalParadigm.Tests.Unit;
+
+public sealed class GivenRetryOnceAsync
+{
+ [Fact]
+ public async Task when_the_first_attempt_succeeds_then_the_result_is_the_first_attempts_success()
+ {
+ var actual = await RetryExtensions.RetryOnceAsync(
+ () => Task.FromResult(Result.Success(1)),
+ () => Task.CompletedTask);
+
+ actual.ShouldBe(new Ok(1));
+ }
+
+ [Fact]
+ public async Task when_the_first_attempt_succeeds_then_the_operation_is_invoked_exactly_once()
+ {
+ var attempts = 0;
+
+ await RetryExtensions.RetryOnceAsync(
+ () =>
+ {
+ attempts++;
+
+ return Task.FromResult(Result.Success(1));
+ },
+ () => Task.CompletedTask);
+
+ attempts.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task when_the_first_attempt_succeeds_then_the_retry_callback_is_never_invoked()
+ {
+ var retryInvoked = false;
+
+ await RetryExtensions.RetryOnceAsync(
+ () => Task.FromResult(Result.Success(1)),
+ () =>
+ {
+ retryInvoked = true;
+
+ return Task.CompletedTask;
+ });
+
+ retryInvoked.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task when_the_first_attempt_fails_and_the_second_succeeds_then_the_result_is_the_second_attempts_success()
+ {
+ var attempts = 0;
+
+ var actual = await RetryExtensions.RetryOnceAsync(
+ () =>
+ {
+ attempts++;
+
+ return Task.FromResult(attempts == 1 ? Result.Failure("first-failed") : Result.Success(2));
+ },
+ () => Task.CompletedTask);
+
+ actual.ShouldBe(new Ok(2));
+ }
+
+ [Fact]
+ public async Task when_the_first_attempt_fails_and_the_second_succeeds_then_the_operation_is_invoked_exactly_twice()
+ {
+ var attempts = 0;
+
+ await RetryExtensions.RetryOnceAsync(
+ () =>
+ {
+ attempts++;
+
+ return Task.FromResult(attempts == 1 ? Result.Failure("first-failed") : Result.Success(2));
+ },
+ () => Task.CompletedTask);
+
+ attempts.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task when_the_first_attempt_fails_and_the_second_succeeds_then_the_retry_callback_is_invoked_exactly_once()
+ {
+ var retryInvocations = 0;
+ var attempts = 0;
+
+ await RetryExtensions.RetryOnceAsync(
+ () =>
+ {
+ attempts++;
+
+ return Task.FromResult(attempts == 1 ? Result.Failure("first-failed") : Result.Success(2));
+ },
+ () =>
+ {
+ retryInvocations++;
+
+ return Task.CompletedTask;
+ });
+
+ retryInvocations.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task when_both_attempts_fail_then_the_result_is_the_second_attempts_failure()
+ {
+ var attempts = 0;
+
+ var actual = await RetryExtensions.RetryOnceAsync(
+ () =>
+ {
+ attempts++;
+
+ return Task.FromResult(Result.Failure($"failed-{attempts}"));
+ },
+ () => Task.CompletedTask);
+
+ actual.ShouldBe(new Fail("failed-2"));
+ }
+
+ [Fact]
+ public async Task when_both_attempts_fail_then_the_operation_is_invoked_exactly_twice()
+ {
+ var attempts = 0;
+
+ await RetryExtensions.RetryOnceAsync(
+ () =>
+ {
+ attempts++;
+
+ return Task.FromResult(Result.Failure($"failed-{attempts}"));
+ },
+ () => Task.CompletedTask);
+
+ attempts.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task when_both_attempts_fail_then_the_retry_callback_is_invoked_exactly_once()
+ {
+ var retryInvocations = 0;
+
+ await RetryExtensions.RetryOnceAsync(
+ () => Task.FromResult(Result.Failure("failed")),
+ () =>
+ {
+ retryInvocations++;
+
+ return Task.CompletedTask;
+ });
+
+ retryInvocations.ShouldBe(1);
+ }
+}
diff --git a/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/DTOs/GivenTheFileClassificationDtoValidator.cs b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/DTOs/GivenTheFileClassificationDtoValidator.cs
new file mode 100644
index 0000000..17dfe1a
--- /dev/null
+++ b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/DTOs/GivenTheFileClassificationDtoValidator.cs
@@ -0,0 +1,70 @@
+using AStar.Dev.FunctionalParadigm;
+using AStar.Dev.Wallpaper.Scrapper.DTOs;
+
+namespace AStar.Dev.Wallpaper.Scrapper.Tests.Unit.DTOs;
+
+public sealed class GivenTheFileClassificationDtoValidator
+{
+ private static FileClassification BuildDto(string name, int level) => new() { Name = name, Level = level, };
+
+ [Fact]
+ public void when_the_name_and_level_are_valid_then_the_result_is_valid() =>
+ FileClassificationDtoValidator.Validate(BuildDto("Animals", 3), 0).ShouldBeOfType>();
+
+ [Fact]
+ public void when_the_name_is_empty_then_the_result_is_invalid() =>
+ FileClassificationDtoValidator.Validate(BuildDto("", 3), 0).ShouldBeOfType>();
+
+ [Fact]
+ public void when_the_name_is_empty_then_the_error_property_identifies_the_row_and_field() =>
+ FileClassificationDtoValidator.Validate(BuildDto("", 3), 2).ShouldBeOfType>()
+ .Errors.ShouldHaveSingleItem().Property.ShouldBe("Categories[2].Name");
+
+ [Fact]
+ public void when_the_level_is_below_the_minimum_then_the_result_is_invalid() =>
+ FileClassificationDtoValidator.Validate(BuildDto("Animals", 0), 0).ShouldBeOfType>();
+
+ [Fact]
+ public void when_the_level_is_above_the_maximum_then_the_result_is_invalid() =>
+ FileClassificationDtoValidator.Validate(BuildDto("Animals", 4), 0).ShouldBeOfType>();
+
+ [Fact]
+ public void when_the_level_is_invalid_then_the_error_property_identifies_the_row_and_field() =>
+ FileClassificationDtoValidator.Validate(BuildDto("Animals", 9), 1).ShouldBeOfType>()
+ .Errors.ShouldHaveSingleItem().Property.ShouldBe("Categories[1].Level");
+
+ [Fact]
+ public void when_both_the_name_and_level_are_invalid_then_both_errors_are_accumulated() =>
+ FileClassificationDtoValidator.Validate(BuildDto("", 0), 0).ShouldBeOfType>()
+ .Errors.Count.ShouldBe(2);
+
+ [Fact]
+ public void when_validating_all_rows_and_all_are_valid_then_the_result_is_valid()
+ {
+ FileClassification[] dtos = [BuildDto("Animals", 3), BuildDto("Cars", 2),];
+
+ FileClassificationDtoValidator.ValidateAll(dtos).ShouldBeOfType>>();
+ }
+
+ [Fact]
+ public void when_validating_all_rows_and_three_rows_are_invalid_then_all_three_errors_are_reported()
+ {
+ FileClassification[] dtos = [BuildDto("", 3), BuildDto("Valid", 0), BuildDto("Another Valid", 9),];
+
+ var errors = FileClassificationDtoValidator.ValidateAll(dtos).ShouldBeOfType>>().Errors;
+
+ errors.Count.ShouldBe(3);
+ }
+
+ [Fact]
+ public void when_validating_all_rows_and_three_rows_are_invalid_then_the_errors_are_reported_in_row_order()
+ {
+ FileClassification[] dtos = [BuildDto("", 3), BuildDto("Valid", 0), BuildDto("Another Valid", 9),];
+
+ var errors = FileClassificationDtoValidator.ValidateAll(dtos).ShouldBeOfType>>().Errors;
+
+ errors[0].Property.ShouldBe("Categories[0].Name");
+ errors[1].Property.ShouldBe("Categories[1].Level");
+ errors[2].Property.ShouldBe("Categories[2].Level");
+ }
+}
diff --git a/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Models/GivenTheImagePageOutcomeFactory.cs b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Models/GivenTheImagePageOutcomeFactory.cs
new file mode 100644
index 0000000..81a09ae
--- /dev/null
+++ b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Models/GivenTheImagePageOutcomeFactory.cs
@@ -0,0 +1,37 @@
+using AStar.Dev.Wallpaper.Scrapper.Models;
+using AStar.Dev.Wallpaper.Scrapper.Repositories;
+
+namespace AStar.Dev.Wallpaper.Scrapper.Tests.Unit.Models;
+
+public sealed class GivenTheImagePageOutcomeFactory
+{
+ private static readonly TagData[] RawTags = [new("Sports Car", "Vehicles > Cars & Motorcycles"),];
+
+ [Fact]
+ public void when_creating_a_scraped_image_then_the_image_url_is_preserved() =>
+ ImagePageOutcomeFactory.CreateScrapedImage("https://example.test/image.jpg", ["dir",], "prefix", ["tag",], RawTags).ImageUrl.ShouldBe("https://example.test/image.jpg");
+
+ [Fact]
+ public void when_creating_a_scraped_image_then_the_directory_segments_are_preserved() =>
+ ImagePageOutcomeFactory.CreateScrapedImage("https://example.test/image.jpg", ["dir",], "prefix", ["tag",], RawTags).DirectorySegments.ShouldBe(["dir",]);
+
+ [Fact]
+ public void when_creating_a_scraped_image_then_the_file_prefix_is_preserved() =>
+ ImagePageOutcomeFactory.CreateScrapedImage("https://example.test/image.jpg", ["dir",], "prefix", ["tag",], RawTags).FilePrefix.ShouldBe("prefix");
+
+ [Fact]
+ public void when_creating_a_scraped_image_then_the_tags_are_preserved() =>
+ ImagePageOutcomeFactory.CreateScrapedImage("https://example.test/image.jpg", ["dir",], "prefix", ["tag",], RawTags).Tags.ShouldBe(["tag",]);
+
+ [Fact]
+ public void when_creating_a_scraped_image_then_the_raw_tags_are_preserved() =>
+ ImagePageOutcomeFactory.CreateScrapedImage("https://example.test/image.jpg", ["dir",], "prefix", ["tag",], RawTags).RawTags.ShouldBe(RawTags);
+
+ [Fact]
+ public void when_creating_a_skipped_image_then_the_tags_are_preserved() =>
+ ImagePageOutcomeFactory.CreateSkippedImage(["ignored-tag",], RawTags).Tags.ShouldBe(["ignored-tag",]);
+
+ [Fact]
+ public void when_creating_a_skipped_image_then_the_raw_tags_are_preserved() =>
+ ImagePageOutcomeFactory.CreateSkippedImage(["ignored-tag",], RawTags).RawTags.ShouldBe(RawTags);
+}
diff --git a/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Models/GivenTheScrapeErrorFactory.cs b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Models/GivenTheScrapeErrorFactory.cs
index dba15ca..f725ef0 100644
--- a/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Models/GivenTheScrapeErrorFactory.cs
+++ b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Models/GivenTheScrapeErrorFactory.cs
@@ -1,3 +1,4 @@
+using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Wallpaper.Scrapper.Models;
namespace AStar.Dev.Wallpaper.Scrapper.Tests.Unit.Models;
@@ -47,4 +48,36 @@ public void when_creating_an_unexpected_error_then_the_exception_is_preserved()
[Fact]
public void when_creating_an_unexpected_error_then_the_message_is_the_exception_message() =>
ScrapeErrorFactory.CreateUnexpectedError(new InvalidOperationException("boom")).Message.ShouldBe("boom");
+
+ [Fact]
+ public void when_creating_an_image_dimension_read_failed_error_then_the_path_is_preserved() =>
+ ScrapeErrorFactory.CreateImageDimensionReadFailed("/some/path/image.jpg", "could not read dimensions").Path.ShouldBe("/some/path/image.jpg");
+
+ [Fact]
+ public void when_creating_an_image_dimension_read_failed_error_then_the_message_is_preserved() =>
+ ScrapeErrorFactory.CreateImageDimensionReadFailed("/some/path/image.jpg", "could not read dimensions").Message.ShouldBe("could not read dimensions");
+
+ [Fact]
+ public void when_creating_an_import_failed_error_then_the_file_path_is_preserved() =>
+ ScrapeErrorFactory.CreateImportFailed("/some/path/export.json", "file not found").FilePath.ShouldBe("/some/path/export.json");
+
+ [Fact]
+ public void when_creating_an_import_failed_error_then_the_message_is_preserved() =>
+ ScrapeErrorFactory.CreateImportFailed("/some/path/export.json", "file not found").Message.ShouldBe("file not found");
+
+ [Fact]
+ public void when_creating_a_validation_failed_error_then_the_errors_are_preserved()
+ {
+ ValidationError[] errors = [new("Name", "Name is required"), new("Level", "Level must be between 1 and 3"),];
+
+ ScrapeErrorFactory.CreateValidationFailed(errors, "validation failed").Errors.ShouldBe(errors);
+ }
+
+ [Fact]
+ public void when_creating_a_validation_failed_error_then_the_message_is_preserved()
+ {
+ ValidationError[] errors = [new("Name", "Name is required"),];
+
+ ScrapeErrorFactory.CreateValidationFailed(errors, "validation failed").Message.ShouldBe("validation failed");
+ }
}
diff --git a/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Pages/GivenASearchResultsPage.cs b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Pages/GivenASearchResultsPage.cs
new file mode 100644
index 0000000..32b31b6
--- /dev/null
+++ b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Pages/GivenASearchResultsPage.cs
@@ -0,0 +1,173 @@
+using NSubstitute.ExceptionExtensions;
+using AStar.Dev.FunctionalParadigm;
+using AStar.Dev.Wallpaper.Scrapper.Models;
+using AStar.Dev.Wallpaper.Scrapper.Pages;
+using AStar.Dev.Wallpaper.Scrapper.Services;
+using Microsoft.Playwright;
+
+namespace AStar.Dev.Wallpaper.Scrapper.Tests.Unit.Pages;
+
+public sealed class GivenASearchResultsPage
+{
+ private const string SearchString = "https://example.test/search/cars/?page=";
+ private const int PageNumber = 3;
+
+ private static SearchResultsPage BuildSut(IPage page)
+ {
+ var playwrightService = Substitute.For();
+ playwrightService.ConfigurePlaywrightAsync().Returns(Task.FromResult(page));
+
+ return new SearchResultsPage(playwrightService);
+ }
+
+ private static IPage BuildPageReturning(IResponse? response)
+ {
+ var page = Substitute.For();
+ page.GotoAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(response));
+
+ return page;
+ }
+
+ [Fact]
+ public async Task when_the_first_navigation_attempt_succeeds_then_the_result_is_ok()
+ {
+ var okResponse = Substitute.For();
+ okResponse.Ok.Returns(true);
+ var page = BuildPageReturning(okResponse);
+ var sut = BuildSut(page);
+
+ var result = await sut.LoadSearchPageAsync(SearchString, PageNumber);
+
+ result.ShouldBeOfType>();
+ }
+
+ [Fact]
+ public async Task when_the_first_navigation_attempt_succeeds_then_goto_async_is_called_exactly_once()
+ {
+ var okResponse = Substitute.For();
+ okResponse.Ok.Returns(true);
+ var page = BuildPageReturning(okResponse);
+ var sut = BuildSut(page);
+
+ await sut.LoadSearchPageAsync(SearchString, PageNumber);
+
+ await page.Received(1).GotoAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task when_both_navigation_attempts_report_not_ok_then_goto_async_is_called_exactly_twice()
+ {
+ var notOkResponse = Substitute.For();
+ notOkResponse.Ok.Returns(false);
+ var page = BuildPageReturning(notOkResponse);
+ var sut = BuildSut(page);
+
+ await sut.LoadSearchPageAsync(SearchString, PageNumber);
+
+ await page.Received(2).GotoAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task when_both_navigation_attempts_report_not_ok_then_a_page_load_failed_error_is_returned()
+ {
+ var notOkResponse = Substitute.For();
+ notOkResponse.Ok.Returns(false);
+ var page = BuildPageReturning(notOkResponse);
+ var sut = BuildSut(page);
+
+ var result = await sut.LoadSearchPageAsync(SearchString, PageNumber);
+
+ result.ShouldBeOfType>().Error.ShouldBeOfType();
+ }
+
+ [Fact]
+ public async Task when_both_navigation_attempts_report_not_ok_then_the_page_load_failed_error_carries_the_full_url()
+ {
+ var notOkResponse = Substitute.For();
+ notOkResponse.Ok.Returns(false);
+ var page = BuildPageReturning(notOkResponse);
+ var sut = BuildSut(page);
+
+ var result = await sut.LoadSearchPageAsync(SearchString, PageNumber);
+
+ result.ShouldBeOfType