Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging daneben. **Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler. Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim letzten Mal stattfand. --- 2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`, `Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen `FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und das Image. **Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes Ersetzen hätte den sonst zerlegt. `docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden in die Irre führt. **Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag: `com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe. 308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS bauen, das Image baut, und ein frischer Container läuft durch Einrichtung und Anmeldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
8.4 KiB
Swift
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
|
|
}
|
|
}
|