Files
Flaro/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift
T
ScarriffleandClaude Opus 5 7673c73757 Vier Sekunden, die ffprobe zu früh aufhörte zu lesen
Die Untertitel kamen vier Sekunden zu spät. Nicht an ihnen lag es: der
Server meldete dem Spieler einen Anfang, der vor dem lag, an dem der
Strom wirklich begann, und genau diese Differenz landet eins zu eins im
Versatz.

Die Schlüsselbildsuche las mit `-read_intervals` bis eine halbe Sekunde
hinter den Wunsch. ffprobe bricht am Ende eines Fensters auf Paketebene
ab — das letzte Schlüsselbild darin kam regelmässig gar nicht mehr
heraus, und die Suche gab das davor zurück. Bei Bildgruppen von vier
Sekunden sind das vier Sekunden.

Nachgemessen gegen die Stelle, an der ffmpeg tatsächlich anfängt, an
einer Datei mit Schlüsselbildern alle vier Sekunden:

    Wunsch   Suche (0,5 s)   ffmpeg    Abweichung
      52,0            48      52,0         −4,0
      73,3            68      72,0         −4,0
     100,2            96     100,0         −4,0

Mit zehn Sekunden Nachlauf lag dieselbe Messung bei allen sieben Werten
auf 0,000. Was über den Wunsch hinausgelesen wird, verwirft die
Filterung ohnehin.

Die Prüfung vergleicht nicht gegen eine erwartete Zahl, sondern gegen
ffmpeg selbst — mit dem alten Wert fällt sie um.

Dazu der Filmteil der Abspannerkennung: `Abspannkante` als reine
Rechnung mit elf Prüfungen, `Bildabtaster` über ffmpeg, Marken auch an
Filmen, Knöpfe zum Suchen und Verwerfen auf der Filmseite. An einer
erzeugten Datei trifft die Kante auf vier Sekunden genau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 06:42:16 +02:00

215 lines
8.1 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,
heroArt: heroImage,
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)],
// **Nur der Abspann.** Einen Vorspann gibt es bei einem Film
// nicht zu finden: erkannt wird er daran, dass er sich über
// die Folgen einer Staffel wiederholt.
markers: (markers ?? []).map {
MediaMarker(
kind: MediaMarker.Kind(rawValue: $0.kind) ?? .unknown,
startMilliseconds: Int($0.startSeconds * 1000),
endMilliseconds: Int($0.endSeconds * 1000))
}
)
}
}
extension Wire.Series {
func neutral() -> MediaItem {
MediaItem(
id: id,
kind: .show,
title: title,
summary: overview,
year: year,
thumb: posterImage,
art: backdropImage,
heroArt: heroImage,
// **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)],
// **Vorspann und Abspann, wenn der Server sie kennt.**
// Ältere Server schicken das Feld gar nicht; dann ist die
// Liste leer und die Spieler zeigen schlicht keinen Knopf.
markers: (markers ?? []).map {
MediaMarker(
kind: MediaMarker.Kind(rawValue: $0.kind) ?? .unknown,
startMilliseconds: Int($0.startSeconds * 1000),
endMilliseconds: Int($0.endSeconds * 1000))
}
)
}
}
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,
heroArt: heroImage,
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) })
}
}