Zwei Fassungen dürfen gewollt sein, und die Startseite darf nicht warten

**Mehrfachfassungen lassen sich abhaken.** Zwei Dateien an einem Film sind
meist zwei Fassungen und völlig in Ordnung. In der Liste standen sie
trotzdem für immer: sie rechnet jedes Mal frisch, und es gab keinen Zustand.
Wer absichtlich zwei Fassungen hält, konnte die Gruppe nicht loswerden — und
unter den gewollten ging das Ungewollte unter.

Abgehakt wird eine Dateizahl, kein Zeitpunkt. Ein Zeitstempel müsste gegen
`MediaFile.ModifiedAt` verglichen werden, und eine alte Datei, frisch
hineinkopiert, behält ihre alte Zeit und käme nie wieder hoch. Die Anzahl
ist genau die Frage: kommt eine dritte Fassung dazu, meldet sich die Gruppe
von selbst zurück. Gerechnet wird sie auf dem Server — ein Client mit
veralteter Liste hakte sonst eine Gruppe mit zwei Dateien ab, die inzwischen
drei hat.

„Ignorierte zeigen" führt zurück, wie bei den erledigten Meldungen; ohne das
wäre das Abhaken unumkehrbar und niemand wüsste, was er weggeklickt hat.

**Die Startseite wartete dreimal nacheinander.** Erst die Serverauskunft,
dann Konto und Mediatheken, dann erst die Startseite — drei Rundreisen,
bevor das erste Bild in Auftrag ging. Liegt eine Sitzung im Speicher, laufen
die ersten beiden jetzt zugleich los. Nur dann: ohne Sitzung liefen
`/auth/me` und `/libraries` vor der Antwort auf `needsSetup` in ein 401, und
die Sitzungsprüfung kann daraufhin abmelden.

Gemessen am laufenden Server: `users/me`, `system/info` und `libraries`
starten alle bei 37 ms, die Startseitenabfragen folgen bei 73 ms. Vorher
lagen drei Runden dazwischen.

**Sieben Hintergrundbilder auf einmal waren sechs zu viel.** Jede Folie des
Aufmachers bekam sofort ihr `w1280`-Bild; sichtbar ist immer nur eine. Jetzt
lädt die sichtbare sofort und der Rest in der Leerlaufzeit — und wer
weiterschaltet, bevor das lief, bekommt sein Bild dann.

**Die Schrift kam von Google.** `display=swap` rettete den Text, nicht den
ersten Aufbau: ein Stylesheet blockiert das Zeichnen, und ein Heimserver
ohne Internet oder mit träger Namensauflösung wartete davor. Inter liegt
jetzt als zwei Dateien von zusammen sechzig Kilobyte im Projekt, variabel
für alle Schnitte, mit `unicode-range` getrennt nach latin und latin-ext.
Gemessen: null Anfragen an fonts.googleapis.com. Lizenztext liegt dabei.

**Und mein Untertitelvorrat bremste mit.** Er startete sofort beim
Hochfahren und las die ganze Mediathek — von derselben Platte, von der die
Startseite ihre Bilder holt. Jetzt beginnt er eine Minute später und stellt
zurück, solange eine Wiedergabe läuft. Die Datei bleibt dabei vorn in der
Reihe; verworfen wird nichts.

678 Tests grün, davon zwei neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 17:56:41 +02:00
co-authored by Claude Opus 5
parent 2809821c9b
commit 1b4784f1db
18 changed files with 1653 additions and 27 deletions
@@ -46,6 +46,14 @@ public sealed record Unzugeordnet(int Gesamt, IReadOnlyList<UnzugeordneteDatei>
public sealed record OhneZuordnung( public sealed record OhneZuordnung(
Guid Id, string Art, string Titel, int? Jahr, int Dateien, string? Beispiel); Guid Id, string Art, string Titel, int? Jahr, int Dateien, string? Beispiel);
/// <summary>Welche Mehrfachfassungen abgehakt oder wieder hervorgeholt werden.</summary>
///
/// <param name="Ignorieren">
/// <c>true</c> hakt ab, <c>false</c> holt zurück. Ein Weg statt zweier
/// Endpunkte: es ist dieselbe Spalte, nur einmal gesetzt und einmal geleert.
/// </param>
public sealed record FassungenRequest(IReadOnlyList<Guid> Ids, bool Ignorieren = true);
/// <param name="Titel">Titel mit mehr als einer Datei — dort versteckt sich Falsches.</param> /// <param name="Titel">Titel mit mehr als einer Datei — dort versteckt sich Falsches.</param>
public sealed record Mehrfachbericht( public sealed record Mehrfachbericht(
int Gesamt, IReadOnlyList<Mehrfachtitel> Titel); int Gesamt, IReadOnlyList<Mehrfachtitel> Titel);
@@ -358,13 +358,23 @@ public static class ManagementEndpoints
// Metadatenquelle auf denselben Treffer liefen — und dann ist einer // Metadatenquelle auf denselben Treffer liefen — und dann ist einer
// davon lautlos verschwunden. Wer die Liste durchsieht, erkennt das // davon lautlos verschwunden. Wer die Liste durchsieht, erkennt das
// an den Dateinamen in zwei Sekunden. // an den Dateinamen in zwei Sekunden.
//
// **`ignorierte` zeigt, was abgehakt wurde.** Ohne diesen Schalter
// wäre das Abhaken unumkehrbar, und niemand wüsste hinterher, was er
// weggeklickt hat — dieselbe Überlegung wie bei den Meldungen.
bibliotheken.MapGet("/{id:guid}/versions", async ( bibliotheken.MapGet("/{id:guid}/versions", async (
Guid id, FlaroContext db, int take = 300) => Guid id, FlaroContext db, int take = 300, bool ignorierte = false) =>
{ {
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound(); if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound();
var mehrfach = await db.Movies.AsNoTracking() var mehrfach = await db.Movies.AsNoTracking()
.Where(m => m.LibraryId == id && db.MediaFiles.Count(f => f.MovieId == m.Id) > 1) .Where(m => m.LibraryId == id && db.MediaFiles.Count(f => f.MovieId == m.Id) > 1)
// Abgehakt ist, wer bei genau dieser Dateizahl abgehakt
// wurde. Kommt eine Fassung dazu, stimmt die Zahl nicht mehr
// und die Gruppe meldet sich von selbst zurück.
.Where(m => ignorierte
? m.FassungenGeprueftBei == db.MediaFiles.Count(f => f.MovieId == m.Id)
: m.FassungenGeprueftBei != db.MediaFiles.Count(f => f.MovieId == m.Id))
.OrderBy(m => m.SortTitleValue) .OrderBy(m => m.SortTitleValue)
.Take(Math.Clamp(take, 1, 2000)) .Take(Math.Clamp(take, 1, 2000))
.Select(m => new .Select(m => new
@@ -384,6 +394,33 @@ public static class ManagementEndpoints
return Results.Ok(new Mehrfachbericht(liste.Count, liste)); return Results.Ok(new Mehrfachbericht(liste.Count, liste));
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
/// Abhaken und wieder hervorholen — beides über denselben Weg.
bibliotheken.MapPost("/{id:guid}/versions/acknowledge", async (
Guid id, FassungenRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id, ct)) return Results.NotFound();
if (anfrage.Ids is not { Count: > 0 }) return Einwand.Bitte("Es war kein Titel ausgewählt.");
var gewaehlt = anfrage.Ids.ToHashSet();
// **Die Zahl wird hier gerechnet, nicht vom Client geschickt.**
// Ein Client, der eine veraltete Liste anzeigt, hakte sonst eine
// Gruppe mit zwei Dateien ab, die inzwischen drei hat — und die
// dritte verschwände ungesehen.
var betroffen = await db.Movies
.Where(m => m.LibraryId == id && gewaehlt.Contains(m.Id))
.Select(m => new { Film = m, Anzahl = db.MediaFiles.Count(f => f.MovieId == m.Id) })
.ToListAsync(ct);
foreach (var z in betroffen)
{
z.Film.FassungenGeprueftBei = anfrage.Ignorieren ? z.Anzahl : null;
}
await db.SaveChangesAsync(ct);
return Results.Ok(new { geaendert = betroffen.Count });
}).RequireAuthorization(FlaroPolicies.Administrator);
bibliotheken.MapGet("/missing", async (FlaroContext db) => bibliotheken.MapGet("/missing", async (FlaroContext db) =>
{ {
var liste = await db.MediaFiles.AsNoTracking() var liste = await db.MediaFiles.AsNoTracking()
+16 -1
View File
@@ -322,8 +322,23 @@ if (Directory.Exists(oberfläche))
// Das im Augenblick des Einschaltens zu tun hiess: zwei Minuten // Das im Augenblick des Einschaltens zu tun hiess: zwei Minuten
// „wird geholt", und noch immer kein Text. Hier läuft es einmal je // „wird geholt", und noch immer kein Text. Hier läuft es einmal je
// Datei durch, eine nach der anderen, während niemand wartet. // Datei durch, eine nach der anderen, während niemand wartet.
_ = app.Services.GetRequiredService<Untertitelvorrat>() //
// **Und nicht sofort.** Der Lauf liest die ganze Mediathek von
// derselben Platte, von der die Startseite ihre Bilder holt. Wer in
// dem Augenblick aufmacht, in dem der Server hochkommt — und das ist
// der häufige Fall, denn man startet ihn ja, um etwas zu schauen —,
// wartete auf beides zugleich. Eine Minute reicht, damit die erste
// Seite steht.
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromMinutes(1), leben.ApplicationStopping);
await app.Services.GetRequiredService<Untertitelvorrat>()
.NachtragenAsync(leben.ApplicationStopping); .NachtragenAsync(leben.ApplicationStopping);
}
catch (OperationCanceledException) { /* Der Server fährt herunter. */ }
});
}); });
leben.ApplicationStopping.Register(() => leben.ApplicationStopping.Register(() =>
tagebuch.Schreiben("Server fährt herunter.")); tagebuch.Schreiben("Server fährt herunter."));
+29
View File
@@ -39,8 +39,21 @@ public sealed class Untertitelvorrat(
FlaroPaths pfade, FlaroPaths pfade,
UntertitelLeser leser, UntertitelLeser leser,
IServiceScopeFactory scopes, IServiceScopeFactory scopes,
PlaybackSessions laufende,
ILogger<Untertitelvorrat> log) ILogger<Untertitelvorrat> log)
{ {
/// <summary>Wie lange gewartet wird, solange jemand zusieht.</summary>
///
/// <remarks>
/// <b>Die laufende Wiedergabe ist wichtiger als der Vorrat.</b> Gelesen
/// wird von derselben Platte, von der auch der Film kommt und die
/// Startseite ihre Bilder holt — und eine Datei zu lesen heisst hier,
/// vierzig Gigabyte zu lesen. Der Vorrat hat Zeit, der Zuschauer nicht.
/// Die Wiedergabesitzungen verfallen nach einer Minute ohne Meldung, ein
/// halbminütiger Blick trifft das Ende also knapp genug.
/// </remarks>
private static readonly TimeSpan Rücksicht = TimeSpan.FromSeconds(30);
/// <summary>Was ein Abruf vorfindet.</summary> /// <summary>Was ein Abruf vorfindet.</summary>
public enum Stand public enum Stand
{ {
@@ -191,6 +204,22 @@ public sealed class Untertitelvorrat(
Laufende = naechste; Laufende = naechste;
} }
// **Erst nachsehen, ob jemand zusieht.** Zurückgestellt, nicht
// verworfen: die Datei bleibt vorn in der Reihe und kommt dran,
// sobald der Abspann läuft.
while (laufende.Laufende().Count > 0)
{
lock (_schloss) { _reihe.AddFirst(naechste); Laufende = null; }
await Task.Delay(Rücksicht);
lock (_schloss)
{
if (_reihe.First is null) { _arbeitet = false; return; }
naechste = _reihe.First.Value;
_reihe.RemoveFirst();
Laufende = naechste;
}
}
try { await Ziehen(naechste); } try { await Ziehen(naechste); }
catch (Exception fehler) catch (Exception fehler)
{ {
@@ -142,6 +142,32 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert
public bool BackdropPinned { get; set; } public bool BackdropPinned { get; set; }
public bool LogoPinned { get; set; } public bool LogoPinned { get; set; }
/// <summary>Wie viele Dateien dieser Film hatte, als jemand die Fassungen abhakte.</summary>
///
/// <remarks>
/// <para>
/// <b>Wozu.</b> Zwei Dateien an einem Film sind meist zwei Fassungen und
/// völlig in Ordnung. In der Liste „Mehrfachfassungen" standen sie
/// trotzdem für immer, weil die Liste jedes Mal frisch rechnet und es
/// keinen Zustand gab — wer absichtlich zwei Fassungen hält, konnte die
/// Gruppe nicht loswerden.
/// </para>
/// <para>
/// <b>Warum eine Anzahl und kein Zeitstempel.</b> Ein Zeitstempel müsste
/// gegen etwas verglichen werden, und das wäre <c>MediaFile.ModifiedAt</c>
/// — die Änderungszeit der Quelldatei. Eine alte Datei, frisch
/// hineinkopiert, behält ihre alte Zeit und käme nie wieder hoch. Die
/// Anzahl ist genau die Frage: kommt eine dritte Fassung dazu, soll die
/// Gruppe wieder erscheinen.
/// </para>
/// <para>
/// Der Preis ist bekannt: wird eine Datei gegen eine andere getauscht,
/// bleibt die Gruppe abgehakt. Das ist die seltenere Hälfte, und
/// „Ignorierte zeigen" führt zurück.
/// </para>
/// </remarks>
public int? FassungenGeprueftBei { get; set; }
/// <summary>Der Titel, wie ihn jemand von Hand gesetzt hat.</summary> /// <summary>Der Titel, wie ihn jemand von Hand gesetzt hat.</summary>
/// ///
/// <remarks> /// <remarks>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Fassungspruefung : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "FassungenGeprueftBei",
table: "Movies",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FassungenGeprueftBei",
table: "Movies");
}
}
}
@@ -561,6 +561,9 @@ namespace Flaro.Data.Migrations
.HasMaxLength(16) .HasMaxLength(16)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("FassungenGeprueftBei")
.HasColumnType("INTEGER");
b.Property<string>("ImdbId") b.Property<string>("ImdbId")
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -560,6 +560,98 @@ public class MetadataTests
}); });
} }
/// <summary>Eine abgehakte Gruppe verlässt die Liste — und kommt zurück, wenn eine Datei dazukommt.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Anlass.</b> Zwei Dateien an einem Film sind meist zwei
/// Fassungen und völlig in Ordnung. In der Liste standen sie trotzdem für
/// immer: sie rechnet jedes Mal frisch, und es gab keinen Zustand. Wer
/// absichtlich zwei Fassungen hält, konnte die Gruppe nicht loswerden —
/// und unter den gewollten ging das Ungewollte unter.
/// </para>
/// <para>
/// <b>Abgehakt wird eine Dateizahl, kein Zeitpunkt.</b> Kommt eine dritte
/// Fassung dazu, stimmt die Zahl nicht mehr und die Gruppe meldet sich
/// von selbst zurück. Genau das prüft der zweite Teil.
/// </para>
/// </remarks>
[Test]
public async Task Eine_abgehakte_Fassungsgruppe_meldet_sich_bei_einer_neuen_Datei_zurueck()
{
Datei("Hanni und Nanni/Hanni und Nanni.mp4");
Datei("Hanni und Nanni/Hanni und Nanni 3.mp4");
var anbieter = new VorgegebenerAnbieter
{
Film = new MovieMetadata(225313, "Hanni & Nanni 3", 2013, null, null, null),
};
using var server = MitAnbieter(anbieter);
var (client, bibliothek) = await Einrichten(server, "Movies");
await Scannen(client, bibliothek);
var vorher = await client.GetFromJsonAsync<Mehrfachbericht>(
$"/api/v1/libraries/{bibliothek}/versions");
Assert.That(vorher!.Gesamt, Is.EqualTo(1), "zwei Dateien an einem Titel");
var film = vorher.Titel[0].Id;
(await client.PostAsJsonAsync($"/api/v1/libraries/{bibliothek}/versions/acknowledge",
new FassungenRequest([film]))).EnsureSuccessStatusCode();
var offen = await client.GetFromJsonAsync<Mehrfachbericht>(
$"/api/v1/libraries/{bibliothek}/versions");
var abgehakt = await client.GetFromJsonAsync<Mehrfachbericht>(
$"/api/v1/libraries/{bibliothek}/versions?ignorierte=true");
Assert.Multiple(() =>
{
Assert.That(offen!.Gesamt, Is.Zero, "abgehakt heisst: nicht mehr in der offenen Liste");
Assert.That(abgehakt!.Gesamt, Is.EqualTo(1), "aber nachsehen muss man können");
});
// Eine dritte Fassung dazu — jetzt stimmt die abgehakte Zahl nicht mehr.
Datei("Hanni und Nanni/Hanni und Nanni 2.mp4");
await Scannen(client, bibliothek);
var wieder = await client.GetFromJsonAsync<Mehrfachbericht>(
$"/api/v1/libraries/{bibliothek}/versions");
Assert.That(wieder!.Gesamt, Is.EqualTo(1),
"eine neue Datei macht die Gruppe wieder zu einer Frage");
}
/// <summary>Zurückholen macht das Abhaken rückgängig.</summary>
[Test]
public async Task Eine_abgehakte_Gruppe_laesst_sich_zurueckholen()
{
Datei("Hanni und Nanni/Hanni und Nanni.mp4");
Datei("Hanni und Nanni/Hanni und Nanni 3.mp4");
var anbieter = new VorgegebenerAnbieter
{
Film = new MovieMetadata(225313, "Hanni & Nanni 3", 2013, null, null, null),
};
using var server = MitAnbieter(anbieter);
var (client, bibliothek) = await Einrichten(server, "Movies");
await Scannen(client, bibliothek);
var film = (await client.GetFromJsonAsync<Mehrfachbericht>(
$"/api/v1/libraries/{bibliothek}/versions"))!.Titel[0].Id;
await client.PostAsJsonAsync($"/api/v1/libraries/{bibliothek}/versions/acknowledge",
new FassungenRequest([film]));
await client.PostAsJsonAsync($"/api/v1/libraries/{bibliothek}/versions/acknowledge",
new FassungenRequest([film], Ignorieren: false));
var offen = await client.GetFromJsonAsync<Mehrfachbericht>(
$"/api/v1/libraries/{bibliothek}/versions");
Assert.That(offen!.Gesamt, Is.EqualTo(1));
}
/// <summary>Eine einzelne Datei lässt sich nicht herauslösen.</summary> /// <summary>Eine einzelne Datei lässt sich nicht herauslösen.</summary>
[Test] [Test]
public async Task Eine_einzelne_Datei_laesst_sich_nicht_herausloesen() public async Task Eine_einzelne_Datei_laesst_sich_nicht_herausloesen()
+13 -7
View File
@@ -20,13 +20,19 @@
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png"> <link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
<link rel="apple-touch-icon" href="/icon-180.png"> <link rel="apple-touch-icon" href="/icon-180.png">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<!-- **Inter, mit der Systemschrift als Rückfall.** `display=swap`, damit der <!-- **Inter liegt beim Server, nicht bei Google.**
Text sofort steht und nicht erst, wenn die Schrift da ist — ein
Heimserver im eigenen Netz soll nicht auf Google warten. --> Hier stand ein Stylesheet von `fonts.googleapis.com`. `display=swap`
<link rel="preconnect" href="https://fonts.googleapis.com"> rettete den Text, nicht den ersten Aufbau: ein Stylesheet blockiert
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> das Zeichnen, und ein Heimserver ohne Internet oder mit träger
<link rel="stylesheet" Namensauflösung wartete davor. Dass die Oberfläche eines Servers im
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"> eigenen Netz überhaupt eine fremde Verbindung braucht, um auszusehen
wie sie soll, war ausserdem eine stille Abhängigkeit.
Die beiden Teilmengen stehen in `style.css` als `@font-face`;
`preload` holt die lateinische, bevor das Stylesheet sie verlangt. -->
<link rel="preload" href="/schriften/inter-latin.woff2" as="font"
type="font/woff2" crossorigin>
</head> </head>
<body> <body>
<section id="login"></section> <section id="login"></section>
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+12 -2
View File
@@ -1074,8 +1074,18 @@ export type Mehrfachtitel = {
export type Mehrfachbericht = { gesamt: number; titel: Mehrfachtitel[] }; export type Mehrfachbericht = { gesamt: number; titel: Mehrfachtitel[] };
/** Titel mit mehr als einer Datei — dort versteckt sich falsch Zusammengelegtes. */ /** Titel mit mehr als einer Datei — dort versteckt sich falsch Zusammengelegtes. */
export const mehrfachfassungen = (bibliothek: string, anzahl = 300) => /** Hakt Mehrfachfassungen ab — oder holt sie zurück.
anfrage<Mehrfachbericht>(`/libraries/${bibliothek}/versions?take=${anzahl}`); *
* **Die Dateizahl rechnet der Server.** Ein Client mit veralteter Liste
* hakte sonst eine Gruppe mit zwei Dateien ab, die inzwischen drei hat, und
* die dritte verschwände ungesehen. */
export const fassungenAbhaken = (bibliothek: string, ids: string[], ignorieren = true) =>
anfrage<{ geaendert: number }>(`/libraries/${bibliothek}/versions/acknowledge`, {
method: 'POST', body: JSON.stringify({ ids, ignorieren }),
});
export const mehrfachfassungen = (bibliothek: string, anzahl = 300, ignorierte = false) =>
anfrage<Mehrfachbericht>(`/libraries/${bibliothek}/versions?take=${anzahl}&ignorierte=${ignorierte}`);
/** Löst eine Datei aus ihrem Titel heraus — sie wird ein eigener Film. /** Löst eine Datei aus ihrem Titel heraus — sie wird ein eigener Film.
* *
+31 -2
View File
@@ -69,15 +69,41 @@ export function hero(
const buehne = document.createElement('div'); const buehne = document.createElement('div');
buehne.className = 'hero-buehne'; buehne.className = 'hero-buehne';
const folien = mitBild.map((e, i) => { const folien = mitBild.map((_, i) => {
const folie = document.createElement('div'); const folie = document.createElement('div');
folie.className = 'hero-folie' + (i === 0 ? ' an' : ''); folie.className = 'hero-folie' + (i === 0 ? ' an' : '');
folie.style.backgroundImage = `url("${bildAdresse(e.hintergrund)}")`;
folie.setAttribute('aria-hidden', i === 0 ? 'false' : 'true'); folie.setAttribute('aria-hidden', i === 0 ? 'false' : 'true');
buehne.appendChild(folie); buehne.appendChild(folie);
return folie; return folie;
}); });
/** Hängt einer Folie ihr Bild an — und nur dieser, und nur einmal.
*
* **Sieben Bilder auf einmal waren sechs zu viel.** Hier wurde jeder
* Folie sofort ihr Hintergrundbild gesetzt; sichtbar ist aber immer nur
* eine, und es sind `w1280`-Bilder. Beim ersten Aufbau der Startseite
* gingen damit sieben grosse Abrufe gleichzeitig hinaus — über dieselbe
* Leitung, auf der auch die Plakate und die Reihen kommen. */
function bildAnhaengen(i: number): void {
const folie = folien[i];
const eintrag = mitBild[i];
if (!folie || !eintrag || folie.style.backgroundImage) return;
folie.style.backgroundImage = `url("${bildAdresse(eintrag.hintergrund)}")`;
}
bildAnhaengen(0);
// **Der Rest, sobald der Browser Luft hat.** Nicht erst beim
// Weiterschalten: dann sähe man beim ersten Wechsel einen leeren Kasten.
// `requestIdleCallback` kennt nicht jeder Browser — der Rückfall ist ein
// Zeitgeber, und eine Sekunde reicht für den ersten Aufbau.
const nachtragen = () => mitBild.forEach((_, i) => bildAnhaengen(i));
const leerlauf = (window as unknown as {
requestIdleCallback?: (f: () => void, o?: { timeout: number }) => void;
}).requestIdleCallback;
if (leerlauf) leerlauf(nachtragen, { timeout: 3000 });
else window.setTimeout(nachtragen, 1000);
const inhalt = document.createElement('div'); const inhalt = document.createElement('div');
inhalt.className = 'hero-inhalt'; inhalt.className = 'hero-inhalt';
@@ -172,6 +198,9 @@ export function hero(
jetzt = ((i % mitBild.length) + mitBild.length) % mitBild.length; jetzt = ((i % mitBild.length) + mitBild.length) % mitBild.length;
const e = mitBild[jetzt]!; const e = mitBild[jetzt]!;
// Wer weiterschaltet, bevor der Nachtrag lief, bekommt sein Bild jetzt.
bildAnhaengen(jetzt);
folien.forEach((f, n) => { folien.forEach((f, n) => {
f.classList.toggle('an', n === jetzt); f.classList.toggle('an', n === jetzt);
f.setAttribute('aria-hidden', n === jetzt ? 'false' : 'true'); f.setAttribute('aria-hidden', n === jetzt ? 'false' : 'true');
+21 -3
View File
@@ -34,6 +34,22 @@ void hochfahren();
async function hochfahren(): Promise<void> { async function hochfahren(): Promise<void> {
try { try {
// **Drei Runden waren zwei zu viel.**
//
// Vorher wartete der Start auf die Serverauskunft, dann auf Konto und
// Mediatheken, und erst dann fing die Startseite an zu laden — drei
// Rundreisen nacheinander, bevor das erste Bild in Auftrag ging.
//
// Liegt eine Sitzung im Speicher, laufen die ersten beiden jetzt
// zugleich los. **Nur dann**, und das ist keine Vorsicht um ihrer selbst
// willen: ohne Sitzung liefen `/auth/me` und `/libraries` vor der
// Antwort auf `needsSetup` in ein 401, und die Sitzungsprüfung in
// `api.ts` kann daraufhin abmelden. Ein frischer Server soll nicht
// dadurch langsamer werden, dass er sich selbst hinauswirft.
const vorgeholt = angemeldet()
? Promise.all([kontoAbgleichen(), bibliotheken()]).catch(() => null)
: null;
const auskunft = await systemauskunft(); const auskunft = await systemauskunft();
hinweise = auskunft.attributions; hinweise = auskunft.attributions;
serverName = auskunft.serverName ?? 'Flaro'; serverName = auskunft.serverName ?? 'Flaro';
@@ -74,9 +90,11 @@ async function hochfahren(): Promise<void> {
// **Das Konto frisch holen, bevor die Kopfleiste steht.** Sie zeigt Bild // **Das Konto frisch holen, bevor die Kopfleiste steht.** Sie zeigt Bild
// und Namen aus der gemerkten Sitzung, und die stammt von der Anmeldung — // und Namen aus der gemerkten Sitzung, und die stammt von der Anmeldung —
// ein Bild, das anderswo gesetzt wurde, käme hier nie an. // ein Bild, das anderswo gesetzt wurde, käme hier nie an.
await Promise.all([kontoAbgleichen(), (async () => { // Das Vorgeholte nehmen, wenn es durchkam; sonst jetzt holen. Ein
mediatheken = await bibliotheken(); // gescheiterter Vorgriff darf nichts kosten ausser der Wiederholung.
})()]); const beides = await vorgeholt
?? await Promise.all([kontoAbgleichen(), bibliotheken()]);
mediatheken = beides[1];
kopfleisteBauen(); kopfleisteBauen();
leisteBauen(); leisteBauen();
+44
View File
@@ -8,6 +8,50 @@
* **Die Farbe nicht.** Plex ist bernsteingelb, Flaro ist Orchidee. Das * **Die Farbe nicht.** Plex ist bernsteingelb, Flaro ist Orchidee. Das
* Vorbild ist die Anordnung, nicht das Aussehen. */ * Vorbild ist die Anordnung, nicht das Aussehen. */
/* ---------- Die Schrift, und zwar von hier ----------
*
* **Warum sie mitgeliefert wird.** Vorher kam sie über ein Stylesheet von
* `fonts.googleapis.com`. Ein Stylesheet blockiert das Zeichnen; ein
* Heimserver ohne Internet oder mit träger Namensauflösung wartete davor,
* bevor das erste Pixel stand. Zwei Dateien, zusammen sechzig Kilobyte,
* nehmen diese Abhängigkeit ganz weg.
*
* **Eine Datei für alle Schnitte.** Inter ist eine variable Schrift: 400
* bis 700 stecken in derselben Datei. Vier getrennte Schnitte wären vier
* Abrufe für dasselbe.
*
* `latin-ext` ist eigen aufgeführt und nicht eingerechnet: die
* `unicode-range` sorgt dafür, dass sie nur lädt, wenn wirklich ein Zeichen
* daraus vorkommt — bei deutschen Titeln selten, bei polnischen oder
* tschechischen eben doch.
*
* Inter steht unter der SIL Open Font License; der Text liegt bei den
* Dateien unter `public/schriften/LIZENZ.txt`. */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400 700;
/* Der Text steht sofort in der Systemschrift und wird ersetzt, sobald
Inter da ist — lieber ein Umspringen als ein leeres Bild. */
font-display: swap;
src: url('/schriften/inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/schriften/inter-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
:root { :root {
/* --- Akzent ------------------------------------------------------------ /* --- Akzent ------------------------------------------------------------
* *
+65 -10
View File
@@ -1,5 +1,5 @@
import { import {
bibliotheken, unzugeordnet, ohneZuordnung, mehrfachfassungen, dateiHerausloesen, bibliotheken, unzugeordnet, ohneZuordnung, mehrfachfassungen, fassungenAbhaken, dateiHerausloesen,
type Mehrfachtitel, type Mehrfachtitel,
} from '../../api'; } from '../../api';
import { esc, knopf, meldung, hinweisZeigen } from '../../util'; import { esc, knopf, meldung, hinweisZeigen } from '../../util';
@@ -24,7 +24,21 @@ import { abschnitt } from './teile';
export function ohneZuordnungBereich(): HTMLElement { export function ohneZuordnungBereich(): HTMLElement {
const bereich = document.createElement('section'); const bereich = document.createElement('section');
bereich.classList.add('hidden'); bereich.classList.add('hidden');
bereich.appendChild(abschnitt('Ohne Zuordnung')); /** Ob die abgehakten Mehrfachfassungen gezeigt werden statt der offenen.
*
* **Ohne diesen Schalter wäre das Abhaken unumkehrbar.** Man klickt eine
* Gruppe weg und weiss hinterher nicht mehr, welche es war — dieselbe
* Überlegung wie bei den erledigten Meldungen. */
let zeigeIgnorierte = false;
const umschalten = knopf('Ignorierte zeigen', 'btn');
umschalten.onclick = () => {
zeigeIgnorierte = !zeigeIgnorierte;
umschalten.textContent = zeigeIgnorierte ? 'Offene zeigen' : 'Ignorierte zeigen';
void laden();
};
bereich.appendChild(abschnitt('Ohne Zuordnung', [umschalten]));
const liste = document.createElement('div'); const liste = document.createElement('div');
liste.className = 'einstellungen'; liste.className = 'einstellungen';
@@ -40,7 +54,7 @@ export function ohneZuordnungBereich(): HTMLElement {
const ergebnisse = await Promise.all(alle.map(async b => { const ergebnisse = await Promise.all(alle.map(async b => {
try { try {
const [offen, mehrfach] = await Promise.all([ const [offen, mehrfach] = await Promise.all([
ohneZuordnung(b.id), mehrfachfassungen(b.id), ohneZuordnung(b.id), mehrfachfassungen(b.id, 300, zeigeIgnorierte),
]); ]);
return { b, offen, mehrfach }; return { b, offen, mehrfach };
} catch { return null; } } catch { return null; }
@@ -50,11 +64,26 @@ export function ohneZuordnungBereich(): HTMLElement {
.filter(e => e !== null) .filter(e => e !== null)
.filter(e => e!.offen.length > 0 || e!.mehrfach.gesamt > 0); .filter(e => e!.offen.length > 0 || e!.mehrfach.gesamt > 0);
if (mitFunden.length === 0) { bereich.classList.add('hidden'); return; } // **Solange jemand die Ignorierten ansieht, bleibt der Bereich stehen.**
// Sonst verschwände er samt Umschalter in dem Augenblick, in dem die
// letzte abgehakte Gruppe zurückgeholt ist — und niemand fände den Weg
// zurück auf die offenen.
if (mitFunden.length === 0 && !zeigeIgnorierte) {
bereich.classList.add('hidden');
return;
}
bereich.classList.remove('hidden'); bereich.classList.remove('hidden');
liste.innerHTML = ''; liste.innerHTML = '';
if (mitFunden.length === 0) {
const leer = document.createElement('p');
leer.className = 'winzig';
leer.textContent = 'Hier ist nichts abgehakt.';
liste.appendChild(leer);
return;
}
mitFunden.forEach(e => { mitFunden.forEach(e => {
const { b, offen, mehrfach } = e!; const { b, offen, mehrfach } = e!;
@@ -76,10 +105,13 @@ export function ohneZuordnungBereich(): HTMLElement {
if (mehrfach.gesamt > 0) { if (mehrfach.gesamt > 0) {
liste.appendChild(fassungsgruppe( liste.appendChild(fassungsgruppe(
`${b.name} — ${anzahl(mehrfach.gesamt, 'Titel')} mit mehreren Dateien`, `${b.name} — ${anzahl(mehrfach.gesamt, 'Titel')} mit mehreren Dateien`,
'Meist zwei Fassungen desselben Films — dann ist alles in Ordnung. ' zeigeIgnorierte
+ 'Stehen hier zwei verschiedene Filme, war einer davon nicht mehr zu ' ? 'Diese Gruppen wurden als geprüft abgehakt. „Wieder zeigen" holt sie zurück.'
+ 'sehen: „Eigener Film" löst ihn wieder heraus.', : 'Meist zwei Fassungen desselben Films — dann ist alles in Ordnung und '
mehrfach.titel)); + '„Ignorieren" nimmt die Gruppe aus der Liste. Stehen hier zwei '
+ 'verschiedene Filme, war einer davon nicht mehr zu sehen: '
+ '„Eigener Film" löst ihn wieder heraus.',
mehrfach.titel, b.id));
} }
}); });
} }
@@ -90,7 +122,7 @@ export function ohneZuordnungBereich(): HTMLElement {
/** Dieselbe Gruppe, aber jede Datei einzeln mit einem Weg heraus. */ /** Dieselbe Gruppe, aber jede Datei einzeln mit einem Weg heraus. */
function fassungsgruppe( function fassungsgruppe(
ueberschrift: string, hinweis: string, titel: Mehrfachtitel[], ueberschrift: string, hinweis: string, titel: Mehrfachtitel[], bibliothek: string,
): HTMLElement { ): HTMLElement {
const d = document.createElement('div'); const d = document.createElement('div');
d.className = 'einstellung'; d.className = 'einstellung';
@@ -108,12 +140,35 @@ export function ohneZuordnungBereich(): HTMLElement {
const zeile = document.createElement('div'); const zeile = document.createElement('div');
zeile.className = 'fassungstitel'; zeile.className = 'fassungstitel';
const kopfzeile = document.createElement('div');
kopfzeile.className = 'fassungsdatei';
const name = document.createElement('button'); const name = document.createElement('button');
name.className = 'ohnezuordnung-zeile'; name.className = 'ohnezuordnung-zeile';
name.innerHTML = `<span class="oz-titel">${esc(t.titel + (t.jahr ? ` (${t.jahr})` : ''))}</span>`; name.innerHTML = `<span class="oz-titel">${esc(t.titel + (t.jahr ? ` (${t.jahr})` : ''))}</span>`;
name.onclick = () => bearbeiten( name.onclick = () => bearbeiten(
t.art === 'series' ? 'series' : 'movies', t.id, () => void laden()); t.art === 'series' ? 'series' : 'movies', t.id, () => void laden());
zeile.appendChild(name);
// **Für die, die absichtlich zwei Fassungen halten.** Die Liste
// rechnet jedes Mal frisch; ohne das Abhaken stand eine gewollte
// Gruppe hier für immer, und darunter ging das Ungewollte unter.
const abhaken = knopf(zeigeIgnorierte ? 'Wieder zeigen' : 'Ignorieren', 'btn klein');
abhaken.onclick = async () => {
abhaken.disabled = true;
try {
await fassungenAbhaken(bibliothek, [t.id], !zeigeIgnorierte);
hinweisZeigen(zeigeIgnorierte
? `„${t.titel}" steht wieder in der Liste.`
: `„${t.titel}" gilt als geprüft. Kommt eine Fassung dazu, meldet er sich wieder.`);
await laden();
} catch (fehler) {
hinweisZeigen(meldung(fehler), 'fehler');
abhaken.disabled = false;
}
};
kopfzeile.append(name, abhaken);
zeile.appendChild(kopfzeile);
t.dateien.forEach(f => { t.dateien.forEach(f => {
const d2 = document.createElement('div'); const d2 = document.createElement('div');