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" ---------- */