Files
Flaro/Packages/FlaroKit/Sources/JellyfinKit/JellyfinBackend+Recordings.swift
ScarriffleandClaude Opus 5 a7cf6c5def Halo heisst ab hier Flaro — die Marke stand im Weg
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>
2026-10-03 19:38:27 +02:00

193 lines
6.8 KiB
Swift

import Foundation
import MediaKit
/// Jellyfin als `LiveTVRecordingBackend`.
///
/// Plex erfüllt diesen Vertrag nicht — es kann in dieser App nichts aufnehmen.
/// Deshalb steht er getrennt: wer aufnehmen will, fragt mit `as?` danach und
/// bekommt eine ehrliche Antwort, statt auf Methoden zu treffen, die nur
/// werfen.
extension JellyfinBackend: LiveTVRecordingBackend {
// MARK: - Aufnahmen
public func recordings() async throws(MediaBackendError) -> [LiveRecording] {
let page = try await client.send(
JellyfinRequest(
path: "/LiveTv/Recordings",
query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "enableImages", value: "true"),
]
),
as: JellyfinItemPage.self
)
let now = Date()
return page.items.map { item in
LiveRecording(
id: item.id,
title: item.name ?? "—",
channelName: item.channelName,
summary: item.overview,
startsAt: item.startDate,
endsAt: item.endDate,
artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]),
// Abgeleitet statt erfragt: eine Aufnahme, deren Ende noch
// bevorsteht, läuft gerade. Das spart ein Feld, das sonst nur
// für diese eine Anzeige im Modell stünde.
isInProgress: (item.endDate.map { $0 > now }) ?? false
)
}
}
public func deleteRecording(id: String) async throws(MediaBackendError) {
_ = try await client.send(JellyfinRequest(
method: .delete,
path: "/LiveTv/Recordings/\(id)"
))
}
// MARK: - Vorgemerkt
public func timers() async throws(MediaBackendError) -> [LiveTimer] {
let page = try await client.send(
JellyfinRequest(path: "/LiveTv/Timers"),
as: JellyfinTimerPage.self
)
return page.items.map { timer in
LiveTimer(
id: timer.id ?? "",
programID: timer.programID,
channelID: timer.channelID,
channelName: timer.channelName,
title: timer.name ?? "—",
startsAt: timer.startDate,
endsAt: timer.endDate,
seriesTimerID: timer.seriesTimerID
)
}
}
public func scheduleTimer(for program: LiveProgram) async throws(MediaBackendError) {
try await schedule(program: program, path: "/LiveTv/Timers")
}
public func cancelTimer(id: String) async throws(MediaBackendError) {
_ = try await client.send(JellyfinRequest(
method: .delete,
path: "/LiveTv/Timers/\(id)"
))
}
// MARK: - Ganze Reihen
public func seriesTimers() async throws(MediaBackendError) -> [LiveSeriesTimer] {
let page = try await client.send(
JellyfinRequest(path: "/LiveTv/SeriesTimers"),
as: JellyfinSeriesTimerPage.self
)
return page.items.map { timer in
LiveSeriesTimer(
id: timer.id ?? "",
name: timer.name ?? "—",
channelID: timer.channelID,
channelName: timer.channelName,
recordsNewOnly: timer.skipEpisodesInLibrary ?? false,
recordsAnyChannel: timer.recordAnyChannel ?? false
)
}
}
public func scheduleSeriesTimer(for program: LiveProgram) async throws(MediaBackendError) {
try await schedule(program: program, path: "/LiveTv/SeriesTimers")
}
public func cancelSeriesTimer(id: String) async throws(MediaBackendError) {
_ = try await client.send(JellyfinRequest(
method: .delete,
path: "/LiveTv/SeriesTimers/\(id)"
))
}
// MARK: - Der gemeinsame Weg
/// Merkt eine Sendung vor — einmalig oder als ganze Reihe.
///
/// **Zwei Schritte, und der erste ist der wichtige.** Der Server liefert
/// unter `Timers/Defaults` eine für diese Sendung fertig ausgefüllte
/// Vorlage: Vor- und Nachlauf, Priorität, Dienstname, wie lange sie
/// aufgehoben wird. Was davon Pflicht ist, steht nirgends — die Beschreibung
/// nennt kein einziges Feld als erforderlich.
///
/// Deshalb wird die Vorlage **roh weitergereicht**, so wie sie kam, und
/// nicht entschlüsselt und neu zusammengesetzt. Jedes Feld, das dabei
/// durch die Modellierung fiele, wäre eines, das der Server vermisst —
/// und man erführe es erst an einer Aufnahme, die nicht startet.
private func schedule(program: LiveProgram, path: String) async throws(MediaBackendError) {
let template = try await client.send(JellyfinRequest(
path: "/LiveTv/Timers/Defaults",
query: [URLQueryItem(name: "programId", value: program.id)]
))
_ = try await client.send(JellyfinRequest(
method: .post,
path: path,
body: template
))
}
}
// MARK: - Was Jellyfin über Vorgemerktes schickt
/// Ein vorgemerkter Termin, wie Jellyfin ihn meldet.
///
/// Kein `JellyfinItem`: Timer sind bei Jellyfin ausdrücklich **keine**
/// Einträge, sondern ein eigener Typ mit eigenen Feldern.
struct JellyfinTimer: Decodable, Sendable {
let id: String?
let programID: String?
let channelID: String?
let channelName: String?
let name: String?
let startDate: Date?
let endDate: Date?
let seriesTimerID: String?
private enum CodingKeys: String, CodingKey {
case id = "Id", programID = "ProgramId", channelID = "ChannelId"
case channelName = "ChannelName", name = "Name"
case startDate = "StartDate", endDate = "EndDate"
case seriesTimerID = "SeriesTimerId"
}
}
struct JellyfinTimerPage: Decodable, Sendable {
let items: [JellyfinTimer]
private enum CodingKeys: String, CodingKey { case items = "Items" }
}
/// Eine stehende Aufnahme für alles, was zu einer Reihe gehört.
struct JellyfinSeriesTimer: Decodable, Sendable {
let id: String?
let channelID: String?
let channelName: String?
let name: String?
/// Ob bereits Vorhandenes übersprungen wird — das, was man umgangssprachlich
/// „nur neue Folgen" nennt.
let skipEpisodesInLibrary: Bool?
let recordAnyChannel: Bool?
private enum CodingKeys: String, CodingKey {
case id = "Id", channelID = "ChannelId", channelName = "ChannelName"
case name = "Name", skipEpisodesInLibrary = "SkipEpisodesInLibrary"
case recordAnyChannel = "RecordAnyChannel"
}
}
struct JellyfinSeriesTimerPage: Decodable, Sendable {
let items: [JellyfinSeriesTimer]
private enum CodingKeys: String, CodingKey { case items = "Items" }
}