Files
vtuber-awards/Backend/Services/NominationEnrichmentService.cs
T
AzuTear b53c7fb736 Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
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>
2026-06-28 23:32:21 +02:00

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);