b53c7fb736
Features: - Category viewer ranges + subcategory templates (admin group modal, tree workspace) - Nomination enrichment via TwitchTracker API (NominationEnrichmentService, TwitchTrackerViewerStatsProvider) with admin tracking rules editor - Nomination group tracker: CategoryGroupName as primary identifier, CategoryId stays as nullable legacy field; StreamerIdentity table - Dynamic showact application form builder (AdminShowactFormBuilder, ShowactApplicationSchedule) - Session idle timeout setting (AdminSessionTimeoutCard) - Share URLs for X and Discord (SiteSettings, public extras) - Workflow rules now stored per season (falls back to global SiteSettings) - New admin routes: settings/access, settings/workflows, tracking-rules - New admin review workspace with subcategory tabs - AdminCategoriesView rebuilt with group/subcategory modals Migrations (all additive): - AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges, AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates, AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule, AddSeasonWorkflowRulesJson Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
174 lines
7.0 KiB
C#
174 lines
7.0 KiB
C#
using System.Text.RegularExpressions;
|
|
using Backend.Data;
|
|
using Backend.Domain;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Backend.Services;
|
|
|
|
public sealed partial class NominationEnrichmentService(
|
|
AwardsDbContext db,
|
|
IViewerStatsProvider viewerStatsProvider,
|
|
NominationTrackingReviewService trackingReviewService)
|
|
{
|
|
public async Task EnrichAsync(Nomination nomination, IReadOnlyCollection<Category> groupCategories, CancellationToken cancellationToken)
|
|
{
|
|
if (!TryResolveStreamIdentity(nomination.StreamUrl, out var identity))
|
|
{
|
|
nomination.TrackerStatus = "unresolved";
|
|
nomination.TrackerCheckedAt = DateTimeOffset.UtcNow;
|
|
await trackingReviewService.ReevaluateAsync(nomination, true, cancellationToken);
|
|
return;
|
|
}
|
|
|
|
var streamerIdentity = await db.StreamerIdentities
|
|
.FirstOrDefaultAsync(item => item.NormalizedKey == identity.NormalizedKey, cancellationToken);
|
|
|
|
if (streamerIdentity is null)
|
|
{
|
|
streamerIdentity = new StreamerIdentity
|
|
{
|
|
Platform = identity.Platform,
|
|
Login = identity.Login,
|
|
NormalizedKey = identity.NormalizedKey,
|
|
DisplayName = identity.DisplayName,
|
|
ProfileUrl = identity.ProfileUrl,
|
|
};
|
|
db.StreamerIdentities.Add(streamerIdentity);
|
|
}
|
|
else
|
|
{
|
|
streamerIdentity.Platform = identity.Platform;
|
|
streamerIdentity.Login = identity.Login;
|
|
streamerIdentity.DisplayName = string.IsNullOrWhiteSpace(streamerIdentity.DisplayName)
|
|
? identity.DisplayName
|
|
: streamerIdentity.DisplayName;
|
|
streamerIdentity.ProfileUrl ??= identity.ProfileUrl;
|
|
}
|
|
|
|
streamerIdentity.LastResolvedAt = DateTimeOffset.UtcNow;
|
|
nomination.StreamerIdentity = streamerIdentity;
|
|
nomination.ResolvedChannel = identity.Login;
|
|
nomination.ResolvedPlatform = identity.Platform;
|
|
nomination.TrackerCheckedAt = DateTimeOffset.UtcNow;
|
|
|
|
if (!identity.SupportsViewerStats)
|
|
{
|
|
nomination.TrackerStatus = "unsupported_platform";
|
|
await trackingReviewService.ReevaluateAsync(nomination, true, cancellationToken);
|
|
return;
|
|
}
|
|
|
|
var summary = await viewerStatsProvider.GetChannelSummaryAsync(identity.Login, cancellationToken);
|
|
nomination.AvgViewers = summary?.AverageViewers;
|
|
nomination.HoursStreamed = summary?.HoursStreamed;
|
|
nomination.HoursWatched = summary?.HoursWatched;
|
|
nomination.PeakViewers = summary?.PeakViewers;
|
|
nomination.FollowersGained = summary?.FollowersGained;
|
|
nomination.SuggestedCategoryId = nomination.AvgViewers.HasValue
|
|
? ResolveSuggestedCategoryId(groupCategories, nomination.AvgViewers.Value)
|
|
: null;
|
|
nomination.TrackerStatus = summary is not null ? "resolved" : "no_data";
|
|
await trackingReviewService.ReevaluateAsync(nomination, true, cancellationToken);
|
|
}
|
|
|
|
public static bool TryResolveStreamIdentity(string? streamUrl, out ResolvedStreamerIdentity identity)
|
|
{
|
|
identity = default;
|
|
if (string.IsNullOrWhiteSpace(streamUrl) || !Uri.TryCreate(streamUrl.Trim(), UriKind.Absolute, out var uri))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var host = uri.Host.Replace("www.", string.Empty, StringComparison.OrdinalIgnoreCase).ToLowerInvariant();
|
|
var pathParts = uri.AbsolutePath
|
|
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Select(part => part.TrimStart('@'))
|
|
.Where(part => !IgnoredPathParts.Contains(part, StringComparer.OrdinalIgnoreCase))
|
|
.ToArray();
|
|
var login = pathParts.FirstOrDefault() ?? string.Empty;
|
|
|
|
if (host.Contains("youtu.be", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
login = pathParts.FirstOrDefault() ?? uri.Host;
|
|
}
|
|
|
|
var platform = ResolvePlatform(host);
|
|
login = SanitizeLogin(login);
|
|
if (string.IsNullOrWhiteSpace(login))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var profileUrl = BuildProfileUrl(platform, login, uri);
|
|
identity = new ResolvedStreamerIdentity(
|
|
platform,
|
|
login,
|
|
$"{platform.ToLowerInvariant()}:{login.ToLowerInvariant()}",
|
|
login,
|
|
profileUrl,
|
|
string.Equals(platform, "Twitch", StringComparison.OrdinalIgnoreCase));
|
|
return true;
|
|
}
|
|
|
|
private static int? ResolveSuggestedCategoryId(IEnumerable<Category> groupCategories, int averageViewers)
|
|
{
|
|
var orderedCategories = groupCategories
|
|
.OrderBy(item => item.SortOrder)
|
|
.ToArray();
|
|
|
|
var rangedCategories = orderedCategories
|
|
.Where(category => category.ViewerRangeMin.HasValue || category.ViewerRangeMax.HasValue)
|
|
.ToArray();
|
|
|
|
if (rangedCategories.Length > 0)
|
|
{
|
|
return rangedCategories
|
|
.FirstOrDefault(category =>
|
|
(!category.ViewerRangeMin.HasValue || averageViewers >= category.ViewerRangeMin.Value)
|
|
&& (!category.ViewerRangeMax.HasValue || averageViewers <= category.ViewerRangeMax.Value))
|
|
?.Id;
|
|
}
|
|
|
|
return orderedCategories
|
|
.FirstOrDefault()
|
|
?.Id;
|
|
}
|
|
|
|
private static string ResolvePlatform(string host)
|
|
{
|
|
if (host.Contains("twitch.tv", StringComparison.OrdinalIgnoreCase)) return "Twitch";
|
|
if (host.Contains("youtube.com", StringComparison.OrdinalIgnoreCase) || host.Contains("youtu.be", StringComparison.OrdinalIgnoreCase)) return "YouTube";
|
|
if (host.Contains("kick.com", StringComparison.OrdinalIgnoreCase)) return "Kick";
|
|
|
|
var firstPart = host.Split('.', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
|
return string.IsNullOrWhiteSpace(firstPart)
|
|
? "Website"
|
|
: $"{char.ToUpperInvariant(firstPart[0])}{firstPart[1..]}";
|
|
}
|
|
|
|
private static string BuildProfileUrl(string platform, string login, Uri originalUrl) =>
|
|
platform.ToLowerInvariant() switch
|
|
{
|
|
"twitch" => $"https://twitch.tv/{login}",
|
|
"youtube" => $"https://youtube.com/{login}",
|
|
"kick" => $"https://kick.com/{login}",
|
|
_ => originalUrl.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped),
|
|
};
|
|
|
|
private static string SanitizeLogin(string value) =>
|
|
LoginRegex().Replace(value.Trim().TrimStart('@'), string.Empty);
|
|
|
|
private static readonly string[] IgnoredPathParts = ["c", "channel", "user", "live", "videos", "video", "clip", "clips", "directory"];
|
|
|
|
[GeneratedRegex("[^a-zA-Z0-9._-]", RegexOptions.Compiled)]
|
|
private static partial Regex LoginRegex();
|
|
}
|
|
|
|
public readonly record struct ResolvedStreamerIdentity(
|
|
string Platform,
|
|
string Login,
|
|
string NormalizedKey,
|
|
string DisplayName,
|
|
string ProfileUrl,
|
|
bool SupportsViewerStats);
|