diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 4ba24be..932f8ce 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -210,9 +210,15 @@ public sealed record MediaStreamResponse( /// App spricht Dateien über ihre Kennung an. Zum Unterscheiden zweier /// Fassungen genügt der Name. /// +/// +/// Wie diese Fassung heisst — „Mobil", „4K", „Director's Cut" — oder +/// null, wenn es nur eine gibt. Ohne diese Angabe standen zwei Dateien +/// derselben Folge als zwei gleich aussehende Knopfreihen untereinander, und +/// die Folge wirkte doppelt. +/// public sealed record MediaFileResponse( Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds, - IReadOnlyList Streams); + IReadOnlyList Streams, string? VersionLabel = null); public sealed record MovieResponse( Guid Id, string Title, int? Year, string? Overview, diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index 08f9334..fd3df20 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -70,8 +70,13 @@ public static class LibraryContentEndpoints private static MediaFileResponse Datei( Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer, - ILookup spuren) => - new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList()); + ILookup spuren, string? fassung = null) => + new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, + spuren[id].ToList(), + // Steht sie nicht in der Datenbank, wird sie aus dem Pfad gelesen: + // ein Bestand von vor dieser Änderung soll nicht erst neu gescannt + // werden müssen, um „Mobil" zu zeigen. + fassung ?? FileNames.Fassung(pfad)); /// Die Genres eines Titels, in der Reihenfolge der Quelle. private static async Task> GenresZu(FlaroContext db, Guid? film, Guid? serie) => @@ -315,7 +320,7 @@ public static class LibraryContentEndpoints var ids = kennungen.Select(m => m.Id).ToList(); var dateien = await db.MediaFiles.AsNoTracking() .Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value)) - .Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId }) + .Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId, f.VersionLabel }) .ToListAsync(); var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList()); var stände = await ZuständeZu(db, wer, filme: ids); @@ -323,7 +328,7 @@ public static class LibraryContentEndpoints var items = kennungen.Select(m => new MovieResponse( m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, dateien.Where(f => f.MovieId == m.Id) - .Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(), + .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(); @@ -477,7 +482,7 @@ public static class LibraryContentEndpoints var dateien = await db.MediaFiles.AsNoTracking() .Where(f => f.MovieId == id) - .Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration }) + .Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.VersionLabel }) .ToListAsync(); var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList()); var stände = await ZuständeZu(db, wer, filme: [id]); @@ -489,7 +494,7 @@ public static class LibraryContentEndpoints return Results.Ok(new MovieResponse( film.Id, uebersetzt?.Title ?? film.Title, film.Year, uebersetzt?.Overview ?? film.Overview, film.PosterImage, film.BackdropImage, - dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(), + dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel)).ToList(), stände.GetValueOrDefault(id), film.LogoImage, film.RuntimeMinutes, film.Rating, uebersetzt?.Tagline ?? film.Tagline, film.ContentRating, @@ -553,7 +558,7 @@ public static class LibraryContentEndpoints var dateien = await db.MediaFiles.AsNoTracking() .Where(d => d.EpisodeId == naechste.Id) - .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration }) + .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel }) .ToListAsync(); var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]); @@ -569,7 +574,7 @@ public static class LibraryContentEndpoints return Results.Ok(new EpisodeResponse( naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber, 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, d.VersionLabel)).ToList(), stände.GetValueOrDefault(naechste.Id))); }).RequireAuthorization(); @@ -589,7 +594,7 @@ public static class LibraryContentEndpoints var ids = folgen.Select(f => f.Id).ToList(); var dateien = await db.MediaFiles.AsNoTracking() .Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value)) - .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId }) + .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId, d.VersionLabel }) .ToListAsync(); var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); var stände = await ZuständeZu(db, wer, folgen: ids); @@ -602,7 +607,7 @@ public static class LibraryContentEndpoints f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage ?? serienBild, dateien.Where(d => d.EpisodeId == f.Id) - .Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(), + .Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(), stände.GetValueOrDefault(f.Id))).ToList(); return Results.Ok(new Page(items, items.Count)); diff --git a/server/src/Flaro.Core/Library/FileNames.cs b/server/src/Flaro.Core/Library/FileNames.cs index 0e8ade1..cc3c4fc 100644 --- a/server/src/Flaro.Core/Library/FileNames.cs +++ b/server/src/Flaro.Core/Library/FileNames.cs @@ -140,12 +140,32 @@ public static partial class FileNames if (hinten.Length > 0 && IstFassungsname(hinten)) return Vereinheitlichen(hinten); } - var ordner = Path.GetFileName(Path.GetDirectoryName(pfad) ?? ""); - if (ordner.Length > 0 && IstFassungsname(ordner)) return Vereinheitlichen(Saeubern(ordner)); + // **Drei Ebenen hoch, nicht nur eine.** Plex legt seine zweite Fassung + // als `Plex Versions/Optimized for Mobile///…` ab — + // der Ordner, der die Fassung benennt, steht dort also weit über der + // Datei. Drei Ebenen decken das ab, ohne irgendwann die Mediathek + // selbst für eine Fassung zu halten. + var ordner = Path.GetDirectoryName(pfad); + for (var ebene = 0; ebene < 3 && !string.IsNullOrEmpty(ordner); ebene++) + { + var name = Path.GetFileName(ordner); + if (name.Length > 0 && IstFassungsname(name)) return Vereinheitlichen(Saeubern(name)); + ordner = Path.GetDirectoryName(ordner); + } return null; } + /// Ordner, die nur Fassungen einsammeln und keine Serie benennen. + /// + /// + /// Plex Versions ist kein Titel, sondern eine Schublade. Ohne diese + /// Prüfung hiesse die Serie so, und darunter läge der halbe Bestand ein + /// zweites Mal. + /// + private static bool IstFassungsordner(string name) => + IstFassungsname(name) || Schlank(name).Equals("plexversions", StringComparison.OrdinalIgnoreCase); + /// Macht aus einer gemessenen Höhe einen Namen: 2160 wird „4K". /// /// @@ -194,6 +214,13 @@ public static partial class FileNames { ["mobil"] = "Mobil", ["mobile"] = "Mobil", ["optimiert"] = "Mobil", ["optimized"] = "Mobil", + // Wie Plex seine zweite Fassung nennt. Nicht um Plex zu bedienen, + // sondern weil die Dateien nun einmal so auf der Platte liegen: + // ohne diese Zeilen steht jede Folge zweimal untereinander, mit + // zwei gleich aussehenden Knopfreihen und ohne Hinweis, warum. + ["optimizedformobile"] = "Mobil", + ["optimiertfürmobilgeräte"] = "Mobil", + ["optimiertfuermobilgeraete"] = "Mobil", ["directorscut"] = "Director's Cut", ["directorcut"] = "Director's Cut", ["extended"] = "Extended", ["extendedcut"] = "Extended", ["theatrical"] = "Kinofassung", ["kinofassung"] = "Kinofassung", @@ -355,8 +382,17 @@ public static partial class FileNames // sie benennt — dann bleibt nur der Dateiname. if (unterhalb is null || !unterhalb.Contains('/')) return null; - var erster = unterhalb[..unterhalb.IndexOf('/')]; - return Saubermachen(erster); + // **Schubladen überspringen.** Unter `Plex Versions/Optimized for + // Mobile/` steht die Serie erst darunter; wer den ersten Ordner + // nimmt, legt eine Serie namens „Plex Versions" an. + var teile = unterhalb.Split('/'); + foreach (var teil in teile[..^1]) + { + if (IstFassungsordner(teil) || IstStaffelordner(teil)) continue; + return Saubermachen(teil); + } + + return null; } var ordner = Path.GetDirectoryName(pfad); diff --git a/server/tests/Flaro.Core.Tests/FileNameTests.cs b/server/tests/Flaro.Core.Tests/FileNameTests.cs index 8ea364a..dacc9fb 100644 --- a/server/tests/Flaro.Core.Tests/FileNameTests.cs +++ b/server/tests/Flaro.Core.Tests/FileNameTests.cs @@ -352,3 +352,64 @@ public class EchteBenennungen Does.Contain("keine Folgennummer")); } } + +/// Was Plex als zweite Fassung ablegt, ist keine zweite Folge. +/// +/// +/// Aus dem echten Bestand. In der Folgenliste stand jede Folge mit zwei +/// gleich aussehenden Knopfreihen untereinander — zwei Dateien, kein Hinweis +/// worauf. Es sind Plex' „Optimized for Mobile"-Kopien. Erkannt heissen sie +/// „Mobil" und stehen als Fassung an derselben Folge; unerkannt sehen sie aus +/// wie ein Fehler. +/// +public class PlexFassungen +{ + private const string Wurzel = "/media/Serien"; + + [Test] + public void Optimiert_fuers_Telefon_heisst_Mobil() + { + Assert.That( + FileNames.Fassung($"{Wurzel}/Plex Versions/Optimized for Mobile/Dark/S01/Dark - S01E01.mkv"), + Is.EqualTo("Mobil")); + } + + [Test] + public void Die_Schublade_ist_keine_Serie() + { + var name = FileNames.ParseEpisode( + $"{Wurzel}/Plex Versions/Optimized for Mobile/Dark/S01/Dark - S01E01.mkv", Wurzel); + + Assert.That(name, Is.Not.Null); + Assert.That(name!.SeriesTitle, Is.EqualTo("Dark"), + "Ohne das hiesse die Serie „Plex Versions“."); + Assert.That(name.Season, Is.EqualTo(1)); + Assert.That(name.Episode, Is.EqualTo(1)); + } + + /// Beide Wege führen zur selben Folge — nur mit anderer Fassung. + [Test] + public void Original_und_Kopie_sind_dieselbe_Folge() + { + var original = FileNames.ParseEpisode($"{Wurzel}/Dark/S01/Dark - S01E01.mkv", Wurzel); + var mobil = FileNames.ParseEpisode( + $"{Wurzel}/Plex Versions/Optimized for Mobile/Dark/S01/Dark - S01E01.mkv", Wurzel); + + Assert.That(mobil!.SeriesTitle, Is.EqualTo(original!.SeriesTitle)); + Assert.That(mobil.Season, Is.EqualTo(original.Season)); + Assert.That(mobil.Episode, Is.EqualTo(original.Episode)); + + Assert.That(FileNames.Fassung($"{Wurzel}/Dark/S01/Dark - S01E01.mkv"), Is.Null); + Assert.That(FileNames.Fassung( + $"{Wurzel}/Plex Versions/Optimized for Mobile/Dark/S01/Dark - S01E01.mkv"), + Is.EqualTo("Mobil")); + } + + /// Ein Staffelordner zwischendrin ändert nichts. + [Test] + public void Staffelordner_benennt_weiterhin_keine_Serie() + { + var name = FileNames.ParseEpisode($"{Wurzel}/Dark/Staffel 2/Dark - S02E05.mkv", Wurzel); + Assert.That(name!.SeriesTitle, Is.EqualTo("Dark")); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 3299603..e4c8f83 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -19,6 +19,8 @@ export type Spur = { export type Datei = { id: string; fileName: string; sizeBytes: number; container: string | null; durationSeconds: number | null; streams: Spur[]; + /** „Mobil", „4K", „Director's Cut" — oder `null`, wenn es nur eine gibt. */ + versionLabel: string | null; }; /** Was dieser Benutzer mit einem Titel zu tun hatte. */ export type Stand = { diff --git a/web/app/src/style.css b/web/app/src/style.css index 72fba16..15702ed 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1045,6 +1045,20 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m .grundblock summary strong { color: var(--accent); margin-right: 6px; } .grundblock .protokoll { max-height: 220px; margin-top: var(--s-snug); } +/* Der Dateiname über einer Aktionsreihe — nur wenn es mehrere gibt. */ +.dateiname.fassung { + font-family: inherit; color: var(--text-dim); font-weight: 600; +} +.dateiname { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--text-faint); margin-top: var(--s-snug); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +/* Der Fühler, der das Nachladen auslöst — unsichtbar, aber nicht null hoch: + * ein Element ohne Höhe kreuzt die Sichtgrenze nie zuverlässig. */ +.nachlader { height: 1px; } + .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/detail.ts b/web/app/src/views/detail.ts index 8156c76..429f046 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -452,8 +452,27 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem block.appendChild(text); } - folge.files.forEach(datei => block.appendChild( - aktionsreihe({ ...({} as Film), id: folge.id, files: folge.files } as Film, folge.state, datei))); + // **Bei mehreren Dateien steht dabei, welche es ist.** + // + // Vorher standen zwei gleich aussehende Reihen untereinander, und die + // Folge wirkte doppelt. Es sind zwei Dateien — eine zweite Fassung, ein + // anderes Format, eine Kopie —, und genau das muss dastehen, damit man + // entscheiden kann, ob eine davon wegkann. + folge.files.forEach(datei => { + if (folge.files.length > 1) { + const welche = document.createElement('div'); + welche.className = 'winzig dateiname'; + // Der Fassungsname, wenn es einen gibt — „Mobil" sagt mehr als ein + // Pfad. Sonst der Dateiname, damit überhaupt etwas dasteht. + welche.textContent = (datei.versionLabel ?? datei.fileName) + + (datei.sizeBytes ? ` · ${groesse(datei.sizeBytes)}` : ''); + welche.title = datei.fileName; + if (datei.versionLabel) welche.classList.add('fassung'); + block.appendChild(welche); + } + block.appendChild(aktionsreihe( + { ...({} as Film), id: folge.id, files: folge.files } as Film, folge.state, datei)); + }); return block; } diff --git a/web/app/src/views/library.ts b/web/app/src/views/library.ts index 8751f30..12dc2c2 100644 --- a/web/app/src/views/library.ts +++ b/web/app/src/views/library.ts @@ -172,18 +172,69 @@ export function mediathek( ]); }).catch(() => {}); - void laden(); + /** Wie viele Einträge der Server höchstens je Zug gibt. */ + const SEITE = 200; - // Beim Zurückkommen aus einer anderen Ansicht leise nachladen — die alte - // Liste bleibt stehen, bis die neue da ist. - beiRueckkehr(wurzel, () => void laden()); + let geladen = 0; + let gesamt = 0; + let amEnde = false; + let holtGerade = false; + + /** Der Fühler unter dem Raster: wird er sichtbar, kommt die nächste Seite. */ + const fuehler = document.createElement('div'); + fuehler.className = 'nachlader'; + ziel.appendChild(fuehler); + + // **Nachladen beim Scrollen statt eines Knopfes.** + // + // Die Mediathek holte genau eine Seite und hörte danach auf — bei 455 + // Filmen endete die Liste mitten im Alphabet, ohne Hinweis, dass da noch + // etwas kommt. Der Fühler steht 600 Bildpunkte vor dem Ende, damit die + // nächste Seite da ist, bevor man sie braucht. + const beobachter = new IntersectionObserver( + eintraege => { if (eintraege.some(e => e.isIntersecting)) void laden(false); }, + { rootMargin: '600px' }); + beobachter.observe(fuehler); + // Kein eigenes Aufräumen: ein abgehängter Fühler löst nichts aus, und + // wird die Ansicht aus dem Zwischenspeicher zurückgeholt, soll er wieder + // greifen. + + // **Und ein zweiter Weg über das Scrollen.** + // + // Ein IntersectionObserver braucht eine Seite, die auch gezeichnet wird; + // in einem Fenster im Hintergrund löst er nicht aus, und beim Prüfen blieb + // die Liste genau deshalb bei 200 stehen. Die Prüfung von Hand kostet + // nichts — sie läuft nur, solange überhaupt noch etwas nachzuladen ist. + const beimScrollen = () => { + if (amEnde || holtGerade) return; + if (!fuehler.isConnected) return; + if (fuehler.getBoundingClientRect().top < window.innerHeight + 600) void laden(false); + }; + window.addEventListener('scroll', beimScrollen, { passive: true, capture: true }); + window.addEventListener('resize', beimScrollen, { passive: true }); + + async function laden(vonVorn = true): Promise { + if (holtGerade) return; + if (!vonVorn && amEnde) return; + holtGerade = true; - async function laden(): Promise { // Der Spinner nur beim ersten Mal; beim Nachladen bleibt stehen, was da // ist, bis es ersetzt wird. - if (gitter.childElementCount === 0) gitter.innerHTML = '
'; + if (vonVorn && gitter.childElementCount === 0) { + gitter.innerHTML = '
'; + } + try { - const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, genre, take: 200 }); + const seite = await inhalt(bibliothek, { + search: suche, sort: ordnung, genre, + skip: vonVorn ? 0 : geladen, take: SEITE, + }); + + if (vonVorn) { gitter.innerHTML = ''; geladen = 0; amEnde = false; } + + gesamt = seite.total; + geladen += seite.items.length; + if (seite.items.length < SEITE || geladen >= gesamt) amEnde = true; // **Gesehen/offen siebt hier, nicht am Server.** Er kann es noch // nicht; es jetzt wegzulassen hiesse, einen Filter anzubieten, den @@ -196,23 +247,43 @@ export function mediathek( return gesehen === 'gesehen' ? ist : !ist; }); - anzahl.textContent = gesehen === '' - ? String(seite.total) - : `${uebrig.length} von ${seite.total}`; + uebrig.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen))); - gitter.innerHTML = ''; - if (uebrig.length === 0) { + // **Die Zahl sagt, wie viel schon da ist.** „455" über einer Liste mit + // 200 Kacheln wäre eine Behauptung, die man beim Scrollen widerlegt. + anzahl.textContent = amEnde && gesehen === '' + ? String(gesamt) + : `${gitter.childElementCount} von ${gesamt}`; + + if (gitter.childElementCount === 0 && amEnde) { gitter.appendChild(leer(suche || genre || gesehen ? 'Nichts gefunden.' : `„${bibliothek.name}" ist leer. Starte unter Verwaltung einen Scan.`)); - return; } - uebrig.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen))); } catch (fehler) { - gitter.innerHTML = ''; + if (vonVorn) gitter.innerHTML = ''; gitter.appendChild(fehlerzeile(meldung(fehler))); + amEnde = true; + } finally { + holtGerade = false; + } + + // Füllt eine Seite den Bildschirm nicht, kommt der Fühler nie in Sicht + // — dann muss von Hand nachgefasst werden. + if (!amEnde && !vonVorn && fuehler.getBoundingClientRect().top < window.innerHeight) { + void laden(false); } } + + // **Erst hier starten.** `laden()` greift auf `holtGerade`, `geladen` und + // den Fühler zu; stand der Aufruf darüber, lief er in die temporale + // Totzone von `const`/`let` — „Cannot access 'holtGerade' before + // initialization", und das Raster blieb leer. + void laden(); + + // Beim Zurückkommen aus einer anderen Ansicht leise nachladen — die alte + // Liste bleibt stehen, bis die neue da ist. + beiRueckkehr(wurzel, () => void laden()); } /* ---------- Reiter „Empfehlungen" ---------- */