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:
co-authored by
Claude Opus 5
parent
f98b995050
commit
a7cf6c5def
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user