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, false, 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 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 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(json, JsonOptions) ?? []) .Select(flag => flag with { BlocksApproval = false }) .ToArray(); } catch (JsonException) { return []; } } public static string SerializeFlagHits(IEnumerable flags) => JsonSerializer.Serialize(flags, JsonOptions); private static TrackingRulesConfigurationDto? Parse(string? json) { if (string.IsNullOrWhiteSpace(json)) { return null; } try { return JsonSerializer.Deserialize(json, JsonOptions); } catch (JsonException) { return null; } } private static TrackingMetricRuleSetting[] MergeMetrics( IEnumerable? storedRules, IEnumerable 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? storedRules, IEnumerable 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 = false, AdminNoteRequiredOnOverride = stored.AdminNoteRequiredOnOverride, }; } private sealed record TrackingRulesConfigurationDto( TrackingSourceSetting? Source, TrackingMetricRuleSetting[]? ImportantMetrics, TrackingMetricRuleSetting[]? OptionalMetrics, TrackingFlagRuleSetting[]? Flags); }