using Fragalytics.Web.Infrastructure; using Fragalytics.Web.Models; using Microsoft.AspNetCore.StaticFiles; namespace Fragalytics.Web.Services; public sealed class QueryService { private readonly JsonAppStore _store; public QueryService(JsonAppStore store) { _store = store; } public Task GetSnapshotAsync(CancellationToken cancellationToken = default) => _store.GetSnapshotAsync(cancellationToken); public async Task GetDashboardAsync(CancellationToken cancellationToken = default) { var state = await _store.GetSnapshotAsync(cancellationToken); return new DashboardViewModel { DemoCount = state.Demos.Count, MatchCount = state.Matches.Count, HighlightCount = state.Highlights.Count, ClipJobCount = state.ClipJobs.Count, Demos = state.Demos.OrderByDescending(item => item.UploadedAtUtc).Take(20).ToList(), ClipJobs = state.ClipJobs.OrderByDescending(item => item.CreatedAtUtc).Take(12).ToList() }; } public async Task GetDemoDetailsAsync(int id, CancellationToken cancellationToken = default) { var state = await _store.GetSnapshotAsync(cancellationToken); var demo = state.Demos.FirstOrDefault(item => item.Id == id); if (demo is null) { return null; } MatchRecord? match = null; List highlights = []; if (demo.MatchId is int matchId) { match = state.Matches.FirstOrDefault(item => item.Id == matchId); highlights = BuildHighlightRows(state, matchId).OrderByDescending(item => item.Highlight.Score).ToList(); } return new DemoDetailsViewModel { Demo = demo, Match = match, Highlights = highlights }; } public async Task GetMatchDetailsAsync(int matchId, CancellationToken cancellationToken = default) { var state = await _store.GetSnapshotAsync(cancellationToken); var match = state.Matches.FirstOrDefault(item => item.Id == matchId); if (match is null) { return null; } return new MatchDetailsViewModel { Match = match, Players = state.Players.Where(item => item.MatchId == matchId).OrderBy(item => item.Name).ToList(), Rounds = state.Rounds.Where(item => item.MatchId == matchId).OrderBy(item => item.Number).ToList(), Highlights = BuildHighlightRows(state, matchId).OrderByDescending(item => item.Highlight.Score).ToList() }; } public async Task> GetClipJobsAsync(CancellationToken cancellationToken = default) { var state = await _store.GetSnapshotAsync(cancellationToken); return state.ClipJobs .OrderByDescending(item => item.CreatedAtUtc) .Select(job => new ClipJobWithHighlightViewModel { ClipJob = job, Highlight = state.Highlights.FirstOrDefault(highlight => highlight.Id == job.HighlightId), ClipAsset = state.ClipAssets.FirstOrDefault(asset => asset.Id == job.ClipAssetId) }) .ToList(); } public async Task GetClipAssetAsync(int clipJobId, CancellationToken cancellationToken = default) { var state = await _store.GetSnapshotAsync(cancellationToken); var clipJob = state.ClipJobs.FirstOrDefault(item => item.Id == clipJobId); if (clipJob?.ClipAssetId is not int assetId) { return null; } var asset = state.ClipAssets.FirstOrDefault(item => item.Id == assetId); if (asset is null || !File.Exists(asset.StoredPath)) { return null; } var provider = new FileExtensionContentTypeProvider(); var contentType = asset.ContentType; if (string.IsNullOrWhiteSpace(contentType) && !provider.TryGetContentType(asset.FileName, out contentType)) { contentType = "application/octet-stream"; } return new ClipAssetDownloadViewModel { Asset = asset, ContentType = contentType }; } private static IEnumerable BuildHighlightRows(AppState state, int matchId) { var playersById = state.Players.Where(player => player.MatchId == matchId).ToDictionary(player => player.Id); var roundsById = state.Rounds.Where(round => round.MatchId == matchId).ToDictionary(round => round.Id); var eventsById = state.Events.Where(@event => @event.MatchId == matchId).ToDictionary(@event => @event.Id); var clipJobsByHighlightId = state.ClipJobs.ToDictionary(job => job.HighlightId); var clipAssetsById = state.ClipAssets.ToDictionary(asset => asset.Id); foreach (var highlight in state.Highlights.Where(item => item.MatchId == matchId)) { eventsById.TryGetValue(highlight.SourceEventId, out var sourceEvent); if (sourceEvent is null) { continue; } PlayerRecord? actor = null; PlayerRecord? target = null; RoundRecord? round = null; if (sourceEvent.ActorPlayerId is int actorId) { playersById.TryGetValue(actorId, out actor); } if (sourceEvent.TargetPlayerId is int targetId) { playersById.TryGetValue(targetId, out target); } if (sourceEvent.RoundId is int roundId) { roundsById.TryGetValue(roundId, out round); } yield return new HighlightDetailsRow { Highlight = highlight, Event = sourceEvent, Actor = actor, Target = target, Round = round, ClipJob = clipJobsByHighlightId.GetValueOrDefault(highlight.Id), ClipAsset = clipJobsByHighlightId.TryGetValue(highlight.Id, out var clipJob) && clipJob.ClipAssetId is int assetId ? clipAssetsById.GetValueOrDefault(assetId) : null }; } } } public sealed class DashboardViewModel { public int DemoCount { get; set; } public int MatchCount { get; set; } public int HighlightCount { get; set; } public int ClipJobCount { get; set; } public List Demos { get; set; } = []; public List ClipJobs { get; set; } = []; } public sealed class DemoDetailsViewModel { public DemoRecord Demo { get; set; } = new(); public MatchRecord? Match { get; set; } public List Highlights { get; set; } = []; } public sealed class MatchDetailsViewModel { public MatchRecord Match { get; set; } = new(); public List Players { get; set; } = []; public List Rounds { get; set; } = []; public List Highlights { get; set; } = []; } public sealed class HighlightDetailsRow { public HighlightRecord Highlight { get; set; } = new(); public EventRecord Event { get; set; } = new(); public PlayerRecord? Actor { get; set; } public PlayerRecord? Target { get; set; } public RoundRecord? Round { get; set; } public ClipJobRecord? ClipJob { get; set; } public ClipAssetRecord? ClipAsset { get; set; } } public sealed class ClipJobWithHighlightViewModel { public ClipJobRecord ClipJob { get; set; } = new(); public HighlightRecord? Highlight { get; set; } public ClipAssetRecord? ClipAsset { get; set; } } public sealed class ClipAssetDownloadViewModel { public ClipAssetRecord Asset { get; set; } = new(); public string ContentType { get; set; } = "application/octet-stream"; }