Files
WowUp/WowUp.WPF/AddonProviders/CurseAddonProvider.cs

598 lines
21 KiB
C#

using Flurl;
using Flurl.Http;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using WowUp.Common.Enums;
using WowUp.Common.Models;
using WowUp.Common.Models.Addons;
using WowUp.Common.Models.Curse;
using WowUp.Common.Services.Contracts;
using WowUp.WPF.AddonProviders.Contracts;
using WowUp.WPF.AddonProviders.Curse;
using WowUp.WPF.Entities;
using WowUp.WPF.Extensions;
using WowUp.WPF.Models.Curse;
using WowUp.WPF.Models.WowUp;
using WowUp.WPF.Services.Contracts;
using WowUp.WPF.Utilities;
namespace WowUp.WPF.AddonProviders
{
public class CurseAddonProvider : ICurseAddonProvider
{
private const string ApiUrl = "https://addons-ecs.forgesvc.net/api/v2";
private const string ClassicGameVersionFlavor = "wow_classic";
private const string RetailGameVersionFlavor = "wow_retail";
private readonly ICacheService _cacheService;
private readonly IAnalyticsService _analyticsService;
public string Name => "Curse";
public CurseAddonProvider(
IAnalyticsService analyticsService,
ICacheService cacheService)
{
_analyticsService = analyticsService;
_cacheService = cacheService;
}
public async Task Scan(
WowClientType clientType,
AddonChannelType addonChannelType,
IEnumerable<AddonFolder> addonFolders)
{
var addonDirectory = addonFolders.FirstOrDefault()?.Directory.Parent.FullName;
var scanResults = await GetScanResults(addonFolders);
await MapAddonFolders(scanResults, clientType);
var addonIds = scanResults
.Where(sr => sr.ExactMatch != null)
.Select(sr => sr.ExactMatch.Id.ToString())
.Distinct();
//var addonIdStr = string.Join(",", addonIds);
var addonResults = await GetAllIds(addonIds);
foreach (var addonFolder in addonFolders)
{
var scanResult = scanResults.First(sr => sr.AddonFolder.Name == addonFolder.Name);
if (scanResult.ExactMatch == null)
{
continue;
}
scanResult.SearchResult = addonResults.FirstOrDefault(ar => ar.Id == scanResult.ExactMatch.Id);
if (scanResult.SearchResult == null)
{
continue;
}
try
{
addonFolder.MatchingAddon = GetAddon(clientType, addonChannelType, scanResult);
}
catch (Exception ex)
{
_analyticsService.Track(ex, $"Failed to create addon for result {scanResult.FolderScanner.Fingerprint}");
}
}
}
public bool IsValidAddonUri(Uri addonUri)
{
return string.IsNullOrEmpty(addonUri.Host) == false &&
addonUri.Host.EndsWith("curseforge.com") &&
addonUri.LocalPath.StartsWith("/wow/addons");
}
public async Task<AddonSearchResult> GetById(
string addonId,
WowClientType clientType)
{
var result = await GetSearchResult(addonId);
if (result == null)
{
return null;
}
var latestFiles = GetLatestFiles(result, clientType);
if (!latestFiles.Any())
{
return null;
}
return GetAddonSearchResult(result, latestFiles);
}
public async Task<CurseSearchResult> GetSearchResult(string addonId)
{
var url = $"{ApiUrl}/addon/{addonId}";
return await _cacheService.GetCache(url, async () =>
{
return await url
.WithHeaders(HttpUtilities.DefaultHeaders)
.GetJsonAsync<CurseSearchResult>();
});
}
public async Task<IEnumerable<PotentialAddon>> Search(string query, WowClientType clientType)
{
var searchResults = new List<PotentialAddon>();
var response = await GetSearchResults(query);
foreach (var result in response)
{
var latestFiles = GetLatestFiles(result, clientType);
if (!latestFiles.Any())
{
continue;
}
searchResults.Add(GetPotentialAddon(result));
}
return searchResults;
}
/// <summary>
/// This is a basic method, curse api does not search via slug, so you have to get lucky basically.
/// Could pre-make a map for slug to addon if wanted.
/// </summary>
/// <param name="addonUri"></param>
/// <param name="clientType"></param>
/// <returns></returns>
public async Task<PotentialAddon> Search(Uri addonUri, WowClientType clientType)
{
var addonSlug = addonUri.LocalPath.Split('/').Last();
var response = await GetSearchResults(addonSlug);
var result = response.FirstOrDefault(res => res.Slug == addonSlug);
if (result == null)
{
return null;
}
var latestFiles = GetLatestFiles(result, clientType);
if (!latestFiles.Any())
{
return null;
}
return GetPotentialAddon(result);
}
public async Task<IEnumerable<AddonSearchResult>> Search(
string addonName,
string folderName,
WowClientType clientType,
string nameOverride = null)
{
var results = new List<AddonSearchResult>();
var matches = await Search(addonName, folderName, clientType);
foreach (var match in matches)
{
var latestFiles = GetLatestFiles(match, clientType);
var searchResult = GetAddonSearchResult(match, latestFiles);
if (searchResult != null)
{
results.Add(searchResult);
}
}
return results;
}
private async Task<List<CurseSearchResult>> Search(
string addonName,
string folderName,
WowClientType clientType)
{
var response = await GetSearchResults(addonName);
var matches = FilterResults(response, addonName, folderName, clientType);
return matches.Where(m => GetLatestFiles(m, clientType).Any()).ToList();
}
public async Task<IList<AddonSearchResult>> GetAll(WowClientType clientType, IEnumerable<string> addonIds)
{
var addonResults = new List<AddonSearchResult>();
if (!addonIds.Any())
{
return addonResults;
}
var results = await GetAllIds(addonIds);
foreach (var result in results)
{
var latestFiles = GetLatestFiles(result, clientType);
if (!latestFiles.Any())
{
continue;
}
var searchResult = GetAddonSearchResult(result, latestFiles);
if (searchResult != null)
{
addonResults.Add(searchResult);
}
}
return addonResults;
}
public async Task<IList<PotentialAddon>> GetFeaturedAddons(WowClientType clientType)
{
var featured = await GetFeaturedAddonList();
featured = FilterClientType(featured, clientType);
return featured.Select(f => GetPotentialAddon(f)).ToList();
}
public void OnPostInstall(Addon addon)
{
}
public async Task<List<CurseScanResult>> GetScanResults(IEnumerable<AddonFolder> addonFolders)
{
var scanResults = new ConcurrentBag<CurseScanResult>();
await addonFolders.ForEachAsync(3, async addonFolder =>
{
var scanner = await new CurseFolderScanner(addonFolder.Directory).ScanFolder();
var scanResult = new CurseScanResult
{
FolderScanner = scanner,
AddonFolder = addonFolder
};
scanResults.Add(scanResult);
});
return scanResults.ToList();
}
private Addon GetAddon(
WowClientType clientType,
AddonChannelType addonChannelType,
AddonFolder addonFolder,
CurseSearchResult searchResult)
{
if (addonFolder == null || searchResult == null)
{
return null;
}
var currentVersion = addonFolder.Toc.Version;
var latestFile = GetLatestFiles(searchResult, clientType).First();
var authors = GetAuthor(searchResult);
return new Addon
{
Author = authors,
Name = searchResult.Name,
ChannelType = addonChannelType,
AutoUpdateEnabled = false,
ClientType = clientType,
DownloadUrl = latestFile.DownloadUrl,
ExternalUrl = searchResult.WebsiteUrl,
ExternalId = searchResult.Id.ToString(),
FolderName = addonFolder.Name,
GameVersion = latestFile.GameVersion.FirstOrDefault(),
InstalledAt = DateTime.Now,
InstalledFolders = string.Join(",", GetFolderNames(latestFile)),
InstalledVersion = currentVersion,
IsIgnored = false,
LatestVersion = latestFile.DisplayName,
ProviderName = Name,
ThumbnailUrl = GetThumbnailUrl(searchResult)
};
}
private Addon GetAddon(
WowClientType clientType,
AddonChannelType addonChannelType,
CurseScanResult scanResult)
{
var currentVersion = scanResult.ExactMatch.File;
var authors = string.Join(", ", scanResult.SearchResult.Authors.Select(author => author.Name));
var folderList = string.Join(",", scanResult.ExactMatch.File.Modules.Select(m => m.Foldername));
var latestVersion = GetLatestFiles(scanResult.SearchResult, clientType).First();
return new Addon
{
Author = string.Join(", ", scanResult.SearchResult.Authors.Select(author => author.Name)),
Name = scanResult.SearchResult.Name,
ChannelType = addonChannelType,
AutoUpdateEnabled = false,
ClientType = clientType,
DownloadUrl = latestVersion.DownloadUrl,
ExternalUrl = scanResult.SearchResult.WebsiteUrl,
ExternalId = scanResult.SearchResult.Id.ToString(),
FolderName = scanResult.AddonFolder.Name,
GameVersion = currentVersion.GameVersion.FirstOrDefault(),
InstalledAt = DateTime.Now,
InstalledFolders = folderList,
InstalledVersion = currentVersion.DisplayName,
IsIgnored = false,
LatestVersion = latestVersion.DisplayName,
ProviderName = Name,
ThumbnailUrl = GetThumbnailUrl(scanResult.SearchResult)
};
}
private async Task MapAddonFolders(List<CurseScanResult> scanResults, WowClientType clientType)
{
//var fingerprintStr = string.Join(",", scanResults.Select(sf => sf.FolderScanner.Fingerprint));
var fingerprintResponse = await GetAddonsByFingerprints(scanResults.Select(sf => sf.FolderScanner.Fingerprint));
foreach (var scanResult in scanResults)
{
// Curse can deliver the wrong result sometimes, ensure the result matches the client type
scanResult.ExactMatch = fingerprintResponse.ExactMatches
.FirstOrDefault(exactMatch =>
IsClientType(exactMatch.File.GameVersionFlavor, clientType) &&
HasMatchingFingerprint(scanResult, exactMatch));
// If the addon does not have an exact match, check the partial matches.
if (scanResult.ExactMatch == null)
{
scanResult.ExactMatch = fingerprintResponse.PartialMatches
.FirstOrDefault(partialMatch => partialMatch.File.Modules.Any(module => module.Fingerprint == scanResult.FolderScanner.Fingerprint));
}
}
}
private bool HasMatchingFingerprint(CurseScanResult scanResult, CurseMatch exactMatch)
{
return exactMatch.File.Modules.Any(m => m.Fingerprint == scanResult.FolderScanner.Fingerprint);
}
private IList<CurseSearchResult> FilterClientType(IEnumerable<CurseSearchResult> results, WowClientType clientType)
{
var clientTypeStr = GetClientTypeString(clientType);
return results
.Where(r => r.LatestFiles.Any(f => DoesFileMatchClientType(f, clientTypeStr)))
.ToList();
}
private bool DoesFileMatchClientType(CurseFile file, string clientTypeStr)
{
return file.ReleaseType == CurseReleaseType.Release &&
file.GameVersionFlavor == clientTypeStr &&
file.IsAlternate == false;
}
private AddonChannelType GetChannelType(CurseReleaseType releaseType)
{
return releaseType switch
{
CurseReleaseType.Alpha => AddonChannelType.Alpha,
CurseReleaseType.Beta => AddonChannelType.Beta,
_ => AddonChannelType.Stable,
};
}
private AddonSearchResult GetAddonSearchResult(CurseSearchResult result, IEnumerable<CurseFile> latestFiles)
{
try
{
var thumbnailUrl = GetThumbnailUrl(result);
var id = result.Id;
var name = result.Name;
var author = GetAuthor(result);
var searchResultFiles = latestFiles.Select(lf => new AddonSearchResultFile
{
ChannelType = GetChannelType(lf.ReleaseType),
Version = lf.DisplayName,
DownloadUrl = lf.DownloadUrl,
Folders = GetFolderNames(lf),
GameVersion = GetGameVersion(lf),
ReleaseDate = lf.FileDate
});
return new AddonSearchResult
{
Author = author,
ExternalId = id.ToString(),
Name = name,
ThumbnailUrl = thumbnailUrl,
ExternalUrl = result.WebsiteUrl,
ProviderName = Name,
Files = searchResultFiles
};
}
catch (Exception ex)
{
_analyticsService.Track(ex, "GetAddonSearchResult");
return null;
}
}
private IList<CurseSearchResult> FilterResults(
IList<CurseSearchResult> results,
string addonName,
string folderName,
WowClientType clientType)
{
var clientTypeStr = GetClientTypeString(clientType);
return results.
Where(r =>
r.Name == addonName ||
r.LatestFiles.Any(f => f.ReleaseType == CurseReleaseType.Release &&
f.GameVersionFlavor == clientTypeStr &&
f.IsAlternate == false &&
f.Modules.Any(m => m.Foldername == folderName)))
.ToList();
}
private IList<string> GetFolderNames(CurseFile file)
{
return file.Modules.Select(m => m.Foldername).ToList();
}
private string GetAuthor(CurseSearchResult result)
{
var authorNames = result.Authors.Select(a => a.Name);
return string.Join(", ", authorNames);
}
private string GetGameVersion(CurseFile file)
{
return file.GameVersion.FirstOrDefault();
}
private IEnumerable<CurseFile> GetLatestFiles(CurseSearchResult result, WowClientType clientType)
{
var clientTypeStr = GetClientTypeString(clientType);
return result.LatestFiles
.Where(f => f.IsAlternate == false && f.GameVersionFlavor == clientTypeStr)
.OrderByDescending(f => f.Id);
}
private string GetThumbnailUrl(CurseSearchResult result)
{
return result.Attachments
.FirstOrDefault(f => f.IsDefault && !string.IsNullOrEmpty(f.ThumbnailUrl))?.ThumbnailUrl;
}
private async Task<IList<CurseSearchResult>> GetAllIds(IEnumerable<string> addonIds)
{
var url = $"{ApiUrl}/addon";
try
{
return await url
.WithHeaders(HttpUtilities.DefaultHeaders)
.PostJsonAsync(addonIds.Select(id => Convert.ToInt32(id)).ToArray())
.ReceiveJson<List<CurseSearchResult>>();
}
catch (Exception ex)
{
_analyticsService.Track(ex, "GetAllIds");
return new List<CurseSearchResult>();
}
}
private async Task<IList<CurseSearchResult>> GetSearchResults(string query)
{
var url = $"{ApiUrl}/addon/search";
try
{
return await url
.SetQueryParams(new { gameId = 1, searchFilter = query })
.WithHeaders(HttpUtilities.DefaultHeaders)
.GetJsonAsync<IList<CurseSearchResult>>();
}
catch (Exception ex)
{
_analyticsService.Track(ex, "GetSearchResults");
return new List<CurseSearchResult>();
}
}
private async Task<IList<CurseSearchResult>> GetFeaturedAddonList()
{
var url = $"{ApiUrl}/addon/featured";
try
{
var body = new
{
GameId = 1,
featuredCount = 6,
popularCount = 50,
updatedCount = 0
};
var response = await _cacheService.GetCache(url, async () =>
{
return await url
.WithHeaders(HttpUtilities.DefaultHeaders)
.PostJsonAsync(body)
.ReceiveJson<CurseGetFeaturedResponse>();
});
return response.Popular.ToList();
}
catch (Exception ex)
{
_analyticsService.Track(ex, "GetSearchResults");
return new List<CurseSearchResult>();
}
}
private async Task<CurseFingerprintsResponse> GetAddonsByFingerprints(IEnumerable<long> fingerprints)
{
var url = $"{ApiUrl}/fingerprint";
return await url
.WithHeaders(HttpUtilities.DefaultHeaders)
.PostJsonAsync(fingerprints)
.ReceiveJson<CurseFingerprintsResponse>();
}
private PotentialAddon GetPotentialAddon(CurseSearchResult searchResult)
{
return new PotentialAddon
{
ProviderName = Name,
Name = searchResult.Name,
DownloadCount = (int)searchResult.DownloadCount,
ThumbnailUrl = GetThumbnailUrl(searchResult),
ExternalId = searchResult.Id.ToString(),
ExternalUrl = searchResult.WebsiteUrl,
Author = GetAuthor(searchResult)
};
}
private bool IsClientType(string gameVesionFlavor, WowClientType clientType)
{
switch (clientType)
{
case WowClientType.Classic:
case WowClientType.ClassicPtr:
return ClassicGameVersionFlavor == gameVesionFlavor;
case WowClientType.Retail:
case WowClientType.RetailPtr:
case WowClientType.Beta:
return RetailGameVersionFlavor == gameVesionFlavor;
default:
return false;
}
}
private string GetClientTypeString(WowClientType clientType)
{
switch (clientType)
{
case WowClientType.Classic:
case WowClientType.ClassicPtr:
return ClassicGameVersionFlavor;
case WowClientType.Retail:
case WowClientType.RetailPtr:
case WowClientType.Beta:
return RetailGameVersionFlavor;
default:
return string.Empty;
}
}
}
}