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>
This commit is contained in:
Scarriffle
2026-10-03 19:38:27 +02:00
co-authored by Claude Opus 5
parent f98b995050
commit a7cf6c5def
390 changed files with 2078 additions and 2069 deletions
@@ -0,0 +1,48 @@
import Foundation
// `os(iOS)` statt `canImport(ActivityKit)`: der Import gelingt auch auf
// macOS, die Typen darin sind dort aber als nicht verfügbar markiert. Eine
// Prüfung auf den Import allein übersetzt deshalb nicht.
// Catalyst ausdrücklich ausgenommen: dort gilt `os(iOS)`, aber ActivityKit
// gibt es nicht — die Typen sind zwar da und jeder Aufruf ist als nicht
// verfügbar markiert.
#if os(iOS) && !targetEnvironment(macCatalyst)
import ActivityKit
/// Was im Sperrbildschirm und in der Dynamic Island steht.
///
/// Liegt in FlaroDownloads, weil App und Widget-Erweiterung denselben Typ
/// brauchen — zwei getrennte Kopien wären die klassische Quelle für eine Live
/// Activity, die sich nicht mehr aktualisieren lässt: ActivityKit gleicht die
/// Typen über ihren vollen Namen ab.
public struct DownloadActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable, Sendable {
/// Der Titel, der gerade lädt.
public var title: String
/// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist.
public var progress: Double?
public var receivedBytes: Int64
public var totalBytes: Int64
/// Wie viele weitere in der Warteschlange stehen.
public var remainingCount: Int
public init(
title: String,
progress: Double?,
receivedBytes: Int64,
totalBytes: Int64,
remainingCount: Int
) {
self.title = title
self.progress = progress
self.receivedBytes = receivedBytes
self.totalBytes = totalBytes
self.remainingCount = remainingCount
}
}
/// Bleibt über die Laufzeit gleich. Hier leer: welcher Titel lädt, wechselt
/// im Laufe einer Staffel und gehört deshalb in den Zustand.
public init() {}
}
#endif
@@ -0,0 +1,174 @@
import Foundation
import SwiftData
/// Ein heruntergeladener oder herunterzuladender Titel.
///
/// Liegt in SwiftData, weil Downloads die App überleben: iOS überträgt im
/// Hintergrund weiter, auch wenn die App beendet ist, und beim nächsten Start
/// muss aus dem gespeicherten Zustand hervorgehen, was zu tun ist.
@Model
public final class DownloadItem {
/// Der Schlüssel des Titels auf dem Server. Eindeutig — derselbe Titel
/// soll nicht zweimal in der Warteschlange stehen.
@Attribute(.unique) public var ratingKey: String
/// Der Zustand als Zeichenkette. SwiftData kann Aufzählungen speichern,
/// aber eine Zeichenkette bleibt bei Umbenennungen im Code lesbar — und
/// ein unlesbarer Zustand hieße: laufende Downloads sind verloren.
public var stateRaw: String
public var title: String
public var subtitle: String?
/// Zu welcher Serie und Staffel der Titel gehört. Gruppiert die
/// Downloads-Ansicht und ist Grundlage für „halte N Folgen offline".
public var seriesKey: String?
public var seriesTitle: String?
public var seasonKey: String?
public var seasonIndex: Int?
public var episodeIndex: Int?
public var totalBytes: Int64
public var receivedBytes: Int64
/// Ob `totalBytes` die **genaue** Größe ist oder eine Schätzung.
///
/// **Daran hängt, wann eine Datei als abgerissen gilt.** Beim Original
/// nennt der Server die Dateigröße in den Metadaten — da ist jedes
/// fehlende Byte ein Abriss. Bei den umgerechneten Stufen rechnet die App
/// die Größe aus Bitrate und Dauer hoch, und die weicht von Haus aus ab;
/// dort wäre dieselbe Strenge falsch.
///
/// Ohne diese Unterscheidung galt für beides die Hälfte-Regel — und eine
/// bei sechzig Prozent abgerissene Datei bekam einen Haken.
public var expectedIsExact: Bool = false
/// Dateiname im Download-Ordner. Nicht der volle Pfad: der Container der
/// App bekommt bei jedem Systemupdate eine neue Kennung, ein gespeicherter
/// absoluter Pfad zeigt danach ins Leere.
public var localFileName: String?
public var artworkFileName: String?
public var addedAt: Date
public var completedAt: Date?
/// Wann der Titel durchgesehen wurde — Grundlage fürs automatische Löschen.
public var watchedAt: Date?
public var failureMessage: String?
/// Woher geladen wird. Enthält den Token, deshalb liegt er im geschützten
/// App-Container und nicht in den Voreinstellungen.
public var sourceURLString: String
public var durationMilliseconds: Int?
/// Was der Player offline über die Datei wissen muss: Container, Codecs,
/// Spuren, Marken. Als JSON, weil die Form von Plex vorgegeben ist und
/// sich eigene SwiftData-Modelle dafür nicht lohnen.
public var offlineMediaData: Data?
/// Zum Fortsetzen nach dem Pausieren. iOS liefert diesen Datensatz beim
/// Abbrechen; ohne ihn beginnt ein pausierter Download wieder bei null.
public var resumeData: Data?
public init(
ratingKey: String,
title: String,
subtitle: String? = nil,
sourceURLString: String,
state: DownloadState = .queued,
totalBytes: Int64 = 0,
seriesKey: String? = nil,
seriesTitle: String? = nil,
seasonKey: String? = nil,
seasonIndex: Int? = nil,
episodeIndex: Int? = nil,
durationMilliseconds: Int? = nil,
offlineMediaData: Data? = nil,
expectedIsExact: Bool = false
) {
self.ratingKey = ratingKey
self.title = title
self.subtitle = subtitle
self.sourceURLString = sourceURLString
self.stateRaw = state.rawValue
self.totalBytes = totalBytes
self.receivedBytes = 0
self.seriesKey = seriesKey
self.seriesTitle = seriesTitle
self.seasonKey = seasonKey
self.seasonIndex = seasonIndex
self.episodeIndex = episodeIndex
self.durationMilliseconds = durationMilliseconds
self.offlineMediaData = offlineMediaData
self.expectedIsExact = expectedIsExact
self.addedAt = Date()
}
public var state: DownloadState {
get { DownloadState(rawValue: stateRaw) ?? .failed }
set { stateRaw = newValue.rawValue }
}
/// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist.
///
/// Ein Balken bei null Prozent, der sich nie bewegt, sieht aus wie ein
/// hängender Download — ohne Gesamtgröße ist ein unbestimmter Fortschritt
/// die ehrlichere Anzeige.
public var progress: Double? {
guard totalBytes > 0 else { return nil }
return min(Double(receivedBytes) / Double(totalBytes), 1)
}
public var isComplete: Bool { state == .completed }
/// Wie viel Platz dieser Eintrag gerade belegt.
public var occupiedBytes: Int64 {
state.occupiesStorage ? max(receivedBytes, 0) : 0
}
}
/// Was der Player offline über eine Datei wissen muss.
///
/// Ein Abzug der Serverangaben zum Zeitpunkt des Downloads. Offline lässt sich
/// nichts nachfragen — und der `PlaybackDecider` braucht Container und Codecs,
/// um zwischen die Engines zu wählen.
public struct OfflineMedia: Sendable, Codable, Equatable {
public var container: String?
public var videoCodec: String?
public var audioCodec: String?
public var videoResolution: String?
public var bitrate: Int?
public var audioLanguages: [String]
public var subtitleLanguages: [String]
/// Intro und Abspann, in Millisekunden.
public var markers: [OfflineMarker]
public init(
container: String? = nil,
videoCodec: String? = nil,
audioCodec: String? = nil,
videoResolution: String? = nil,
bitrate: Int? = nil,
audioLanguages: [String] = [],
subtitleLanguages: [String] = [],
markers: [OfflineMarker] = []
) {
self.container = container
self.videoCodec = videoCodec
self.audioCodec = audioCodec
self.videoResolution = videoResolution
self.bitrate = bitrate
self.audioLanguages = audioLanguages
self.subtitleLanguages = subtitleLanguages
self.markers = markers
}
}
public struct OfflineMarker: Sendable, Codable, Equatable {
public var type: String
public var startMilliseconds: Int
public var endMilliseconds: Int
public init(type: String, startMilliseconds: Int, endMilliseconds: Int) {
self.type = type
self.startMilliseconds = startMilliseconds
self.endMilliseconds = endMilliseconds
}
}