From 3a6a6c42426f07283db11752ed375073dd3bc333 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 1 Oct 2026 08:47:06 +0200 Subject: [PATCH] Intro und Abspann bei Jellyfin, und drei Dinge aus dem Gebrauch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 --- Flixy/Resources/Localizable.xcstrings | 21 ++++- .../Views/Components/ItemActionEntries.swift | 11 +++ Flixy/Views/Settings/HomeLayoutView.swift | 24 ++++-- .../JellyfinBackend+Segments.swift | 82 +++++++++++++++++++ .../Sources/JellyfinKit/JellyfinBackend.swift | 39 ++++++++- .../JellyfinKit/JellyfinItem+Media.swift | 10 ++- .../Sources/MediaKit/HeroSelection.swift | 19 +++-- .../Sources/MediaKit/MediaBackend.swift | 7 ++ .../Endpoints/PlexServer+Playback.swift | 16 ++++ .../Sources/PlexKit/PlexBackend.swift | 4 + .../MediaKitTests/HeroSelectionTests.swift | 28 +++---- Tools/JellyfinStub/server.py | 22 ++++- 12 files changed, 250 insertions(+), 33 deletions(-) create mode 100644 Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Segments.swift diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 0780ed6..4a0976d 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -261,6 +261,23 @@ } } }, + "action.removeFromContinue": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus „Weiterschauen“ entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove from Continue Watching" + } + } + } + }, "action.retry": { "extractionState": "stale", "localizations": { @@ -2096,13 +2113,13 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ohne Auswahl kommt alles infrage. Angefangenes bleibt immer dabei: aus welcher Mediathek eine begonnene Folge stammt, sagt der Server nicht." + "value": "Ohne Auswahl kommt alles infrage. Ist etwas gewählt, speist sich der Aufmacher nur noch aus diesen Mediatheken — Weiterschauen und Favoriten bleiben dann draussen, weil der Server nicht sagt, aus welcher Mediathek ein angefangener Titel stammt." } }, "en": { "stringUnit": { "state": "translated", - "value": "With nothing selected, everything is eligible. What you have started always stays: the server does not say which library a part-watched episode came from." + "value": "With nothing selected, everything is eligible. Once you select libraries, the hero is drawn only from them — Continue Watching and Favourites stay out, because the server does not say which library a part-watched title came from." } } } diff --git a/Flixy/Views/Components/ItemActionEntries.swift b/Flixy/Views/Components/ItemActionEntries.swift index be676e7..caecad7 100644 --- a/Flixy/Views/Components/ItemActionEntries.swift +++ b/Flixy/Views/Components/ItemActionEntries.swift @@ -41,6 +41,17 @@ struct ItemActionEntries: View { ) } + // **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle + // stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den + // Fehler bei sich. + if item.watchProgress != nil { + Button { + run { try await $0.removeFromContinueWatching(id: item.id) } + } label: { + Label("action.removeFromContinue", systemImage: "minus.circle") + } + } + if capabilities.contains(.favourites) { Button { run { try await $0.setFavourite(!item.isFavourite, id: item.id) } diff --git a/Flixy/Views/Settings/HomeLayoutView.swift b/Flixy/Views/Settings/HomeLayoutView.swift index 74508ee..8dd01cf 100644 --- a/Flixy/Views/Settings/HomeLayoutView.swift +++ b/Flixy/Views/Settings/HomeLayoutView.swift @@ -16,11 +16,6 @@ struct HomeLayoutView: View { .background(Color.flixyCanvas) .navigationTitle(Text("homeLayout.title")) .flixyInlineTitle() - // Auf dem Mac gibt es den Knopf nicht — dort zieht man Zeilen ohne - // vorherigen Moduswechsel. - #if os(iOS) - .toolbar { EditButton() } - #endif .task { await load() } } @@ -136,7 +131,24 @@ struct HomeLayoutView: View { layout.setOrder(rows.map(\.id)) } } header: { - Text("home.rowsHeader") + // **Beim Abschnitt, auf den er wirkt.** In der Werkzeugleiste + // stand er über allem — über dem Aufmacher, über den + // Mediatheken — und tat dort nichts. Ein Knopf, der nur für + // einen Teil des Bildschirms gilt, gehört an diesen Teil. + // + // Auf dem Mac gibt es ihn nicht: dort zieht man Zeilen ohne + // vorherigen Moduswechsel. + HStack { + Text("home.rowsHeader") + #if os(iOS) + if !rows.isEmpty { + Spacer() + EditButton() + .font(.flixyCaption) + .textCase(nil) + } + #endif + } } footer: { Text("homeLayout.explanation") } diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Segments.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Segments.swift new file mode 100644 index 0000000..1607c79 --- /dev/null +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Segments.swift @@ -0,0 +1,82 @@ +import Foundation +import MediaKit + +/// Ein Abschnitt, den man überspringen können soll. +/// +/// Jellyfin nennt sie seit 10.10 „Media Segments" und liefert sie über einen +/// eigenen Weg — **nicht** als Feld am Titel. Deshalb steht das hier und nicht +/// in `JellyfinItem`. +/// +/// Gefüllt werden sie von einem Anbieter im Server, etwa dem Intro-Skipper. +/// Hat der Server keinen, kommt eine leere Liste — oder gar nichts, wenn er +/// älter ist als 10.10. Beides ist kein Fehler, sondern heisst schlicht: es +/// gibt hier nichts zu überspringen. +struct JellyfinSegment: Decodable, Sendable { + let type: String? + /// In Hundertnanosekunden, wie alles Zeitliche bei Jellyfin. + let startTicks: Int64? + let endTicks: Int64? + + enum CodingKeys: String, CodingKey { + case type = "Type" + case startTicks = "StartTicks" + case endTicks = "EndTicks" + } + + /// `nil`, wenn der Abschnitt keiner ist, mit dem die App etwas anfangen + /// kann — etwa eine Vorschau oder ein Rückblick. Lieber nichts anbieten + /// als einen Knopf, der an einer unerwarteten Stelle auftaucht. + var asMediaMarker: MediaMarker? { + guard let startTicks, let endTicks, endTicks > startTicks else { return nil } + let kind: MediaMarker.Kind + switch type?.lowercased() { + case "intro": kind = .intro + case "outro": kind = .credits + case "commercial": kind = .commercial + default: return nil + } + return MediaMarker( + kind: kind, + startMilliseconds: Int(startTicks / 10_000), + endMilliseconds: Int(endTicks / 10_000) + ) + } +} + +/// Die Antwort ist eine Seite wie jede andere bei Jellyfin. +private struct JellyfinSegmentPage: Decodable { + let items: [JellyfinSegment] + + enum CodingKeys: String, CodingKey { case items = "Items" } +} + +extension JellyfinBackend { + /// Holt Intro und Abspann zu einem Titel. + /// + /// **Wirft nicht.** Ein Server ohne Anbieter antwortet mit einer leeren + /// Liste, einer vor 10.10 mit einem 404 — und ein Film ohne Intro ist + /// kein Fehler, sondern der Normalfall. Daran die Detailseite scheitern + /// zu lassen, wäre die schlechtere Hälfte des Tauschs. + /// + /// **`includeSegmentTypes` zweimal, nicht einmal mit Komma.** Als + /// `Intro,Outro` beantwortet Jellyfin dieselbe Frage mit null Treffern; + /// als zwei Angaben kommen sie. Das steht nirgends in der Dokumentation + /// und kostet sonst eine Stunde Suche am falschen Ende. + func segments(forItem id: String) async -> [MediaMarker] { + do { + let page = try await client.send( + JellyfinRequest( + path: "/MediaSegments/\(id)", + query: [ + URLQueryItem(name: "includeSegmentTypes", value: "Intro"), + URLQueryItem(name: "includeSegmentTypes", value: "Outro"), + ] + ), + as: JellyfinSegmentPage.self + ) + return page.items.compactMap(\.asMediaMarker).sorted { $0.startMilliseconds < $1.startMilliseconds } + } catch { + return [] + } + } +} diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift index 139c98a..5cdb090 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift @@ -243,6 +243,11 @@ public func isInLocalNetwork() async -> Bool { } public func item(id: String) async throws(MediaBackendError) -> MediaItem { + // **Nebenher, nicht nacheinander.** Die Marken stehen bei Jellyfin + // hinter einem eigenen Weg; sie hinterher zu holen hiesse, die + // Detailseite um eine zweite Laufzeit warten zu lassen. + async let marken = segments(forItem: id) + let item = try await client.send( JellyfinRequest( path: "/Users/\(account.userID)/Items/\(id)", @@ -250,7 +255,7 @@ public func isInLocalNetwork() async -> Bool { ), as: JellyfinItem.self ) - return item.asMediaItem(imageBase: images) + return item.asMediaItem(imageBase: images, markers: await marken) } /// Alle Folgen einer Serie in **einer** Anfrage. @@ -442,6 +447,38 @@ public func isInLocalNetwork() async -> Bool { )) } + /// Setzt die gemerkte Stelle auf null — damit fällt der Titel aus + /// „Weiterschauen" heraus. + /// + /// **Jellyfin kennt dafür keinen eigenen Weg.** Ein Vorschlag, einen + /// Titel mit einem Merker auszublenden, liegt seit Jahren als Entwurf + /// vor; was es gibt, ist das Benutzerdatenblatt. „Weiterschauen" führt, + /// was eine Stelle grösser null hat — also nimmt man sie weg. + /// + /// **Erst lesen, dann schreiben.** Das Blatt trägt auch „gesehen", + /// „Favorit" und die Bewertung. Nur ein Feld zu schicken hiesse, dem + /// Server die übrigen ohne Wert vorzulegen — und wer seinen Favoriten + /// verliert, weil er einen Film aus einer Liste genommen hat, sucht den + /// Grund nie an dieser Stelle. Deshalb kommt das Blatt zurück, bekommt + /// eine Null und geht so, wie es war, wieder hin. + public func removeFromContinueWatching(id: String) async throws(MediaBackendError) { + let pfad = "/Users/\(account.userID)/Items/\(id)/UserData" + let roh = try await client.send(JellyfinRequest(path: pfad)) + + guard var blatt = (try? JSONSerialization.jsonObject(with: roh)) as? [String: Any] else { + throw .decoding(description: "Benutzerdaten zu \(id) waren kein Objekt") + } + blatt["PlaybackPositionTicks"] = 0 + // Ohne das bliebe der Anteil stehen, und manche Ansicht rechnet den + // Balken daraus statt aus der Stelle. + blatt["PlayedPercentage"] = NSNull() + + guard let körper = try? JSONSerialization.data(withJSONObject: blatt) else { + throw .decoding(description: "Benutzerdaten zu \(id) liessen sich nicht schreiben") + } + try await client.send(JellyfinRequest(method: .post, path: pfad, body: körper)) + } + public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) { try await client.send(JellyfinRequest( method: isFavourite ? .post : .delete, diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift index ec87953..753b75e 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift @@ -7,7 +7,13 @@ import MediaKit /// es sie zweimal gibt, ist der Preis dafür, dass es die Ansichten nur einmal /// gibt. extension JellyfinItem { - public func asMediaItem(imageBase: JellyfinImagePaths) -> MediaItem { + /// `markers` kommen von aussen, weil Jellyfin sie nicht am Titel + /// liefert: sie stehen hinter `/MediaSegments/{id}` und damit hinter + /// einer zweiten Anfrage. Siehe `JellyfinBackend.segments(forItem:)`. + public func asMediaItem( + imageBase: JellyfinImagePaths, + markers: [MediaMarker] = [] + ) -> MediaItem { MediaItem( id: id, path: nil, @@ -68,7 +74,7 @@ extension JellyfinItem { thumb: person.id.map { imageBase.primary(itemID: $0, tag: person.primaryImageTag) } ?? nil ) }, - markers: [], + markers: markers, artwork: logoArtwork(imageBase: imageBase) ) } diff --git a/Packages/FlixyKit/Sources/MediaKit/HeroSelection.swift b/Packages/FlixyKit/Sources/MediaKit/HeroSelection.swift index 195eda9..0a96e9c 100644 --- a/Packages/FlixyKit/Sources/MediaKit/HeroSelection.swift +++ b/Packages/FlixyKit/Sources/MediaKit/HeroSelection.swift @@ -21,24 +21,29 @@ public enum HeroSelection { /// Wie lange dieselbe Auswahl gilt. public static let windowHours: Double = 6 - /// Welche Reihen den Aufmacher überhaupt speisen dürfen. + /// Welche Reihen den Aufmacher speisen dürfen. /// /// **Leer heisst alle.** Eine Einstellung, bei der nichts angekreuzt ist, /// soll nicht in einen leeren Aufmacher führen — „ich habe nichts /// gewählt" heisst „mir ist es gleich", nicht „nimm nichts". /// - /// **Reihen ohne Mediathek bleiben immer drin.** Weiterschauen, Als - /// Nächstes und Favoriten gehen quer durch alle; welcher Mediathek eine - /// angefangene Folge entstammt, sagt der Server nicht. Sie deshalb - /// wegzuwerfen hiesse, dem Nutzer das Angefangene zu nehmen, weil er eine - /// Mediathek abgewählt hat, die damit nichts zu tun haben muss. + /// **Ist etwas gewählt, kommt nur daher etwas.** Reihen, die sich keiner + /// Mediathek zuordnen lassen — Weiterschauen, Als Nächstes, Favoriten — + /// bleiben dann draussen. + /// + /// Das war zuerst andersherum, und es war die falsche Entscheidung. Die + /// Überlegung „Angefangenes soll man nicht verlieren" klingt freundlich, + /// führt aber dazu, dass im Aufmacher weiter eine Serie steht, obwohl + /// gerade „nur Filme" eingestellt wurde — und niemand bringt das mit der + /// Einstellung in Verbindung. Eine Einstellung muss das tun, was sie + /// sagt; wer Weiterschauen oben haben will, wählt keine Mediathek aus. public static func rows( _ rows: [MediaRowGroup], limitedTo libraries: Set ) -> [MediaRowGroup] { guard !libraries.isEmpty else { return rows } return rows.filter { row in - guard let libraryID = row.libraryID else { return true } + guard let libraryID = row.libraryID else { return false } return libraries.contains(libraryID) } } diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift index 9d7ba7b..fe8781f 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift @@ -115,6 +115,13 @@ public protocol MediaBackend: Sendable { func markWatched(id: String) async throws(MediaBackendError) func markUnwatched(id: String) async throws(MediaBackendError) + /// Nimmt einen Titel aus „Weiterschauen", ohne ihn als gesehen zu melden. + /// + /// **Der Fall, für den es sonst nichts gibt.** Nach einer halben Stunde + /// keine Lust mehr — der Film ist weder gesehen noch ungesehen, er soll + /// nur nicht ewig weiter oben stehen. „Als gesehen markieren" wäre dafür + /// eine Lüge, „als ungesehen" tut nichts: der Fortschritt bleibt. + func removeFromContinueWatching(id: String) async throws(MediaBackendError) func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) func rate(id: String, rating: Int) async throws(MediaBackendError) func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift index 82bd601..878018a 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift @@ -234,6 +234,22 @@ extension PlexServer { _ = try await client.send(scrobbleRequest(path: "/:/unscrobble", ratingKey: ratingKey)) } + /// Nimmt einen Titel aus „Weiterschauen". + /// + /// **Plex hat dafür einen eigenen Weg** — anders als Jellyfin, wo man + /// die gemerkte Stelle wegnehmen muss. `/:/unscrobble` wäre hier falsch: + /// das setzt zugleich auf ungesehen, und beides zu vermischen war + /// ausdrücklich nicht gewollt. + public func removeFromContinueWatching(ratingKey: String) async throws(PlexError) { + _ = try await client.send( + PlexRequest( + method: .put, + path: "/actions/removeFromContinueWatching", + query: [URLQueryItem(name: "ratingKey", value: ratingKey)] + ) + ) + } + /// Setzt eine Bewertung. `rating` von 0 bis 10; 0 entfernt sie. public func rate(ratingKey: String, rating: Int) async throws(PlexError) { _ = try await client.send( diff --git a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift index fcd97be..650b306 100644 --- a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift +++ b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift @@ -214,6 +214,10 @@ public struct PlexBackend: MediaBackend { try await mapping { try await server.markUnwatched(ratingKey: id) } } + public func removeFromContinueWatching(id: String) async throws(MediaBackendError) { + try await mapping { try await server.removeFromContinueWatching(ratingKey: id) } + } + public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) { // Plex kennt keine Favoriten. Stillschweigend nichts zu tun wäre // schlimmer als zu sagen, dass es das hier nicht gibt. diff --git a/Packages/FlixyKit/Tests/MediaKitTests/HeroSelectionTests.swift b/Packages/FlixyKit/Tests/MediaKitTests/HeroSelectionTests.swift index d63da88..0f7c6ac 100644 --- a/Packages/FlixyKit/Tests/MediaKitTests/HeroSelectionTests.swift +++ b/Packages/FlixyKit/Tests/MediaKitTests/HeroSelectionTests.swift @@ -142,28 +142,28 @@ struct HeroLibraryFilterTests { #expect(!übrig.contains("latest.serien")) } - /// Weiterschauen und Favoriten tragen keine Mediathek — und dürfen durch - /// eine Auswahl nicht verschwinden. Sonst nähme ein abgewähltes - /// „Musikvideos" dem Nutzer das Angefangene. - @Test("Reihen ohne Mediathek bleiben immer") - func rowsWithoutLibraryAlwaysStay() { + /// **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.contains("resume")) - #expect(übrig.contains("favourites")) + #expect(übrig == ["latest.filme"]) } /// Eine Mediathek, die es nicht mehr gibt — umbenannt, gelöscht, Server - /// gewechselt. Die gespeicherte Kennung bleibt dann stehen und darf nicht - /// dazu führen, dass gar nichts mehr durchkommt. - @Test("Eine unbekannte Kennung lässt die zuordnungslosen Reihen übrig") - func unknownSelectionLeavesTheUnattributable() { - let übrig = HeroSelection.rows(alle, limitedTo: ["gibtsnicht"]).map(\.id) - #expect(übrig == ["resume", "favourites"]) + /// 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 == alle.map(\.id)) + #expect(übrig == ["latest.filme", "latest.serien"]) } } diff --git a/Tools/JellyfinStub/server.py b/Tools/JellyfinStub/server.py index ee8d9dc..578e135 100644 --- a/Tools/JellyfinStub/server.py +++ b/Tools/JellyfinStub/server.py @@ -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.",