Eine Folge ist weder ein Film noch eine Serie

Die App meldete auf der Startseite „Dieser Titel ist auf dem Server nicht
mehr vorhanden" — über eine Folge, die sie selbst gerade anzeigte.

Gemessen im Quelltext: `item(id:)` probiert `/movies/{id}`, dann
`/series/{id}`, und wirft sonst `.notFound`. In „Weiterschauen" steht
eine Folge mit ihrer eigenen Kennung, und einen Endpunkt für eine
einzelne Folge gab es gar nicht. Jetzt gibt es `GET /api/v1/episodes/{id}`
— mit `SeriesId`, denn ohne die Serie fehlen der Folge Titel und Bild.
Im Simulator nachgestellt und nachgeprüft: die Seite steht.

**Zuletzt hinzugefügt.** Die App zeigte „Filme" und „Serien" — den Anfang
der Mediathek in der Reihenfolge der Datenbank, also immer denselben.
Jetzt je Mediathek eine Reihe „Neu in …", sortiert nach Zugang. Die
Kennungen beginnen nun mit `latest`, und daran hängen zwei Einstellungen,
die an einem Flaro-Server deshalb nichts taten: „Gesehenes in Neuzugängen
ausblenden" und „Aufmacher nur aus Neuzugängen".

**Geplante Titel.** `MediaItem` kennt jetzt `availableFrom`. Aufmacher und
Detailseite zeigen statt des Knopfes „Bald ist es soweit" mit dem Termin.
Die Kachel spielte trotzdem los — im Simulator kam der Spieler bis „Wird
vorbereitet …" —, also zählt ein geplanter Titel jetzt wie eine Sammlung:
nichts zu starten, der Tipp führt auf die Seite.

**Und das Bild im Aufmacher.** Bei einer angefangenen Folge stand dort ihr
Standbild statt des Bildes der Serie; wer der Serie eines ausgesucht
hatte, fand ein anderes vor. Die Kachel behält das Standbild — sie zeigt,
wo man steht —, der Aufmacher bekommt `SeriesBackdropImage`.

**Die Punkte im Karussell waren 26 × 3 Pixel.** Von drei Klicks auf
denselben Punkt traf nur der auf der Mittellinie; fünf Pixel daneben
geschah nichts. Der Knopf ist jetzt 27 Pixel hoch, sichtbar bleiben 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 16:06:58 +02:00
co-authored by Claude Opus 5
parent f10fb4c1bc
commit 10a5070e58
17 changed files with 389 additions and 71 deletions
@@ -54,13 +54,12 @@ public actor FlaroBackend: MediaBackend {
}
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
// **Vier Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der eine
// fertige Startseite liefert, und das ist Absicht: welche Reihen eine
// App zeigt, entscheidet die App. Der Server liefert die Zutaten.
// **Mehrere Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der
// eine fertige Startseite liefert, und das ist Absicht: welche Reihen
// eine App zeigt, entscheidet die App. Der Server liefert die Zutaten.
async let weiter = reihe(pfad: "/api/v1/home/continue")
async let naechste = reihe(pfad: "/api/v1/home/next")
async let filme = seite(pfad: "/api/v1/movies", take: 24, as: Wire.Movie.self)
async let serien = seite(pfad: "/api/v1/series", take: 24, as: Wire.Series.self)
let mediatheken = (try? await libraries()) ?? []
var reihen: [MediaRowGroup] = []
@@ -76,18 +75,46 @@ public actor FlaroBackend: MediaBackend {
title: String(localized: "flaro.row.nextUp", bundle: .module),
items: eintraege))
}
if let gefunden = try? await filme, !gefunden.items.isEmpty {
// **Je Mediathek eine Reihe mit dem zuletzt Hinzugefügten.**
//
// Vorher standen hier zwei Reihen, „Filme" und „Serien", mit dem
// Anfang der Mediathek in der Reihenfolge der Datenbank — also immer
// demselben. Drei Dinge waren damit falsch: es war kein Neuzugang,
// es warf getrennte Mediatheken wieder zusammen, und die Kennungen
// begannen nicht mit `latest`. An dieser Vorsilbe hängen zwei
// Einstellungen — „Gesehenes in Neuzugängen ausblenden" und
// „Aufmacher nur aus Neuzugängen" —, und beide taten an einem
// Flaro-Server deshalb nichts.
for mediathek in mediatheken {
let pfad = mediathek.kind == .show ? "/api/v1/series" : "/api/v1/movies"
let abfrage = [
URLQueryItem(name: "library", value: mediathek.id),
URLQueryItem(name: "sort", value: "added"),
URLQueryItem(name: "take", value: "24"),
]
let titel: [MediaItem]
if mediathek.kind == .show {
guard let seite = try? await client.send(
FlaroRequest(path: pfad, query: abfrage),
as: Wire.Page<Wire.Series>.self) else { continue }
titel = seite.items.map { $0.neutral() }
} else {
guard let seite = try? await client.send(
FlaroRequest(path: pfad, query: abfrage),
as: Wire.Page<Wire.Movie>.self) else { continue }
titel = seite.items.map { $0.neutral() }
}
guard !titel.isEmpty else { continue }
reihen.append(MediaRowGroup(
id: "flaro.movies",
title: String(localized: "flaro.row.movies", bundle: .module),
items: gefunden.items.map { $0.neutral() }))
}
if let gefunden = try? await serien, !gefunden.items.isEmpty {
reihen.append(MediaRowGroup(
id: "flaro.series",
title: String(localized: "flaro.row.series", bundle: .module),
items: gefunden.items.map { $0.neutral() }))
id: "latest.\(mediathek.id)",
title: String(
localized: "flaro.row.latestIn \(mediathek.title)",
bundle: .module),
libraryID: mediathek.id,
items: titel))
}
return reihen
}
@@ -135,6 +162,21 @@ public actor FlaroBackend: MediaBackend {
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
return serie.neutral()
}
// **Und zuletzt eine Folge.**
//
// Sie fehlte, und das war der Grund für „Dieser Titel ist auf dem
// Server nicht mehr vorhanden" direkt von der Startseite: in
// „Weiterschauen" steht eine Folge mit ihrer eigenen Kennung, und die
// ist weder ein Film noch eine Serie.
if let folge = try? await client.send(
FlaroRequest(path: "/api/v1/episodes/\(id)"), as: Wire.Episode.self) {
var serie: Wire.Series?
if let kennung = folge.seriesId {
serie = try? await client.send(
FlaroRequest(path: "/api/v1/series/\(kennung)"), as: Wire.Series.self)
}
return folge.neutral(series: serie)
}
throw .notFound
}
@@ -89,6 +89,8 @@ enum Wire {
let backdropImage: String?
let files: [File]
let state: ItemState?
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
let publishAt: Date?
}
struct Series: Decodable, Sendable {
@@ -101,6 +103,7 @@ enum Wire {
let episodeCount: Int
let seasons: [Int]
let unwatchedCount: Int
let publishAt: Date?
}
struct Episode: Decodable, Sendable {
@@ -112,6 +115,10 @@ enum Wire {
let stillImage: String?
let files: [File]
let state: ItemState?
let publishAt: Date?
/// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich
/// nicht darstellbar: Titel und Bild der Serie fehlten.
let seriesId: String?
}
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
@@ -126,6 +133,12 @@ enum Wire {
let posterImage: String?
let backdropImage: String?
let state: ItemState
/// Zu welcher Serie die Folge gehört.
let seriesId: String?
/// Das breite Bild der Serie — für den Aufmacher, nicht für die
/// Kachel: die will das Standbild der Folge.
let seriesBackdropImage: String?
let publishAt: Date?
}
struct CodeResponse: Decodable, Sendable {
@@ -20,6 +20,7 @@ extension Wire.Movie {
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false,
availableFrom: publishAt,
versions: [Wire.fassung(id: id, files: files)]
)
}
@@ -39,6 +40,7 @@ extension Wire.Series {
// offen ist — die App zeigt daraus die Plakette, rechnet aber
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
// zu derselben Aussage zu haben.
availableFrom: publishAt,
episodeCount: episodeCount,
playedEpisodeCount: episodeCount - unwatchedCount,
childCount: seasons.count
@@ -60,11 +62,14 @@ extension Wire.Episode {
index: episode,
parentIndex: season,
seriesTitle: series?.title,
seriesID: series?.id,
// Die Serie aus dem Vertrag, wenn sie nicht mitgegeben wurde:
// ohne sie führte der Weg zurück zur Serie ins Leere.
seriesID: series?.id ?? seriesId,
playCount: state?.playCount,
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false,
availableFrom: publishAt,
versions: [Wire.fassung(id: id, files: files)]
)
}
@@ -85,15 +90,21 @@ extension Wire.Started {
year: year,
duration: state.runtimeSeconds.map { Int($0 * 1000) },
thumb: folge ? backdropImage : posterImage,
art: backdropImage,
// **Für die Fläche über den halben Bildschirm das Bild der
// Serie.** `backdropImage` ist bei einer Folge ihr Standbild —
// richtig für die Kachel, falsch für den Aufmacher: wer der
// Serie ein Bild ausgesucht hatte, fand dort ein anderes vor.
art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage,
seriesThumb: folge ? posterImage : nil,
index: episode,
parentIndex: season,
seriesTitle: folge ? title : nil,
seriesID: folge ? seriesId : nil,
playCount: state.playCount,
resumeMilliseconds: Int(state.positionSeconds * 1000),
lastPlayedAt: state.lastPlayedAt,
isFavourite: state.favourite
isFavourite: state.favourite,
availableFrom: publishAt
)
}
}
@@ -35,6 +35,23 @@
}
}
},
"flaro.row.latestIn %@": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Neu in %@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "New in %@"
}
}
}
},
"flaro.row.movies": {
"extractionState": "manual",
"localizations": {
@@ -71,6 +71,14 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
/// eine Fähigkeit, die es nur auf einem gibt.
public let isFavourite: Bool
/// Ab wann es den Titel gibt — `nil` heisst: jetzt.
///
/// **Ein geplanter Titel steht in der Mediathek, läuft aber nicht.** Das
/// ist der Sinn des Planens: man soll sehen, dass er kommt. Ohne dieses
/// Feld bot die App „Abspielen" an und bekam vom Server eine Absage —
/// ein Knopf, der ein Versprechen gibt und es zurücknimmt.
public let availableFrom: Date?
// Zählwerte bei Serien und Staffeln.
public let episodeCount: Int?
public let playedEpisodeCount: Int?
@@ -117,6 +125,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
lastPlayedAt: Date? = nil,
addedAt: Date? = nil,
isFavourite: Bool = false,
availableFrom: Date? = nil,
episodeCount: Int? = nil,
playedEpisodeCount: Int? = nil,
childCount: Int? = nil,
@@ -155,6 +164,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
self.lastPlayedAt = lastPlayedAt
self.addedAt = addedAt
self.isFavourite = isFavourite
self.availableFrom = availableFrom
self.episodeCount = episodeCount
self.playedEpisodeCount = playedEpisodeCount
self.childCount = childCount
@@ -225,6 +235,15 @@ extension MediaItem {
public var isWatched: Bool { (playCount ?? 0) > 0 }
/// Ob der Titel erst noch kommt.
///
/// Gefragt wird an einer Stelle, beantwortet an einer: sonst steht an
/// einem Bildschirm „Abspielen" und am nächsten der Termin.
public var isUpcoming: Bool {
guard let availableFrom else { return false }
return availableFrom > Date()
}
/// Ab wo fortgesetzt wird.
public var resumeTime: Duration {
.milliseconds(resumeMilliseconds ?? 0)