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:
@@ -10,5 +10,6 @@ public interface IUserSessionService
|
||||
Task<UserSession> CreateSessionAsync(string twitchUserId, string displayName, string role, RequestMetadata metadata, CancellationToken cancellationToken = default);
|
||||
Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default);
|
||||
Task<int> CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default);
|
||||
Task<int> GetIdleTimeoutHoursAsync(CancellationToken cancellationToken = default);
|
||||
Task LogoutAsync(UserSession session, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Backend.Services;
|
||||
|
||||
public interface IViewerStatsProvider
|
||||
{
|
||||
Task<ViewerStatsSnapshot?> GetChannelSummaryAsync(string twitchLogin, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record ViewerStatsSnapshot(
|
||||
int AverageViewers,
|
||||
int HoursStreamed,
|
||||
int HoursWatched,
|
||||
int PeakViewers,
|
||||
int FollowersGained,
|
||||
string WindowKey);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,225 @@
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Services;
|
||||
|
||||
public sealed class NominationTrackingReviewService(AwardsDbContext db)
|
||||
{
|
||||
public async Task ReevaluateAsync(Nomination nomination, bool resetManualResolution, CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||
|
||||
ApplyEvaluation(nomination, TrackingRulesSettings.Read(settings), resetManualResolution);
|
||||
}
|
||||
|
||||
public async Task ReevaluateAllAsync(bool resetManualResolution, CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||
|
||||
var rules = TrackingRulesSettings.Read(settings);
|
||||
await ReevaluateAllAsync(rules, resetManualResolution, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ReevaluateAllAsync(
|
||||
TrackingRulesConfiguration rules,
|
||||
bool resetManualResolution,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var nominations = await db.Nominations.ToArrayAsync(cancellationToken);
|
||||
foreach (var nomination in nominations)
|
||||
{
|
||||
ApplyEvaluation(nomination, rules, resetManualResolution);
|
||||
}
|
||||
}
|
||||
|
||||
public NominationTrackingEvaluation Evaluate(Nomination nomination, TrackingRulesConfiguration rules)
|
||||
{
|
||||
var requiredMetrics = rules.ImportantMetrics
|
||||
.Where(item => item.Enabled && item.RequiredForAutoClassification)
|
||||
.ToArray();
|
||||
var missingRequiredMetrics = requiredMetrics
|
||||
.Where(metric => !IsMetricPresent(metric, nomination))
|
||||
.ToArray();
|
||||
var unsupportedAutomaticWindows = rules.ImportantMetrics
|
||||
.Concat(rules.OptionalMetrics)
|
||||
.Where(item => item.Enabled && item.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(item))
|
||||
.ToArray();
|
||||
|
||||
var triggeredFlags = new List<TrackingFlagHit>();
|
||||
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagTrackerUnresolved, nomination.TrackerStatus == "unresolved");
|
||||
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagUnsupportedPlatform, nomination.TrackerStatus == "unsupported_platform");
|
||||
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagNoTrackerData, nomination.TrackerStatus == "no_data");
|
||||
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagMissingRequiredMetric, missingRequiredMetrics.Length > 0);
|
||||
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagUnsupportedMetricWindow, unsupportedAutomaticWindows.Length > 0);
|
||||
|
||||
var needsManualReview = triggeredFlags.Any(flag => flag.RequiresManualReview);
|
||||
AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagManualReviewRequired, needsManualReview);
|
||||
|
||||
return new NominationTrackingEvaluation(
|
||||
missingRequiredMetrics.Select(item => item.Key).ToArray(),
|
||||
triggeredFlags.ToArray(),
|
||||
needsManualReview || triggeredFlags.Any(flag => flag.Key == TrackingRulesSettings.FlagManualReviewRequired),
|
||||
triggeredFlags.Any(flag => flag.BlocksApproval),
|
||||
triggeredFlags.Any(flag => flag.AdminNoteRequiredOnOverride));
|
||||
}
|
||||
|
||||
public TrackingMetricState[] BuildMetricStates(Nomination nomination, TrackingRulesConfiguration rules) =>
|
||||
rules.ImportantMetrics
|
||||
.Concat(rules.OptionalMetrics)
|
||||
.Where(item => item.Enabled && item.ShowInReview)
|
||||
.Select(metric => new TrackingMetricState(
|
||||
metric.Key,
|
||||
metric.Label,
|
||||
metric.RequiredForAutoClassification,
|
||||
metric.SourceSupport,
|
||||
IsMetricPresent(metric, nomination),
|
||||
ResolveMetricValue(metric, nomination),
|
||||
metric.Description,
|
||||
metric.WindowKey,
|
||||
TrackingRulesSettings.WindowLabel(metric.WindowKey),
|
||||
TrackingRulesSettings.SupportsAutomaticWindow(metric)))
|
||||
.ToArray();
|
||||
|
||||
private void ApplyEvaluation(Nomination nomination, TrackingRulesConfiguration rules, bool resetManualResolution)
|
||||
{
|
||||
var evaluation = Evaluate(nomination, rules);
|
||||
nomination.TrackingFlagsJson = TrackingRulesSettings.SerializeFlagHits(evaluation.Flags);
|
||||
|
||||
if (resetManualResolution || nomination.TrackingReviewStatus is not ("reviewed" or "overridden"))
|
||||
{
|
||||
nomination.TrackingReviewStatus = evaluation.RequiresManualReview ? "flagged" : "clear";
|
||||
if (resetManualResolution)
|
||||
{
|
||||
nomination.TrackingReviewNote = null;
|
||||
nomination.TrackingReviewedByTwitchId = null;
|
||||
nomination.TrackingReviewedAt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsMetricPresent(TrackingMetricRuleSetting metric, Nomination nomination)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => nomination.AvgViewers.HasValue,
|
||||
TrackingRulesSettings.TrackerStatus => !string.IsNullOrWhiteSpace(nomination.TrackerStatus),
|
||||
TrackingRulesSettings.TrackerCheckedAt => nomination.TrackerCheckedAt.HasValue,
|
||||
TrackingRulesSettings.HoursStreamed => nomination.HoursStreamed.HasValue,
|
||||
TrackingRulesSettings.HoursWatched => nomination.HoursWatched.HasValue,
|
||||
TrackingRulesSettings.PeakViewers => nomination.PeakViewers.HasValue,
|
||||
TrackingRulesSettings.FollowersGained => nomination.FollowersGained.HasValue,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveMetricValue(TrackingMetricRuleSetting metric, Nomination nomination)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return $"Auto nur fuer {string.Join(", ", metric.AutoSupportedWindowKeys.Select(TrackingRulesSettings.WindowLabel))}";
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => nomination.AvgViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.TrackerStatus => string.IsNullOrWhiteSpace(nomination.TrackerStatus) ? "offen" : nomination.TrackerStatus,
|
||||
TrackingRulesSettings.TrackerCheckedAt => nomination.TrackerCheckedAt?.ToString("g") ?? "offen",
|
||||
TrackingRulesSettings.HoursStreamed => nomination.HoursStreamed?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.HoursWatched => nomination.HoursWatched?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.PeakViewers => nomination.PeakViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.FollowersGained => nomination.FollowersGained?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.CategoryFit => "Manueller Kategorie-Check",
|
||||
TrackingRulesSettings.TopCategoriesContext => BuildTopCategoriesContextSummary(metric),
|
||||
_ => "manuell",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTopCategoriesContextSummary(TrackingMetricRuleSetting metric)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (metric.TopCount.HasValue)
|
||||
{
|
||||
parts.Add($"Top {metric.TopCount.Value}");
|
||||
}
|
||||
|
||||
if (metric.MinPrimaryCategorySharePercent.HasValue)
|
||||
{
|
||||
parts.Add($">= {metric.MinPrimaryCategorySharePercent.Value}% Hauptkategorie");
|
||||
}
|
||||
|
||||
if (metric.MinPrimaryCategoryHours.HasValue)
|
||||
{
|
||||
parts.Add($">= {metric.MinPrimaryCategoryHours.Value}h Hauptkategorie");
|
||||
}
|
||||
|
||||
if (metric.MaxDistinctCategoriesBeforeFlag.HasValue)
|
||||
{
|
||||
parts.Add($"Flag ab {metric.MaxDistinctCategoriesBeforeFlag.Value}+ Kategorien");
|
||||
}
|
||||
|
||||
if (metric.IgnoredCategories.Length > 0)
|
||||
{
|
||||
parts.Add($"Ignore: {string.Join(", ", metric.IgnoredCategories)}");
|
||||
}
|
||||
|
||||
return parts.Count > 0
|
||||
? string.Join(" · ", parts)
|
||||
: "Top-Kategorien manuell pruefen";
|
||||
}
|
||||
|
||||
private static void AddFlagIf(
|
||||
ICollection<TrackingFlagHit> target,
|
||||
IEnumerable<TrackingFlagRuleSetting> availableFlags,
|
||||
string key,
|
||||
bool shouldAdd)
|
||||
{
|
||||
if (!shouldAdd)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rule = availableFlags.FirstOrDefault(item => item.Key == key);
|
||||
if (rule is null || !rule.Enabled || !rule.AutoTriggerEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
target.Add(new TrackingFlagHit(
|
||||
rule.Key,
|
||||
rule.Label,
|
||||
rule.Severity,
|
||||
rule.Description,
|
||||
rule.RequiresManualReview,
|
||||
rule.BlocksApproval,
|
||||
rule.AdminNoteRequiredOnOverride));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record NominationTrackingEvaluation(
|
||||
string[] MissingRequiredMetricKeys,
|
||||
TrackingFlagHit[] Flags,
|
||||
bool RequiresManualReview,
|
||||
bool HasBlockingFlag,
|
||||
bool RequiresOverrideNote);
|
||||
|
||||
public sealed record TrackingMetricState(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Required,
|
||||
string SourceSupport,
|
||||
bool Present,
|
||||
string Value,
|
||||
string Description,
|
||||
string WindowKey,
|
||||
string WindowLabel,
|
||||
bool AutoWindowSupported);
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Backend.Contracts;
|
||||
using Backend.Domain;
|
||||
|
||||
namespace Backend.Services;
|
||||
|
||||
public sealed record SeasonSubcategoryTemplateSetting(
|
||||
string Name,
|
||||
string Slug,
|
||||
int SortOrder,
|
||||
int? ViewerRangeMin,
|
||||
int? ViewerRangeMax);
|
||||
|
||||
public static class SeasonSubcategoryTemplateSettings
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly SeasonSubcategoryTemplateSetting[] DefaultTemplates =
|
||||
[
|
||||
new("Hidden Star", "hidden-star", 1, 1, 20),
|
||||
new("Rising Star", "rising-star", 2, 21, 60),
|
||||
new("Shining Star", "shining-star", 3, 61, null),
|
||||
];
|
||||
|
||||
public static SeasonSubcategoryTemplateSetting[] Read(Season season, IEnumerable<Category>? fallbackCategories = null)
|
||||
{
|
||||
var stored = OnlyViewerTemplates(Parse(season.SubcategoryTemplatesJson));
|
||||
if (stored.Length > 0)
|
||||
{
|
||||
return stored;
|
||||
}
|
||||
|
||||
if (fallbackCategories is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var fallback = fallbackCategories
|
||||
.GroupBy(category => new { category.Name, category.ViewerRangeMin, category.ViewerRangeMax })
|
||||
.Where(group => group.Key.ViewerRangeMin is not null || group.Key.ViewerRangeMax is not null)
|
||||
.Select(group =>
|
||||
{
|
||||
var first = group.OrderBy(item => item.SortOrder).First();
|
||||
return Normalize(new SeasonSubcategoryTemplateSetting(
|
||||
first.Name,
|
||||
ExtractTemplateSlug(first.Slug, first.GroupName),
|
||||
first.SortOrder,
|
||||
first.ViewerRangeMin,
|
||||
first.ViewerRangeMax));
|
||||
})
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select((item, index) => item with { SortOrder = index + 1 })
|
||||
.ToArray();
|
||||
|
||||
return fallback.Length > 0 ? fallback : DefaultTemplates;
|
||||
}
|
||||
|
||||
public static string Serialize(IEnumerable<SeasonSubcategoryTemplateSetting> templates) =>
|
||||
JsonSerializer.Serialize(
|
||||
templates.Select(Normalize)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase),
|
||||
JsonOptions);
|
||||
|
||||
public static SeasonSubcategoryTemplateSetting[] Normalize(IEnumerable<AdminSubcategoryTemplateDto>? templates) =>
|
||||
(templates ?? [])
|
||||
.Select(template => Normalize(new SeasonSubcategoryTemplateSetting(
|
||||
template.Name,
|
||||
template.Slug,
|
||||
template.SortOrder,
|
||||
template.ViewerRangeMin,
|
||||
template.ViewerRangeMax)))
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select((item, index) => item with { SortOrder = index + 1 })
|
||||
.ToArray();
|
||||
|
||||
public static AdminSubcategoryTemplateDto[] ToDtos(IEnumerable<SeasonSubcategoryTemplateSetting> templates) =>
|
||||
templates
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(item => new AdminSubcategoryTemplateDto(
|
||||
item.Name,
|
||||
item.Slug,
|
||||
item.SortOrder,
|
||||
item.ViewerRangeMin,
|
||||
item.ViewerRangeMax))
|
||||
.ToArray();
|
||||
|
||||
public static bool MatchesTemplate(Category category, IEnumerable<SeasonSubcategoryTemplateSetting> templates)
|
||||
{
|
||||
var categoryTemplateSlug = ExtractTemplateSlug(category.Slug, category.GroupName);
|
||||
return templates.Any(template =>
|
||||
string.Equals(category.Name, template.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(categoryTemplateSlug, template.Slug, StringComparison.OrdinalIgnoreCase)
|
||||
|| category.Slug.EndsWith($"-{template.Slug}", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static SeasonSubcategoryTemplateSetting[] Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SeasonSubcategoryTemplateSetting[]>(json, JsonOptions)?
|
||||
.Select(Normalize)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select((item, index) => item with { SortOrder = index + 1 })
|
||||
.ToArray()
|
||||
?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static SeasonSubcategoryTemplateSetting Normalize(SeasonSubcategoryTemplateSetting template)
|
||||
{
|
||||
var name = template.Name.Trim();
|
||||
var slug = Slugify(template.Slug);
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
{
|
||||
slug = Slugify(name);
|
||||
}
|
||||
|
||||
return template with
|
||||
{
|
||||
Name = name,
|
||||
Slug = slug,
|
||||
SortOrder = Math.Clamp(template.SortOrder, 1, 99),
|
||||
ViewerRangeMin = NormalizeNullableNumber(template.ViewerRangeMin),
|
||||
ViewerRangeMax = NormalizeNullableNumber(template.ViewerRangeMax),
|
||||
};
|
||||
}
|
||||
|
||||
private static int? NormalizeNullableNumber(int? value) => value is null ? null : Math.Clamp(value.Value, 0, 100000);
|
||||
|
||||
private static SeasonSubcategoryTemplateSetting[] OnlyViewerTemplates(IEnumerable<SeasonSubcategoryTemplateSetting> templates)
|
||||
{
|
||||
var items = templates
|
||||
.Select(Normalize)
|
||||
.Where(item => item.ViewerRangeMin is not null || item.ViewerRangeMax is not null)
|
||||
.GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.OrderBy(item => item.SortOrder).First())
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select((item, index) => item with { SortOrder = index + 1 })
|
||||
.ToArray();
|
||||
|
||||
return items.Length > 0 ? items : [];
|
||||
}
|
||||
|
||||
private static string ExtractTemplateSlug(string categorySlug, string groupName)
|
||||
{
|
||||
var groupSlug = Slugify(groupName);
|
||||
var slug = categorySlug.Trim().ToLowerInvariant();
|
||||
var prefix = string.IsNullOrWhiteSpace(groupSlug) ? string.Empty : $"{groupSlug}-";
|
||||
if (!string.IsNullOrWhiteSpace(prefix) && slug.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return slug[prefix.Length..];
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
public static string Slugify(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty)
|
||||
.Trim()
|
||||
.ToLowerInvariant()
|
||||
.Normalize(NormalizationForm.FormD);
|
||||
|
||||
var builder = new StringBuilder(normalized.Length);
|
||||
var lastWasDash = false;
|
||||
|
||||
foreach (var character in normalized)
|
||||
{
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(character) == UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsLetterOrDigit(character))
|
||||
{
|
||||
builder.Append(character);
|
||||
lastWasDash = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lastWasDash && builder.Length > 0)
|
||||
{
|
||||
builder.Append('-');
|
||||
lastWasDash = true;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString().Trim('-');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
using System.Text.Json;
|
||||
using Backend.Domain;
|
||||
|
||||
namespace Backend.Services;
|
||||
|
||||
public sealed record TrackingSourceSetting(
|
||||
string ProviderKey,
|
||||
string BaseUrl,
|
||||
string NotesSummary,
|
||||
bool ShowManualReviewNotesInReview);
|
||||
|
||||
public sealed record TrackingMetricRuleSetting(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Enabled,
|
||||
string SourceSupport,
|
||||
string Description,
|
||||
bool RequiredForAutoClassification,
|
||||
bool ShowInReview,
|
||||
bool ShowInAdminSummary,
|
||||
bool ManualOverrideAllowed,
|
||||
string WindowKey,
|
||||
string[] AutoSupportedWindowKeys,
|
||||
string? ProviderFieldKey,
|
||||
int? TopCount,
|
||||
int? MinPrimaryCategorySharePercent,
|
||||
int? MinPrimaryCategoryHours,
|
||||
int? MaxDistinctCategoriesBeforeFlag,
|
||||
string[] IgnoredCategories,
|
||||
bool MatchAwardCategoryAgainstTopCategories,
|
||||
bool FlagIfAwardCategoryNotInTopX,
|
||||
bool FlagIfCategorySpreadTooWide,
|
||||
bool FlagIfNoCategoryContextAvailable,
|
||||
int? MinValue,
|
||||
int? MaxValue);
|
||||
|
||||
public sealed record TrackingFlagRuleSetting(
|
||||
string Key,
|
||||
string Label,
|
||||
bool Enabled,
|
||||
string Severity,
|
||||
string Description,
|
||||
bool AutoTriggerEnabled,
|
||||
bool RequiresManualReview,
|
||||
bool BlocksApproval,
|
||||
bool AdminNoteRequiredOnOverride);
|
||||
|
||||
public sealed record TrackingRulesConfiguration(
|
||||
TrackingSourceSetting Source,
|
||||
TrackingMetricRuleSetting[] ImportantMetrics,
|
||||
TrackingMetricRuleSetting[] OptionalMetrics,
|
||||
TrackingFlagRuleSetting[] Flags);
|
||||
|
||||
public sealed record TrackingFlagHit(
|
||||
string Key,
|
||||
string Label,
|
||||
string Severity,
|
||||
string Description,
|
||||
bool RequiresManualReview,
|
||||
bool BlocksApproval,
|
||||
bool AdminNoteRequiredOnOverride);
|
||||
|
||||
public static class TrackingRulesSettings
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public const string ProviderKey = "twitchtracker";
|
||||
public const string DefaultBaseUrl = "https://twitchtracker.com/api";
|
||||
|
||||
public const string Window7d = "7d";
|
||||
public const string Window30d = "30d";
|
||||
public const string Window90d = "90d";
|
||||
public const string WindowAllTime = "all_time";
|
||||
|
||||
public const string AvgViewers = "avg_viewers";
|
||||
public const string TrackerStatus = "tracker_status";
|
||||
public const string TrackerCheckedAt = "tracker_checked_at";
|
||||
public const string HoursStreamed = "hours_streamed";
|
||||
public const string HoursWatched = "hours_watched";
|
||||
public const string PeakViewers = "peak_viewers";
|
||||
public const string FollowersGained = "followers_gained";
|
||||
public const string CategoryFit = "category_fit";
|
||||
public const string TopCategoriesContext = "top_categories_context";
|
||||
|
||||
public const string FlagTrackerUnresolved = "tracker_unresolved";
|
||||
public const string FlagUnsupportedPlatform = "unsupported_platform";
|
||||
public const string FlagNoTrackerData = "no_tracker_data";
|
||||
public const string FlagMissingRequiredMetric = "missing_required_metric";
|
||||
public const string FlagManualReviewRequired = "manual_review_required";
|
||||
public const string FlagLowConfidenceSmallChannel = "low_confidence_small_channel";
|
||||
public const string FlagInsufficientActivityContext = "insufficient_activity_context";
|
||||
public const string FlagCategoryFitNeedsReview = "category_fit_needs_review";
|
||||
public const string FlagUnsupportedMetricWindow = "unsupported_metric_window";
|
||||
|
||||
public static readonly string[] SupportedMetricWindows = [Window7d, Window30d, Window90d, WindowAllTime];
|
||||
public static readonly string[] TwitchTrackerAutoWindowSupport = [Window30d];
|
||||
|
||||
public static TrackingSourceSetting DefaultSource { get; } =
|
||||
new(
|
||||
ProviderKey,
|
||||
DefaultBaseUrl,
|
||||
"TwitchTracker Basic API liefert aktuell Channel-Summary-Daten fuer 30 Tage. Andere Zeitfenster bleiben konfigurierbar, werden aber als manueller Review-Fall markiert.",
|
||||
true);
|
||||
|
||||
public static TrackingMetricRuleSetting[] DefaultImportantMetrics { get; } =
|
||||
[
|
||||
new(AvgViewers, "Avg Viewer", true, "auto", "Durchschnittliche Viewer fuer den gewaehlten Zeitraum.", true, true, true, true, Window90d, TwitchTrackerAutoWindowSupport, "avg_viewers", null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(TrackerStatus, "Tracker-Status", true, "auto", "Zeigt, ob der TwitchTracker-Lookup sauber aufgeloest werden konnte.", true, true, true, false, Window30d, TwitchTrackerAutoWindowSupport, "tracker_status", null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(TrackerCheckedAt, "Letzter Tracker-Check", true, "auto", "Zeitpunkt der letzten automatischen Datenaufloesung.", true, true, false, false, Window30d, TwitchTrackerAutoWindowSupport, "tracker_checked_at", null, null, null, null, [], false, false, false, false, null, null),
|
||||
];
|
||||
|
||||
public static TrackingMetricRuleSetting[] DefaultOptionalMetrics { get; } =
|
||||
[
|
||||
new(HoursStreamed, "Hours Streamed", true, "auto", "Gesamte Streamstunden im gewaehlten Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "hours_streamed", null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(HoursWatched, "Hours Watched", true, "auto", "Gesamte Watch Time fuer den gewaehlten Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "hours_watched", null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(PeakViewers, "Peak Viewer", true, "auto", "Hoechster gleichzeitiger Zuschauerwert im Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "peak_viewers", null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(FollowersGained, "Follower Growth", true, "auto", "Follower-Zuwachs im gewaehlten Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "followers_gained", null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(CategoryFit, "Category Fit", false, "context_only", "Admin-Einschaetzung, ob die Person inhaltlich zur Unterkategorie passt.", false, true, false, true, Window90d, [], null, null, null, null, null, [], false, false, false, false, null, null),
|
||||
new(TopCategoriesContext, "Top Categories Context", false, "context_only", "Manueller Kontext aus zuletzt meistgestreamten Kategorien oder Games des Channels.", false, true, true, true, Window90d, [], null, 5, 60, 20, 6, ["Just Chatting", "Special Events"], true, true, true, true, null, null),
|
||||
];
|
||||
|
||||
public static TrackingFlagRuleSetting[] DefaultFlags { get; } =
|
||||
[
|
||||
new(FlagTrackerUnresolved, "Tracker-Link nicht aufloesbar", true, "high", "Der Stream-Link konnte nicht sauber in einen Twitch-Channel aufgeloest werden.", true, true, false, false),
|
||||
new(FlagUnsupportedPlatform, "Plattform nicht unterstuetzt", true, "medium", "Die Nominierung zeigt auf eine Plattform ohne automatische TwitchTracker-Daten.", true, true, false, false),
|
||||
new(FlagNoTrackerData, "Keine Tracker-Daten", true, "medium", "TwitchTracker hat keinen belastbaren Summary-Wert geliefert.", true, true, false, false),
|
||||
new(FlagMissingRequiredMetric, "Pflichtmetrik fehlt", true, "high", "Mindestens eine aktivierte Pflichtmetrik fuer die Auto-Klassifizierung fehlt.", true, true, true, false),
|
||||
new(FlagManualReviewRequired, "Manuelle Pruefung noetig", true, "medium", "Die Auto-Daten reichen nicht fuer eine sichere Review-Entscheidung.", true, true, false, false),
|
||||
new(FlagLowConfidenceSmallChannel, "Low Confidence Small Channel", false, "low", "Kleine Kanaele koennen manuell tiefer geprueft werden.", false, true, false, false),
|
||||
new(FlagInsufficientActivityContext, "Zu wenig Aktivitaetskontext", false, "low", "Ohne zusaetzliche Kontextdaten wie Streamstunden ist die Bewertung unsicher.", false, true, false, false),
|
||||
new(FlagCategoryFitNeedsReview, "Category Fit manuell pruefen", false, "low", "Unterkategorie muss inhaltlich manuell bestaetigt werden.", false, true, false, false),
|
||||
new(FlagUnsupportedMetricWindow, "Gewaehltes Zeitfenster nicht auto-verfuegbar", true, "medium", "Die aktuelle TwitchTracker API liefert diese Metrik nicht fuer das konfigurierte Zeitfenster.", true, true, false, false),
|
||||
];
|
||||
|
||||
public static TrackingRulesConfiguration Read(SiteSettings? settings)
|
||||
{
|
||||
var parsed = Parse(settings?.TrackingRulesJson);
|
||||
var source = NormalizeSource(parsed?.Source, DefaultSource);
|
||||
|
||||
return new TrackingRulesConfiguration(
|
||||
source,
|
||||
MergeMetrics(parsed?.ImportantMetrics, DefaultImportantMetrics),
|
||||
MergeMetrics(parsed?.OptionalMetrics, DefaultOptionalMetrics),
|
||||
MergeFlags(parsed?.Flags, DefaultFlags));
|
||||
}
|
||||
|
||||
public static string Serialize(TrackingRulesConfiguration configuration)
|
||||
{
|
||||
var normalized = new TrackingRulesConfiguration(
|
||||
NormalizeSource(configuration.Source, DefaultSource),
|
||||
MergeMetrics(configuration.ImportantMetrics, DefaultImportantMetrics),
|
||||
MergeMetrics(configuration.OptionalMetrics, DefaultOptionalMetrics),
|
||||
MergeFlags(configuration.Flags, DefaultFlags));
|
||||
|
||||
return JsonSerializer.Serialize(normalized, JsonOptions);
|
||||
}
|
||||
|
||||
public static TrackingMetricRuleSetting FindMetric(IEnumerable<TrackingMetricRuleSetting> rules, string key) =>
|
||||
rules.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
?? DefaultImportantMetrics.Concat(DefaultOptionalMetrics).First(item => item.Key == key);
|
||||
|
||||
public static TrackingFlagRuleSetting FindFlag(IEnumerable<TrackingFlagRuleSetting> flags, string key) =>
|
||||
flags.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
?? DefaultFlags.First(item => item.Key == key);
|
||||
|
||||
public static string NormalizeBaseUrl(string? rawValue)
|
||||
{
|
||||
var trimmed = (rawValue ?? string.Empty).Trim();
|
||||
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
return DefaultBaseUrl;
|
||||
}
|
||||
|
||||
return uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
}
|
||||
|
||||
public static string NormalizeWindowKey(string? value, string fallback)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return SupportedMetricWindows.Contains(normalized, StringComparer.OrdinalIgnoreCase)
|
||||
? normalized
|
||||
: fallback;
|
||||
}
|
||||
|
||||
public static string WindowLabel(string windowKey) =>
|
||||
NormalizeWindowKey(windowKey, Window30d) switch
|
||||
{
|
||||
Window7d => "7 Tage",
|
||||
Window30d => "30 Tage",
|
||||
Window90d => "3 Monate",
|
||||
WindowAllTime => "All Time",
|
||||
_ => "30 Tage",
|
||||
};
|
||||
|
||||
public static bool SupportsAutomaticWindow(TrackingMetricRuleSetting metric) =>
|
||||
metric.ProviderFieldKey is not null
|
||||
&& metric.AutoSupportedWindowKeys.Any(item => string.Equals(item, metric.WindowKey, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static TrackingFlagHit[] ReadFlagHits(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<TrackingFlagHit[]>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public static string SerializeFlagHits(IEnumerable<TrackingFlagHit> flags) =>
|
||||
JsonSerializer.Serialize(flags, JsonOptions);
|
||||
|
||||
private static TrackingRulesConfigurationDto? Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<TrackingRulesConfigurationDto>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static TrackingMetricRuleSetting[] MergeMetrics(
|
||||
IEnumerable<TrackingMetricRuleSetting>? storedRules,
|
||||
IEnumerable<TrackingMetricRuleSetting> defaults) =>
|
||||
defaults
|
||||
.Select(defaultRule =>
|
||||
{
|
||||
var storedRule = storedRules?.FirstOrDefault(item => string.Equals(item.Key, defaultRule.Key, StringComparison.OrdinalIgnoreCase));
|
||||
return NormalizeMetric(storedRule, defaultRule);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
private static TrackingFlagRuleSetting[] MergeFlags(
|
||||
IEnumerable<TrackingFlagRuleSetting>? storedRules,
|
||||
IEnumerable<TrackingFlagRuleSetting> defaults) =>
|
||||
defaults
|
||||
.Select(defaultRule =>
|
||||
{
|
||||
var storedRule = storedRules?.FirstOrDefault(item => string.Equals(item.Key, defaultRule.Key, StringComparison.OrdinalIgnoreCase));
|
||||
return NormalizeFlag(storedRule, defaultRule);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
private static TrackingSourceSetting NormalizeSource(TrackingSourceSetting? stored, TrackingSourceSetting fallback) =>
|
||||
new(
|
||||
fallback.ProviderKey,
|
||||
NormalizeBaseUrl(stored?.BaseUrl ?? fallback.BaseUrl),
|
||||
string.IsNullOrWhiteSpace(stored?.NotesSummary) ? fallback.NotesSummary : stored.NotesSummary.Trim(),
|
||||
stored?.ShowManualReviewNotesInReview ?? fallback.ShowManualReviewNotesInReview);
|
||||
|
||||
private static TrackingMetricRuleSetting NormalizeMetric(TrackingMetricRuleSetting? stored, TrackingMetricRuleSetting fallback)
|
||||
{
|
||||
if (stored is null)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var sourceSupport = stored.SourceSupport.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"auto" => "auto",
|
||||
"manual" => "manual",
|
||||
"context_only" => "context_only",
|
||||
_ => fallback.SourceSupport,
|
||||
};
|
||||
|
||||
var autoSupportedWindowKeys = (stored.AutoSupportedWindowKeys ?? fallback.AutoSupportedWindowKeys)
|
||||
.Select(item => NormalizeWindowKey(item, Window30d))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return fallback with
|
||||
{
|
||||
Enabled = stored.Enabled,
|
||||
SourceSupport = sourceSupport,
|
||||
RequiredForAutoClassification = stored.RequiredForAutoClassification,
|
||||
ShowInReview = stored.ShowInReview,
|
||||
ShowInAdminSummary = stored.ShowInAdminSummary,
|
||||
ManualOverrideAllowed = stored.ManualOverrideAllowed,
|
||||
WindowKey = NormalizeWindowKey(stored.WindowKey, fallback.WindowKey),
|
||||
AutoSupportedWindowKeys = autoSupportedWindowKeys,
|
||||
ProviderFieldKey = string.IsNullOrWhiteSpace(stored.ProviderFieldKey) ? fallback.ProviderFieldKey : stored.ProviderFieldKey.Trim(),
|
||||
TopCount = stored.TopCount,
|
||||
MinPrimaryCategorySharePercent = stored.MinPrimaryCategorySharePercent,
|
||||
MinPrimaryCategoryHours = stored.MinPrimaryCategoryHours,
|
||||
MaxDistinctCategoriesBeforeFlag = stored.MaxDistinctCategoriesBeforeFlag,
|
||||
IgnoredCategories = (stored.IgnoredCategories ?? fallback.IgnoredCategories)
|
||||
.Select(item => item.Trim())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray(),
|
||||
MatchAwardCategoryAgainstTopCategories = stored.MatchAwardCategoryAgainstTopCategories,
|
||||
FlagIfAwardCategoryNotInTopX = stored.FlagIfAwardCategoryNotInTopX,
|
||||
FlagIfCategorySpreadTooWide = stored.FlagIfCategorySpreadTooWide,
|
||||
FlagIfNoCategoryContextAvailable = stored.FlagIfNoCategoryContextAvailable,
|
||||
MinValue = stored.MinValue,
|
||||
MaxValue = stored.MaxValue,
|
||||
};
|
||||
}
|
||||
|
||||
private static TrackingFlagRuleSetting NormalizeFlag(TrackingFlagRuleSetting? stored, TrackingFlagRuleSetting fallback)
|
||||
{
|
||||
if (stored is null)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var severity = stored.Severity.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"high" => "high",
|
||||
"medium" => "medium",
|
||||
"low" => "low",
|
||||
_ => fallback.Severity,
|
||||
};
|
||||
|
||||
return fallback with
|
||||
{
|
||||
Enabled = stored.Enabled,
|
||||
Severity = severity,
|
||||
AutoTriggerEnabled = stored.AutoTriggerEnabled,
|
||||
RequiresManualReview = stored.RequiresManualReview,
|
||||
BlocksApproval = stored.BlocksApproval,
|
||||
AdminNoteRequiredOnOverride = stored.AdminNoteRequiredOnOverride,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record TrackingRulesConfigurationDto(
|
||||
TrackingSourceSetting? Source,
|
||||
TrackingMetricRuleSetting[]? ImportantMetrics,
|
||||
TrackingMetricRuleSetting[]? OptionalMetrics,
|
||||
TrackingFlagRuleSetting[]? Flags);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Backend.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Services;
|
||||
|
||||
public sealed class TwitchTrackerViewerStatsProvider(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AwardsDbContext db,
|
||||
ILogger<TwitchTrackerViewerStatsProvider> logger)
|
||||
: IViewerStatsProvider
|
||||
{
|
||||
public async Task<ViewerStatsSnapshot?> GetChannelSummaryAsync(string twitchLogin, CancellationToken cancellationToken)
|
||||
{
|
||||
var login = twitchLogin.Trim().TrimStart('@').ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(3));
|
||||
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1, timeout.Token);
|
||||
|
||||
var baseUrl = TrackingRulesSettings.NormalizeBaseUrl(settings?.ViewerStatsProviderBaseUrl);
|
||||
var client = httpClientFactory.CreateClient("TwitchTracker");
|
||||
var response = await client.GetFromJsonAsync<TwitchTrackerChannelSummaryResponse>(
|
||||
$"{baseUrl}/channels/summary/{Uri.EscapeDataString(login)}",
|
||||
timeout.Token);
|
||||
|
||||
if (response is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ViewerStatsSnapshot(
|
||||
response.AverageViewers,
|
||||
Math.Max(0, response.MinutesStreamed / 60),
|
||||
response.HoursWatched,
|
||||
response.PeakViewers,
|
||||
response.FollowersGained,
|
||||
TrackingRulesSettings.Window30d);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("TwitchTracker API lookup timed out for {TwitchLogin}.", login);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogInformation(ex, "TwitchTracker API lookup failed for {TwitchLogin}.", login);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TwitchTrackerChannelSummaryResponse
|
||||
{
|
||||
[JsonPropertyName("rank")]
|
||||
public int Rank { get; init; }
|
||||
|
||||
[JsonPropertyName("minutes_streamed")]
|
||||
public int MinutesStreamed { get; init; }
|
||||
|
||||
[JsonPropertyName("avg_viewers")]
|
||||
public int AverageViewers { get; init; }
|
||||
|
||||
[JsonPropertyName("max_viewers")]
|
||||
public int PeakViewers { get; init; }
|
||||
|
||||
[JsonPropertyName("hours_watched")]
|
||||
public int HoursWatched { get; init; }
|
||||
|
||||
[JsonPropertyName("followers")]
|
||||
public int FollowersGained { get; init; }
|
||||
|
||||
[JsonPropertyName("followers_total")]
|
||||
public int FollowersTotal { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Repositories;
|
||||
using Backend.Security;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Backend.Services;
|
||||
|
||||
public sealed class UserSessionService(IUserSessionRepository userSessionRepository) : IUserSessionService
|
||||
public sealed class UserSessionService(IUserSessionRepository userSessionRepository, AwardsDbContext db) : IUserSessionService
|
||||
{
|
||||
private static readonly TimeSpan IdleSessionLifetime = TimeSpan.FromHours(12);
|
||||
public const int MinimumIdleTimeoutHours = 3;
|
||||
public const int DefaultIdleTimeoutHours = 3;
|
||||
private static readonly TimeSpan AbsoluteSessionLifetime = TimeSpan.FromDays(30);
|
||||
|
||||
public async Task<UserSession?> ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default)
|
||||
@@ -27,7 +30,8 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (IsExpired(session, now))
|
||||
var idleSessionLifetime = await ResolveIdleSessionLifetimeAsync(cancellationToken);
|
||||
if (IsExpired(session, now, idleSessionLifetime))
|
||||
{
|
||||
session.IsActive = false;
|
||||
await userSessionRepository.SaveChangesAsync(cancellationToken);
|
||||
@@ -40,6 +44,17 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
|
||||
return session;
|
||||
}
|
||||
|
||||
public async Task<int> GetIdleTimeoutHoursAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var configuredHours = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Id == 1)
|
||||
.Select(item => (int?)item.SessionIdleTimeoutHours)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return NormalizeIdleTimeoutHours(configuredHours);
|
||||
}
|
||||
|
||||
public Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return CreateSessionAsync(
|
||||
@@ -94,9 +109,18 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool IsExpired(UserSession session, DateTimeOffset now) =>
|
||||
private async Task<TimeSpan> ResolveIdleSessionLifetimeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var idleTimeoutHours = await GetIdleTimeoutHoursAsync(cancellationToken);
|
||||
return TimeSpan.FromHours(idleTimeoutHours);
|
||||
}
|
||||
|
||||
public static int NormalizeIdleTimeoutHours(int? configuredHours) =>
|
||||
Math.Max(MinimumIdleTimeoutHours, configuredHours ?? DefaultIdleTimeoutHours);
|
||||
|
||||
private static bool IsExpired(UserSession session, DateTimeOffset now, TimeSpan idleSessionLifetime) =>
|
||||
session.CreatedAt <= now.Subtract(AbsoluteSessionLifetime)
|
||||
|| session.LastSeenAt <= now.Subtract(IdleSessionLifetime);
|
||||
|| session.LastSeenAt <= now.Subtract(idleSessionLifetime);
|
||||
|
||||
private async Task<UserSession> PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ public static class WorkflowRuleSettings
|
||||
public const string MaxCandidateAppearances = "max_candidate_appearances";
|
||||
public const string MaxWinnerPlacements = "max_winner_placements";
|
||||
public const string WinnerRequiresClip = "winner_requires_clip";
|
||||
public const string RecommendedNominatorsPerSubcategory = "recommended_nominators_per_subcategory";
|
||||
|
||||
public static WorkflowRuleSetting[] Defaults { get; } =
|
||||
[
|
||||
@@ -26,11 +27,28 @@ public static class WorkflowRuleSettings
|
||||
new(MaxCandidateAppearances, "Kandidaturen pro Person", true, 2, "warn", "Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht."),
|
||||
new(MaxWinnerPlacements, "Gewinnerplätze pro Person", true, 1, "block", "Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird."),
|
||||
new(WinnerRequiresClip, "Gewinner braucht Clip-Link", true, 1, "block", "Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird."),
|
||||
new(RecommendedNominatorsPerSubcategory, "Empfohlene Nominierer pro Unterkategorie", true, 4, "warn", "Zeigt in der Kategorie-Übersicht an, ab wann eine Unterkategorie nominierungsseitig gut getragen ist. Diese Regel blockiert nichts."),
|
||||
];
|
||||
|
||||
public static WorkflowRuleSetting[] Read(SiteSettings? settings)
|
||||
{
|
||||
var storedRules = Parse(settings?.WorkflowRulesJson);
|
||||
return Read(storedRules);
|
||||
}
|
||||
|
||||
public static WorkflowRuleSetting[] Read(Season? season, SiteSettings? settings)
|
||||
{
|
||||
var seasonRules = Parse(season?.WorkflowRulesJson);
|
||||
if (seasonRules.Length > 0)
|
||||
{
|
||||
return Read(seasonRules);
|
||||
}
|
||||
|
||||
return Read(settings);
|
||||
}
|
||||
|
||||
private static WorkflowRuleSetting[] Read(WorkflowRuleSetting[] storedRules)
|
||||
{
|
||||
return Defaults
|
||||
.Select(defaultRule =>
|
||||
{
|
||||
@@ -98,6 +116,11 @@ public static class WorkflowRuleSettings
|
||||
_ => fallback.Mode,
|
||||
};
|
||||
|
||||
if (string.Equals(fallback.Key, RecommendedNominatorsPerSubcategory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mode = "warn";
|
||||
}
|
||||
|
||||
return rule with
|
||||
{
|
||||
Key = fallback.Key,
|
||||
|
||||
Reference in New Issue
Block a user