diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index dc84e3a..deb232c 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -94,7 +94,27 @@ public sealed record LibraryResponse( /// scannt, sieht das Ergebnis an ihr — dass zwanzig Titel irgendwo anders /// warten, erfährt er sonst nur, wenn er weiterscrollt. /// - int PendingCount = 0); + int PendingCount = 0, + + /// Wie viele Mediendateien in dieser Mediathek liegen. + /// + /// + /// Weil „455 Filme" und „461 Dateien" beide stimmen können. Plex + /// zählte 451, Flaro 449 — und der Scan lief über 461 Dateien. Drei + /// Zahlen für drei verschiedene Dinge, und keine davon falsch. Nebeneinander + /// zu sehen, wie sie sich aufteilen, beendet die Frage; eine davon allein + /// zu zeigen hält sie offen. + /// + int FileCount = 0, + + /// Wie viele Folgen — nur bei Serien, sonst null. + int? EpisodeCount = null, + + /// Dateien, die an keinem Titel hängen. + int UnassignedCount = 0, + + /// Dateien über die erste je Titel hinaus — zweite Fassungen. + int ExtraVersionCount = 0); public sealed record RenameLibraryRequest(string Name); @@ -253,7 +273,17 @@ public sealed record MovieResponse( IReadOnlyList? Credits = null, /// Der Titel in der Sprache des Originals, wenn er abweicht. - string? OriginalTitle = null); + string? OriginalTitle = null, + + /// Ab wann sichtbar — null heisst: wartet auf Freigabe. + /// + /// + /// Damit die Mediathek es zeigen kann. Für einen Administrator + /// stehen wartende Titel mitten zwischen den freigegebenen, ohne dass + /// etwas sie unterscheidet — und wer einen davon sucht, findet ihn nur + /// über die Prüfliste. + /// + DateTimeOffset? PublishAt = null); public sealed record CreditResponse( Guid Id, string Name, string Role, string? Character, string? Image); @@ -279,7 +309,17 @@ public sealed record SeriesResponse( IReadOnlyList? Credits = null, /// Der Titel in der Sprache des Originals, wenn er abweicht. - string? OriginalTitle = null); + string? OriginalTitle = null, + + /// Ab wann sichtbar — null heisst: wartet auf Freigabe. + /// + /// + /// Damit die Mediathek es zeigen kann. Für einen Administrator + /// stehen wartende Titel mitten zwischen den freigegebenen, ohne dass + /// etwas sie unterscheidet — und wer einen davon sucht, findet ihn nur + /// über die Prüfliste. + /// + DateTimeOffset? PublishAt = null); public sealed record EpisodeResponse( Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage, diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index fd3df20..686e161 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -311,6 +311,7 @@ public static class LibraryContentEndpoints .Select(t => t.Overview).FirstOrDefault() ?? m.Overview, m.PosterImage, m.BackdropImage, m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating, + m.PublishAt, }) .ToListAsync(); @@ -331,7 +332,8 @@ public static class LibraryContentEndpoints .Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel)).ToList(), stände.GetValueOrDefault(m.Id), m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating, - OriginalTitle: m.OriginalTitle)).ToList(); + OriginalTitle: m.OriginalTitle, + PublishAt: m.PublishAt)).ToList(); return Results.Ok(new Page(items, gesamt)); }).RequireAuthorization(); @@ -381,6 +383,11 @@ public static class LibraryContentEndpoints .Select(t => t.Overview).FirstOrDefault() ?? s.Overview, s.PosterImage, s.BackdropImage, s.LogoImage, s.RuntimeMinutes, s.Rating, s.ContentRating, + // **Eine Serie wartet, solange keine Folge sichtbar ist.** + // Der früheste Zeitpunkt sagt, ab wann überhaupt etwas da + // ist; steht dort nichts, wartet alles. + PublishAt = db.Episodes.Where(f => f.SeriesId == s.Id) + .Min(f => f.PublishAt), }) .ToListAsync(); @@ -403,7 +410,8 @@ public static class LibraryContentEndpoints meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(), meine.Count(f => !gesehen.Contains(f.Id)), s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating, - OriginalTitle: s.OriginalTitle); + OriginalTitle: s.OriginalTitle, + PublishAt: s.PublishAt); }).ToList(); return Results.Ok(new Page(items, gesamt)); diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index f113f9c..5d175ce 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -101,15 +101,64 @@ public static class ManagementEndpoints .Select(g => new { Id = g.Key, Anzahl = g.Count() }) .ToDictionaryAsync(x => x.Id, x => x.Anzahl); - return Results.Ok(liste.Select(l => new LibraryResponse( - l.Id, l.Name, l.Kind.ToString(), l.Path, - l.Kind == LibraryKind.Shows - ? serien.GetValueOrDefault(l.Id) - : filme.GetValueOrDefault(l.Id), - l.ReviewNewItems, - l.Kind == LibraryKind.Shows - ? wartendeFolgen.GetValueOrDefault(l.Id) - : wartendeFilme.GetValueOrDefault(l.Id))).ToList()); + // **Drei Zahlen, die alle stimmen und Verschiedenes meinen.** + // + // Plex zählte 451 Filme, Flaro 449, und der Scan lief über 461 + // Dateien. Keine davon ist falsch — es sind Titel, Dateien und + // zweite Fassungen. Nebeneinander beantwortet sich die Frage von + // selbst; einzeln bleibt sie offen. + var folgen = await db.Episodes.AsNoTracking() + .GroupBy(f => db.Series.Where(x => x.Id == f.SeriesId) + .Select(x => x.LibraryId).First()) + .Select(g => new { Id = g.Key, Anzahl = g.Count() }) + .ToDictionaryAsync(x => x.Id, x => x.Anzahl); + + // Vermisste zählen nicht mit: sie liegen nicht mehr auf der Platte, + // und der Scan hat sie deshalb auch nicht gesehen. + // + // **`MitTitel` zählt Titel, nicht Dateien** — und zwar nur solche, + // zu denen wirklich noch eine Datei daliegt. Ohne das ginge die + // Rechnung schief, sobald etwas vermisst wird: ein erster Versuch + // verglich gegen alle Folgen und kam auf 1223 Titel bei 324 + // Dateien. + var dateien = await db.MediaFiles.AsNoTracking() + .Where(f => f.MissingSince == null) + .GroupBy(f => f.LibraryId) + .Select(g => new + { + Id = g.Key, + Alle = g.Count(), + Ohne = g.Count(f => f.MovieId == null && f.EpisodeId == null), + MitTitel = g.Where(f => f.MovieId != null).Select(f => f.MovieId).Distinct().Count() + + g.Where(f => f.EpisodeId != null).Select(f => f.EpisodeId).Distinct().Count(), + }) + .ToDictionaryAsync(x => x.Id, x => x); + + return Results.Ok(liste.Select(l => + { + var zaehler = dateien.GetValueOrDefault(l.Id); + var alleDateien = zaehler?.Alle ?? 0; + var ohneTitel = zaehler?.Ohne ?? 0; + + // Jeder Titel, zu dem noch eine Datei daliegt, hat mindestens + // eine. Was darüber liegt, sind zweite Fassungen — Plex' + // „Optimized for Mobile" etwa. + var mitTitel = zaehler?.MitTitel ?? 0; + + return new LibraryResponse( + l.Id, l.Name, l.Kind.ToString(), l.Path, + l.Kind == LibraryKind.Shows + ? serien.GetValueOrDefault(l.Id) + : filme.GetValueOrDefault(l.Id), + l.ReviewNewItems, + l.Kind == LibraryKind.Shows + ? wartendeFolgen.GetValueOrDefault(l.Id) + : wartendeFilme.GetValueOrDefault(l.Id), + FileCount: alleDateien, + EpisodeCount: l.Kind == LibraryKind.Shows ? folgen.GetValueOrDefault(l.Id) : null, + UnassignedCount: ohneTitel, + ExtraVersionCount: Math.Max(0, alleDateien - ohneTitel - mitTitel)); + }).ToList()); }).RequireAuthorization(); // **Was nicht mehr da ist, aber noch in der Mediathek steht.** diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 9807937..992e7f3 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -47,6 +47,8 @@ export type Film = { /** Leer in Listen, gefüllt beim Einzelabruf. */ genres: string[] | null; credits: Mitwirkender[] | null; + /** `null` heisst: wartet auf Freigabe. Ein Zeitpunkt in der Zukunft: geplant. */ + publishAt: string | null; /** Der Titel in der Sprache des Originals, wenn er abweicht. */ originalTitle: string | null; }; @@ -61,6 +63,8 @@ export type Serie = { contentRating: string | null; genres: string[] | null; credits: Mitwirkender[] | null; + /** `null` heisst: wartet auf Freigabe. Ein Zeitpunkt in der Zukunft: geplant. */ + publishAt: string | null; /** Der Titel in der Sprache des Originals, wenn er abweicht. */ originalTitle: string | null; }; @@ -91,6 +95,15 @@ export type Bibliothek = { /** Wie viele Titel hier noch auf Freigabe warten. */ pendingCount: number; + + /** Mediendateien in dieser Mediathek — ohne die vermissten. */ + fileCount: number; + /** Nur bei Serien: wie viele Folgen. */ + episodeCount: number | null; + /** Dateien, die an keinem Titel hängen. */ + unassignedCount: number; + /** Dateien über die erste je Titel hinaus — zweite Fassungen. */ + extraVersionCount: number; }; export type Seite = { items: T[]; total: number }; export type Lieferung = { diff --git a/web/app/src/card.ts b/web/app/src/card.ts index 4d155f6..f97ae14 100644 --- a/web/app/src/card.ts +++ b/web/app/src/card.ts @@ -23,6 +23,8 @@ export type Kachel = { stand?: Stand | null; /** Die Zahl offener Folgen, als Plakette oben rechts. */ offen?: number | null; + /** `null` heisst: wartet auf Freigabe. Ein Zeitpunkt in der Zukunft: geplant. */ + freigabe?: string | null; }; /* ---------- Umwandlungen ---------- */ @@ -35,6 +37,7 @@ export const alsKachel = (e: Film | Serie): Kachel => ({ bild: e.posterImage, stand: 'state' in e ? e.state : null, offen: 'unwatchedCount' in e ? e.unwatchedCount : null, + freigabe: e.publishAt, }); /** Eine Kachel aus „Weiterschauen" — bei Folgen dreizeilig, wie bei Plex. */ @@ -110,6 +113,23 @@ export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string) rahmen.appendChild(bild); } + // **Was noch wartet, trägt ein Band.** + // + // Für einen Administrator stehen wartende Titel mitten zwischen den + // freigegebenen, ohne dass etwas sie unterscheidet — und wer einen davon + // sucht, findet ihn nur über die Prüfliste. Für alle anderen ist er gar + // nicht da, dann erscheint das Band auch nie. + if (k.freigabe === null || (k.freigabe && new Date(k.freigabe) > new Date())) { + const band = document.createElement('span'); + band.className = 'wartet'; + band.textContent = k.freigabe === null ? 'Wartet' : 'Geplant'; + band.title = k.freigabe === null + ? 'Wartet auf Freigabe — nur für Administratoren sichtbar' + : `Erscheint am ${new Date(k.freigabe).toLocaleString('de-CH', + { dateStyle: 'medium', timeStyle: 'short' })}`; + rahmen.appendChild(band); + } + // **Der Haken sitzt in der Ecke, nicht in der Beschriftung.** So wie bei // Plex: ein Blick über das Raster zeigt, was noch offen ist, ohne eine // einzige Zeile zu lesen. diff --git a/web/app/src/style.css b/web/app/src/style.css index 8f8f064..4d2882b 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1063,6 +1063,16 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m * Abbrechen noch zu Bestätigen. */ .dlg-weiterer { margin-right: auto; } +/* Das Band an einer Kachel, die noch nicht freigegeben ist. Unten links, + * damit es weder dem Haken oben rechts noch dem Fortschrittsbalken am + * unteren Rand in die Quere kommt. */ +.wartet { + position: absolute; left: 0; bottom: 0; + padding: 3px 8px; border-top-right-radius: var(--r-knopf); + background: color-mix(in srgb, var(--accent) 88%, black); + color: #fff; font-size: 11px; font-weight: 700; letter-spacing: .02em; +} + .bildblock { display: flex; flex-direction: column; gap: var(--s-snug); } .bildblock h3 { margin: 0; font-size: var(--t-gross); font-weight: 600; flex: 1; } diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index b76a61e..b71d69e 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -808,20 +808,35 @@ function bibliotheksbereich( function kopfSchreiben(): void { const serie = aktuell.kind === 'Shows'; - // „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat. - const anzahl = aktuell.itemCount == null ? '' - : ` · ${aktuell.itemCount} ` + (aktuell.itemCount === 1 - ? (serie ? 'Serie' : 'Film') - : (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 = aktuell.pendingCount > 0 - ? ` · ${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe` - : ''; + // **Alle Zahlen nebeneinander, weil sie Verschiedenes meinen.** + // + // Plex zählte 451 Filme, Flaro 449, und der Scan lief über 461 Dateien. + // Keine davon war falsch — es sind Titel, Dateien und zweite Fassungen. + // Einzeln gezeigt bleibt die Frage offen, nebeneinander beantwortet sie + // sich. + const teile: string[] = [aktuell.path]; + + if (aktuell.itemCount != null) { + teile.push(`${aktuell.itemCount} ` + (aktuell.itemCount === 1 + ? (serie ? 'Serie' : 'Film') + : (serie ? 'Serien' : 'Filme'))); + } + if (serie && aktuell.episodeCount != null) { + teile.push(`${aktuell.episodeCount} ${aktuell.episodeCount === 1 ? 'Folge' : 'Folgen'}`); + } + teile.push(`${aktuell.fileCount} ${aktuell.fileCount === 1 ? 'Datei' : 'Dateien'}`); + if (aktuell.extraVersionCount > 0) { + teile.push(`${aktuell.extraVersionCount} zweite Fassung` + + (aktuell.extraVersionCount === 1 ? '' : 'en')); + } + if (aktuell.unassignedCount > 0) teile.push(`${aktuell.unassignedCount} nicht zugeordnet`); + if (aktuell.pendingCount > 0) { + teile.push(`${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe`); + } kopf.innerHTML = `${esc(aktuell.name)}` - + `${esc(aktuell.path)}${esc(anzahl)}${esc(wartet)}`; + + `${esc(teile.join(' · '))}`; } kopfSchreiben();