451 gegen 461, und ein Band für das, was noch wartet

Drei Zahlen, die alle stimmen und Verschiedenes meinen: Plex zählte 451
Filme, Flaro 449, und der Scan lief über 461 Dateien. Die
Mediathekszeile nennt sie jetzt getrennt — Titel, Folgen, Dateien,
zweite Fassungen und nicht Zugeordnetes. Damit beantwortet sich die
Frage selbst, statt offen zu bleiben.

Die Rechnung dahinter war beim ersten Versuch falsch: sie verglich
gegen alle Folgen und kam auf 1223 Titel bei 324 Dateien. Gezählt wird
jetzt, zu wie vielen Titeln überhaupt noch eine Datei daliegt — dann
geht Dateien = Titel + Fassungen + ohne auf, auch wenn etwas vermisst
wird. Nachgemessen mit einer echten Plex-Kopie: 3 Titel, 4 Dateien, 1
zweite Fassung.

Dazu trägt eine Kachel, die noch auf Freigabe wartet, jetzt ein Band —
„Wartet" oder „Geplant". Für einen Administrator standen wartende Titel
bisher mitten zwischen den freigegebenen, ohne dass etwas sie
unterschied; wer einen suchte, fand ihn nur über die Prüfliste. Dafür
reicht `PublishAt` jetzt bis in die Kachel durch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 22:04:17 +02:00
co-authored by Claude Opus 5
parent 5a5cf3d006
commit c854c4a09a
7 changed files with 180 additions and 25 deletions
+43 -3
View File
@@ -94,7 +94,27 @@ public sealed record LibraryResponse(
/// scannt, sieht das Ergebnis an ihr — dass zwanzig Titel irgendwo anders /// scannt, sieht das Ergebnis an ihr — dass zwanzig Titel irgendwo anders
/// warten, erfährt er sonst nur, wenn er weiterscrollt. /// warten, erfährt er sonst nur, wenn er weiterscrollt.
/// </remarks> /// </remarks>
int PendingCount = 0); int PendingCount = 0,
/// <summary>Wie viele Mediendateien in dieser Mediathek liegen.</summary>
///
/// <remarks>
/// <b>Weil „455 Filme" und „461 Dateien" beide stimmen können.</b> 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.
/// </remarks>
int FileCount = 0,
/// <summary>Wie viele Folgen — nur bei Serien, sonst <c>null</c>.</summary>
int? EpisodeCount = null,
/// <summary>Dateien, die an keinem Titel hängen.</summary>
int UnassignedCount = 0,
/// <summary>Dateien über die erste je Titel hinaus — zweite Fassungen.</summary>
int ExtraVersionCount = 0);
public sealed record RenameLibraryRequest(string Name); public sealed record RenameLibraryRequest(string Name);
@@ -253,7 +273,17 @@ public sealed record MovieResponse(
IReadOnlyList<CreditResponse>? Credits = null, IReadOnlyList<CreditResponse>? Credits = null,
/// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary> /// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary>
string? OriginalTitle = null); string? OriginalTitle = null,
/// <summary>Ab wann sichtbar — <c>null</c> heisst: wartet auf Freigabe.</summary>
///
/// <remarks>
/// <b>Damit die Mediathek es zeigen kann.</b> 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.
/// </remarks>
DateTimeOffset? PublishAt = null);
public sealed record CreditResponse( public sealed record CreditResponse(
Guid Id, string Name, string Role, string? Character, string? Image); Guid Id, string Name, string Role, string? Character, string? Image);
@@ -279,7 +309,17 @@ public sealed record SeriesResponse(
IReadOnlyList<CreditResponse>? Credits = null, IReadOnlyList<CreditResponse>? Credits = null,
/// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary> /// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary>
string? OriginalTitle = null); string? OriginalTitle = null,
/// <summary>Ab wann sichtbar — <c>null</c> heisst: wartet auf Freigabe.</summary>
///
/// <remarks>
/// <b>Damit die Mediathek es zeigen kann.</b> 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.
/// </remarks>
DateTimeOffset? PublishAt = null);
public sealed record EpisodeResponse( public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage, Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
@@ -311,6 +311,7 @@ public static class LibraryContentEndpoints
.Select(t => t.Overview).FirstOrDefault() ?? m.Overview, .Select(t => t.Overview).FirstOrDefault() ?? m.Overview,
m.PosterImage, m.BackdropImage, m.PosterImage, m.BackdropImage,
m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating, m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating,
m.PublishAt,
}) })
.ToListAsync(); .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(), .Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel)).ToList(),
stände.GetValueOrDefault(m.Id), stände.GetValueOrDefault(m.Id),
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating, 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<MovieResponse>(items, gesamt)); return Results.Ok(new Page<MovieResponse>(items, gesamt));
}).RequireAuthorization(); }).RequireAuthorization();
@@ -381,6 +383,11 @@ public static class LibraryContentEndpoints
.Select(t => t.Overview).FirstOrDefault() ?? s.Overview, .Select(t => t.Overview).FirstOrDefault() ?? s.Overview,
s.PosterImage, s.BackdropImage, s.PosterImage, s.BackdropImage,
s.LogoImage, s.RuntimeMinutes, s.Rating, s.ContentRating, 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(); .ToListAsync();
@@ -403,7 +410,8 @@ public static class LibraryContentEndpoints
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(), meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
meine.Count(f => !gesehen.Contains(f.Id)), meine.Count(f => !gesehen.Contains(f.Id)),
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating, s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating,
OriginalTitle: s.OriginalTitle); OriginalTitle: s.OriginalTitle,
PublishAt: s.PublishAt);
}).ToList(); }).ToList();
return Results.Ok(new Page<SeriesResponse>(items, gesamt)); return Results.Ok(new Page<SeriesResponse>(items, gesamt));
@@ -101,7 +101,51 @@ public static class ManagementEndpoints
.Select(g => new { Id = g.Key, Anzahl = g.Count() }) .Select(g => new { Id = g.Key, Anzahl = g.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Anzahl); .ToDictionaryAsync(x => x.Id, x => x.Anzahl);
return Results.Ok(liste.Select(l => new LibraryResponse( // **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.Id, l.Name, l.Kind.ToString(), l.Path,
l.Kind == LibraryKind.Shows l.Kind == LibraryKind.Shows
? serien.GetValueOrDefault(l.Id) ? serien.GetValueOrDefault(l.Id)
@@ -109,7 +153,12 @@ public static class ManagementEndpoints
l.ReviewNewItems, l.ReviewNewItems,
l.Kind == LibraryKind.Shows l.Kind == LibraryKind.Shows
? wartendeFolgen.GetValueOrDefault(l.Id) ? wartendeFolgen.GetValueOrDefault(l.Id)
: wartendeFilme.GetValueOrDefault(l.Id))).ToList()); : 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(); }).RequireAuthorization();
// **Was nicht mehr da ist, aber noch in der Mediathek steht.** // **Was nicht mehr da ist, aber noch in der Mediathek steht.**
+13
View File
@@ -47,6 +47,8 @@ export type Film = {
/** Leer in Listen, gefüllt beim Einzelabruf. */ /** Leer in Listen, gefüllt beim Einzelabruf. */
genres: string[] | null; genres: string[] | null;
credits: Mitwirkender[] | 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. */ /** Der Titel in der Sprache des Originals, wenn er abweicht. */
originalTitle: string | null; originalTitle: string | null;
}; };
@@ -61,6 +63,8 @@ export type Serie = {
contentRating: string | null; contentRating: string | null;
genres: string[] | null; genres: string[] | null;
credits: Mitwirkender[] | 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. */ /** Der Titel in der Sprache des Originals, wenn er abweicht. */
originalTitle: string | null; originalTitle: string | null;
}; };
@@ -91,6 +95,15 @@ export type Bibliothek = {
/** Wie viele Titel hier noch auf Freigabe warten. */ /** Wie viele Titel hier noch auf Freigabe warten. */
pendingCount: number; 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<T> = { items: T[]; total: number }; export type Seite<T> = { items: T[]; total: number };
export type Lieferung = { export type Lieferung = {
+20
View File
@@ -23,6 +23,8 @@ export type Kachel = {
stand?: Stand | null; stand?: Stand | null;
/** Die Zahl offener Folgen, als Plakette oben rechts. */ /** Die Zahl offener Folgen, als Plakette oben rechts. */
offen?: number | null; offen?: number | null;
/** `null` heisst: wartet auf Freigabe. Ein Zeitpunkt in der Zukunft: geplant. */
freigabe?: string | null;
}; };
/* ---------- Umwandlungen ---------- */ /* ---------- Umwandlungen ---------- */
@@ -35,6 +37,7 @@ export const alsKachel = (e: Film | Serie): Kachel => ({
bild: e.posterImage, bild: e.posterImage,
stand: 'state' in e ? e.state : null, stand: 'state' in e ? e.state : null,
offen: 'unwatchedCount' in e ? e.unwatchedCount : null, offen: 'unwatchedCount' in e ? e.unwatchedCount : null,
freigabe: e.publishAt,
}); });
/** Eine Kachel aus „Weiterschauen" — bei Folgen dreizeilig, wie bei Plex. */ /** 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); 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 // **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 // Plex: ein Blick über das Raster zeigt, was noch offen ist, ohne eine
// einzige Zeile zu lesen. // einzige Zeile zu lesen.
+10
View File
@@ -1063,6 +1063,16 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
* Abbrechen noch zu Bestätigen. */ * Abbrechen noch zu Bestätigen. */
.dlg-weiterer { margin-right: auto; } .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 { display: flex; flex-direction: column; gap: var(--s-snug); }
.bildblock h3 { margin: 0; font-size: var(--t-gross); font-weight: 600; flex: 1; } .bildblock h3 { margin: 0; font-size: var(--t-gross); font-weight: 600; flex: 1; }
+26 -11
View File
@@ -808,20 +808,35 @@ function bibliotheksbereich(
function kopfSchreiben(): void { function kopfSchreiben(): void {
const serie = aktuell.kind === 'Shows'; 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 // **Alle Zahlen nebeneinander, weil sie Verschiedenes meinen.**
// man bis zur Prüfliste weiterscrollt. //
const wartet = aktuell.pendingCount > 0 // Plex zählte 451 Filme, Flaro 449, und der Scan lief über 461 Dateien.
? ` · ${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe` // 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 = `<strong>${esc(aktuell.name)}</strong>` kopf.innerHTML = `<strong>${esc(aktuell.name)}</strong>`
+ `<span class="winzig">${esc(aktuell.path)}${esc(anzahl)}${esc(wartet)}</span>`; + `<span class="winzig">${esc(teile.join(' · '))}</span>`;
} }
kopfSchreiben(); kopfSchreiben();