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