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;
}