Der Ordner bestimmt die Serie, nicht der Dateiname

Aus einer Serie wurden siebenhundert. Dateien wie "S01E001_Aang.mkv"
tragen vor der Folgennummer nichts, also wurde der ganze Dateiname zum
Serientitel — jede Folge eine eigene Serie. Und umgekehrt ergab
"Doctor Who (2005) - S12E04.mkv" im Ordner "Doctor Who" zwei Serien, weil
die Jahresangabe nur im Dateinamen stand.

Jetzt gilt: was unter Serien/Pingu/ liegt, gehört zu Pingu. Egal wie tief,
egal wie die Datei heisst. Der Ordner ist die verlässlichere Angabe, weil
ihn ein Mensch angelegt hat; so macht es Plex seit je und aus demselben
Grund. Staffel und Folge liest weiterhin der Dateiname.

Ohne bekannte Wurzel bleibt es beim Dateinamen — dort könnte über dem
Staffelordner irgendetwas stehen. Diesen Weg gehen nur die Tests.

Dazu vier Dinge aus der Benutzung:

Die Mediatheken standen scheinbar in zufälliger Reihenfolge. SQLite
vergleicht Text zeichengenau, und dort stehen alle Grossbuchstaben vor
allen kleinen: "Serien" landete über "film". Jetzt ohne Rücksicht auf
Gross- und Kleinschreibung — und umsortierbar über zwei Pfeile. Nicht über
Ziehen: das ist schöner, aber mit der Tastatur unbedienbar und auf dem
Telefon ein Glücksspiel.

Die Sprache des Servers steckte im Dialog "Metadaten", zusammen mit dem
TMDB-Schlüssel — also dort, wo niemand nach einer Sprache sucht. Wer die
Mediathek auf Englisch vorfand, sah nur "Meine Sprache" und hielt das für
die einzige Möglichkeit. Jetzt eine eigene Zeile.

Das Kontextmenü gab es nur per Rechtsklick, und den findet nur, wer ihn
vermutet — auf dem Telefon gibt es ihn gar nicht. Jetzt drei Punkte auf
jeder Kachel, beim Überfahren und bei Tastaturfokus, auf Geräten ohne
Zeiger dauerhaft. Und "Zuordnung korrigieren" steht endlich darin: wer
zwanzig falsch erkannte Titel vor sich hat, soll sie dort geradeziehen
können statt über vierzig Seitenwechsel.

Folgen ohne eigenes Bild nehmen das der Serie. TMDB liefert Folgenbilder
nur für einen Teil der Folgen und bei kleineren Serien für gar keine; eine
graue Fläche sagt nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 14:20:00 +02:00
co-authored by Claude Opus 5
parent fa18c0d7fa
commit 98565e14b5
19 changed files with 1509 additions and 50 deletions
+17 -2
View File
@@ -58,7 +58,15 @@ public sealed record CreateUserRequest(string UserName, string Password, bool Is
public sealed record LibraryResponse(
Guid Id, string Name, string Kind, string Path, int? ItemCount,
/// <summary>Ob neue Funde erst geprüft werden.</summary>
bool ReviewNewItems = false);
bool ReviewNewItems = false,
/// <summary>Wie viele Titel dieser Mediathek noch auf Freigabe warten.</summary>
///
/// <remarks>
/// <b>Hier und nicht nur in der Prüfliste.</b> Wer eine Mediathek
/// scannt, sieht das Ergebnis an ihr — dass zwanzig Titel irgendwo anders
/// warten, erfährt er sonst nur, wenn er weiterscrollt.
/// </remarks>
int PendingCount = 0);
public sealed record RenameLibraryRequest(string Name);
@@ -69,6 +77,9 @@ public sealed record CreateLibraryRequest(
public sealed record PruefungRequest(bool ReviewNewItems);
/// <summary>Die Reihenfolge der Mediatheken, von oben nach unten.</summary>
public sealed record ReihenfolgeRequest(IReadOnlyList<Guid>? Ids);
public sealed record SprachRequest(string? Language);
/// <summary>Welche Sprache gilt — und wie weit sie abgedeckt ist.</summary>
@@ -112,7 +123,11 @@ public sealed record Pruefeintrag(
/// <summary>Worauf eine Freigabe wirkt.</summary>
IReadOnlyList<Guid> Ids);
public sealed record FreigabeRequest(IReadOnlyList<Guid>? Ids, DateTimeOffset? PublishAt);
public sealed record FreigabeRequest(
IReadOnlyList<Guid>? Ids,
DateTimeOffset? PublishAt,
/// <summary>Statt einzelner Kennungen: alles Wartende dieser Mediathek.</summary>
Guid? LibraryId = null);
/// <summary>Ein Protokolltag.</summary>
public sealed record Protokolltag(string Date, long Bytes);
@@ -84,6 +84,25 @@ public static class FreigabeEndpoints
{
var wann = anfrage.PublishAt ?? uhr.GetUtcNow();
// Alles Wartende einer einzelnen Mediathek — der Knopf, der dort
// steht, wo man gerade gescannt hat.
if (anfrage.LibraryId is { } mediathek)
{
var jetztDort = uhr.GetUtcNow();
var dortFilme = await db.Movies
.Where(m => m.LibraryId == mediathek
&& (m.PublishAt == null || m.PublishAt > jetztDort))
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct);
var dortFolgen = await db.Episodes
.Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek)
&& (f.PublishAt == null || f.PublishAt > jetztDort))
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct);
return Results.Ok(new { published = dortFilme + dortFolgen });
}
// **Alles auf einmal, wenn keine Kennungen dabei sind.** Bei
// vierhundert neuen Titeln wäre einzeln Klicken keine Bedienung.
if (anfrage.Ids is null or { Count: 0 })
@@ -558,9 +558,17 @@ public static class LibraryContentEndpoints
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]);
// **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
// nichts; das Plakat der Serie sagt wenigstens, worum es geht.
// TMDB liefert Folgenbilder nur für einen Teil der Folgen, und
// bei kleineren Serien für gar keine.
var serienBild = await db.Series.AsNoTracking()
.Where(x => x.Id == diese.SeriesId)
.Select(x => x.PosterImage).FirstOrDefaultAsync();
return Results.Ok(new EpisodeResponse(
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
naechste.Title, naechste.Overview, naechste.StillImage,
naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild,
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
stände.GetValueOrDefault(naechste.Id)));
}).RequireAuthorization();
@@ -586,8 +594,13 @@ public static class LibraryContentEndpoints
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: ids);
var serienBild = await db.Series.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => x.PosterImage).FirstOrDefaultAsync();
var items = folgen.Select(f => new EpisodeResponse(
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage,
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview,
f.StillImage ?? serienBild,
dateien.Where(d => d.EpisodeId == f.Id)
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
stände.GetValueOrDefault(f.Id))).ToList();
@@ -51,9 +51,44 @@ public static class ManagementEndpoints
// dem sich nach dem Klick nichts findet — da ist der Unterschied
// zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens
// sichtbar. Zwei Abfragen für alle zusammen, nicht eine je Zeile.
bibliotheken.MapGet("/", async (FlaroContext db) =>
bibliotheken.MapGet("/", async (FlaroContext db, TimeProvider uhr) =>
{
var liste = await db.Libraries.AsNoTracking().OrderBy(l => l.Name).ToListAsync();
// **Ohne Rücksicht auf Gross- und Kleinschreibung.**
//
// SQLite vergleicht Text sonst zeichengenau, und dort stehen alle
// Grossbuchstaben vor allen kleinen: „Serien" landete über „film",
// obwohl f vor s kommt. Wer eine Mediathek umbenennt, sieht sie
// dann scheinbar grundlos springen — es sah aus, als stünde die
// zuletzt bearbeitete oben.
// **Erst die eigene Reihenfolge, dann der Name.** Wer nie
// umsortiert hat, bekommt das Alphabet; wer es getan hat, seine
// Reihenfolge. Ohne den zweiten Schlüssel stünden alle
// Nie-Sortierten in zufälliger Folge.
var liste = await db.Libraries.AsNoTracking()
.OrderBy(l => l.SortOrder)
.ThenBy(l => EF.Functions.Collate(l.Name, "NOCASE"))
.ToListAsync();
var jetzt = uhr.GetUtcNow();
// **Was noch wartet, je Mediathek.** Wer scannt, sieht das Ergebnis
// an der Mediathek — dass zwanzig Titel irgendwo weiter unten
// warten, erführe er sonst nur durch Weiterscrollen.
//
// Für einen gewöhnlichen Benutzer bleibt die Zahl null: der
// Freigabefilter lässt ihn Unveröffentlichtes gar nicht erst
// zählen, und das ist richtig so.
var wartendeFilme = await db.Movies.AsNoTracking()
.Where(m => m.PublishAt == null || m.PublishAt > jetzt)
.GroupBy(m => m.LibraryId)
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
var wartendeFolgen = await db.Episodes.AsNoTracking()
.Where(f => f.PublishAt == null || f.PublishAt > jetzt)
.GroupBy(f => db.Series.Where(s => s.Id == f.SeriesId)
.Select(s => s.LibraryId).First())
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
var filme = await db.Movies.AsNoTracking()
.GroupBy(m => m.LibraryId)
@@ -70,7 +105,10 @@ public static class ManagementEndpoints
l.Kind == LibraryKind.Shows
? serien.GetValueOrDefault(l.Id)
: filme.GetValueOrDefault(l.Id),
l.ReviewNewItems)).ToList());
l.ReviewNewItems,
l.Kind == LibraryKind.Shows
? wartendeFolgen.GetValueOrDefault(l.Id)
: wartendeFilme.GetValueOrDefault(l.Id))).ToList());
}).RequireAuthorization();
// **Was nicht mehr da ist, aber noch in der Mediathek steht.**
@@ -78,6 +116,29 @@ public static class ManagementEndpoints
// Der Scan löscht nichts mehr von selbst — eine kurz nicht eingehängte
// Platte hätte sonst Titel samt Sehfortschritt gekostet. Stattdessen
// steht es hier, und das Löschen ist eine Entscheidung.
// **Die Reihenfolge der Seitenleiste.**
//
// Als ganze Liste und nicht als „eins hoch": zwei Benutzer, die
// gleichzeitig schieben, kämen sonst in einen Zustand, den keiner von
// beiden wollte. Die Liste beschreibt, wie es danach aussehen soll.
bibliotheken.MapPut("/order", async (
ReihenfolgeRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
if (anfrage.Ids is null or { Count: 0 })
return Results.BadRequest(new { fehler = "Ohne Reihenfolge gibt es nichts zu ordnen." });
// Ab 1 zählen: die 0 bleibt denen, die nie umsortiert wurden.
for (var i = 0; i < anfrage.Ids.Count; i++)
{
var platz = i + 1;
var kennung = anfrage.Ids[i];
await db.Libraries.Where(l => l.Id == kennung)
.ExecuteUpdateAsync(s => s.SetProperty(l => l.SortOrder, platz), ct);
}
return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator);
// Die Prüfung je Mediathek umstellen.
bibliotheken.MapPut("/{id:guid}/review", async (
Guid id, PruefungRequest anfrage, FlaroContext db, CancellationToken ct) =>