Files
Flaro/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift
T
ScarriffleandClaude Opus 5 e30158b84c Eine Sekunde heisst nicht „Weiterschauen"
Die Schwelle aus den Einstellungen wirkt serverseitig, also sofort überall:
`/home/continue` liest die gespeicherte Spalte. Eine Stelle blieb trotzdem
widersprüchlich — die **Beschriftung**.

Web und Apps entschieden „Weiterschauen" gegen „Abspielen" selbst, allein
daran, ob eine Stelle über null steht. Nach einer Sekunde stand der Titel
damit nicht mehr in der Reihe, hiess aber weiter „Weiterschauen", trug
einen Fortschrittsbalken unter der Kachel und bot „Aus Weiterschauen
entfernen" an.

Der Server hatte diese Frage längst beantwortet. `ItemStateResponse` bringt
sie jetzt mit; `MediaItem.isResumable` und `.resumeProgress` sind die
einzigen Stellen, die sie noch stellen. Im Simulator nachgeprüft: nach
einer Sekunde heisst der Knopf „Abspielen", „Von vorn" fehlt, und der
Balken unter der Kachel ist weg.

Kennt ein Server das Feld nicht — Jellyfin, oder ein älterer Flaro —, gilt
wie früher die blosse Stelle.

**Und das Profilbild steht in den Apps.** `FlaroAccount` führt es mit,
`/auth/me` bringt es bei jedem Start frisch, und der Buchstabenkreis tritt
zurück, sobald eines da ist. Jellyfin bleibt beim Buchstaben — dort gibt es
kein Profilbild, und das ist keine Lücke, sondern der Stand der Dinge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 17:30:38 +02:00

194 lines
7.0 KiB
Swift

import Foundation
import MediaKit
/// Aus Flaros Antworten werden neutrale Typen.
///
/// Die einzige Stelle, an der Flaros Schreibweise in die der App übersetzt
/// wird. Alles darüber kennt nur noch `MediaItem`.
extension Wire.Movie {
func neutral() -> MediaItem {
MediaItem(
id: id,
kind: .movie,
title: title,
summary: overview,
year: year,
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
thumb: posterImage,
art: backdropImage,
playCount: state?.playCount,
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false,
isInProgress: state?.inProgress,
availableFrom: publishAt,
versions: [Wire.fassung(id: id, files: files)]
)
}
}
extension Wire.Series {
func neutral() -> MediaItem {
MediaItem(
id: id,
kind: .show,
title: title,
summary: overview,
year: year,
thumb: posterImage,
art: backdropImage,
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
// 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
)
}
}
extension Wire.Episode {
func neutral(series: Wire.Series? = nil) -> MediaItem {
MediaItem(
id: id,
kind: .episode,
title: title ?? "Folge \(episode)",
summary: overview,
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
thumb: stillImage,
seriesThumb: series?.posterImage,
seriesArt: series?.backdropImage,
index: episode,
parentIndex: season,
seriesTitle: series?.title,
// 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,
isInProgress: state?.inProgress,
availableFrom: publishAt,
versions: [Wire.fassung(id: id, files: files)]
)
}
}
extension Wire.Started {
/// Die Kachel aus „Weiterschauen" als Titel.
///
/// **Ohne Fassungen.** Die Reihe nennt keine Dateien, und das ist
/// richtig so: eine Kachel zeigt Plakat, Titel und Balken. Wer sie
/// antippt, holt den Titel einzeln — und bekommt dann alles.
func neutral() -> MediaItem {
let folge = kind == "episode"
return MediaItem(
id: id,
kind: folge ? .episode : .movie,
title: folge ? (subTitle ?? "Folge \(episode ?? 0)") : title,
year: year,
duration: state.runtimeSeconds.map { Int($0 * 1000) },
thumb: folge ? backdropImage : posterImage,
// **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,
isInProgress: state.inProgress,
availableFrom: publishAt
)
}
}
extension Wire.Library {
func neutral() -> MediaLibrary {
MediaLibrary(
id: id,
title: name,
// Flaro kennt genau zwei Arten. Was es nicht gibt, als `.other`
// durchzulassen wäre eine Vorsorge für einen Fall, den der
// Server gar nicht erzeugen kann.
kind: kind.lowercased() == "shows" ? .show : .movie)
}
}
extension Wire.File {
func neutral(itemID: String) -> MediaFile {
MediaFile(
id: id,
key: "/api/v1/files/\(id)/download",
itemID: itemID,
container: container,
size: sizeBytes,
duration: durationSeconds.map { Int($0 * 1000) },
file: fileName,
streams: streams.map { $0.neutral() })
}
}
extension Wire.Stream {
func neutral() -> MediaStream {
let art: MediaStream.Kind = switch kind.lowercased() {
case "video": .video
case "subtitle": .subtitle
default: .audio
}
return MediaStream(
// **Die Spurnummer ist die Kennung.** `MediaStream.index` liest
// sie aus `id` zurück, und daran hängt die Spurwahl beim
// Umpacken. Eine eigene Kennung wäre hier eine, die niemand
// zurückrechnen kann.
id: String(index),
kind: art,
codec: codec,
language: language,
languageCode: language,
displayTitle: title ?? language,
channels: channels,
isSelected: isDefault,
isForced: isForced,
title: title,
isDefault: isDefault)
}
}
extension Wire {
/// Eine Fassung aus den Dateien eines Titels.
///
/// **Alle Dateien in einer Fassung**, nicht eine Fassung je Datei: Flaro
/// führt mehrere Dateien zu einem Titel als Fassungen desselben Films —
/// 4K und 1080p nebeneinander — und `MediaVersion.files` ist genau dafür
/// da. Die Eckdaten kommen aus der ersten Datei; welche gespielt wird,
/// entscheidet die App.
static func fassung(id: String, files: [Wire.File]) -> MediaVersion {
let erste = files.first
let bild = erste?.streams.first { $0.kind.lowercased() == "video" }
let ton = erste?.streams.first { $0.kind.lowercased() == "audio" }
return MediaVersion(
id: erste?.id ?? id,
container: erste?.container,
videoCodec: bild?.codec,
audioCodec: ton?.codec,
videoResolution: bild?.height.map(String.init),
width: bild?.width,
height: bild?.height,
audioChannels: ton?.channels,
duration: erste?.durationSeconds.map { Int($0 * 1000) },
files: files.map { $0.neutral(itemID: id) })
}
}