**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>
170 lines
6.9 KiB
Swift
170 lines
6.9 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import MediaKit
|
|
|
|
@Suite("Auswahl für den Aufmacher")
|
|
struct HeroSelectionTests {
|
|
|
|
private func titel(_ id: String, backdrop: Bool = true) -> MediaItem {
|
|
MediaItem(id: id, kind: .movie, title: "Film \(id)", art: backdrop ? "/art/\(id)" : nil)
|
|
}
|
|
|
|
private var zwanzig: [MediaItem] { (1...20).map { titel(String($0)) } }
|
|
|
|
/// **Der Fehler, wegen dem es diesen Typ gibt.** Dieselbe Liste, dasselbe
|
|
/// Zeitfenster, zweimal gefragt — und zweimal dasselbe. Vorher kam das
|
|
/// nur heraus, solange die Liste sich nicht um einen einzigen Eintrag
|
|
/// unterschied.
|
|
@Test("Gleiche Eingabe, gleiches Ergebnis")
|
|
func stableWithinWindow() {
|
|
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
|
|
#expect(
|
|
HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
|
|
== HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
|
|
)
|
|
}
|
|
|
|
/// Und der eigentliche Punkt: ein Titel mehr darf die übrigen nicht
|
|
/// durcheinanderbringen. Genau das ist bei jedem Neustart passiert —
|
|
/// „Weiterschauen" wächst und schrumpft ständig.
|
|
@Test("Ein zusätzlicher Titel verschiebt die anderen nicht")
|
|
func addingOneKeepsTheRest() {
|
|
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
|
|
let vorher = HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
|
|
let nachher = HeroSelection.ordered(zwanzig + [titel("99")], at: jetzt).map(\.id)
|
|
|
|
// Der Neue darf irgendwo stehen; die übrigen müssen in derselben
|
|
// Reihenfolge zueinander stehen wie vorher.
|
|
#expect(nachher.filter { $0 != "99" } == vorher)
|
|
}
|
|
|
|
@Test("Ein fehlender Titel hinterlässt nur eine Lücke")
|
|
func removingOneKeepsTheRest() {
|
|
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
|
|
let vorher = HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
|
|
let ohne = HeroSelection.ordered(zwanzig.filter { $0.id != "7" }, at: jetzt).map(\.id)
|
|
|
|
#expect(ohne == vorher.filter { $0 != "7" })
|
|
}
|
|
|
|
/// Sechs Stunden später eine andere Auswahl — sonst wäre es keine
|
|
/// Abwechslung, sondern eine feste Reihenfolge.
|
|
@Test("Im nächsten Zeitfenster steht es anders")
|
|
func changesAcrossWindows() {
|
|
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
|
|
let später = jetzt.addingTimeInterval(7 * 3_600)
|
|
#expect(
|
|
HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
|
|
!= HeroSelection.ordered(zwanzig, at: später).map(\.id)
|
|
)
|
|
}
|
|
|
|
@Test("Eine Stunde später steht es noch gleich")
|
|
func stableAcrossAnHour() {
|
|
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
|
|
#expect(
|
|
HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
|
|
== HeroSelection.ordered(zwanzig, at: jetzt.addingTimeInterval(1_800)).map(\.id)
|
|
)
|
|
}
|
|
|
|
/// Ohne Hintergrundbild ist der Aufmacher eine graue Fläche mit Text.
|
|
/// Ausschliessen wäre zu hart — schlechter losen ist genug.
|
|
@Test("Titel ohne Hintergrundbild landen meistens hinten")
|
|
func backdropsWin() {
|
|
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
|
|
let gemischt = (1...10).map { titel("mit\($0)") }
|
|
+ (1...10).map { titel("ohne\($0)", backdrop: false) }
|
|
|
|
let ersteFünf = HeroSelection.ordered(gemischt, at: jetzt).prefix(5).map(\.id)
|
|
#expect(ersteFünf.allSatisfy { $0.hasPrefix("mit") })
|
|
}
|
|
|
|
/// Gäbe es nur Titel ohne Bild, dürfte nicht einfach nichts kommen.
|
|
@Test("Gibt es nur Titel ohne Bild, kommen sie trotzdem")
|
|
func withoutBackdropsStillReturns() {
|
|
let ohne = (1...5).map { titel(String($0), backdrop: false) }
|
|
#expect(HeroSelection.ordered(ohne).count == 5)
|
|
}
|
|
|
|
@Test("Eine leere Liste bleibt leer")
|
|
func emptyStaysEmpty() {
|
|
#expect(HeroSelection.ordered([]).isEmpty)
|
|
}
|
|
|
|
/// **Nicht `hashValue`.** Swift streut Hashes je Programmlauf zufällig —
|
|
/// damit wäre die Auswahl nach jedem Start eine andere, also genau der
|
|
/// Fehler, der hier behoben werden soll.
|
|
@Test("Der Streuwert hängt nur am Text")
|
|
func hashIsStable() {
|
|
#expect(HeroSelection.stableHash("abc") == HeroSelection.stableHash("abc"))
|
|
#expect(HeroSelection.stableHash("abc") != HeroSelection.stableHash("abd"))
|
|
}
|
|
|
|
@Test("Die Lose liegen zwischen null und eins")
|
|
func lotsAreInRange() {
|
|
for id in 1...200 {
|
|
let los = HeroSelection.unitRandom(seed: UInt64(id))
|
|
#expect(los >= 0 && los < 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suite("Mediatheken für den Aufmacher")
|
|
struct HeroLibraryFilterTests {
|
|
|
|
private func reihe(_ id: String, library: String? = nil) -> MediaRowGroup {
|
|
MediaRowGroup(
|
|
id: id,
|
|
title: id,
|
|
libraryID: library,
|
|
items: [MediaItem(id: "\(id).1", kind: .movie, title: "Titel")]
|
|
)
|
|
}
|
|
|
|
private var alle: [MediaRowGroup] {
|
|
[reihe("resume"), reihe("latest.filme", library: "filme"),
|
|
reihe("latest.serien", library: "serien"), reihe("favourites")]
|
|
}
|
|
|
|
/// **Der wichtigste Fall.** Wer nichts ankreuzt, meint „mir ist es
|
|
/// gleich" — nicht „nimm nichts". Ein leerer Aufmacher wäre hier der
|
|
/// Fehler, den niemand mit der Einstellung in Verbindung brächte.
|
|
@Test("Ohne Auswahl bleibt alles")
|
|
func emptySelectionKeepsEverything() {
|
|
#expect(HeroSelection.rows(alle, limitedTo: []).map(\.id) == alle.map(\.id))
|
|
}
|
|
|
|
@Test("Eine gewählte Mediathek lässt nur ihre Reihe durch")
|
|
func selectionKeepsOnlyThatLibrary() {
|
|
let übrig = HeroSelection.rows(alle, limitedTo: ["filme"]).map(\.id)
|
|
#expect(übrig.contains("latest.filme"))
|
|
#expect(!übrig.contains("latest.serien"))
|
|
}
|
|
|
|
/// **Die Regel, die zuerst andersherum stand.** Weiterschauen und
|
|
/// Favoriten tragen keine Mediathek; sie trotzdem durchzulassen hiess,
|
|
/// dass bei „nur Filme" weiter eine Serie oben stand — und niemand
|
|
/// brachte das mit der Einstellung in Verbindung.
|
|
@Test("Reihen ohne Mediathek fallen weg, sobald etwas gewählt ist")
|
|
func rowsWithoutLibraryDropOutOnSelection() {
|
|
let übrig = HeroSelection.rows(alle, limitedTo: ["filme"]).map(\.id)
|
|
#expect(übrig == ["latest.filme"])
|
|
}
|
|
|
|
/// Eine Mediathek, die es nicht mehr gibt — umbenannt, gelöscht, Server
|
|
/// gewechselt. Dann bleibt nichts übrig, und der Aufmacher fällt auf den
|
|
/// ersten Titel der Reihen zurück; das ist ehrlicher, als heimlich doch
|
|
/// etwas anderes zu zeigen.
|
|
@Test("Eine unbekannte Kennung lässt nichts übrig")
|
|
func unknownSelectionLeavesNothing() {
|
|
#expect(HeroSelection.rows(alle, limitedTo: ["gibtsnicht"]).isEmpty)
|
|
}
|
|
|
|
@Test("Mehrere gewählte Mediatheken kommen alle durch")
|
|
func multipleSelection() {
|
|
let übrig = HeroSelection.rows(alle, limitedTo: ["filme", "serien"]).map(\.id)
|
|
#expect(übrig == ["latest.filme", "latest.serien"])
|
|
}
|
|
}
|