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>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user