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>
555 lines
23 KiB
C#
555 lines
23 KiB
C#
using System.Text.Json;
|
|
using Backend.Domain;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Backend.Data;
|
|
|
|
public static partial class SeedDataBootstrapper
|
|
{
|
|
private static async Task EnsureSeedOperationalDataAsync(AwardsDbContext db, Season season)
|
|
{
|
|
var categories = await db.Categories
|
|
.Where(item => item.SeasonId == season.Id)
|
|
.ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase);
|
|
var candidates = await db.Candidates
|
|
.Where(item => item.SeasonId == season.Id)
|
|
.ToArrayAsync();
|
|
|
|
var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db);
|
|
await EnsureSeedReviewNominationsAsync(db, season, categories, candidates);
|
|
|
|
if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id))
|
|
{
|
|
db.ClipSubmissions.AddRange(
|
|
new ClipSubmission
|
|
{
|
|
SeasonId = season.Id,
|
|
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"),
|
|
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Hoshimi Miyu"),
|
|
SubmittedByTwitchId = "local_user_3",
|
|
ClipUrl = "https://clips.twitch.tv/StarlitDebutMoment",
|
|
Title = "Starlight Debut Moment",
|
|
Creator = "Hoshimi Miyu",
|
|
Platform = "Twitch",
|
|
Status = "approved",
|
|
ReviewNote = "Geprüfter Clip für Voting-Vorschau.",
|
|
ReviewedByTwitchId = "jayuhime_admin",
|
|
CreatedFromIp = "127.0.0.1",
|
|
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero),
|
|
ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 5, 0, TimeSpan.Zero),
|
|
},
|
|
new ClipSubmission
|
|
{
|
|
SeasonId = season.Id,
|
|
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"),
|
|
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Kurainu"),
|
|
SubmittedByTwitchId = "local_user_4",
|
|
ClipUrl = "https://clips.twitch.tv/KurainuFinaleHype",
|
|
Title = "Finale-Hype mit Chat-Chaos",
|
|
Creator = "Kurainu",
|
|
Platform = "Twitch",
|
|
Status = "approved",
|
|
ReviewNote = "Geprüfter Clip für Voting-Vorschau.",
|
|
ReviewedByTwitchId = "jayuhime_admin",
|
|
CreatedFromIp = "127.0.0.1",
|
|
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero),
|
|
ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 10, 0, TimeSpan.Zero),
|
|
},
|
|
new ClipSubmission
|
|
{
|
|
SeasonId = season.Id,
|
|
CategoryId = ResolveCategoryId(categories, "best-gaming-shining-star"),
|
|
CandidateId = ResolveCandidateId(categories, candidates, "best-gaming-shining-star", "Kurainu"),
|
|
SubmittedByTwitchId = "local_user",
|
|
ClipUrl = "https://clips.twitch.tv/EpicGamingMoment",
|
|
Title = "Epischer Clutch im Finale",
|
|
Creator = "Kurainu",
|
|
Platform = "Twitch",
|
|
Status = "pending",
|
|
CreatedFromIp = "127.0.0.1",
|
|
CreatedAt = new DateTimeOffset(2026, 6, 17, 9, 10, 0, TimeSpan.Zero),
|
|
},
|
|
new ClipSubmission
|
|
{
|
|
SeasonId = season.Id,
|
|
CategoryId = ResolveCategoryId(categories, "gesang-musik-shining-star"),
|
|
CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik-shining-star", "Melo Diva"),
|
|
SubmittedByTwitchId = "local_user_2",
|
|
ClipUrl = "https://www.youtube.com/watch?v=liveCoverMoment",
|
|
Title = "Live-Cover mit Gänsehaut",
|
|
Creator = "Melo Diva",
|
|
Platform = "YouTube",
|
|
Status = "approved",
|
|
ReviewNote = "Geprüfter Clip für Review-Workflow.",
|
|
ReviewedByTwitchId = "jayuhime_admin",
|
|
CreatedFromIp = "127.0.0.1",
|
|
CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero),
|
|
ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 0, 0, TimeSpan.Zero),
|
|
});
|
|
}
|
|
|
|
if (!normalizedLegacyState.HasRiskSeed && !await db.RiskFlags.AnyAsync(item => item.Source == "seed"))
|
|
{
|
|
db.RiskFlags.Add(new RiskFlag
|
|
{
|
|
SeasonId = season.Id,
|
|
TwitchUserId = "sample_user",
|
|
Source = "seed",
|
|
Type = "rapid_vote_updates",
|
|
Severity = "medium",
|
|
Status = "open",
|
|
Summary = "Mehrere Voting-Aenderungen in kurzer Zeit erkannt.",
|
|
CreatedFromIp = "127.0.0.1",
|
|
UserAgent = "seed-bootstrap",
|
|
MetadataJson = JsonSerializer.Serialize(new { recentVoteSubmissions = 3 }),
|
|
CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 40, 0, TimeSpan.Zero),
|
|
});
|
|
}
|
|
|
|
if (!normalizedLegacyState.HasAuditSeed && !await db.AdminAuditEntries.AnyAsync(item => item.ActionType == "seed.initialize"))
|
|
{
|
|
db.AdminAuditEntries.Add(new AdminAuditEntry
|
|
{
|
|
AdminTwitchUserId = "system",
|
|
ActionType = "seed.initialize",
|
|
EntityType = "database",
|
|
EntityId = season.Year.ToString(),
|
|
Summary = "Startinhalte wurden in der Datenbank bereitgestellt.",
|
|
MetadataJson = JsonSerializer.Serialize(new { awardCategories = SeedCatalog.AwardCategorySeeds.Length, subcategories = SeedCatalog.DefaultSubcategoryTemplates.Length }),
|
|
CreatedFromIp = "seed",
|
|
UserAgent = "seed-bootstrap",
|
|
CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 32, 0, TimeSpan.Zero),
|
|
});
|
|
}
|
|
}
|
|
|
|
private static int? ResolveCategoryId(IReadOnlyDictionary<string, Category> categories, string slug) =>
|
|
categories.TryGetValue(slug, out var category) ? category.Id : null;
|
|
|
|
private static int? ResolveCandidateId(
|
|
IReadOnlyDictionary<string, Category> categories,
|
|
IEnumerable<Candidate> candidates,
|
|
string categorySlug,
|
|
string displayName)
|
|
{
|
|
var categoryId = ResolveCategoryId(categories, categorySlug);
|
|
return categoryId is int resolvedCategoryId
|
|
? candidates.FirstOrDefault(item =>
|
|
item.CategoryId == resolvedCategoryId
|
|
&& string.Equals(item.DisplayName, displayName, StringComparison.OrdinalIgnoreCase))?.Id
|
|
: null;
|
|
}
|
|
|
|
private static async Task<LegacySeedState> NormalizeLegacyDemoLabelsAsync(AwardsDbContext db)
|
|
{
|
|
var legacySessions = await db.UserSessions
|
|
.Where(item => item.TwitchUserId == "admin_demo" || item.TwitchUserId == "jayuhime_demo" || item.TwitchUserId == "demo_user")
|
|
.ToArrayAsync();
|
|
|
|
foreach (var session in legacySessions)
|
|
{
|
|
session.TwitchUserId = session.TwitchUserId switch
|
|
{
|
|
"admin_demo" => "jayuhime_admin",
|
|
"jayuhime_demo" => "jayuhime_viewer",
|
|
"demo_user" => "local_user",
|
|
_ => session.TwitchUserId,
|
|
};
|
|
session.DisplayName = session.DisplayName switch
|
|
{
|
|
"Admin Demo" => "Jayuhime Admin",
|
|
"Demo User" => "Local User",
|
|
_ => session.DisplayName,
|
|
};
|
|
}
|
|
|
|
var legacyClipSubmissions = await db.ClipSubmissions
|
|
.Where(item =>
|
|
item.SubmittedByTwitchId == "demo_user" ||
|
|
item.SubmittedByTwitchId == "demo_user_2" ||
|
|
item.ClipUrl.Contains("Demo") ||
|
|
item.ClipUrl.Contains("demo") ||
|
|
(item.ReviewNote != null && item.ReviewNote.Contains("Demo-Clip")))
|
|
.ToArrayAsync();
|
|
|
|
foreach (var clip in legacyClipSubmissions)
|
|
{
|
|
clip.SubmittedByTwitchId = clip.SubmittedByTwitchId switch
|
|
{
|
|
"demo_user" => "local_user",
|
|
"demo_user_2" => "local_user_2",
|
|
_ => clip.SubmittedByTwitchId,
|
|
};
|
|
clip.ClipUrl = clip.ClipUrl
|
|
.Replace("DemoGamingMoment", "EpicGamingMoment")
|
|
.Replace("demoSong", "liveCoverMoment");
|
|
clip.ReviewNote = clip.ReviewNote?.Replace("Demo-Clip", "Geprüfter Clip");
|
|
}
|
|
await LinkExistingClipsToCandidatesAsync(db);
|
|
|
|
var legacyRiskFlags = await db.RiskFlags
|
|
.Where(item =>
|
|
item.Source == "demo" ||
|
|
item.Summary.StartsWith("Demo:") ||
|
|
item.TwitchUserId == "jayuhime_demo" ||
|
|
item.TwitchUserId == "demo_user")
|
|
.ToArrayAsync();
|
|
|
|
foreach (var flag in legacyRiskFlags)
|
|
{
|
|
flag.Source = "seed";
|
|
flag.TwitchUserId = flag.TwitchUserId switch
|
|
{
|
|
"demo_user" => "local_user",
|
|
"jayuhime_demo" => "jayuhime_viewer",
|
|
_ => flag.TwitchUserId,
|
|
};
|
|
flag.Summary = flag.Summary.Replace("Demo: ", string.Empty);
|
|
flag.UserAgent = flag.UserAgent == "demo-seed" ? "seed-bootstrap" : flag.UserAgent;
|
|
}
|
|
|
|
var legacyAuditEntries = await db.AdminAuditEntries
|
|
.Where(item =>
|
|
item.ActionType == "demo.seed" ||
|
|
item.Summary.Contains("Demo-Inhalte") ||
|
|
item.AdminTwitchUserId == "admin_demo" ||
|
|
item.AdminTwitchUserId == "jayuhime_demo")
|
|
.ToArrayAsync();
|
|
|
|
foreach (var entry in legacyAuditEntries)
|
|
{
|
|
entry.AdminTwitchUserId = entry.AdminTwitchUserId switch
|
|
{
|
|
"admin_demo" => "jayuhime_admin",
|
|
"jayuhime_demo" => "jayuhime_viewer",
|
|
_ => entry.AdminTwitchUserId,
|
|
};
|
|
if (entry.ActionType == "demo.seed")
|
|
{
|
|
entry.ActionType = "seed.initialize";
|
|
}
|
|
if (entry.Summary.Contains("Demo-Inhalte"))
|
|
{
|
|
entry.Summary = "Startinhalte wurden in der Datenbank bereitgestellt.";
|
|
}
|
|
}
|
|
|
|
return new LegacySeedState(
|
|
legacyRiskFlags.Length > 0 || await db.RiskFlags.AnyAsync(item => item.Source == "seed"),
|
|
legacyAuditEntries.Length > 0 || await db.AdminAuditEntries.AnyAsync(item => item.ActionType == "seed.initialize"));
|
|
}
|
|
|
|
private static async Task LinkExistingClipsToCandidatesAsync(AwardsDbContext db)
|
|
{
|
|
var clips = await db.ClipSubmissions
|
|
.Where(item => item.CandidateId == null && item.CategoryId != null && item.Creator != string.Empty)
|
|
.ToArrayAsync();
|
|
if (clips.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var seasonIds = clips.Select(item => item.SeasonId).Distinct().ToArray();
|
|
var categoryIds = clips.Select(item => item.CategoryId!.Value).Distinct().ToArray();
|
|
var candidates = await db.Candidates
|
|
.Where(item => seasonIds.Contains(item.SeasonId) && categoryIds.Contains(item.CategoryId))
|
|
.ToArrayAsync();
|
|
|
|
foreach (var clip in clips)
|
|
{
|
|
var creatorKey = NormalizeSeedCandidateKey(clip.Creator);
|
|
var candidate = candidates.FirstOrDefault(item =>
|
|
item.SeasonId == clip.SeasonId
|
|
&& item.CategoryId == clip.CategoryId
|
|
&& (NormalizeSeedCandidateKey(item.DisplayName) == creatorKey
|
|
|| NormalizeSeedCandidateKey(item.ChannelSlug) == creatorKey));
|
|
|
|
if (candidate is not null)
|
|
{
|
|
clip.CandidateId = candidate.Id;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string NormalizeSeedCandidateKey(string value) =>
|
|
new(
|
|
value
|
|
.Trim()
|
|
.TrimStart('@')
|
|
.ToLowerInvariant()
|
|
.Where(char.IsLetterOrDigit)
|
|
.ToArray());
|
|
|
|
private static async Task EnsureSeedReviewNominationsAsync(
|
|
AwardsDbContext db,
|
|
Season season,
|
|
IReadOnlyDictionary<string, Category> categories,
|
|
Candidate[] candidates)
|
|
{
|
|
var staleSeedNominations = await db.Nominations
|
|
.Where(item =>
|
|
item.SeasonId == season.Id
|
|
&& (
|
|
item.SubmittedByTwitchId.StartsWith("seed_review_")
|
|
|| item.SubmittedByTwitchId == "twitch_hoshi"
|
|
|| item.SubmittedByTwitchId == "twitch_kurainu"
|
|
|| item.SubmittedByTwitchId.StartsWith("demo_user")
|
|
|| item.SubmittedByTwitchId.StartsWith("local_user")
|
|
))
|
|
.ToArrayAsync();
|
|
|
|
if (staleSeedNominations.Length > 0)
|
|
{
|
|
db.Nominations.RemoveRange(staleSeedNominations);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var orderedCategories = categories.Values
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.Name)
|
|
.ToArray();
|
|
var candidatesByCategoryId = candidates
|
|
.GroupBy(item => item.CategoryId)
|
|
.ToDictionary(
|
|
grouping => grouping.Key,
|
|
grouping => grouping.OrderBy(item => item.DisplayName).ToArray());
|
|
|
|
var seedNominations = new List<Nomination>();
|
|
var createdAt = new DateTimeOffset(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
foreach (var category in orderedCategories)
|
|
{
|
|
candidatesByCategoryId.TryGetValue(category.Id, out var categoryCandidates);
|
|
var existingCandidate = categoryCandidates?.FirstOrDefault();
|
|
|
|
seedNominations.AddRange(BuildPendingSeedGroup(
|
|
category,
|
|
existingCandidate,
|
|
groupKey: "existing",
|
|
firstSubmitter: $"seed_review_{category.Slug}_existing_a",
|
|
secondSubmitter: $"seed_review_{category.Slug}_existing_b",
|
|
createdAt,
|
|
useSuggestedCategory: true,
|
|
trackerStatus: "resolved"));
|
|
createdAt = createdAt.AddMinutes(8);
|
|
|
|
seedNominations.AddRange(BuildPendingSeedGroup(
|
|
category,
|
|
existingCandidate: null,
|
|
groupKey: "fresh",
|
|
firstSubmitter: $"seed_review_{category.Slug}_fresh_a",
|
|
secondSubmitter: $"seed_review_{category.Slug}_fresh_b",
|
|
createdAt,
|
|
useSuggestedCategory: false,
|
|
trackerStatus: "unsupported_platform"));
|
|
createdAt = createdAt.AddMinutes(8);
|
|
}
|
|
|
|
foreach (var category in orderedCategories.Take(2))
|
|
{
|
|
seedNominations.AddRange(BuildReviewedSeedGroup(
|
|
category,
|
|
status: "rejected",
|
|
displayName: $"{category.GroupName} Review Return",
|
|
submittedByPrefix: $"seed_review_{category.Slug}_rejected",
|
|
createdAt,
|
|
reviewedByTwitchId: "jayuhime_admin",
|
|
candidateId: null,
|
|
candidateDisplayName: null));
|
|
createdAt = createdAt.AddMinutes(10);
|
|
}
|
|
|
|
foreach (var category in orderedCategories.Skip(2).Take(2))
|
|
{
|
|
candidatesByCategoryId.TryGetValue(category.Id, out var categoryCandidates);
|
|
var candidate = categoryCandidates?.FirstOrDefault();
|
|
seedNominations.AddRange(BuildReviewedSeedGroup(
|
|
category,
|
|
status: "approved",
|
|
displayName: candidate?.DisplayName ?? $"{category.GroupName} Approved Pick",
|
|
submittedByPrefix: $"seed_review_{category.Slug}_approved",
|
|
createdAt,
|
|
reviewedByTwitchId: "jayuhime_admin",
|
|
candidateId: candidate?.Id,
|
|
candidateDisplayName: candidate?.DisplayName));
|
|
createdAt = createdAt.AddMinutes(10);
|
|
}
|
|
|
|
db.Nominations.AddRange(seedNominations);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private static IEnumerable<Nomination> BuildPendingSeedGroup(
|
|
Category category,
|
|
Candidate? existingCandidate,
|
|
string groupKey,
|
|
string firstSubmitter,
|
|
string secondSubmitter,
|
|
DateTimeOffset createdAt,
|
|
bool useSuggestedCategory,
|
|
string trackerStatus)
|
|
{
|
|
var displayName = existingCandidate?.DisplayName ?? BuildFreshSeedName(category, groupKey);
|
|
var platform = existingCandidate?.Platform ?? "YouTube";
|
|
var channelSlug = existingCandidate?.ChannelSlug ?? BuildSeedChannelSlug(category, groupKey);
|
|
var streamUrl = BuildSeedStreamUrl(platform, channelSlug);
|
|
var avgViewers = ResolveSeedViewerValue(category);
|
|
int? suggestedCategoryId = useSuggestedCategory ? category.Id : null;
|
|
|
|
yield return new Nomination
|
|
{
|
|
SeasonId = category.SeasonId,
|
|
CategoryId = category.Id,
|
|
CategoryGroupName = category.GroupName,
|
|
SubmittedByTwitchId = firstSubmitter,
|
|
CandidateText = displayName,
|
|
StreamUrl = streamUrl,
|
|
ResolvedChannel = channelSlug.TrimStart('@'),
|
|
ResolvedPlatform = platform,
|
|
AvgViewers = avgViewers,
|
|
SuggestedCategoryId = suggestedCategoryId,
|
|
TrackerStatus = trackerStatus,
|
|
TrackerCheckedAt = createdAt.AddMinutes(2),
|
|
TrackingReviewStatus = "clear",
|
|
Status = "pending",
|
|
CreatedAt = createdAt,
|
|
};
|
|
|
|
yield return new Nomination
|
|
{
|
|
SeasonId = category.SeasonId,
|
|
CategoryId = category.Id,
|
|
CategoryGroupName = category.GroupName,
|
|
SubmittedByTwitchId = secondSubmitter,
|
|
CandidateText = displayName,
|
|
StreamUrl = streamUrl,
|
|
ResolvedChannel = channelSlug.TrimStart('@'),
|
|
ResolvedPlatform = platform,
|
|
AvgViewers = avgViewers,
|
|
SuggestedCategoryId = suggestedCategoryId,
|
|
TrackerStatus = trackerStatus,
|
|
TrackerCheckedAt = createdAt.AddMinutes(3),
|
|
TrackingReviewStatus = "clear",
|
|
Status = "pending",
|
|
CreatedAt = createdAt.AddMinutes(1),
|
|
};
|
|
}
|
|
|
|
private static IEnumerable<Nomination> BuildReviewedSeedGroup(
|
|
Category category,
|
|
string status,
|
|
string displayName,
|
|
string submittedByPrefix,
|
|
DateTimeOffset createdAt,
|
|
string reviewedByTwitchId,
|
|
int? candidateId,
|
|
string? candidateDisplayName)
|
|
{
|
|
var channelSlug = BuildSeedChannelSlug(category, $"{status}_{displayName}");
|
|
var streamUrl = BuildSeedStreamUrl("Twitch", channelSlug);
|
|
var reviewNote = status == "approved"
|
|
? "Seed-Datensatz: bereits als Kandidat übernommen."
|
|
: "Seed-Datensatz: bewusst verworfen für Undo-Tests.";
|
|
|
|
yield return new Nomination
|
|
{
|
|
SeasonId = category.SeasonId,
|
|
CategoryId = category.Id,
|
|
CategoryGroupName = category.GroupName,
|
|
SubmittedByTwitchId = $"{submittedByPrefix}_a",
|
|
CandidateId = candidateId,
|
|
CandidateText = displayName,
|
|
StreamUrl = streamUrl,
|
|
ResolvedChannel = channelSlug.TrimStart('@'),
|
|
ResolvedPlatform = "Twitch",
|
|
AvgViewers = ResolveSeedViewerValue(category),
|
|
SuggestedCategoryId = category.Id,
|
|
TrackerStatus = "resolved",
|
|
TrackerCheckedAt = createdAt.AddMinutes(2),
|
|
TrackingReviewStatus = "reviewed",
|
|
TrackingReviewNote = reviewNote,
|
|
TrackingReviewedByTwitchId = reviewedByTwitchId,
|
|
TrackingReviewedAt = createdAt.AddMinutes(4),
|
|
Status = status,
|
|
ReviewNote = reviewNote,
|
|
ReviewedByTwitchId = reviewedByTwitchId,
|
|
CreatedAt = createdAt,
|
|
ReviewedAt = createdAt.AddMinutes(4),
|
|
};
|
|
|
|
yield return new Nomination
|
|
{
|
|
SeasonId = category.SeasonId,
|
|
CategoryId = category.Id,
|
|
CategoryGroupName = category.GroupName,
|
|
SubmittedByTwitchId = $"{submittedByPrefix}_b",
|
|
CandidateId = candidateId,
|
|
CandidateText = displayName,
|
|
StreamUrl = streamUrl,
|
|
ResolvedChannel = channelSlug.TrimStart('@'),
|
|
ResolvedPlatform = "Twitch",
|
|
AvgViewers = ResolveSeedViewerValue(category),
|
|
SuggestedCategoryId = category.Id,
|
|
TrackerStatus = "resolved",
|
|
TrackerCheckedAt = createdAt.AddMinutes(3),
|
|
TrackingReviewStatus = "reviewed",
|
|
TrackingReviewNote = reviewNote,
|
|
TrackingReviewedByTwitchId = reviewedByTwitchId,
|
|
TrackingReviewedAt = createdAt.AddMinutes(5),
|
|
Status = status,
|
|
ReviewNote = reviewNote,
|
|
ReviewedByTwitchId = reviewedByTwitchId,
|
|
CreatedAt = createdAt.AddMinutes(1),
|
|
ReviewedAt = createdAt.AddMinutes(5),
|
|
};
|
|
}
|
|
|
|
private static string BuildFreshSeedName(Category category, string groupKey) =>
|
|
groupKey switch
|
|
{
|
|
"fresh" => $"{category.GroupName} Spotlight {category.Name}",
|
|
_ => $"{category.GroupName} {category.Name} Pick",
|
|
};
|
|
|
|
private static string BuildSeedChannelSlug(Category category, string suffix)
|
|
{
|
|
var raw = $"{category.Slug}-{suffix}"
|
|
.Trim()
|
|
.TrimStart('@')
|
|
.ToLowerInvariant();
|
|
|
|
return new string(raw.Where(char.IsLetterOrDigit).ToArray());
|
|
}
|
|
|
|
private static string BuildSeedStreamUrl(string platform, string channelSlug) =>
|
|
platform.Trim().ToLowerInvariant() switch
|
|
{
|
|
"youtube" => $"https://www.youtube.com/@{channelSlug}",
|
|
"kick" => $"https://kick.com/{channelSlug}",
|
|
"cake" => $"https://cake.gg/{channelSlug}",
|
|
_ => $"https://www.twitch.tv/{channelSlug}",
|
|
};
|
|
|
|
private static int ResolveSeedViewerValue(Category category)
|
|
{
|
|
if (category.ViewerRangeMin is int min && category.ViewerRangeMax is int max)
|
|
{
|
|
return min + ((max - min) / 2);
|
|
}
|
|
|
|
if (category.ViewerRangeMin is int lowerBound)
|
|
{
|
|
return lowerBound + 12;
|
|
}
|
|
|
|
if (category.ViewerRangeMax is int upperBound)
|
|
{
|
|
return Math.Max(1, upperBound - 5);
|
|
}
|
|
|
|
return 25;
|
|
}
|
|
|
|
private sealed record LegacySeedState(bool HasRiskSeed, bool HasAuditSeed);
|
|
}
|