From e662cd11e70a2e308eb15b5ffe1f5464ea783b10 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 14 Jan 2026 18:56:20 +0100 Subject: [PATCH 1/2] feat(content-pipeline): implement universal content pipeline and creator publishing system - Standardize content discovery, resolution, and acquisition across multiple sources (GitHub, ModDB, CNCLabs, AODMaps). - Introduce Creator Publishing system with self-hosted JSON catalogs and subscriptions. - Redesign Downloads browser with publisher-specific filters and sidebar navigation. - Implement Playwright-based scraping for robust web discovery. - Extensive documentation and architectural flowcharts for the new system. --- GenHub/GenHub.Core/GenHub.Core.csproj | 1 + .../Providers/IPublisherCatalogParser.cs | 33 + .../IPublisherCatalogRefreshService.cs | 24 + .../Providers/IPublisherSubscriptionStore.cs | 68 ++ .../Interfaces/Providers/IVersionSelector.cs | 31 + .../Interfaces/Providers/VersionPolicy.cs | 22 + .../Messages/CloseContentDetailMessage.cs | 10 + .../Messages/ClosePublisherDetailsMessage.cs | 10 + .../Messages/OpenPublisherDetailsMessage.cs | 11 + .../Models/Providers/CatalogContentItem.cs | 60 ++ .../Models/Providers/CatalogDependency.cs | 39 + .../Models/Providers/ContentRelease.cs | 54 ++ .../Models/Providers/ContentRichMetadata.cs | 45 + .../Models/Providers/PublisherCatalog.cs | 46 + .../Models/Providers/PublisherProfile.cs | 46 + .../Models/Providers/PublisherReferral.cs | 28 + .../Models/Providers/PublisherSubscription.cs | 79 ++ .../PublisherSubscriptionCollection.cs | 21 + .../Models/Providers/ReleaseArtifact.cs | 47 + .../ConfigurationProviderServiceTests.cs | 4 +- .../Content/CNCLabsMapDiscovererTests.cs | 43 + .../Features/Content/GitHubResolverTests.cs | 1 + .../GameClientManifestIntegrationTests.cs | 17 +- .../ViewModels/MainViewModelTests.cs | 33 +- .../ViewModels/SettingsViewModelTests.cs | 38 + .../Manifest/ContentManifestBuilderTests.cs | 17 +- .../ManifestGenerationServiceTests.cs | 6 +- .../SharedViewModelModuleTests.cs | 2 +- GenHub/GenHub/App.axaml.cs | 79 +- .../GenHub/Common/ViewModels/MainViewModel.cs | 8 +- GenHub/GenHub/Common/Views/MainView.axaml | 3 + GenHub/GenHub/Common/Views/MainWindow.axaml | 13 +- .../GenHub/Common/Views/MainWindow.axaml.cs | 54 ++ .../Catalog/GenericCatalogDiscoverer.cs | 260 +++++ .../Catalog/GenericCatalogResolver.cs | 143 +++ .../Catalog/JsonPublisherCatalogParser.cs | 172 ++++ .../Catalog/PublisherCatalogRefreshService.cs | 104 ++ .../Catalog/PublisherSubscriptionStore.cs | 279 ++++++ .../Services/Catalog/VersionSelector.cs | 88 ++ .../ContentDeliverers/FileSystemDeliverer.cs | 1 - .../FileSystemDiscoverer.cs | 36 +- .../ContentDiscoverers/ModDBDiscoverer.cs | 399 ++++---- .../ContentResolvers/AODMapsResolver.cs | 3 +- .../ContentResolvers/CNCLabsMapResolver.cs | 10 +- .../ContentResolvers/ModDBResolver.cs | 229 ++--- .../GeneralsOnlineDiscoverer.cs | 6 +- .../GeneralsOnlineJsonCatalogParser.cs | 15 +- .../GitHub/GitHubReleasesDiscoverer.cs | 5 +- .../Services/Parsers/ModDBPageParser.cs | 894 ++++++++++++++++++ .../Publishers/AODMapsManifestFactory.cs | 2 - .../Publishers/CNCLabsManifestFactory.cs | 33 +- .../Publishers/ModDBManifestFactory.cs | 50 +- .../Services/Tools/PlaywrightService.cs | 243 +++++ .../SubscriptionConfirmationViewModel.cs | 139 +++ .../ViewModels/ContentDetailViewModel.cs | 556 +++++++++++ .../ViewModels/ContentGridItemViewModel.cs | 260 +++++ ...loadsBrowserViewModel.InstallationCheck.cs | 15 + .../ViewModels/DownloadsBrowserViewModel.cs | 640 +++++++++++++ .../ViewModels/DownloadsViewModel.cs | 114 ++- .../Filters/AODMapsFilterViewModel.cs | 82 ++ .../Filters/CNCLabsFilterViewModel.cs | 223 +++++ .../CommunityOutpostFilterViewModel.cs | 101 ++ .../Filters/FilterPanelViewModelBase.cs | 66 ++ .../Filters/GitHubFilterViewModel.cs | 133 +++ .../Filters/IFilterPanelViewModel.cs | 57 ++ .../Filters/ModDBFilterViewModel.cs | 298 ++++++ .../ViewModels/Filters/ModDBSection.cs | 16 + .../Filters/StaticPublisherFilterViewModel.cs | 109 +++ .../Filters/SuperHackersFilterViewModel.cs | 100 ++ .../ViewModels/InstallableVariant.cs | 30 + .../ViewModels/PublisherItemViewModel.cs | 53 ++ .../Downloads/Views/ContentCardView.axaml | 208 ++++ .../Downloads/Views/ContentCardView.axaml.cs | 17 + .../Downloads/Views/ContentDetailView.axaml | 372 ++++++++ .../Views/ContentDetailView.axaml.cs | 17 + .../Views/DownloadsBrowserView.axaml | 229 +++++ .../Views/DownloadsBrowserView.axaml.cs | 17 + .../Downloads/Views/DownloadsView.axaml | 145 ++- .../Downloads/Views/FilterPanelView.axaml | 354 +++++++ .../Downloads/Views/FilterPanelView.axaml.cs | 17 + .../Views/PublisherSidebarView.axaml | 105 ++ .../Views/PublisherSidebarView.axaml.cs | 17 + .../SubscriptionConfirmationDialog.axaml | 157 +++ .../SubscriptionConfirmationDialog.axaml.cs | 45 + .../Settings/ViewModels/SettingsViewModel.cs | 126 +++ .../Settings/Views/SettingsView.axaml | 77 +- .../Settings/Views/SettingsView.axaml.cs | 1 + .../ContentPipelineModule.cs | 203 ++-- .../SharedViewModelModule.cs | 13 +- docs/.vitepress/config.js | 3 +- docs/FlowCharts/Discovery-Flow.md | 22 +- docs/FlowCharts/Downloads-Flow.md | 252 +++++ docs/FlowCharts/index.md | 3 +- docs/dev/constants.md | 82 ++ docs/dev/contribution-guidelines.md | 45 + docs/dev/creator-publishing-roadmap.md | 433 +++++++++ docs/dev/models.md | 68 ++ docs/features/content/content-dependencies.md | 201 ++++ docs/features/content/content-pipeline.md | 415 ++++++++ docs/features/content/creator-publishing.md | 408 ++++++++ docs/features/content/index.md | 13 +- .../content/provider-configuration.md | 42 +- docs/features/content/universal-parser.md | 441 +++++++++ 103 files changed, 10915 insertions(+), 590 deletions(-) create mode 100644 GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogParser.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogRefreshService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Providers/IPublisherSubscriptionStore.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Providers/IVersionSelector.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Providers/VersionPolicy.cs create mode 100644 GenHub/GenHub.Core/Messages/CloseContentDetailMessage.cs create mode 100644 GenHub/GenHub.Core/Messages/ClosePublisherDetailsMessage.cs create mode 100644 GenHub/GenHub.Core/Messages/OpenPublisherDetailsMessage.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/CatalogContentItem.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/CatalogDependency.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/ContentRelease.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/ContentRichMetadata.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/PublisherCatalog.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/PublisherProfile.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/PublisherReferral.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/PublisherSubscription.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/PublisherSubscriptionCollection.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/ReleaseArtifact.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/GenericCatalogDiscoverer.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/GenericCatalogResolver.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/JsonPublisherCatalogParser.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/PublisherCatalogRefreshService.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/PublisherSubscriptionStore.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/VersionSelector.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Parsers/ModDBPageParser.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Tools/PlaywrightService.cs create mode 100644 GenHub/GenHub/Features/Content/ViewModels/Catalog/SubscriptionConfirmationViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/ContentDetailViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/ContentGridItemViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/DownloadsBrowserViewModel.InstallationCheck.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/DownloadsBrowserViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/AODMapsFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/CNCLabsFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/CommunityOutpostFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/FilterPanelViewModelBase.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/GitHubFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/IFilterPanelViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/ModDBFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/ModDBSection.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/StaticPublisherFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/SuperHackersFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/InstallableVariant.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/PublisherItemViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/Views/ContentCardView.axaml create mode 100644 GenHub/GenHub/Features/Downloads/Views/ContentCardView.axaml.cs create mode 100644 GenHub/GenHub/Features/Downloads/Views/ContentDetailView.axaml create mode 100644 GenHub/GenHub/Features/Downloads/Views/ContentDetailView.axaml.cs create mode 100644 GenHub/GenHub/Features/Downloads/Views/DownloadsBrowserView.axaml create mode 100644 GenHub/GenHub/Features/Downloads/Views/DownloadsBrowserView.axaml.cs create mode 100644 GenHub/GenHub/Features/Downloads/Views/FilterPanelView.axaml create mode 100644 GenHub/GenHub/Features/Downloads/Views/FilterPanelView.axaml.cs create mode 100644 GenHub/GenHub/Features/Downloads/Views/PublisherSidebarView.axaml create mode 100644 GenHub/GenHub/Features/Downloads/Views/PublisherSidebarView.axaml.cs create mode 100644 GenHub/GenHub/Features/Downloads/Views/SubscriptionConfirmationDialog.axaml create mode 100644 GenHub/GenHub/Features/Downloads/Views/SubscriptionConfirmationDialog.axaml.cs create mode 100644 docs/FlowCharts/Downloads-Flow.md create mode 100644 docs/dev/contribution-guidelines.md create mode 100644 docs/dev/creator-publishing-roadmap.md create mode 100644 docs/features/content/content-dependencies.md create mode 100644 docs/features/content/content-pipeline.md create mode 100644 docs/features/content/creator-publishing.md create mode 100644 docs/features/content/universal-parser.md diff --git a/GenHub/GenHub.Core/GenHub.Core.csproj b/GenHub/GenHub.Core/GenHub.Core.csproj index b11a95605..751c33938 100644 --- a/GenHub/GenHub.Core/GenHub.Core.csproj +++ b/GenHub/GenHub.Core/GenHub.Core.csproj @@ -13,6 +13,7 @@ + diff --git a/GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogParser.cs b/GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogParser.cs new file mode 100644 index 000000000..0c94b9dcc --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogParser.cs @@ -0,0 +1,33 @@ +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results; + +namespace GenHub.Core.Interfaces.Providers; + +/// +/// Parses publisher catalog JSON into structured models. +/// +public interface IPublisherCatalogParser +{ + /// + /// Parses a catalog from JSON content. + /// + /// The raw JSON content of the catalog. + /// Cancellation token. + /// The parsed catalog or an error. + Task> ParseCatalogAsync(string catalogJson, CancellationToken cancellationToken = default); + + /// + /// Validates that a catalog conforms to the expected schema version. + /// + /// The catalog to validate. + /// Validation result with any errors. + OperationResult ValidateCatalog(PublisherCatalog catalog); + + /// + /// Verifies the catalog signature if present. + /// + /// The raw JSON content. + /// The parsed catalog with signature field. + /// True if signature is valid or not required. + bool VerifySignature(string catalogJson, PublisherCatalog catalog); +} diff --git a/GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogRefreshService.cs b/GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogRefreshService.cs new file mode 100644 index 000000000..83bc839eb --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogRefreshService.cs @@ -0,0 +1,24 @@ +using GenHub.Core.Models.Results; + +namespace GenHub.Core.Interfaces.Providers; + +/// +/// Service for refreshing subscribed publisher catalogs. +/// +public interface IPublisherCatalogRefreshService +{ + /// + /// Refreshes all subscribed catalogs. + /// + /// Cancellation token. + /// Summary of the refresh operation. + Task> RefreshAllAsync(CancellationToken cancellationToken = default); + + /// + /// Refreshes a specific publisher's catalog. + /// + /// The publisher identifier. + /// Cancellation token. + /// True if refreshed, false otherwise. + Task> RefreshPublisherAsync(string publisherId, CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Providers/IPublisherSubscriptionStore.cs b/GenHub/GenHub.Core/Interfaces/Providers/IPublisherSubscriptionStore.cs new file mode 100644 index 000000000..785983806 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Providers/IPublisherSubscriptionStore.cs @@ -0,0 +1,68 @@ +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results; + +namespace GenHub.Core.Interfaces.Providers; + +/// +/// Manages user subscriptions to publisher catalogs. +/// Subscriptions are stored locally and enable discovery of creator content. +/// +public interface IPublisherSubscriptionStore +{ + /// + /// Gets all active publisher subscriptions. + /// + /// Cancellation token. + /// List of active subscriptions. + Task>> GetSubscriptionsAsync(CancellationToken cancellationToken = default); + + /// + /// Gets a specific subscription by publisher ID. + /// + /// The publisher identifier. + /// Cancellation token. + /// The subscription if found, null otherwise. + Task> GetSubscriptionAsync(string publisherId, CancellationToken cancellationToken = default); + + /// + /// Adds a new publisher subscription. + /// + /// The subscription to add. + /// Cancellation token. + /// Operation result indicating success or failure. + Task> AddSubscriptionAsync(PublisherSubscription subscription, CancellationToken cancellationToken = default); + + /// + /// Removes a publisher subscription. + /// + /// The publisher identifier to remove. + /// Cancellation token. + /// Operation result indicating success or failure. + Task> RemoveSubscriptionAsync(string publisherId, CancellationToken cancellationToken = default); + + /// + /// Updates an existing subscription. + /// + /// The updated subscription data. + /// Cancellation token. + /// Operation result indicating success or failure. + Task> UpdateSubscriptionAsync(PublisherSubscription subscription, CancellationToken cancellationToken = default); + + /// + /// Checks if a publisher subscription exists. + /// + /// The publisher identifier. + /// Cancellation token. + /// True if subscribed, false otherwise. + Task> IsSubscribedAsync(string publisherId, CancellationToken cancellationToken = default); + + /// + /// Updates the trust level for a publisher. + /// + /// The publisher identifier. + /// The new trust level. + /// Cancellation token. + /// Operation result indicating success or failure. + Task> UpdateTrustLevelAsync(string publisherId, TrustLevel trustLevel, CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Providers/IVersionSelector.cs b/GenHub/GenHub.Core/Interfaces/Providers/IVersionSelector.cs new file mode 100644 index 000000000..a7100b52e --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Providers/IVersionSelector.cs @@ -0,0 +1,31 @@ +using GenHub.Core.Models.Providers; + +namespace GenHub.Core.Interfaces.Providers; + +/// +/// Filters content releases based on version display policy. +/// +public interface IVersionSelector +{ + /// + /// Selects releases based on the specified policy. + /// + /// All available releases. + /// The version selection policy. + /// Filtered releases according to policy. + IEnumerable SelectReleases(IEnumerable releases, VersionPolicy policy); + + /// + /// Gets the latest stable release from a collection. + /// + /// All available releases. + /// The latest stable release, or null if none exist. + ContentRelease? GetLatestStable(IEnumerable releases); + + /// + /// Gets the latest release (including prereleases) from a collection. + /// + /// All available releases. + /// The latest release, or null if none exist. + ContentRelease? GetLatest(IEnumerable releases); +} diff --git a/GenHub/GenHub.Core/Interfaces/Providers/VersionPolicy.cs b/GenHub/GenHub.Core/Interfaces/Providers/VersionPolicy.cs new file mode 100644 index 000000000..bd8b7f8d1 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Providers/VersionPolicy.cs @@ -0,0 +1,22 @@ +namespace GenHub.Core.Interfaces.Providers; + +/// +/// Defines the version filtering policy for content display. +/// +public enum VersionPolicy +{ + /// + /// Show only the latest stable release (default). + /// + LatestStableOnly, + + /// + /// Show all versions including older releases. + /// + AllVersions, + + /// + /// Include prerelease versions in addition to stable releases. + /// + IncludePrereleases, +} diff --git a/GenHub/GenHub.Core/Messages/CloseContentDetailMessage.cs b/GenHub/GenHub.Core/Messages/CloseContentDetailMessage.cs new file mode 100644 index 000000000..86df06824 --- /dev/null +++ b/GenHub/GenHub.Core/Messages/CloseContentDetailMessage.cs @@ -0,0 +1,10 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace GenHub.Core.Messages; + +/// +/// Message sent when a user wants to close the content details view. +/// +public class CloseContentDetailMessage() : ValueChangedMessage(true) +{ +} diff --git a/GenHub/GenHub.Core/Messages/ClosePublisherDetailsMessage.cs b/GenHub/GenHub.Core/Messages/ClosePublisherDetailsMessage.cs new file mode 100644 index 000000000..5965d242c --- /dev/null +++ b/GenHub/GenHub.Core/Messages/ClosePublisherDetailsMessage.cs @@ -0,0 +1,10 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace GenHub.Core.Messages; + +/// +/// Message sent when a user wants to close the publisher details view and return to the dashboard. +/// +public class ClosePublisherDetailsMessage() : ValueChangedMessage(true) +{ +} diff --git a/GenHub/GenHub.Core/Messages/OpenPublisherDetailsMessage.cs b/GenHub/GenHub.Core/Messages/OpenPublisherDetailsMessage.cs new file mode 100644 index 000000000..9b56b1d6d --- /dev/null +++ b/GenHub/GenHub.Core/Messages/OpenPublisherDetailsMessage.cs @@ -0,0 +1,11 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace GenHub.Core.Messages; + +/// +/// Message sent when a user wants to view details for a specific publisher. +/// +/// The ID of the publisher to view. +public class OpenPublisherDetailsMessage(string publisherId) : ValueChangedMessage(publisherId) +{ +} diff --git a/GenHub/GenHub.Core/Models/Providers/CatalogContentItem.cs b/GenHub/GenHub.Core/Models/Providers/CatalogContentItem.cs new file mode 100644 index 000000000..a9b8b3ec3 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/CatalogContentItem.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Serialization; +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Models.Providers; + +/// +/// A content item entry within a publisher catalog. +/// Represents a mod, map, addon, or other content with one or more releases. +/// +public class CatalogContentItem +{ + /// + /// Gets or sets the unique content identifier within this publisher's catalog. + /// Combined with publisher ID to form the full manifest ID. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the human-readable content name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the content description. + /// + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets the content type (Mod, Map, Addon, etc.). + /// + [JsonPropertyName("contentType")] + public ContentType ContentType { get; set; } = ContentType.Mod; + + /// + /// Gets or sets the target game for this content. + /// + [JsonPropertyName("targetGame")] + public GameType TargetGame { get; set; } = GameType.ZeroHour; + + /// + /// Gets or sets the list of releases (versions) for this content. + /// + [JsonPropertyName("releases")] + public List Releases { get; set; } = []; + + /// + /// Gets or sets rich presentation metadata (banners, screenshots, videos). + /// + [JsonPropertyName("metadata")] + public ContentRichMetadata? Metadata { get; set; } + + /// + /// Gets or sets tags for categorization and search. + /// + [JsonPropertyName("tags")] + public List Tags { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Providers/CatalogDependency.cs b/GenHub/GenHub.Core/Models/Providers/CatalogDependency.cs new file mode 100644 index 000000000..f61954f48 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/CatalogDependency.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Represents a dependency on content from another publisher. +/// +public class CatalogDependency +{ + /// + /// Gets or sets the publisher ID of the dependency. + /// + [JsonPropertyName("publisherId")] + public string PublisherId { get; set; } = string.Empty; + + /// + /// Gets or sets the content ID within the publisher's catalog. + /// + [JsonPropertyName("contentId")] + public string ContentId { get; set; } = string.Empty; + + /// + /// Gets or sets the version constraint (e.g., ">=1.0.0", "^2.0", "1.5.0"). + /// + [JsonPropertyName("versionConstraint")] + public string? VersionConstraint { get; set; } + + /// + /// Gets or sets a value indicating whether the dependency is optional. + /// + [JsonPropertyName("isOptional")] + public bool IsOptional { get; set; } + + /// + /// Gets or sets a hint for where to find this dependency (catalog URL). + /// + [JsonPropertyName("catalogUrl")] + public string? CatalogUrl { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Providers/ContentRelease.cs b/GenHub/GenHub.Core/Models/Providers/ContentRelease.cs new file mode 100644 index 000000000..02a13ac76 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/ContentRelease.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Represents a specific version/release of a content item. +/// +public class ContentRelease +{ + /// + /// Gets or sets the semantic version string (e.g., "1.0.0", "2.1.0-beta"). + /// + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets the release date. + /// + [JsonPropertyName("releaseDate")] + public DateTime ReleaseDate { get; set; } = DateTime.UtcNow; + + /// + /// Gets or sets a value indicating whether this is a prerelease version. + /// Prereleases are hidden by default unless user opts in. + /// + [JsonPropertyName("isPrerelease")] + public bool IsPrerelease { get; set; } + + /// + /// Gets or sets a value indicating whether this is the latest stable release. + /// Used for "Latest Only" version filtering. + /// + [JsonPropertyName("isLatest")] + public bool IsLatest { get; set; } + + /// + /// Gets or sets the changelog/release notes. + /// Supports markdown formatting. + /// + [JsonPropertyName("changelog")] + public string? Changelog { get; set; } + + /// + /// Gets or sets the downloadable artifacts for this release. + /// + [JsonPropertyName("artifacts")] + public List Artifacts { get; set; } = []; + + /// + /// Gets or sets dependencies required by this release. + /// + [JsonPropertyName("dependencies")] + public List Dependencies { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Providers/ContentRichMetadata.cs b/GenHub/GenHub.Core/Models/Providers/ContentRichMetadata.cs new file mode 100644 index 000000000..9ad9c0bc3 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/ContentRichMetadata.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Rich presentation metadata for content display in the UI. +/// +public class ContentRichMetadata +{ + /// + /// Gets or sets the banner image URL for content detail pages. + /// + [JsonPropertyName("bannerUrl")] + public string? BannerUrl { get; set; } + + /// + /// Gets or sets a collection of screenshot URLs. + /// + [JsonPropertyName("screenshotUrls")] + public List ScreenshotUrls { get; set; } = []; + + /// + /// Gets or sets a video URL (YouTube, Vimeo, direct MP4). + /// + [JsonPropertyName("videoUrl")] + public string? VideoUrl { get; set; } + + /// + /// Gets or sets a documentation or wiki URL. + /// + [JsonPropertyName("documentationUrl")] + public string? DocumentationUrl { get; set; } + + /// + /// Gets or sets the author display name (if different from publisher). + /// + [JsonPropertyName("author")] + public string? Author { get; set; } + + /// + /// Gets or sets the license type (MIT, GPL, etc.). + /// + [JsonPropertyName("license")] + public string? License { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Providers/PublisherCatalog.cs b/GenHub/GenHub.Core/Models/Providers/PublisherCatalog.cs new file mode 100644 index 000000000..ed73fa7db --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/PublisherCatalog.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Root model for a publisher's content catalog. +/// This JSON file is hosted by creators and fetched by GenHub to discover their content. +/// +public class PublisherCatalog +{ + /// + /// Gets or sets the schema version for catalog format compatibility. + /// + [JsonPropertyName("$schemaVersion")] + public int SchemaVersion { get; set; } = 1; + + /// + /// Gets or sets the publisher identity and branding information. + /// + [JsonPropertyName("publisher")] + public PublisherProfile Publisher { get; set; } = new(); + + /// + /// Gets or sets the list of content items available from this publisher. + /// + [JsonPropertyName("content")] + public List Content { get; set; } = []; + + /// + /// Gets or sets when the catalog was last updated. + /// + [JsonPropertyName("lastUpdated")] + public DateTime LastUpdated { get; set; } = DateTime.UtcNow; + + /// + /// Gets or sets an optional SHA256 signature for catalog integrity verification. + /// + [JsonPropertyName("signature")] + public string? Signature { get; set; } + + /// + /// Gets or sets referrals to other publishers (cross-publisher discovery). + /// + [JsonPropertyName("referrals")] + public List Referrals { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Providers/PublisherProfile.cs b/GenHub/GenHub.Core/Models/Providers/PublisherProfile.cs new file mode 100644 index 000000000..1c28da766 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/PublisherProfile.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Publisher identity and branding information within a catalog. +/// +public class PublisherProfile +{ + /// + /// Gets or sets the unique publisher identifier (e.g., "my-mods", "general-steve"). + /// Used in manifest ID generation and subscription matching. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the human-readable publisher name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the publisher's website URL. + /// + [JsonPropertyName("website")] + public string? Website { get; set; } + + /// + /// Gets or sets the publisher's avatar/logo URL. + /// + [JsonPropertyName("avatarUrl")] + public string? AvatarUrl { get; set; } + + /// + /// Gets or sets the support URL (Discord, GitHub Issues, etc.). + /// + [JsonPropertyName("supportUrl")] + public string? SupportUrl { get; set; } + + /// + /// Gets or sets the publisher's contact email. + /// + [JsonPropertyName("contactEmail")] + public string? ContactEmail { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Providers/PublisherReferral.cs b/GenHub/GenHub.Core/Models/Providers/PublisherReferral.cs new file mode 100644 index 000000000..861563de3 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/PublisherReferral.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Represents a referral to another publisher's catalog. +/// Enables cross-publisher discovery and recommendations. +/// +public class PublisherReferral +{ + /// + /// Gets or sets the referred publisher's ID. + /// + [JsonPropertyName("publisherId")] + public string PublisherId { get; set; } = string.Empty; + + /// + /// Gets or sets the URL to the referred publisher's catalog. + /// + [JsonPropertyName("catalogUrl")] + public string CatalogUrl { get; set; } = string.Empty; + + /// + /// Gets or sets a descriptive note about the referral. + /// + [JsonPropertyName("note")] + public string? Note { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Providers/PublisherSubscription.cs b/GenHub/GenHub.Core/Models/Providers/PublisherSubscription.cs new file mode 100644 index 000000000..d207a9240 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/PublisherSubscription.cs @@ -0,0 +1,79 @@ +using System; +using System.Text.Json.Serialization; +using CommunityToolkit.Mvvm.ComponentModel; +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Models.Providers; + +/// +/// Represents a user's subscription to a publisher's catalog. +/// Stored locally in the user's application data. +/// +public class PublisherSubscription : ObservableObject +{ + private TrustLevel _trustLevel = TrustLevel.Untrusted; + + /// + /// Gets or sets the unique publisher identifier. + /// + [JsonPropertyName("publisherId")] + public string PublisherId { get; set; } = string.Empty; + + /// + /// Gets or sets the human-readable publisher name. + /// + [JsonPropertyName("publisherName")] + public string PublisherName { get; set; } = string.Empty; + + /// + /// Gets or sets the URL to the publisher's catalog JSON. + /// + [JsonPropertyName("catalogUrl")] + public string CatalogUrl { get; set; } = string.Empty; + + /// + /// Gets or sets when the subscription was added. + /// + [JsonPropertyName("added")] + public DateTime Added { get; set; } = DateTime.UtcNow; + + /// + /// Gets or sets the trust level for this publisher. + /// + [JsonPropertyName("trustLevel")] + public TrustLevel TrustLevel + { + get => _trustLevel; + set => SetProperty(ref _trustLevel, value); + } + + /// + /// Gets or sets a value indicating whether to auto-update content from this publisher. + /// + [JsonPropertyName("autoUpdate")] + public bool AutoUpdate { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to notify on new releases. + /// + [JsonPropertyName("notifyNewReleases")] + public bool NotifyNewReleases { get; set; } = true; + + /// + /// Gets or sets the cached catalog hash for change detection. + /// + [JsonPropertyName("cachedCatalogHash")] + public string? CachedCatalogHash { get; set; } + + /// + /// Gets or sets when the catalog was last fetched. + /// + [JsonPropertyName("lastFetched")] + public DateTime? LastFetched { get; set; } + + /// + /// Gets or sets the publisher's avatar URL for sidebar display. + /// + [JsonPropertyName("avatarUrl")] + public string? AvatarUrl { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Providers/PublisherSubscriptionCollection.cs b/GenHub/GenHub.Core/Models/Providers/PublisherSubscriptionCollection.cs new file mode 100644 index 000000000..125f612af --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/PublisherSubscriptionCollection.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Root model for the subscriptions.json file stored in user's app data. +/// +public class PublisherSubscriptionCollection +{ + /// + /// Gets or sets the format version for subscription file compatibility. + /// + [JsonPropertyName("version")] + public int Version { get; set; } = 1; + + /// + /// Gets or sets the list of publisher subscriptions. + /// + [JsonPropertyName("subscriptions")] + public List Subscriptions { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Providers/ReleaseArtifact.cs b/GenHub/GenHub.Core/Models/Providers/ReleaseArtifact.cs new file mode 100644 index 000000000..0151d25ea --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/ReleaseArtifact.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Represents a downloadable file artifact within a release. +/// +public class ReleaseArtifact +{ + /// + /// Gets or sets the artifact filename (e.g., "MyMod-1.0.0.zip"). + /// + [JsonPropertyName("filename")] + public string Filename { get; set; } = string.Empty; + + /// + /// Gets or sets the direct download URL. + /// Supports GitHub Releases, ModDB, generic HTTP, Google Drive, Dropbox, etc. + /// + [JsonPropertyName("downloadUrl")] + public string DownloadUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the file size in bytes. + /// + [JsonPropertyName("size")] + public long Size { get; set; } + + /// + /// Gets or sets the SHA256 hash for integrity verification. + /// + [JsonPropertyName("sha256")] + public string Sha256 { get; set; } = string.Empty; + + /// + /// Gets or sets the MIME type of the artifact. + /// + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + /// + /// Gets or sets a value indicating whether this is the primary artifact. + /// When multiple artifacts exist, the primary one is downloaded by default. + /// + [JsonPropertyName("isPrimary")] + public bool IsPrimary { get; set; } = true; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs index 1233fa904..b744bb7f7 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs @@ -706,7 +706,7 @@ public void GetContentDirectories_WithNullUserSetting_ReturnsDefaults() { // Arrange var appDataPath = "/app/data/path"; - var userSettings = new UserSettings { ContentDirectories = [] }; + var userSettings = new UserSettings { ContentDirectories = new() }; _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns(appDataPath); @@ -749,7 +749,7 @@ public void GetGitHubDiscoveryRepositories_WithUserSetting_ReturnsUserSetting() public void GetGitHubDiscoveryRepositories_WithNullUserSetting_ReturnsDefaults() { // Arrange - var userSettings = new UserSettings { GitHubDiscoveryRepositories = [] }; + var userSettings = new UserSettings { GitHubDiscoveryRepositories = new() }; _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); var provider = CreateProvider(); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CNCLabsMapDiscovererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CNCLabsMapDiscovererTests.cs index 6843eef2f..06a33c127 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CNCLabsMapDiscovererTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CNCLabsMapDiscovererTests.cs @@ -196,6 +196,49 @@ COOP GLA vs CHI - Call of Dragon Assert.Equal("3239", item.ResolverMetadata[CNCLabsConstants.MapIdMetadataKey]); } + /// + /// Verifies that + /// correctly identifies more items when the "Next" link is present. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithNextLink_SetsHasMoreItemsTrue() + { + // Arrange + var query = new ContentSearchQuery + { + TargetGame = GameType.Generals, + ContentType = GenHub.Core.Models.Enums.ContentType.Map, + Page = 1, + }; + + var html = @" + +
+ + Test Map +
+ +"; + + using var http = CreateHttpClient(_ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(html), + }); + + var sut = CreateSut(http); + + // Act + var result = await sut.DiscoverAsync(query); + + // Assert + Assert.True(result.Success); + Assert.True(result.Data!.HasMoreItems); + } + // ---- helpers ---------------------------------------------------------- /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubResolverTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubResolverTests.cs index aa342689f..d8a241e25 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubResolverTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubResolverTests.cs @@ -4,6 +4,7 @@ using GenHub.Core.Models.Enums; using GenHub.Core.Models.GitHub; using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; using GenHub.Core.Models.Results.Content; using GenHub.Features.Content.Services.GitHub; using Microsoft.Extensions.DependencyInjection; diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientManifestIntegrationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientManifestIntegrationTests.cs index 0fbb3446d..22eb3a0a0 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientManifestIntegrationTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientManifestIntegrationTests.cs @@ -1,10 +1,5 @@ -using System; -using System.IO; -using System.Linq; -using System.Threading.Tasks; using GenHub.Common.Services; using GenHub.Core.Interfaces.Common; -using GenHub.Core.Interfaces.GameClients; using GenHub.Core.Interfaces.Manifest; using GenHub.Core.Interfaces.Tools; using GenHub.Core.Models.Content; @@ -30,6 +25,9 @@ public class GameClientManifestIntegrationTests : IDisposable private readonly ManifestGenerationService _manifestService; private readonly Mock _manifestPoolMock; private readonly GameClientDetector _detector; + private readonly Mock _downloadServiceMock; + private readonly Mock _configProviderMock; + private readonly Mock _playwrightServiceMock; /// /// Initializes a new instance of the class. @@ -41,12 +39,17 @@ public GameClientManifestIntegrationTests() _hashProvider = new Sha256HashProvider(); _manifestIdService = new ManifestIdService(); + _downloadServiceMock = new Mock(); + _configProviderMock = new Mock(); + _playwrightServiceMock = new Mock(); + _manifestService = new ManifestGenerationService( NullLogger.Instance, _hashProvider, _manifestIdService, - new Mock().Object, - new Mock().Object); + _downloadServiceMock.Object, + _configProviderMock.Object, + _playwrightServiceMock.Object); _manifestPoolMock = new Mock(); _manifestPoolMock.Setup(x => x.AddManifestAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs index e7d4386ec..4afe31a20 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs @@ -1,6 +1,7 @@ using System.Reactive.Linq; using GenHub.Common.ViewModels; using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; // Added using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.GameProfiles; using GenHub.Core.Interfaces.GameSettings; @@ -17,7 +18,8 @@ using GenHub.Core.Models.Enums; using GenHub.Core.Models.Notifications; using GenHub.Features.AppUpdate.Interfaces; -using GenHub.Features.Content.Services.ContentDiscoverers; +using GenHub.Core.Interfaces.Providers; // Added +using GenHub.Features.Content.Services.Publishers; using GenHub.Features.Downloads.ViewModels; using GenHub.Features.GameProfiles.Services; using GenHub.Features.GameProfiles.ViewModels; @@ -195,7 +197,7 @@ public void CurrentTabViewModel_ReturnsCorrectViewModel(NavigationTab tab) Assert.IsType(currentViewModel); break; case NavigationTab.Downloads: - Assert.IsType(currentViewModel); + Assert.IsType(currentViewModel); break; case NavigationTab.Tools: Assert.IsType(currentViewModel); @@ -246,6 +248,9 @@ private static (SettingsViewModel SettingsVm, Mock UserSet mockWorkspaceManager.Object, mockManifestPool.Object, mockUpdateManager.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, mockNotificationServiceForSettings.Object, mockConfigurationProvider.Object, mockInstallationService.Object, @@ -265,28 +270,20 @@ private static IConfigurationProviderService CreateConfigProviderMock() } /// - /// Helper method to create a DownloadsViewModel with mocked dependencies. + /// Helper method to create a DownloadsBrowserViewModel with mocked dependencies. /// - private static DownloadsViewModel CreateDownloadsViewModel() + private static DownloadsBrowserViewModel CreateDownloadsViewModel() { var mockServiceProvider = new Mock(); - var mockLogger = new Mock>(); - var mockNotificationService = new Mock(); - - // Create the three required dependencies for the discoverer - var mockGitHubClient = new Mock(); - var mockDiscovererLogger = new Mock>(); + var mockLogger = new Mock>(); + var mockDiscoverers = Enumerable.Empty(); + var mockDownloadService = new Mock(); - // Instantiate the real class with the two mocks - var realGitHubDiscoverer = new GitHubTopicsDiscoverer( - mockGitHubClient.Object, - mockDiscovererLogger.Object); - - return new DownloadsViewModel( + return new DownloadsBrowserViewModel( mockServiceProvider.Object, mockLogger.Object, - mockNotificationService.Object, - realGitHubDiscoverer); + mockDiscoverers, + mockDownloadService.Object); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs index bc7954481..6b4a9f65d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs @@ -1,8 +1,10 @@ using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.GameProfiles; +using GenHub.Core.Interfaces.GitHub; using GenHub.Core.Interfaces.Manifest; using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Interfaces.Providers; using GenHub.Core.Interfaces.Storage; using GenHub.Core.Interfaces.UserData; using GenHub.Core.Interfaces.Workspace; @@ -36,6 +38,9 @@ public class SettingsViewModelTests private readonly Mock _mockConfigurationProvider; private readonly Mock _mockInstallationService; private readonly Mock _mockStorageLocationService; + private readonly Mock _mockGitHubApiClient; + private readonly Mock _mockSubscriptionStore; + private readonly Mock _mockCatalogRefreshService; private readonly Mock _mockUserDataTracker; private readonly UserSettings _defaultSettings; @@ -55,6 +60,9 @@ public SettingsViewModelTests() _mockConfigurationProvider = new Mock(); _mockInstallationService = new Mock(); _mockStorageLocationService = new Mock(); + _mockGitHubApiClient = new Mock(); + _mockSubscriptionStore = new Mock(); + _mockCatalogRefreshService = new Mock(); _mockUserDataTracker = new Mock(); _defaultSettings = new UserSettings(); @@ -87,6 +95,9 @@ public void Constructor_LoadsSettingsFromUserSettingsService() _mockWorkspaceManager.Object, _mockManifestPool.Object, _mockUpdateManager.Object, + _mockSubscriptionStore.Object, + _mockCatalogRefreshService.Object, + _mockGitHubApiClient.Object, _mockNotificationService.Object, _mockConfigurationProvider.Object, _mockInstallationService.Object, @@ -116,6 +127,9 @@ public async Task SaveSettingsCommand_UpdatesUserSettingsService() _mockWorkspaceManager.Object, _mockManifestPool.Object, _mockUpdateManager.Object, + _mockSubscriptionStore.Object, + _mockCatalogRefreshService.Object, + _mockGitHubApiClient.Object, _mockNotificationService.Object, _mockConfigurationProvider.Object, _mockInstallationService.Object, @@ -150,6 +164,9 @@ public async Task ResetToDefaultsCommand_ResetsAllProperties() _mockWorkspaceManager.Object, _mockManifestPool.Object, _mockUpdateManager.Object, + _mockSubscriptionStore.Object, + _mockCatalogRefreshService.Object, + _mockGitHubApiClient.Object, _mockNotificationService.Object, _mockConfigurationProvider.Object, _mockInstallationService.Object, @@ -186,6 +203,9 @@ public void MaxConcurrentDownloads_SetsValueWithinBounds() _mockWorkspaceManager.Object, _mockManifestPool.Object, _mockUpdateManager.Object, + _mockSubscriptionStore.Object, + _mockCatalogRefreshService.Object, + _mockGitHubApiClient.Object, _mockNotificationService.Object, _mockConfigurationProvider.Object, _mockInstallationService.Object, @@ -221,6 +241,9 @@ public void AvailableThemes_ReturnsExpectedValues() _mockWorkspaceManager.Object, _mockManifestPool.Object, _mockUpdateManager.Object, + _mockSubscriptionStore.Object, + _mockCatalogRefreshService.Object, + _mockGitHubApiClient.Object, _mockNotificationService.Object, _mockConfigurationProvider.Object, _mockInstallationService.Object, @@ -251,6 +274,9 @@ public void AvailableWorkspaceStrategies_ReturnsAllEnumValues() _mockWorkspaceManager.Object, _mockManifestPool.Object, _mockUpdateManager.Object, + _mockSubscriptionStore.Object, + _mockCatalogRefreshService.Object, + _mockGitHubApiClient.Object, _mockNotificationService.Object, _mockConfigurationProvider.Object, _mockInstallationService.Object, @@ -283,6 +309,9 @@ public async Task SaveSettingsCommand_HandlesUserSettingsServiceException() _mockWorkspaceManager.Object, _mockManifestPool.Object, _mockUpdateManager.Object, + _mockSubscriptionStore.Object, + _mockCatalogRefreshService.Object, + _mockGitHubApiClient.Object, _mockNotificationService.Object, _mockConfigurationProvider.Object, _mockInstallationService.Object, @@ -321,6 +350,9 @@ public void Constructor_HandlesUserSettingsServiceException() _mockWorkspaceManager.Object, _mockManifestPool.Object, _mockUpdateManager.Object, + _mockSubscriptionStore.Object, + _mockCatalogRefreshService.Object, + _mockGitHubApiClient.Object, _mockNotificationService.Object, _mockConfigurationProvider.Object, _mockInstallationService.Object, @@ -358,6 +390,9 @@ public async Task DeleteCasStorageCommand_CallsService() _mockWorkspaceManager.Object, _mockManifestPool.Object, _mockUpdateManager.Object, + _mockSubscriptionStore.Object, + _mockCatalogRefreshService.Object, + _mockGitHubApiClient.Object, _mockNotificationService.Object, _mockConfigurationProvider.Object, _mockInstallationService.Object, @@ -387,6 +422,9 @@ public async Task UninstallGenHubCommand_CallsService() _mockWorkspaceManager.Object, _mockManifestPool.Object, _mockUpdateManager.Object, + _mockSubscriptionStore.Object, + _mockCatalogRefreshService.Object, + _mockGitHubApiClient.Object, _mockNotificationService.Object, _mockConfigurationProvider.Object, _mockInstallationService.Object, diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs index 24ba21bce..69088488e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs @@ -34,14 +34,19 @@ public class ContentManifestBuilderTests private readonly Mock _manifestIdServiceMock; /// - /// Mock for the download service used in the builder. + /// Mock for the download service. /// private readonly Mock _downloadServiceMock; /// - /// Mock for the configuration provider service used in the builder. + /// Mock for the configuration provider. /// - private readonly Mock _configProviderServiceMock; + private readonly Mock _configurationProviderMock; + + /// + /// Mock for the playwright service. + /// + private readonly Mock _playwrightServiceMock; /// /// The content manifest builder under test. @@ -57,7 +62,8 @@ public ContentManifestBuilderTests() _hashProviderMock = new Mock(); _manifestIdServiceMock = new Mock(); _downloadServiceMock = new Mock(); - _configProviderServiceMock = new Mock(); + _configurationProviderMock = new Mock(); + _playwrightServiceMock = new Mock(); // Set up mock to return success for ValidateAndCreateManifestId _manifestIdServiceMock.Setup(x => x.ValidateAndCreateManifestId(It.IsAny())) @@ -80,7 +86,8 @@ public ContentManifestBuilderTests() _hashProviderMock.Object, _manifestIdServiceMock.Object, _downloadServiceMock.Object, - _configProviderServiceMock.Object); + _configurationProviderMock.Object, + _playwrightServiceMock.Object); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestGenerationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestGenerationServiceTests.cs index 4af353790..3a55019a6 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestGenerationServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestGenerationServiceTests.cs @@ -9,9 +9,10 @@ using Microsoft.Extensions.Logging.Abstractions; using Moq; using ContentType = GenHub.Core.Models.Enums.ContentType; -using GameInstallationType = GenHub.Core.Models.Enums.GameInstallationType; using GameType = GenHub.Core.Models.Enums.GameType; +using GenHub.Core.Models.Enums; + namespace GenHub.Tests.Core.Features.Manifest; /// @@ -67,7 +68,8 @@ public ManifestGenerationServiceTests() _hashProviderMock.Object, _manifestIdServiceMock.Object, _downloadServiceMock.Object, - _configProviderServiceMock.Object); + _configProviderMock.Object, + _playwrightServiceMock.Object); _tempDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(_tempDirectory); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs index e0d024b16..0997a5adc 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs @@ -142,7 +142,7 @@ public void AllViewModels_Registered() // Act & Assert: Try to resolve each ViewModel that doesn't require complex constructor parameters Assert.NotNull(serviceProvider.GetService()); Assert.NotNull(serviceProvider.GetService()); - Assert.NotNull(serviceProvider.GetService()); + Assert.NotNull(serviceProvider.GetService()); Assert.NotNull(serviceProvider.GetService()); Assert.NotNull(serviceProvider.GetService()); } diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs index 851af2958..053a1f96c 100644 --- a/GenHub/GenHub/App.axaml.cs +++ b/GenHub/GenHub/App.axaml.cs @@ -11,6 +11,8 @@ using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameProfiles; +using GenHub.Features.Content.ViewModels.Catalog; +using GenHub.Features.Downloads.Views; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -72,6 +74,58 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } + /// + /// Handles a subscription command for a given URL. + /// + /// The URL to subscribe to. + /// A task representing the asynchronous operation. + public async Task HandleSubscribeCommandAsync(string url) + { + var logger = _serviceProvider.GetService>(); + try + { + logger?.LogInformation("Processing subscription for URL: {Url}", url); + + // For now, we'll just log it. + // Phase 5 will implement the confirmation dialog and actual subscription logic. + // Dispatch to UI thread if we need to show a dialog + await Dispatcher.UIThread.InvokeAsync(async () => + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow != null) + { + logger?.LogInformation("Showing subscription confirmation dialog for: {Url}", url); + + var viewModel = ActivatorUtilities.CreateInstance(_serviceProvider, url); + var dialog = new SubscriptionConfirmationDialog + { + DataContext = viewModel, + }; + + var result = await dialog.ShowDialog(desktop.MainWindow); + if (result) + { + logger?.LogInformation("User confirmed subscription for: {Url}", url); + + // Optional: Trigger a refresh of the publishers list in DownloadsBrowserViewModel if it's active + var mainVm = desktop.MainWindow.DataContext as MainViewModel; + if (mainVm?.DownloadsViewModel is { } downloadsVm) + { + await downloadsVm.InitializeAsync(); + } + } + else + { + logger?.LogInformation("User cancelled subscription for: {Url}", url); + } + } + }); + } + catch (Exception ex) + { + logger?.LogError(ex, "Failed to handle subscription command for {Url}", url); + } + } + private static void UpdateViewModelAfterLaunch(MainWindow mainWindow, string profileId, int processId) { var mainViewModel = mainWindow.DataContext as MainViewModel; @@ -173,15 +227,20 @@ private async Task HandleLaunchProfileArgsAsync(string[]? args, MainWindow mainW } var profileId = CommandLineParser.ExtractProfileId(args); - if (string.IsNullOrWhiteSpace(profileId)) + if (!string.IsNullOrWhiteSpace(profileId)) { - return; + var logger = _serviceProvider.GetService>(); + logger?.LogInformation("Startup launch detected for profile: {ProfileId}", profileId); + await LaunchProfileByIdAsync(profileId, mainWindow); } - var logger = _serviceProvider.GetService>(); - logger?.LogInformation("Startup launch detected for profile: {ProfileId}", profileId); - - await LaunchProfileByIdAsync(profileId, mainWindow); + var subscriptionUrl = CommandLineParser.ExtractSubscriptionUrl(args); + if (!string.IsNullOrWhiteSpace(subscriptionUrl)) + { + var logger = _serviceProvider.GetService>(); + logger?.LogInformation("Startup subscription detected: {Url}", subscriptionUrl); + await HandleSubscribeCommandAsync(subscriptionUrl); + } } private void SubscribeToSingleInstanceCommands(MainWindow mainWindow) @@ -215,6 +274,14 @@ private void HandleSingleInstanceCommand(string command, MainWindow mainWindow) // Launch the profile SafeFireAndForget(LaunchProfileByIdAsync(profileId, mainWindow), "LaunchProfileByIdAsync"); } + else if (command.StartsWith(IpcCommands.SubscribePrefix, StringComparison.OrdinalIgnoreCase)) + { + var url = command[IpcCommands.SubscribePrefix.Length..]; + logger?.LogInformation("Received IPC subscribe command for URL: {Url}", url); + + // Handle subscription + SafeFireAndForget(HandleSubscribeCommandAsync(url), "HandleSubscribeCommandAsync"); + } else { logger?.LogWarning("Unknown IPC command received: {Command}", command); diff --git a/GenHub/GenHub/Common/ViewModels/MainViewModel.cs b/GenHub/GenHub/Common/ViewModels/MainViewModel.cs index 91c5f7e6e..e672069ad 100644 --- a/GenHub/GenHub/Common/ViewModels/MainViewModel.cs +++ b/GenHub/GenHub/Common/ViewModels/MainViewModel.cs @@ -35,7 +35,7 @@ namespace GenHub.Common.ViewModels; /// Logger instance. public partial class MainViewModel( GameProfileLauncherViewModel gameProfilesViewModel, - DownloadsViewModel downloadsViewModel, + DownloadsBrowserViewModel downloadsViewModel, ToolsViewModel toolsViewModel, SettingsViewModel settingsViewModel, NotificationManagerViewModel notificationManager, @@ -61,7 +61,7 @@ public partial class MainViewModel( /// /// Gets the downloads view model. /// - public DownloadsViewModel DownloadsViewModel { get; } = downloadsViewModel; + public DownloadsBrowserViewModel DownloadsViewModel { get; } = downloadsViewModel; /// /// Gets the tools view model. @@ -124,6 +124,7 @@ private static NavigationTab LoadInitialTab(IConfigurationProviderService config { NavigationTab.GameProfiles => GameProfilesViewModel, NavigationTab.Downloads => DownloadsViewModel, + NavigationTab.Tools => ToolsViewModel, NavigationTab.Settings => SettingsViewModel, _ => GameProfilesViewModel, @@ -164,6 +165,9 @@ public async Task InitializeAsync() await ToolsViewModel.InitializeAsync(); logger?.LogInformation("MainViewModel initialized"); + // Ensure the initial tab's activation logic runs (triggers lazy loading) + OnSelectedTabChanged(SelectedTab); + // Start background check with cancellation support _ = CheckForUpdatesInBackgroundAsync(_initializationCts.Token); } diff --git a/GenHub/GenHub/Common/Views/MainView.axaml b/GenHub/GenHub/Common/Views/MainView.axaml index bab4d32cf..87d604a23 100644 --- a/GenHub/GenHub/Common/Views/MainView.axaml +++ b/GenHub/GenHub/Common/Views/MainView.axaml @@ -122,6 +122,9 @@ + + + diff --git a/GenHub/GenHub/Common/Views/MainWindow.axaml b/GenHub/GenHub/Common/Views/MainWindow.axaml index 35bdb7d0f..1be687da5 100644 --- a/GenHub/GenHub/Common/Views/MainWindow.axaml +++ b/GenHub/GenHub/Common/Views/MainWindow.axaml @@ -11,18 +11,7 @@ Title="GenHub" WindowStartupLocation="CenterScreen" Icon="avares://GenHub/Assets/Icons/generalshub-icon.png" - SystemDecorations="Full" - ExtendClientAreaToDecorationsHint="True" - ExtendClientAreaChromeHints="NoChrome" - ExtendClientAreaTitleBarHeightHint="-1"> - - - - - + DragDrop.AllowDrop="True"> diff --git a/GenHub/GenHub/Common/Views/MainWindow.axaml.cs b/GenHub/GenHub/Common/Views/MainWindow.axaml.cs index e11b82cf0..103c81b49 100644 --- a/GenHub/GenHub/Common/Views/MainWindow.axaml.cs +++ b/GenHub/GenHub/Common/Views/MainWindow.axaml.cs @@ -1,6 +1,8 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Markup.Xaml; +using System; +using System.Linq; namespace GenHub.Common.Views; @@ -15,6 +17,58 @@ public partial class MainWindow : Window public MainWindow() { InitializeComponent(); + AddHandler(DragDrop.DropEvent, OnDrop); + AddHandler(DragDrop.DragOverEvent, OnDragOver); + } + + private void OnDragOver(object? sender, DragEventArgs e) + { + if (e.Data.Contains(DataFormats.Files)) + { + e.DragEffects = DragDropEffects.Link; + } + else + { + e.DragEffects = DragDropEffects.None; + } + } + + private async void OnDrop(object? sender, DragEventArgs e) + { + var files = e.Data.GetFiles()?.ToList(); + if (files == null || files.Count == 0) return; + + foreach (var file in files) + { + var filePath = file.Path.LocalPath; + if (System.IO.Path.GetExtension(filePath).Equals(".json", StringComparison.OrdinalIgnoreCase)) + { + try + { + var json = await System.IO.File.ReadAllTextAsync(filePath); + using var doc = System.Text.Json.JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("catalogUrl", out var urlProp)) + { + var url = urlProp.GetString(); + if (!string.IsNullOrEmpty(url)) + { + if (Avalonia.Application.Current is App app) + { + // Call the internal subscription handler + // We use reflection or make it public if needed, + // but App already has SingleInstance handle logic. + // Actually we can just call the public method if we add it. + await app.HandleSubscribeCommandAsync(url); + } + } + } + } + catch + { + // Ignore invalid files + } + } + } } /// diff --git a/GenHub/GenHub/Features/Content/Services/Catalog/GenericCatalogDiscoverer.cs b/GenHub/GenHub/Features/Content/Services/Catalog/GenericCatalogDiscoverer.cs new file mode 100644 index 000000000..ddaa5b4ab --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Catalog/GenericCatalogDiscoverer.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Results.Content; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services.Catalog; + +/// +/// Generic discoverer for publisher catalogs following the GenHub schema. +/// Works for ALL subscribed publishers - no custom code needed per publisher. +/// +public class GenericCatalogDiscoverer( + ILogger logger, + IHttpClientFactory httpClientFactory, + IPublisherCatalogParser catalogParser, + IVersionSelector versionSelector) : IContentDiscoverer +{ + private readonly ILogger _logger = logger; + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + private readonly IPublisherCatalogParser _catalogParser = catalogParser; + private readonly IVersionSelector _versionSelector = versionSelector; + + private PublisherSubscription? _subscription; + private PublisherCatalog? _cachedCatalog; + + /// + /// Gets the unique identifier of the resolver used by this discoverer. + /// + public static string ResolverId => CatalogConstants.GenericCatalogResolverId; + + private static bool MatchesQuery(CatalogContentItem content, ContentSearchQuery query) + { + // Filter by game type + if (query.TargetGame.HasValue && content.TargetGame != query.TargetGame.Value) + { + return false; + } + + // Filter by content type + if (query.ContentType.HasValue && content.ContentType != query.ContentType.Value) + { + return false; + } + + // Filter by search text + if (!string.IsNullOrWhiteSpace(query.SearchTerm)) + { + var searchLower = query.SearchTerm.ToLowerInvariant(); + if (!content.Name.Contains(searchLower, StringComparison.OrdinalIgnoreCase) && + !content.Description.Contains(searchLower, StringComparison.OrdinalIgnoreCase) && + !content.Tags.Any(t => t.Contains(searchLower, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + } + + return true; + } + + private static int ExtractVersionNumber(string version) + { + if (int.TryParse(new string([.. version.Where(char.IsDigit)]), out var result)) + { + return result; + } + + return 0; + } + + /// + public string SourceName => _subscription?.PublisherName ?? "Generic Catalog"; + + /// + public string Description => _subscription != null + ? $"Content from {_subscription.PublisherName}" + : "Generic catalog-based content source"; + + /// + public bool IsEnabled => _subscription != null; + + /// + public ContentSourceCapabilities Capabilities => ContentSourceCapabilities.RequiresDiscovery | ContentSourceCapabilities.SupportsManifestGeneration; + + /// + /// Configures this discoverer for a specific publisher subscription. + /// + /// The publisher subscription. + public void Configure(PublisherSubscription subscription) + { + ArgumentNullException.ThrowIfNull(subscription); + _subscription = subscription; + _logger.LogDebug("Configured discoverer for publisher: {PublisherId}", subscription.PublisherId); + } + + /// + public async Task> DiscoverAsync( + ContentSearchQuery query, + CancellationToken cancellationToken = default) + { + if (_subscription == null) + { + return OperationResult.CreateFailure( + "Discoverer not configured with subscription"); + } + + try + { + // Fetch and parse catalog + var catalogResult = await FetchCatalogAsync(cancellationToken); + if (!catalogResult.Success) + { + return OperationResult.CreateFailure(catalogResult); + } + + var catalog = catalogResult.Data!; + _cachedCatalog = catalog; + + // Convert catalog items to search results + var searchResults = ConvertCatalogToSearchResults(catalog, query).ToList(); + + var result = new ContentDiscoveryResult + { + Items = searchResults, + TotalItems = searchResults.Count, + HasMoreItems = false, // All results returned at once from catalog + }; + + _logger.LogInformation( + "Discovered {Count} content items from publisher '{PublisherId}'", + searchResults.Count, + _subscription.PublisherId); + + return OperationResult.CreateSuccess(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to discover content from publisher '{PublisherId}'", _subscription.PublisherId); + return OperationResult.CreateFailure($"Discovery failed: {ex.Message}"); + } + } + + private async Task> FetchCatalogAsync(CancellationToken cancellationToken) + { + try + { + var httpClient = _httpClientFactory.CreateClient(); + httpClient.Timeout = TimeSpan.FromSeconds(30); + + _logger.LogDebug("Fetching catalog from: {CatalogUrl}", _subscription!.CatalogUrl); + + var response = await httpClient.GetAsync(_subscription.CatalogUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + + // Check size limit + if (response.Content.Headers.ContentLength > CatalogConstants.MaxCatalogSizeBytes) + { + return OperationResult.CreateFailure( + $"Catalog exceeds maximum size of {CatalogConstants.MaxCatalogSizeBytes} bytes"); + } + + var catalogJson = await response.Content.ReadAsStringAsync(cancellationToken); + + // Parse catalog + return await _catalogParser.ParseCatalogAsync(catalogJson, cancellationToken); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP error fetching catalog"); + return OperationResult.CreateFailure($"Failed to fetch catalog: {ex.Message}"); + } + catch (TaskCanceledException ex) + { + _logger.LogWarning(ex, "Catalog fetch timed out"); + return OperationResult.CreateFailure("Catalog fetch timed out"); + } + } + + private List ConvertCatalogToSearchResults( + PublisherCatalog catalog, + ContentSearchQuery query) + { + var results = new List(); + + foreach (var contentItem in catalog.Content) + { + // Apply version filtering (default: latest only) + var policy = query.IncludeOlderVersions + ? VersionPolicy.AllVersions + : VersionPolicy.LatestStableOnly; + + var selectedReleases = _versionSelector.SelectReleases(contentItem.Releases, policy); + + foreach (var release in selectedReleases) + { + // Apply search filters + if (!MatchesQuery(contentItem, query)) + { + continue; + } + + var searchResult = new ContentSearchResult + { + Id = ManifestIdGenerator.GeneratePublisherContentId( + catalog.Publisher.Id, + contentItem.ContentType, + contentItem.Id, + ExtractVersionNumber(release.Version)), + Name = contentItem.Name, + Description = contentItem.Description, + Version = release.Version, + ContentType = contentItem.ContentType, + TargetGame = contentItem.TargetGame, + ProviderName = catalog.Publisher.Name, + AuthorName = contentItem.Metadata?.Author ?? catalog.Publisher.Name, + ResolverId = ResolverId, + IconUrl = catalog.Publisher.AvatarUrl, // Default to publisher avatar + BannerUrl = contentItem.Metadata?.BannerUrl, + LastUpdated = release.ReleaseDate, + RequiresResolution = true, + }; + + // Add screenshots + if (contentItem.Metadata?.ScreenshotUrls != null) + { + foreach (var url in contentItem.Metadata.ScreenshotUrls) + { + searchResult.ScreenshotUrls.Add(url); + } + } + + // Add tags (read-only collection, use .Add()) + foreach (var tag in contentItem.Tags) + { + searchResult.Tags.Add(tag); + } + + // Add resolver metadata (read-only dictionary, use .Add()) + searchResult.ResolverMetadata["catalogItemJson"] = System.Text.Json.JsonSerializer.Serialize(contentItem); + searchResult.ResolverMetadata["releaseJson"] = System.Text.Json.JsonSerializer.Serialize(release); + searchResult.ResolverMetadata["publisherProfileJson"] = System.Text.Json.JsonSerializer.Serialize(catalog.Publisher); + + results.Add(searchResult); + } + } + + return results; + } +} diff --git a/GenHub/GenHub/Features/Content/Services/Catalog/GenericCatalogResolver.cs b/GenHub/GenHub/Features/Content/Services/Catalog/GenericCatalogResolver.cs new file mode 100644 index 000000000..6ec7b4256 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Catalog/GenericCatalogResolver.cs @@ -0,0 +1,143 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Results.Content; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services.Catalog; + +/// +/// Resolves content from generic publisher catalogs into ContentManifest objects. +/// Leverages existing ContentManifestBuilder for archive extraction and CAS storage. +/// +public class GenericCatalogResolver( + ILogger logger, + IContentManifestBuilder manifestBuilder) : IContentResolver +{ + private readonly ILogger _logger = logger; + private readonly IContentManifestBuilder _manifestBuilder = manifestBuilder; + + /// + public string ResolverId => CatalogConstants.GenericCatalogResolverId; + + /// + public async Task> ResolveAsync( + ContentSearchResult searchResult, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(searchResult); + + try + { + // Extract catalog metadata from search result (stored as JSON strings) + if (!searchResult.ResolverMetadata.TryGetValue("releaseJson", out var releaseJson)) + { + return OperationResult.CreateFailure("Missing release metadata"); + } + + if (!searchResult.ResolverMetadata.TryGetValue("catalogItemJson", out var contentItemJson)) + { + return OperationResult.CreateFailure("Missing content item metadata"); + } + + if (!searchResult.ResolverMetadata.TryGetValue("publisherProfileJson", out var publisherJson)) + { + return OperationResult.CreateFailure("Missing publisher profile"); + } + + // Deserialize from JSON + var release = System.Text.Json.JsonSerializer.Deserialize(releaseJson); + var contentItem = System.Text.Json.JsonSerializer.Deserialize(contentItemJson); + var publisher = System.Text.Json.JsonSerializer.Deserialize(publisherJson); + + if (release == null || contentItem == null || publisher == null) + { + return OperationResult.CreateFailure("Failed to deserialize catalog metadata"); + } + + _logger.LogInformation( + "Resolving content '{ContentName}' v{Version} from publisher '{PublisherId}'", + contentItem.Name, + release.Version, + publisher.Id); + + // Build manifest using existing ContentManifestBuilder + var builder = _manifestBuilder + .WithBasicInfo(publisher.Id, contentItem.Name, release.Version) + .WithContentType(contentItem.ContentType, contentItem.TargetGame) + .WithPublisher(publisher.Name, publisher.Website ?? string.Empty, publisher.SupportUrl ?? string.Empty, publisher.ContactEmail ?? string.Empty, publisher.Id) + .WithMetadata( + description: contentItem.Description, + tags: [.. contentItem.Tags], + iconUrl: contentItem.Metadata?.BannerUrl ?? string.Empty, + screenshotUrls: contentItem.Metadata?.ScreenshotUrls?.ToList()); + + // Add primary artifact for download + var primaryArtifact = release.Artifacts.FirstOrDefault(a => a.IsPrimary) ?? release.Artifacts.First(); + + // ContentManifestBuilder.AddDownloadedFileAsync will: + // 1. Download the file + // 2. Auto-detect if it's an archive (ZIP/RAR/7z) + // 3. Extract all files if archive + // 4. Store each file in CAS + // 5. Add ManifestFile entries + await builder.AddDownloadedFileAsync( + relativePath: primaryArtifact.Filename, + downloadUrl: primaryArtifact.DownloadUrl, + sourceType: ContentSourceType.ContentAddressable, + isExecutable: false, + permissions: null, + refererUrl: publisher.Website, + userAgent: null); + + // Add dependencies + foreach (var dependency in release.Dependencies) + { + var dependencyId = ManifestIdGenerator.GeneratePublisherContentId( + dependency.PublisherId, + ContentType.Mod, // Default to Mod for catalog dependencies if not specified + dependency.ContentId, + ExtractVersionNumber(dependency.VersionConstraint ?? "0")); + + builder.AddDependency( + id: ManifestId.Create(dependencyId), + name: dependency.ContentId, + dependencyType: ContentType.Mod, + installBehavior: dependency.IsOptional ? DependencyInstallBehavior.Optional : DependencyInstallBehavior.RequireExisting, + minVersion: dependency.VersionConstraint ?? string.Empty); + } + + var manifest = builder.Build(); + + _logger.LogInformation( + "Successfully resolved manifest for '{ContentName}' with {FileCount} files", + manifest.Name, + manifest.Files.Count); + + return OperationResult.CreateSuccess(manifest); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to resolve content from catalog"); + return OperationResult.CreateFailure($"Resolution failed: {ex.Message}"); + } + } + + private static int ExtractVersionNumber(string version) + { + if (int.TryParse(new string([.. version.Where(char.IsDigit)]), out var result)) + { + return result; + } + + return 0; + } +} diff --git a/GenHub/GenHub/Features/Content/Services/Catalog/JsonPublisherCatalogParser.cs b/GenHub/GenHub/Features/Content/Services/Catalog/JsonPublisherCatalogParser.cs new file mode 100644 index 000000000..4c6e4ae56 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Catalog/JsonPublisherCatalogParser.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services.Catalog; + +/// +/// Parses GenHub-format publisher catalog JSON files. +/// +public class JsonPublisherCatalogParser(ILogger logger) : IPublisherCatalogParser +{ + private readonly ILogger _logger = logger; + + /// + public async Task> ParseCatalogAsync( + string catalogJson, + CancellationToken cancellationToken = default) + { + try + { + if (string.IsNullOrWhiteSpace(catalogJson)) + { + return OperationResult.CreateFailure("Catalog JSON is empty or null"); + } + + var catalog = await Task.Run( + () => JsonSerializer.Deserialize(catalogJson), + cancellationToken); + + if (catalog == null) + { + return OperationResult.CreateFailure("Failed to deserialize catalog JSON"); + } + + // Validate after parsing + var validationResult = ValidateCatalog(catalog); + if (!validationResult.Success) + { + return OperationResult.CreateFailure(validationResult); + } + + _logger.LogInformation( + "Successfully parsed catalog for publisher '{PublisherId}' with {ContentCount} content items", + catalog.Publisher.Id, + catalog.Content.Count); + + return OperationResult.CreateSuccess(catalog); + } + catch (JsonException ex) + { + _logger.LogError(ex, "JSON parsing error"); + return OperationResult.CreateFailure($"Invalid JSON format: {ex.Message}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error parsing catalog"); + return OperationResult.CreateFailure($"Catalog parsing failed: {ex.Message}"); + } + } + + /// + public OperationResult ValidateCatalog(PublisherCatalog catalog) + { + ArgumentNullException.ThrowIfNull(catalog); + + var errors = new List(); + + // Validate schema version + if (catalog.SchemaVersion < 1) + { + errors.Add($"Invalid schema version: {catalog.SchemaVersion}. Must be >= 1."); + } + + // Validate publisher info + if (string.IsNullOrWhiteSpace(catalog.Publisher?.Id)) + { + errors.Add("Publisher ID is required"); + } + + if (string.IsNullOrWhiteSpace(catalog.Publisher?.Name)) + { + errors.Add("Publisher name is required"); + } + + // Validate content items + if (catalog.Content == null || catalog.Content.Count == 0) + { + errors.Add("Catalog must contain at least one content item"); + } + else + { + for (int i = 0; i < catalog.Content.Count; i++) + { + var content = catalog.Content[i]; + if (string.IsNullOrWhiteSpace(content.Id)) + { + errors.Add($"Content item {i} is missing ID"); + } + + if (string.IsNullOrWhiteSpace(content.Name)) + { + errors.Add($"Content item '{content.Id}' is missing name"); + } + + if (content.Releases == null || content.Releases.Count == 0) + { + errors.Add($"Content item '{content.Id}' has no releases"); + } + else + { + // Validate each release + foreach (var release in content.Releases) + { + if (string.IsNullOrWhiteSpace(release.Version)) + { + errors.Add($"Content '{content.Id}' has release with missing version"); + } + + if (release.Artifacts == null || release.Artifacts.Count == 0) + { + errors.Add($"Content '{content.Id}' release '{release.Version}' has no artifacts"); + } + else + { + foreach (var artifact in release.Artifacts) + { + if (string.IsNullOrWhiteSpace(artifact.DownloadUrl)) + { + errors.Add($"Artifact in '{content.Id}' v{release.Version} missing download URL"); + } + + if (string.IsNullOrWhiteSpace(artifact.Sha256)) + { + errors.Add($"Artifact in '{content.Id}' v{release.Version} missing SHA256 hash"); + } + } + } + } + } + } + } + + if (errors.Count > 0) + { + _logger.LogWarning("Catalog validation failed with {ErrorCount} errors", errors.Count); + return OperationResult.CreateFailure(errors); + } + + return OperationResult.CreateSuccess(true); + } + + /// + public bool VerifySignature(string catalogJson, PublisherCatalog catalog) + { + // TODO: Implement catalog signature verification + // For now, signatures are optional + if (string.IsNullOrEmpty(catalog.Signature)) + { + _logger.LogDebug("No signature present in catalog"); + return true; + } + + _logger.LogWarning("Signature verification not yet implemented"); + return true; + } +} diff --git a/GenHub/GenHub/Features/Content/Services/Catalog/PublisherCatalogRefreshService.cs b/GenHub/GenHub/Features/Content/Services/Catalog/PublisherCatalogRefreshService.cs new file mode 100644 index 000000000..7e069ec2b --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Catalog/PublisherCatalogRefreshService.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services.Catalog; + +/// +/// Service for refreshing subscribed publisher catalogs. +/// +public class PublisherCatalogRefreshService( + ILogger logger, + IHttpClientFactory httpClientFactory, + IPublisherSubscriptionStore subscriptionStore, + IPublisherCatalogParser catalogParser) : IPublisherCatalogRefreshService +{ + /// + public async Task> RefreshAllAsync(CancellationToken cancellationToken = default) + { + try + { + var subsResult = await subscriptionStore.GetSubscriptionsAsync(cancellationToken); + if (!subsResult.Success) return OperationResult.CreateFailure(subsResult); + + var subscriptions = subsResult.Data!; + var tasks = subscriptions.Select(s => RefreshPublisherAsync(s.PublisherId, cancellationToken)); + + var results = await Task.WhenAll(tasks); + var failures = results.Where(r => !r.Success).ToList(); + + if (failures.Count > 0) + { + logger.LogWarning("Refreshed catalogs with {FailureCount} failures", failures.Count); + } + + return OperationResult.CreateSuccess(true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to refresh all catalogs"); + return OperationResult.CreateFailure($"Refresh failed: {ex.Message}"); + } + } + + /// + public async Task> RefreshPublisherAsync(string publisherId, CancellationToken cancellationToken = default) + { + try + { + var subResult = await subscriptionStore.GetSubscriptionAsync(publisherId, cancellationToken); + if (!subResult.Success || subResult.Data == null) + { + return OperationResult.CreateFailure($"Subscription '{publisherId}' not found"); + } + + var subscription = subResult.Data; + logger.LogInformation("Refreshing catalog for: {PublisherName}", subscription.PublisherName); + + var httpClient = httpClientFactory.CreateClient(); + httpClient.Timeout = TimeSpan.FromSeconds(30); + + var response = await httpClient.GetAsync(subscription.CatalogUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + + var catalogJson = await response.Content.ReadAsStringAsync(cancellationToken); + + // Validate catalog + var parseResult = await catalogParser.ParseCatalogAsync(catalogJson, cancellationToken); + if (!parseResult.Success) + { + return OperationResult.CreateFailure($"Failed to parse catalog: {parseResult.FirstError}"); + } + + // Update subscription metadata + var hash = ComputeHash(catalogJson); + subscription.CachedCatalogHash = hash; + subscription.LastFetched = DateTime.UtcNow; + subscription.AvatarUrl = parseResult.Data?.Publisher.AvatarUrl ?? subscription.AvatarUrl; + subscription.PublisherName = parseResult.Data?.Publisher.Name ?? subscription.PublisherName; + + await subscriptionStore.UpdateSubscriptionAsync(subscription, cancellationToken); + + return OperationResult.CreateSuccess(true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to refresh catalog for {PublisherId}", publisherId); + return OperationResult.CreateFailure($"Refresh failed: {ex.Message}"); + } + } + + private static string ComputeHash(string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash); + } +} diff --git a/GenHub/GenHub/Features/Content/Services/Catalog/PublisherSubscriptionStore.cs b/GenHub/GenHub/Features/Content/Services/Catalog/PublisherSubscriptionStore.cs new file mode 100644 index 000000000..d688f397a --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Catalog/PublisherSubscriptionStore.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services.Catalog; + +/// +/// File-based storage for publisher subscriptions. +/// Persists to {AppData}/GenHub/subscriptions.json. +/// +public class PublisherSubscriptionStore : IPublisherSubscriptionStore +{ + private readonly ILogger _logger; + private readonly IConfigurationProviderService _configurationProvider; + private readonly string _subscriptionsFilePath; + private readonly SemaphoreSlim _fileLock = new(1, 1); + private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; + + private PublisherSubscriptionCollection? _cachedSubscriptions; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + /// The configuration provider service. + public PublisherSubscriptionStore( + ILogger logger, + IConfigurationProviderService configurationProvider) + { + _logger = logger; + _configurationProvider = configurationProvider; + + var appDataPath = _configurationProvider.GetApplicationDataPath(); + _subscriptionsFilePath = Path.Combine(appDataPath, "subscriptions.json"); + } + + /// + public async Task>> GetSubscriptionsAsync( + CancellationToken cancellationToken = default) + { + try + { + var collection = await LoadSubscriptionsAsync(cancellationToken); + return OperationResult>.CreateSuccess(collection.Subscriptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get subscriptions"); + return OperationResult>.CreateFailure( + $"Failed to load subscriptions: {ex.Message}"); + } + } + + /// + public async Task> GetSubscriptionAsync( + string publisherId, + CancellationToken cancellationToken = default) + { + try + { + var collection = await LoadSubscriptionsAsync(cancellationToken); + var subscription = collection.Subscriptions + .FirstOrDefault(s => s.PublisherId.Equals(publisherId, StringComparison.OrdinalIgnoreCase)); + + return OperationResult.CreateSuccess(subscription); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get subscription for {PublisherId}", publisherId); + return OperationResult.CreateFailure( + $"Failed to load subscription: {ex.Message}"); + } + } + + /// + public async Task> AddSubscriptionAsync( + PublisherSubscription subscription, + CancellationToken cancellationToken = default) + { + await _fileLock.WaitAsync(cancellationToken); + try + { + var collection = await LoadSubscriptionsAsync(cancellationToken); + + // Check for duplicates + if (collection.Subscriptions.Any(s => s.PublisherId.Equals(subscription.PublisherId, StringComparison.OrdinalIgnoreCase))) + { + return OperationResult.CreateFailure($"Subscription for '{subscription.PublisherId}' already exists"); + } + + collection.Subscriptions.Add(subscription); + await SaveSubscriptionsAsync(collection, cancellationToken); + + _logger.LogInformation("Added subscription for publisher: {PublisherId}", subscription.PublisherId); + return OperationResult.CreateSuccess(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to add subscription for {PublisherId}", subscription.PublisherId); + return OperationResult.CreateFailure($"Failed to add subscription: {ex.Message}"); + } + finally + { + _fileLock.Release(); + } + } + + /// + public async Task> RemoveSubscriptionAsync( + string publisherId, + CancellationToken cancellationToken = default) + { + await _fileLock.WaitAsync(cancellationToken); + try + { + var collection = await LoadSubscriptionsAsync(cancellationToken); + var removed = collection.Subscriptions.RemoveAll(s => + s.PublisherId.Equals(publisherId, StringComparison.OrdinalIgnoreCase)); + + if (removed == 0) + { + return OperationResult.CreateFailure($"Subscription for '{publisherId}' not found"); + } + + await SaveSubscriptionsAsync(collection, cancellationToken); + + _logger.LogInformation("Removed subscription for publisher: {PublisherId}", publisherId); + return OperationResult.CreateSuccess(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to remove subscription for {PublisherId}", publisherId); + return OperationResult.CreateFailure($"Failed to remove subscription: {ex.Message}"); + } + finally + { + _fileLock.Release(); + } + } + + /// + public async Task> UpdateSubscriptionAsync( + PublisherSubscription subscription, + CancellationToken cancellationToken = default) + { + await _fileLock.WaitAsync(cancellationToken); + try + { + var collection = await LoadSubscriptionsAsync(cancellationToken); + var index = collection.Subscriptions.FindIndex(s => + s.PublisherId.Equals(subscription.PublisherId, StringComparison.OrdinalIgnoreCase)); + + if (index == -1) + { + return OperationResult.CreateFailure($"Subscription for '{subscription.PublisherId}' not found"); + } + + collection.Subscriptions[index] = subscription; + await SaveSubscriptionsAsync(collection, cancellationToken); + + _logger.LogInformation("Updated subscription for publisher: {PublisherId}", subscription.PublisherId); + return OperationResult.CreateSuccess(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update subscription for {PublisherId}", subscription.PublisherId); + return OperationResult.CreateFailure($"Failed to update subscription: {ex.Message}"); + } + finally + { + _fileLock.Release(); + } + } + + /// + public async Task> IsSubscribedAsync( + string publisherId, + CancellationToken cancellationToken = default) + { + try + { + var collection = await LoadSubscriptionsAsync(cancellationToken); + var exists = collection.Subscriptions.Any(s => + s.PublisherId.Equals(publisherId, StringComparison.OrdinalIgnoreCase)); + + return OperationResult.CreateSuccess(exists); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to check subscription for {PublisherId}", publisherId); + return OperationResult.CreateFailure($"Failed to check subscription: {ex.Message}"); + } + } + + /// + public async Task> UpdateTrustLevelAsync( + string publisherId, + TrustLevel trustLevel, + CancellationToken cancellationToken = default) + { + await _fileLock.WaitAsync(cancellationToken); + try + { + var collection = await LoadSubscriptionsAsync(cancellationToken); + var subscription = collection.Subscriptions.FirstOrDefault(s => + s.PublisherId.Equals(publisherId, StringComparison.OrdinalIgnoreCase)); + + if (subscription == null) + { + return OperationResult.CreateFailure($"Subscription for '{publisherId}' not found"); + } + + subscription.TrustLevel = trustLevel; + await SaveSubscriptionsAsync(collection, cancellationToken); + + _logger.LogInformation("Updated trust level for publisher {PublisherId} to {TrustLevel}", publisherId, trustLevel); + return OperationResult.CreateSuccess(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update trust level for {PublisherId}", publisherId); + return OperationResult.CreateFailure($"Failed to update trust level: {ex.Message}"); + } + finally + { + _fileLock.Release(); + } + } + + private async Task LoadSubscriptionsAsync(CancellationToken cancellationToken) + { + // Return cached if available + if (_cachedSubscriptions != null) + { + return _cachedSubscriptions; + } + + if (!File.Exists(_subscriptionsFilePath)) + { + _logger.LogInformation("Subscriptions file not found, creating new collection"); + _cachedSubscriptions = new PublisherSubscriptionCollection(); + return _cachedSubscriptions; + } + + var json = await File.ReadAllTextAsync(_subscriptionsFilePath, cancellationToken); + _cachedSubscriptions = JsonSerializer.Deserialize(json) + ?? new PublisherSubscriptionCollection(); + + _logger.LogDebug("Loaded {Count} subscriptions from file", _cachedSubscriptions.Subscriptions.Count); + return _cachedSubscriptions; + } + + private async Task SaveSubscriptionsAsync( + PublisherSubscriptionCollection collection, + CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(_subscriptionsFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(collection, _jsonOptions); + await File.WriteAllTextAsync(_subscriptionsFilePath, json, cancellationToken); + + _cachedSubscriptions = collection; + _logger.LogDebug("Saved {Count} subscriptions to file", collection.Subscriptions.Count); + } +} diff --git a/GenHub/GenHub/Features/Content/Services/Catalog/VersionSelector.cs b/GenHub/GenHub/Features/Content/Services/Catalog/VersionSelector.cs new file mode 100644 index 000000000..40c50cd47 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Catalog/VersionSelector.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Providers; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services.Catalog; + +/// +/// Filters content releases based on version display policy. +/// Implements "Latest Stable Only" by default to address user feedback about version clutter. +/// +public class VersionSelector(ILogger logger) : IVersionSelector +{ + private readonly ILogger _logger = logger; + + /// + public IEnumerable SelectReleases( + IEnumerable releases, + VersionPolicy policy) + { + ArgumentNullException.ThrowIfNull(releases); + + var releasesList = releases.ToList(); + if (releasesList.Count == 0) + { + return releasesList; + } + + return policy switch + { + VersionPolicy.LatestStableOnly => GetLatestStableReleases(releasesList), + VersionPolicy.AllVersions => releasesList, + VersionPolicy.IncludePrereleases => GetLatestWithPrereleases(releasesList), + _ => throw new ArgumentOutOfRangeException(nameof(policy), policy, "Unknown version policy"), + }; + } + + /// + public ContentRelease? GetLatestStable(IEnumerable releases) + { + ArgumentNullException.ThrowIfNull(releases); + + return releases + .Where(r => !r.IsPrerelease) + .OrderByDescending(r => r.ReleaseDate) + .FirstOrDefault(r => r.IsLatest) ?? releases + .Where(r => !r.IsPrerelease) + .OrderByDescending(r => r.ReleaseDate) + .FirstOrDefault(); + } + + /// + public ContentRelease? GetLatest(IEnumerable releases) + { + ArgumentNullException.ThrowIfNull(releases); + + return releases + .OrderByDescending(r => r.ReleaseDate) + .FirstOrDefault(); + } + + private IEnumerable GetLatestStableReleases(List releases) + { + var latest = GetLatestStable(releases); + if (latest != null) + { + _logger.LogDebug("Selected latest stable release: {Version}", latest.Version); + return [latest]; + } + + _logger.LogWarning("No stable releases found"); + return []; + } + + private IEnumerable GetLatestWithPrereleases(List releases) + { + var latest = GetLatest(releases); + if (latest != null) + { + _logger.LogDebug("Selected latest release (including prereleases): {Version}", latest.Version); + return [latest]; + } + + return []; + } +} diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs index 42ee8b423..43f745df7 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; -using GenHub.Core.Interfaces.Tools; using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; diff --git a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/FileSystemDiscoverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/FileSystemDiscoverer.cs index ff491f688..7a3994b22 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/FileSystemDiscoverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/FileSystemDiscoverer.cs @@ -28,24 +28,6 @@ public class FileSystemDiscoverer : IContentDiscoverer private readonly ManifestDiscoveryService _manifestDiscoveryService; private readonly IConfigurationProviderService _configurationProvider; - /// - /// Initializes a new instance of the class. - /// - /// The logger instance. - /// The manifest discovery service. - /// The unified configuration provider. - public FileSystemDiscoverer( - ILogger logger, - ManifestDiscoveryService manifestDiscoveryService, - IConfigurationProviderService configurationProvider) - { - _logger = logger; - _manifestDiscoveryService = manifestDiscoveryService; - _configurationProvider = configurationProvider; - - InitializeContentDirectories(); - } - private static bool MatchesQuery(ContentManifest manifest, ContentSearchQuery query) { if (!string.IsNullOrWhiteSpace(query.SearchTerm) && @@ -68,6 +50,24 @@ private static bool MatchesQuery(ContentManifest manifest, ContentSearchQuery qu return true; } + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + /// The manifest discovery service. + /// The unified configuration provider. + public FileSystemDiscoverer( + ILogger logger, + ManifestDiscoveryService manifestDiscoveryService, + IConfigurationProviderService configurationProvider) + { + _logger = logger; + _manifestDiscoveryService = manifestDiscoveryService; + _configurationProvider = configurationProvider; + + InitializeContentDirectories(); + } + /// public string SourceName => "Local File System"; diff --git a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/ModDBDiscoverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/ModDBDiscoverer.cs index 36c0cc089..871fe5001 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/ModDBDiscoverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/ModDBDiscoverer.cs @@ -13,16 +13,18 @@ using GenHub.Core.Models.Results; using GenHub.Core.Models.Results.Content; using Microsoft.Extensions.Logging; +using Microsoft.Playwright; namespace GenHub.Features.Content.Services.ContentDiscoverers; /// -/// Discovers content from ModDB website across mods, downloads, and addons sections. -/// Supports comprehensive filtering and category-based discovery. +/// Discovers content from ModDB website using Playwright to bypass WAF/Bot protections. /// -public class ModDBDiscoverer(HttpClient httpClient, ILogger logger) : IContentDiscoverer +public class ModDBDiscoverer(ILogger logger) : IContentDiscoverer { - private readonly HttpClient _httpClient = httpClient; + private static readonly SemaphoreSlim _browserLock = new(1, 1); + private static IPlaywright? _playwright; + private static IBrowser? _browser; private readonly ILogger _logger = logger; /// @@ -44,30 +46,36 @@ public async Task> DiscoverAsync( { try { + await EnsurePlaywrightInitializedAsync(); + var gameType = query.TargetGame ?? GameType.ZeroHour; - _logger.LogInformation("Discovering ModDB content for {Game}", gameType); + _logger.LogInformation("Discovering ModDB content for {Game} using Playwright", gameType); List results = []; + bool hasMoreItems = false; - // Determine which sections to search based on ContentType filter - var sectionsToSearch = DetermineSectionsToSearch(query.ContentType); + // Determine which sections to search based on query filters + var sectionsToSearch = DetermineSectionsToSearch(query); foreach (var section in sectionsToSearch) { - var sectionResults = await DiscoverFromSectionAsync(section, gameType, query, cancellationToken); + var (sectionResults, sectionHasMore) = await DiscoverFromSectionAsync(section, gameType, query, cancellationToken); results.AddRange(sectionResults); + if (sectionHasMore) + { + hasMoreItems = true; + } } _logger.LogInformation( "Discovered {Count} ModDB items across {Sections} sections", results.Count, sectionsToSearch.Count); - var list = results.ToList(); + return OperationResult.CreateSuccess(new ContentDiscoveryResult { - Items = list, - TotalItems = list.Count, - HasMoreItems = false, + Items = results, + HasMoreItems = hasMoreItems, }); } catch (Exception ex) @@ -77,190 +85,161 @@ public async Task> DiscoverAsync( } } - /// - /// Determines which sections to search based on content type filter. - /// - private static List DetermineSectionsToSearch(ContentType? contentType) + private static async Task EnsurePlaywrightInitializedAsync() { - if (contentType == null) + if (_browser != null) return; + + await _browserLock.WaitAsync(); + try { - // No filter - search all sections - return ["mods", "downloads", "addons"]; - } + if (_browser != null) return; - return contentType switch + _playwright = await Playwright.CreateAsync(); + _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true, + Args = ["--disable-blink-features=AutomationControlled"], // Attempt to hide automation + }); + } + finally { - ContentType.Mod => ["mods", "downloads"], // Some full mods are in downloads - ContentType.Patch => ["downloads"], - ContentType.Map or ContentType.MapPack => ["addons", "downloads"], - ContentType.Skin => ["addons"], - ContentType.ModdingTool => ["downloads"], - ContentType.LanguagePack => ["downloads", "addons"], - ContentType.Video => ["downloads"], - _ => ["downloads", "addons"], - }; + _browserLock.Release(); + } } - /// - /// Maps GenHub ContentType to ModDB category code. - /// - private static string? MapContentTypeToCategory(ContentType contentType, string section) + private static List DetermineSectionsToSearch(ContentSearchQuery query) { - if (section == "downloads") + // Use explicit section from query if provided + if (!string.IsNullOrEmpty(query.ModDBSection)) { - return contentType switch - { - ContentType.Mod => ModDBConstants.CategoryFullVersion, - ContentType.Patch => ModDBConstants.CategoryPatch, - ContentType.Video => ModDBConstants.CategoryMovie, - ContentType.ModdingTool => ModDBConstants.CategoryMappingTool, - ContentType.LanguagePack => ModDBConstants.CategoryLanguagePack, - _ => null, - }; + return [query.ModDBSection]; } - else if (section == "addons") + + // Map ContentType to section if possible + if (query.ContentType.HasValue) { - return contentType switch + return query.ContentType.Value switch { - ContentType.Map => ModDBConstants.AddonMultiplayerMap, - ContentType.Skin => ModDBConstants.AddonPlayerSkin, - ContentType.LanguagePack => ModDBConstants.AddonLanguageSounds, - _ => null, + ContentType.Mod or ContentType.Patch or ContentType.Video => ["downloads"], + ContentType.Map or ContentType.Skin or ContentType.LanguagePack => ["addons"], + _ => ["downloads", "addons"], }; } - return null; + // Default to both if no explicit type is set, or just downloads if that's safer + return ["downloads"]; } - /// - /// Determines content type from section and category. - /// - private static ContentType DetermineContentType(string section, string? category, string url) + private static ModDBFilter BuildFilterFromQuery(ContentSearchQuery query) { - // First try category-based mapping if available - if (!string.IsNullOrEmpty(category)) + var filter = new ModDBFilter { - var mapped = ModDBCategoryMapper.MapCategoryByName(category); + Keyword = query.SearchTerm, + Page = query.Page ?? 1, + }; - // Addon is the default fallback - if (mapped != ContentType.Addon) - { - return mapped; - } + // Apply Category filter (for downloads section) + if (!string.IsNullOrWhiteSpace(query.ModDBCategory)) + { + filter.Category = query.ModDBCategory; } - // Fallback to section-based heuristics - return section switch + // Apply AddonCategory filter (for categoryaddon param) + if (!string.IsNullOrWhiteSpace(query.ModDBAddonCategory)) { - "mods" => ContentType.Mod, - "downloads" => url.Contains("/maps/") ? ContentType.Map : ContentType.Addon, - "addons" => url.Contains("/maps/") ? ContentType.Map : ContentType.Addon, - _ => ContentType.Addon, - }; - } + filter.AddonCategory = query.ModDBAddonCategory; + } - /// - /// Extracts ModDB ID from a URL. - /// - private static string ExtractModDBIdFromUrl(string url) - { - try + // Apply License filter + if (!string.IsNullOrWhiteSpace(query.ModDBLicense)) { - var uri = new Uri(url); - var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); - return segments.Length > 0 ? segments[^1] : Guid.NewGuid().ToString(); + filter.Licence = query.ModDBLicense; } - catch + + // Apply Timeframe filter + if (!string.IsNullOrWhiteSpace(query.ModDBTimeframe)) { - return Guid.NewGuid().ToString(); + filter.Timeframe = query.ModDBTimeframe; } + + return filter; } - /// - /// Builds a filter object from the search query. - /// - private static ModDBFilter BuildFilterFromQuery(ContentSearchQuery query, string section) + private static string? MapContentTypeToCategory(ContentType contentType, string section) { - var filter = new ModDBFilter + if (section == "downloads") { - Keyword = query.SearchTerm, - Page = query.Page ?? 1, - }; - - // Map ContentType to ModDB category if specified - if (query.ContentType.HasValue) + return contentType switch + { + ContentType.Mod => ModDBConstants.CategoryFullVersion, + ContentType.Patch => ModDBConstants.CategoryPatch, + ContentType.Video => ModDBConstants.CategoryMovie, + ContentType.ModdingTool => ModDBConstants.CategoryMappingTool, + ContentType.LanguagePack => ModDBConstants.CategoryLanguagePack, + _ => null, + }; + } + else if (section == "addons") { - filter.Category = MapContentTypeToCategory(query.ContentType.Value, section); - - // For addons, the parameter is different - if (section == "addons") + return contentType switch { - filter.AddonCategory = filter.Category; - filter.Category = null; // Clear main category if it's an addon search - } + ContentType.Map => ModDBConstants.AddonMultiplayerMap, + ContentType.Skin => ModDBConstants.AddonPlayerSkin, + ContentType.LanguagePack => ModDBConstants.AddonLanguageSounds, + _ => null, + }; } - return filter; + return null; } - /// - /// Parses a single content item from HTML. - /// private static ContentSearchResult? ParseContentItem(AngleSharp.Dom.IElement item, GameType gameType, string section) { - // Extract title and link - // ModDB structure:

Title

var titleLink = item.QuerySelector("h4 a, h3 a, a.title") ?? item.QuerySelector("td.content.name a"); - - if (titleLink == null) - { - return null; - } + if (titleLink == null) return null; var title = titleLink.TextContent?.Trim(); var href = titleLink.GetAttribute("href"); + if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(href)) return null; - if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(href)) - { - return null; - } - - // Filter out non-content links if we grabbed something generic - if (!href.Contains("/mods/") && !href.Contains("/downloads/") && !href.Contains("/addons/")) - { - return null; - } + if (!href.Contains("/mods/") && !href.Contains("/downloads/") && !href.Contains("/addons/")) return null; - // Make absolute URL var detailUrl = href.StartsWith("http") ? href : ModDBConstants.BaseUrl + href; - // Extract author - var authorLink = item.QuerySelector(ModDBConstants.AuthorLinkSelector); - var author = authorLink?.TextContent?.Trim() ?? ModDBConstants.DefaultAuthor; + // Try multiple selectors for author + var authorLink = item.QuerySelector("a[href*='/members/']") ?? + item.QuerySelector("span.by a") ?? + item.QuerySelector("span.author a"); + var author = authorLink?.TextContent?.Trim(); + if (string.IsNullOrWhiteSpace(author)) author = "Unknown"; - // Extract preview image - var img = item.QuerySelector("img"); + var img = item.QuerySelector("img.image, img.screenshot, div.image img, td.content.image img") ?? item.QuerySelector("img"); var iconUrl = img?.GetAttribute("src") ?? string.Empty; - if (!string.IsNullOrEmpty(iconUrl) && !iconUrl.StartsWith("http")) + if (!string.IsNullOrEmpty(iconUrl)) { - iconUrl = ModDBConstants.BaseUrl + iconUrl; + if (iconUrl.Contains("blank.gif")) iconUrl = string.Empty; + else if (!iconUrl.StartsWith("http")) iconUrl = ModDBConstants.BaseUrl + iconUrl; } - // Extract description - var descEl = item.QuerySelector("p, div.summary, span.summary"); + var descEl = item.QuerySelector("p, div.summary, span.summary, td.content.name span.summary"); var description = descEl?.TextContent?.Trim() ?? string.Empty; - // Try to extract category from the page var categoryEl = item.QuerySelector("span.category, div.category, span.subheading"); var category = categoryEl?.TextContent?.Trim(); - // Map to ContentType - var contentType = DetermineContentType(section, category, detailUrl); + // Extract date from timeago or time element + var dateEl = item.QuerySelector("time[datetime]") ?? item.QuerySelector("abbr.timeago"); + var dateStr = dateEl?.GetAttribute("datetime") ?? dateEl?.GetAttribute("title"); + DateTime? lastUpdated = null; + if (!string.IsNullOrEmpty(dateStr) && DateTime.TryParse(dateStr, out var parsedDate)) + { + lastUpdated = parsedDate; + } - // Extract ModDB ID from URL + var contentType = DetermineContentType(section, category, detailUrl); var moddbId = ExtractModDBIdFromUrl(detailUrl); - var searchResult = new ContentSearchResult + var result = new ContentSearchResult { Id = $"{ModDBConstants.PublisherPrefix}-{moddbId}", Name = title, @@ -273,24 +252,57 @@ private static ModDBFilter BuildFilterFromQuery(ContentSearchQuery query, string RequiresResolution = true, ResolverId = ModDBConstants.ResolverId, SourceUrl = detailUrl, + LastUpdated = lastUpdated, }; - // Add metadata - searchResult.ResolverMetadata[ModDBConstants.ContentIdMetadataKey] = moddbId; - searchResult.ResolverMetadata[ModDBConstants.SectionMetadataKey] = section; + result.ResolverMetadata[ModDBConstants.ContentIdMetadataKey] = moddbId; + result.ResolverMetadata[ModDBConstants.SectionMetadataKey] = section; - return searchResult; + return result; } - /// - /// Discovers content from a specific section. - /// - private async Task> DiscoverFromSectionAsync( + private static ContentType DetermineContentType(string section, string? category, string url) + { + if (!string.IsNullOrEmpty(category)) + { + var mapped = ModDBCategoryMapper.MapCategoryByName(category); + if (mapped != ContentType.Addon) return mapped; + } + + return section switch + { + "mods" => ContentType.Mod, + "downloads" => url.Contains("/maps/") ? ContentType.Map : ContentType.Addon, + "addons" => url.Contains("/maps/") ? ContentType.Map : ContentType.Addon, + _ => ContentType.Addon, + }; + } + + private static string ExtractModDBIdFromUrl(string url) + { + try + { + var uri = new Uri(url); + + // http://.../mods/contra + // http://.../downloads/contra-009 + var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + return segments.Length > 0 ? segments[^1] : Guid.NewGuid().ToString(); + } + catch + { + return Guid.NewGuid().ToString(); + } + } + + private async Task<(List Items, bool HasMoreItems)> DiscoverFromSectionAsync( string section, GameType gameType, ContentSearchQuery query, CancellationToken cancellationToken) { + IBrowserContext? context = null; + IPage? page = null; try { // Build URL for the section @@ -298,21 +310,47 @@ private async Task> DiscoverFromSectionAsync( ? $"{ModDBConstants.GeneralsBaseUrl}/{section}" : $"{ModDBConstants.ZeroHourBaseUrl}/{section}"; - // Build filter query string if needed - var filter = BuildFilterFromQuery(query, section); + var filter = BuildFilterFromQuery(query); var queryString = filter.ToQueryString(); - var url = baseUrl + queryString; - _logger.LogDebug("Fetching from URL: {Url}", url); + // ModDB uses path-based pagination: /page/2, /page/3, etc. + var pageSuffix = filter.Page > 1 ? $"/page/{filter.Page}" : string.Empty; + var url = baseUrl + pageSuffix + queryString; - var html = await _httpClient.GetStringAsync(url, cancellationToken); - var context = BrowsingContext.New(Configuration.Default); - var document = await context.OpenAsync(req => req.Content(html), cancellationToken); + _logger.LogInformation( + "[ModDB] Fetching page {Page} from section '{Section}': {Url}", + filter.Page, + section, + url); - List results = []; + if (_browser == null) throw new InvalidOperationException("Browser not initialized"); + + // Create a new context/page for this request to ensure clean session or isolated cookies if needed + context = await _browser.NewContextAsync(new BrowserNewContextOptions + { + UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + }); + page = await context.NewPageAsync(); + + await page.GotoAsync(url, new PageGotoOptions { Timeout = 30000, WaitUntil = WaitUntilState.DOMContentLoaded }); + + // Wait for content to load + try + { + await page.WaitForSelectorAsync("div.row.rowcontent, div.table tr", new PageWaitForSelectorOptions { Timeout = 5000 }); + } + catch + { + _logger.LogWarning("Timeout waiting for content selector on {Url}, parsing what we have...", url); + } + + var html = await page.ContentAsync(); + + // Use AngleSharp to parse the HTML (Robust and already implemented) + var browsingContext = BrowsingContext.New(Configuration.Default); + var document = await browsingContext.OpenAsync(req => req.Content(html), cancellationToken); - // Parse content items - ModDB uses consistent structure across sections - // Usually div.row.rowcontent or div.table depending on the view, but rowcontent is common for lists + List results = []; var contentItems = document.QuerySelectorAll("div.row.rowcontent, div.table tr"); foreach (var item in contentItems) @@ -325,19 +363,60 @@ private async Task> DiscoverFromSectionAsync( results.Add(searchResult); } } - catch (Exception ex) + catch + { + // Ignore parse errors for individual items + } + } + + // NEW LOGGING: + _logger.LogInformation("[ModDB] Pagination Logic Starting..."); + var pagesDiv = document.QuerySelector("div.pages"); + if (pagesDiv != null) + { + _logger.LogInformation("[ModDB] found div.pages. Html content length: {Length}", pagesDiv.InnerHtml.Length); + var allLinks = pagesDiv.QuerySelectorAll("a"); + foreach (var link in allLinks) { - _logger.LogDebug(ex, "Failed to parse content item"); + _logger.LogInformation("[ModDB] Link in pages: Text='{Text}', Href='{Href}', Class='{Class}'", link.TextContent?.Trim(), link.GetAttribute("href"), link.ClassName); } } + else + { + _logger.LogWarning("[ModDB] div.pages NOT FOUND"); + } + + // Check for pagination "next" button + // ModDB typically has a 'a.next' or 'span.next' inside a div.pages + var nextLink = document.QuerySelector("div.pages a.next") ?? document.QuerySelector("a.next"); + + if (nextLink == null) + { + _logger.LogWarning("[ModDB] NEXT LINK IS NULL. Trying broader search..."); + var anyNext = document.QuerySelectorAll("a").FirstOrDefault(a => a.TextContent != null && a.TextContent.Contains("next", StringComparison.OrdinalIgnoreCase)); + if (anyNext != null) + { + _logger.LogInformation("[ModDB] Found a link containing 'next' (but not matching selector): Text='{Text}', Href='{Href}', Class='{Class}'", anyNext.TextContent, anyNext.GetAttribute("href"), anyNext.ClassName); + } + } + else + { + _logger.LogInformation("[ModDB] Found next link via selector: {Url}", nextLink.GetAttribute("href")); + } + + var hasMoreItems = nextLink != null; - _logger.LogDebug("Found {Count} items in {Section} section", results.Count, section); - return results; + return (results, hasMoreItems); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to discover from {Section} section", section); - return []; + _logger.LogError(ex, "Failed to discover from {Section} with Playwright", section); + return ([], false); + } + finally + { + if (page != null) await page.CloseAsync(); + if (context != null) await context.DisposeAsync(); } } } diff --git a/GenHub/GenHub/Features/Content/Services/ContentResolvers/AODMapsResolver.cs b/GenHub/GenHub/Features/Content/Services/ContentResolvers/AODMapsResolver.cs index db153325c..bfc396a39 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentResolvers/AODMapsResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentResolvers/AODMapsResolver.cs @@ -7,6 +7,7 @@ using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Parsers; using GenHub.Core.Models.Results; using GenHub.Core.Models.Results.Content; using GenHub.Features.Content.Services.Parsers; @@ -92,7 +93,7 @@ public async Task> ResolveAsync( } } - private static ParsedContentDetails ConvertToMapDetails(File file, GenHub.Core.Models.Parsers.GlobalContext context, ContentSearchResult item) + private static ParsedContentDetails ConvertToMapDetails(File file, GlobalContext context, ContentSearchResult item) { // Determine GameType and ContentType // AODMaps are mostly Zero Hour or Generals. diff --git a/GenHub/GenHub/Features/Content/Services/ContentResolvers/CNCLabsMapResolver.cs b/GenHub/GenHub/Features/Content/Services/ContentResolvers/CNCLabsMapResolver.cs index 7cb0a4fd9..b4a32b0d5 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentResolvers/CNCLabsMapResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentResolvers/CNCLabsMapResolver.cs @@ -45,6 +45,12 @@ public async Task> ResolveAsync( ContentSearchResult discoveredItem, CancellationToken cancellationToken = default) { + // [TEMP] DEBUG: ResolveAsync entry point + logger.LogInformation( + "[TEMP] CNCLabsMapResolver.ResolveAsync called - Item: {Name}, SourceUrl: {Url}", + discoveredItem?.Name, + discoveredItem?.SourceUrl); + if (discoveredItem?.SourceUrl == null) { return OperationResult.CreateFailure("Invalid discovered item or source URL"); @@ -94,8 +100,8 @@ public async Task> ResolveAsync( if (!mapId.HasValue) { - logger.LogWarning("Invalid or missing map ID in resolver metadata for {Url}", discoveredItem.SourceUrl); - return OperationResult.CreateFailure("Invalid map ID in resolver metadata"); + logger.LogWarning("Invalid or missing map ID in resolver metadata for {Url}", discoveredItem.SourceUrl); + return OperationResult.CreateFailure("Invalid map ID in resolver metadata"); } // Use factory to create manifest diff --git a/GenHub/GenHub/Features/Content/Services/ContentResolvers/ModDBResolver.cs b/GenHub/GenHub/Features/Content/Services/ContentResolvers/ModDBResolver.cs index 17864d6b5..723281818 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentResolvers/ModDBResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentResolvers/ModDBResolver.cs @@ -1,19 +1,17 @@ using System; -using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using AngleSharp; -using AngleSharp.Dom; -using GenHub.Core.Constants; -using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Parsers; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.ModDB; +using GenHub.Core.Models.Parsers; using GenHub.Core.Models.Results; using GenHub.Core.Models.Results.Content; +using GenHub.Features.Content.Services.Parsers; using GenHub.Features.Content.Services.Publishers; using Microsoft.Extensions.Logging; using MapDetails = GenHub.Core.Models.ModDB.MapDetails; @@ -22,92 +20,33 @@ namespace GenHub.Features.Content.Services.ContentResolvers; /// /// Resolves ModDB content details from discovered items. -/// Parses HTML detail pages and generates content manifests. +/// Uses the universal web page parser to extract rich content. /// public class ModDBResolver( HttpClient httpClient, ModDBManifestFactory manifestFactory, + ModDBPageParser webPageParser, ILogger logger) : IContentResolver { private readonly HttpClient _httpClient = httpClient; private readonly ModDBManifestFactory _manifestFactory = manifestFactory; + private readonly ModDBPageParser _webPageParser = webPageParser; private readonly ILogger _logger = logger; - /// - /// Extracts submission/release date from the page. - /// Critical for manifest ID generation which requires YYYYMMDD format. - /// - private static DateTime ExtractSubmissionDate(IDocument document) - { - // Try various selectors for date - // ModDB often uses
-public class GeneralsOnlineJsonCatalogParser( - ILogger logger -) : ICatalogParser +public class GeneralsOnlineJsonCatalogParser(ILogger logger) : ICatalogParser { private static readonly JsonSerializerOptions _jsonOptions = new() { @@ -46,8 +42,7 @@ public Task>> ParseAsync( { logger.LogWarning("Catalog content is empty"); return Task.FromResult( - OperationResult>.CreateSuccess( - [])); + OperationResult>.CreateSuccess([])); } // Parse the wrapper to determine source type @@ -107,16 +102,14 @@ public Task>> ParseAsync( { logger.LogInformation("No Generals Online releases found in catalog"); return Task.FromResult( - OperationResult>.CreateSuccess( - [])); + OperationResult>.CreateSuccess([])); } // Create search result from release var searchResult = CreateSearchResult(release, provider); return Task.FromResult( - OperationResult>.CreateSuccess( - [searchResult])); + OperationResult>.CreateSuccess([searchResult])); } catch (JsonException ex) { diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubReleasesDiscoverer.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubReleasesDiscoverer.cs index 75b0118b3..64c68178c 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubReleasesDiscoverer.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubReleasesDiscoverer.cs @@ -60,7 +60,7 @@ public async Task> DiscoverAsync( try { // Get all releases from GitHub - var releases = await gitHubClient.GetReleasesAsync(owner, repo, cancellationToken); + var releases = (await gitHubClient.GetReleasesAsync(owner, repo, cancellationToken))?.ToList(); if (releases != null) { @@ -76,7 +76,7 @@ public async Task> DiscoverAsync( Id = $"github.{owner}.{repo}.{release.TagName}", Name = release.Name ?? $"{repo} {release.TagName}", Description = release.Body ?? "GitHub release - full details available after resolution", - Version = release.TagName, + Version = release.TagName.TrimStart('v', 'V'), AuthorName = release.Author, ContentType = inferredContentType.Type, TargetGame = inferredGame.Type, @@ -85,6 +85,7 @@ public async Task> DiscoverAsync( RequiresResolution = true, ResolverId = ContentSourceNames.GitHubResolverId, SourceUrl = release.HtmlUrl, + IconUrl = "avares://GenHub/Assets/Logos/thesuperhackers-logo.png", // Default icon for SuperHackers LastUpdated = release.PublishedAt?.DateTime ?? release.CreatedAt.DateTime, ResolverMetadata = { diff --git a/GenHub/GenHub/Features/Content/Services/Parsers/ModDBPageParser.cs b/GenHub/GenHub/Features/Content/Services/Parsers/ModDBPageParser.cs new file mode 100644 index 000000000..b2087d73d --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Parsers/ModDBPageParser.cs @@ -0,0 +1,894 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AngleSharp; +using AngleSharp.Dom; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Parsers; +using GenHub.Core.Interfaces.Tools; +using GenHub.Core.Models.Parsers; +using Microsoft.Extensions.Logging; +using IDocument = AngleSharp.Dom.IDocument; + +namespace GenHub.Features.Content.Services.Parsers; + +/// +/// Parser for ModDB pages that extracts rich content including files, videos, images, articles, reviews, and comments. +/// +public class ModDBPageParser(IPlaywrightService playwrightService, ILogger logger) : IWebPageParser +{ + /// + /// Detects the page type based on URL patterns and DOM structure. + /// + private static PageType DetectPageType(string url, IDocument document) + { + // Check for file detail page + if (document.QuerySelector(ModDBParserConstants.DownloadsInfoSelector) != null) + { + return PageType.FileDetail; + } + + // Check for list pages (addons, images) + if (url.Contains("/addons", StringComparison.OrdinalIgnoreCase) || + url.Contains("/images", StringComparison.OrdinalIgnoreCase)) + { + return PageType.List; + } + + // Check for summary/news pages + if (document.QuerySelector(ModDBParserConstants.ArticlesBrowseSelector) != null) + { + return PageType.Summary; + } + + // Default to detail page + return PageType.Detail; + } + + /// + /// Extracts content sections from summary/news pages. + /// + private static List ExtractSummarySections(IDocument document) + { + var sections = new List(); + + // Extract articles + sections.AddRange(ExtractArticles(document)); + + return sections; + } + + /// + /// Extracts a file from a row element. + /// + private static File? ExtractFileFromRow(IElement row) + { + var nameEl = row.QuerySelector(ModDBParserConstants.FileNameSelector); + var name = nameEl?.TextContent?.Trim(); + if (string.IsNullOrEmpty(name)) + { + return null; + } + + var sizeEl = row.QuerySelector(ModDBParserConstants.FileSizeSelector); + var sizeText = sizeEl?.TextContent?.Trim(); + long? sizeBytes = null; + if (!string.IsNullOrEmpty(sizeText)) + { + sizeBytes = ParseFileSize(sizeText); + } + + var dateEl = row.QuerySelector(ModDBParserConstants.FileDateSelector); + DateTime? uploadDate = null; + if (dateEl != null) + { + var dateStr = dateEl.GetAttribute("datetime") ?? dateEl.TextContent?.Trim(); + if (!string.IsNullOrEmpty(dateStr) && DateTime.TryParse(dateStr, out var parsedDate)) + { + uploadDate = parsedDate; + } + } + + var linkEl = row.QuerySelector(ModDBParserConstants.FileDownloadSelector); + var downloadUrl = linkEl?.GetAttribute("href"); + if (!string.IsNullOrEmpty(downloadUrl) && !downloadUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + downloadUrl = "https:" + downloadUrl; + } + + var commentCountEl = row.QuerySelector(ModDBParserConstants.FileCommentCountSelector); + int? commentCount = null; + if (commentCountEl != null) + { + var countText = commentCountEl.TextContent?.Trim(); + if (!string.IsNullOrEmpty(countText) && int.TryParse(countText, out var count)) + { + commentCount = count; + } + } + + return new File( + Name: name, + SizeBytes: sizeBytes, + SizeDisplay: sizeText, + UploadDate: uploadDate, + DownloadUrl: downloadUrl, + CommentCount: commentCount); + } + + /// + /// Extracts metadata from the sidebar profile section. + /// + private static ProfileMeta ExtractProfileMeta(IDocument document) + { + var sidebar = document.QuerySelector(ModDBParserConstants.ProfileSidebarSelector); + if (sidebar == null) + { + return new ProfileMeta(null, null, null, null, null, null); + } + + string? name = null; // Often the page title, but sometimes in sidebar + long? sizeBytes = null; + string? sizeDisplay = null; + DateTime? releaseDate = null; + string? developer = null; + string? md5Hash = null; + + var rows = sidebar.QuerySelectorAll(ModDBParserConstants.ProfileRowSelector); + foreach (var row in rows) + { + var labelEl = row.QuerySelector(ModDBParserConstants.ProfileLabelSelector); + var contentEl = row.QuerySelector(ModDBParserConstants.ProfileContentSelector); + + if (labelEl == null || contentEl == null) + { + continue; + } + + var label = labelEl.TextContent?.Trim().ToLowerInvariant().Replace(":", string.Empty); + var content = contentEl.TextContent?.Trim(); + + if (string.IsNullOrEmpty(label) || string.IsNullOrEmpty(content)) + { + continue; + } + + switch (label) + { + case "filename": + case "file": + name = content; + break; + case "size": + sizeDisplay = content; + + // Sometimes format is "134mb (140,505,749 bytes)" + // Try to parse the bytes part first if available + if (content.Contains("bytes") && content.Contains('(') && content.Contains(')')) + { + var bytesPart = content.Split('(').Last().Replace("bytes)", string.Empty).Replace(",", string.Empty).Trim(); + if (long.TryParse(bytesPart, out var bytesVal)) + { + sizeBytes = bytesVal; + } + } + + sizeBytes ??= ParseFileSize(content); + + break; + case "uploader": + case "author": + developer = content; + break; + case "added": + case "date": + case "release date": + if (DateTime.TryParse(content, out var dt)) + { + releaseDate = dt; + } + + break; + case "md5 hash": + case "md5": + md5Hash = content; + break; + } + } + + return new ProfileMeta(name, sizeBytes, sizeDisplay, releaseDate, developer, md5Hash); + } + + /// + /// Extracts videos from the document. + /// + private static List