Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,223 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import MediaKit
|
||||
@testable import JellyfinKit
|
||||
|
||||
/// Die Übersetzung der Jellyfin-Antworten in die neutrale Form.
|
||||
///
|
||||
/// Gegen aufgezeichnete Antworten, weil genau dort die Fallen liegen: Zeiten
|
||||
/// in Ticks, großgeschriebene Feldnamen, und Zählwerte, die das Gegenteil von
|
||||
/// dem meinen, was man erwartet.
|
||||
@Suite("Übersetzung aus Jellyfin")
|
||||
struct TranslationTests {
|
||||
|
||||
private func item(_ fixture: String) throws -> JellyfinItem {
|
||||
let url = try #require(Bundle.module.url(forResource: "Fixtures/\(fixture)", withExtension: "json"))
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let page = try decoder.decode(JellyfinItemPage.self, from: try Data(contentsOf: url))
|
||||
return try #require(page.items.first)
|
||||
}
|
||||
|
||||
private func media(_ fixture: String) throws -> MediaItem {
|
||||
try item(fixture).asMediaItem(imageBase: JellyfinImagePaths())
|
||||
}
|
||||
|
||||
@Test("Ticks werden zu Millisekunden")
|
||||
func convertsTicks() throws {
|
||||
let episode = try media("episode")
|
||||
|
||||
// 26.400.000.000 Ticks sind 44 Minuten. Zehntausend Ticks je
|
||||
// Millisekunde — verrechnet man sich hier um eine Null, zeigt die App
|
||||
// Laufzeiten von siebeneinhalb Stunden.
|
||||
#expect(episode.duration == 2_640_000)
|
||||
#expect(episode.resumeMilliseconds == 600_000)
|
||||
}
|
||||
|
||||
@Test("Der Fortschritt ergibt sich richtig")
|
||||
func computesProgress() throws {
|
||||
let episode = try media("episode")
|
||||
let progress = try #require(episode.watchProgress)
|
||||
|
||||
// Zehn Minuten von 44 — knapp 23 Prozent.
|
||||
#expect(abs(progress - 10.0 / 44.0) < 0.001)
|
||||
}
|
||||
|
||||
@Test("Serie, Staffel und Nummern kommen an")
|
||||
func translatesHierarchy() throws {
|
||||
let episode = try media("episode")
|
||||
|
||||
#expect(episode.kind == .episode)
|
||||
#expect(episode.title == "Der Anfang")
|
||||
#expect(episode.seriesTitle == "Eine Serie")
|
||||
#expect(episode.seriesID == "series-1")
|
||||
#expect(episode.parentTitle == "Staffel 2")
|
||||
#expect(episode.index == 3)
|
||||
#expect(episode.parentIndex == 2)
|
||||
}
|
||||
|
||||
@Test("Favoriten gibt es hier wirklich")
|
||||
func readsFavourite() throws {
|
||||
// Das Feld, das bei Plex immer false ist.
|
||||
#expect(try media("episode").isFavourite)
|
||||
}
|
||||
|
||||
@Test("Aus ungesehenen Folgen werden gesehene")
|
||||
func invertsUnplayedCount() throws {
|
||||
let series = try media("series")
|
||||
|
||||
// Jellyfin meldet **ungesehene** Folgen. Die Zahl, die die Oberfläche
|
||||
// zeigt, ist die andere: 24 minus 9 sind 15 gesehene. Ohne diese
|
||||
// Umkehrung stünde bei einer fast fertigen Serie „9 von 24".
|
||||
#expect(series.episodeCount == 24)
|
||||
#expect(series.playedEpisodeCount == 15)
|
||||
}
|
||||
|
||||
@Test("Bitrate kommt in Kilobit an, nicht in Bit")
|
||||
func convertsBitrate() throws {
|
||||
let version = try #require(try media("episode").versions.first)
|
||||
|
||||
// Jellyfin rechnet in Bit je Sekunde. Ungeprüft übernommen stünde in
|
||||
// der App „12000000 Mbit/s".
|
||||
#expect(version.bitrate == 12_000)
|
||||
#expect(version.qualityLabel.contains("12 Mbit/s"))
|
||||
}
|
||||
|
||||
@Test("Die Fassung trägt Codecs und Spuren")
|
||||
func translatesVersion() throws {
|
||||
let version = try #require(try media("episode").versions.first)
|
||||
let file = try #require(version.files.first)
|
||||
|
||||
#expect(version.container == "mkv")
|
||||
#expect(version.videoCodec == "hevc")
|
||||
#expect(version.pixelHeight == 2_160)
|
||||
#expect(version.resolutionLabel == "4K")
|
||||
// Die gewählte Tonspur ist die als Standard markierte.
|
||||
#expect(file.selectedAudioStream?.codec == "dts")
|
||||
#expect(file.subtitleStreams.count == 1)
|
||||
#expect(file.size == 5_368_709_120)
|
||||
}
|
||||
|
||||
@Test("Das Wiedergabe-Profil beschreibt, was iOS nicht kann")
|
||||
func buildsProfile() throws {
|
||||
let version = try #require(try media("episode").versions.first)
|
||||
let profile = version.profile(file: version.files.first)
|
||||
|
||||
// MKV mit DTS: genau der Fall, für den es den zweiten Player gibt.
|
||||
#expect(profile.container == "mkv")
|
||||
#expect(profile.audioCodec == "dts")
|
||||
#expect(profile.hasKnownStreams)
|
||||
}
|
||||
|
||||
@Test("Besetzung und Regie werden getrennt")
|
||||
func separatesPeople() throws {
|
||||
let episode = try media("episode")
|
||||
|
||||
#expect(episode.cast.count == 1)
|
||||
#expect(episode.cast.first?.role == "Die Hauptrolle")
|
||||
#expect(episode.directors == ["Wer Regie führte"])
|
||||
#expect(episode.genres == ["Drama", "Mystery"])
|
||||
}
|
||||
|
||||
@Test("Bildadressen tragen ihre Marke")
|
||||
func buildsImagePaths() throws {
|
||||
let episode = try media("episode")
|
||||
|
||||
// Ohne die Marke liefert der Server zwar ein Bild, aber der
|
||||
// Zwischenspeicher merkt nicht, wenn es sich ändert.
|
||||
#expect(episode.thumb == "/Items/a1b2c3d4e5f6/Images/Primary?tag=tag-primary")
|
||||
#expect(episode.clearLogoPath == "/Items/a1b2c3d4e5f6/Images/Logo?tag=tag-logo")
|
||||
#expect(episode.seriesThumb == "/Items/series-1/Images/Primary?tag=tag-series")
|
||||
#expect(episode.art == "/Items/a1b2c3d4e5f6/Images/Backdrop?tag=tag-backdrop")
|
||||
}
|
||||
|
||||
@Test("Ohne eigenes Hintergrundbild kommt es von der Serie")
|
||||
func borrowsBackdropFromParent() throws {
|
||||
let item = try decode("""
|
||||
{"Id": "ohne-bild", "Name": "Folge ohne Bild", "Type": "Episode",
|
||||
"ParentBackdropItemId": "series-1"}
|
||||
""")
|
||||
|
||||
// Die Bildadressen entstehen aus der Kennung, nicht aus einer Antwort
|
||||
// des Servers. Ohne diese Unterscheidung stünde hinter jeder Folge
|
||||
// eine Adresse, die mit „nicht gefunden" antwortet — und das Feld, das
|
||||
// Jellyfin dafür mitschickt, wäre totes Gewicht.
|
||||
#expect(item.art == "/Items/series-1/Images/Backdrop")
|
||||
}
|
||||
|
||||
@Test("Gibt es weder eigenes noch geliehenes Bild, bleibt das Feld leer")
|
||||
func leavesBackdropEmpty() throws {
|
||||
let item = try decode("""
|
||||
{"Id": "ganz-ohne", "Name": "Nichts da", "Type": "Movie"}
|
||||
""")
|
||||
|
||||
#expect(item.art == nil)
|
||||
}
|
||||
|
||||
@Test("Die Neuzugänge kommen als nacktes Feld, nicht als Objekt")
|
||||
func decodesBareArray() throws {
|
||||
// `/Users/{id}/Items/Latest` antwortet anders als jede andere Liste:
|
||||
// ohne `Items`-Hülle und ohne Gesamtzahl. Wer das übersieht, bekommt
|
||||
// einen Entschlüsselungsfehler statt einer Reihe — und die Startseite
|
||||
// bliebe leer, ohne dass klar wäre, warum.
|
||||
let json = """
|
||||
[{"Id": "neu-1", "Name": "Frisch dabei", "Type": "Movie"},
|
||||
{"Id": "neu-2", "Name": "Auch neu", "Type": "Series"}]
|
||||
"""
|
||||
let items = try JSONDecoder().decode([JellyfinItem].self, from: Data(json.utf8))
|
||||
|
||||
#expect(items.map(\.id) == ["neu-1", "neu-2"])
|
||||
#expect(items.map { $0.asMediaItem(imageBase: JellyfinImagePaths()).kind } == [.movie, .show])
|
||||
}
|
||||
|
||||
/// Ein einzelner Eintrag, wie er über die Leitung käme.
|
||||
///
|
||||
/// Für die Fälle, die in den aufgezeichneten Antworten nicht vorkommen —
|
||||
/// eine Folge ohne eigenes Bild etwa. Eine weitere Datei dafür anzulegen
|
||||
/// verstellt den Blick darauf, worum es im Test geht.
|
||||
private func decode(_ json: String) throws -> MediaItem {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let item = try decoder.decode(JellyfinItem.self, from: Data(json.utf8))
|
||||
return item.asMediaItem(imageBase: JellyfinImagePaths())
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Die Art einer Mediathek")
|
||||
struct LibraryKindTests {
|
||||
|
||||
@Test(
|
||||
"Die bekannten Arten",
|
||||
arguments: [
|
||||
("movies", MediaLibrary.Kind.movie),
|
||||
("tvshows", .show),
|
||||
("music", .music),
|
||||
("photos", .photo),
|
||||
("livetv", .liveTV),
|
||||
]
|
||||
)
|
||||
func mapsKnownTypes(type: String, expected: MediaLibrary.Kind) {
|
||||
#expect(MediaLibrary.Kind(jellyfinCollectionType: type) == expected)
|
||||
}
|
||||
|
||||
/// **Der Fall, der eine ganze Mediathek verschwinden liess.** Jellyfin
|
||||
/// nennt „Heimvideos & Fotos" `homevideos`; weil Fotos im Namen stehen,
|
||||
/// lag es bei den Fotos — und fiel damit unter `isSupported` heraus. Die
|
||||
/// Mediathek war in der App nicht vorhanden, nicht einmal in der Auswahl
|
||||
/// für den Aufmacher.
|
||||
@Test("Heimvideos sind Videos, keine Fotos")
|
||||
func homeVideosAreWatchable() {
|
||||
let art = MediaLibrary.Kind(jellyfinCollectionType: "homevideos")
|
||||
#expect(art == .other)
|
||||
#expect(MediaLibrary(id: "1", title: "Videos", kind: art).isSupported)
|
||||
}
|
||||
|
||||
/// Eine gemischte Mediathek meldet gar keine Art. Sie deshalb
|
||||
/// wegzulassen hiesse, den häufigsten selbstgebauten Fall zu übersehen.
|
||||
@Test("Ohne Angabe bleibt sie trotzdem da")
|
||||
func unknownStays() {
|
||||
let art = MediaLibrary.Kind(jellyfinCollectionType: nil)
|
||||
#expect(art == .other)
|
||||
#expect(MediaLibrary(id: "1", title: "Gemischtes", kind: art).isSupported)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user