diff --git a/.github/workflows/gitnexus.yml b/.github/workflows/gitnexus.yml
index 23d62900e..99b5a338a 100644
--- a/.github/workflows/gitnexus.yml
+++ b/.github/workflows/gitnexus.yml
@@ -33,6 +33,8 @@ jobs:
name: GitNexus Index & Artifact
runs-on: ubuntu-latest
timeout-minutes: 10
+ env:
+ NODE_OPTIONS: "--dns-result-order=ipv4first"
steps:
- name: Checkout Code
diff --git a/.gitignore b/.gitignore
index 7237ca5d8..f749850c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,8 @@
.*
!.github/
!.gitignore
+!.gitattributes
+!**/.gitattributes
!.agents/
!.claude/
diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props
index 8c7e0e70c..f42c712b2 100644
--- a/GenHub/Directory.Packages.props
+++ b/GenHub/Directory.Packages.props
@@ -5,8 +5,7 @@
-
-
+
@@ -56,4 +55,4 @@
-
+
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Constants/AODMapsConstants.cs b/GenHub/GenHub.Core/Constants/AODMapsConstants.cs
index 56d978056..87c372241 100644
--- a/GenHub/GenHub.Core/Constants/AODMapsConstants.cs
+++ b/GenHub/GenHub.Core/Constants/AODMapsConstants.cs
@@ -3,7 +3,7 @@
namespace GenHub.Core.Constants;
///
-/// Constants for AODMaps (Age of Defense Maps) provider.
+/// Constants for AODMaps (Art of Defense Maps) provider.
///
[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")]
[SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "Domain acronym")]
@@ -19,7 +19,7 @@ public static class AODMapsConstants
public const string DiscovererSourceName = "AODMaps";
/// Gets the discoverer description.
- public const string DiscovererDescription = "Age of Defense Maps";
+ public const string DiscovererDescription = "Art of Defense Maps";
/// Gets the resolver ID for AODMaps.
public const string ResolverId = "AODMaps";
@@ -117,6 +117,9 @@ public static class AODMapsConstants
/// Gets the download URL metadata key.
public const string DownloadUrlMetadataKey = "downloadUrl";
+ /// Gets the list page URL metadata key.
+ public const string ListPageUrlMetadataKey = "listPageUrl";
+
/// Gets the direct download metadata key.
public const string DirectDownloadMetadataKey = "directDownload";
@@ -126,6 +129,30 @@ public static class AODMapsConstants
/// Gets the download count metadata key.
public const string DownloadCountMetadataKey = "downloadCount";
+ /// Gets the player count metadata key.
+ public const string PlayerCountMetadataKey = ContentConstants.PlayerCountMetadataKey;
+
+ /// Gets the category metadata key used for download-card badges and combined filtering.
+ public const string CategoryMetadataKey = ContentConstants.CategoryMetadataKey;
+
+ /// Filter / badge label for Compstomp maps.
+ public const string CategoryCompstomp = "Compstomp";
+
+ /// Filter / badge label for map packs.
+ public const string CategoryMapPacks = "Map Packs";
+
+ /// Filter / badge label for air maps.
+ public const string CategoryAir = "Air";
+
+ /// Filter / badge label for race maps.
+ public const string CategoryRace = "Race";
+
+ /// Filter / badge label for Art of Attack maps.
+ public const string CategoryAoa = "AOA";
+
+ /// Filter / badge label for Contra AOD maps.
+ public const string CategoryContra = "Contra";
+
/// Gets the last updated metadata key.
public const string LastUpdatedMetadataKey = "lastUpdated";
diff --git a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs
index 6b5bd656f..7b360fc25 100644
--- a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs
+++ b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs
@@ -173,6 +173,28 @@ public static class AppUpdateConstants
"3. Launch the installed version (will be in %LOCALAPPDATA%\\GenHub)\n\n" +
"Update available: v{1}";
+ ///
+ /// Default update notification title.
+ ///
+ public const string UpdateNotificationTitle = "Update Available";
+
+ ///
+ /// Update available notification message format.
+ /// {0}: Version.
+ ///
+ public const string UpdateAvailableMessageFormat = "A new version ({0}) is available.";
+
+ ///
+ /// Branch/Artifact update notification title.
+ ///
+ public const string BranchUpdateNotificationTitle = "Branch Update Available";
+
+ ///
+ /// Branch update available notification message format.
+ /// {0}: Version, {1}: Branch.
+ ///
+ public const string BranchUpdateAvailableMessageFormat = "A new build ({0}) is available on branch '{1}'.";
+
///
/// Update available notification title for release channel.
///
@@ -214,7 +236,7 @@ public static class AppUpdateConstants
public const string UpdateFailedNotificationFormat = "Failed to install update: {0}";
///
- /// View updates action button text.
+ /// "View Updates" action text.
///
public const string ViewUpdatesAction = "View Updates";
diff --git a/GenHub/GenHub.Core/Constants/CatalogConstants.cs b/GenHub/GenHub.Core/Constants/CatalogConstants.cs
index 0e0ff3f5d..2e23578b4 100644
--- a/GenHub/GenHub.Core/Constants/CatalogConstants.cs
+++ b/GenHub/GenHub.Core/Constants/CatalogConstants.cs
@@ -1,8 +1,26 @@
namespace GenHub.Core.Constants;
///
-/// Constants for publisher catalog system.
+/// Constants for the modular publisher-catalog system.
///
+///
+/// Layering (see Publisher Studio architecture):
+///
+/// -
+/// Provider Definition — static publisher metadata + catalog endpoint(s)
+/// (bundled *.provider.json today; user-hosted definitions via Publisher Studio later).
+///
+/// -
+/// Catalog — dynamic content listing (catalog.json / remote endpoint), updated on each release.
+///
+/// -
+/// Artifacts — downloadable files referenced by catalog releases.
+///
+///
+/// Anyone can author a GenHub-schema catalog, host it, and share
+/// genhub://subscribe?url=.... Discovery uses
+/// for catalog-direct subscriptions without per-publisher code.
+///
public static class CatalogConstants
{
///
@@ -11,12 +29,17 @@ public static class CatalogConstants
public const int CatalogSchemaVersion = 1;
///
- /// Filename for subscriptions storage.
+ /// Filename for user subscription storage under application data.
///
public const string SubscriptionFileName = "subscriptions.json";
///
- /// Resolver ID for generic catalog resolver.
+ /// Sidebar / discoverer category for user-subscribed catalogs (vs built-in static/dynamic).
+ ///
+ public const string SubscribedPublisherCategory = "subscribed";
+
+ ///
+ /// Resolver / pipeline ID for the generic catalog pipeline (any GenHub-schema catalog).
///
public const string GenericCatalogResolverId = "generic-catalog";
@@ -29,4 +52,44 @@ public static class CatalogConstants
/// Maximum catalog size in bytes (10 MB).
///
public const long MaxCatalogSizeBytes = 10 * 1024 * 1024;
+
+ ///
+ /// Maximum number of entries allowed when extracting publisher catalog archives.
+ ///
+ public const int MaxZipEntryCount = 50_000;
+
+ ///
+ /// Maximum cumulative uncompressed size allowed when extracting publisher catalog archives (5 GB).
+ ///
+ public const long MaxZipUncompressedSizeBytes = 5L * 1024 * 1024 * 1024;
+
+ ///
+ /// Resolver metadata key for serialized publisher profile JSON.
+ ///
+ public const string PublisherProfileJsonMetadataKey = "publisherProfileJson";
+
+ ///
+ /// Resolver metadata key for serialized catalog item JSON.
+ ///
+ public const string CatalogItemJsonMetadataKey = "catalogItemJson";
+
+ ///
+ /// Resolver metadata key for serialized release JSON.
+ ///
+ public const string ReleaseJsonMetadataKey = "releaseJson";
+
+ ///
+ /// Resolver metadata key for the stable catalog content id (not the display name).
+ ///
+ public const string CatalogContentIdMetadataKey = "catalogContentId";
+
+ ///
+ /// Resolver metadata key for serialized bundle component descriptors.
+ ///
+ public const string BundleComponentsJsonMetadataKey = "bundleComponentsJson";
+
+ ///
+ /// Resolver metadata key for serialized publisher referrals JSON.
+ ///
+ public const string CatalogReferralsJsonMetadataKey = "catalogReferralsJson";
}
diff --git a/GenHub/GenHub.Core/Constants/CncLabsConstants.cs b/GenHub/GenHub.Core/Constants/CncLabsConstants.cs
index c360b654b..c5e734c0d 100644
--- a/GenHub/GenHub.Core/Constants/CncLabsConstants.cs
+++ b/GenHub/GenHub.Core/Constants/CncLabsConstants.cs
@@ -117,24 +117,19 @@ public static class CNCLabsConstants
public const string QueryStringIdParameter = "id";
///
- /// CSS selector for a single downloadable item container on list pages.
+ /// CSS selector for a single downloadable item container on list pages (2026 Bootstrap redesign).
///
- public const string ListItemSelector = "div.DownloadItem";
+ public const string ListItemSelector = "div.list-group-item";
///
- /// CSS selector for the hidden input that carries the map's numeric File Id.
+ /// CSS selector for the anchor with the display name of the map (2026 Bootstrap redesign).
///
- public const string FileIdHiddenSelector = "input[type='hidden'][id$='FileIdField']";
+ public const string DisplayNameAnchorSelector = "h5 a[href*='/downloads/details/']";
///
- /// CSS selector for the anchor with the display name of the map.
+ /// CSS selector for the element that contains the short description (2026 Bootstrap redesign).
///
- public const string DisplayNameAnchorSelector = "a.DisplayName";
-
- ///
- /// CSS selector for the element that contains the short description.
- ///
- public const string DescriptionSelector = "span[id$='DescriptionLabel']";
+ public const string DescriptionSelector = "div.mb-1.text-muted.small";
///
/// CSS selector for bold labels inside the item description cell (used to locate the "Author:" label).
diff --git a/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs b/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs
index 260c6d2cc..5b91b843a 100644
--- a/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs
+++ b/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs
@@ -15,7 +15,10 @@ public static class CommunityOutpostCatalogConstants
public const string UnknownVersion = "unknown";
/// Default base URL for making relative URLs absolute.
- public const string DefaultBaseUrl = "https://legi.cc/patch";
+ public const string DefaultBaseUrl = CommunityOutpostConstants.BaseUrl + "/patch";
+
+ /// Default base URL for downloading GenPatcher content .dat packages.
+ public const string DefaultFilesBaseUrl = CommunityOutpostConstants.BaseUrl + "/gp2/f";
/// Metadata key for the content code.
public const string ContentCodeKey = "contentCode";
diff --git a/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs b/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs
index 93780ed46..5dd0475fb 100644
--- a/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs
+++ b/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs
@@ -10,9 +10,14 @@ namespace GenHub.Core.Constants;
/// Endpoint URLs and timeouts are configured via data-driven configuration.
/// See Providers/communityoutpost.provider.json for runtime-configurable values.
///
-[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")]
+[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Default base URL for Community Outpost service")]
public static class CommunityOutpostConstants
{
+ ///
+ /// Base URL for Community Outpost / GenPatcher service.
+ ///
+ public const string BaseUrl = "https://legi.cc";
+
///
/// The publisher ID for Community Outpost.
///
@@ -53,6 +58,11 @@ public static class CommunityOutpostConstants
///
public const string ContentName = "Community Patch";
+ ///
+ /// Tag and content code for Community Patch items.
+ ///
+ public const string CommunityPatchTag = "community-patch";
+
///
/// Description for the discoverer.
///
diff --git a/GenHub/GenHub.Core/Constants/ContentConstants.cs b/GenHub/GenHub.Core/Constants/ContentConstants.cs
index 0bc3c907c..96d6a6db0 100644
--- a/GenHub/GenHub.Core/Constants/ContentConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ContentConstants.cs
@@ -94,4 +94,31 @@ public static class ContentConstants
/// Maximum allowed size for the content catalog in bytes (10 MB).
///
public const long MaxCatalogSizeBytes = 10 * ConversionConstants.BytesPerMegabyte;
+
+ ///
+ /// Shared resolver/display metadata key for map player counts.
+ /// Builtin and catalog publishers should set this so download cards can render a consistent badge.
+ ///
+ public const string PlayerCountMetadataKey = "playerCount";
+
+ ///
+ /// Shared resolver/display metadata key for content categories (AOA, Compstomp, ModDB category, etc.).
+ ///
+ public const string CategoryMetadataKey = "category";
+
+ ///
+ /// Display metadata key for a comma-separated list of included/required content names
+ /// (e.g. catalog ContentBundle dependencies resolved to friendly titles).
+ ///
+ public const string IncludesSummaryMetadataKey = "includesSummary";
+
+ ///
+ /// Number of recent releases and addons to eagerly preload extended details for.
+ ///
+ public const int PreloadRecentItemsLimit = 5;
+
+ ///
+ /// Maximum concurrent background requests when preloading recent item details.
+ ///
+ public const int PreloadConcurrencyLimit = 3;
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Constants/DirectoryNames.cs b/GenHub/GenHub.Core/Constants/DirectoryNames.cs
index 4938758ab..037870073 100644
--- a/GenHub/GenHub.Core/Constants/DirectoryNames.cs
+++ b/GenHub/GenHub.Core/Constants/DirectoryNames.cs
@@ -93,4 +93,19 @@ public static class DirectoryNames
/// Directory for storing tool workspaces.
///
public const string ToolWorkspaces = "ToolWorkspaces";
+
+ ///
+ /// Directory for persistent Playwright browser profiles (cookies/storage for bot-protected sites).
+ ///
+ public const string BrowserProfiles = "BrowserProfiles";
+
+ ///
+ /// Directory for the app-owned Playwright Chromium runtime (not the system Chrome/Edge install).
+ ///
+ public const string BrowserRuntime = "BrowserRuntime";
+
+ ///
+ /// Directory for game movie assets.
+ ///
+ public const string Movies = "Movies";
}
diff --git a/GenHub/GenHub.Core/Constants/GameClientConstants.cs b/GenHub/GenHub.Core/Constants/GameClientConstants.cs
index 6937d2e2c..45c4b5c5a 100644
--- a/GenHub/GenHub.Core/Constants/GameClientConstants.cs
+++ b/GenHub/GenHub.Core/Constants/GameClientConstants.cs
@@ -78,6 +78,12 @@ public static class GameClientConstants
/// Primary Generals Vanilla Patch archive filename.
public const string GeneralsPatchBig = "Patch.big";
+ /// Generals Vanilla security archive filename.
+ public const string GeneralsSecurityBig = "gensec.big";
+
+ /// Zero Hour archive extension suffix.
+ public const string ZeroHourArchiveExtensionSuffix = "ZH.big";
+
// ===== GeneralsOnline Client Detection =====
/// GeneralsOnline 60Hz client executable name.
diff --git a/GenHub/GenHub.Core/Constants/GameContentConstants.cs b/GenHub/GenHub.Core/Constants/GameContentConstants.cs
new file mode 100644
index 000000000..a082d20f8
--- /dev/null
+++ b/GenHub/GenHub.Core/Constants/GameContentConstants.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace GenHub.Core.Constants;
+
+///
+/// Constants for game content structure, archive payload normalization, and recognized game assets.
+///
+public static class GameContentConstants
+{
+ ///
+ /// Maximum recursive wrapper directory stripping depth.
+ ///
+ public const int MaxWrapperNormalizationDepth = 10;
+
+ ///
+ /// File name for the Electronic Arts intro logo movie.
+ ///
+ public const string EaLogoBikFileName = "EA_LOGO.BIK";
+
+ ///
+ /// Supported archive file extensions.
+ ///
+ public static readonly IReadOnlyList ArchiveExtensions =
+ [
+ ".zip",
+ ".7z",
+ ".rar",
+ ".dat",
+ ];
+
+ ///
+ /// Canonical directory names used at the game workspace root.
+ ///
+ public static readonly IReadOnlyList RecognizedGameDirectories =
+ [
+ "Data",
+ "Art",
+ "Window",
+ "Audio",
+ "Maps",
+ "INI",
+ "Scripts",
+ "Textures",
+ "W3D",
+ "English",
+ "German",
+ "French",
+ "Italian",
+ "Spanish",
+ "Korean",
+ "Polish",
+ "Chinese",
+ ];
+
+ ///
+ /// Canonical file extensions for game assets, binaries, and configurations.
+ ///
+ public static readonly IReadOnlyList RecognizedGameFileExtensions =
+ [
+ ".big",
+ ".exe",
+ ".dll",
+ ".str",
+ ".csf",
+ ".ini",
+ ".map",
+ ".bik",
+ ".asi",
+ ];
+
+ ///
+ /// Extensions for loose non-game documentation and metadata files.
+ ///
+ public static readonly IReadOnlyList DocumentationExtensions =
+ [
+ ".txt",
+ ".url",
+ ".md",
+ ".htm",
+ ".html",
+ ".pdf",
+ ".lnk",
+ ".jpg",
+ ".jpeg",
+ ".png",
+ ".gif",
+ ".bmp",
+ ];
+
+ ///
+ /// System junk file or directory names to purge during payload normalization.
+ ///
+ public static readonly IReadOnlyList SystemJunkNames =
+ [
+ ".ds_store",
+ "thumbs.db",
+ "desktop.ini",
+ "__macosx",
+ ];
+
+ ///
+ /// Subfolder aliases that denote Zero Hour specific game content.
+ ///
+ public static readonly IReadOnlyList ZeroHourSubfolderAliases =
+ [
+ "Zero Hour",
+ "ZH",
+ "Command and Conquer Generals Zero Hour",
+ "Command & Conquer Generals - Zero Hour",
+ "Command & Conquer: Generals - Zero Hour",
+ "C&C Generals Zero Hour",
+ "ZeroHour",
+ ];
+
+ ///
+ /// Subfolder aliases that denote Generals specific game content.
+ ///
+ public static readonly IReadOnlyList GeneralsSubfolderAliases =
+ [
+ "Generals",
+ "CCG",
+ "Command and Conquer Generals",
+ "Command & Conquer Generals",
+ "C&C Generals",
+ ];
+
+ ///
+ /// Determines whether the specified directory name is a recognized canonical game directory.
+ ///
+ /// The directory name to check.
+ /// true if recognized; otherwise, false.
+ public static bool IsRecognizedGameDirectory(string? directoryName)
+ {
+ return !string.IsNullOrEmpty(directoryName) &&
+ RecognizedGameDirectories.Contains(directoryName, StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Determines whether the specified file extension or file name represents a recognized game asset.
+ ///
+ /// The file name or extension to check.
+ /// true if recognized; otherwise, false.
+ public static bool IsRecognizedGameFile(string? fileNameOrExtension)
+ {
+ if (string.IsNullOrEmpty(fileNameOrExtension))
+ {
+ return false;
+ }
+
+ var ext = Path.GetExtension(fileNameOrExtension);
+ if (string.IsNullOrEmpty(ext))
+ {
+ ext = fileNameOrExtension;
+ }
+
+ return RecognizedGameFileExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase);
+ }
+}
diff --git a/GenHub/GenHub.Core/Constants/GenLauncherConstants.cs b/GenHub/GenHub.Core/Constants/GenLauncherConstants.cs
index 6576ee203..2443b4be4 100644
--- a/GenHub/GenHub.Core/Constants/GenLauncherConstants.cs
+++ b/GenHub/GenHub.Core/Constants/GenLauncherConstants.cs
@@ -25,6 +25,11 @@ public static class GenLauncherConstants
///
public const string GibExtension = ".gib";
+ ///
+ /// Contra mod inactive .big file extension.
+ ///
+ public const string CtrExtension = ".ctr";
+
///
/// Standard .big file extension.
///
@@ -44,4 +49,13 @@ public static class GenLauncherConstants
OriginalFileSuffix,
TempCopySuffix,
];
+
+ ///
+ /// Extensions for inactive BIG archive files used by mods/launchers.
+ ///
+ public static readonly string[] InactiveBigExtensions =
+ [
+ GibExtension,
+ CtrExtension,
+ ];
}
diff --git a/GenHub/GenHub.Core/Constants/GitHubTopicsConstants.cs b/GenHub/GenHub.Core/Constants/GitHubTopicsConstants.cs
index 6cf607713..0e127d19a 100644
--- a/GenHub/GenHub.Core/Constants/GitHubTopicsConstants.cs
+++ b/GenHub/GenHub.Core/Constants/GitHubTopicsConstants.cs
@@ -129,4 +129,9 @@ public static class GitHubTopicsConstants
/// Metadata key for primary language.
///
public const string LanguageMetadataKey = "language";
+
+ ///
+ /// Metadata key for asset name.
+ ///
+ public const string AssetNameMetadataKey = "asset-name";
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs b/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs
new file mode 100644
index 000000000..a10b32379
--- /dev/null
+++ b/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs
@@ -0,0 +1,37 @@
+namespace GenHub.Core.Constants;
+
+///
+/// Constants for image downloading, validation, and caching.
+///
+public static class ImageCacheConstants
+{
+ ///
+ /// Maximum allowed image download payload in bytes (15 MB).
+ ///
+ public const long MaxImageDownloadSizeBytes = 15L * 1024 * 1024;
+
+ ///
+ /// Maximum number of bitmap entries stored in the memory LRU cache.
+ ///
+ public const int MaxMemoryCacheEntries = 200;
+
+ ///
+ /// Maximum disk cache size in bytes (250 MB).
+ ///
+ public const long MaxDiskCacheSizeBytes = 250L * 1024 * 1024;
+
+ ///
+ /// Time-to-live for disk-cached images in days.
+ ///
+ public const int DiskCacheTtlDays = 30;
+
+ ///
+ /// Default HTTP timeout in seconds for downloading images.
+ ///
+ public const int DefaultTimeoutSeconds = 30;
+
+ ///
+ /// Maximum allowed HTTP redirects when downloading images.
+ ///
+ public const int MaxRedirects = 5;
+}
diff --git a/GenHub/GenHub.Core/Constants/InfoConstants.cs b/GenHub/GenHub.Core/Constants/InfoConstants.cs
index de315df05..7c89353a4 100644
--- a/GenHub/GenHub.Core/Constants/InfoConstants.cs
+++ b/GenHub/GenHub.Core/Constants/InfoConstants.cs
@@ -19,6 +19,11 @@ public static class InfoConstants
///
public const string FaqDefaultLanguage = "en";
+ ///
+ /// Section ID for the quickstart guide.
+ ///
+ public const string QuickstartSectionId = "quickstart";
+
///
/// Module name for GenHub Guide.
///
@@ -47,8 +52,8 @@ public static class InfoConstants
///
/// The list of supported languages for the FAQ.
///
- public static readonly IReadOnlyList SupportedFaqLanguages = new[]
- {
+ public static readonly IReadOnlyList SupportedFaqLanguages =
+ [
"en", "de", "ph", "ar",
- };
+ ];
}
diff --git a/GenHub/GenHub.Core/Constants/ManifestConstants.cs b/GenHub/GenHub.Core/Constants/ManifestConstants.cs
index d58959ce7..f72cc010b 100644
--- a/GenHub/GenHub.Core/Constants/ManifestConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ManifestConstants.cs
@@ -124,6 +124,16 @@ public static class ManifestConstants
///
public const string ZeroHourManifestVersion = "1.04";
+ ///
+ /// Type-only foundation requirement ID for Zero Hour game installations.
+ ///
+ public const string ZeroHourFoundationDependencyId = "1.104.any.gameinstallation.zerohour";
+
+ ///
+ /// Type-only foundation requirement ID for Generals game installations.
+ ///
+ public const string GeneralsFoundationDependencyId = "1.108.any.gameinstallation.generals";
+
/// Tag for unknown authors.
public const string UnknownAuthor = "unknown";
diff --git a/GenHub/GenHub.Core/Constants/ModDBConstants.cs b/GenHub/GenHub.Core/Constants/ModDBConstants.cs
index 30096acb0..4d90739b8 100644
--- a/GenHub/GenHub.Core/Constants/ModDBConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ModDBConstants.cs
@@ -38,22 +38,73 @@ public static class ModDBConstants
/// Downloads section for Zero Hour.
public const string ZeroHourDownloadsUrl = ZeroHourBaseUrl + "/downloads";
+ /// Mods URL path segment.
+ public const string ModsSegment = "/mods/";
+
+ /// Downloads URL path segment.
+ public const string DownloadsSegment = "/downloads/";
+
+ /// Downloads section name.
+ public const string DownloadsSection = "downloads";
+
+ /// Mods section name.
+ public const string ModsSection = "mods";
+
+ /// Addons URL path segment.
+ public const string AddonsSegment = "/addons/";
+
+ /// Addons section name.
+ public const string AddonsSection = "addons";
+
+ /// Articles URL path segment.
+ public const string ArticlesSegment = "/articles";
+
+ /// Games URL path segment.
+ public const string GamesSegment = "/games/";
+
+ /// Games section name.
+ public const string GamesSection = "games";
+
+ /// News URL path segment.
+ public const string NewsSegment = "/news";
+
+ /// Tutorials URL path segment.
+ public const string TutorialsSegment = "/tutorials";
+
+ /// Videos URL path segment.
+ public const string VideosSegment = "/videos";
+
+ /// Images URL path segment.
+ public const string ImagesSegment = "/images";
+
+ /// Reviews URL path segment.
+ public const string ReviewsSegment = "/reviews";
+
+ /// Placeholder blank gif image filename.
+ public const string BlankGifFileName = "blank.gif";
+
/// Addons section for Generals.
public const string GeneralsAddonsUrl = GeneralsBaseUrl + "/addons";
/// Addons section for Zero Hour.
public const string ZeroHourAddonsUrl = ZeroHourBaseUrl + "/addons";
+ /// Media RSS XML namespace URI.
+ public const string MediaRssNamespace = "http://search.yahoo.com/mrss/";
+
// ===== Publisher Info =====
+ /// Canonical identifier string for ModDB.
+ public const string ModDbKey = "moddb";
+
/// Publisher prefix for ModDB content (to be combined with author: moddb-{author}).
- public const string PublisherPrefix = "moddb";
+ public const string PublisherPrefix = ModDbKey;
/// Publisher type identifier for ModDB content pipeline.
- public const string PublisherType = "moddb";
+ public const string PublisherType = ModDbKey;
/// Publisher ID for the ModDB service.
- public const string PublisherId = "moddb";
+ public const string PublisherId = ModDbKey;
/// Display name for the publisher.
public const string PublisherDisplayName = "ModDB";
@@ -75,6 +126,12 @@ public static class ModDBConstants
/// ModDB website URL.
public const string PublisherWebsite = BaseUrl;
+ ///
+ /// On-disk Playwright browser profile name used to persist the Cloudflare clearance cookie so
+ /// the user only solves the bot challenge once per session (and across restarts until expiry).
+ ///
+ public const string BrowserProfileName = "moddb";
+
/// Short description for publisher card display.
public const string ShortDescription = "Community mods, maps, and content from ModDB";
@@ -187,6 +244,29 @@ public static class ModDBConstants
/// Value for filter parameter when enabled.
public const string FilterEnabledValue = "t";
+ // ===== Sort Values =====
+
+ /// Sort: Date descending (newest first).
+ public const string SortDateDesc = "date-desc";
+
+ /// Sort: Date ascending (oldest first).
+ public const string SortDateAsc = "date-asc";
+
+ /// Sort: Visits / Popularity descending.
+ public const string SortVisitDesc = "visit-desc";
+
+ /// Sort: Rating descending.
+ public const string SortRatingDesc = "rating-desc";
+
+ /// Sort: Name ascending (A-Z).
+ public const string SortNameAsc = "name-asc";
+
+ /// Sort: Name descending (Z-A).
+ public const string SortNameDesc = "name-desc";
+
+ /// Default sort value for ModDB searches and listings (newest first).
+ public const string DefaultSort = SortDateDesc;
+
// ===== Category Values =====
// Downloads Section - Releases
@@ -359,6 +439,34 @@ public static class ModDBConstants
/// Metadata key for original category.
public const string OriginalCategoryMetadataKey = "moddbCategory";
+ /// Metadata key for identifying if content is a mod.
+ public const string IsModMetadataKey = "IsMod";
+
+ /// Metadata key for parent mod URL.
+ public const string ParentModUrlMetadataKey = "ParentModUrl";
+
+ // ===== Playwright / Scraping Constants =====
+
+ /// Default timeout for page navigation (ms).
+ public const int DefaultGotoTimeout = 30000;
+
+ ///
+ /// Default timeout for waiting for a selector (ms). ModDB sits behind Cloudflare; the headed
+ /// browser persistent profile usually receives the clearance cookie after verification, but 15 s gives a safe margin
+ /// for manual challenge solves before the scraper parses whatever it has.
+ ///
+ public const int DefaultSelectorTimeout = 15000;
+
+ ///
+ /// How long (ms) the listing scrape waits for the user to solve a Cloudflare challenge in the
+ /// visible browser before giving up. Long enough for a manual "I am not a robot" click; the
+ /// page stays open after the deadline so the user can finish and retry.
+ ///
+ public const int VerificationWaitTimeoutMs = 120000;
+
+ /// Selector for content items in listing pages (Fallback).
+ public const string DefaultListItemSelector = "div.row.rowcontent, div.table tr";
+
// ===== Error Messages =====
/// Error message for invalid URL.
@@ -407,6 +515,29 @@ public static class ModDBConstants
/// Timeframe: Year or older.
public const string TimeframeYearOrOlder = "5";
+ // ===== Managed Chromium Runtime Notifications =====
+
+ /// Title for the Chromium runtime installation toast.
+ public const string ChromiumInstallTitle = "Installing Chromium Runtime";
+
+ /// Initial message when downloading the managed Chromium runtime.
+ public const string ChromiumDownloadingMessage = "Downloading Chromium (~240 MB)... Please wait.";
+
+ /// Message while extracting and configuring the managed Chromium runtime.
+ public const string ChromiumExtractingMessage = "Extracting and configuring Chromium runtime...";
+
+ /// Title when the Chromium runtime installation completes successfully.
+ public const string ChromiumReadyTitle = "Chromium Runtime Ready";
+
+ /// Message when the Chromium runtime installation completes successfully.
+ public const string ChromiumReadyMessage = "Chromium runtime installed successfully.";
+
+ /// Title when the Chromium runtime installation fails.
+ public const string ChromiumInstallFailedTitle = "Chromium Installation Failed";
+
+ /// Message when the Chromium runtime installation fails.
+ public const string ChromiumInstallFailedMessage = "GenHub could not install its managed Chromium runtime. Check your network connection and try again.";
+
// ===== Content Tags =====
/// Content tags for search and categorization.
diff --git a/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs b/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs
index 55ab7fa3c..91ce3b6e1 100644
--- a/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs
@@ -17,9 +17,15 @@ public static class ModDBParserConstants
/// Selector for developer/publisher links.
public const string DeveloperSelector = "a[href*='/members/'], a[href*='/company/']";
+ /// Selector for the profile/mod info box that carries the real developer name.
+ public const string DeveloperProfileSelector = "#modsinfo a[href*='/members/'], #modsinfo a[href*='/company/'], .sidecolumn a[href*='/members/'], .sidecolumn a[href*='/company/']";
+
/// Selector for release date.
public const string ReleaseDateSelector = "time[datetime], .date, .released";
+ /// Attribute name for HTML datetime attribute.
+ public const string DateTimeAttribute = "datetime";
+
/// Selector for game name.
public const string GameNameSelector = ".game, .parentgame";
@@ -59,7 +65,7 @@ public static class ModDBParserConstants
public const string FileMetadataValueSelector = "td:last-child";
/// Selector for the main download button on file pages.
- public const string MainDownloadButtonSelector = "a.download, a.downloadarea, .downloadbutton a, a[href*='/downloads/start/']";
+ public const string MainDownloadButtonSelector = "a.download, a.downloadarea, .downloadbutton a, a[href*='/downloads/start/'], a[href*='/addons/start/']";
/// Selector for download size on the button.
public const string DownloadSizeSelector = ".download .size, .downloadbutton .size";
@@ -87,10 +93,13 @@ public static class ModDBParserConstants
// ===== Description/Summary Selectors =====
/// Selector for full description content.
- public const string FullDescriptionSelector = "#articlebrowse, .summary .content, .description .content, .modtext";
+ public const string FullDescriptionSelector = "#downloaddescription, #downloadsummary, #articlebrowse .articlebody, .articlebody, #modsummary, .modtext, #profile .description, #description, #articlebrowse, .summary .content, .description .content";
+
+ /// Selector for the file-page body copy (not the breadcrumb .summary trail).
+ public const string FileDescriptionSelector = "#downloaddescription, #downloadsummary";
- /// Selector for truncated summary.
- public const string SummarySelector = ".summary p, .description p";
+ /// Selector for summary or description container.
+ public const string SummarySelector = ".description, .rubric, p[itemprop='description']";
// ===== Legacy File Selectors =====
@@ -98,28 +107,31 @@ public static class ModDBParserConstants
public const string FilesTableSelector = "table.filelist, .table.files, #files";
/// Selector for individual file rows.
- public const string FileRowSelector = "tr.file, .row.file, .file";
+ public const string FileRowSelector = "tr.file, .row.file, .file, .row.rowcontent";
/// Selector for file name.
- public const string FileNameSelector = "h5, h4, .name, .title";
+ public const string FileNameSelector = "h4 a, h5 a, h3 a, .heading a, .title a, a.title, .name a, h5, h4, .name, .title";
/// Selector for file version.
public const string FileVersionSelector = ".version, .ver";
/// Selector for file size.
- public const string FileSizeSelector = ".size, .filesize";
+ public const string FileSizeSelector = ".size, .filesize, .filesizes, span.size";
+
+ /// Selector for file subheading or metadata row.
+ public const string FileSubheadingSelector = ".subheading, span.subheading, .meta, .details, .info, p.summary, .summary";
/// Selector for file upload date.
- public const string FileDateSelector = "time[datetime], .date, .uploaded";
+ public const string FileDateSelector = "time[datetime], .date, .uploaded, time";
/// Selector for file category.
- public const string FileCategorySelector = ".category, .type";
+ public const string FileCategorySelector = ".category, .type, span.category";
/// Selector for file uploader.
- public const string FileUploaderSelector = ".uploader, .author, a[href*='/members/']";
+ public const string FileUploaderSelector = ".uploader, .author, a[href*='/members/'], a[href*='/company/']";
/// Selector for file download link (robust).
- public const string FileDownloadSelector = "a.button.download, a[href*='/downloads/start/'], .download a";
+ public const string FileDownloadSelector = "a.button, a.buttonlarge, a.download, a.btn, a[href*='/downloads/start/'], a[href*='/addons/start/'], a[href*='/downloads/'], a[href*='/addons/'], .download a, .actions a";
/// Selector for file MD5 hash.
public const string FileMd5Selector = ".md5, .hash";
@@ -130,22 +142,41 @@ public static class ModDBParserConstants
// ===== Videos Section Selectors =====
/// Selector for embedded video iframes.
- public const string VideoSelector = "iframe[src*='youtube'], iframe[src*='vimeo'], iframe[src*='youtu.be']";
+ public const string VideoSelector = "iframe[src*='youtube'], iframe[src*='youtube-nocookie'], iframe[src*='youtu.be'], iframe[src*='vimeo'], iframe[src*='dailymotion'], iframe[src*='moddb.com/media/iframe'], iframe[src*='moddb.com/media/embed'], iframe[src*='moddb.com/videos/iframe'], iframe[src*='moddb.com/videos/embed']";
+
+ /// Selector for video gallery containers and items.
+ public const string VideoGallerySelector = "#videobox, #videosbrowse, #mediabrowse, .mediarow, .mediabox";
+
+ /// Selector for video links.
+ public const string VideoLinkSelector = "a[href*='/videos/'], a[href*='youtube.com/watch'], a[href*='youtu.be/'], a[href*='vimeo.com/']";
/// Selector for video thumbnails.
- public const string VideoThumbnailSelector = ".thumbnail img, .preview img";
+ public const string VideoThumbnailSelector = ".thumbnail img, .preview img, img";
/// Selector for video titles.
- public const string VideoTitleSelector = ".title, h3, h4";
+ public const string VideoTitleSelector = ".title, h3, h4, h5, .caption";
+
+ /// Selector for recommendation and related content sections.
+ public const string RecommendationsSelector = "#recommendations, .recommendations, #related, .related, #similar, .similar, #fansalsoviewed, .fansalsoviewed, .youmayalso, [class*='recommend'], [id*='recommend'], [class*='similar'], [id*='similar']";
// ===== Images Section Selectors =====
/// Selector for image gallery container.
- public const string ImageGallerySelector = ".mediarow, .screenshot, .imagebox, .gallery";
+ public const string ImageGallerySelector = "#imagebox, #mediaimage, #imagebrowse, #mediabrowse, .mediarow";
+
+ ///
+ /// Selector for gallery images only. Deliberately excludes a blanket
+ /// img[src*='media.moddb.com'] match, which previously pulled game icons, member
+ /// avatars, and file-page chrome into the Media tab.
+ ///
+ public const string GalleryImageSelector = "#imagebox img, #mediaimage img, #imagebrowse img, #mediabrowse img, .mediarow img, .media .holder img, #downloadsummary img, #downloaddescription img, .preview img, a[href*='/mods/'][href*='/images/'] img";
/// Selector for individual images.
public const string ImageSelector = "img";
+ /// Sidebar/profile containers whose images are icons and avatars, not gallery media.
+ public const string ImageSidebarSelector = "#modsinfo, #downloadsprofilemenu, #profile, .sidecolumn, aside";
+
/// Selector for image thumbnails.
public const string ImageThumbnailSelector = ".thumbnail img, .thumb img";
@@ -197,20 +228,24 @@ public static class ModDBParserConstants
// ===== Comments Section Selectors =====
- /// Selector for comments container.
- public const string CommentsSelector = ".comment, .post, .comments";
+ /// Selector for comments container. Do not use #commentform — that is the composer.
+ public const string CommentsSelector = "#commentsbrowse";
- /// Selector for individual comment rows.
- public const string CommentRowSelector = ".comment, .post";
+ ///
+ /// Selector for posted comment rows. Requires the exact rowcomment class so the
+ /// composer rows (rowcommentguest, rowcommentsummary, rowcommentemail)
+ /// and #commentform are not treated as comments.
+ ///
+ public const string CommentRowSelector = ".row.rowcomment, .rowcomment";
/// Selector for comment authors.
- public const string CommentAuthorSelector = ".author, .username, a[href*='/members/']";
+ public const string CommentAuthorSelector = ".author, .username, .heading a, a[href*='/members/']";
- /// Selector for comment content.
- public const string CommentContentSelector = ".content, .body, .text";
+ /// Selector for comment content. Avoids bare p which matches login chrome and CSS blobs.
+ public const string CommentContentSelector = ":scope > .commentbody, .commentbody, p.comment";
/// Selector for comment dates.
- public const string CommentDateSelector = "time[datetime], .date";
+ public const string CommentDateSelector = "time[datetime], time, .date, .datetime, span.subheading";
/// Selector for comment karma/votes.
public const string CommentKarmaSelector = ".karma, .votes, .goodkarma, .badkarma";
@@ -245,4 +280,100 @@ public static class ModDBParserConstants
/// Pattern for games URLs.
public const string GamesUrlPattern = "/games/";
+
+ // ===== Mod Detail Page Selectors =====
+
+ /// Selector for the downloads section on mod pages.
+ public const string DownloadsSectionSelector = "#downloads, .downloads, .files";
+
+ /// Selector for the addons section on mod pages.
+ public const string AddonsSectionSelector = "#addons, .addons";
+
+ /// Selector for the tabs/navigation on mod pages.
+ public const string TabsSelector = ".tabs, .navigation, nav";
+
+ /// Selector for individual tab links.
+ public const string TabLinkSelector = "a[href*='/downloads'], a[href*='/addons']";
+
+ // ===== Metadata Keys (Internal/Normalized) =====
+
+ /// Metadata key for filename.
+ public const string MetadataFilename = "filename";
+
+ /// Alternative metadata key for filename.
+ public const string MetadataFileNameAlt = "file name";
+
+ /// Alternative metadata key for file.
+ public const string MetadataFileAlt = "file";
+
+ /// Metadata key for size.
+ public const string MetadataSize = "size";
+
+ /// Alternative metadata key for size.
+ public const string MetadataFileSizeAlt = "file size";
+
+ /// Metadata key for uploader.
+ public const string MetadataUploader = "uploader";
+
+ /// Alternative metadata key for uploaded by.
+ public const string MetadataUploadedBy = "uploaded by";
+
+ /// Alternative metadata key for author.
+ public const string MetadataAuthor = "author";
+
+ /// Metadata key for category.
+ public const string MetadataCategory = "category";
+
+ /// Alternative metadata key for file category.
+ public const string MetadataFileCategory = "file category";
+
+ /// Alternative metadata key for type.
+ public const string MetadataType = "type";
+
+ /// Metadata key for MD5 hash.
+ public const string MetadataMd5Hash = "md5 hash";
+
+ /// Metadata key for MD5 hash (alternative).
+ public const string MetadataMd5HashAlt = "md5hash";
+
+ /// Alternative metadata key for MD5 checksum.
+ public const string MetadataMd5Checksum = "md5 checksum";
+
+ /// Alternative metadata key for MD5.
+ public const string MetadataMd5 = "md5";
+
+ /// Alternative metadata key for hash.
+ public const string MetadataHash = "hash";
+
+ /// Alternative metadata key for checksum.
+ public const string MetadataChecksum = "checksum";
+
+ /// Metadata key for total downloads.
+ public const string MetadataTotalDownloads = "total downloads";
+
+ /// Alternative metadata key for download count.
+ public const string MetadataDownloadCount = "download count";
+
+ /// Metadata key for added date.
+ public const string MetadataAdded = "added";
+
+ /// Metadata key for updated date.
+ public const string MetadataUpdated = "updated";
+
+ // ===== Additional Selectors =====
+
+ /// Selector for fallback titles (h1, h2, etc).
+ public const string FallbackTitleSelector = "h2 a, h1 a, h2, h1";
+
+ /// Selector for file detail page title heading outside the global headerbox.
+ public const string FilePageTitleSelector = ".midcolumn h2, .columncenter h2, #downloadsfiles h2, #downloadsinfo h2, #downloads h2, .heading h2, .title h2, h2.title, .midcolumn h3, .heading h3";
+
+ /// Selector for file detail page preview images.
+ public const string FilePreviewImagesSelector = "#downloadmedia img, #downloadsmedia img, #preview img, #media img, .mediagallery img, .imagebox img, #imagebox img, #downloaddescription img, #downloadsummary img, a[href*='/images/'] img, .previewholder img, .media .holder img";
+
+ /// Selector for file description container elements.
+ public const string FileDescriptionContainerSelector = "#downloaddescription, #downloadsummary, #description, .description, .articlebody, #profiletotal";
+
+ /// Regex pattern for extracting parent mod path.
+ public const string ParentModPathRegex = @"(/mods/[^/]+)/(?:downloads|addons)/";
}
diff --git a/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs b/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs
index 43b03d702..b88bff564 100644
--- a/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs
+++ b/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs
@@ -271,7 +271,87 @@ public static (string Name, string Website, string SupportUrl) GetPublisherInfo(
GameInstallationType.Wine => (Wine.Name, Wine.Website, Wine.SupportUrl),
GameInstallationType.CDISO => (CdIso.Name, CdIso.Website, CdIso.SupportUrl),
GameInstallationType.Retail => (Retail.Name, Retail.Website, Retail.SupportUrl),
+
+ // Unknown is the enum default for unrecognized installs and Lutris is a legitimate Linux
+ // install type; both fall back to Retail, matching InstallationTypeDisplayConverter.
+ GameInstallationType.Unknown => (Retail.Name, Retail.Website, Retail.SupportUrl),
+ GameInstallationType.Lutris => (Retail.Name, Retail.Website, Retail.SupportUrl),
_ => (Retail.Name, Retail.Website, Retail.SupportUrl), // Default to retail
};
}
-}
\ No newline at end of file
+
+ ///
+ /// Gets the logo source URI for a publisher or content item based on publisher ID, provider name, or title.
+ ///
+ /// The publisher ID or provider display name.
+ /// The content ID, title, or manifest ID context.
+ /// An avares:// URI string pointing to the logo image asset, or null if unmapped.
+ public static string? GetPublisherLogo(string? publisherIdOrName, string? contentIdOrName = null)
+ {
+ var primary = MatchLogo(publisherIdOrName);
+ var secondary = MatchLogo(contentIdOrName);
+
+ // If primary matched generic GitHub, but secondary matched a specific publisher, prefer the specific publisher
+ if (primary == GitHub.LogoSource && secondary != null && secondary != GitHub.LogoSource)
+ {
+ return secondary;
+ }
+
+ return primary ?? secondary;
+ }
+
+ private static string? MatchLogo(string? input)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ return null;
+ }
+
+ if (input.Contains("communityoutpost", StringComparison.OrdinalIgnoreCase) ||
+ input.Contains("community outpost", StringComparison.OrdinalIgnoreCase) ||
+ input.Contains("community-outpost", StringComparison.OrdinalIgnoreCase))
+ {
+ return CommunityOutpost.LogoSource;
+ }
+
+ if (input.Contains("superhacker", StringComparison.OrdinalIgnoreCase))
+ {
+ return TheSuperHackers.LogoSource;
+ }
+
+ if (input.Contains("generalsonline", StringComparison.OrdinalIgnoreCase) ||
+ input.Contains("generals online", StringComparison.OrdinalIgnoreCase) ||
+ input.Contains("generals-online", StringComparison.OrdinalIgnoreCase))
+ {
+ return GeneralsOnline.LogoSource;
+ }
+
+ if (input.Contains("moddb", StringComparison.OrdinalIgnoreCase) ||
+ input.Contains("mod db", StringComparison.OrdinalIgnoreCase) ||
+ input.Contains("mod-db", StringComparison.OrdinalIgnoreCase))
+ {
+ return ModDB.LogoSource;
+ }
+
+ if (input.Contains("cnclabs", StringComparison.OrdinalIgnoreCase) ||
+ input.Contains("cnc labs", StringComparison.OrdinalIgnoreCase) ||
+ input.Contains("cnc-labs", StringComparison.OrdinalIgnoreCase))
+ {
+ return CNCLabs.LogoSource;
+ }
+
+ if (input.Contains("aodmaps", StringComparison.OrdinalIgnoreCase) ||
+ input.Contains("aod maps", StringComparison.OrdinalIgnoreCase) ||
+ input.Contains("aod-maps", StringComparison.OrdinalIgnoreCase))
+ {
+ return AODMaps.LogoSource;
+ }
+
+ if (input.Contains("github", StringComparison.OrdinalIgnoreCase))
+ {
+ return GitHub.LogoSource;
+ }
+
+ return null;
+ }
+}
diff --git a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs
index 160115a2b..e4e3b9a00 100644
--- a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs
+++ b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs
@@ -22,9 +22,6 @@ namespace GenHub.Core.Constants;
///
public static class PublisherTypeConstants
{
- /// Combined view of all publishers.
- public const string All = "all";
-
/// Unknown or unspecified publisher.
public const string Unknown = "unknown";
diff --git a/GenHub/GenHub.Core/Constants/SettingsConstants.cs b/GenHub/GenHub.Core/Constants/SettingsConstants.cs
index ac1d945c9..e78b62e92 100644
--- a/GenHub/GenHub.Core/Constants/SettingsConstants.cs
+++ b/GenHub/GenHub.Core/Constants/SettingsConstants.cs
@@ -55,6 +55,11 @@ public static class SettingsConstants
///
public const string SectionUpdates = "updates";
+ ///
+ /// Section ID for Catalog Subscriptions.
+ ///
+ public const string SectionSubscriptions = "subscriptions";
+
///
/// Section ID for Danger Zone.
///
diff --git a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
index d5d3dffce..473438dca 100644
--- a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
+++ b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
@@ -120,4 +120,7 @@ public static class SuperHackersConstants
/// Delimiter used in manifest versions.
public const string VersionDelimiter = ".";
+
+ /// Default page size for discovery (10 items = 5 release cards).
+ public const int PageSize = 10;
}
diff --git a/GenHub/GenHub.Core/Constants/UiConstants.cs b/GenHub/GenHub.Core/Constants/UiConstants.cs
index e3dc98279..e1421d0da 100644
--- a/GenHub/GenHub.Core/Constants/UiConstants.cs
+++ b/GenHub/GenHub.Core/Constants/UiConstants.cs
@@ -25,6 +25,26 @@ public static class UiConstants
///
public const double DefaultProfileSettingsHeight = 700;
+ ///
+ /// Default width for the profile settings sidebar in pixels.
+ ///
+ public const double DefaultProfileSettingsSidebarWidth = 190;
+
+ ///
+ /// Minimum width for the profile settings sidebar (shows icons only) in pixels.
+ ///
+ public const double MinProfileSettingsSidebarWidth = 68;
+
+ ///
+ /// Maximum width for the profile settings sidebar in pixels.
+ ///
+ public const double MaxProfileSettingsSidebarWidth = 300;
+
+ ///
+ /// Progressive item render delay in milliseconds for streaming cards into the download browser grid.
+ ///
+ public const int ProgressiveItemRenderDelayMs = 20;
+
// Status colors
///
@@ -37,6 +57,36 @@ public static class UiConstants
///
public const string StatusErrorColor = "#F44336";
+ ///
+ /// color used for downloaded status indicator.
+ ///
+ public const string StatusDownloadedColor = "#4CAF50";
+
+ ///
+ /// color used for not downloaded status indicator.
+ ///
+ public const string StatusNotDownloadedColor = "#B388FF";
+
+ ///
+ /// color used for update available status indicator.
+ ///
+ public const string StatusUpdateAvailableColor = "#FFB74D";
+
+ ///
+ /// svg path data for transparent checkmark icon.
+ ///
+ public const string TransparentCheckmarkIconPath = "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z";
+
+ ///
+ /// svg path data for detailed download arrow icon into tray.
+ ///
+ public const string DownloadArrowIconPath = "M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z";
+
+ ///
+ /// svg path data for update sync icon.
+ ///
+ public const string UpdateSyncIconPath = "M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z";
+
///
/// Default theme color for Generals content.
///
diff --git a/GenHub/GenHub.Core/Extensions/ContentAcquisitionProgressExtensions.cs b/GenHub/GenHub.Core/Extensions/ContentAcquisitionProgressExtensions.cs
new file mode 100644
index 000000000..767214839
--- /dev/null
+++ b/GenHub/GenHub.Core/Extensions/ContentAcquisitionProgressExtensions.cs
@@ -0,0 +1,92 @@
+using GenHub.Core.Helpers;
+using GenHub.Core.Models.Content;
+
+namespace GenHub.Core.Extensions;
+
+///
+/// Extension methods for ContentAcquisitionProgress.
+///
+public static class ContentAcquisitionProgressExtensions
+{
+ ///
+ /// Formats a user-friendly progress status message with stage indicators.
+ ///
+ /// The progress object to format.
+ /// A formatted progress status string.
+ public static string FormatProgressStatus(this ContentAcquisitionProgress progress)
+ {
+ ArgumentNullException.ThrowIfNull(progress);
+
+ if (progress.TotalStages > 0 && progress.CurrentStage > 0)
+ {
+ return FormatStagedProgress(progress);
+ }
+
+ var phaseName = GetPhaseName(progress.Phase);
+ return FormatPhaseProgress(progress, phaseName);
+ }
+
+ private static string FormatStagedProgress(ContentAcquisitionProgress progress)
+ {
+ string stagePart = $"{progress.CurrentStage}/{progress.TotalStages}";
+ string description = !string.IsNullOrEmpty(progress.CurrentOperation) &&
+ !string.Equals(progress.CurrentOperation, progress.StageDescription, StringComparison.Ordinal)
+ ? $"{progress.StageDescription}: {progress.CurrentOperation}"
+ : progress.StageDescription;
+
+ string percentPart = progress.StageProgress is > 0 and < 100
+ ? $" ({progress.StageProgress:F0}%)"
+ : string.Empty;
+
+ string bottleneckPart = progress.IsBottleneck && !string.IsNullOrEmpty(progress.BottleneckReason)
+ ? $" - {progress.BottleneckReason}"
+ : string.Empty;
+
+ string filesPart = progress.TotalFiles > 1
+ ? $" [{progress.FilesProcessed}/{progress.TotalFiles}]"
+ : string.Empty;
+
+ return $"{stagePart} - {description}{percentPart}{filesPart}{bottleneckPart}";
+ }
+
+ private static string GetPhaseName(ContentAcquisitionPhase phase) => phase switch
+ {
+ ContentAcquisitionPhase.None => "Processing",
+ ContentAcquisitionPhase.Downloading => "Downloading",
+ ContentAcquisitionPhase.Extracting => "Extracting",
+ ContentAcquisitionPhase.Copying => "Copying",
+ ContentAcquisitionPhase.ValidatingManifest => "Validating manifest",
+ ContentAcquisitionPhase.ValidatingFiles => "Validating files",
+ ContentAcquisitionPhase.Delivering => "Installing",
+ ContentAcquisitionPhase.StoringInCas => "Storing",
+ ContentAcquisitionPhase.Completed => "Complete",
+ _ => "Processing",
+ };
+
+ private static string FormatPhaseProgress(ContentAcquisitionProgress progress, string phaseName)
+ {
+ if (!string.IsNullOrEmpty(progress.CurrentOperation))
+ {
+ return $"{phaseName}: {progress.CurrentOperation}";
+ }
+
+ string percentText = progress.ProgressPercentage >= 0 ? $"{progress.ProgressPercentage:F0}%" : string.Empty;
+
+ if (progress.TotalBytes > 0 && progress.Phase == ContentAcquisitionPhase.Downloading)
+ {
+ string downloaded = ByteFormatHelper.FormatBytes(progress.BytesProcessed);
+ string total = ByteFormatHelper.FormatBytes(progress.TotalBytes);
+ return !string.IsNullOrEmpty(percentText)
+ ? $"{phaseName}: {downloaded} / {total} ({percentText})"
+ : $"{phaseName}: {downloaded} / {total}";
+ }
+
+ if (progress.TotalFiles > 0)
+ {
+ int phasePercent = (int)((double)progress.FilesProcessed / progress.TotalFiles * 100);
+ return $"{phaseName}: {progress.FilesProcessed}/{progress.TotalFiles} files ({phasePercent}%)";
+ }
+
+ return !string.IsNullOrEmpty(percentText) ? $"{phaseName}... {percentText}" : $"{phaseName}...";
+ }
+}
diff --git a/GenHub/GenHub.Core/Extensions/ContentTypeExtensions.cs b/GenHub/GenHub.Core/Extensions/ContentTypeExtensions.cs
index 78f686e01..e16fd1cb8 100644
--- a/GenHub/GenHub.Core/Extensions/ContentTypeExtensions.cs
+++ b/GenHub/GenHub.Core/Extensions/ContentTypeExtensions.cs
@@ -30,6 +30,11 @@ public static string GetDisplayName(this ContentType contentType)
ContentType.ContentReferral => "Content Referral",
ContentType.ModdingTool => "Tool",
ContentType.Executable => "Executable",
+ ContentType.Skin => "Skin",
+ ContentType.Video => "Video",
+ ContentType.Replay => "Replay",
+ ContentType.Screensaver => "Screensaver",
+ ContentType.UnknownContentType => "Unknown",
_ => contentType.ToString(),
};
}
@@ -56,6 +61,10 @@ public static string ToManifestIdString(this ContentType contentType)
ContentType.ContentReferral => "contentreferral",
ContentType.Mission => "mission",
ContentType.Map => "map",
+ ContentType.Skin => "skin",
+ ContentType.Video => "video",
+ ContentType.Replay => "replay",
+ ContentType.Screensaver => "screensaver",
ContentType.ModdingTool => "moddingtool",
ContentType.Executable => "executable",
ContentType.UnknownContentType => "unknown",
diff --git a/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs
new file mode 100644
index 000000000..d80379257
--- /dev/null
+++ b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs
@@ -0,0 +1,194 @@
+using System;
+using System.IO;
+using GenHub.Core.Models.Results;
+
+namespace GenHub.Core.Helpers;
+
+///
+/// Policy and validation helper for ensuring file system paths remain safely contained
+/// within a target root directory, preventing directory traversal and zip slip attacks across OS platforms.
+///
+public static class ContentPathPolicy
+{
+ ///
+ /// Resolves a candidate relative path within a designated root directory, ensuring that the
+ /// resolved canonical path is strictly contained within that root directory.
+ ///
+ /// The trusted root directory.
+ /// The relative path to validate and resolve.
+ ///
+ /// An containing the normalized absolute destination path if safe,
+ /// or a failure result if the path escapes the root directory or contains illegal rooted/traversal components.
+ ///
+ public static OperationResult ResolveContainedFile(string? rootDirectory, string? relativePath)
+ {
+ if (string.IsNullOrWhiteSpace(rootDirectory))
+ {
+ return OperationResult.CreateFailure("Root directory cannot be null or empty.");
+ }
+
+ if (string.IsNullOrWhiteSpace(relativePath))
+ {
+ return OperationResult.CreateFailure("Relative path cannot be null or empty.");
+ }
+
+ if (Path.IsPathRooted(relativePath) ||
+ (relativePath.Length >= 2 && relativePath[1] == ':' && char.IsLetter(relativePath[0])) ||
+ relativePath.StartsWith("\\\\", StringComparison.Ordinal) ||
+ relativePath.StartsWith("//", StringComparison.Ordinal))
+ {
+ return OperationResult.CreateFailure($"Relative path cannot be rooted or absolute: {relativePath}");
+ }
+
+ // Normalize directory separators
+ var normalizedRelative = relativePath.Replace('/', Path.DirectorySeparatorChar)
+ .Replace('\\', Path.DirectorySeparatorChar)
+ .TrimStart(Path.DirectorySeparatorChar);
+
+ if (string.IsNullOrWhiteSpace(normalizedRelative))
+ {
+ return OperationResult.CreateFailure("Normalized relative path cannot be empty.");
+ }
+
+ var normalizedRoot = rootDirectory.Replace('\\', Path.DirectorySeparatorChar)
+ .Replace('/', Path.DirectorySeparatorChar);
+ var fullRoot = Path.GetFullPath(normalizedRoot);
+ var fullCandidate = Path.GetFullPath(Path.Combine(fullRoot, normalizedRelative));
+
+ if (!IsContainedInternal(fullRoot, fullCandidate))
+ {
+ return OperationResult.CreateFailure(
+ $"Path '{relativePath}' escapes target root directory '{rootDirectory}'.");
+ }
+
+ return OperationResult.CreateSuccess(fullCandidate);
+ }
+
+ ///
+ /// Validates whether a candidate path is strictly contained within a designated root directory.
+ ///
+ /// The root directory.
+ /// The candidate path to check.
+ /// if the candidate path is contained within the root; otherwise .
+ public static bool IsContained(string? rootDirectory, string? candidatePath)
+ {
+ if (string.IsNullOrWhiteSpace(rootDirectory) || string.IsNullOrWhiteSpace(candidatePath))
+ {
+ return false;
+ }
+
+ try
+ {
+ var normalizedRoot = rootDirectory.Replace('\\', Path.DirectorySeparatorChar)
+ .Replace('/', Path.DirectorySeparatorChar);
+ var normalizedCandidate = candidatePath.Replace('\\', Path.DirectorySeparatorChar)
+ .Replace('/', Path.DirectorySeparatorChar);
+
+ var fullRoot = Path.GetFullPath(normalizedRoot);
+ var fullCandidate = Path.GetFullPath(normalizedCandidate);
+
+ return IsContainedInternal(fullRoot, fullCandidate);
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ catch (NotSupportedException)
+ {
+ return false;
+ }
+ catch (PathTooLongException)
+ {
+ return false;
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+
+ private static bool IsContainedInternal(string fullRoot, string fullCandidate)
+ {
+ var rootPrefix = fullRoot.EndsWith(Path.DirectorySeparatorChar)
+ ? fullRoot
+ : fullRoot + Path.DirectorySeparatorChar;
+
+ if (!fullCandidate.StartsWith(rootPrefix, PathHelper.PathComparison) &&
+ !fullCandidate.Equals(fullRoot, PathHelper.PathComparison))
+ {
+ return false;
+ }
+
+ var realRoot = ResolveRealPath(fullRoot);
+ var realCandidate = ResolveRealPath(fullCandidate);
+
+ var realRootPrefix = realRoot.EndsWith(Path.DirectorySeparatorChar)
+ ? realRoot
+ : realRoot + Path.DirectorySeparatorChar;
+
+ return realCandidate.StartsWith(realRootPrefix, PathHelper.PathComparison) ||
+ realCandidate.Equals(realRoot, PathHelper.PathComparison);
+ }
+
+ private static string ResolveRealPath(string path)
+ {
+ try
+ {
+ var current = path;
+ while (!string.IsNullOrEmpty(current))
+ {
+ var resolved = TryResolveFileSystemLink(current, path);
+ if (resolved != null)
+ {
+ return resolved;
+ }
+
+ if (File.Exists(current))
+ {
+ break;
+ }
+
+ current = Path.GetDirectoryName(current);
+ }
+ }
+ catch
+ {
+ // Fallback to path if resolution fails
+ }
+
+ return path;
+ }
+
+ private static string? TryResolveFileSystemLink(string current, string originalPath)
+ {
+ FileSystemInfo? info = null;
+ if (File.Exists(current))
+ {
+ info = new FileInfo(current);
+ }
+ else if (Directory.Exists(current))
+ {
+ info = new DirectoryInfo(current);
+ }
+
+ if (info?.LinkTarget == null)
+ {
+ return null;
+ }
+
+ var target = info.ResolveLinkTarget(returnFinalTarget: true);
+ if (target == null)
+ {
+ return null;
+ }
+
+ var relativeSuffix = Path.GetRelativePath(current, originalPath);
+ return relativeSuffix == "."
+ ? target.FullName
+ : Path.GetFullPath(Path.Combine(target.FullName, relativeSuffix));
+ }
+}
diff --git a/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs
new file mode 100644
index 000000000..b19f11d94
--- /dev/null
+++ b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Net;
+using System.Text.RegularExpressions;
+
+namespace GenHub.Core.Helpers;
+
+///
+/// Provides high-performance utilities for stripping HTML tags, decoding HTML entities,
+/// and normalizing text descriptions for display across the application.
+///
+public static partial class HtmlTextHelper
+{
+ ///
+ /// Converts an HTML snippet or formatted description into clean, normalized plain text:
+ /// - Replaces <br> and block element closures (</p>, </div>, etc.) with line breaks.
+ /// - Strips all remaining HTML tags.
+ /// - Decodes HTML entities (e.g., &, ", >, ).
+ /// - Normalizes whitespace and excessive blank lines.
+ /// - Uses the platform newline format.
+ ///
+ /// The raw HTML or formatted text string to normalize.
+ /// Normalized plain text, or empty string if input is null or whitespace.
+ public static string NormalizeHtml(string? html)
+ {
+ if (string.IsNullOrWhiteSpace(html))
+ {
+ return string.Empty;
+ }
+
+ // 0. Remove script and style elements along with their contents
+ var text = ScriptTagRegex().Replace(html, string.Empty);
+ text = StyleTagRegex().Replace(text, string.Empty);
+
+ // 1. Convert
tags to newline
+ text = BrTagRegex().Replace(text, "\n");
+
+ // 2. Convert paragraph closing tags to double newline for paragraph separation
+ text = ParagraphCloseTagRegex().Replace(text, "\n\n");
+
+ // 3. Convert other block-level closing tags and
tags to newline
+ text = BlockCloseTagRegex().Replace(text, "\n");
+
+ // 4. Strip all remaining HTML/XML tags
+ text = HtmlTagRegex().Replace(text, string.Empty);
+
+ // 5. Decode HTML entities ( , >, ", ', numeric entities, etc.)
+ text = WebUtility.HtmlDecode(text);
+
+ // 6. Normalize non-breaking spaces and line endings
+ text = text.Replace('\u00A0', ' ')
+ .Replace("\r\n", "\n")
+ .Replace('\r', '\n');
+
+ // 7. Clean trailing whitespace on lines and collapse excess blank lines
+ text = TrailingWhitespaceBeforeNewlineRegex().Replace(text, "\n");
+ text = ExcessBlankLinesRegex().Replace(text, "\n\n");
+
+ // 8. Trim and unify with environment newline
+ text = text.Trim();
+ text = text.Replace("\n", Environment.NewLine);
+
+ return text;
+ }
+
+ ///
+ /// Converts an HTML snippet or multi-line text into a single-line summary without HTML tags,
+ /// collapsing all whitespace runs into a single space, and optionally truncating with an ellipsis.
+ ///
+ /// The input HTML or text string.
+ /// Optional maximum character length including ellipsis.
+ /// A single-line plain text summary.
+ public static string CleanToSingleLine(string? htmlOrText, int? maxLength = null)
+ {
+ if (string.IsNullOrWhiteSpace(htmlOrText))
+ {
+ return string.Empty;
+ }
+
+ // Strip HTML if tags exist, decode entities, and normalize
+ var text = NormalizeHtml(htmlOrText);
+
+ // Collapse all newlines, tabs, and multiple spaces into a single space
+ text = MultiWhitespaceRegex().Replace(text, " ").Trim();
+
+ if (maxLength.HasValue && maxLength.Value > 0 && text.Length > maxLength.Value)
+ {
+ return TruncateWithEllipsis(text, maxLength.Value);
+ }
+
+ return text;
+ }
+
+ ///
+ /// Truncates a string to a specified maximum length and appends an ellipsis ("...") if truncated.
+ ///
+ /// The text to truncate.
+ /// The maximum allowed length (including the ellipsis).
+ /// The truncated text with an ellipsis if it exceeded maxLength, or the original text.
+ public static string TruncateWithEllipsis(string? text, int maxLength)
+ {
+ if (string.IsNullOrWhiteSpace(text) || maxLength <= 0)
+ {
+ return string.Empty;
+ }
+
+ if (text.Length <= maxLength)
+ {
+ return text;
+ }
+
+ if (maxLength <= 3)
+ {
+ return text[..maxLength];
+ }
+
+ return string.Concat(text.AsSpan(0, maxLength - 3), "...");
+ }
+
+ [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex ScriptTagRegex();
+
+ [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex StyleTagRegex();
+
+ [GeneratedRegex(@"
", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex BrTagRegex();
+
+ [GeneratedRegex(@"
", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex ParagraphCloseTagRegex();
+
+ [GeneratedRegex(@"?(?:div|li|h[1-6]|tr|section|article|blockquote|header|footer|hr)\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex BlockCloseTagRegex();
+
+ [GeneratedRegex(@"?[A-Za-z][^>]*>", RegexOptions.CultureInvariant)]
+ private static partial Regex HtmlTagRegex();
+
+ [GeneratedRegex(@"[ \t]+\n", RegexOptions.CultureInvariant)]
+ private static partial Regex TrailingWhitespaceBeforeNewlineRegex();
+
+ [GeneratedRegex(@"(?:\n){3,}", RegexOptions.CultureInvariant)]
+ private static partial Regex ExcessBlankLinesRegex();
+
+ [GeneratedRegex(@"\s+", RegexOptions.CultureInvariant)]
+ private static partial Regex MultiWhitespaceRegex();
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs b/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs
new file mode 100644
index 000000000..ffd5fa79b
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Enums;
+
+namespace GenHub.Core.Interfaces.Content;
+
+///
+/// Service for safely extracting archives and normalizing payload directory structures for game workspaces.
+///
+public interface IArchivePayloadProcessor
+{
+ ///
+ /// Extracts all archives located within the directory safely, recursively removing archive files after extraction.
+ ///
+ /// The directory containing extracted or downloaded content.
+ /// Optional content type to constrain executable archive extraction.
+ /// Cancellation token.
+ /// A task representing the asynchronous extraction operation.
+ Task ExtractArchivesSafelyAsync(
+ string extractedDirectory,
+ ContentType? contentType = null,
+ CancellationToken cancellationToken = default) =>
+ ExtractArchivesSafelyAsync(extractedDirectory, contentType, progress: null, cancellationToken);
+
+ ///
+ /// Extracts all archives located within the directory safely with progress reporting, recursively removing archive files after extraction.
+ ///
+ /// The directory containing extracted or downloaded content.
+ /// Optional content type to constrain executable archive extraction.
+ /// Optional progress reporter for extraction progress updates.
+ /// Cancellation token.
+ /// A task representing the asynchronous extraction operation.
+ Task ExtractArchivesSafelyAsync(
+ string extractedDirectory,
+ ContentType? contentType,
+ IProgress? progress,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Normalizes the directory structure of an extracted payload, removing extraneous wrapper directories
+ /// and reconciling the content root with the workspace/target directory.
+ ///
+ /// The directory containing extracted files.
+ /// The content type (e.g. Mod, Map, GameClient, etc.).
+ /// The target game type (Generals or ZeroHour).
+ /// Cancellation token.
+ /// A task representing the asynchronous normalization operation.
+ Task NormalizeDirectoryStructureAsync(
+ string extractedDirectory,
+ ContentType contentType,
+ GameType targetGame,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Normalizes the directory structure of an extracted payload, removing extraneous wrapper directories
+ /// and reconciling the content root with the workspace/target directory with inactive archive normalization control.
+ ///
+ /// The directory containing extracted files.
+ /// The content type (e.g. Mod, Map, GameClient, etc.).
+ /// The target game type (Generals or ZeroHour).
+ /// Whether to convert inactive mod archives (.gib, .ctr, .skw) to .big.
+ /// Cancellation token.
+ /// A task representing the asynchronous normalization operation.
+ Task NormalizeDirectoryStructureAsync(
+ string extractedDirectory,
+ ContentType contentType,
+ GameType targetGame,
+ bool normalizeInactiveArchives,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Extracts archives safely and normalizes the payload directory structure in one coordinated operation.
+ ///
+ /// The directory containing extracted or downloaded content.
+ /// The content type (e.g. Mod, Map, GameClient, etc.).
+ /// The target game type (Generals or ZeroHour).
+ /// Cancellation token.
+ /// A task representing the asynchronous processing operation.
+ Task ProcessPayloadAsync(
+ string extractedDirectory,
+ ContentType contentType,
+ GameType targetGame,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Extracts archives safely and normalizes the payload directory structure in one coordinated operation with inactive archive normalization control.
+ ///
+ /// The directory containing extracted or downloaded content.
+ /// The content type (e.g. Mod, Map, GameClient, etc.).
+ /// The target game type (Generals or ZeroHour).
+ /// Whether to convert inactive mod archives (.gib, .ctr, .skw) to .big.
+ /// Cancellation token.
+ /// A task representing the asynchronous processing operation.
+ Task ProcessPayloadAsync(
+ string extractedDirectory,
+ ContentType contentType,
+ GameType targetGame,
+ bool normalizeInactiveArchives,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Extracts archives safely and normalizes the payload directory structure in one coordinated operation with progress reporting.
+ ///
+ /// The directory containing extracted or downloaded content.
+ /// The content type (e.g. Mod, Map, GameClient, etc.).
+ /// The target game type (Generals or ZeroHour).
+ /// Whether to convert inactive mod archives (.gib, .ctr, .skw) to .big.
+ /// Optional progress reporter for extraction progress updates.
+ /// Cancellation token.
+ /// A task representing the asynchronous processing operation.
+ Task ProcessPayloadAsync(
+ string extractedDirectory,
+ ContentType contentType,
+ GameType targetGame,
+ bool normalizeInactiveArchives,
+ IProgress? progress,
+ CancellationToken cancellationToken = default);
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Content/IContentDownloadCoordinator.cs b/GenHub/GenHub.Core/Interfaces/Content/IContentDownloadCoordinator.cs
new file mode 100644
index 000000000..2c3631a63
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Content/IContentDownloadCoordinator.cs
@@ -0,0 +1,24 @@
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Models.Results;
+using GenHub.Core.Models.Results.Content;
+
+namespace GenHub.Core.Interfaces.Content;
+
+///
+/// Orchestrates the high-level download flow, including acquisition, state updates, and notifications.
+///
+public interface IContentDownloadCoordinator
+{
+ ///
+ /// Downloads content, updates state, and shows notifications.
+ ///
+ /// The content search result to download.
+ /// Progress reporter.
+ /// Cancellation token.
+ /// The acquired manifest if successful.
+ Task> DownloadContentAsync(
+ ContentSearchResult searchResult,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default);
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Content/IContentStateService.cs b/GenHub/GenHub.Core/Interfaces/Content/IContentStateService.cs
new file mode 100644
index 000000000..b9707465e
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Content/IContentStateService.cs
@@ -0,0 +1,68 @@
+namespace GenHub.Core.Interfaces.Content;
+
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Results.Content;
+
+///
+/// Service to determine the current state of content for UI display.
+///
+public interface IContentStateService
+{
+ ///
+ /// Event raised when content state changes (downloaded, updated, or removed).
+ ///
+ event EventHandler? ContentStateChanged;
+
+ ///
+ /// Notifies subscribers that content state has changed.
+ ///
+ /// The ID of the content that changed.
+ /// The new state of the content.
+ /// The manifest ID if available.
+ void NotifyStateChanged(string contentId, ContentState newState, string? manifestId = null);
+
+ ///
+ /// Gets the state for a content search result.
+ ///
+ /// The content search result from discovery.
+ /// Cancellation token.
+ /// The current state of the content.
+ Task GetStateAsync(ContentSearchResult item, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the state by generating a prospective manifest ID from components.
+ ///
+ /// Publisher identifier.
+ /// Content type.
+ /// Content name.
+ /// Release date (used as version).
+ /// Cancellation token.
+ /// The current state of the content.
+ Task GetStateAsync(
+ string publisher,
+ ContentType contentType,
+ string contentName,
+ DateTime releaseDate,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the local manifest ID if content is downloaded.
+ ///
+ /// The content search result.
+ /// Cancellation token.
+ /// The local manifest ID if downloaded, null otherwise.
+ Task GetLocalManifestIdAsync(ContentSearchResult item, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the state for a specific manifest ID, without requiring a full
+ /// . Useful for per-variant state lookups where
+ /// only the manifest identity is known.
+ ///
+ /// The manifest ID to check.
+ /// Cancellation token.
+ ///
+ /// if the manifest is in the pool;
+ /// otherwise.
+ ///
+ Task GetStateByManifestIdAsync(string manifestId, CancellationToken cancellationToken = default);
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs b/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs
new file mode 100644
index 000000000..1b416b594
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs
@@ -0,0 +1,58 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Manifest;
+
+namespace GenHub.Core.Interfaces.Content;
+
+///
+/// Service for detecting, isolating, converting, and packaging Control Bar content into SAGE-compatible .big archives.
+///
+public interface IControlBarPackageProcessor
+{
+ ///
+ /// Checks whether the extracted directory or manifest represents a Control Bar mod or UI addon that needs repacking.
+ ///
+ /// The directory containing extracted files.
+ /// The content manifest.
+ /// True if the content is a Control Bar that requires processing.
+ bool IsControlBarContent(string extractedDirectory, ContentManifest manifest);
+
+ ///
+ /// Processes extracted Control Bar content: isolates the requested resolution variant, converts AVIF/WebP textures to TGA,
+ /// repacks Art/Data folders into .big archives, ensures metadata BIG is present, and cleans up raw sources.
+ ///
+ /// The directory containing extracted files.
+ /// The content manifest.
+ /// Optional explicit variant identifier (e.g. "1080p").
+ /// Cancellation token.
+ /// A list of generated or included .big file names.
+ Task> ProcessAndRepackControlBarAsync(
+ string extractedDirectory,
+ ContentManifest manifest,
+ string? requestedVariant = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Finds the variant BIG root directory within extracted content.
+ ///
+ /// The extracted root directory.
+ /// The variant identifier (e.g. "1080p").
+ /// The path to the variant root directory, or null if not found.
+ string? FindControlBarVariantBigRoot(string extractedDirectory, string variantId);
+
+ ///
+ /// Gets the normalized suffix for a variant identifier (e.g. "1080p" -> "1080").
+ ///
+ /// The variant identifier.
+ /// The normalized variant suffix.
+ string GetControlBarVariantSuffix(string variantId);
+
+ ///
+ /// Checks if a file is an allowed Control Bar .big archive for the given variant suffix.
+ ///
+ /// The file name.
+ /// The variant suffix.
+ /// True if the file is allowed.
+ bool IsAllowedControlBarBig(string fileName, string variantSuffix);
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Content/IPublisherManifestFactory.cs b/GenHub/GenHub.Core/Interfaces/Content/IPublisherManifestFactory.cs
index 1ca68db84..176c9b5e1 100644
--- a/GenHub/GenHub.Core/Interfaces/Content/IPublisherManifestFactory.cs
+++ b/GenHub/GenHub.Core/Interfaces/Content/IPublisherManifestFactory.cs
@@ -1,3 +1,8 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Content;
using GenHub.Core.Models.Manifest;
namespace GenHub.Core.Interfaces.Content;
@@ -52,6 +57,28 @@ public interface IPublisherManifestFactory
Task> CreateManifestsFromExtractedContentAsync(
ContentManifest originalManifest,
string extractedDirectory,
+ CancellationToken cancellationToken = default) =>
+ CreateManifestsFromExtractedContentAsync(originalManifest, extractedDirectory, progress: null, cancellationToken);
+
+ ///
+ /// Creates enriched manifests from extracted content with progress reporting.
+ ///
+ ///
+ /// The manifest from the resolver, containing download URLs but no file hashes.
+ ///
+ ///
+ /// Directory where the deliverer extracted the package files.
+ ///
+ /// Optional progress reporter for processing updates.
+ /// Cancellation token.
+ ///
+ /// One or more manifests with file hashes and sizes. Multi-variant content
+ /// (e.g., separate Generals and Zero Hour executables) may return multiple manifests.
+ ///
+ Task> CreateManifestsFromExtractedContentAsync(
+ ContentManifest originalManifest,
+ string extractedDirectory,
+ IProgress? progress,
CancellationToken cancellationToken = default);
///
diff --git a/GenHub/GenHub.Core/Interfaces/Content/ITabProvider.cs b/GenHub/GenHub.Core/Interfaces/Content/ITabProvider.cs
new file mode 100644
index 000000000..29bc73e63
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Content/ITabProvider.cs
@@ -0,0 +1,34 @@
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Results.Content;
+
+namespace GenHub.Core.Interfaces.Content;
+
+///
+/// Defines dynamic custom tab extensions for the downloads browser content detail view.
+/// When users inspect a game mod, map, or patch in the downloads browser detail page, publishers can add extra custom tabs (such as documentation, changelogs, sub-addons, or external links) defined in their catalog json.
+///
+public interface ITabProvider
+{
+ ///
+ /// Gets the unique identifier for this tab provider instance (e.g. catalog-tabs).
+ /// Provider ids uniquely identify tab sources in the tab provider registry.
+ ///
+ string ProviderId { get; }
+
+ ///
+ /// Evaluates whether this tab provider can supply custom tabs for a specific content item selected in the downloads browser.
+ ///
+ /// The content item search result being viewed in the downloads section.
+ /// True if this provider can build custom tabs for the specified downloads content item; otherwise false.
+ bool CanProvideTabsFor(ContentSearchResult searchResult);
+
+ ///
+ /// Retrieves custom tab definitions to render as navigation tabs in the downloads browser detail view.
+ ///
+ /// The downloads content item to retrieve custom tabs for.
+ /// Cancellation token for asynchronous catalog fetching operations.
+ /// Read-only list of custom tab definitions to populate in the downloads detail view model.
+ Task> GetTabsAsync(
+ ContentSearchResult searchResult,
+ CancellationToken cancellationToken = default);
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Content/ITabProviderRegistry.cs b/GenHub/GenHub.Core/Interfaces/Content/ITabProviderRegistry.cs
new file mode 100644
index 000000000..4b9ef5b14
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Content/ITabProviderRegistry.cs
@@ -0,0 +1,39 @@
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Results.Content;
+
+namespace GenHub.Core.Interfaces.Content;
+
+///
+/// Registry for managing custom tab providers.
+///
+public interface ITabProviderRegistry
+{
+ ///
+ /// Registers a tab provider.
+ ///
+ /// The provider to register.
+ void RegisterProvider(ITabProvider provider);
+
+ ///
+ /// Unregisters a tab provider.
+ ///
+ /// The id of the provider to unregister.
+ /// True if the provider was found and removed.
+ bool UnregisterProvider(string providerId);
+
+ ///
+ /// Gets all registered tab providers.
+ ///
+ /// Read-only list of all registered providers.
+ IReadOnlyList GetAllProviders();
+
+ ///
+ /// Gets all custom tabs for the given content from all registered providers.
+ ///
+ /// The content to get tabs for.
+ /// Cancellation token.
+ /// Read-only list of custom tab definitions, sorted by order.
+ Task> GetTabsForContentAsync(
+ ContentSearchResult searchResult,
+ CancellationToken cancellationToken = default);
+}
diff --git a/GenHub/GenHub.Core/Interfaces/GameProfiles/IProfileContentService.cs b/GenHub/GenHub.Core/Interfaces/GameProfiles/IProfileContentService.cs
index 9468c92c2..cd051142d 100644
--- a/GenHub/GenHub.Core/Interfaces/GameProfiles/IProfileContentService.cs
+++ b/GenHub/GenHub.Core/Interfaces/GameProfiles/IProfileContentService.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using GenHub.Core.Models.GameProfile;
using GenHub.Core.Models.Results;
@@ -21,6 +22,18 @@ Task AddContentToProfileAsync(
string manifestId,
CancellationToken cancellationToken = default);
+ ///
+ /// Adds multiple acquired content items to a profile, resolving the combined dependency graph.
+ ///
+ /// The profile ID to add content to.
+ /// The manifest IDs of the content to add.
+ /// A cancellation token.
+ /// An operation result indicating success with details about any swapped content.
+ Task AddContentToProfileAsync(
+ string profileId,
+ IReadOnlyList manifestIds,
+ CancellationToken cancellationToken = default);
+
///
/// Checks for content conflicts without making changes.
///
@@ -45,6 +58,18 @@ Task> CreateProfileWithContentAsync(
string manifestId,
CancellationToken cancellationToken = default);
+ ///
+ /// Creates a new profile with multiple acquired content items pre-enabled.
+ ///
+ /// Name for the new profile.
+ /// The manifest IDs of the content to enable.
+ /// A cancellation token.
+ /// An operation result containing the created profile.
+ Task> CreateProfileWithContentAsync(
+ string profileName,
+ IReadOnlyList manifestIds,
+ CancellationToken cancellationToken = default);
+
///
/// Validates a profile's enabled content for conflicts.
///
diff --git a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs
index 58985d1e0..9362f5a25 100644
--- a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs
+++ b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs
@@ -116,6 +116,7 @@ public interface IContentManifestBuilder
/// List of compatible versions.
/// Whether the dependency is exclusive.
/// List of conflicting dependency IDs.
+ /// List of compatible game types.
/// The builder instance for chaining.
IContentManifestBuilder AddDependency(
ManifestId id,
@@ -126,7 +127,8 @@ IContentManifestBuilder AddDependency(
string maxVersion = "",
List? compatibleVersions = null,
bool isExclusive = false,
- List? conflictsWith = null);
+ List? conflictsWith = null,
+ List? compatibleGameTypes = null);
///
/// Scans a directory and adds files with the specified source type.
@@ -282,6 +284,27 @@ IContentManifestBuilder AddContentReference(
/// The builder instance for chaining.
IContentManifestBuilder AddPatchFile(string targetRelativePath, string patchSourceFile);
+ ///
+ /// Sets the relative path of the main launch executable for this manifest.
+ ///
+ /// The relative path of the entry point file.
+ /// The builder instance for chaining.
+ IContentManifestBuilder WithEntryPoint(string entryPoint);
+
+ ///
+ /// Explicitly sets the manifest ID, bypassing automatic generation.
+ ///
+ /// The manifest identifier.
+ /// The builder instance for chaining.
+ IContentManifestBuilder WithId(ManifestId id);
+
+ ///
+ /// Sets the human-readable display name for this manifest.
+ ///
+ /// The display name.
+ /// The builder instance for chaining.
+ IContentManifestBuilder WithName(string name);
+
///
/// Builds the final ContentManifest.
///
diff --git a/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs b/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs
index 136018297..2d75645fe 100644
--- a/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs
+++ b/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs
@@ -1,3 +1,8 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using GenHub.Core.Models.Parsers;
namespace GenHub.Core.Interfaces.Parsers;
@@ -36,4 +41,56 @@ public interface IWebPageParser
/// Cancellation token.
/// A parsed web page with all extracted content sections.
Task ParseAsync(string url, string html, CancellationToken cancellationToken = default);
+
+ ///
+ /// Parses a specific file or item detail page.
+ /// Default implementation delegates to .
+ ///
+ /// The detail page URL.
+ /// Cancellation token.
+ /// A parsed web page containing the detailed file information.
+ Task ParseFileDetailAsync(string url, CancellationToken cancellationToken = default)
+ => ParseAsync(url, cancellationToken);
+
+ ///
+ /// Parses multiple file or item detail pages in a batch.
+ /// Default implementation delegates to .
+ ///
+ /// The detail page URLs to parse.
+ /// Cancellation token.
+ /// A dictionary mapping each URL to its parsed web page result.
+ async Task> ParseFileDetailsManyAsync(
+ IReadOnlyList urls,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(urls);
+ var results = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var url in urls.Distinct(StringComparer.OrdinalIgnoreCase))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ var page = await ParseFileDetailAsync(url, cancellationToken);
+ results[url] = page;
+ }
+ catch (HttpRequestException)
+ {
+ // soft failure per url in batch
+ }
+ catch (IOException)
+ {
+ // soft failure per url in batch
+ }
+ catch (InvalidOperationException)
+ {
+ // soft failure per url in batch
+ }
+ catch (FormatException)
+ {
+ // soft failure per url in batch
+ }
+ }
+
+ return results;
+ }
}
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..9d04d62e3
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Providers/IPublisherSubscriptionStore.cs
@@ -0,0 +1,69 @@
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Providers;
+using GenHub.Core.Models.Results;
+
+namespace GenHub.Core.Interfaces.Providers;
+
+///
+/// Persists user subscriptions to creator catalogs (and later provider definitions).
+/// Subscriptions enable modular discovery: any GenHub-schema catalog can appear in Downloads
+/// via the generic catalog pipeline without shipping a custom discoverer.
+///
+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/Interfaces/Tools/IPlaywrightService.cs b/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs
index f2007576a..df73f9827 100644
--- a/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs
@@ -6,6 +6,7 @@
using GenHub.Core.Models.Results;
using Microsoft.Playwright;
using System;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -25,6 +26,31 @@ public interface IPlaywrightService
/// A new IPage instance.
Task CreatePageAsync(BrowserNewContextOptions? options = null, CancellationToken cancellationToken = default);
+ ///
+ /// Creates a page in a persistent, headed browser context whose cookies and storage survive
+ /// across calls. Use this for bot-protected sites (e.g. ModDB's Cloudflare): the user solves
+ /// the challenge once, the resulting clearance cookie is persisted to disk, and subsequent
+ /// pages in the same session (and across app restarts, until the cookie expires) load without
+ /// another challenge. A real browser window is shown while the challenge is pending.
+ ///
+ /// The on-disk profile name (scoped under the app data browser-profile root).
+ /// Cancellation token.
+ /// A new in the persistent context.
+ Task CreatePersistentPageAsync(string profileName, CancellationToken cancellationToken = default);
+
+ ///
+ /// Closes a page from and shuts down the headed Chromium
+ /// window when no active pages remain. Prefer this over page.CloseAsync alone so
+ /// callers do not leave an about:blank window open after a successful ModDB scrape.
+ ///
+ /// The persistent-context page to close.
+ ///
+ /// When , leaves the page open (e.g. so the user can finish a Cloudflare
+ /// challenge) without closing the browser.
+ ///
+ /// A task representing the asynchronous operation.
+ Task ClosePersistentPageAsync(IPage page, bool keepOpen = false);
+
///
/// Fetches HTML content from a URL using Playwright.
///
@@ -41,6 +67,35 @@ public interface IPlaywrightService
/// A parsed AngleSharp IDocument.
Task FetchAndParseAsync(string url, CancellationToken cancellationToken = default);
+ ///
+ /// Fetches and parses a web page in a persistent, headed browser context whose cookies survive
+ /// across calls. Use this for bot-protected URLs (e.g. ModDB) so the Cloudflare clearance cookie
+ /// obtained from a single manual challenge solve is reused.
+ ///
+ /// The on-disk profile name (scoped under the app data browser-profile root).
+ /// The URL to fetch and parse.
+ /// Cancellation token.
+ /// A parsed AngleSharp IDocument.
+ Task FetchAndParsePersistentAsync(string profileName, string url, CancellationToken cancellationToken = default);
+
+ ///
+ /// Fetches and parses multiple URLs in one persistent headed page — open once, navigate each
+ /// URL in order, then close. Use this for ModDB section sweeps so Chromium does not spawn a
+ /// new window per section (and so concurrent NewPage/Close races cannot tear down the context
+ /// mid-navigation).
+ ///
+ /// The on-disk profile name (scoped under the app data browser-profile root).
+ /// URLs to fetch in order. Duplicates are fetched once; order of first occurrence is kept.
+ /// Cancellation token.
+ ///
+ /// A map of URL → parsed document for every URL that loaded successfully. Failed URLs are omitted;
+ /// callers should treat a missing key as a soft failure for that section.
+ ///
+ Task> FetchAndParsePersistentManyAsync(
+ string profileName,
+ IReadOnlyList urls,
+ CancellationToken cancellationToken = default);
+
///
/// Downloads a file using Playwright to handle complex scenarios (like anti-bot protections).
///
@@ -48,4 +103,27 @@ public interface IPlaywrightService
/// Cancellation token.
/// A DownloadResult indicating success or failure.
Task DownloadFileAsync(DownloadConfiguration configuration, CancellationToken cancellationToken = default);
+
+ ///
+ /// Executes an operation within a scoped persistent browser context session.
+ /// The persistent browser window stays open for the duration of the operation and closes
+ /// immediately when the operation completes, avoiding multiple window launches and idle delays.
+ ///
+ /// The return type of the operation.
+ /// The on-disk profile name.
+ /// The asynchronous operation to execute.
+ /// Cancellation token.
+ /// The result of the operation.
+ Task ExecuteInPersistentContextAsync(
+ string profileName,
+ Func> operation,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Asynchronously pre-warms the Playwright driver runtime in the background so subsequent
+ /// browser operations launch with minimal latency.
+ ///
+ /// Cancellation token.
+ /// A task representing the background warmup operation.
+ Task WarmupAsync(CancellationToken cancellationToken = default);
}
diff --git a/GenHub/GenHub.Core/Interfaces/UserData/IUserDataTracker.cs b/GenHub/GenHub.Core/Interfaces/UserData/IUserDataTracker.cs
index 61aff6eaa..bc71e9b92 100644
--- a/GenHub/GenHub.Core/Interfaces/UserData/IUserDataTracker.cs
+++ b/GenHub/GenHub.Core/Interfaces/UserData/IUserDataTracker.cs
@@ -139,6 +139,14 @@ Task> CleanupProfileAsync(
Task> GetTotalUserDataSizeAsync(
CancellationToken cancellationToken = default);
+ ///
+ /// Gets the profile ID that currently has active user data materialized on disk, if any.
+ ///
+ /// Cancellation token.
+ /// The active profile ID or null if no profile user data is active.
+ Task> GetActiveProfileIdAsync(
+ CancellationToken cancellationToken = default);
+
///
/// Deletes ALL tracked user data files, manifests, and indexes.
/// This is a destructive operation used for "Delete All Data" functionality.
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/ContentLibraryClearedMessage.cs b/GenHub/GenHub.Core/Messages/ContentLibraryClearedMessage.cs
new file mode 100644
index 000000000..0ea08081c
--- /dev/null
+++ b/GenHub/GenHub.Core/Messages/ContentLibraryClearedMessage.cs
@@ -0,0 +1,8 @@
+namespace GenHub.Core.Messages;
+
+///
+/// Announces that every acquired content manifest was removed from the local library.
+///
+public sealed class ContentLibraryClearedMessage
+{
+}
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/CommunityOutpost/GenPatcherContentRegistry.cs b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs
index ad0432121..d3812eb02 100644
--- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs
+++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs
@@ -11,6 +11,7 @@ namespace GenHub.Core.Models.CommunityOutpost;
///
public static class GenPatcherContentRegistry
{
+ private const string CommunityPatchCode = "community-patch";
private const string ResolutionVariantType = "resolution";
private const string LanguageVariantType = "language";
private const string Pattern720 = "*720*";
@@ -65,9 +66,9 @@ public static class GenPatcherContentRegistry
private static readonly Dictionary KnownContent = new(StringComparer.OrdinalIgnoreCase)
{
// Community Patch (TheSuperHackers Build from legi.cc/patch)
- ["community-patch"] = new GenPatcherContentMetadata
+ [CommunityPatchCode] = new GenPatcherContentMetadata
{
- ContentCode = "community-patch",
+ ContentCode = CommunityPatchCode,
DisplayName = "Community Patch (TheSuperHackers Build)",
Description = "The latest TheSuperHackers patch build for Zero Hour. Includes bug fixes, balance changes, and quality of life improvements.",
ContentType = ContentType.GameClient,
@@ -410,10 +411,62 @@ public static class GenPatcherContentRegistry
},
};
+ ///
+ /// Content code alias mappings for catalog items and legacy names.
+ ///
+ private static readonly Dictionary ContentCodeAliases = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["legionnaire-hotkeys"] = "hleg",
+ ["legionnairehotkeys"] = "hleg",
+ ["legionnaireshotkeys"] = "hleg",
+ ["gentool-suite-86"] = "gent",
+ ["gentoolsuite86"] = "gent",
+ ["gentool-suite-89"] = "gent",
+ ["gentoolsuite89"] = "gent",
+ ["gentool-89-suite"] = "gent",
+ ["gentool89suite"] = "gent",
+ ["gentool-suite"] = "gent",
+ ["gentoolsuite"] = "gent",
+ ["gentool"] = "gent",
+ ["leikezes-hotkeys"] = "hlei",
+ ["leikezeshotkeys"] = "hlei",
+ ["easy-win-hotkeys-advanced"] = "ewba",
+ ["easywinhotkeysadvanced"] = "ewba",
+ ["easy-win-hotkeys-international"] = "ewbi",
+ ["easywinhotkeysinternational"] = "ewbi",
+ ["standard-hotkeys-german"] = "hlde",
+ ["standardhotkeysgerman"] = "hlde",
+ ["hotkeys-indicators"] = "hlen",
+ ["hotkeysindicators"] = "hlen",
+ ["hlenenglish"] = "hlen",
+ ["hlen-english"] = "hlen",
+ ["communityoutpost-controlbar-pro"] = "cbpr",
+ ["controlbarproexile"] = "cbpr",
+ ["controlbarproxezon"] = "cbpx",
+ ["communitypatch"] = CommunityPatchCode,
+ ["communitypatchthesuperhackersbuild"] = CommunityPatchCode,
+ ["communityoutpostgameclientcommunitypatch"] = CommunityPatchCode,
+ ["community-patch-gameclient"] = CommunityPatchCode,
+ ["zerohour104"] = "10zh",
+ ["zerohour-104"] = "10zh",
+ ["generals108"] = "10gn",
+ ["generals-108"] = "10gn",
+ ["mapsartofdefense"] = "maod",
+ ["custommissionspack"] = "mmis",
+ ["mapscriptingresources"] = "mscr",
+ ["skirmishmappack"] = "mskr",
+ ["iconspack"] = "icon",
+ ["directxtextures"] = "drtx",
+ ["uncutcontent"] = "unct",
+ ["vc2005redistributable"] = "vc05",
+ ["vc2008redistributable"] = "vc08",
+ ["vc2010redistributable"] = "vc10",
+ };
+
///
/// Gets metadata for a content code.
///
- /// The 4-character content code.
+ /// The 4-character content code or catalog content id.
/// Content metadata, or a dynamically generated one if the code is unknown.
public static GenPatcherContentMetadata GetMetadata(string contentCode)
{
@@ -423,21 +476,25 @@ public static GenPatcherContentMetadata GetMetadata(string contentCode)
}
var normalizedCode = contentCode.Trim();
+ if (ContentCodeAliases.TryGetValue(normalizedCode, out var aliasCode))
+ {
+ normalizedCode = aliasCode;
+ }
- // Check for known content first (case-insensitive due to dictionary comparer)
+ // check for known content first (case-insensitive due to dictionary comparer)
if (KnownContent.TryGetValue(normalizedCode, out var metadata))
{
return metadata;
}
- // Try to parse as a patch code (e.g., "108e", "104b")
+ // try to parse as a patch code (e.g., "108e", "104b")
var patchMetadata = TryParsePatchCode(normalizedCode);
if (patchMetadata != null)
{
return patchMetadata;
}
- // Return unknown metadata
+ // return unknown metadata
return CreateUnknownMetadata(contentCode);
}
diff --git a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs
index 0824f25b1..9d746aedc 100644
--- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs
+++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs
@@ -427,6 +427,20 @@ private static void AddHotkeyDependencies(
{
AddHotkeyIndicatorDependency(dependencies);
}
+
+ // Legionnaire's Hotkeys hooks through GenTool. This is an actual runtime
+ // requirement, so let the resolver acquire and reconcile it with the profile.
+ if (metadata.ContentCode.Equals("hleg", StringComparison.OrdinalIgnoreCase))
+ {
+ dependencies.Add(new ContentDependency
+ {
+ Id = ManifestId.Create($"1.{ManifestConstants.DefaultManifestVersion}.{CommunityOutpostConstants.PublisherType}.addon.gent"),
+ Name = "GenTool (required for Legionnaire's Hotkeys)",
+ DependencyType = ContentType.Addon,
+ InstallBehavior = DependencyInstallBehavior.AutoInstall,
+ IsOptional = false,
+ });
+ }
}
///
@@ -527,4 +541,4 @@ private static void AddGenericGameDependency(
dependencies.Add(CreateZeroHour104Dependency());
}
}
-}
\ No newline at end of file
+}
diff --git a/GenHub/GenHub.Core/Models/Content/ContentAcquisitionProgress.cs b/GenHub/GenHub.Core/Models/Content/ContentAcquisitionProgress.cs
index 8e82f1f1d..381e340e8 100644
--- a/GenHub/GenHub.Core/Models/Content/ContentAcquisitionProgress.cs
+++ b/GenHub/GenHub.Core/Models/Content/ContentAcquisitionProgress.cs
@@ -50,4 +50,59 @@ public class ContentAcquisitionProgress
/// Gets or sets the estimated time remaining for the current phase.
///
public TimeSpan EstimatedTimeRemaining { get; set; }
+
+ ///
+ /// Gets or sets the current stage number (1-based). Set to 0 to disable staged progress display.
+ ///
+ public int CurrentStage { get; set; } = 0;
+
+ ///
+ /// Gets or sets the total number of stages in the acquisition process. Set to 0 to disable staged progress display.
+ ///
+ public int TotalStages { get; set; } = 0;
+
+ ///
+ /// Gets or sets the progress within the current stage (0-100).
+ ///
+ public double StageProgress { get; set; }
+
+ ///
+ /// Gets or sets the description of the current stage.
+ ///
+ public string StageDescription { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the time elapsed since the last progress update.
+ /// Used to detect stalled operations and provide feedback.
+ ///
+ public TimeSpan TimeSinceLastUpdate { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the current operation is a bottleneck (e.g., hash calculation).
+ ///
+ public bool IsBottleneck { get; set; }
+
+ ///
+ /// Gets or sets a message explaining why the operation is slow (if IsBottleneck is true).
+ ///
+ public string? BottleneckReason { get; set; }
+
+ ///
+ /// Gets the formatted stage indicator (e.g., "2/5").
+ ///
+ public string StageIndicator => $"{CurrentStage}/{TotalStages}";
+
+ ///
+ /// Gets a formatted progress string combining stage and percentage.
+ ///
+ public string FormattedProgress
+ {
+ get
+ {
+ var stagePart = $"{CurrentStage}/{TotalStages}";
+ var percentPart = StageProgress > 0 ? $" ({StageProgress:F0}%)" : string.Empty;
+ var description = !string.IsNullOrEmpty(StageDescription) ? $" - {StageDescription}" : string.Empty;
+ return $"{stagePart}{description}{percentPart}";
+ }
+ }
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Models/Content/ContentCardBadgeHelper.cs b/GenHub/GenHub.Core/Models/Content/ContentCardBadgeHelper.cs
new file mode 100644
index 000000000..ee4cce61e
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Content/ContentCardBadgeHelper.cs
@@ -0,0 +1,405 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text.RegularExpressions;
+using GenHub.Core.Constants;
+using GenHub.Core.Extensions;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Results.Content;
+
+namespace GenHub.Core.Models.Content;
+
+///
+/// Shared helpers for promoting and reading download-card badge metadata across publishers.
+///
+public static partial class ContentCardBadgeHelper
+{
+ ///
+ /// Applies a player-count value to search-result metadata and tags.
+ ///
+ /// The search result to update.
+ /// The player count to store.
+ public static void ApplyPlayerCount(ContentSearchResult result, int playerCount)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ if (playerCount <= 0)
+ {
+ return;
+ }
+
+ var value = playerCount.ToString(CultureInfo.InvariantCulture);
+ result.ResolverMetadata[ContentConstants.PlayerCountMetadataKey] = value;
+ result.Metadata[ContentConstants.PlayerCountMetadataKey] = value;
+
+ var tag = playerCount == 1 ? "1 Player" : $"{playerCount} Players";
+ if (!result.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
+ {
+ result.Tags.Add(tag);
+ }
+ }
+
+ ///
+ /// Applies a category label to search-result metadata and tags.
+ ///
+ /// The search result to update.
+ /// The category display label.
+ public static void ApplyCategory(ContentSearchResult result, string? category)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ if (string.IsNullOrWhiteSpace(category))
+ {
+ return;
+ }
+
+ var trimmed = category.Trim();
+ result.ResolverMetadata[ContentConstants.CategoryMetadataKey] = trimmed;
+ result.Metadata[ContentConstants.CategoryMetadataKey] = trimmed;
+
+ if (!result.Tags.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
+ {
+ result.Tags.Add(trimmed);
+ }
+ }
+
+ ///
+ /// Promotes conventional tags (for example 4 Players or category:AOA) into badge metadata.
+ ///
+ /// The search result to update.
+ public static void PromoteFromTags(ContentSearchResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+
+ if (!HasMetadata(result, ContentConstants.PlayerCountMetadataKey))
+ {
+ PromotePlayerCountFromTags(result);
+ }
+
+ if (!HasMetadata(result, ContentConstants.CategoryMetadataKey))
+ {
+ PromoteCategoryFromTags(result);
+ }
+ }
+
+ ///
+ /// Resolves the player-count badge text for a download card, or an empty string when unavailable.
+ ///
+ /// The search result.
+ /// Badge text such as 4 players.
+ public static string GetPlayerCountBadge(ContentSearchResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+
+ if (result.ContentType is not (ContentType.Map or ContentType.MapPack or ContentType.UnknownContentType or ContentType.GameInstallation))
+ {
+ return string.Empty;
+ }
+
+ if (TryGetMetadata(result, ContentConstants.PlayerCountMetadataKey, out var raw) &&
+ int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count) &&
+ count > 0)
+ {
+ return count == 1 ? "1 player" : $"{count} players";
+ }
+
+ foreach (var tag in result.Tags)
+ {
+ if (string.IsNullOrWhiteSpace(tag))
+ {
+ continue;
+ }
+
+ var match = PlayerCountTagRegex().Match(tag);
+ if (match.Success && int.TryParse(match.Groups["players"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var taggedCount) && taggedCount > 0)
+ {
+ return taggedCount == 1 ? "1 player" : $"{taggedCount} players";
+ }
+ }
+
+ return string.Empty;
+ }
+
+ ///
+ /// Resolves the category badge text for a download card, or an empty string when unavailable.
+ ///
+ /// The search result.
+ /// Category badge text.
+ public static string GetCategoryBadge(ContentSearchResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+
+ if (TryGetMetadata(result, ContentConstants.CategoryMetadataKey, out var category) &&
+ !string.IsNullOrWhiteSpace(category))
+ {
+ return category.Trim();
+ }
+
+ foreach (var tag in result.Tags)
+ {
+ if (string.IsNullOrWhiteSpace(tag))
+ {
+ continue;
+ }
+
+ var match = CategoryTagRegex().Match(tag);
+ if (match.Success)
+ {
+ var val = match.Groups["category"].Value.Trim();
+ if (!string.IsNullOrWhiteSpace(val))
+ {
+ return val;
+ }
+ }
+ }
+
+ return string.Empty;
+ }
+
+ ///
+ /// Resolves the best card/detail thumbnail URL: banner, then first screenshot, then icon.
+ /// Returns null when no thumbnail is present so cards can fall back to publisher logo placeholders cleanly.
+ ///
+ /// The search result.
+ /// A thumbnail URL, or null when none is available.
+ public static string? GetThumbnailUrl(ContentSearchResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+
+ if (!string.IsNullOrWhiteSpace(result.BannerUrl))
+ {
+ return result.BannerUrl;
+ }
+
+ var screenshot = result.ScreenshotUrls.FirstOrDefault(static url => !string.IsNullOrWhiteSpace(url));
+ if (!string.IsNullOrWhiteSpace(screenshot))
+ {
+ return screenshot;
+ }
+
+ if (!string.IsNullOrWhiteSpace(result.IconUrl))
+ {
+ return result.IconUrl;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Resolves the canonical publisher logo URI for a content search result.
+ ///
+ /// The search result.
+ /// A logo URI string, or null when unmapped.
+ public static string? GetPublisherLogoUrl(ContentSearchResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ return PublisherInfoConstants.GetPublisherLogo(result.ProviderName, $"{result.AuthorName} {result.Id} {result.Name}");
+ }
+
+ ///
+ /// Checks whether a category badge text is equivalent to the given content type display or manifest string,
+ /// preventing duplicate badge display (for example showing both "Content Bundle" and "ContentBundle").
+ ///
+ /// The category string.
+ /// The content type.
+ /// True if the category is equivalent to the content type; otherwise false.
+ public static bool IsCategoryDuplicateOfContentType(string? category, ContentType contentType)
+ {
+ if (string.IsNullOrWhiteSpace(category))
+ {
+ return true;
+ }
+
+ static string Normalize(string input) =>
+ new(input.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
+
+ var normCategory = Normalize(category);
+ if (string.IsNullOrEmpty(normCategory))
+ {
+ return true;
+ }
+
+ var normDisplayName = Normalize(contentType.GetDisplayName());
+ var normManifestName = Normalize(contentType.ToManifestIdString());
+ var normEnumName = Normalize(contentType.ToString());
+
+ return normCategory == normDisplayName ||
+ normCategory == normManifestName ||
+ normCategory == normEnumName;
+ }
+
+ ///
+ /// Reads a precomputed includes/required-content summary from search-result metadata.
+ ///
+ /// The search result.
+ /// Comma-separated included content names, or empty when unavailable.
+ public static string GetIncludesSummary(ContentSearchResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+
+ if (TryGetMetadata(result, ContentConstants.IncludesSummaryMetadataKey, out var summary) &&
+ !string.IsNullOrWhiteSpace(summary))
+ {
+ return summary.Trim();
+ }
+
+ return string.Empty;
+ }
+
+ ///
+ /// Applies an includes/required-content summary for glanceable card and detail display.
+ ///
+ /// The search result to update.
+ /// Friendly names of included or required content.
+ public static void ApplyIncludesSummary(ContentSearchResult result, IEnumerable includedNames)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ ArgumentNullException.ThrowIfNull(includedNames);
+
+ var names = includedNames
+ .Where(static name => !string.IsNullOrWhiteSpace(name))
+ .Select(static name => name.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (names.Count == 0)
+ {
+ return;
+ }
+
+ var summary = string.Join(", ", names);
+ result.Metadata[ContentConstants.IncludesSummaryMetadataKey] = summary;
+ result.ResolverMetadata[ContentConstants.IncludesSummaryMetadataKey] = summary;
+ }
+
+ ///
+ /// Returns the result's tags that are not already surfaced by the dedicated
+ /// player-count or category badges, for display as additional tag chips on a card.
+ ///
+ /// The search result.
+ ///
+ /// A list of tag strings with promoted player-count ("N Players") and category
+ /// ("category:X") tags removed. Tags are compared case-insensitively against the
+ /// resolved badge values.
+ ///
+ public static List GetCardTags(ContentSearchResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+
+ var playerBadge = GetPlayerCountBadge(result);
+ var categoryBadge = GetCategoryBadge(result);
+
+ var excluded = new HashSet(StringComparer.OrdinalIgnoreCase);
+ if (!string.IsNullOrWhiteSpace(playerBadge))
+ {
+ excluded.Add(playerBadge);
+ }
+
+ if (!string.IsNullOrWhiteSpace(categoryBadge))
+ {
+ excluded.Add(categoryBadge.Trim());
+ }
+
+ // Also drop the raw source tags that the badges were promoted from, so chips
+ // do not duplicate a "3 Players" or "category:AOA" tag that already fed a badge.
+ foreach (var tag in result.Tags)
+ {
+ if (string.IsNullOrWhiteSpace(tag))
+ {
+ continue;
+ }
+
+ var trimmedTag = tag.Trim();
+ if (PlayerCountTagRegex().IsMatch(trimmedTag) || CategoryTagRegex().IsMatch(trimmedTag))
+ {
+ excluded.Add(trimmedTag);
+ }
+ }
+
+ return [.. result.Tags
+ .Select(t => t?.Trim() ?? string.Empty)
+ .Where(t => !string.IsNullOrWhiteSpace(t) && !excluded.Contains(t))];
+ }
+
+ ///
+ /// Extracts a trailing YYYY-MM-DD date from a build-stamp style version tag
+ /// (e.g. weekly-2026-07-03), returning the date string for badge display.
+ /// Returns null when the version is not a date-bearing build tag.
+ ///
+ /// The raw version/tag string.
+ /// The date portion, or null when no trailing date is present.
+ public static string? ExtractDateFromTag(string? version)
+ {
+ if (string.IsNullOrWhiteSpace(version))
+ {
+ return null;
+ }
+
+ var match = BuildStampDateRegex().Match(version);
+ return match.Success ? match.Groups["date"].Value : null;
+ }
+
+ private static bool HasMetadata(ContentSearchResult result, string key) =>
+ result.ResolverMetadata.ContainsKey(key) || result.Metadata.ContainsKey(key);
+
+ private static bool TryGetMetadata(ContentSearchResult result, string key, out string value)
+ {
+ if (result.ResolverMetadata.TryGetValue(key, out var resolverValue) && !string.IsNullOrWhiteSpace(resolverValue))
+ {
+ value = resolverValue;
+ return true;
+ }
+
+ if (result.Metadata.TryGetValue(key, out var metadataValue) && !string.IsNullOrWhiteSpace(metadataValue))
+ {
+ value = metadataValue;
+ return true;
+ }
+
+ value = string.Empty;
+ return false;
+ }
+
+ private static void PromotePlayerCountFromTags(ContentSearchResult result)
+ {
+ foreach (var tag in result.Tags)
+ {
+ var match = PlayerCountTagRegex().Match(tag);
+ if (match.Success && int.TryParse(match.Groups["players"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count))
+ {
+ ApplyPlayerCount(result, count);
+ break;
+ }
+ }
+ }
+
+ private static void PromoteCategoryFromTags(ContentSearchResult result)
+ {
+ foreach (var tag in result.Tags)
+ {
+ if (string.IsNullOrWhiteSpace(tag))
+ {
+ continue;
+ }
+
+ var match = CategoryTagRegex().Match(tag);
+ if (match.Success)
+ {
+ var cat = match.Groups["category"].Value.Trim();
+ if (!string.IsNullOrWhiteSpace(cat))
+ {
+ ApplyCategory(result, cat);
+ break;
+ }
+ }
+ }
+ }
+
+ [GeneratedRegex(@"^(?:players?:)?(?[1-8])\s*players?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex PlayerCountTagRegex();
+
+ [GeneratedRegex(@"^category:\s*(?.+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex CategoryTagRegex();
+
+ [GeneratedRegex(@"^(?:(?:weekly|nightly|daily|build|dev|snapshot|stamp)[-_])?(?\d{4}-\d{2}-\d{2})$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex BuildStampDateRegex();
+}
diff --git a/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs b/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs
index d3239277c..2a0d663b0 100644
--- a/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs
+++ b/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs
@@ -1,4 +1,7 @@
+using System;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Linq;
using GenHub.Core.Constants;
using GenHub.Core.Models.Enums;
@@ -126,6 +129,23 @@ public class ContentSearchQuery
///
public Collection CNCLabsMapTags { get; } = [];
+ // ===== AODMaps-specific filters =====
+
+ ///
+ /// Gets or sets the AODMaps player count filter (2, 3, 4, 6, 8 players).
+ ///
+ public string? AODMapsPlayerCount { get; set; }
+
+ ///
+ /// Gets or sets the AODMaps category filter (Compstomp, Air, Race, Map Packs).
+ ///
+ public string? AODMapsCategory { get; set; }
+
+ ///
+ /// Gets or sets the AODMaps map type filter (1v1, 2v2, FFA).
+ ///
+ public string? AODMapsMapType { get; set; }
+
// ===== GitHub-specific filters =====
///
@@ -151,6 +171,20 @@ public string? Language
set => _language = NormalizeLanguage(value);
}
+ ///
+ /// Generates a deterministic cache key representing the query and all its active filters.
+ ///
+ /// A string cache key.
+ public string ToCacheKey()
+ {
+ static string Escape(string? value) => System.Uri.EscapeDataString(value ?? string.Empty);
+ var tags = Tags.Count > 0 ? string.Join(",", Tags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).Select(Escape)) : string.Empty;
+ var cncTags = CNCLabsMapTags.Count > 0 ? string.Join(",", CNCLabsMapTags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).Select(Escape)) : string.Empty;
+ var minDate = MinDate?.ToString("o", System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
+ var maxDate = MaxDate?.ToString("o", System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
+ return $"search::{Escape(ProviderName)}::{Escape(SearchTerm)}::{Escape(AuthorName)}::{ContentType}::{TargetGame}::{Skip}::{Take}::{SortOrder}::{Escape(Sort)}::{IncludeInstalled}::{IncludeOlderVersions}::{NumberOfPlayers}::{Page}::{minDate}::{maxDate}::{Escape(ModDBCategory)}::{Escape(ModDBAddonCategory)}::{Escape(ModDBLicense)}::{Escape(ModDBTimeframe)}::{Escape(ModDBSection)}::{Escape(AODMapsPlayerCount)}::{Escape(AODMapsCategory)}::{Escape(AODMapsMapType)}::{Escape(GitHubTopic)}::{Escape(GitHubAuthor)}::{Escape(Language)}::{tags}::{cncTags}";
+ }
+
private static readonly Dictionary LanguageMap =
new(StringComparer.OrdinalIgnoreCase)
{
diff --git a/GenHub/GenHub.Core/Models/Content/CustomTabCardDefinition.cs b/GenHub/GenHub.Core/Models/Content/CustomTabCardDefinition.cs
new file mode 100644
index 000000000..c652738af
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Content/CustomTabCardDefinition.cs
@@ -0,0 +1,22 @@
+namespace GenHub.Core.Models.Content;
+
+///
+/// A display-ready publisher card shown inside a custom content detail tab.
+///
+public class CustomTabCardDefinition
+{
+ /// Gets or sets the card heading.
+ public string Title { get; set; } = string.Empty;
+
+ /// Gets or sets supporting text for the card.
+ public string Description { get; set; } = string.Empty;
+
+ /// Gets or sets an optional local or remote image URL.
+ public string? ImageUrl { get; set; }
+
+ /// Gets or sets an optional compact label displayed above the card title.
+ public string? Label { get; set; }
+
+ /// Gets or sets the card accent colour in a format understood by Avalonia.
+ public string AccentColor { get; set; } = "#303D59";
+}
diff --git a/GenHub/GenHub.Core/Models/Content/CustomTabDefinition.cs b/GenHub/GenHub.Core/Models/Content/CustomTabDefinition.cs
new file mode 100644
index 000000000..78517b191
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Content/CustomTabDefinition.cs
@@ -0,0 +1,76 @@
+namespace GenHub.Core.Models.Content;
+
+///
+/// Defines a custom tab for content detail pages.
+///
+public class CustomTabDefinition
+{
+ ///
+ /// Gets or sets the unique identifier for this tab.
+ ///
+ public required string TabId { get; set; }
+
+ ///
+ /// Gets or sets the display name shown in the tab header.
+ ///
+ public required string Header { get; set; }
+
+ ///
+ /// Gets or sets the icon name or path for the tab (optional).
+ ///
+ public string? Icon { get; set; }
+
+ ///
+ /// Gets or sets the order/priority of the tab (lower numbers appear first).
+ ///
+ public int Order { get; set; } = 100;
+
+ ///
+ /// Gets or sets the tab content type.
+ ///
+ public TabContentType ContentType { get; set; } = TabContentType.Custom;
+
+ ///
+ /// Gets or sets the data source URL for the tab content (optional).
+ /// Can be a catalog URL, API endpoint, or web page URL.
+ ///
+ public string? DataSourceUrl { get; set; }
+
+ ///
+ /// Gets or sets the content template identifier.
+ /// Used to determine which UI template to use for rendering.
+ ///
+ public string? ContentTemplate { get; set; }
+
+ ///
+ /// Gets or sets introductory copy displayed above the tab cards.
+ ///
+ public string? Intro { get; set; }
+
+ ///
+ /// Gets or sets the display cards supplied by the publisher for this tab.
+ ///
+ public List Cards { get; set; } = [];
+
+ ///
+ /// Gets or sets custom metadata for the tab.
+ /// Can be used to pass additional configuration to the tab renderer.
+ ///
+ public Dictionary Metadata { get; set; } = [];
+
+ ///
+ /// Gets or sets a value indicating whether the tab should be visible.
+ /// Can be used for conditional visibility based on content availability.
+ ///
+ public bool IsVisible { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether the tab content should be lazy-loaded.
+ ///
+ public bool LazyLoad { get; set; } = true;
+
+ ///
+ /// Gets or sets the function to load tab data dynamically.
+ ///
+ public Func