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
+34
View File
@@ -7837,6 +7837,40 @@
} }
} }
} }
},
"upcoming.from %@": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ab %@ Uhr"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "From %@"
}
}
}
},
"upcoming.title": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Bald ist es soweit"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Coming soon"
}
}
}
} }
}, },
"version": "1.1" "version": "1.1"
+5 -1
View File
@@ -57,7 +57,11 @@ struct QuickPlayTile<Card: View>: View {
/// Ob es überhaupt etwas zu starten gibt. /// Ob es überhaupt etwas zu starten gibt.
/// ///
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung. /// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
private var isPlayable: Bool { item.kind != .collection } /// Ein geplanter Titel ebenso: der Server lehnt ihn bis zum Termin ab,
/// und ein Tipp auf die Kachel startete trotzdem — gemessen am Simulator,
/// der Spieler kam bis „Wird vorbereitet …". Dort führt der Tipp jetzt
/// auf die Seite, wo der Termin steht.
private var isPlayable: Bool { item.kind != .collection && !item.isUpcoming }
private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) } private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) }
@@ -0,0 +1,44 @@
import SwiftUI
import FlaroDesign
import MediaKit
/// Steht dort, wo sonst „Abspielen" stünde.
///
/// **Ein Knopf, der nichts tut, ist schlimmer als keiner.** Ein geplanter
/// Titel steht in der Mediathek, damit man weiss, dass er kommt — das ist der
/// Sinn des Planens. Der Server lehnt ihn bis zum Termin ab; stünde der Knopf
/// trotzdem da, wäre die Reihenfolge: draufdrücken, freuen, Absage lesen.
///
/// Ein Baustein für Aufmacher und Detailseite, weil sonst an der einen Stelle
/// der Termin stünde und an der anderen weiter der Knopf.
struct UpcomingNotice: View {
@Environment(\.flaroAccent) private var accent
let date: Date
var body: some View {
HStack(spacing: FlaroSpacing.regular) {
Image(systemName: "clock")
.font(.flaroCardTitle)
.foregroundStyle(accent)
VStack(alignment: .leading, spacing: 2) {
Text("upcoming.title")
.font(.flaroCardTitle)
Text("upcoming.from \(Self.formatted(date))")
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextSecondary)
}
}
.padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, FlaroSpacing.snug)
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
.accessibilityElement(children: .combine)
}
/// „Dienstag, 6. Oktober 2026 um 18:00" — in der Sprache des Geräts.
static func formatted(_ date: Date) -> String {
date.formatted(
.dateTime.weekday(.wide).day().month(.wide).year().hour().minute())
}
}
@@ -97,6 +97,11 @@ struct PlayableDetailView: View {
// Karussell auf dem Telefon quer ist. // Karussell auf dem Telefon quer ist.
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact) aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
) { ) {
// Geplant heisst sichtbar, nicht abspielbar. Weder Abspielen
// noch Herunterladen — der Server lehnt beides bis zum Termin ab.
if item.isUpcoming, let ab = item.availableFrom {
UpcomingNotice(date: ab)
} else {
Button { Button {
playback.play(item, mediaIndex: selectedVersion) playback.play(item, mediaIndex: selectedVersion)
} label: { } label: {
@@ -128,6 +133,7 @@ struct PlayableDetailView: View {
DownloadButton(item: item, mediaIndex: selectedVersion) DownloadButton(item: item, mediaIndex: selectedVersion)
} }
} }
}
/// Die Zeile unter dem Folgentitel — mit Wegen nach oben. /// Die Zeile unter dem Folgentitel — mit Wegen nach oben.
/// ///
+12 -5
View File
@@ -47,12 +47,18 @@ struct HeroSection: View {
// Bildschirm leer. // Bildschirm leer.
fillsAvailableSpace: sizeClass != .compact fillsAvailableSpace: sizeClass != .compact
) { ) {
// Geplant heisst sichtbar, nicht abspielbar — statt des Knopfes
// der Termin.
if item.isUpcoming, let ab = item.availableFrom {
UpcomingNotice(date: ab)
} else {
Button { Button {
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher // **Über QuickPlay, nicht unmittelbar.** Steht im
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert // Aufmacher eine Serie — und Jellyfins „Zuletzt
// neue Folgen als ihre Serie —, schickte der direkte Weg // hinzugefügt" liefert neue Folgen als ihre Serie —,
// deren eigene Kennung an den Player, und der scheiterte mit // schickte der direkte Weg deren eigene Kennung an den
// „keine abspielbare Datei". Eine Serie hat keine Fassungen. // Player, und der scheiterte mit „keine abspielbare
// Datei". Eine Serie hat keine Fassungen.
quickPlay.start(item, backend: session.backend, coordinator: playback) quickPlay.start(item, backend: session.backend, coordinator: playback)
} label: { } label: {
Label(playLabel, systemImage: "play.fill") Label(playLabel, systemImage: "play.fill")
@@ -62,6 +68,7 @@ struct HeroSection: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(accent) .tint(accent)
}
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) { NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
Label("hero.details", systemImage: "info.circle") Label("hero.details", systemImage: "info.circle")
@@ -54,13 +54,12 @@ public actor FlaroBackend: MediaBackend {
} }
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] { public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
// **Vier Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der eine // **Mehrere Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der
// fertige Startseite liefert, und das ist Absicht: welche Reihen eine // eine fertige Startseite liefert, und das ist Absicht: welche Reihen
// App zeigt, entscheidet die App. Der Server liefert die Zutaten. // eine App zeigt, entscheidet die App. Der Server liefert die Zutaten.
async let weiter = reihe(pfad: "/api/v1/home/continue") async let weiter = reihe(pfad: "/api/v1/home/continue")
async let naechste = reihe(pfad: "/api/v1/home/next") async let naechste = reihe(pfad: "/api/v1/home/next")
async let filme = seite(pfad: "/api/v1/movies", take: 24, as: Wire.Movie.self) let mediatheken = (try? await libraries()) ?? []
async let serien = seite(pfad: "/api/v1/series", take: 24, as: Wire.Series.self)
var reihen: [MediaRowGroup] = [] var reihen: [MediaRowGroup] = []
@@ -76,18 +75,46 @@ public actor FlaroBackend: MediaBackend {
title: String(localized: "flaro.row.nextUp", bundle: .module), title: String(localized: "flaro.row.nextUp", bundle: .module),
items: eintraege)) items: eintraege))
} }
if let gefunden = try? await filme, !gefunden.items.isEmpty {
reihen.append(MediaRowGroup( // **Je Mediathek eine Reihe mit dem zuletzt Hinzugefügten.**
id: "flaro.movies", //
title: String(localized: "flaro.row.movies", bundle: .module), // Vorher standen hier zwei Reihen, „Filme" und „Serien", mit dem
items: gefunden.items.map { $0.neutral() })) // 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() }
} }
if let gefunden = try? await serien, !gefunden.items.isEmpty { guard !titel.isEmpty else { continue }
reihen.append(MediaRowGroup( reihen.append(MediaRowGroup(
id: "flaro.series", id: "latest.\(mediathek.id)",
title: String(localized: "flaro.row.series", bundle: .module), title: String(
items: gefunden.items.map { $0.neutral() })) localized: "flaro.row.latestIn \(mediathek.title)",
bundle: .module),
libraryID: mediathek.id,
items: titel))
} }
return reihen return reihen
} }
@@ -135,6 +162,21 @@ public actor FlaroBackend: MediaBackend {
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) { FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
return serie.neutral() 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 throw .notFound
} }
@@ -89,6 +89,8 @@ enum Wire {
let backdropImage: String? let backdropImage: String?
let files: [File] let files: [File]
let state: ItemState? let state: ItemState?
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
let publishAt: Date?
} }
struct Series: Decodable, Sendable { struct Series: Decodable, Sendable {
@@ -101,6 +103,7 @@ enum Wire {
let episodeCount: Int let episodeCount: Int
let seasons: [Int] let seasons: [Int]
let unwatchedCount: Int let unwatchedCount: Int
let publishAt: Date?
} }
struct Episode: Decodable, Sendable { struct Episode: Decodable, Sendable {
@@ -112,6 +115,10 @@ enum Wire {
let stillImage: String? let stillImage: String?
let files: [File] let files: [File]
let state: ItemState? 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". /// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
@@ -126,6 +133,12 @@ enum Wire {
let posterImage: String? let posterImage: String?
let backdropImage: String? let backdropImage: String?
let state: ItemState 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 { struct CodeResponse: Decodable, Sendable {
@@ -20,6 +20,7 @@ extension Wire.Movie {
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) }, resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt, lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false, isFavourite: state?.favourite ?? false,
availableFrom: publishAt,
versions: [Wire.fassung(id: id, files: files)] versions: [Wire.fassung(id: id, files: files)]
) )
} }
@@ -39,6 +40,7 @@ extension Wire.Series {
// offen ist — die App zeigt daraus die Plakette, rechnet aber // offen ist — die App zeigt daraus die Plakette, rechnet aber
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege // intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
// zu derselben Aussage zu haben. // zu derselben Aussage zu haben.
availableFrom: publishAt,
episodeCount: episodeCount, episodeCount: episodeCount,
playedEpisodeCount: episodeCount - unwatchedCount, playedEpisodeCount: episodeCount - unwatchedCount,
childCount: seasons.count childCount: seasons.count
@@ -60,11 +62,14 @@ extension Wire.Episode {
index: episode, index: episode,
parentIndex: season, parentIndex: season,
seriesTitle: series?.title, 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, playCount: state?.playCount,
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) }, resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt, lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false, isFavourite: state?.favourite ?? false,
availableFrom: publishAt,
versions: [Wire.fassung(id: id, files: files)] versions: [Wire.fassung(id: id, files: files)]
) )
} }
@@ -85,15 +90,21 @@ extension Wire.Started {
year: year, year: year,
duration: state.runtimeSeconds.map { Int($0 * 1000) }, duration: state.runtimeSeconds.map { Int($0 * 1000) },
thumb: folge ? backdropImage : posterImage, 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, seriesThumb: folge ? posterImage : nil,
index: episode, index: episode,
parentIndex: season, parentIndex: season,
seriesTitle: folge ? title : nil, seriesTitle: folge ? title : nil,
seriesID: folge ? seriesId : nil,
playCount: state.playCount, playCount: state.playCount,
resumeMilliseconds: Int(state.positionSeconds * 1000), resumeMilliseconds: Int(state.positionSeconds * 1000),
lastPlayedAt: state.lastPlayedAt, 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": { "flaro.row.movies": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
@@ -71,6 +71,14 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
/// eine Fähigkeit, die es nur auf einem gibt. /// eine Fähigkeit, die es nur auf einem gibt.
public let isFavourite: Bool 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. // Zählwerte bei Serien und Staffeln.
public let episodeCount: Int? public let episodeCount: Int?
public let playedEpisodeCount: Int? public let playedEpisodeCount: Int?
@@ -117,6 +125,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
lastPlayedAt: Date? = nil, lastPlayedAt: Date? = nil,
addedAt: Date? = nil, addedAt: Date? = nil,
isFavourite: Bool = false, isFavourite: Bool = false,
availableFrom: Date? = nil,
episodeCount: Int? = nil, episodeCount: Int? = nil,
playedEpisodeCount: Int? = nil, playedEpisodeCount: Int? = nil,
childCount: Int? = nil, childCount: Int? = nil,
@@ -155,6 +164,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
self.lastPlayedAt = lastPlayedAt self.lastPlayedAt = lastPlayedAt
self.addedAt = addedAt self.addedAt = addedAt
self.isFavourite = isFavourite self.isFavourite = isFavourite
self.availableFrom = availableFrom
self.episodeCount = episodeCount self.episodeCount = episodeCount
self.playedEpisodeCount = playedEpisodeCount self.playedEpisodeCount = playedEpisodeCount
self.childCount = childCount self.childCount = childCount
@@ -225,6 +235,15 @@ extension MediaItem {
public var isWatched: Bool { (playCount ?? 0) > 0 } 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. /// Ab wo fortgesetzt wird.
public var resumeTime: Duration { public var resumeTime: Duration {
.milliseconds(resumeMilliseconds ?? 0) .milliseconds(resumeMilliseconds ?? 0)
+23 -2
View File
@@ -430,7 +430,18 @@ public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage, Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
IReadOnlyList<MediaFileResponse> Files, IReadOnlyList<MediaFileResponse> Files,
ItemStateResponse? State, ItemStateResponse? State,
DateTimeOffset? PublishAt = null); DateTimeOffset? PublishAt = null,
/// <summary>Zu welcher Serie sie gehört.</summary>
///
/// <remarks>
/// <b>Damit eine Folge für sich geöffnet werden kann.</b> Die Apps
/// reichen eine Kennung durch, ohne zu wissen, was dahintersteckt. War es
/// eine Folge, fand sich weder ein Film noch eine Serie, und die
/// Startseite meldete „Dieser Titel ist auf dem Server nicht mehr
/// vorhanden" — über einen Titel, den sie selbst gerade angezeigt hatte.
/// </remarks>
Guid? SeriesId = null);
public sealed record Page<T>(IReadOnlyList<T> Items, int Total); public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
@@ -631,7 +642,17 @@ public sealed record ContinueEntry(
double? DurationSeconds = null, double? DurationSeconds = null,
/// <summary>Ab wann abspielbar. In der Zukunft heisst: noch nicht.</summary> /// <summary>Ab wann abspielbar. In der Zukunft heisst: noch nicht.</summary>
DateTimeOffset? PublishAt = null); DateTimeOffset? PublishAt = null,
/// <summary>Das breite Bild der Serie, zu der die Folge gehört.</summary>
///
/// <remarks>
/// <b>Neben dem Folgenbild, nicht statt seiner.</b> Die Kachel will das
/// Bild der Folge — es zeigt, wo man steht. Der Aufmacher füllt die halbe
/// Seite und will das Bild der Serie; dort stand bisher das Folgenbild,
/// und wer das Serienbild selbst ausgesucht hatte, fand ein anderes vor.
/// </remarks>
string? SeriesBackdropImage = null);
/// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary> /// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary>
public sealed record SessionResponse( public sealed record SessionResponse(
@@ -666,6 +666,47 @@ public static class LibraryContentEndpoints
PublishAt: folgen.Min(f => f.PublishAt))); PublishAt: folgen.Min(f => f.PublishAt)));
}).RequireAuthorization(); }).RequireAuthorization();
// **Eine Folge für sich.**
//
// Es gab sie nur im Rudel — als Liste einer Serie. Die Apps reichen
// aber eine blanke Kennung durch: sie haben sie aus „Weiterschauen"
// und wissen nicht, ob ein Film, eine Serie oder eine Folge
// dahintersteckt. Sie fragten der Reihe nach Film und Serie, fanden
// beides nicht und meldeten „Dieser Titel ist auf dem Server nicht
// mehr vorhanden" — über eine Folge, die sie selbst gerade zeigten.
app.MapGet("/api/v1/episodes/{id:guid}", async (
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
{
var folge = await db.Episodes.AsNoTracking()
.Where(f => f.Id == id)
.Select(f => new
{
f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber,
f.Title, f.Overview, f.StillImage, f.PublishAt,
})
.FirstOrDefaultAsync();
if (folge is null) return Results.NotFound();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId == id)
.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: [id]);
var wurzel = await MediathekspfadFuer(db, wer,
await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId)
.Select(x => x.LibraryId).FirstAsync());
return Results.Ok(new EpisodeResponse(
folge.Id, folge.SeasonNumber, folge.EpisodeNumber,
folge.Title, folge.Overview, folge.StillImage,
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(id),
folge.PublishAt,
SeriesId: folge.SeriesId));
}).RequireAuthorization();
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann // **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
// Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann // Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann
// der Scan sie fand, nicht wann sie läuft. // der Scan sie fand, nicht wann sie läuft.
@@ -709,7 +750,8 @@ public static class LibraryContentEndpoints
naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild, naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild,
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(), dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
stände.GetValueOrDefault(naechste.Id), stände.GetValueOrDefault(naechste.Id),
naechste.PublishAt)); naechste.PublishAt,
SeriesId: diese.SeriesId));
}).RequireAuthorization(); }).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}/episodes", async ( app.MapGet("/api/v1/series/{id:guid}/episodes", async (
@@ -747,7 +789,8 @@ public static class LibraryContentEndpoints
dateien.Where(d => d.EpisodeId == f.Id) dateien.Where(d => d.EpisodeId == f.Id)
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(), .Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(f.Id), stände.GetValueOrDefault(f.Id),
f.PublishAt)).ToList(); f.PublishAt,
SeriesId: id)).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count)); return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization(); }).RequireAuthorization();
@@ -296,7 +296,7 @@ public static class PlaybackEndpoints
var serien = await db.Series.AsNoTracking() var serien = await db.Series.AsNoTracking()
.Where(x => serienIds.Contains(x.Id)) .Where(x => serienIds.Contains(x.Id))
.Select(x => new { x.Id, x.Title, x.PosterImage }) .Select(x => new { x.Id, x.Title, x.PosterImage, x.BackdropImage })
.ToDictionaryAsync(x => x.Id); .ToDictionaryAsync(x => x.Id);
var liste = new List<ContinueEntry>(); var liste = new List<ContinueEntry>();
@@ -320,7 +320,8 @@ public static class PlaybackEndpoints
serie.PosterImage, naechste.StillImage ?? serie.PosterImage, serie.PosterImage, naechste.StillImage ?? serie.PosterImage,
zustand is null ? Leer() : Abbild(zustand), zustand is null ? Leer() : Abbild(zustand),
SeriesId: x.SeriesId, SeriesId: x.SeriesId,
PublishAt: naechste.PublishAt)); PublishAt: naechste.PublishAt,
SeriesBackdropImage: serie.BackdropImage));
} }
// Die Dateien erst jetzt, für die ausgewählten Folgen — nicht für // Die Dateien erst jetzt, für die ausgewählten Folgen — nicht für
@@ -445,6 +446,7 @@ public static class PlaybackEndpoints
{ {
f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage, f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage,
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt, SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt,
SerienBild = s.BackdropImage,
}) })
.ToDictionaryAsync(f => f.Id); .ToDictionaryAsync(f => f.Id);
@@ -467,7 +469,8 @@ public static class PlaybackEndpoints
SeriesId: f.SerienId, SeriesId: f.SerienId,
FileId: dateien.GetValueOrDefault(f.Id).Id, FileId: dateien.GetValueOrDefault(f.Id).Id,
DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden, DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden,
PublishAt: f.PublishAt)); PublishAt: f.PublishAt,
SeriesBackdropImage: f.SerienBild));
} }
return liste; return liste;
} }
@@ -179,6 +179,44 @@ public class PlaybackTests
}); });
} }
/// <summary>Eine Folge muss sich für sich abrufen lassen.</summary>
///
/// <remarks>
/// <b>Die Apps reichen eine blanke Kennung durch.</b> Sie haben sie aus
/// „Weiterschauen" und wissen nicht, was dahintersteckt — also fragen sie
/// der Reihe nach. Fand sich weder Film noch Serie, meldeten sie „Dieser
/// Titel ist auf dem Server nicht mehr vorhanden", über eine Folge, die
/// sie selbst gerade anzeigten.
/// </remarks>
[Test]
public async Task Eine_Folge_laesst_sich_einzeln_abrufen()
{
Datei("Dark (2017)/Season 01/Dark - S01E01 - Geheimnisse.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
var serie = serien!.Items.Single();
var ausDerListe = (await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/api/v1/series/{serie.Id}/episodes"))!.Items.Single();
var einzeln = await client.GetFromJsonAsync<EpisodeResponse>(
$"/api/v1/episodes/{ausDerListe.Id}");
Assert.Multiple(() =>
{
Assert.That(einzeln!.Id, Is.EqualTo(ausDerListe.Id));
Assert.That(einzeln.Season, Is.EqualTo(ausDerListe.Season));
Assert.That(einzeln.Episode, Is.EqualTo(ausDerListe.Episode));
Assert.That(einzeln.SeriesId, Is.EqualTo(serie.Id),
"ohne die Serie fehlen der Folge Titel und Bild");
Assert.That(einzeln.Files, Has.Count.EqualTo(ausDerListe.Files.Count));
});
var unbekannt = await client.GetAsync($"/api/v1/episodes/{Guid.NewGuid()}");
Assert.That(unbekannt.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
}
[Test] [Test]
public async Task Durchgeschautes_verlaesst_Weiterschauen() public async Task Durchgeschautes_verlaesst_Weiterschauen()
{ {
+2
View File
@@ -107,6 +107,8 @@ export type Angefangen = {
durationSeconds?: number | null; durationSeconds?: number | null;
/** Ab wann abspielbar. In der Zukunft heisst: noch nicht. */ /** Ab wann abspielbar. In der Zukunft heisst: noch nicht. */
publishAt?: string | null; publishAt?: string | null;
/** Das breite Bild der Serie — für den Aufmacher, nicht für die Kachel. */
seriesBackdropImage?: string | null;
}; };
export type Bibliothek = { export type Bibliothek = {
id: string; name: string; id: string; name: string;
+5 -1
View File
@@ -268,7 +268,11 @@ export function angefangenAlsHero(eintrag: Angefangen): Heroeintrag {
id: folge ? eintrag.seriesId ?? eintrag.id : eintrag.id, id: folge ? eintrag.seriesId ?? eintrag.id : eintrag.id,
titel: eintrag.title, titel: eintrag.title,
schriftzug: null, schriftzug: null,
hintergrund: eintrag.backdropImage ?? '', // **Das Bild der Serie, nicht das der Folge.** `backdropImage` ist bei
// einer Folge ihr eigenes Standbild — richtig für die Kachel, falsch für
// eine Fläche über den halben Bildschirm: wer der Serie ein Bild
// ausgesucht hat, fand hier ein anderes vor.
hintergrund: eintrag.seriesBackdropImage ?? eintrag.backdropImage ?? '',
jahr: eintrag.year, jahr: eintrag.year,
laufzeit: null, laufzeit: null,
freigabe: null, freigabe: null,
+15 -5
View File
@@ -1147,16 +1147,26 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; }
.hero-knoepfe .btn { height: 40px; padding: 0 var(--s-section); gap: 6px; } .hero-knoepfe .btn { height: 40px; padding: 0 var(--s-section); gap: 6px; }
.hero-punkte { .hero-punkte {
position: absolute; right: var(--s-page); bottom: var(--s-page); position: absolute; right: var(--s-page); bottom: calc(var(--s-page) - 12px);
display: flex; gap: 6px; display: flex; gap: 6px;
} }
/* **Der Strich ist drei Pixel hoch, die Fläche darf es nicht sein.**
Gemessen: von drei Klicks auf denselben Punkt traf nur der genau auf der
Mittellinie; fünf Pixel darüber oder darunter geschah nichts. Der Knopf
trägt deshalb jetzt 27 Pixel Höhe, von denen man 3 sieht. */
.hero-punkt { .hero-punkt {
width: 26px; height: 3px; padding: 0; border: 0; border-radius: 2px; width: 26px; height: 27px; padding: 0; border: 0;
background: none;
display: flex; align-items: center;
}
.hero-punkt::before {
content: ''; flex: 1; height: 3px; border-radius: 2px;
background: rgba(255, 255, 255, .28); background: rgba(255, 255, 255, .28);
transition: background .2s ease; transition: background .2s ease;
} }
.hero-punkt.an { background: var(--text); } .hero-punkt.an::before { background: var(--text); }
.hero-punkt:hover { background: rgba(255, 255, 255, .6); } .hero-punkt:hover::before { background: rgba(255, 255, 255, .6); }
@media (max-width: 860px) { @media (max-width: 860px) {
.hero { .hero {
@@ -1164,7 +1174,7 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; }
margin: calc(var(--s-loose) * -1) calc(var(--s-loose) * -1) var(--s-section); margin: calc(var(--s-loose) * -1) calc(var(--s-loose) * -1) var(--s-section);
} }
.hero-inhalt { padding: var(--s-loose); max-width: none; } .hero-inhalt { padding: var(--s-loose); max-width: none; }
.hero-punkte { right: var(--s-loose); bottom: var(--s-loose); } .hero-punkte { right: var(--s-loose); bottom: calc(var(--s-loose) - 12px); }
} }
/* --- Kontextmenü --------------------------------------------------------- /* --- Kontextmenü ---------------------------------------------------------