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:
co-authored by
Claude Opus 5
parent
fa18c0d7fa
commit
98565e14b5
@@ -58,7 +58,15 @@ public sealed record CreateUserRequest(string UserName, string Password, bool Is
|
|||||||
public sealed record LibraryResponse(
|
public sealed record LibraryResponse(
|
||||||
Guid Id, string Name, string Kind, string Path, int? ItemCount,
|
Guid Id, string Name, string Kind, string Path, int? ItemCount,
|
||||||
/// <summary>Ob neue Funde erst geprüft werden.</summary>
|
/// <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);
|
public sealed record RenameLibraryRequest(string Name);
|
||||||
|
|
||||||
@@ -69,6 +77,9 @@ public sealed record CreateLibraryRequest(
|
|||||||
|
|
||||||
public sealed record PruefungRequest(bool ReviewNewItems);
|
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);
|
public sealed record SprachRequest(string? Language);
|
||||||
|
|
||||||
/// <summary>Welche Sprache gilt — und wie weit sie abgedeckt ist.</summary>
|
/// <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>
|
/// <summary>Worauf eine Freigabe wirkt.</summary>
|
||||||
IReadOnlyList<Guid> Ids);
|
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>
|
/// <summary>Ein Protokolltag.</summary>
|
||||||
public sealed record Protokolltag(string Date, long Bytes);
|
public sealed record Protokolltag(string Date, long Bytes);
|
||||||
|
|||||||
@@ -84,6 +84,25 @@ public static class FreigabeEndpoints
|
|||||||
{
|
{
|
||||||
var wann = anfrage.PublishAt ?? uhr.GetUtcNow();
|
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
|
// **Alles auf einmal, wenn keine Kennungen dabei sind.** Bei
|
||||||
// vierhundert neuen Titeln wäre einzeln Klicken keine Bedienung.
|
// vierhundert neuen Titeln wäre einzeln Klicken keine Bedienung.
|
||||||
if (anfrage.Ids is null or { Count: 0 })
|
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 spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]);
|
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(
|
return Results.Ok(new EpisodeResponse(
|
||||||
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
|
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(),
|
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
|
||||||
stände.GetValueOrDefault(naechste.Id)));
|
stände.GetValueOrDefault(naechste.Id)));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
@@ -586,8 +594,13 @@ public static class LibraryContentEndpoints
|
|||||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, folgen: ids);
|
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(
|
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)
|
dateien.Where(d => d.EpisodeId == f.Id)
|
||||||
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
|
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
|
||||||
stände.GetValueOrDefault(f.Id))).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
|
// dem sich nach dem Klick nichts findet — da ist der Unterschied
|
||||||
// zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens
|
// zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens
|
||||||
// sichtbar. Zwei Abfragen für alle zusammen, nicht eine je Zeile.
|
// 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()
|
var filme = await db.Movies.AsNoTracking()
|
||||||
.GroupBy(m => m.LibraryId)
|
.GroupBy(m => m.LibraryId)
|
||||||
@@ -70,7 +105,10 @@ public static class ManagementEndpoints
|
|||||||
l.Kind == LibraryKind.Shows
|
l.Kind == LibraryKind.Shows
|
||||||
? serien.GetValueOrDefault(l.Id)
|
? serien.GetValueOrDefault(l.Id)
|
||||||
: filme.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();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// **Was nicht mehr da ist, aber noch in der Mediathek steht.**
|
// **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
|
// Der Scan löscht nichts mehr von selbst — eine kurz nicht eingehängte
|
||||||
// Platte hätte sonst Titel samt Sehfortschritt gekostet. Stattdessen
|
// Platte hätte sonst Titel samt Sehfortschritt gekostet. Stattdessen
|
||||||
// steht es hier, und das Löschen ist eine Entscheidung.
|
// 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.
|
// Die Prüfung je Mediathek umstellen.
|
||||||
bibliotheken.MapPut("/{id:guid}/review", async (
|
bibliotheken.MapPut("/{id:guid}/review", async (
|
||||||
Guid id, PruefungRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
Guid id, PruefungRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -27,4 +27,17 @@ public sealed class Library
|
|||||||
/// erst zu einem bestimmten Zeitpunkt erscheinen soll.
|
/// erst zu einem bestimmten Zeitpunkt erscheinen soll.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool ReviewNewItems { get; set; }
|
public bool ReviewNewItems { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Wo sie in der Seitenleiste steht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Weil alphabetisch nicht immer richtig ist.</b> Wer „Filme" und
|
||||||
|
/// „Kinderfilme" und „Dokumentationen" hat, will nicht nach dem Alphabet
|
||||||
|
/// sortiert sein, sondern danach, was er am häufigsten öffnet.
|
||||||
|
///
|
||||||
|
/// <c>0</c> für alles, was nie umsortiert wurde; dann entscheidet der
|
||||||
|
/// Name. Damit bleibt eine frisch angelegte Mediathek da, wo man sie
|
||||||
|
/// erwartet, statt am Ende zu landen.
|
||||||
|
/// </remarks>
|
||||||
|
public int SortOrder { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,7 +186,11 @@ public static partial class FileNames
|
|||||||
/// ein Film oder etwas, das niemand benennen kann — in beiden Fällen wäre
|
/// ein Film oder etwas, das niemand benennen kann — in beiden Fällen wäre
|
||||||
/// eine geratene Staffel 1 schlimmer als gar keine.
|
/// eine geratene Staffel 1 schlimmer als gar keine.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static EpisodeName? ParseEpisode(string dateiname)
|
/// <param name="wurzel">
|
||||||
|
/// Der Wurzelpfad der Mediathek. Ist er angegeben, bestimmt der erste
|
||||||
|
/// Ordner darunter die Serie — und zwar <b>allein</b>.
|
||||||
|
/// </param>
|
||||||
|
public static EpisodeName? ParseEpisode(string dateiname, string? wurzel = null)
|
||||||
{
|
{
|
||||||
var roh = Rohname(dateiname);
|
var roh = Rohname(dateiname);
|
||||||
|
|
||||||
@@ -206,33 +210,67 @@ public static partial class FileNames
|
|||||||
var (worteDanach, _) = Zerlegen(danach, istDateiname: false);
|
var (worteDanach, _) = Zerlegen(danach, istDateiname: false);
|
||||||
var (folgenTitel, _) = TitelBis(worteDanach, worteDanach.Length);
|
var (folgenTitel, _) = TitelBis(worteDanach, worteDanach.Length);
|
||||||
|
|
||||||
// **Steht im Dateinamen keine Serie, sagt der Ordner sie.**
|
// **Der Ordner entscheidet, nicht der Dateiname.**
|
||||||
//
|
//
|
||||||
// Bei „Dark/Staffel 1/S01E01.mkv" steht vor der Folgennummer nichts —
|
// Was unter „Serien/Pingu/" liegt, gehört zu Pingu — ohne Rücksicht
|
||||||
// und bis hierher wurde dann der ganze Dateiname zum Serientitel.
|
// darauf, wie die Datei heisst. Vorher entschied der Dateiname, und
|
||||||
// Jede so benannte Datei ergab damit eine eigene Serie: aus zwanzig
|
// das ging reihenweise schief: Dateien namens „S01E001_Aang.mkv"
|
||||||
// Folgen wurden zwanzig Serien namens „S01E01", „S01E02", …
|
// tragen vor der Folgennummer nichts, also wurde der ganze Dateiname
|
||||||
|
// zum Serientitel. Aus einer Serie mit zwanzig Folgen wurden zwanzig
|
||||||
|
// Serien.
|
||||||
//
|
//
|
||||||
// Plex und Jellyfin lesen den Namen seit je aus dem Ordner. Der
|
// Umgekehrt ebenso wichtig: „Doctor Who (2005) - S12E04.mkv" im Ordner
|
||||||
// Staffelordner wird dabei übersprungen, sonst hiesse die Serie
|
// „Doctor Who" ergab zwei Serien, weil die Jahresangabe im Dateinamen
|
||||||
// „Staffel 1".
|
// stand und im Ordner nicht. Der Ordner ist die verlässlichere Angabe,
|
||||||
var ausOrdner = serie.Length > 0 ? null : SerieAusOrdner(dateiname);
|
// weil ihn ein Mensch angelegt hat.
|
||||||
|
//
|
||||||
|
// So macht es Plex seit je, und aus demselben Grund.
|
||||||
|
// **Mit Wurzel entscheidet der Ordner, ohne Wurzel nur im Notfall.**
|
||||||
|
//
|
||||||
|
// Steht die Wurzel fest, weiss man, welcher Ordner die Serie benennt —
|
||||||
|
// der erste darunter, von einem Menschen angelegt. Fehlt sie, könnte
|
||||||
|
// über dem Staffelordner irgendetwas stehen; dann ist der Dateiname
|
||||||
|
// die bessere Angabe, und der Ordner greift nur, wenn er gar nichts
|
||||||
|
// hergibt.
|
||||||
|
var ausOrdner = wurzel is { Length: > 0 }
|
||||||
|
? SerieAusOrdner(dateiname, wurzel)
|
||||||
|
: serie.Length > 0 ? null : SerieAusOrdner(dateiname, null);
|
||||||
|
|
||||||
return new EpisodeName(
|
return new EpisodeName(
|
||||||
SeriesTitle: serie.Length > 0 ? serie : ausOrdner ?? Rohname(dateiname),
|
SeriesTitle: ausOrdner ?? (serie.Length > 0 ? serie : Rohname(dateiname)),
|
||||||
Season: staffel,
|
Season: staffel,
|
||||||
Episode: folge,
|
Episode: folge,
|
||||||
EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null);
|
EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Der Serienname aus dem Pfad — Staffelordner übersprungen.</summary>
|
/// <summary>Der Serienname aus dem Pfad.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b>Höchstens zwei Ebenen hoch.</b> Weiter oben steht der Wurzelordner
|
/// <para>
|
||||||
/// der Mediathek, und „Serien" ist kein Serientitel.
|
/// <b>Mit Wurzel: der erste Ordner darunter.</b> Alles, was in
|
||||||
|
/// <c>Serien/Pingu/</c> liegt, gehört zu Pingu — egal wie tief und egal
|
||||||
|
/// wie die Datei heisst. Das ist die Regel, die ein Mensch beim Anlegen
|
||||||
|
/// der Ordner im Kopf hatte.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ohne Wurzel: höchstens zwei Ebenen hoch</b>, Staffelordner
|
||||||
|
/// übersprungen. Diesen Weg gehen nur die Tests, die mit relativen Pfaden
|
||||||
|
/// arbeiten.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static string? SerieAusOrdner(string pfad)
|
private static string? SerieAusOrdner(string pfad, string? wurzel)
|
||||||
{
|
{
|
||||||
|
if (wurzel is { Length: > 0 })
|
||||||
|
{
|
||||||
|
var unterhalb = UnterWurzel(pfad, wurzel);
|
||||||
|
// Liegt die Datei direkt in der Wurzel, gibt es keinen Ordner, der
|
||||||
|
// sie benennt — dann bleibt nur der Dateiname.
|
||||||
|
if (unterhalb is null || !unterhalb.Contains('/')) return null;
|
||||||
|
|
||||||
|
var erster = unterhalb[..unterhalb.IndexOf('/')];
|
||||||
|
return Saubermachen(erster);
|
||||||
|
}
|
||||||
|
|
||||||
var ordner = Path.GetDirectoryName(pfad);
|
var ordner = Path.GetDirectoryName(pfad);
|
||||||
|
|
||||||
for (var ebene = 0; ebene < 2 && !string.IsNullOrEmpty(ordner); ebene++)
|
for (var ebene = 0; ebene < 2 && !string.IsNullOrEmpty(ordner); ebene++)
|
||||||
@@ -240,12 +278,7 @@ public static partial class FileNames
|
|||||||
var name = Path.GetFileName(ordner);
|
var name = Path.GetFileName(ordner);
|
||||||
if (name.Length == 0) return null;
|
if (name.Length == 0) return null;
|
||||||
|
|
||||||
if (!IstStaffelordner(name))
|
if (!IstStaffelordner(name)) return Saubermachen(name);
|
||||||
{
|
|
||||||
var (worte, _) = Zerlegen(name, istDateiname: false);
|
|
||||||
var (titel, _) = TitelBis(worte, worte.Length);
|
|
||||||
return titel.Length > 0 ? titel : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
ordner = Path.GetDirectoryName(ordner);
|
ordner = Path.GetDirectoryName(ordner);
|
||||||
}
|
}
|
||||||
@@ -253,6 +286,23 @@ public static partial class FileNames
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Pfad unterhalb der Wurzel, mit „/" getrennt.</summary>
|
||||||
|
private static string? UnterWurzel(string pfad, string wurzel)
|
||||||
|
{
|
||||||
|
var p = pfad.Replace('\\', '/');
|
||||||
|
var w = wurzel.Replace('\\', '/').TrimEnd('/');
|
||||||
|
|
||||||
|
if (!p.StartsWith(w + "/", StringComparison.OrdinalIgnoreCase)) return null;
|
||||||
|
return p[(w.Length + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Saubermachen(string ordnername)
|
||||||
|
{
|
||||||
|
var (worte, _) = Zerlegen(ordnername, istDateiname: false);
|
||||||
|
var (titel, _) = TitelBis(worte, worte.Length);
|
||||||
|
return titel.Length > 0 ? titel : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>„Staffel 1", „Season 02", „S3" — Ordner, die keine Serie benennen.</summary>
|
/// <summary>„Staffel 1", „Season 02", „S3" — Ordner, die keine Serie benennen.</summary>
|
||||||
private static bool IstStaffelordner(string name) =>
|
private static bool IstStaffelordner(string name) =>
|
||||||
StaffelOrdnerName().IsMatch(name.Trim());
|
StaffelOrdnerName().IsMatch(name.Trim());
|
||||||
|
|||||||
@@ -114,7 +114,10 @@ public sealed class LibraryScanner(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var name = FileNames.ParseEpisode(datei.Path);
|
// **Mit der Wurzel der Mediathek.** Erst damit kann der
|
||||||
|
// Scanner wissen, welcher Ordner die Serie benennt — und ohne
|
||||||
|
// das wurde aus jeder Folgendatei eine eigene Serie.
|
||||||
|
var name = FileNames.ParseEpisode(datei.Path, bibliothek.Path);
|
||||||
if (name is null)
|
if (name is null)
|
||||||
{
|
{
|
||||||
// **Nicht raten.** Eine erfundene Staffel 1 landete unter
|
// **Nicht raten.** Eine erfundene Staffel 1 landete unter
|
||||||
|
|||||||
@@ -0,0 +1,885 @@
|
|||||||
|
// <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("20261004121630_Reihenfolge")]
|
||||||
|
partial class Reihenfolge
|
||||||
|
{
|
||||||
|
/// <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>("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<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>("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<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<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
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<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>("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<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<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
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<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("UserId", "EpisodeId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "MovieId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" 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.Users.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Reihenfolge : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SortOrder",
|
||||||
|
table: "Libraries",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SortOrder",
|
||||||
|
table: "Libraries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,6 +135,9 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<bool>("ReviewNewItems")
|
b.Property<bool>("ReviewNewItems")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("Libraries");
|
b.ToTable("Libraries");
|
||||||
|
|||||||
@@ -198,3 +198,60 @@ public class SerieAusOrdnerTests
|
|||||||
Assert.That(name.Episode, Is.EqualTo(5));
|
Assert.That(name.Episode, Is.EqualTo(5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Mit Wurzel entscheidet der Ordner — allein.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Der Fall, der aus einer Serie siebenhundert machte.</b> Dateien wie
|
||||||
|
/// „S01E001_Aang.mkv" tragen vor der Folgennummer nichts, also wurde der ganze
|
||||||
|
/// Dateiname zum Serientitel. Und „Doctor Who (2005) - S12E04.mkv" im Ordner
|
||||||
|
/// „Doctor Who" ergab zwei Serien, weil die Jahresangabe nur im Dateinamen
|
||||||
|
/// stand. Der Ordner ist die verlässlichere Angabe: den hat ein Mensch
|
||||||
|
/// angelegt.
|
||||||
|
/// </remarks>
|
||||||
|
public class SerieAusDerWurzelTests
|
||||||
|
{
|
||||||
|
private const string Wurzel = "/media/Serien";
|
||||||
|
|
||||||
|
[TestCase("/media/Serien/Pingu/S01E001_Willkommen.mkv", "Pingu")]
|
||||||
|
[TestCase("/media/Serien/Pingu/Staffel 1/S01E002_Volltreffer.mkv", "Pingu")]
|
||||||
|
[TestCase("/media/Serien/Avatar/Buch 1/S01E001_Aang.mkv", "Avatar")]
|
||||||
|
[TestCase("/media/Serien/Nachtstreife/S01E05_Folge 5.mkv", "Nachtstreife")]
|
||||||
|
public void Alles_im_Serienordner_gehoert_zu_dieser_Serie(string pfad, string erwartet) =>
|
||||||
|
Assert.That(FileNames.ParseEpisode(pfad, Wurzel)?.SeriesTitle, Is.EqualTo(erwartet));
|
||||||
|
|
||||||
|
/// <summary>Auch dann, wenn der Dateiname etwas anderes behauptet.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Der_Ordner_sticht_den_Dateinamen() =>
|
||||||
|
Assert.That(
|
||||||
|
FileNames.ParseEpisode("/media/Serien/Doctor Who/Staffel 12/Doctor Who (2005) - S12E04.mkv", Wurzel)
|
||||||
|
?.SeriesTitle,
|
||||||
|
Is.EqualTo("Doctor Who"));
|
||||||
|
|
||||||
|
/// <summary>Zwei Schreibweisen im Dateinamen, ein Ordner, eine Serie.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Zwei_Dateien_desselben_Ordners_ergeben_eine_Serie()
|
||||||
|
{
|
||||||
|
var a = FileNames.ParseEpisode("/media/Serien/Dark/S01E01.mkv", Wurzel);
|
||||||
|
var b = FileNames.ParseEpisode("/media/Serien/Dark/Staffel 1/Dark (2017) - S01E02.mkv", Wurzel);
|
||||||
|
|
||||||
|
Assert.That(a!.SeriesTitle, Is.EqualTo(b!.SeriesTitle));
|
||||||
|
Assert.That(a.SeriesTitle, Is.EqualTo("Dark"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Staffel und Folge liest weiterhin der Dateiname.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Nummern_kommen_weiter_aus_dem_Dateinamen()
|
||||||
|
{
|
||||||
|
var name = FileNames.ParseEpisode("/media/Serien/Dark/Staffel 2/S02E05_Titel.mkv", Wurzel);
|
||||||
|
Assert.That(name!.Season, Is.EqualTo(2));
|
||||||
|
Assert.That(name.Episode, Is.EqualTo(5));
|
||||||
|
Assert.That(name.EpisodeTitle, Is.EqualTo("Titel"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Liegt die Datei direkt in der Wurzel, gibt es keinen Ordner, der sie benennt.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Direkt_in_der_Wurzel_gilt_der_Dateiname() =>
|
||||||
|
Assert.That(FileNames.ParseEpisode("/media/Serien/Dark - S01E01.mkv", Wurzel)?.SeriesTitle,
|
||||||
|
Is.EqualTo("Dark"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ export type Bibliothek = {
|
|||||||
|
|
||||||
/** Ob neue Funde erst geprüft werden. */
|
/** Ob neue Funde erst geprüft werden. */
|
||||||
reviewNewItems: boolean;
|
reviewNewItems: boolean;
|
||||||
|
|
||||||
|
/** Wie viele Titel hier noch auf Freigabe warten. */
|
||||||
|
pendingCount: number;
|
||||||
};
|
};
|
||||||
export type Seite<T> = { items: T[]; total: number };
|
export type Seite<T> = { items: T[]; total: number };
|
||||||
export type Lieferung = {
|
export type Lieferung = {
|
||||||
@@ -540,10 +543,21 @@ export const veroeffentlichen = (ids?: string[], publishAt?: string) =>
|
|||||||
anfrage<{ published: number }>('/review/publish',
|
anfrage<{ published: number }>('/review/publish',
|
||||||
{ method: 'POST', body: JSON.stringify({ ids: ids ?? null, publishAt: publishAt ?? null }) });
|
{ method: 'POST', body: JSON.stringify({ ids: ids ?? null, publishAt: publishAt ?? null }) });
|
||||||
|
|
||||||
|
/** Alles Wartende einer einzelnen Mediathek. */
|
||||||
|
export const mediathekFreigeben = (libraryId: string, publishAt?: string) =>
|
||||||
|
anfrage<{ published: number }>('/review/publish', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids: null, publishAt: publishAt ?? null, libraryId }),
|
||||||
|
});
|
||||||
|
|
||||||
export const zurueckziehen = (ids: string[]) =>
|
export const zurueckziehen = (ids: string[]) =>
|
||||||
anfrage<{ withheld: number }>('/review/withhold',
|
anfrage<{ withheld: number }>('/review/withhold',
|
||||||
{ method: 'POST', body: JSON.stringify({ ids, publishAt: null }) });
|
{ method: 'POST', body: JSON.stringify({ ids, publishAt: null }) });
|
||||||
|
|
||||||
|
/** Die Reihenfolge der Seitenleiste, von oben nach unten. */
|
||||||
|
export const reihenfolgeSetzen = (ids: string[]) =>
|
||||||
|
anfrage<void>('/libraries/order', { method: 'PUT', body: JSON.stringify({ ids }) });
|
||||||
|
|
||||||
export const pruefungSetzen = (bibliothek: string, an: boolean) =>
|
export const pruefungSetzen = (bibliothek: string, an: boolean) =>
|
||||||
anfrage<void>(`/libraries/${bibliothek}/review`,
|
anfrage<void>(`/libraries/${bibliothek}/review`,
|
||||||
{ method: 'PUT', body: JSON.stringify({ reviewNewItems: an }) });
|
{ method: 'PUT', body: JSON.stringify({ reviewNewItems: an }) });
|
||||||
|
|||||||
+37
-2
@@ -4,8 +4,9 @@ import {
|
|||||||
} from './api';
|
} from './api';
|
||||||
import { esc, anteil } from './util';
|
import { esc, anteil } from './util';
|
||||||
import { icons } from './icons';
|
import { icons } from './icons';
|
||||||
import { kontextmenue, type Menuepunkt } from './menue';
|
import { kontextmenue, menueAnZiel, type Menuepunkt } from './menue';
|
||||||
import { bilddialog } from './bilder';
|
import { bilddialog } from './bilder';
|
||||||
|
import { zuordnungsdialog } from './zuordnung';
|
||||||
|
|
||||||
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
|
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
|
||||||
export type Kachel = {
|
export type Kachel = {
|
||||||
@@ -69,6 +70,29 @@ export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string)
|
|||||||
// Titeln hiess „die hab ich alle gesehen" vierzig Seitenwechsel.
|
// Titeln hiess „die hab ich alle gesehen" vierzig Seitenwechsel.
|
||||||
kontextmenue(knopf, () => kachelpunkte(k, knopf, oeffnen));
|
kontextmenue(knopf, () => kachelpunkte(k, knopf, oeffnen));
|
||||||
|
|
||||||
|
// **Ein sichtbarer Griff zum selben Menü.**
|
||||||
|
//
|
||||||
|
// Rechtsklick findet nur, wer ihn vermutet, und auf einem Telefon gibt es
|
||||||
|
// ihn gar nicht. Die drei Punkte erscheinen beim Überfahren und bei
|
||||||
|
// Tastaturfokus — sie sind ein eigener Knopf, nicht Teil der Kachel, sonst
|
||||||
|
// öffnete jeder Klick darauf auch die Detailseite.
|
||||||
|
const mehr = document.createElement('span');
|
||||||
|
mehr.className = 'kachel-mehr';
|
||||||
|
mehr.setAttribute('role', 'button');
|
||||||
|
mehr.setAttribute('tabindex', '0');
|
||||||
|
mehr.setAttribute('aria-label', `Mehr zu „${k.titel}“`);
|
||||||
|
mehr.innerHTML = icons.punkte;
|
||||||
|
mehr.onclick = e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
menueAnZiel(mehr, kachelpunkte(k, knopf, oeffnen));
|
||||||
|
};
|
||||||
|
mehr.onkeydown = e => {
|
||||||
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
menueAnZiel(mehr, kachelpunkte(k, knopf, oeffnen));
|
||||||
|
};
|
||||||
|
|
||||||
const rahmen = document.createElement('div');
|
const rahmen = document.createElement('div');
|
||||||
rahmen.className = 'plakat';
|
rahmen.className = 'plakat';
|
||||||
rahmen.setAttribute('role', 'img');
|
rahmen.setAttribute('role', 'img');
|
||||||
@@ -119,6 +143,7 @@ export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string)
|
|||||||
schweben.className = 'schweber';
|
schweben.className = 'schweber';
|
||||||
schweben.innerHTML = icons.abspielen;
|
schweben.innerHTML = icons.abspielen;
|
||||||
rahmen.appendChild(schweben);
|
rahmen.appendChild(schweben);
|
||||||
|
rahmen.appendChild(mehr);
|
||||||
|
|
||||||
knopf.appendChild(rahmen);
|
knopf.appendChild(rahmen);
|
||||||
|
|
||||||
@@ -236,10 +261,20 @@ function kachelpunkte(
|
|||||||
punkte.push({ text: 'Details', icon: 'mehr', tun: () => oeffnen(k.art, k.id) });
|
punkte.push({ text: 'Details', icon: 'mehr', tun: () => oeffnen(k.art, k.id) });
|
||||||
|
|
||||||
if (istAdmin()) {
|
if (istAdmin()) {
|
||||||
|
const art = k.art === 'serien' ? 'series' : 'movies';
|
||||||
punkte.push({
|
punkte.push({
|
||||||
text: 'Bilder bearbeiten',
|
text: 'Bilder bearbeiten',
|
||||||
icon: 'stift',
|
icon: 'stift',
|
||||||
tun: () => bilddialog(k.art === 'serien' ? 'series' : 'movies', k.id, k.titel),
|
tun: () => bilddialog(art, k.id, k.titel),
|
||||||
|
});
|
||||||
|
punkte.push({
|
||||||
|
text: 'Zuordnung korrigieren',
|
||||||
|
icon: 'suche',
|
||||||
|
// **Hier und nicht nur auf der Detailseite.** Wer zwanzig falsch
|
||||||
|
// erkannte Titel vor sich hat, soll sie hier geradeziehen können.
|
||||||
|
tun: () => zuordnungsdialog(art, k.id, k.titel,
|
||||||
|
k.zweite && /^\d{4}$/.test(k.zweite) ? Number(k.zweite) : null,
|
||||||
|
() => location.reload()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-2
@@ -255,16 +255,53 @@ function ordnerfeld(eingabe: HTMLInputElement): HTMLElement {
|
|||||||
|
|
||||||
let hier: Ordnerstand | null = null;
|
let hier: Ordnerstand | null = null;
|
||||||
|
|
||||||
|
/** Welcher Abruf gerade gilt — ältere Antworten werden verworfen. */
|
||||||
|
let lauf = 0;
|
||||||
|
|
||||||
async function gehe(ziel?: string): Promise<void> {
|
async function gehe(ziel?: string): Promise<void> {
|
||||||
|
const dieser = ++lauf;
|
||||||
liste.innerHTML = '<div class="spinner"></div>';
|
liste.innerHTML = '<div class="spinner"></div>';
|
||||||
|
|
||||||
|
// **Nach zehn Sekunden sagen, dass es dauert.** Ein Spinner, der ewig
|
||||||
|
// dreht, sagt nichts — und genau so stand er, als ein Ordner nicht
|
||||||
|
// antwortete. Jetzt steht wenigstens da, worauf gewartet wird.
|
||||||
|
const langsam = window.setTimeout(() => {
|
||||||
|
if (dieser !== lauf) return;
|
||||||
|
liste.innerHTML = '';
|
||||||
|
liste.appendChild(hinweiszeile(
|
||||||
|
`„${ziel ?? 'Start'}" antwortet seit zehn Sekunden nicht. `
|
||||||
|
+ 'Schläft die Platte, dauert der erste Zugriff — sonst stimmt etwas nicht.'));
|
||||||
|
liste.appendChild(spinnerzeile());
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
hier = await ordner(ziel);
|
hier = await ordner(ziel);
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
|
if (dieser !== lauf) return;
|
||||||
liste.innerHTML = '';
|
liste.innerHTML = '';
|
||||||
liste.appendChild(hinweiszeile((fehler as Error).message));
|
liste.appendChild(hinweiszeile(`Konnte nicht gelesen werden: ${(fehler as Error).message}`));
|
||||||
return;
|
return;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(langsam);
|
||||||
}
|
}
|
||||||
zeichne();
|
|
||||||
|
// Ein überholter Abruf darf nicht das Bild des neueren überschreiben.
|
||||||
|
if (dieser !== lauf) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
zeichne();
|
||||||
|
} catch (fehler) {
|
||||||
|
// **Auch ein Fehler beim Zeichnen muss sichtbar werden.** Vorher blieb
|
||||||
|
// dann der Spinner stehen, und es sah aus, als käme keine Antwort.
|
||||||
|
liste.innerHTML = '';
|
||||||
|
liste.appendChild(hinweiszeile(`Anzeige gescheitert: ${(fehler as Error).message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spinnerzeile(): HTMLElement {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'spinner';
|
||||||
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
function zeichne(): void {
|
function zeichne(): void {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export const icons = {
|
|||||||
/** Ordner — fürs Durchklicken durch das Dateisystem. */
|
/** Ordner — fürs Durchklicken durch das Dateisystem. */
|
||||||
ordner: rahmen('<path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4.3a1.5 1.5 0 0 1 1.07.45L11.4 7.6"'
|
ordner: rahmen('<path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4.3a1.5 1.5 0 0 1 1.07.45L11.4 7.6"'
|
||||||
+ ' /><path d="M3 7.5h16.5A1.5 1.5 0 0 1 21 9v8.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17.5Z"/>'),
|
+ ' /><path d="M3 7.5h16.5A1.5 1.5 0 0 1 21 9v8.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17.5Z"/>'),
|
||||||
|
/** Drei Punkte — der sichtbare Griff zum Kontextmenü. */
|
||||||
|
punkte: rahmen('<circle cx="12" cy="5" r="1.4"/><circle cx="12" cy="12" r="1.4"/>'
|
||||||
|
+ '<circle cx="12" cy="19" r="1.4"/>', true),
|
||||||
/** Winkel nach oben — eine Ebene zurück im Baum. */
|
/** Winkel nach oben — eine Ebene zurück im Baum. */
|
||||||
hoch: rahmen('<path d="m6 14 6-6 6 6"/>'),
|
hoch: rahmen('<path d="m6 14 6-6 6 6"/>'),
|
||||||
/** Filmstreifen — für eine Filmmediathek. */
|
/** Filmstreifen — für eine Filmmediathek. */
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ export function kontextmenue(ziel: HTMLElement, punkte: () => Menuepunkt[]): voi
|
|||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Öffnet das Menü an einem Element — für den sichtbaren Griff.
|
||||||
|
*
|
||||||
|
* **Am Element und nicht am Mauszeiger.** Wer die drei Punkte mit der
|
||||||
|
* Tastatur auslöst, hat keinen Zeiger; das Menü soll trotzdem dort erscheinen,
|
||||||
|
* wo der Griff ist.
|
||||||
|
*/
|
||||||
|
export function menueAnZiel(ziel: HTMLElement, punkte: Menuepunkt[]): void {
|
||||||
|
const kasten = ziel.getBoundingClientRect();
|
||||||
|
oeffnen(kasten.right, kasten.bottom + 4, punkte);
|
||||||
|
}
|
||||||
|
|
||||||
/** Das offene Menü — immer höchstens eines. */
|
/** Das offene Menü — immer höchstens eines. */
|
||||||
let offen: HTMLElement | null = null;
|
let offen: HTMLElement | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -963,3 +963,27 @@ input[type="datetime-local"].feld::-webkit-calendar-picker-indicator:hover {
|
|||||||
color: var(--text-dim); font-size: var(--t-normal);
|
color: var(--text-dim); font-size: var(--t-normal);
|
||||||
}
|
}
|
||||||
.reihe-kopf .knopfreihe .bleiben input { accent-color: var(--accent); width: 16px; height: 16px; }
|
.reihe-kopf .knopfreihe .bleiben input { accent-color: var(--accent); width: 16px; height: 16px; }
|
||||||
|
|
||||||
|
/* Der sichtbare Griff zum Kontextmenü auf einer Kachel.
|
||||||
|
*
|
||||||
|
* Rechtsklick findet nur, wer ihn vermutet, und auf dem Telefon gibt es ihn
|
||||||
|
* gar nicht. Beim Überfahren und bei Tastaturfokus erscheinen drei Punkte. */
|
||||||
|
.kachel-mehr {
|
||||||
|
position: absolute; right: 6px; top: 6px; z-index: 4;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 28px; height: 28px; border-radius: 50%;
|
||||||
|
background: rgba(10, 10, 12, .78);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
color: var(--text);
|
||||||
|
opacity: 0; transition: opacity .15s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.kachel-mehr svg { width: 16px; height: 16px; }
|
||||||
|
.kachel:hover .kachel-mehr,
|
||||||
|
.kachel:focus-within .kachel-mehr,
|
||||||
|
.kachel-mehr:focus-visible { opacity: 1; }
|
||||||
|
/* Auf Geräten ohne Mauszeiger steht er dauerhaft da — sonst käme man nie
|
||||||
|
heran. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.kachel-mehr { opacity: .85; }
|
||||||
|
}
|
||||||
|
|||||||
+100
-17
@@ -4,6 +4,7 @@ import {
|
|||||||
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,
|
||||||
|
mediathekFreigeben, reihenfolgeSetzen,
|
||||||
type Pruefeintrag,
|
type Pruefeintrag,
|
||||||
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
|
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
|
||||||
servernameSetzen, systemauskunft,
|
servernameSetzen, systemauskunft,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
} from '../api';
|
} from '../api';
|
||||||
import { esc, dauer, knopf, fehlerzeile, leer } from '../util';
|
import { esc, dauer, knopf, fehlerzeile, leer } from '../util';
|
||||||
import { dialog, nachfragen } from '../dialog';
|
import { dialog, nachfragen } from '../dialog';
|
||||||
import { icon } from '../icons';
|
import { icon, icons } from '../icons';
|
||||||
|
|
||||||
/** Die Verwaltung.
|
/** Die Verwaltung.
|
||||||
*
|
*
|
||||||
@@ -519,6 +520,22 @@ function vermisstenbereich(geaendert: () => void): HTMLElement {
|
|||||||
/* ---------- Bibliotheken ---------- */
|
/* ---------- Bibliotheken ---------- */
|
||||||
|
|
||||||
function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLElement {
|
function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLElement {
|
||||||
|
/** Die Liste, wie sie gerade steht — für das Umsortieren. */
|
||||||
|
let alleBibliotheken: Bibliothek[] = [];
|
||||||
|
|
||||||
|
/** Schiebt einen Eintrag und schickt die ganze neue Reihenfolge. */
|
||||||
|
async function schieben(von: number, nach: number): Promise<void> {
|
||||||
|
if (nach < 0 || nach >= alleBibliotheken.length) return;
|
||||||
|
|
||||||
|
const neu = [...alleBibliotheken];
|
||||||
|
const [eintrag] = neu.splice(von, 1);
|
||||||
|
neu.splice(nach, 0, eintrag!);
|
||||||
|
|
||||||
|
await reihenfolgeSetzen(neu.map(b => b.id));
|
||||||
|
await laden();
|
||||||
|
geaendert();
|
||||||
|
}
|
||||||
|
|
||||||
const bereich = document.createElement('section');
|
const bereich = document.createElement('section');
|
||||||
|
|
||||||
const anlegen = knopf('Mediathek hinzufügen', 'btn btn-primary');
|
const anlegen = knopf('Mediathek hinzufügen', 'btn btn-primary');
|
||||||
@@ -571,14 +588,15 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
|||||||
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
|
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
alle.forEach(b => liste.appendChild(block(b)));
|
alleBibliotheken = alle;
|
||||||
|
alle.forEach((b, i) => liste.appendChild(block(b, i)));
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
liste.innerHTML = '';
|
liste.innerHTML = '';
|
||||||
liste.appendChild(fehlerzeile((fehler as Error).message));
|
liste.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function block(b: Bibliothek): HTMLElement {
|
function block(b: Bibliothek, platz: number): HTMLElement {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.className = 'einstellung';
|
d.className = 'einstellung';
|
||||||
|
|
||||||
@@ -589,10 +607,16 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
|||||||
? (serie ? 'Serie' : 'Film')
|
? (serie ? 'Serie' : 'Film')
|
||||||
: (serie ? 'Serien' : 'Filme'));
|
: (serie ? 'Serien' : 'Filme'));
|
||||||
|
|
||||||
|
// Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn
|
||||||
|
// man bis zur Prüfliste weiterscrollt.
|
||||||
|
const wartet = b.pendingCount > 0
|
||||||
|
? ` · ${b.pendingCount} wartet${b.pendingCount === 1 ? '' : 'en'} auf Freigabe`
|
||||||
|
: '';
|
||||||
|
|
||||||
const kopf = document.createElement('div');
|
const kopf = document.createElement('div');
|
||||||
kopf.className = 'zeile';
|
kopf.className = 'zeile';
|
||||||
kopf.innerHTML = `<strong>${esc(b.name)}</strong>`
|
kopf.innerHTML = `<strong>${esc(b.name)}</strong>`
|
||||||
+ `<span class="winzig">${esc(b.path)}${esc(anzahl)}</span>`;
|
+ `<span class="winzig">${esc(b.path)}${esc(anzahl)}${esc(wartet)}</span>`;
|
||||||
d.appendChild(kopf);
|
d.appendChild(kopf);
|
||||||
|
|
||||||
const stand = document.createElement('div');
|
const stand = document.createElement('div');
|
||||||
@@ -685,9 +709,51 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// **Umsortieren über zwei Pfeile, nicht über Ziehen.**
|
||||||
|
//
|
||||||
|
// Ziehen und Ablegen ist schöner, aber mit der Tastatur unbedienbar und
|
||||||
|
// auf dem Telefon ein Glücksspiel. Zwei Knöpfe kann jeder bedienen, und
|
||||||
|
// der Server bekommt die ganze Liste statt eines „eins hoch" — zwei
|
||||||
|
// Benutzer, die gleichzeitig schieben, kämen sonst in einen Zustand, den
|
||||||
|
// keiner von beiden wollte.
|
||||||
|
const hoch = knopf('', 'btn rund');
|
||||||
|
hoch.innerHTML = icons.hoch;
|
||||||
|
hoch.setAttribute('aria-label', `„${b.name}“ nach oben`);
|
||||||
|
hoch.disabled = platz === 0;
|
||||||
|
hoch.onclick = () => void schieben(platz, platz - 1);
|
||||||
|
|
||||||
|
const runter = knopf('', 'btn rund');
|
||||||
|
runter.innerHTML = icons.hoch;
|
||||||
|
runter.style.transform = 'rotate(180deg)';
|
||||||
|
runter.setAttribute('aria-label', `„${b.name}“ nach unten`);
|
||||||
|
runter.disabled = platz === alleBibliotheken.length - 1;
|
||||||
|
runter.onclick = () => void schieben(platz, platz + 1);
|
||||||
|
|
||||||
const knoepfe = document.createElement('div');
|
const knoepfe = document.createElement('div');
|
||||||
knoepfe.className = 'knopfreihe';
|
knoepfe.className = 'knopfreihe';
|
||||||
knoepfe.append(los, auffrischen, pruefung, umbenennen, entfernen);
|
knoepfe.append(hoch, runter, los, auffrischen, pruefung, umbenennen, entfernen);
|
||||||
|
|
||||||
|
// **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun
|
||||||
|
// hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es
|
||||||
|
// geht, ohne dass man erst weiterscrollen muss.
|
||||||
|
if (b.pendingCount > 0) {
|
||||||
|
const freigeben = knopf(
|
||||||
|
`${b.pendingCount} ${b.pendingCount === 1 ? 'Titel' : 'Titel'} freigeben`,
|
||||||
|
'btn btn-primary');
|
||||||
|
freigeben.onclick = async () => {
|
||||||
|
freigeben.disabled = true;
|
||||||
|
try {
|
||||||
|
const bericht = await mediathekFreigeben(b.id);
|
||||||
|
stand.textContent = `${bericht.published} freigegeben.`;
|
||||||
|
await laden();
|
||||||
|
geaendert();
|
||||||
|
} catch (fehler) {
|
||||||
|
d.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
|
freigeben.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
knoepfe.appendChild(freigeben);
|
||||||
|
}
|
||||||
d.append(knoepfe, stand);
|
d.append(knoepfe, stand);
|
||||||
void verfolgen(false);
|
void verfolgen(false);
|
||||||
return d;
|
return d;
|
||||||
@@ -821,21 +887,38 @@ function serverbereich(geaendert: () => void): HTMLElement {
|
|||||||
platzhalter: 'Read Access Token oder API Key',
|
platzhalter: 'Read Access Token oder API Key',
|
||||||
hinweis: 'Leer lassen, um den mitgelieferten zu behalten.',
|
hinweis: 'Leer lassen, um den mitgelieferten zu behalten.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'sprache', beschriftung: 'Sprache der Metadaten',
|
|
||||||
auswahl: sprachen, wert: stand.language,
|
|
||||||
hinweis: 'In dieser Sprache werden Titel und Beschreibungen geholt. '
|
|
||||||
+ 'Sie bestimmt auch, welche Altersfreigabe gilt. Vorhandene Titel '
|
|
||||||
+ 'behalten ihre alte Sprache — dafür gibt es bei jeder Mediathek '
|
|
||||||
+ 'den Knopf „Metadaten auffrischen".',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
bestaetigen: 'Speichern',
|
bestaetigen: 'Speichern',
|
||||||
tun: async wert => {
|
tun: async wert => {
|
||||||
await metadatenSetzen({
|
await metadatenSetzen({ apiKey: wert('schluessel') || undefined });
|
||||||
apiKey: wert('schluessel') || undefined,
|
location.reload();
|
||||||
language: wert('sprache') || undefined,
|
},
|
||||||
});
|
})));
|
||||||
|
|
||||||
|
// **Die Sprache des Servers als eigene Zeile.**
|
||||||
|
//
|
||||||
|
// Sie 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.
|
||||||
|
liste.appendChild(zeile(
|
||||||
|
'Sprache des Servers',
|
||||||
|
sprachen.find(x => x.wert === stand.language)?.text ?? stand.language,
|
||||||
|
'In dieser Sprache werden Titel und Beschreibungen geholt, und sie '
|
||||||
|
+ 'bestimmt die Altersfreigabe. Gilt für alle. Vorhandene Titel behalten '
|
||||||
|
+ 'ihre alte Sprache — dafür gibt es bei jeder Mediathek „Metadaten '
|
||||||
|
+ 'auffrischen".',
|
||||||
|
() => dialog({
|
||||||
|
titel: 'Sprache des Servers',
|
||||||
|
text: 'Die Umstellung wirkt auf neue Funde sofort. Für den vorhandenen '
|
||||||
|
+ 'Bestand drückst du danach bei jeder Mediathek „Metadaten auffrischen" '
|
||||||
|
+ '— sonst behalten die Titel ihre alte Sprache.',
|
||||||
|
felder: [{
|
||||||
|
name: 'sprache', beschriftung: 'Sprache', auswahl: sprachen, wert: stand.language,
|
||||||
|
}],
|
||||||
|
bestaetigen: 'Übernehmen',
|
||||||
|
tun: async wert => {
|
||||||
|
await metadatenSetzen({ language: wert('sprache') });
|
||||||
location.reload();
|
location.reload();
|
||||||
},
|
},
|
||||||
})));
|
})));
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { vorschlaege, zuordnen } from './api';
|
||||||
|
import { esc, knopf, feld, fehlerzeile, leer } from './util';
|
||||||
|
import { icons } from './icons';
|
||||||
|
|
||||||
|
/** Einen Titel einem anderen Eintrag der Metadatenquelle zuordnen.
|
||||||
|
*
|
||||||
|
* **Als eigener Dialog und nicht nur auf der Detailseite.** Wer in der
|
||||||
|
* Mediathek zwanzig falsch erkannte Titel vor sich hat, soll sie dort
|
||||||
|
* geradeziehen können — vierzig Seitenwechsel sind keine Bedienung.
|
||||||
|
*
|
||||||
|
* `fertig` wird gerufen, wenn etwas zugeordnet wurde; der Aufrufer frischt
|
||||||
|
* damit auf, was er zeigt.
|
||||||
|
*/
|
||||||
|
export function zuordnungsdialog(
|
||||||
|
art: 'movies' | 'series',
|
||||||
|
id: string,
|
||||||
|
titel: string,
|
||||||
|
jahr: number | null,
|
||||||
|
fertig?: () => void,
|
||||||
|
): void {
|
||||||
|
const d = document.createElement('dialog');
|
||||||
|
d.className = 'dlg dlg-breit';
|
||||||
|
|
||||||
|
const kopf = document.createElement('div');
|
||||||
|
kopf.className = 'dlg-kopf';
|
||||||
|
const h = document.createElement('h2');
|
||||||
|
h.textContent = 'Zuordnung korrigieren';
|
||||||
|
const zu = document.createElement('button');
|
||||||
|
zu.className = 'dlg-zu';
|
||||||
|
zu.innerHTML = icons.schliessen;
|
||||||
|
zu.setAttribute('aria-label', 'Schliessen');
|
||||||
|
zu.onclick = () => d.close();
|
||||||
|
kopf.append(h, zu);
|
||||||
|
|
||||||
|
const koerper = document.createElement('div');
|
||||||
|
koerper.className = 'dlg-koerper';
|
||||||
|
|
||||||
|
const hinweis = document.createElement('p');
|
||||||
|
hinweis.className = 'hinweis';
|
||||||
|
hinweis.textContent = 'Der gewählte Eintrag ersetzt Titel, Beschreibung, Bilder '
|
||||||
|
+ 'und Besetzung. Von Hand gewählte Bilder bleiben.';
|
||||||
|
|
||||||
|
const suche = feld('Anderer Suchbegriff');
|
||||||
|
suche.value = titel;
|
||||||
|
const jahrfeld = feld('Jahr', 'number');
|
||||||
|
if (jahr) jahrfeld.value = String(jahr);
|
||||||
|
const suchen = knopf('Suchen', 'btn');
|
||||||
|
|
||||||
|
const reihe = document.createElement('div');
|
||||||
|
reihe.className = 'knopfreihe';
|
||||||
|
reihe.append(suche, jahrfeld, suchen);
|
||||||
|
|
||||||
|
const treffer = document.createElement('div');
|
||||||
|
treffer.className = 'einstellungen';
|
||||||
|
|
||||||
|
koerper.append(hinweis, reihe, treffer);
|
||||||
|
d.append(kopf, koerper);
|
||||||
|
|
||||||
|
d.addEventListener('close', () => d.remove());
|
||||||
|
d.addEventListener('click', e => { if (e.target === d) d.close(); });
|
||||||
|
// Enter im Suchfeld sucht, statt den Dialog zu schliessen.
|
||||||
|
suche.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); void fragen(); } };
|
||||||
|
|
||||||
|
document.body.appendChild(d);
|
||||||
|
d.showModal();
|
||||||
|
|
||||||
|
suchen.onclick = () => void fragen();
|
||||||
|
void fragen();
|
||||||
|
|
||||||
|
async function fragen(): Promise<void> {
|
||||||
|
treffer.innerHTML = '<div class="spinner"></div>';
|
||||||
|
try {
|
||||||
|
const liste = await vorschlaege(art, id,
|
||||||
|
suche.value.trim(), jahrfeld.value ? Number(jahrfeld.value) : undefined);
|
||||||
|
|
||||||
|
treffer.innerHTML = '';
|
||||||
|
if (liste.length === 0) { treffer.appendChild(leer('Nichts gefunden.')); return; }
|
||||||
|
|
||||||
|
liste.forEach(v => {
|
||||||
|
const zeile = document.createElement('button');
|
||||||
|
zeile.className = 'einstellung waehlbar';
|
||||||
|
zeile.innerHTML =
|
||||||
|
`<div class="zeile"><strong>${esc(v.title)}</strong>`
|
||||||
|
+ `<span class="winzig">${esc(v.year ?? '')} · TMDB ${v.tmdbId}</span></div>`
|
||||||
|
+ `<div class="hinweis">${esc((v.overview ?? '').slice(0, 160))}</div>`;
|
||||||
|
zeile.onclick = async () => {
|
||||||
|
zeile.disabled = true;
|
||||||
|
try {
|
||||||
|
await zuordnen(art, id, v.tmdbId);
|
||||||
|
d.close();
|
||||||
|
fertig?.();
|
||||||
|
} catch (fehler) {
|
||||||
|
treffer.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
|
zeile.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
treffer.appendChild(zeile);
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
treffer.innerHTML = '';
|
||||||
|
treffer.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user