Files
Flaro/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift
T
ScarriffleandClaude Opus 5 8cf2118723 Vorspann und Abspann — erkannt, abgelegt, überspringbar
Beides mit derselben Rechnung, nur in einem anderen Fenster: vorn die
ersten fünfzehn Minuten, hinten die letzten zehn. Was `Tonabgleich`
findet, hängt allein daran, welchen Ausschnitt man ihm gibt — eine
zweite Erkennung für den Abspann wäre dieselbe Suche ein zweites Mal.

Je Staffel verglichen, nicht je Serie: Serien wechseln ihren Vorspann
zwischen Staffeln, und über alle hinweg fände man nichts oder das
Falsche. Gefundenes über fünf Minuten wird verworfen — zwei Folgen, die
dieselbe Datei tragen, teilen vierzig, und darüber stünde sonst ein
Überspringen-Knopf.

Der Lauf geht auf Zuruf, nicht bei jedem Scan: er liest von jeder Folge
ein Stück Ton und dauert Minuten. Der Knopf dafür steht auf der
Serienseite, bei den Verwalterknöpfen.

Die Marken gehen im Folgenvertrag mit — als „intro" und „credits", die
Wörter, die die Apps von Jellyfin her ohnehin kennen. In der App zeichnet
sie der vorhandene Überspringen-Knopf, der bisher nie etwas zu tun
hatte; im Web ist er neu, rechtsbündig über der Zeitleiste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:28:36 +02:00

203 lines
7.5 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)],
// **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,
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) })
}
}