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>
252 lines
11 KiB
Swift
252 lines
11 KiB
Swift
import Foundation
|
|
|
|
/// Was ein Server können muss, damit die App mit ihm arbeiten kann.
|
|
///
|
|
/// Der Vertrag ist bewusst schmal. Alles, was nur ein Server kann — Plex' PIN-
|
|
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
|
|
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
|
|
/// der alles umfasst, wäre kein Nenner mehr.
|
|
public protocol MediaBackend: Sendable {
|
|
/// Wie der Server heißt, so wie er sich selbst nennt.
|
|
nonisolated var serverName: String { get }
|
|
/// Wer dieser Server ist — für alles, was ihn über einen Start hinaus
|
|
/// wiedererkennen muss.
|
|
///
|
|
/// **Nicht der Name.** Zwei Server dürfen „Medien" heißen, und derselbe
|
|
/// Server darf umbenannt werden. Was hier steht, ist so gewählt, dass es
|
|
/// beides überlebt und beim nächsten Start noch gleich lautet. Es ist
|
|
/// eine Kennung und geht nirgends an die Oberfläche.
|
|
nonisolated var identity: String { get }
|
|
/// Was dieser Server darüber hinaus beherrscht.
|
|
nonisolated var capabilities: Set<BackendCapability> { get }
|
|
|
|
// MARK: - Blättern
|
|
|
|
func libraries() async throws(MediaBackendError) -> [MediaLibrary]
|
|
func homeRows() async throws(MediaBackendError) -> [MediaRowGroup]
|
|
func items(
|
|
inLibrary library: MediaLibrary,
|
|
query: MediaQuery,
|
|
offset: Int,
|
|
limit: Int
|
|
) async throws(MediaBackendError) -> MediaPage
|
|
func item(id: String) async throws(MediaBackendError) -> MediaItem
|
|
/// In welcher Mediathek ein Titel liegt.
|
|
///
|
|
/// **Warum das eine Frage ist.** Ein Titel weiss es nicht von selbst: er
|
|
/// kennt seinen unmittelbaren Vorgesetzten — bei einer Folge die Staffel,
|
|
/// bei einem Film die Mediathek — und mehr nicht. Wer die Mediathek einer
|
|
/// Folge wissen will, muss die Kette hinaufgehen, und das ist eine
|
|
/// Serveranfrage.
|
|
///
|
|
/// Deshalb steht sie hier als eigene Frage und nicht als Feld an
|
|
/// `MediaItem`: sie wird selten gebraucht, kostet etwas, und ein Feld,
|
|
/// das meistens `nil` ist, verschweigt genau das.
|
|
///
|
|
/// `nil`, wenn der Server es nicht sagen kann — dann fällt die Ebene weg,
|
|
/// statt zu raten.
|
|
func libraryID(for item: MediaItem) async -> String?
|
|
func children(of id: String) async throws(MediaBackendError) -> [MediaItem]
|
|
func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup]
|
|
/// Die möglichen Werte eines Filters, etwa alle Genres einer Mediathek.
|
|
func filterOptions(
|
|
inLibrary library: MediaLibrary,
|
|
field: MediaQuery.Field
|
|
) async throws(MediaBackendError) -> [MediaFilterOption]
|
|
/// Alle Folgen einer Serie, über alle Staffeln hinweg.
|
|
///
|
|
/// **Als eigene Frage, weil der allgemeine Weg zu teuer ist.** Staffeln
|
|
/// holen und dann jede Staffel einzeln kostet bei zwölf Staffeln
|
|
/// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel.
|
|
/// Ein Server, der es in einer Anfrage kann, soll das dürfen.
|
|
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem]
|
|
/// Die nächste Folge nach dieser, sofern es eine gibt.
|
|
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
|
|
|
// MARK: - Bilder
|
|
|
|
/// Ein Bild in der gewünschten Größe.
|
|
///
|
|
/// `nonisolated`, weil die Adresse ohne Netzzugriff entsteht und der
|
|
/// Systemplayer sie ohne Kopfzeilen abruft — der Token steckt deshalb in
|
|
/// der Adresse selbst.
|
|
nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL?
|
|
nonisolated func rawImageURL(path: String?) -> URL?
|
|
|
|
// MARK: - Herunterladen
|
|
|
|
/// Die Adresse, unter der eine Datei unverändert zu holen ist.
|
|
nonisolated func downloadURL(for file: MediaFile) -> URL?
|
|
|
|
/// Die Adresse für eine verkleinerte Fassung.
|
|
///
|
|
/// `nil`, wenn der Server das nicht anbietet — dann bleibt nur das
|
|
/// Original, und die Oberfläche soll die Wahl gar nicht erst zeigen.
|
|
nonisolated func compressedDownloadURL(
|
|
itemID: String,
|
|
versionIndex: Int,
|
|
maximumBitrate: Int,
|
|
resolution: String?
|
|
) -> URL?
|
|
|
|
/// Die Adresse für dieselbe Datei in einer **anderen Schachtel**.
|
|
///
|
|
/// Gemeint ist Umpacken, nicht Umrechnen: dieselben Ströme, eine Hülle,
|
|
/// die der Systemplayer öffnen kann. Nötig für Dateien, die offline sonst
|
|
/// unspielbar wären — auf dem Gerät gibt es keinen Server mehr, der
|
|
/// nachträglich helfen könnte.
|
|
///
|
|
/// `nil`, wenn der Server das nicht anbietet. Dann bleibt das Original,
|
|
/// und der Titel ist offline nur so gut wie der Systemplayer.
|
|
///
|
|
/// **Mit Spurnummern, weil beim Umpacken eine Wahl ansteht.** Der Server
|
|
/// legt in die neue Hülle genau eine Tonspur und höchstens einen
|
|
/// Untertitel. Welche, entscheidet der Aufrufer — offline lässt sich das
|
|
/// nicht nachholen.
|
|
nonisolated func remuxDownloadURL(
|
|
itemID: String,
|
|
versionIndex: Int,
|
|
audioStreamIndex: Int?,
|
|
subtitleStreamIndex: Int?
|
|
) -> URL?
|
|
|
|
// MARK: - Abspielen
|
|
|
|
/// Lässt den Server eine Wiedergabe vorbereiten und nennt die Adresse.
|
|
///
|
|
/// Wirft mit der Begründung des Servers, wenn er ablehnt. Genau daran hing
|
|
/// beim Live-TV tagelang alles: der Server hatte im Klartext gesagt, warum
|
|
/// er nicht mitspielt, und die Antwort wurde weggeworfen.
|
|
func preparePlayback(_ intent: PlaybackIntent) async throws(MediaBackendError) -> PlaybackTarget
|
|
|
|
/// Beendet, was der Server für diese Wiedergabe angelegt hat.
|
|
///
|
|
/// Ohne dieses Signal rechnet ein Transcoder minutenlang weiter, obwohl
|
|
/// niemand mehr zusieht.
|
|
func endPlayback(sessionID: String) async
|
|
|
|
/// Meldet, wo man gerade steht.
|
|
///
|
|
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
|
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
|
/// wäre die schlechtere Hälfte des Tauschs.
|
|
func reportProgress(_ report: PlaybackReport) async
|
|
|
|
// MARK: - Zustand melden
|
|
|
|
func markWatched(id: String) async throws(MediaBackendError)
|
|
func markUnwatched(id: String) async throws(MediaBackendError)
|
|
/// Nimmt einen Titel aus „Weiterschauen", ohne ihn als gesehen zu melden.
|
|
///
|
|
/// **Der Fall, für den es sonst nichts gibt.** Nach einer halben Stunde
|
|
/// keine Lust mehr — der Film ist weder gesehen noch ungesehen, er soll
|
|
/// nur nicht ewig weiter oben stehen. „Als gesehen markieren" wäre dafür
|
|
/// eine Lüge, „als ungesehen" tut nichts: der Fortschritt bleibt.
|
|
///
|
|
/// **Der ganze Titel, nicht nur seine Kennung.** Jellyfin kennt dafür
|
|
/// keinen eigenen Weg; dort wird das Benutzerdatenblatt geschrieben, und
|
|
/// darin stehen auch „gesehen" und „Favorit". Wer sie nicht mitschickt,
|
|
/// löscht sie — und der Aufrufer hat sie ohnehin zur Hand.
|
|
func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError)
|
|
func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError)
|
|
func rate(id: String, rating: Int) async throws(MediaBackendError)
|
|
func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError)
|
|
}
|
|
|
|
/// Was ein Server über das Nötigste hinaus kann.
|
|
///
|
|
/// Abgefragt statt vorausgesetzt: Plex kennt keine Favoriten, Jellyfin keine
|
|
/// Plex-Empfänger, und eine Oberfläche, die Knöpfe für Dinge zeigt, die der
|
|
/// Server nicht kann, ist schlimmer als eine, die sie weglässt.
|
|
public enum BackendCapability: String, Sendable, Hashable {
|
|
case liveTV
|
|
/// Aufnehmen und Vormerken — nicht dasselbe wie `liveTV`.
|
|
///
|
|
/// Plex kann Sender zeigen und einschalten, aber in dieser App nichts
|
|
/// aufnehmen; Jellyfin kann beides. Eine Fähigkeit für beides zusammen
|
|
/// hieße, entweder Plex Live-TV wegzunehmen oder einen Knopf zu zeigen,
|
|
/// der dort nichts tut.
|
|
case liveTVRecordings
|
|
case favourites
|
|
case ratings
|
|
case libraryScan
|
|
/// Die Verwaltung des Servers aus der App heraus.
|
|
case administration
|
|
case transcoding
|
|
}
|
|
|
|
/// Ein Ausschnitt aus einer Mediathek.
|
|
///
|
|
/// „Seite" ist hier eine technische Portion, **keine Seite in der Oberfläche**.
|
|
/// Die Mediathek ist eine einzige lange Liste, die nachlädt, sobald man sich
|
|
/// dem Ende nähert — Blättern mit „weiter zu Seite 2" gehört nicht in eine
|
|
/// Mediathek, in der man stöbert.
|
|
public struct MediaPage: Sendable, Equatable {
|
|
public let items: [MediaItem]
|
|
/// Ab welcher Stelle dieser Ausschnitt beginnt.
|
|
public let offset: Int
|
|
/// Wie viele es insgesamt gibt — Grundlage fürs Nachladen.
|
|
public let totalCount: Int
|
|
|
|
public init(items: [MediaItem], offset: Int = 0, totalCount: Int) {
|
|
self.items = items
|
|
self.offset = offset
|
|
self.totalCount = totalCount
|
|
}
|
|
|
|
public var hasMore: Bool { offset + items.count < totalCount }
|
|
public var nextOffset: Int { offset + items.count }
|
|
}
|
|
|
|
public extension MediaBackend {
|
|
/// Vorgabe: der Server kann es nicht.
|
|
///
|
|
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
|
|
/// gibt — und wer es anbietet, sagt es ausdrücklich.
|
|
///
|
|
/// **Mit Spurnummern, weil beim Umpacken eine Wahl ansteht.** Der Server
|
|
/// legt in die neue Hülle genau eine Tonspur und höchstens einen
|
|
/// Untertitel. Welche, entscheidet der Aufrufer — offline lässt sich das
|
|
/// nicht nachholen.
|
|
nonisolated func remuxDownloadURL(
|
|
itemID: String,
|
|
versionIndex: Int,
|
|
audioStreamIndex: Int?,
|
|
subtitleStreamIndex: Int?
|
|
) -> URL? { nil }
|
|
|
|
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
|
|
///
|
|
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
|
|
///
|
|
/// **Ohne Sortieren und ohne Filtern.** Beides ist Sache von
|
|
/// `EpisodeSelection`, und es zweimal zu tun hiesse, dass die beiden
|
|
/// irgendwann auseinanderlaufen.
|
|
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] {
|
|
let staffeln = try await children(of: id)
|
|
var folgen: [MediaItem] = []
|
|
for staffel in staffeln {
|
|
folgen += (try? await children(of: staffel.id)) ?? []
|
|
}
|
|
return folgen
|
|
}
|
|
|
|
/// Ohne eigene Antwort keine Mediathek.
|
|
///
|
|
/// Plex beantwortet das heute nicht, und eine falsche Antwort wäre
|
|
/// schlechter als keine: sie stellte die Sprache einer fremden Mediathek
|
|
/// über den allgemeinen Wunsch.
|
|
func libraryID(for item: MediaItem) async -> String? { nil }
|
|
}
|
|
|
|
/// Ein möglicher Wert für einen Filter.
|
|
public struct MediaFilterOption: Sendable, Equatable, Identifiable {
|
|
public let id: String
|
|
public let title: String
|
|
|
|
public init(id: String, title: String) {
|
|
self.id = id
|
|
self.title = title
|
|
}
|
|
}
|