Files
Flaro/Packages/HaloKit/Sources/JellyfinKit/JellyfinItem+Media.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 11:12:32 +02:00

204 lines
8.4 KiB
Swift

import Foundation
import MediaKit
/// Die Übersetzung der Jellyfin-Modelle in die neutrale Form.
///
/// **Hier und nicht in den Ansichten.** Jeder Server bringt seine eigenen
/// Felder mit; sie genau einmal an der Modulgrenze zu übersetzen ist der
/// Preis dafür, dass es die Ansichten nur einmal gibt. Kommt ein zweiter
/// Server dazu, bekommt er seine eigene Datei dieser Art — und sonst nichts.
extension JellyfinItem {
/// `markers` kommen von aussen, weil Jellyfin sie nicht am Titel
/// liefert: sie stehen hinter `/MediaSegments/{id}` und damit hinter
/// einer zweiten Anfrage. Siehe `JellyfinBackend.segments(forItem:)`.
public func asMediaItem(
imageBase: JellyfinImagePaths,
markers: [MediaMarker] = []
) -> MediaItem {
MediaItem(
id: id,
path: nil,
kind: Self.kind(type),
title: name ?? "—",
summary: overview,
year: productionYear,
duration: runTimeTicks?.millisecondsFromTicks,
contentRating: officialRating,
// Jellyfin führt beide auf einer Zehnerskala, wie Plex auch.
rating: criticRating.map { $0 / 10 } ?? communityRating,
audienceRating: communityRating,
// **Nur wenn es auch eines gibt** — dieselbe Vorsicht wie eine
// Zeile tiefer beim Hintergrundbild, die hier gefehlt hat.
//
// Die Adresse entsteht aus der Kennung, nicht aus einer Antwort
// des Servers. Ohne die Prüfung trug jeder Titel ohne eigenes
// Plakat eine Adresse, die mit „nicht gefunden" antwortet — und
// weil ein 404 zu Recht nicht wiederholt wird, blieb die Kachel
// für immer grau. Betroffen waren vor allem Staffeln: die haben
// bei Jellyfin oft kein eigenes Bild, sondern leihen das der
// Serie. Ist hier nichts, darf die Kachel genau das tun.
thumb: imageTags?["Primary"].map { imageBase.primary(itemID: id, tag: $0) },
// Nur wenn es auch eines gibt: die Adresse entsteht aus der
// Kennung, nicht aus einer Antwort des Servers — ohne diese
// Prüfung stünde hinter jedem Titel ohne Hintergrundbild eine
// Adresse, die mit „nicht gefunden" antwortet. Fehlt dem Titel
// eines, nennt Jellyfin die Serie, von der es geliehen werden darf.
art: backdropImageTags?.first.map { imageBase.backdrop(itemID: id, tag: $0) }
?? parentBackdropItemID.map { imageBase.backdrop(itemID: $0, tag: nil) },
parentThumb: seasonID.map { imageBase.primary(itemID: $0, tag: nil) },
seriesThumb: seriesID.map { imageBase.primary(itemID: $0, tag: seriesPrimaryImageTag) },
seriesArt: seriesID.map { imageBase.backdrop(itemID: $0, tag: nil) },
index: indexNumber,
parentIndex: parentIndexNumber,
parentTitle: seasonName,
parentID: seasonID,
seriesTitle: seriesName,
seriesID: seriesID,
playCount: userData?.playCount,
resumeMilliseconds: userData?.playbackPositionTicks?.millisecondsFromTicks,
lastPlayedAt: nil,
addedAt: dateCreated,
// Das Feld, das es bei Plex nicht gibt — hier kommt es frei mit.
isFavourite: userData?.isFavorite ?? false,
episodeCount: recursiveItemCount,
playedEpisodeCount: playedCount,
childCount: childCount,
versions: (mediaSources ?? []).map { $0.asMediaVersion(itemID: id) },
genres: genres ?? [],
directors: (people ?? []).filter { $0.type == "Director" }.compactMap(\.name),
writers: (people ?? []).filter { $0.type == "Writer" }.compactMap(\.name),
cast: (people ?? []).filter { $0.type == "Actor" }.map { person in
MediaPerson(
id: person.id ?? person.name ?? UUID().uuidString,
name: person.name ?? "—",
role: person.role,
thumb: person.id.map { imageBase.primary(itemID: $0, tag: person.primaryImageTag) } ?? nil
)
},
markers: markers,
artwork: logoArtwork(imageBase: imageBase)
)
}
/// Wie viele Folgen gesehen sind.
///
/// Jellyfin meldet die **ungesehenen**. Die Zahl, die die Oberfläche
/// zeigt, ist die andere — und sie auszurechnen ist ehrlicher, als das
/// Gegenteil anzuzeigen und es „Fortschritt" zu nennen.
private var playedCount: Int? {
guard let total = recursiveItemCount, let unplayed = userData?.unplayedItemCount else {
return nil
}
return max(0, total - unplayed)
}
private func logoArtwork(imageBase: JellyfinImagePaths) -> [MediaArtwork] {
guard let tag = imageTags?["Logo"] else { return [] }
return [MediaArtwork(kind: .clearLogo, path: imageBase.logo(itemID: id, tag: tag))]
}
private static func kind(_ type: String?) -> MediaItem.Kind? {
switch type {
case "Movie": .movie
case "Series": .show
case "Season": .season
case "Episode": .episode
case "BoxSet": .collection
case "Trailer", "Video": .clip
default: nil
}
}
}
extension JellyfinMediaSource {
public func asMediaVersion(itemID: String) -> MediaVersion {
let streams = mediaStreams ?? []
let video = streams.first { $0.type == "Video" }
let audio = streams.first { $0.type == "Audio" && $0.isDefault == true }
?? streams.first { $0.type == "Audio" }
return MediaVersion(
id: id ?? itemID,
container: container,
videoCodec: video?.codec,
audioCodec: audio?.codec,
videoResolution: video?.height.map(String.init),
// Jellyfin rechnet in Bit je Sekunde, die App in Kilobit.
bitrate: bitrate.map { $0 / 1_000 },
width: video?.width,
height: video?.height,
audioChannels: audio?.channels,
duration: runTimeTicks?.millisecondsFromTicks,
files: [
// Jellyfin trennt Fassung und Datei nicht: eine Quelle ist
// beides. Die neutrale Form kennt beide Ebenen, weil Plex sie
// trennt — hier ist die zweite eben einelementig.
MediaFile(
id: id ?? itemID,
key: id ?? itemID,
itemID: itemID,
container: container,
size: size,
duration: runTimeTicks?.millisecondsFromTicks,
streams: streams.compactMap(\.asMediaStream)
)
]
)
}
}
extension JellyfinMediaStream {
public var asMediaStream: MediaStream? {
let mapped: MediaStream.Kind
switch type {
case "Video": mapped = .video
case "Audio": mapped = .audio
case "Subtitle": mapped = .subtitle
default: return nil
}
return MediaStream(
id: String(index ?? 0),
kind: mapped,
codec: codec,
language: language,
languageCode: language,
displayTitle: displayTitle,
channels: channels,
isSelected: isDefault ?? false,
isForced: isForced ?? false,
title: title,
isDefault: isDefault ?? false,
isHearingImpaired: isHearingImpaired ?? false,
isOriginal: isOriginal ?? false
)
}
}
/// Baut die Adressen der Bilder.
///
/// Eigener Typ, weil jedes Bild eine eigene Adresse ist und nicht ein Pfad wie
/// bei Plex. Die Marke (`tag`) gehört dazu: ohne sie liefert der Server zwar
/// ein Bild, aber der Zwischenspeicher merkt nicht, wenn es sich ändert.
public struct JellyfinImagePaths: Sendable {
public init() {}
public func primary(itemID: String, tag: String?) -> String {
path(itemID: itemID, kind: "Primary", tag: tag)
}
public func backdrop(itemID: String, tag: String?) -> String {
path(itemID: itemID, kind: "Backdrop", tag: tag)
}
public func logo(itemID: String, tag: String?) -> String {
path(itemID: itemID, kind: "Logo", tag: tag)
}
private func path(itemID: String, kind: String, tag: String?) -> String {
var path = "/Items/\(itemID)/Images/\(kind)"
if let tag { path += "?tag=\(tag)" }
return path
}
}