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>
347 lines
16 KiB
C#
347 lines
16 KiB
C#
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);
|
|
}
|