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();