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,308 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein Eintrag in der Mediathek — Film, Serie, Staffel oder Folge.
|
||||
///
|
||||
/// Die Form, in der die Oberfläche Titel sieht, egal von welchem Server sie
|
||||
/// stammen. Plex und Jellyfin liefern dieselben Dinge unter anderen Namen;
|
||||
/// jeder Server-Adapter übersetzt einmal hierher, und danach ist die Herkunft
|
||||
/// ohne Belang.
|
||||
///
|
||||
/// Die Namen sind dort geblieben, wo sie ohnehin neutral waren — `title`,
|
||||
/// `summary`, `year`, `index`. Umbenannt wurde nur, was Plex-Jargon ist:
|
||||
/// `ratingKey` heißt jetzt `id`, `viewOffset` heißt `resumeMilliseconds`, und
|
||||
/// aus dem „Großelternteil" ist die Serie geworden, die damit gemeint war.
|
||||
public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
||||
public enum Kind: String, Sendable, Codable, CaseIterable {
|
||||
case movie, show, season, episode, clip, collection
|
||||
}
|
||||
|
||||
/// Die Kennung, unter der der Server den Eintrag führt.
|
||||
///
|
||||
/// Bei Plex eine Zahl als Zeichenkette, bei Jellyfin eine GUID. Für die
|
||||
/// App ist beides nur ein undurchsichtiger Schlüssel — **außer** beim
|
||||
/// Herunterladen, wo er mit einem Vorsatz versehen wird, damit sich zwei
|
||||
/// Server nicht ins Gehege kommen.
|
||||
public let id: String
|
||||
/// Der serverinterne Pfad, falls einer gebraucht wird.
|
||||
public let path: String?
|
||||
public let kind: Kind?
|
||||
public let title: String
|
||||
public let sortTitle: String?
|
||||
public let summary: String?
|
||||
public let year: Int?
|
||||
/// Laufzeit in Millisekunden.
|
||||
public let duration: Int?
|
||||
public let contentRating: String?
|
||||
/// Bewertung der Kritik, 0–10.
|
||||
public let rating: Double?
|
||||
/// Bewertung des Publikums, 0–10.
|
||||
public let audienceRating: Double?
|
||||
|
||||
// Bildpfade. Relativ zum Server — die vollständige Adresse entsteht erst
|
||||
// beim Abruf, weil sie Token und Größenangaben braucht.
|
||||
public let thumb: String?
|
||||
public let art: String?
|
||||
/// Das Bild der Staffel.
|
||||
public let parentThumb: String?
|
||||
/// Das Bild der Serie. Bei Plex hieß das „grandparent" — gemeint war immer
|
||||
/// die Serie, und so heißt es jetzt auch.
|
||||
public let seriesThumb: String?
|
||||
public let seriesArt: String?
|
||||
|
||||
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
|
||||
// Staffel.
|
||||
public let index: Int?
|
||||
public let parentIndex: Int?
|
||||
public let parentTitle: String?
|
||||
public let parentID: String?
|
||||
public let seriesTitle: String?
|
||||
public let seriesID: String?
|
||||
|
||||
// Fortschritt.
|
||||
public let playCount: Int?
|
||||
/// Wie weit gesehen wurde, in Millisekunden.
|
||||
public let resumeMilliseconds: Int?
|
||||
public let lastPlayedAt: Date?
|
||||
public let addedAt: Date?
|
||||
/// Ob der Titel als Favorit markiert ist.
|
||||
///
|
||||
/// Plex kennt das nicht und meldet immer `false`; Jellyfin führt eine
|
||||
/// eigene Liste. Ein Feld, das ein Server nicht füllt, ist ehrlicher als
|
||||
/// eine Fähigkeit, die es nur auf einem gibt.
|
||||
public let isFavourite: Bool
|
||||
|
||||
// Zählwerte bei Serien und Staffeln.
|
||||
public let episodeCount: Int?
|
||||
public let playedEpisodeCount: Int?
|
||||
public let childCount: Int?
|
||||
|
||||
/// Die Fassungen, in denen der Titel vorliegt.
|
||||
public let versions: [MediaVersion]
|
||||
|
||||
// Schlagwörter. Beide Server liefern sie nur bei der Einzelabfrage mit,
|
||||
// nicht in Listen — dort sind sie deshalb leer.
|
||||
public let genres: [String]
|
||||
public let directors: [String]
|
||||
public let writers: [String]
|
||||
public let cast: [MediaPerson]
|
||||
public let markers: [MediaMarker]
|
||||
/// Zusätzliche Bilder, etwa der Schriftzug eines Films.
|
||||
public let artwork: [MediaArtwork]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
path: String? = nil,
|
||||
kind: Kind? = nil,
|
||||
title: String,
|
||||
sortTitle: String? = nil,
|
||||
summary: String? = nil,
|
||||
year: Int? = nil,
|
||||
duration: Int? = nil,
|
||||
contentRating: String? = nil,
|
||||
rating: Double? = nil,
|
||||
audienceRating: Double? = nil,
|
||||
thumb: String? = nil,
|
||||
art: String? = nil,
|
||||
parentThumb: String? = nil,
|
||||
seriesThumb: String? = nil,
|
||||
seriesArt: String? = nil,
|
||||
index: Int? = nil,
|
||||
parentIndex: Int? = nil,
|
||||
parentTitle: String? = nil,
|
||||
parentID: String? = nil,
|
||||
seriesTitle: String? = nil,
|
||||
seriesID: String? = nil,
|
||||
playCount: Int? = nil,
|
||||
resumeMilliseconds: Int? = nil,
|
||||
lastPlayedAt: Date? = nil,
|
||||
addedAt: Date? = nil,
|
||||
isFavourite: Bool = false,
|
||||
episodeCount: Int? = nil,
|
||||
playedEpisodeCount: Int? = nil,
|
||||
childCount: Int? = nil,
|
||||
versions: [MediaVersion] = [],
|
||||
genres: [String] = [],
|
||||
directors: [String] = [],
|
||||
writers: [String] = [],
|
||||
cast: [MediaPerson] = [],
|
||||
markers: [MediaMarker] = [],
|
||||
artwork: [MediaArtwork] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.path = path
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
self.sortTitle = sortTitle
|
||||
self.summary = summary
|
||||
self.year = year
|
||||
self.duration = duration
|
||||
self.contentRating = contentRating
|
||||
self.rating = rating
|
||||
self.audienceRating = audienceRating
|
||||
self.thumb = thumb
|
||||
self.art = art
|
||||
self.parentThumb = parentThumb
|
||||
self.seriesThumb = seriesThumb
|
||||
self.seriesArt = seriesArt
|
||||
self.index = index
|
||||
self.parentIndex = parentIndex
|
||||
self.parentTitle = parentTitle
|
||||
self.parentID = parentID
|
||||
self.seriesTitle = seriesTitle
|
||||
self.seriesID = seriesID
|
||||
self.playCount = playCount
|
||||
self.resumeMilliseconds = resumeMilliseconds
|
||||
self.lastPlayedAt = lastPlayedAt
|
||||
self.addedAt = addedAt
|
||||
self.isFavourite = isFavourite
|
||||
self.episodeCount = episodeCount
|
||||
self.playedEpisodeCount = playedEpisodeCount
|
||||
self.childCount = childCount
|
||||
self.versions = versions
|
||||
self.genres = genres
|
||||
self.directors = directors
|
||||
self.writers = writers
|
||||
self.cast = cast
|
||||
self.markers = markers
|
||||
self.artwork = artwork
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaItem {
|
||||
/// Dasselbe ohne alles, was eine Kachel nicht zeigt.
|
||||
///
|
||||
/// **Wozu.** Ein Titel aus einer Listenabfrage bringt seine Fassungen mit,
|
||||
/// und die bringen jede Spur jeder Datei mit — Codec, Sprache, Kanäle. Im
|
||||
/// Raster steht davon nichts: dort stehen Plakat, Titel, Untertitel und
|
||||
/// der Fortschrittsbalken. Gespeichert wäre der Rest das Zwanzigfache an
|
||||
/// Daten, die bei jedem Aufrufen gelesen und ausgepackt werden müssten —
|
||||
/// und damit wäre der Speicher genau das nicht mehr, wofür es ihn gibt.
|
||||
///
|
||||
/// **Warum das gefahrlos ist.** Aus einer Kachel führt nur ein Weg, und
|
||||
/// der geht über die Kennung: die Detailansicht holt den Titel neu. Was
|
||||
/// hier fehlt, fehlt nur so lange, bis der Server geantwortet hat.
|
||||
var listEssentials: MediaItem {
|
||||
MediaItem(
|
||||
id: id,
|
||||
path: path,
|
||||
kind: kind,
|
||||
title: title,
|
||||
sortTitle: sortTitle,
|
||||
year: year,
|
||||
duration: duration,
|
||||
contentRating: contentRating,
|
||||
thumb: thumb,
|
||||
art: art,
|
||||
parentThumb: parentThumb,
|
||||
seriesThumb: seriesThumb,
|
||||
index: index,
|
||||
parentIndex: parentIndex,
|
||||
parentTitle: parentTitle,
|
||||
parentID: parentID,
|
||||
seriesTitle: seriesTitle,
|
||||
seriesID: seriesID,
|
||||
playCount: playCount,
|
||||
resumeMilliseconds: resumeMilliseconds,
|
||||
lastPlayedAt: lastPlayedAt,
|
||||
addedAt: addedAt,
|
||||
isFavourite: isFavourite,
|
||||
episodeCount: episodeCount,
|
||||
playedEpisodeCount: playedEpisodeCount,
|
||||
childCount: childCount
|
||||
)
|
||||
}
|
||||
|
||||
/// Wie weit gesehen — als Anteil zwischen 0 und 1.
|
||||
///
|
||||
/// `nil`, wenn noch nicht begonnen wurde: ein Balken bei null Prozent
|
||||
/// sieht aus wie ein Fehler, nicht wie „noch nicht angefangen".
|
||||
public var watchProgress: Double? {
|
||||
guard let resumeMilliseconds, let duration, duration > 0, resumeMilliseconds > 0 else {
|
||||
return nil
|
||||
}
|
||||
return min(Double(resumeMilliseconds) / Double(duration), 1)
|
||||
}
|
||||
|
||||
public var isWatched: Bool { (playCount ?? 0) > 0 }
|
||||
|
||||
/// Ab wo fortgesetzt wird.
|
||||
public var resumeTime: Duration {
|
||||
.milliseconds(resumeMilliseconds ?? 0)
|
||||
}
|
||||
|
||||
/// Die Fassung, die gespielt werden soll, wenn niemand wählt.
|
||||
public var preferredVersion: MediaVersion? { versions.first }
|
||||
|
||||
public var hasMultipleVersions: Bool { versions.count > 1 }
|
||||
|
||||
/// Der Schriftzug des Films, falls der Server einen hat.
|
||||
///
|
||||
/// Viele Filme bringen ihr eigenes Logo mit — Harry Potter in seiner
|
||||
/// Schrift sagt mehr als derselbe Titel in der Systemschrift.
|
||||
public var clearLogoPath: String? {
|
||||
artwork.first { $0.kind == .clearLogo }?.path
|
||||
}
|
||||
}
|
||||
|
||||
/// Jemand, der mitgewirkt hat.
|
||||
public struct MediaPerson: Sendable, Equatable, Identifiable, Codable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
/// Die Rolle, sofern es eine gibt.
|
||||
public let role: String?
|
||||
public let thumb: String?
|
||||
|
||||
public init(id: String, name: String, role: String? = nil, thumb: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.role = role
|
||||
self.thumb = thumb
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Abschnitt, den man überspringen möchte.
|
||||
public struct MediaMarker: Sendable, Equatable, Identifiable, Codable {
|
||||
public enum Kind: String, Sendable, Codable {
|
||||
case intro, credits, commercial, unknown
|
||||
}
|
||||
|
||||
public let kind: Kind
|
||||
public let startMilliseconds: Int
|
||||
public let endMilliseconds: Int
|
||||
|
||||
public init(kind: Kind, startMilliseconds: Int, endMilliseconds: Int) {
|
||||
self.kind = kind
|
||||
self.startMilliseconds = startMilliseconds
|
||||
self.endMilliseconds = endMilliseconds
|
||||
}
|
||||
|
||||
public func contains(_ milliseconds: Int) -> Bool {
|
||||
milliseconds >= startMilliseconds && milliseconds < endMilliseconds
|
||||
}
|
||||
|
||||
/// Der Anfang taugt als Kennung.
|
||||
///
|
||||
/// Zwei Marken derselben Sendung können nicht an derselben Stelle
|
||||
/// beginnen, und der Player braucht die Kennung nur, um sich zu merken,
|
||||
/// was er schon übersprungen hat — ohne dieses Gedächtnis spränge ein
|
||||
/// Rücksprung ins Intro es sofort wieder weg.
|
||||
public var id: Int { startMilliseconds }
|
||||
|
||||
public var isIntro: Bool { kind == .intro }
|
||||
public var isCredits: Bool { kind == .credits }
|
||||
}
|
||||
|
||||
/// Ein zusätzliches Bild zu einem Titel.
|
||||
public struct MediaArtwork: Sendable, Equatable, Codable {
|
||||
public enum Kind: String, Sendable, Codable {
|
||||
case poster, background, banner, clearLogo, unknown
|
||||
}
|
||||
|
||||
public let kind: Kind
|
||||
public let path: String
|
||||
|
||||
public init(kind: Kind, path: String) {
|
||||
self.kind = kind
|
||||
self.path = path
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user