Intro und Abspann bei Jellyfin, und drei Dinge aus dem Gebrauch

**Intro überspringen gab es für Jellyfin nie.** Der Knopf, die Marken,
das automatische Überspringen, die Einstellungen dazu — alles stand seit
Langem im Player und auf dem Fernseher. Nur kam bei Jellyfin nie eine
Marke an: `JellyfinItem+Media.swift` setzte `markers: []`, weil Jellyfin
sie nicht am Titel liefert.

Seit 10.10 gibt es `/MediaSegments/{itemId}`, gefüllt von einem Anbieter
im Server wie dem Intro-Skipper. Die Anfrage läuft **neben** dem Abruf
des Titels, nicht danach — sonst wartete die Detailseite zweimal.

Zwei Dinge, die sonst eine Stunde kosten: `includeSegmentTypes` muss
**zweimal** stehen, nicht einmal mit Komma (mit Komma antwortet Jellyfin
mit null Treffern), und die Abfrage wirft nicht. Ein Server ohne Anbieter
antwortet leer, einer vor 10.10 mit einem 404 — und ein Film ohne Intro
ist kein Fehler, sondern der Normalfall.

Geprüft im Simulator: die Attrappe meldet jetzt Segmente, und im Player
steht „Intro überspringen".

---

**„Aus Weiterschauen entfernen".** Eine halbe Stunde gesehen und keine
Lust mehr — der Titel ist weder gesehen noch ungesehen, er soll nur nicht
ewig oben stehen. Beide Server können es, auf verschiedenen Wegen:

- **Plex** hat einen eigenen: `PUT /actions/removeFromContinueWatching`.
  **Nicht** `/:/unscrobble` — das setzt zugleich auf ungesehen.
- **Jellyfin** hat keinen. Was es gibt, ist das Benutzerdatenblatt; die
  Liste führt, was eine gemerkte Stelle grösser null hat. Also wird das
  Blatt gelesen, bekommt eine Null und geht so, wie es war, zurück.
  **Erst lesen, dann schreiben**, weil dort auch „gesehen", „Favorit" und
  die Bewertung stehen — wer seinen Favoriten verliert, weil er einen
  Film aus einer Liste genommen hat, sucht den Grund nie dort.

Der Eintrag erscheint nur, wenn es eine gemerkte Stelle gibt.

---

**Die Mediatheken für den Aufmacher taten zu wenig.** Die Regel „Reihen
ohne Mediathek bleiben immer drin" war freundlich gemeint und falsch: bei
„nur Filme" stand weiter eine Serie oben, weil sie aus „Weiterschauen"
kam. Gemessen und nicht vermutet — es lag nicht am Zeitfenster der
Auswahl, wie zuerst angenommen.

Jetzt gilt: ist etwas gewählt, speist sich der Aufmacher **nur** daraus.
Wer Weiterschauen oben haben will, wählt keine Mediathek. Der Hinweis
unter der Auswahl sagt das auch.

---

**Der „Bearbeiten"-Knopf stand über allem und tat dort nichts.** Er gilt
nur für die Reihen; jetzt steht er an deren Überschrift.

---

Die Attrappe kann drei Dinge mehr, sonst wäre nichts davon prüfbar:
Segmente, `parentId` bei den Neuzugängen (vorher stand in jeder Mediathek
dasselbe) und damit überhaupt eine Mediathekenauswahl, die man ansehen
kann.

iOS und tvOS bauen, 468 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-01 08:47:06 +02:00
co-authored by Claude Opus 5
parent dc93d6e22e
commit 3a6a6c4242
12 changed files with 250 additions and 33 deletions
+21 -1
View File
@@ -150,13 +150,33 @@ class Handler(BaseHTTPRequestHandler):
{"Id": "lib-mixed", "Name": "Gemischtes", "Type": "CollectionFolder"},
]))
if p == f"/Users/{USER_ID}/Items/Latest":
return self.reply([item(*t) for t in TITLES[:6]])
# `parentId` wird beachtet, sonst stuende in jeder Mediathek
# dasselbe — und die Auswahl der Mediatheken fuer den Aufmacher
# liesse sich gar nicht pruefen.
eltern = (q.get("parentId") or q.get("ParentId") or [None])[0]
erlaubt = {
"lib-movies": {"Movie"},
"lib-shows": {"Series"},
}.get(eltern)
gewaehlt = [t for t in TITLES if erlaubt is None or t[2] in erlaubt]
return self.reply([item(*t) for t in gewaehlt[:6]])
if p == f"/Users/{USER_ID}/Items/Resume":
return self.reply(page([
item("e1", "Der Nebel kommt", "Episode", "Erste Folge, letzte Ruhe.",
resume=0.4, series=("s1", "Küstenwache", 3)),
item("m2", TITLES[1][1], "Movie", TITLES[1][3], resume=0.2),
]))
if p.startswith("/MediaSegments/"):
# Intro und Abspann, wie ein Server mit Intro-Skipper sie meldet.
# In Hundertnanosekunden. Die Beispieldatei ist zehn Sekunden
# lang; das Intro deckt davon die ersten fuenf ab, damit der
# Knopf lange genug steht, um ihn zu sehen und zu treffen.
return self.reply(page([
{"Id": "seg-1", "ItemId": p.split("/")[2], "Type": "Intro",
"StartTicks": 0, "EndTicks": 5 * 10_000_000},
{"Id": "seg-2", "ItemId": p.split("/")[2], "Type": "Outro",
"StartTicks": 8 * 10_000_000, "EndTicks": 10 * 10_000_000},
]))
if p == "/Shows/NextUp":
return self.reply(page([
item("e2", "Das Wrack", "Episode", "Was der Sturm freilegt.",