using System.Security.Claims; using Flaro.Core.Metadata; using Flaro.Data; using Microsoft.EntityFrameworkCore; namespace Flaro.Api.Endpoints; /// Das eigene Konto — für jeden, nicht nur für Administratoren. /// /// /// /// Eine eigene Gruppe, weil die Rechte andere sind. Alles unter /// /api/v1/users war bisher Verwaltung und damit Administratoren /// vorbehalten. Hier geht es um das eigene Konto: wer angemeldet ist, darf /// sein Bild tauschen, und niemand darf das eines anderen anfassen. Die /// Kennung kommt deshalb aus dem Token und nie aus der Adresse. /// /// /// Es war bisher nicht erreichbar. „Meine Sprache" stand mitten in der /// Verwaltung, und die zeigt der Client nur Administratoren — der Endpunkt /// erlaubte es längst jedem. Wer kein Admin war, konnte seine Sprache also /// nirgends ändern. /// /// public static class ProfilEndpoints { /// Höchstens so gross darf ein Profilbild sein. /// /// /// Fünf Megabyte sind für ein Bild von 200 Pixeln reichlich. Dieselbe /// Grenze wie bei Plakaten wäre hier zu grosszügig: ein Plakat sieht man /// gross, einen Kreis von 28 Pixeln nicht. /// private const long HoechstensBytes = 5 * 1024 * 1024; public static void MapProfil(this IEndpointRouteBuilder app) { var gruppe = app.MapGroup("/api/v1/users/me").RequireAuthorization(); gruppe.MapGet("", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) => { if (Kennung(wer) is not { } id) return Results.Unauthorized(); var benutzer = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id, ct); return benutzer is null ? Results.Unauthorized() : Results.Ok(new AuthenticatedUser( benutzer.Id, benutzer.UserName, benutzer.IsAdministrator, benutzer.Language, benutzer.AvatarImage)); }); gruppe.MapPost("/avatar", async ( HttpRequest anfrage, ClaimsPrincipal wer, FlaroContext db, IImageStore ablage, CancellationToken ct) => { if (Kennung(wer) is not { } id) return Results.Unauthorized(); if (!anfrage.HasFormContentType) return Einwand.Bitte("Kein Formular."); var form = await anfrage.ReadFormAsync(ct); var datei = form.Files.FirstOrDefault(); if (datei is null || datei.Length == 0) return Einwand.Bitte("Keine Datei dabei."); if (datei.Length > HoechstensBytes) return Einwand.Bitte("Höchstens 5 MB."); await using var strom = datei.OpenReadStream(); var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct); if (schluessel is null) return Results.Problem("Das Bild liess sich nicht ablegen.", statusCode: 502); var benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct); if (benutzer is null) return Results.Unauthorized(); benutzer.AvatarImage = schluessel; await db.SaveChangesAsync(ct); return Results.Ok(new { image = schluessel }); }).DisableAntiforgery(); // **Entfernen heisst vergessen, nicht löschen.** Die Datei bleibt in // der Bildablage liegen — dort liegt sie unter dem Hash ihres Inhalts // und könnte an einem anderen Konto hängen. Was dort aufräumt, ist // „Bildspeicher leeren" in der Verwaltung, und das kennt alle Bezüge. gruppe.MapDelete("/avatar", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) => { if (Kennung(wer) is not { } id) return Results.Unauthorized(); var benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct); if (benutzer is null) return Results.Unauthorized(); benutzer.AvatarImage = null; await db.SaveChangesAsync(ct); return Results.NoContent(); }); MapStatistik(gruppe); } /// Was jemand geschaut hat. /// /// /// /// Jeder nur sich selbst, auch ein Administrator. Das ist keine /// Erfindung für diese Seite, sondern die Linie, die im Server schon /// steht: wer wann welchen Film anschaut, ist eine Auskunft über Menschen /// und nicht über den Server. Es gibt deshalb keinen Weg, ein fremdes /// Konto zu meinen — die Kennung kommt aus dem Token. /// /// /// Wochen beginnen am Montag. Nicht am Sonntag und nicht am Tag der /// Anfrage: eine Kurve, deren Balken je nach Abfragezeitpunkt anders /// zusammenfallen, lässt sich mit sich selbst nicht vergleichen. /// /// private static void MapStatistik(RouteGroupBuilder gruppe) { gruppe.MapGet("/statistics", async ( ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, int wochen = 12, CancellationToken ct = default) => { if (Kennung(wer) is not { } id) return Results.Unauthorized(); wochen = Math.Clamp(wochen, 1, 104); var heute = DateOnly.FromDateTime(clock.GetUtcNow().ToLocalTime().DateTime); var ersterMontag = Montag(heute).AddDays(-7 * (wochen - 1)); // ---- Das Protokoll ---- var tage = await db.Sehtage.AsNoTracking() .Where(z => z.UserId == id) .Select(z => new { z.Tag, z.Sekunden, z.MovieId, z.EpisodeId }) .ToListAsync(ct); var gesamt = tage.Sum(z => z.Sekunden); var seit = tage.Count == 0 ? (DateTimeOffset?)null : new DateTimeOffset(tage.Min(z => z.Tag).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); var jeWoche = tage .Where(z => z.Tag >= ersterMontag) .GroupBy(z => Montag(z.Tag)) .ToDictionary(g => g.Key, g => g.Sum(x => x.Sekunden)); var kurve = Enumerable.Range(0, wochen) .Select(n => ersterMontag.AddDays(7 * n)) .Select(montag => new Wochenwert(montag, jeWoche.GetValueOrDefault(montag))) .ToList(); // ---- Was ohne Protokoll vollständig ist ---- var staende = await db.UserItemStates.AsNoTracking() .Where(z => z.UserId == id && (z.MovieId != null || z.EpisodeId != null)) .Select(z => new { z.MovieId, z.EpisodeId, z.Watched, z.LastPlayedAt, Laufzeit = z.Runtime, }) .ToListAsync(ct); var gesehene = staende.Where(z => z.Watched).ToList(); // ---- Zuletzt gesehen ---- var letzte = staende .Where(z => z.LastPlayedAt != null) .OrderByDescending(z => z.LastPlayedAt) .Take(12) .ToList(); var filmIds = letzte.Where(z => z.MovieId != null).Select(z => z.MovieId!.Value).ToList(); var folgenIds = letzte.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList(); var filme = await db.Movies.AsNoTracking().Where(m => filmIds.Contains(m.Id)) .Select(m => new { m.Id, m.Title, m.PosterImage }).ToDictionaryAsync(m => m.Id, ct); var folgen = await db.Episodes.AsNoTracking().Where(f => folgenIds.Contains(f.Id)) .Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new { f.Id, Folgentitel = f.Title, f.SeasonNumber, f.EpisodeNumber, Serientitel = s.Title, s.PosterImage, SerienId = s.Id, }) .ToDictionaryAsync(f => f.Id, ct); var zuletzt = new List(); foreach (var z in letzte) { if (z.MovieId is { } film && filme.TryGetValue(film, out var m)) { zuletzt.Add(new Zuletztgesehen( m.Id, "movie", m.Title, null, m.PosterImage, z.LastPlayedAt!.Value, z.Watched)); } else if (z.EpisodeId is { } folge && folgen.TryGetValue(folge, out var f)) { zuletzt.Add(new Zuletztgesehen( f.SerienId, "episode", f.Serientitel, $"S{f.SeasonNumber} · F{f.EpisodeNumber}" + (f.Folgentitel is { Length: > 0 } t ? $" — {t}" : ""), f.PosterImage, z.LastPlayedAt!.Value, z.Watched)); } } // ---- Genres, aus dem Protokoll ---- var genres = await GenresAus(db, tage.Select(t => (t.MovieId, t.EpisodeId, t.Sekunden)), ct); return Results.Ok(new StatistikResponse( gesamt, gesehene.Count, gesehene.Sum(z => z.Laufzeit?.TotalSeconds ?? 0), seit, kurve, zuletzt, genres)); }); } /// Der Montag der Woche, in der dieser Tag liegt. private static DateOnly Montag(DateOnly tag) => tag.AddDays(-((int)tag.DayOfWeek + 6) % 7); /// Welche Genres die gesehene Zeit ausmachen. /// /// /// Eine Folge hat keine Genres — ihre Serie hat sie. Deshalb wird jede /// Folge erst zu ihrer Serie aufgelöst; sonst stünden Serien gar nicht in /// der Auswertung, obwohl sie den grösseren Teil der Zeit ausmachen. /// private static async Task> GenresAus( FlaroContext db, IEnumerable<(Guid? Film, Guid? Folge, double Sekunden)> zeilen, CancellationToken ct) { var liste = zeilen.ToList(); if (liste.Count == 0) return []; var folgenIds = liste.Where(z => z.Folge != null).Select(z => z.Folge!.Value).Distinct().ToList(); var serieZuFolge = await db.Episodes.AsNoTracking() .Where(f => folgenIds.Contains(f.Id)) .ToDictionaryAsync(f => f.Id, f => f.SeriesId, ct); // Sekunden je Titel zusammenziehen, bevor die Genres dazukommen — // sonst wird derselbe Tag so oft gezählt, wie der Titel Genres hat. var jeFilm = new Dictionary(); var jeSerie = new Dictionary(); foreach (var (film, folge, sekunden) in liste) { if (film is { } f) jeFilm[f] = jeFilm.GetValueOrDefault(f) + sekunden; else if (folge is { } e && serieZuFolge.TryGetValue(e, out var serie)) jeSerie[serie] = jeSerie.GetValueOrDefault(serie) + sekunden; } var filmIds = jeFilm.Keys.ToList(); var serienIds = jeSerie.Keys.ToList(); var verweise = await db.GenreLinks.AsNoTracking() .Where(l => (l.MovieId != null && filmIds.Contains(l.MovieId!.Value)) || (l.SeriesId != null && serienIds.Contains(l.SeriesId!.Value))) .Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id, (l, g) => new { l.MovieId, l.SeriesId, g.Name }) .ToListAsync(ct); var summen = new Dictionary(); foreach (var v in verweise) { var sekunden = v.MovieId is { } f ? jeFilm.GetValueOrDefault(f) : v.SeriesId is { } s ? jeSerie.GetValueOrDefault(s) : 0; if (sekunden <= 0) continue; summen[v.Name] = summen.GetValueOrDefault(v.Name) + sekunden; } return summen.OrderByDescending(p => p.Value).Take(6) .Select(p => new Genrewert(p.Key, p.Value)).ToList(); } /// Wer anfragt — aus dem Token, nie aus der Adresse. private static Guid? Kennung(ClaimsPrincipal wer) => Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; }