Spider-Man ist nicht 720p, er ist breit
Die Auflösung wurde allein aus der Höhe gebildet. Ein Kinoformat von 2.39:1 liegt als 1920×800 vor — nach der Höhe gerechnet kam „720p" heraus, und das stand so in der Oberfläche. Jetzt zählt die grössere von Breite und Höhe; Hochformat kippt damit nicht, und 4K-Breitbild heisst nicht mehr 1440p. Die Detailansicht hat zwei Leserschaften, und bisher bekamen beide dasselbe. Wer schauen will, sieht jetzt Laufzeit, Auflösung und die Sprachen — jede einmal, statt „Englisch · Englisch · Englisch · Englisch · Deutsch". Wer die Mediathek betreut, sieht darunter Dateiformat, Grösse und den Ordner. Der Ordner verrät eine falsche Zuordnung in einer Sekunde: steht über der Beschreibung „Hanni & Nanni 3" und der Ordner heisst „Hanni und Nanni", ist die Sache klar. Er kommt nur für Administratoren überhaupt vom Server — versteckt im Client wäre er weiterhin auslesbar. Ein geplanter Titel war unsichtbar. Das ist die Hälfte, die nichts bringt: dass er kommt, ist der Sinn des Planens. Er steht jetzt mit „Verfügbar ab …" in der Mediathek, und die Datei geht trotzdem nicht heraus — Download wie Strom lehnen bis zum Termin ab, auch für Administratoren. Wer vorziehen will, hat „Jetzt veröffentlichen"; eine Hintertür für eine Rolle wäre eine Regel, die manchmal gilt. Was ein Auffrischen geändert hat, steht jetzt unter „Zu prüfen" — in einer eigenen Gruppe mit „Zur Kenntnis" statt „Veröffentlichen". Diese Titel sind sichtbar; es gibt nichts freizugeben, nur etwas abzuhaken. Und das Protokoll sagt endlich, wo es knallt. „InvalidOperationException: Nullable object must have a value." ohne eine einzige Zeile Herkunft ist für niemanden eine Diagnose — weder für den Betreiber noch für mich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f44779c899
commit
255eb7c2f0
@@ -194,7 +194,19 @@ public sealed record Pruefeintrag(
|
|||||||
/// <summary>Wie viele Titel dahinterstecken — bei einer Serie die Zahl der Folgen.</summary>
|
/// <summary>Wie viele Titel dahinterstecken — bei einer Serie die Zahl der Folgen.</summary>
|
||||||
int Count,
|
int Count,
|
||||||
/// <summary>Worauf eine Freigabe wirkt.</summary>
|
/// <summary>Worauf eine Freigabe wirkt.</summary>
|
||||||
IReadOnlyList<Guid> Ids);
|
IReadOnlyList<Guid> Ids,
|
||||||
|
|
||||||
|
/// <summary>Warum der Eintrag hier steht: <c>neu</c> oder <c>geaendert</c>.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Zwei verschiedene Dinge in einer Liste, und das mit Absicht.</b> Ein
|
||||||
|
/// neuer Fund wartet auf Freigabe und ist bis dahin unsichtbar. Ein
|
||||||
|
/// geänderter Titel steht längst in der Mediathek — er soll nur nicht
|
||||||
|
/// unbemerkt anders heissen oder ein anderes Plakat tragen. Beides gehört
|
||||||
|
/// vor dieselben Augen, aber nicht unter denselben Knopf: das eine wird
|
||||||
|
/// veröffentlicht, das andere nur zur Kenntnis genommen.
|
||||||
|
/// </remarks>
|
||||||
|
string Grund = "neu");
|
||||||
|
|
||||||
public sealed record FreigabeRequest(
|
public sealed record FreigabeRequest(
|
||||||
IReadOnlyList<Guid>? Ids,
|
IReadOnlyList<Guid>? Ids,
|
||||||
@@ -256,7 +268,17 @@ public sealed record MediaStreamResponse(
|
|||||||
/// dieselbe Tonspur am Fernseher anders als im Browser. Die Rohwerte
|
/// dieselbe Tonspur am Fernseher anders als im Browser. Die Rohwerte
|
||||||
/// stehen weiterhin daneben; wer sie braucht, hat sie.
|
/// stehen weiterhin daneben; wer sie braucht, hat sie.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
string? Display = null);
|
string? Display = null,
|
||||||
|
|
||||||
|
/// <summary>Die Sprache als Wort — „Deutsch" statt „deu".</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit eine Liste von Sprachen ohne Rätselraten entsteht.</b> Die
|
||||||
|
/// Detailansicht nennt, in welchen Sprachen ein Film vorliegt; sie dafür
|
||||||
|
/// aus <see cref="Display"/> herauszuschneiden hiesse, sich auf dessen
|
||||||
|
/// Satzbau zu verlassen.
|
||||||
|
/// </remarks>
|
||||||
|
string? LanguageName = null);
|
||||||
|
|
||||||
/// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary>
|
/// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary>
|
||||||
///
|
///
|
||||||
@@ -272,9 +294,26 @@ public sealed record MediaStreamResponse(
|
|||||||
/// derselben Folge als zwei gleich aussehende Knopfreihen untereinander, und
|
/// derselben Folge als zwei gleich aussehende Knopfreihen untereinander, und
|
||||||
/// die Folge wirkte doppelt.
|
/// die Folge wirkte doppelt.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="Folder">
|
||||||
|
/// <b>Der Ordner unterhalb der Mediathek</b> — <c>null</c> für alle, die nicht
|
||||||
|
/// verwalten dürfen.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Weil man einer falschen Zuordnung am Ordner sofort ansieht, dass sie
|
||||||
|
/// falsch ist.</b> Steht über der Beschreibung „Hanni & Nanni 3" und der
|
||||||
|
/// Ordner heisst <c>Hanni und Nanni</c>, ist die Sache in einer Sekunde klar
|
||||||
|
/// — ohne Umweg über den Zuordnungsdialog.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nur der Teil unterhalb der Mediathek, und nur für Administratoren.</b>
|
||||||
|
/// Der volle Pfad verriete jedem Angemeldeten den Aufbau des Servers; der
|
||||||
|
/// relative sagt genau das, was zum Beurteilen nötig ist.
|
||||||
|
/// </para>
|
||||||
|
/// </param>
|
||||||
public sealed record MediaFileResponse(
|
public sealed record MediaFileResponse(
|
||||||
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
|
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
|
||||||
IReadOnlyList<MediaStreamResponse> Streams, string? VersionLabel = null);
|
IReadOnlyList<MediaStreamResponse> Streams, string? VersionLabel = null,
|
||||||
|
string? Folder = null);
|
||||||
|
|
||||||
public sealed record MovieResponse(
|
public sealed record MovieResponse(
|
||||||
Guid Id, string Title, int? Year, string? Overview,
|
Guid Id, string Title, int? Year, string? Overview,
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ public static class DeliveryEndpoints
|
|||||||
// durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
|
// durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
|
||||||
// Genau das ist passiert: der Spieler bekam HTML statt Video.
|
// Genau das ist passiert: der Spieler bekam HTML statt Video.
|
||||||
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async (
|
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async (
|
||||||
Guid id, FlaroContext db, PackageCoordinator packer) =>
|
Guid id, FlaroContext db, PackageCoordinator packer, TimeProvider uhr) =>
|
||||||
{
|
{
|
||||||
var gefunden = await Laden(db, id);
|
var gefunden = await Laden(db, id);
|
||||||
if (gefunden is not var (datei, plan)) return Results.NotFound();
|
if (gefunden is not var (datei, plan)) return Results.NotFound();
|
||||||
|
|
||||||
|
if (await FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht) return NochNicht(nochNicht);
|
||||||
|
|
||||||
// Passt schon alles, geht die Datei unverändert hinaus — mit
|
// Passt schon alles, geht die Datei unverändert hinaus — mit
|
||||||
// Längenangabe und Bereichsanfragen, also mit Fortschritt und
|
// Längenangabe und Bereichsanfragen, also mit Fortschritt und
|
||||||
// Fortsetzen.
|
// Fortsetzen.
|
||||||
@@ -70,6 +72,41 @@ public static class DeliveryEndpoints
|
|||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Ab wann diese Datei herausgehen darf — <c>null</c> heisst: sofort.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Hier und nicht im Sichtbarkeitsfilter.</b> Ein geplanter Titel steht
|
||||||
|
/// mit „Verfügbar ab …" in der Mediathek — das ist der Sinn des Planens.
|
||||||
|
/// Sichtbar heisst aber nicht abspielbar: ohne diese Prüfung liesse sich
|
||||||
|
/// die Datei trotzdem herunterladen, und die Angabe wäre eine Zierde.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Für alle, auch für Administratoren.</b> Wer die Freigabe vorziehen
|
||||||
|
/// will, hat dafür den Knopf „Jetzt veröffentlichen" — eine Hintertür für
|
||||||
|
/// eine Rolle wäre eine Regel, die manchmal gilt.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static async Task<DateTimeOffset?> FreiAb(
|
||||||
|
FlaroContext db, MediaFile datei, DateTimeOffset jetzt)
|
||||||
|
{
|
||||||
|
DateTimeOffset? wann = datei.MovieId is { } film
|
||||||
|
? await db.Movies.AsNoTracking().Where(m => m.Id == film)
|
||||||
|
.Select(m => m.PublishAt).FirstOrDefaultAsync()
|
||||||
|
: datei.EpisodeId is { } folge
|
||||||
|
? await db.Episodes.AsNoTracking().Where(f => f.Id == folge)
|
||||||
|
.Select(f => f.PublishAt).FirstOrDefaultAsync()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return wann is { } termin && termin > jetzt ? termin : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Satz, der statt der Datei kommt.</summary>
|
||||||
|
internal static IResult NochNicht(DateTimeOffset wann) =>
|
||||||
|
Results.Problem(
|
||||||
|
$"Dieser Titel ist ab {wann.ToLocalTime():dd.MM.yyyy, HH:mm} Uhr verfügbar.",
|
||||||
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext db, Guid id)
|
private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext db, Guid id)
|
||||||
{
|
{
|
||||||
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
|
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ public static class FreigabeEndpoints
|
|||||||
var jetzt = uhr.GetUtcNow();
|
var jetzt = uhr.GetUtcNow();
|
||||||
|
|
||||||
var filme = await db.Movies.AsNoTracking()
|
var filme = await db.Movies.AsNoTracking()
|
||||||
.Where(m => m.PublishAt == null || m.PublishAt > jetzt)
|
.Where(m => m.PublishAt == null || m.PublishAt > jetzt || m.ChangedSince != null)
|
||||||
.OrderBy(m => m.SortTitleValue)
|
.OrderBy(m => m.SortTitleValue)
|
||||||
.Select(m => new Pruefeintrag(
|
.Select(m => new Pruefeintrag(
|
||||||
"movie", m.Id, m.Title, m.Year, m.PosterImage, m.PublishAt,
|
"movie", m.Id, m.Title, m.Year, m.PosterImage, m.PublishAt,
|
||||||
db.MediaFiles.Where(f => f.MovieId == m.Id).Select(f => f.Path).FirstOrDefault(),
|
db.MediaFiles.Where(f => f.MovieId == m.Id).Select(f => f.Path).FirstOrDefault(),
|
||||||
m.TmdbId != null, 1, new List<Guid> { m.Id }))
|
m.TmdbId != null, 1, new List<Guid> { m.Id },
|
||||||
|
// **Neu sticht geändert.** Ein Titel, der noch gar nicht
|
||||||
|
// sichtbar ist, braucht eine Freigabe — dass sich dabei
|
||||||
|
// auch etwas geändert hat, ist die kleinere Nachricht.
|
||||||
|
m.PublishAt == null || m.PublishAt > jetzt ? "neu" : "geaendert"))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
// **Folgen werden nach Serie gebündelt.** Zwanzig neue Folgen
|
// **Folgen werden nach Serie gebündelt.** Zwanzig neue Folgen
|
||||||
@@ -71,13 +75,54 @@ public static class FreigabeEndpoints
|
|||||||
.OrderBy(e => e.Title)
|
.OrderBy(e => e.Title)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return Results.Ok(filme.Concat(gebuendelt).ToList());
|
// **Geänderte Serien stehen für sich.** Die Bündelung oben geht
|
||||||
|
// über wartende Folgen; eine Serie, deren Titel oder Plakat sich
|
||||||
|
// geändert hat, hat damit nichts zu tun — ihre Folgen sind längst
|
||||||
|
// sichtbar.
|
||||||
|
var schonDa = gebuendelt.Select(e => e.Id).ToHashSet();
|
||||||
|
var geaenderteSerien = await db.Series.AsNoTracking()
|
||||||
|
.Where(x => x.ChangedSince != null && !schonDa.Contains(x.Id))
|
||||||
|
.OrderBy(x => x.SortTitleValue)
|
||||||
|
.Select(x => new Pruefeintrag(
|
||||||
|
"series", x.Id, x.Title, x.Year, x.PosterImage, null,
|
||||||
|
null, x.TmdbId != null,
|
||||||
|
db.Episodes.Count(f => f.SeriesId == x.Id),
|
||||||
|
new List<Guid> { x.Id },
|
||||||
|
"geaendert"))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(filme.Concat(gebuendelt).Concat(geaenderteSerien).ToList());
|
||||||
|
|
||||||
// Bei einer Serie sagt der Ordner mehr als die erste Folgendatei.
|
// Bei einer Serie sagt der Ordner mehr als die erste Folgendatei.
|
||||||
static string? Pfad(string? datei) =>
|
static string? Pfad(string? datei) =>
|
||||||
datei is null ? null : System.IO.Path.GetDirectoryName(datei);
|
datei is null ? null : System.IO.Path.GetDirectoryName(datei);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// **Zur Kenntnis genommen.**
|
||||||
|
//
|
||||||
|
// Für Titel, die längst sichtbar sind und sich nur geändert haben.
|
||||||
|
// Veröffentlichen wäre dort das falsche Wort — es gibt nichts
|
||||||
|
// freizugeben, nur etwas abzuhaken.
|
||||||
|
gruppe.MapPost("/acknowledge", async (
|
||||||
|
FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var ids = anfrage.Ids ?? [];
|
||||||
|
|
||||||
|
var filme = ids.Count == 0
|
||||||
|
? await db.Movies.Where(m => m.ChangedSince != null)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.ChangedSince, (DateTimeOffset?)null), ct)
|
||||||
|
: await db.Movies.Where(m => ids.Contains(m.Id) && m.ChangedSince != null)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.ChangedSince, (DateTimeOffset?)null), ct);
|
||||||
|
|
||||||
|
var serien = ids.Count == 0
|
||||||
|
? await db.Series.Where(x => x.ChangedSince != null)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ChangedSince, (DateTimeOffset?)null), ct)
|
||||||
|
: await db.Series.Where(x => ids.Contains(x.Id) && x.ChangedSince != null)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ChangedSince, (DateTimeOffset?)null), ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { Acknowledged = filme + serien });
|
||||||
|
});
|
||||||
|
|
||||||
// Veröffentlichen oder planen. Ohne Zeitpunkt heisst: jetzt.
|
// Veröffentlichen oder planen. Ohne Zeitpunkt heisst: jetzt.
|
||||||
gruppe.MapPost("/publish", async (
|
gruppe.MapPost("/publish", async (
|
||||||
FreigabeRequest anfrage, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
FreigabeRequest anfrage, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Flaro.Api.Auth;
|
||||||
using Flaro.Core.Library;
|
using Flaro.Core.Library;
|
||||||
using Flaro.Core.Media;
|
using Flaro.Core.Media;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
@@ -90,21 +91,70 @@ public static class LibraryContentEndpoints
|
|||||||
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height,
|
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height,
|
||||||
Display: s.Kind switch
|
Display: s.Kind switch
|
||||||
{
|
{
|
||||||
MediaStreamKind.Video => Benennung.Bildspur(s.Codec, s.Height),
|
MediaStreamKind.Video => Benennung.Bildspur(s.Codec, s.Width, s.Height),
|
||||||
MediaStreamKind.Audio => Benennung.Tonspur(s.Language, s.Codec, s.Channels, s.Title),
|
MediaStreamKind.Audio => Benennung.Tonspur(s.Language, s.Codec, s.Channels, s.Title),
|
||||||
_ => Benennung.Untertitelspur(s.Language, s.IsForced, fuerHoergeschaedigte: false),
|
_ => Benennung.Untertitelspur(s.Language, s.IsForced, fuerHoergeschaedigte: false),
|
||||||
}));
|
},
|
||||||
|
LanguageName: Benennung.Sprache(s.Language)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MediaFileResponse Datei(
|
private static MediaFileResponse Datei(
|
||||||
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
|
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
|
||||||
ILookup<Guid, MediaStreamResponse> spuren, string? fassung = null) =>
|
ILookup<Guid, MediaStreamResponse> spuren, string? fassung = null,
|
||||||
|
string? mediathekspfad = null) =>
|
||||||
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds,
|
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds,
|
||||||
spuren[id].ToList(),
|
spuren[id].ToList(),
|
||||||
// Steht sie nicht in der Datenbank, wird sie aus dem Pfad gelesen:
|
// Steht sie nicht in der Datenbank, wird sie aus dem Pfad gelesen:
|
||||||
// ein Bestand von vor dieser Änderung soll nicht erst neu gescannt
|
// ein Bestand von vor dieser Änderung soll nicht erst neu gescannt
|
||||||
// werden müssen, um „Mobil" zu zeigen.
|
// werden müssen, um „Mobil" zu zeigen.
|
||||||
fassung ?? FileNames.Fassung(pfad));
|
fassung ?? FileNames.Fassung(pfad),
|
||||||
|
Ordner(pfad, mediathekspfad));
|
||||||
|
|
||||||
|
/// <summary>Der Pfad der Mediathek — nur für Administratoren.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Hier entscheidet sich, wer den Ordner sieht.</b> Ohne Wurzel bleibt
|
||||||
|
/// <see cref="Ordner"/> bei <c>null</c>, und das Feld kommt erst gar nicht
|
||||||
|
/// in der Antwort an — statt es im Client zu verstecken, wo es jeder
|
||||||
|
/// auslesen könnte.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<string?> MediathekspfadFuer(
|
||||||
|
FlaroContext db, ClaimsPrincipal wer, Guid bibliothek)
|
||||||
|
{
|
||||||
|
if (!wer.IsInRole(FlaroRoles.Administrator)) return null;
|
||||||
|
|
||||||
|
return await db.Libraries.AsNoTracking()
|
||||||
|
.Where(l => l.Id == bibliothek)
|
||||||
|
.Select(l => l.Path)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Ordner unterhalb der Mediathek — oder <c>null</c>.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Nur der Teil unterhalb, nie der volle Pfad.</b> Der verriete jedem
|
||||||
|
/// Angemeldeten den Aufbau des Servers; was zum Beurteilen einer Zuordnung
|
||||||
|
/// nötig ist, ist der Ordnername — „Hanni und Nanni" neben dem Titel
|
||||||
|
/// „Hanni & Nanni 3" sagt in einer Sekunde, dass etwas nicht stimmt.
|
||||||
|
///
|
||||||
|
/// <c>null</c> für alle, die nicht verwalten dürfen: dort wird der
|
||||||
|
/// Mediathekspfad gar nicht erst hereingereicht.
|
||||||
|
/// </remarks>
|
||||||
|
private static string? Ordner(string pfad, string? mediathekspfad)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(mediathekspfad)) return null;
|
||||||
|
|
||||||
|
var ordner = System.IO.Path.GetDirectoryName(pfad);
|
||||||
|
if (string.IsNullOrEmpty(ordner)) return null;
|
||||||
|
|
||||||
|
var wurzel = mediathekspfad.TrimEnd('/', '\\');
|
||||||
|
if (!ordner.StartsWith(wurzel, StringComparison.Ordinal)) return null;
|
||||||
|
|
||||||
|
var darunter = ordner[wurzel.Length..].Trim('/', '\\');
|
||||||
|
// Liegt die Datei unmittelbar in der Mediathek, gibt es keinen Ordner
|
||||||
|
// — und „" wäre eine Zeile, die nichts sagt.
|
||||||
|
return darunter.Length > 0 ? darunter : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
|
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
|
||||||
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
|
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
|
||||||
@@ -559,6 +609,7 @@ public static class LibraryContentEndpoints
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, filme: [id]);
|
var stände = await ZuständeZu(db, wer, filme: [id]);
|
||||||
|
var wurzel = await MediathekspfadFuer(db, wer, film.LibraryId);
|
||||||
|
|
||||||
var sprache = await SpracheVon(wer, db);
|
var sprache = await SpracheVon(wer, db);
|
||||||
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
|
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
|
||||||
@@ -567,7 +618,7 @@ public static class LibraryContentEndpoints
|
|||||||
return Results.Ok(new MovieResponse(
|
return Results.Ok(new MovieResponse(
|
||||||
film.Id, uebersetzt?.Title ?? film.Title, film.Year,
|
film.Id, uebersetzt?.Title ?? film.Title, film.Year,
|
||||||
uebersetzt?.Overview ?? film.Overview, film.PosterImage, film.BackdropImage,
|
uebersetzt?.Overview ?? film.Overview, film.PosterImage, film.BackdropImage,
|
||||||
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel)).ToList(),
|
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel, wurzel)).ToList(),
|
||||||
stände.GetValueOrDefault(id),
|
stände.GetValueOrDefault(id),
|
||||||
film.LogoImage, film.RuntimeMinutes, film.Rating,
|
film.LogoImage, film.RuntimeMinutes, film.Rating,
|
||||||
uebersetzt?.Tagline ?? film.Tagline, film.ContentRating,
|
uebersetzt?.Tagline ?? film.Tagline, film.ContentRating,
|
||||||
@@ -671,6 +722,10 @@ public static class LibraryContentEndpoints
|
|||||||
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
|
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
var wurzel = await MediathekspfadFuer(db, wer,
|
||||||
|
await db.Series.AsNoTracking().Where(x => x.Id == id)
|
||||||
|
.Select(x => x.LibraryId).FirstAsync());
|
||||||
|
|
||||||
var ids = folgen.Select(f => f.Id).ToList();
|
var ids = folgen.Select(f => f.Id).ToList();
|
||||||
var dateien = await db.MediaFiles.AsNoTracking()
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value))
|
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value))
|
||||||
@@ -687,7 +742,7 @@ public static class LibraryContentEndpoints
|
|||||||
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview,
|
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview,
|
||||||
f.StillImage ?? serienBild,
|
f.StillImage ?? serienBild,
|
||||||
dateien.Where(d => d.EpisodeId == f.Id)
|
dateien.Where(d => d.EpisodeId == f.Id)
|
||||||
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
|
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||||
stände.GetValueOrDefault(f.Id))).ToList();
|
stände.GetValueOrDefault(f.Id))).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
||||||
|
|||||||
@@ -32,10 +32,18 @@ public static class StreamEndpoints
|
|||||||
// was der Benutzer gewählt hat.
|
// was der Benutzer gewählt hat.
|
||||||
app.MapPost("/api/v1/files/{id:guid}/stream", async (
|
app.MapPost("/api/v1/files/{id:guid}/stream", async (
|
||||||
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
|
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
|
||||||
Streamkoordinator koordinator, FlaroContext db) =>
|
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr) =>
|
||||||
{
|
{
|
||||||
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
|
||||||
|
// **Geplant heisst sichtbar, nicht abspielbar.** Ohne diese
|
||||||
|
// Prüfung liesse sich ein Titel, der mit „Verfügbar ab …" dasteht,
|
||||||
|
// trotzdem abspielen — und die Angabe wäre eine Zierde.
|
||||||
|
var gewuenschte = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
|
||||||
|
if (gewuenschte is null) return Results.NotFound();
|
||||||
|
if (await DeliveryEndpoints.FreiAb(db, gewuenschte, uhr.GetUtcNow()) is { } nochNicht)
|
||||||
|
return DeliveryEndpoints.NochNicht(nochNicht);
|
||||||
|
|
||||||
var profil = Profil(anfrage);
|
var profil = Profil(anfrage);
|
||||||
var hoehe = anfrage.Height is { } gewuenscht
|
var hoehe = anfrage.Height is { } gewuenscht
|
||||||
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
|
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
@@ -76,16 +77,56 @@ public static partial class Fehlertext
|
|||||||
{
|
{
|
||||||
var text = new StringBuilder();
|
var text = new StringBuilder();
|
||||||
var tiefe = 0;
|
var tiefe = 0;
|
||||||
|
Exception innerste = fehler;
|
||||||
|
|
||||||
for (Exception? e = fehler; e is not null; e = e.InnerException, tiefe++)
|
for (Exception? e = fehler; e is not null; e = e.InnerException, tiefe++)
|
||||||
{
|
{
|
||||||
if (tiefe > 0) text.Append(" ← ");
|
if (tiefe > 0) text.Append(" ← ");
|
||||||
text.Append(e.GetType().Name).Append(": ").Append(e.Message);
|
text.Append(e.GetType().Name).Append(": ").Append(e.Message);
|
||||||
|
innerste = e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Und wo es passiert ist.**
|
||||||
|
//
|
||||||
|
// Ohne diese Zeilen stand in der Tagesdatei „InvalidOperationException:
|
||||||
|
// Nullable object must have a value." und sonst nichts — eine Meldung,
|
||||||
|
// mit der weder der Betreiber noch der Entwickler etwas anfangen kann.
|
||||||
|
// Gerade die Fehler, die man nicht erwartet hat, sind die, bei denen
|
||||||
|
// die Stelle alles ist.
|
||||||
|
//
|
||||||
|
// Nur die innerste Ausnahme und nur die eigenen Bilder: der Rahmenwerk-
|
||||||
|
// Anteil einer Spur ist dreimal so lang und sagt nichts über Flaro.
|
||||||
|
var spur = Eigene(innerste.StackTrace);
|
||||||
|
if (spur.Length > 0) text.Append('\n').Append(spur);
|
||||||
|
|
||||||
return text.ToString();
|
return text.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Zeilen der Spur, die zu Flaro gehören.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Höchstens zwölf.</b> Was darunter liegt, ist ASP.NET und EF Core —
|
||||||
|
/// bei einem Fehler im eigenen Code steht die Antwort immer oben.
|
||||||
|
/// </remarks>
|
||||||
|
private static string Eigene(string? spur)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(spur)) return "";
|
||||||
|
|
||||||
|
var zeilen = spur.Split('\n')
|
||||||
|
.Select(z => z.TrimEnd('\r'))
|
||||||
|
.Where(z => z.Contains(" Flaro.", StringComparison.Ordinal))
|
||||||
|
.Take(12)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Steckt gar nichts Eigenes darin, ist die ganze Spur besser als keine.
|
||||||
|
if (zeilen.Count == 0)
|
||||||
|
{
|
||||||
|
zeilen = spur.Split('\n').Select(z => z.TrimEnd('\r')).Take(6).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join('\n', zeilen);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Welche Eindeutigkeitsregel gebrochen wurde — in Worten.</summary>
|
/// <summary>Welche Eindeutigkeitsregel gebrochen wurde — in Worten.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -169,6 +169,24 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool SortTitlePinned { get; set; }
|
public bool SortTitlePinned { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Seit wann eine Änderung auf ihre Kenntnisnahme wartet.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nicht dasselbe wie <see cref="PublishAt"/>.</b> Der Titel bleibt
|
||||||
|
/// sichtbar — es geht nicht darum, ihn zurückzuhalten, sondern darum, dass
|
||||||
|
/// jemand hinsieht. Ein Auffrischen kann einen Titel umbenennen, ein
|
||||||
|
/// Plakat tauschen oder die Zuordnung wechseln; wer eine Mediathek
|
||||||
|
/// betreut, will das mitbekommen und nicht zufällig entdecken.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>null</c> heisst: nichts offen. Gesetzt wird es nur, wenn die
|
||||||
|
/// Mediathek auf Prüfung steht — wer keine Prüfung will, soll auch keine
|
||||||
|
/// Liste bekommen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset? ChangedSince { get; set; }
|
||||||
|
|
||||||
/// <summary>In welcher Sprache die Angaben geholt wurden.</summary>
|
/// <summary>In welcher Sprache die Angaben geholt wurden.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -350,6 +368,24 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool SortTitlePinned { get; set; }
|
public bool SortTitlePinned { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Seit wann eine Änderung auf ihre Kenntnisnahme wartet.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nicht dasselbe wie <see cref="PublishAt"/>.</b> Der Titel bleibt
|
||||||
|
/// sichtbar — es geht nicht darum, ihn zurückzuhalten, sondern darum, dass
|
||||||
|
/// jemand hinsieht. Ein Auffrischen kann einen Titel umbenennen, ein
|
||||||
|
/// Plakat tauschen oder die Zuordnung wechseln; wer eine Mediathek
|
||||||
|
/// betreut, will das mitbekommen und nicht zufällig entdecken.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>null</c> heisst: nichts offen. Gesetzt wird es nur, wenn die
|
||||||
|
/// Mediathek auf Prüfung steht — wer keine Prüfung will, soll auch keine
|
||||||
|
/// Liste bekommen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset? ChangedSince { get; set; }
|
||||||
|
|
||||||
/// <summary>In welcher Sprache die Angaben geholt wurden.</summary>
|
/// <summary>In welcher Sprache die Angaben geholt wurden.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -88,7 +88,45 @@ public static class Benennung
|
|||||||
/// Zeilen statt 1080; genau das stand in der Oberfläche und sah aus wie
|
/// Zeilen statt 1080; genau das stand in der Oberfläche und sah aus wie
|
||||||
/// ein Fehler. Der genaue Wert gehört in den Tooltip, nicht in die Zeile.
|
/// ein Fehler. Der genaue Wert gehört in den Tooltip, nicht in die Zeile.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static string? Aufloesung(int? hoehe) => hoehe switch
|
public static string? Aufloesung(int? hoehe) => AusHoehe(hoehe);
|
||||||
|
|
||||||
|
/// <summary>Die Stufe aus Breite <i>und</i> Höhe — die grössere gewinnt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Die Höhe allein lügt bei Breitbild.</b> „Spider-Man" liegt als
|
||||||
|
/// 1920×800 vor — ein Kinoformat von 2.39:1, also Full HD. Nach der Höhe
|
||||||
|
/// gerechnet kam „720p" heraus, und das stand so in der Oberfläche: eine
|
||||||
|
/// Angabe, die jeder sofort als falsch erkennt, der die Datei kennt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Die grössere der beiden Stufen, nicht die Breite allein.</b> Sonst
|
||||||
|
/// kippt es beim Hochformat: ein 1080×1920 gedrehtes Bild wäre nach der
|
||||||
|
/// Breite „720p". So stimmt beides, und keine Richtung ist bevorzugt.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static string? Aufloesung(int? breite, int? hoehe)
|
||||||
|
{
|
||||||
|
var a = Stufe(hoehe);
|
||||||
|
var b = Stufe(breite * 9 / 16); // Breite auf eine 16:9-Höhe umgerechnet
|
||||||
|
var groessere = Math.Max(a ?? 0, b ?? 0);
|
||||||
|
return groessere > 0 ? AusHoehe(groessere) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Zeilenzahl, auf die eine Stufe lautet — zum Vergleichen.</summary>
|
||||||
|
private static int? Stufe(int? wert) => wert switch
|
||||||
|
{
|
||||||
|
null or <= 0 => null,
|
||||||
|
>= 2000 => 2160,
|
||||||
|
>= 1400 => 1440,
|
||||||
|
>= 1000 => 1080,
|
||||||
|
>= 700 => 720,
|
||||||
|
>= 500 => 576,
|
||||||
|
>= 300 => 480,
|
||||||
|
_ => wert,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string? AusHoehe(int? hoehe) => hoehe switch
|
||||||
{
|
{
|
||||||
null or <= 0 => null,
|
null or <= 0 => null,
|
||||||
>= 2000 => "2160p",
|
>= 2000 => "2160p",
|
||||||
@@ -133,10 +171,13 @@ public static class Benennung
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Die ganze Zeile zu einer Bildspur.</summary>
|
/// <summary>Die ganze Zeile zu einer Bildspur.</summary>
|
||||||
public static string Bildspur(string? codec, int? hoehe)
|
public static string Bildspur(string? codec, int? hoehe) => Bildspur(codec, null, hoehe);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Bildspur(string?, int?)"/>
|
||||||
|
public static string Bildspur(string? codec, int? breite, int? hoehe)
|
||||||
{
|
{
|
||||||
var teile = new List<string>();
|
var teile = new List<string>();
|
||||||
if (Aufloesung(hoehe) is { } a) teile.Add(a);
|
if (Aufloesung(breite, hoehe) is { } a) teile.Add(a);
|
||||||
if (Codec(codec) is { } c) teile.Add(c);
|
if (Codec(codec) is { } c) teile.Add(c);
|
||||||
return teile.Count > 0 ? string.Join(" · ", teile) : "Bild";
|
return teile.Count > 0 ? string.Join(" · ", teile) : "Bild";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,22 +278,34 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
|
|
||||||
// **Die Freigaberegel, einmal und für jede Abfrage.**
|
// **Die Freigaberegel, einmal und für jede Abfrage.**
|
||||||
//
|
//
|
||||||
// Ein Titel ist sichtbar, wenn ein Zeitpunkt gesetzt ist und er
|
// <b>Zwei Zustände, nicht einer.</b> Ohne Zeitpunkt wartet ein Titel
|
||||||
// erreicht wurde. Gerechnet statt gespeichert: damit stimmt es nach
|
// auf eine Entscheidung — den soll niemand sehen, er ist noch nicht
|
||||||
// einem Neustart, nach einer Zeitumstellung und auch dann, wenn der
|
// einmal geprüft. Mit einem Zeitpunkt in der Zukunft ist die
|
||||||
// Server zum geplanten Zeitpunkt gar nicht lief.
|
// Entscheidung gefallen: er kommt, nur noch nicht. Den zu verstecken
|
||||||
|
// hiesse, eine Vorfreude zu unterschlagen, die der Sinn des Planens
|
||||||
|
// ist — bei Netflix steht „Ab 14. März" auf der Kachel, und genau das
|
||||||
|
// soll hier auch stehen.
|
||||||
|
//
|
||||||
|
// <b>Sichtbar heisst nicht abspielbar.</b> Dass die Datei vor dem
|
||||||
|
// Zeitpunkt nicht herausgeht, steht nicht hier, sondern bei der
|
||||||
|
// Auslieferung — ein Filter, der etwas zeigt, darf nicht zugleich der
|
||||||
|
// sein, der es herausgibt.
|
||||||
|
//
|
||||||
|
// Gerechnet statt gespeichert: damit stimmt es nach einem Neustart,
|
||||||
|
// nach einer Zeitumstellung und auch dann, wenn der Server zum
|
||||||
|
// geplanten Zeitpunkt gar nicht lief.
|
||||||
b.Entity<Movie>().HasQueryFilter(m =>
|
b.Entity<Movie>().HasQueryFilter(m =>
|
||||||
AuchUnveroeffentlichtes || (m.PublishAt != null && m.PublishAt <= Jetzt));
|
AuchUnveroeffentlichtes || m.PublishAt != null);
|
||||||
|
|
||||||
b.Entity<Episode>().HasQueryFilter(f =>
|
b.Entity<Episode>().HasQueryFilter(f =>
|
||||||
AuchUnveroeffentlichtes || (f.PublishAt != null && f.PublishAt <= Jetzt));
|
AuchUnveroeffentlichtes || f.PublishAt != null);
|
||||||
|
|
||||||
// **Die Serie hängt an ihren Folgen.** Ein eigenes Feld gäbe zwei
|
// **Die Serie hängt an ihren Folgen.** Ein eigenes Feld gäbe zwei
|
||||||
// Wahrheiten — eine veröffentlichte Serie, deren Folgen alle noch
|
// Wahrheiten — eine veröffentlichte Serie, deren Folgen alle noch
|
||||||
// warten, wäre ein leerer Eintrag in der Mediathek.
|
// warten, wäre ein leerer Eintrag in der Mediathek.
|
||||||
b.Entity<Series>().HasQueryFilter(s =>
|
b.Entity<Series>().HasQueryFilter(s =>
|
||||||
AuchUnveroeffentlichtes
|
AuchUnveroeffentlichtes
|
||||||
|| Episodes.Any(f => f.SeriesId == s.Id && f.PublishAt != null && f.PublishAt <= Jetzt));
|
|| Episodes.Any(f => f.SeriesId == s.Id && f.PublishAt != null));
|
||||||
|
|
||||||
b.Entity<ItemTranslation>(e =>
|
b.Entity<ItemTranslation>(e =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public sealed class MetadataEnricher(
|
|||||||
IImageReader bildLeser,
|
IImageReader bildLeser,
|
||||||
ISidecarStore beiwerk,
|
ISidecarStore beiwerk,
|
||||||
Zusammenlegung zusammenlegung,
|
Zusammenlegung zusammenlegung,
|
||||||
|
TimeProvider uhr,
|
||||||
ILogger<MetadataEnricher> log)
|
ILogger<MetadataEnricher> log)
|
||||||
{
|
{
|
||||||
/// <param name="erneut">
|
/// <param name="erneut">
|
||||||
@@ -66,11 +67,18 @@ public sealed class MetadataEnricher(
|
|||||||
return new EnrichReport(0, 0, 0, Skipped: true, null);
|
return new EnrichReport(0, 0, 0, Skipped: true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Ob die Mediathek auf Prüfung steht.** Nur dann wird eine Änderung
|
||||||
|
// vorgemerkt — wer keine Prüfung will, soll auch keine Liste bekommen.
|
||||||
|
var pruefungNoetig = await db.Libraries.AsNoTracking()
|
||||||
|
.Where(l => l.Id == libraryId)
|
||||||
|
.Select(l => l.ReviewNewItems)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
int filme = 0, serien = 0, folgen = 0;
|
int filme = 0, serien = 0, folgen = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
filme = await FilmeZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese);
|
filme = await FilmeZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese, pruefungNoetig);
|
||||||
serien = await SerienZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese);
|
serien = await SerienZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese, pruefungNoetig);
|
||||||
folgen = await FolgenZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese);
|
folgen = await FolgenZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese);
|
||||||
}
|
}
|
||||||
catch (MetadataCredentialsException fehler)
|
catch (MetadataCredentialsException fehler)
|
||||||
@@ -88,7 +96,7 @@ public sealed class MetadataEnricher(
|
|||||||
|
|
||||||
private async Task<int> FilmeZuordnen(
|
private async Task<int> FilmeZuordnen(
|
||||||
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
|
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
|
||||||
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null)
|
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null, bool pruefungNoetig = false)
|
||||||
{
|
{
|
||||||
var offen = await db.Movies
|
var offen = await db.Movies
|
||||||
.Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null)
|
.Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null)
|
||||||
@@ -190,6 +198,11 @@ public sealed class MetadataEnricher(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Was vorher dastand** — nur daran lässt sich sagen, ob ein
|
||||||
|
// Auffrischen wirklich etwas geändert hat. Ein Lauf, der denselben
|
||||||
|
// Titel noch einmal holt, ist nichts zum Abnicken.
|
||||||
|
var vorher = (film.TmdbId, film.Title, film.PosterImage, film.Year);
|
||||||
|
|
||||||
vergeben[gefunden.ProviderId] = film.Id;
|
vergeben[gefunden.ProviderId] = film.Id;
|
||||||
// **Auch das Jahr, und zwar sofort.** Die beiden Tabellen müssen
|
// **Auch das Jahr, und zwar sofort.** Die beiden Tabellen müssen
|
||||||
// im Gleichschritt wachsen: wird nur `vergeben` fortgeschrieben,
|
// im Gleichschritt wachsen: wird nur `vergeben` fortgeschrieben,
|
||||||
@@ -229,6 +242,15 @@ public sealed class MetadataEnricher(
|
|||||||
await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct);
|
await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct);
|
||||||
zugeordnet++;
|
zugeordnet++;
|
||||||
|
|
||||||
|
// **Eine Änderung an einem Titel, den es schon gab, wird vorgemerkt.**
|
||||||
|
// Bei einem neuen Fund nicht: der steht ohnehin schon in der
|
||||||
|
// Prüfliste, weil er auf Freigabe wartet.
|
||||||
|
if (pruefungNoetig && vorher.TmdbId is not null
|
||||||
|
&& vorher != (film.TmdbId, film.Title, film.PosterImage, film.Year))
|
||||||
|
{
|
||||||
|
film.ChangedSince ??= uhr.GetUtcNow();
|
||||||
|
}
|
||||||
|
|
||||||
if (datei is not null) await FilmBeiwerk(film, datei, ct);
|
if (datei is not null) await FilmBeiwerk(film, datei, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +321,7 @@ public sealed class MetadataEnricher(
|
|||||||
|
|
||||||
private async Task<int> SerienZuordnen(
|
private async Task<int> SerienZuordnen(
|
||||||
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
|
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
|
||||||
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null)
|
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null, bool pruefungNoetig = false)
|
||||||
{
|
{
|
||||||
var offen = await db.Series
|
var offen = await db.Series
|
||||||
.Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null)
|
.Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null)
|
||||||
@@ -349,6 +371,8 @@ public sealed class MetadataEnricher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
vergeben[gefunden.ProviderId] = serie.Id;
|
vergeben[gefunden.ProviderId] = serie.Id;
|
||||||
|
var vorher = (serie.TmdbId, serie.Title, serie.PosterImage, serie.Year);
|
||||||
|
|
||||||
serie.TmdbId = gefunden.ProviderId;
|
serie.TmdbId = gefunden.ProviderId;
|
||||||
serie.ImdbId = gefunden.ImdbId;
|
serie.ImdbId = gefunden.ImdbId;
|
||||||
if (!serie.TitlePinned)
|
if (!serie.TitlePinned)
|
||||||
@@ -373,6 +397,12 @@ public sealed class MetadataEnricher(
|
|||||||
|
|
||||||
await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct);
|
await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct);
|
||||||
await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct);
|
await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct);
|
||||||
|
|
||||||
|
if (pruefungNoetig && vorher.TmdbId is not null
|
||||||
|
&& vorher != (serie.TmdbId, serie.Title, serie.PosterImage, serie.Year))
|
||||||
|
{
|
||||||
|
serie.ChangedSince ??= uhr.GetUtcNow();
|
||||||
|
}
|
||||||
serie.MatchLocked = festgelegt;
|
serie.MatchLocked = festgelegt;
|
||||||
zugeordnet++;
|
zugeordnet++;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,933 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Flaro.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(FlaroContext))]
|
||||||
|
[Migration("20261006123929_Kenntnisnahme")]
|
||||||
|
partial class Kenntnisnahme
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ApprovedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("ConsumedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("HandleFingerprint")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code");
|
||||||
|
|
||||||
|
b.HasIndex("HandleFingerprint")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("AuthCodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenFingerprint")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenFingerprint")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("DeviceTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("ReviewNewItems")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Libraries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Character")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("PersonId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("PersonId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("Credits");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EpisodeNumber")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("MetadataLanguage")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SeasonNumber")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StillImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Episodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.UseCollation("NOCASE");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Genres");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("GenreId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("GenreId", "MovieId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("GenreId", "SeriesId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("GenreLinks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FetchedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Translations");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("BitRate")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Container")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("Duration")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("LibraryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("MissingSince")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ModifiedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("ProbedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("SeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("SizeBytes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("VersionLabel")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "Path")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MediaFiles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Channels")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Codec")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Height")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Index")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDefault")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsForced")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("MediaFileId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Width")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MediaFileId", "Index")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MediaStreams");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("BackdropImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("ChangedSince")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ContentRating")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ImdbId")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("LibraryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("LogoImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("MetadataLanguage")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ParsedTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParsedYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PosterImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<double?>("Rating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.Property<int?>("RuntimeMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("SortTitlePinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SortTitleValue")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("TitlePinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "MetadataLanguage");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "SortTitleValue");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Movies");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Image")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TmdbId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("People");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("BackdropImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("ChangedSince")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ContentRating")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ImdbId")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("LibraryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("LogoImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("MetadataLanguage")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ParsedTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParsedYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PosterImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<double?>("Rating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.Property<int?>("RuntimeMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("SortTitlePinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SortTitleValue")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("TitlePinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "MetadataLanguage");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "SortTitleValue");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Dismissed")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Favourite")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("InProgress")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPlayedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("PlayCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("Position")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Rating")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("Runtime")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Watched")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "EpisodeId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "MovieId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "SeriesId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "InProgress", "UpdatedAt");
|
||||||
|
|
||||||
|
b.ToTable("UserItemStates");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.ToTable("Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Users.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdministrator")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.UseCollation("NOCASE");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Users.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MovieId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Person", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PersonId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Genre", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("GenreId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MovieId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.MediaFile", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MediaFileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Episode", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EpisodeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MovieId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Users.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Kenntnisnahme : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "ChangedSince",
|
||||||
|
table: "Series",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "ChangedSince",
|
||||||
|
table: "Movies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ChangedSince",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ChangedSince",
|
||||||
|
table: "Movies");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -455,6 +455,9 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<bool>("BackdropPinned")
|
b.Property<bool>("BackdropPinned")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("ChangedSince")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("ContentRating")
|
b.Property<string>("ContentRating")
|
||||||
.HasMaxLength(16)
|
.HasMaxLength(16)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -587,6 +590,9 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<bool>("BackdropPinned")
|
b.Property<bool>("BackdropPinned")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("ChangedSince")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("ContentRating")
|
b.Property<string>("ContentRating")
|
||||||
.HasMaxLength(16)
|
.HasMaxLength(16)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|||||||
@@ -112,9 +112,24 @@ public class FreigabeTests
|
|||||||
Assert.That(await Titel(gast), Is.EqualTo(new[] { "Geheimsache" }));
|
Assert.That(await Titel(gast), Is.EqualTo(new[] { "Geheimsache" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ein Zeitpunkt in der Zukunft hält ihn zurück — ohne Hintergrunddienst.</summary>
|
/// <summary>Ein geplanter Titel ist sichtbar, aber noch nicht abspielbar.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Zwei Zustände, nicht einer.</b> Ohne Zeitpunkt wartet ein Titel auf
|
||||||
|
/// eine Entscheidung — den soll niemand sehen. Mit einem Zeitpunkt in der
|
||||||
|
/// Zukunft ist die Entscheidung gefallen: er kommt, nur noch nicht. Ihn
|
||||||
|
/// dann zu verstecken hiesse, die Vorfreude zu unterschlagen, die der Sinn
|
||||||
|
/// des Planens ist.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Sichtbar heisst aber nicht abspielbar</b>, und das ist der Teil, der
|
||||||
|
/// wirklich zählt: ohne den Riegel bei der Auslieferung wäre „Verfügbar ab
|
||||||
|
/// …" eine Zierde, und die Datei ginge trotzdem heraus.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Ein_geplanter_Titel_erscheint_erst_zu_seiner_Zeit()
|
public async Task Ein_geplanter_Titel_ist_sichtbar_aber_nicht_abspielbar()
|
||||||
{
|
{
|
||||||
Datei("Geheimsache (2024).mkv");
|
Datei("Geheimsache (2024).mkv");
|
||||||
|
|
||||||
@@ -132,7 +147,28 @@ public class FreigabeTests
|
|||||||
// Auf morgen gelegt.
|
// Auf morgen gelegt.
|
||||||
await alsAdmin.PostAsJsonAsync("/api/v1/review/publish",
|
await alsAdmin.PostAsJsonAsync("/api/v1/review/publish",
|
||||||
new FreigabeRequest(null, DateTimeOffset.UtcNow.AddDays(1)));
|
new FreigabeRequest(null, DateTimeOffset.UtcNow.AddDays(1)));
|
||||||
Assert.That(await Titel(gast), Is.Empty, "Ein geplanter Titel ist noch nicht sichtbar.");
|
|
||||||
|
Assert.That(await Titel(gast), Is.EqualTo(new[] { "Geheimsache" }),
|
||||||
|
"Ein geplanter Titel steht in der Mediathek — dafür plant man ihn.");
|
||||||
|
|
||||||
|
// **Aber die Datei geht nicht heraus.** Das ist der Teil, der zählt.
|
||||||
|
var filme = await gast.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||||
|
var datei = filme!.Items.Single().Files.Single();
|
||||||
|
|
||||||
|
var geladen = await gast.GetAsync($"/api/v1/files/{datei.Id}/download");
|
||||||
|
var gestroemt = await gast.PostAsJsonAsync($"/api/v1/files/{datei.Id}/stream",
|
||||||
|
new StreamRequest(["mp4"], ["h264"], ["aac"]));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(geladen.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||||||
|
Assert.That(gestroemt.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||||||
|
});
|
||||||
|
|
||||||
|
// **Auch für Administratoren.** Eine Hintertür für eine Rolle wäre
|
||||||
|
// eine Regel, die manchmal gilt; wer vorziehen will, hat den Knopf.
|
||||||
|
var alsAdminGeladen = await alsAdmin.GetAsync($"/api/v1/files/{datei.Id}/download");
|
||||||
|
Assert.That(alsAdminGeladen.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||||||
|
|
||||||
// Und er steht weiterhin in der Prüfliste — mit seinem Termin.
|
// Und er steht weiterhin in der Prüfliste — mit seinem Termin.
|
||||||
var wartend = await alsAdmin.GetFromJsonAsync<List<Pruefeintrag>>("/api/v1/review");
|
var wartend = await alsAdmin.GetFromJsonAsync<List<Pruefeintrag>>("/api/v1/review");
|
||||||
@@ -143,6 +179,10 @@ public class FreigabeTests
|
|||||||
await alsAdmin.PostAsJsonAsync("/api/v1/review/publish",
|
await alsAdmin.PostAsJsonAsync("/api/v1/review/publish",
|
||||||
new FreigabeRequest(null, DateTimeOffset.UtcNow.AddDays(-1)));
|
new FreigabeRequest(null, DateTimeOffset.UtcNow.AddDays(-1)));
|
||||||
Assert.That(await Titel(gast), Is.EqualTo(new[] { "Geheimsache" }));
|
Assert.That(await Titel(gast), Is.EqualTo(new[] { "Geheimsache" }));
|
||||||
|
|
||||||
|
var jetztGeladen = await gast.GetAsync($"/api/v1/files/{datei.Id}/download");
|
||||||
|
Assert.That(jetztGeladen.StatusCode, Is.Not.EqualTo(HttpStatusCode.Forbidden),
|
||||||
|
"ab dem Termin geht die Datei heraus");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ohne Prüfung erscheint alles sofort — das ist die Vorgabe.</summary>
|
/// <summary>Ohne Prüfung erscheint alles sofort — das ist die Vorgabe.</summary>
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ export type Spur = {
|
|||||||
channels: number | null; width: number | null; height: number | null;
|
channels: number | null; width: number | null; height: number | null;
|
||||||
/** Die fertige Zeile vom Server, etwa Deutsch · Dolby Digital Plus · 5.1. */
|
/** Die fertige Zeile vom Server, etwa Deutsch · Dolby Digital Plus · 5.1. */
|
||||||
display: string | null;
|
display: string | null;
|
||||||
|
/** Die Sprache als Wort — „Deutsch" statt „deu". */
|
||||||
|
languageName: string | null;
|
||||||
};
|
};
|
||||||
export type Datei = {
|
export type Datei = {
|
||||||
id: string; fileName: string; sizeBytes: number;
|
id: string; fileName: string; sizeBytes: number;
|
||||||
container: string | null; durationSeconds: number | null; streams: Spur[];
|
container: string | null; durationSeconds: number | null; streams: Spur[];
|
||||||
/** „Mobil", „4K", „Director's Cut" — oder `null`, wenn es nur eine gibt. */
|
/** „Mobil", „4K", „Director's Cut" — oder `null`, wenn es nur eine gibt. */
|
||||||
versionLabel: string | null;
|
versionLabel: string | null;
|
||||||
|
/** Der Ordner unterhalb der Mediathek — nur für Administratoren gefüllt. */
|
||||||
|
folder: string | null;
|
||||||
};
|
};
|
||||||
/** Was dieser Benutzer mit einem Titel zu tun hatte. */
|
/** Was dieser Benutzer mit einem Titel zu tun hatte. */
|
||||||
export type Stand = {
|
export type Stand = {
|
||||||
@@ -698,6 +702,8 @@ export const bildHochladen = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Pruefeintrag = {
|
export type Pruefeintrag = {
|
||||||
|
/** „neu" (wartet auf Freigabe) oder „geaendert" (nur zur Kenntnis). */
|
||||||
|
grund?: 'neu' | 'geaendert';
|
||||||
kind: 'movie' | 'series';
|
kind: 'movie' | 'series';
|
||||||
id: string; title: string; year: number | null;
|
id: string; title: string; year: number | null;
|
||||||
posterImage: string | null;
|
posterImage: string | null;
|
||||||
@@ -713,6 +719,14 @@ export type Pruefeintrag = {
|
|||||||
|
|
||||||
export const zuPruefen = () => anfrage<Pruefeintrag[]>('/review');
|
export const zuPruefen = () => anfrage<Pruefeintrag[]>('/review');
|
||||||
|
|
||||||
|
/** Hakt geänderte Titel ab — ohne Kennung alle.
|
||||||
|
*
|
||||||
|
* **Nicht dasselbe wie veröffentlichen.** Diese Titel stehen längst in der
|
||||||
|
* Mediathek; es gibt nichts freizugeben, nur etwas zur Kenntnis zu nehmen. */
|
||||||
|
export const zurKenntnis = (ids?: string[]) =>
|
||||||
|
anfrage<{ acknowledged: number }>('/review/acknowledge',
|
||||||
|
{ method: 'POST', body: JSON.stringify({ ids: ids ?? null, publishAt: null }) });
|
||||||
|
|
||||||
/** Ohne Kennungen: alles, was noch nicht sichtbar ist. Ohne Zeitpunkt: jetzt. */
|
/** Ohne Kennungen: alles, was noch nicht sichtbar ist. Ohne Zeitpunkt: jetzt. */
|
||||||
export const veroeffentlichen = (ids?: string[], publishAt?: string) =>
|
export const veroeffentlichen = (ids?: string[], publishAt?: string) =>
|
||||||
anfrage<{ published: number }>('/review/publish',
|
anfrage<{ published: number }>('/review/publish',
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export const icons = {
|
|||||||
regler: rahmen('<path d="M4 7h7M15 7h5M4 17h3M11 17h9"/>'
|
regler: rahmen('<path d="M4 7h7M15 7h5M4 17h3M11 17h9"/>'
|
||||||
+ '<circle cx="13" cy="7" r="2.1"/><circle cx="9" cy="17" r="2.1"/>'),
|
+ '<circle cx="13" cy="7" r="2.1"/><circle cx="9" cy="17" r="2.1"/>'),
|
||||||
|
|
||||||
|
/** Eine Uhr — für alles, was erst später gilt. */
|
||||||
|
uhr: rahmen('<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 1.8"/>'),
|
||||||
|
|
||||||
/** Den Spieler auf die Leiste unten schrumpfen. */
|
/** Den Spieler auf die Leiste unten schrumpfen. */
|
||||||
verkleinern: rahmen('<path d="M5 9.5 12 16l7-6.5"/>'),
|
verkleinern: rahmen('<path d="M5 9.5 12 16l7-6.5"/>'),
|
||||||
|
|
||||||
|
|||||||
@@ -1626,3 +1626,29 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; }
|
|||||||
100% { background: transparent; }
|
100% { background: transparent; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) { .hervor { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .hervor { animation: none; } }
|
||||||
|
|
||||||
|
/* Der Ordner in den Medienangaben — schmaler gesetzt, er ist ein Pfad. */
|
||||||
|
.medientabelle dd.pfad {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px; color: var(--text-dim);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Überschrift der zweiten Gruppe in der Prüfliste. */
|
||||||
|
.pruef-gruppe {
|
||||||
|
display: flex; align-items: center; gap: var(--s-regular);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: var(--s-regular) var(--s-snug) var(--s-snug);
|
||||||
|
border-top: 1px solid var(--line); margin-top: var(--s-snug);
|
||||||
|
}
|
||||||
|
.pruef-gruppe strong { font-size: 14px; }
|
||||||
|
.pruef-gruppe .winzig { flex: 1 1 240px; }
|
||||||
|
|
||||||
|
/* „Verfügbar ab …" steht dort, wo sonst Abspielen stünde. */
|
||||||
|
.verfuegbar-ab {
|
||||||
|
display: inline-flex; align-items: center; gap: var(--s-snug);
|
||||||
|
padding: 10px 16px; border-radius: var(--r-control);
|
||||||
|
background: var(--raised); border: 1px solid var(--line);
|
||||||
|
font-size: 14px; font-weight: 600; color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.verfuegbar-ab .icon svg { width: 18px; height: 18px; }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
|
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
|
||||||
vermisste, vermisstesEntfernen, type VermissteDatei,
|
vermisste, vermisstesEntfernen, type VermissteDatei,
|
||||||
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
|
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
|
||||||
meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen,
|
meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen, zurKenntnis,
|
||||||
mediathekZuruecksetzen,
|
mediathekZuruecksetzen,
|
||||||
type Pruefeintrag,
|
type Pruefeintrag,
|
||||||
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
|
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
|
||||||
@@ -290,17 +290,48 @@ function pruefbereich(geaendert: () => void): {
|
|||||||
freigeben.textContent = `Veröffentlichen (${wieViele})`;
|
freigeben.textContent = `Veröffentlichen (${wieViele})`;
|
||||||
|
|
||||||
liste.innerHTML = '';
|
liste.innerHTML = '';
|
||||||
stand.forEach(e => liste.appendChild(zeileFuer(e)));
|
|
||||||
|
// **Zwei Gruppen, zwei Bedeutungen.** Ein neuer Fund wartet auf Freigabe
|
||||||
|
// und ist bis dahin unsichtbar; ein geänderter Titel steht längst in der
|
||||||
|
// Mediathek und soll nur nicht unbemerkt anders heissen. Beides in einer
|
||||||
|
// Reihe hiesse, „Veröffentlichen" auf etwas anzubieten, das veröffentlicht
|
||||||
|
// ist.
|
||||||
|
const neue = stand.filter(e => e.grund !== 'geaendert');
|
||||||
|
const geaenderte = stand.filter(e => e.grund === 'geaendert');
|
||||||
|
|
||||||
|
neue.forEach(e => liste.appendChild(zeileFuer(e)));
|
||||||
|
|
||||||
|
if (geaenderte.length > 0) {
|
||||||
|
const kopf = document.createElement('div');
|
||||||
|
kopf.className = 'pruef-gruppe';
|
||||||
|
kopf.innerHTML = '<strong>Geändert</strong>'
|
||||||
|
+ '<span class="winzig">Diese Titel sind schon sichtbar. Ein Auffrischen '
|
||||||
|
+ 'hat Titel, Jahr oder Plakat geändert — hier nur zur Kenntnis.</span>';
|
||||||
|
|
||||||
|
const abhaken = knopf('Alle zur Kenntnis', 'btn');
|
||||||
|
abhaken.onclick = async () => {
|
||||||
|
abhaken.disabled = true;
|
||||||
|
try { await zurKenntnis(); await laden(); }
|
||||||
|
catch (fehler) { liste.prepend(fehlerzeile(meldung(fehler))); abhaken.disabled = false; }
|
||||||
|
};
|
||||||
|
kopf.appendChild(abhaken);
|
||||||
|
|
||||||
|
liste.append(kopf, ...geaenderte.map(zeileFuer));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function zeileFuer(e: Pruefeintrag): HTMLElement {
|
function zeileFuer(e: Pruefeintrag): HTMLElement {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.className = 'einstellung pruefzeile';
|
d.className = 'einstellung pruefzeile';
|
||||||
|
|
||||||
|
const geaendertes = e.grund === 'geaendert';
|
||||||
|
|
||||||
const haken = document.createElement('input');
|
const haken = document.createElement('input');
|
||||||
haken.type = 'checkbox';
|
haken.type = 'checkbox';
|
||||||
haken.checked = gewaehlt.has(e.id);
|
haken.checked = gewaehlt.has(e.id);
|
||||||
haken.setAttribute('aria-label', `„${e.title}“ auswählen`);
|
haken.setAttribute('aria-label', `„${e.title}“ auswählen`);
|
||||||
|
// Die Sammelaktionen oben veröffentlichen — auf Geändertes passt das nicht.
|
||||||
|
haken.classList.toggle('hidden', geaendertes);
|
||||||
haken.onchange = () => {
|
haken.onchange = () => {
|
||||||
if (haken.checked) gewaehlt.add(e.id); else gewaehlt.delete(e.id);
|
if (haken.checked) gewaehlt.add(e.id); else gewaehlt.delete(e.id);
|
||||||
zeichnen();
|
zeichnen();
|
||||||
@@ -323,7 +354,9 @@ function pruefbereich(geaendert: () => void): {
|
|||||||
const was = e.kind === 'series'
|
const was = e.kind === 'series'
|
||||||
? `${e.count} ${e.count === 1 ? 'Folge' : 'Folgen'}`
|
? `${e.count} ${e.count === 1 ? 'Folge' : 'Folgen'}`
|
||||||
: null;
|
: null;
|
||||||
const lage = geplant
|
const lage = geaendertes
|
||||||
|
? 'Sichtbar — ein Auffrischen hat etwas geändert'
|
||||||
|
: geplant
|
||||||
? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}`
|
? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}`
|
||||||
: e.kind === 'series' && e.count > 1 && !e.publishAt
|
: e.kind === 'series' && e.count > 1 && !e.publishAt
|
||||||
? 'Wartet'
|
? 'Wartet'
|
||||||
@@ -335,11 +368,19 @@ function pruefbereich(geaendert: () => void): {
|
|||||||
text.innerHTML = `<strong>${esc(e.title)}${e.year ? ` (${e.year})` : ''}</strong>`
|
text.innerHTML = `<strong>${esc(e.title)}${e.year ? ` (${e.year})` : ''}</strong>`
|
||||||
+ `<span class="winzig">${[was, lage, hinweis].filter(Boolean).map(esc).join(' · ')}</span>`;
|
+ `<span class="winzig">${[was, lage, hinweis].filter(Boolean).map(esc).join(' · ')}</span>`;
|
||||||
|
|
||||||
const nur = knopf(geplant ? 'Jetzt veröffentlichen' : 'Veröffentlichen', 'btn');
|
// **Geändertes wird abgehakt, nicht veröffentlicht** — es ist schon
|
||||||
|
// sichtbar. „Veröffentlichen" wäre dort ein Knopf, der nichts tut.
|
||||||
|
const nur = knopf(
|
||||||
|
geaendertes ? 'Zur Kenntnis' : geplant ? 'Jetzt veröffentlichen' : 'Veröffentlichen',
|
||||||
|
'btn');
|
||||||
nur.onclick = async () => {
|
nur.onclick = async () => {
|
||||||
nur.disabled = true;
|
nur.disabled = true;
|
||||||
try { await veroeffentlichen(e.ids); await laden(); geaendert(); }
|
try {
|
||||||
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
|
if (geaendertes) await zurKenntnis([e.id]);
|
||||||
|
else await veroeffentlichen(e.ids);
|
||||||
|
await laden();
|
||||||
|
geaendert();
|
||||||
|
} catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
|
||||||
};
|
};
|
||||||
|
|
||||||
// **Dasselbe Dreipunktemenü wie auf einer Kachel.**
|
// **Dasselbe Dreipunktemenü wie auf einer Kachel.**
|
||||||
|
|||||||
+82
-22
@@ -239,7 +239,25 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
|||||||
const hinweisfeld = document.createElement('div');
|
const hinweisfeld = document.createElement('div');
|
||||||
hinweisfeld.className = 'hinweis';
|
hinweisfeld.className = 'hinweis';
|
||||||
|
|
||||||
if (datei) {
|
// **Geplant heisst sichtbar, nicht abspielbar.**
|
||||||
|
//
|
||||||
|
// Der Titel steht in der Mediathek, damit man weiss, dass er kommt — das
|
||||||
|
// ist der Sinn des Planens. Abspielen und Herunterladen gibt es erst ab
|
||||||
|
// dem Termin, und der Server lehnt sie bis dahin auch ab; stünden die
|
||||||
|
// Knöpfe trotzdem da, wäre die Angabe eine Zierde.
|
||||||
|
const geplantAb = eintrag.publishAt ? new Date(eintrag.publishAt) : null;
|
||||||
|
const nochNicht = geplantAb !== null && geplantAb > new Date();
|
||||||
|
|
||||||
|
if (nochNicht) {
|
||||||
|
const hinweisZeile = document.createElement('div');
|
||||||
|
hinweisZeile.className = 'verfuegbar-ab';
|
||||||
|
hinweisZeile.append(icon('uhr'), document.createTextNode(
|
||||||
|
`Verfügbar ab ${geplantAb!.toLocaleString('de-CH',
|
||||||
|
{ dateStyle: 'long', timeStyle: 'short' })}`));
|
||||||
|
reihe.appendChild(hinweisZeile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datei && !nochNicht) {
|
||||||
const stelle = stand?.positionSeconds ?? 0;
|
const stelle = stand?.positionSeconds ?? 0;
|
||||||
const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
|
const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
|
||||||
los.prepend(icon('abspielen'));
|
los.prepend(icon('abspielen'));
|
||||||
@@ -305,6 +323,19 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
|||||||
/* ---------- Medienangaben ---------- */
|
/* ---------- Medienangaben ---------- */
|
||||||
|
|
||||||
/** Video / Ton / Untertitel — Plex' Tabelle unter der Beschreibung. */
|
/** Video / Ton / Untertitel — Plex' Tabelle unter der Beschreibung. */
|
||||||
|
/** Was über einer Datei steht.
|
||||||
|
*
|
||||||
|
* **Zwei Leserschaften, zwei Bedürfnisse.**
|
||||||
|
*
|
||||||
|
* Wer einen Film sucht, will wissen: wie lang, in welcher Qualität, in
|
||||||
|
* welchen Sprachen — Ton wie Untertitel. Dateiformat, Grösse und Ordner
|
||||||
|
* sagen ihm nichts und gehen ihn auch nichts an: der Pfad verriete den
|
||||||
|
* Aufbau des Servers.
|
||||||
|
*
|
||||||
|
* Wer die Mediathek betreut, braucht genau das Gegenteil. Deshalb stehen
|
||||||
|
* die technischen Angaben darunter und nur für Administratoren — der Ordner
|
||||||
|
* kommt ohnehin nur dann vom Server.
|
||||||
|
*/
|
||||||
function medienangaben(datei: Datei): HTMLElement {
|
function medienangaben(datei: Datei): HTMLElement {
|
||||||
const liste = document.createElement('dl');
|
const liste = document.createElement('dl');
|
||||||
liste.className = 'medientabelle';
|
liste.className = 'medientabelle';
|
||||||
@@ -313,19 +344,59 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
|||||||
const ton = datei.streams.filter(s => s.kind === 'Audio');
|
const ton = datei.streams.filter(s => s.kind === 'Audio');
|
||||||
const text = datei.streams.filter(s => s.kind === 'Subtitle');
|
const text = datei.streams.filter(s => s.kind === 'Subtitle');
|
||||||
|
|
||||||
zeile('Datei', [datei.container?.toUpperCase(), groesse(datei.sizeBytes),
|
/* ---------- Für alle ---------- */
|
||||||
dauer(datei.durationSeconds)].filter(Boolean).join(' · '));
|
|
||||||
// **Auch hier die Formulierung vom Server.** Vorher baute diese Zeile
|
if (datei.durationSeconds) zeile('Laufzeit', laufzeit(datei.durationSeconds));
|
||||||
// ihren Text selbst — „240p (H264)" statt „240p · H.264" —, und damit
|
|
||||||
// stand in derselben Tabelle zweierlei Schreibweise untereinander.
|
|
||||||
if (bild) {
|
if (bild) {
|
||||||
const v = zeile('Video', spurname(bild));
|
// Nur die Stufe, nicht der Codec: „H.264" ist für den, der schauen
|
||||||
// Die gemessene Höhe gehört in den Tooltip: 1040 ist keine Stufe,
|
// will, kein Unterschied, den er trifft.
|
||||||
// sondern ein beschnittenes Bild.
|
const stufe = bild.display?.split(' · ')[0] ?? null;
|
||||||
|
if (stufe) {
|
||||||
|
const v = zeile('Auflösung', stufe);
|
||||||
|
// Die gemessene Grösse gehört in den Tooltip: 1920×800 ist kein
|
||||||
|
// Fehler, sondern Kinoformat.
|
||||||
if (bild.width && bild.height) v.title = `${bild.width} × ${bild.height}`;
|
if (bild.width && bild.height) v.title = `${bild.width} × ${bild.height}`;
|
||||||
}
|
}
|
||||||
if (ton.length) zeile('Ton', ton.map(spurname).join(' · '));
|
}
|
||||||
if (text.length) zeile('Untertitel', text.map(spurname).join(' · '));
|
|
||||||
|
// **Nur die Sprachen, und jede einmal.** Vorher stand hier „Englisch ·
|
||||||
|
// Englisch · Englisch · Englisch · Deutsch · …", weil jede Spur einzeln
|
||||||
|
// aufgezählt wurde — bei sieben Tonspuren eine Zeile, die niemand liest.
|
||||||
|
if (ton.length) zeile('Ton', sprachen(ton));
|
||||||
|
if (text.length) zeile('Untertitel', sprachen(text));
|
||||||
|
|
||||||
|
/* ---------- Nur für die Verwaltung ---------- */
|
||||||
|
|
||||||
|
if (istAdmin()) {
|
||||||
|
const technisch = [datei.container?.toUpperCase(), groesse(datei.sizeBytes)]
|
||||||
|
.filter(Boolean).join(' · ');
|
||||||
|
if (technisch) {
|
||||||
|
const d = zeile('Datei', technisch);
|
||||||
|
d.title = datei.fileName;
|
||||||
|
}
|
||||||
|
// Der Ordner verrät eine falsche Zuordnung in einer Sekunde: steht über
|
||||||
|
// der Beschreibung „Hanni & Nanni 3" und der Ordner heisst „Hanni und
|
||||||
|
// Nanni", ist die Sache klar — ohne Umweg über den Zuordnungsdialog.
|
||||||
|
if (datei.folder) zeile('Ordner', datei.folder).className = 'pfad';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Sprachen einer Spurenliste — jede einmal, in ihrer Reihenfolge. */
|
||||||
|
function sprachen(spuren: Datei['streams']): string {
|
||||||
|
const namen: string[] = [];
|
||||||
|
spuren.forEach(s => {
|
||||||
|
const name = s.languageName ?? s.display?.split(' · ')[0] ?? null;
|
||||||
|
if (name && !namen.includes(name)) namen.push(name);
|
||||||
|
});
|
||||||
|
return namen.length > 0 ? namen.join(' · ') : `${spuren.length} Spuren`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „2 Std. 25 Min." — nicht „2:25:01". */
|
||||||
|
function laufzeit(sekunden: number): string {
|
||||||
|
const m = Math.round(sekunden / 60);
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
return h > 0 ? `${h} Std. ${m % 60} Min.` : `${m} Min.`;
|
||||||
|
}
|
||||||
|
|
||||||
function zeile(name: string, wert: string): HTMLElement {
|
function zeile(name: string, wert: string): HTMLElement {
|
||||||
const dt = document.createElement('dt');
|
const dt = document.createElement('dt');
|
||||||
@@ -339,17 +410,6 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
|||||||
return liste;
|
return liste;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der Name einer Spur — vom Server, nicht hier zusammengesetzt.
|
|
||||||
*
|
|
||||||
* **Vorher stand hier „DEU EAC3 6.0".** Jedes Stück davon war richtig und
|
|
||||||
* keines verständlich: EAC3 heisst Dolby Digital Plus, und sechs Kanäle
|
|
||||||
* sind 5.1, nicht 6.0. Der Server formuliert das jetzt, weil die Apps
|
|
||||||
* denselben Satz brauchen — zweimal formuliert driftet er auseinander. */
|
|
||||||
function spurname(s: Datei['streams'][number]): string {
|
|
||||||
if (s.display) return s.display;
|
|
||||||
// Rückfall für einen älteren Server, der das Feld noch nicht schickt.
|
|
||||||
return [s.language?.toUpperCase(), s.codec?.toUpperCase()].filter(Boolean).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Besetzung ---------- */
|
/* ---------- Besetzung ---------- */
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user