diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index a2764f6..f967c05 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -34,6 +34,7 @@ "isCommentAutoGenerated": true }, "action.addFavourite": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -203,6 +204,7 @@ } }, "action.removeFavourite": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1891,6 +1893,7 @@ } }, "library.kind.liveTV": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1907,6 +1910,7 @@ } }, "library.kind.movie": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1923,6 +1927,7 @@ } }, "library.kind.music": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1939,6 +1944,7 @@ } }, "library.kind.other": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1955,6 +1961,7 @@ } }, "library.kind.photo": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1971,6 +1978,7 @@ } }, "library.kind.show": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -2492,6 +2500,7 @@ } }, "player.jellyfinPending": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -3250,6 +3259,7 @@ } }, "settings.account": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -3687,6 +3697,22 @@ } } }, + "settings.scanExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Suchlauf durchsucht die Ordner auf dem Server nach neuen Dateien. Das kann einige Minuten dauern." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "A scan looks through the folders on the server for new files. It can take a few minutes." + } + } + } + }, "settings.server": { "extractionState": "stale", "localizations": { @@ -3705,6 +3731,7 @@ } }, "settings.serverKind": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -3992,6 +4019,7 @@ } }, "signIn.jellyfin.addressMissing": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -4008,6 +4036,7 @@ } }, "signIn.jellyfin.addressPlaceholder": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -4040,6 +4069,7 @@ } }, "signIn.jellyfin.codeExpired": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -4088,6 +4118,7 @@ } }, "signIn.jellyfin.notFound": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -4104,6 +4135,7 @@ } }, "signIn.jellyfin.otherAddress": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -4120,6 +4152,7 @@ } }, "signIn.jellyfin.password": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -4184,6 +4217,7 @@ } }, "signIn.jellyfin.searching": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -4200,6 +4234,7 @@ } }, "signIn.jellyfin.signingIn": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -4248,6 +4283,7 @@ } }, "signIn.jellyfin.useCode": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -4264,6 +4300,7 @@ } }, "signIn.jellyfin.usePassword": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -4280,6 +4317,7 @@ } }, "signIn.jellyfin.userName": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -4345,6 +4383,7 @@ } }, "signIn.tagline": { + "comment": "Untertitel auf dem Anmeldebildschirm", "localizations": { "de": { "stringUnit": { @@ -4358,8 +4397,7 @@ "value": "Your library, nicer." } } - }, - "comment": "Untertitel auf dem Anmeldebildschirm" + } }, "signIn.timedOut": { "comment": "Die Anmeldung wurde nicht innerhalb der Gültigkeit des Codes abgeschlossen", diff --git a/Flixy/Views/Home/HeroCarousel.swift b/Flixy/Views/Home/HeroCarousel.swift new file mode 100644 index 0000000..cc08d71 --- /dev/null +++ b/Flixy/Views/Home/HeroCarousel.swift @@ -0,0 +1,74 @@ +import SwiftUI +import FlixyDesign +import MediaKit + +/// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln. +/// +/// Ein einzelnes Bild sagt jedes Mal dasselbe. Hier wandert der Aufmacher +/// alle paar Sekunden weiter und lässt sich auch von Hand durchblättern; die +/// Punkte darunter zeigen, wie viele es sind und wo man gerade ist. +/// +/// Das Weiterwandern hält an, sobald jemand selbst blättert — der Zeitgeber +/// hängt an der aktuellen Seite und fängt bei jedem Wechsel von vorn an. +/// Sonst rutschte der Aufmacher genau in dem Moment weiter, in dem man sich +/// einen Titel ansieht. +struct HeroCarousel: View { + let items: [MediaItem] + + /// Wie lange ein Titel stehen bleibt. + /// + /// Lang genug, um Titel, Beschreibung und Knöpfe zu lesen — bei drei + /// Sekunden ist der Satz zu Ende, bevor man ihn gelesen hat. + private static let dwell: Duration = .seconds(7) + + @State private var index = 0 + /// Bewegung, die von selbst losgeht, ist für manche Menschen unangenehm + /// bis unbrauchbar. Wer sie abgestellt hat, blättert von Hand. + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.flixyAccent) private var accent + + var body: some View { + VStack(spacing: FlixySpacing.regular) { + TabView(selection: $index) { + ForEach(Array(items.enumerated()), id: \.element.id) { position, item in + HeroSection(item: item) + .tag(position) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + // Die eigene Anzeige des TabView läge über dem Bild und damit über + // den Knöpfen. Die Höhe kommt aus dem Seitenverhältnis, weil ein + // TabView sonst nimmt, was es kriegen kann. + .aspectRatio(FlixyAspectRatio.hero, contentMode: .fit) + + if items.count > 1 { indicator } + } + .task(id: index) { await advance() } + .onChange(of: items.map(\.id)) { _, ids in + // Nach dem Aktualisieren kann die Liste kürzer sein als die + // Stelle, an der man gerade war. + if index >= ids.count { index = 0 } + } + } + + private var indicator: some View { + HStack(spacing: FlixySpacing.snug) { + ForEach(items.indices, id: \.self) { position in + Capsule() + .fill(position == index ? accent : Color.flixyTextMuted.opacity(0.4)) + .frame(width: position == index ? 18 : 6, height: 6) + } + } + .animation(.snappy(duration: 0.25), value: index) + .accessibilityHidden(true) + } + + private func advance() async { + guard items.count > 1, !reduceMotion else { return } + try? await Task.sleep(for: Self.dwell) + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: 0.5)) { + index = (index + 1) % items.count + } + } +} diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index e04c570..6f3a4f1 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -58,8 +58,9 @@ struct HomeView: View { private func content(hubs: [MediaRowGroup]) -> some View { ScrollView { LazyVStack(alignment: .leading, spacing: FlixySpacing.section) { - if let featured = heroItem(in: hubs) { - HeroSection(item: featured) + let featured = heroItems(in: hubs) + if !featured.isEmpty { + HeroCarousel(items: featured) } ForEach(hubs) { hub in @@ -71,14 +72,26 @@ struct HomeView: View { .scrollIndicators(.hidden) } + /// Wie viele Titel der Aufmacher durchwechselt. + private static let heroCount = 5 + /// Was oben groß gezeigt wird. /// - /// Erste Wahl ist etwas Angefangenes — wer die App öffnet, will meistens - /// weiterschauen. Erst wenn es nichts Angefangenes gibt, tritt der - /// Neuzugang an die Stelle. - private func heroItem(in hubs: [MediaRowGroup]) -> MediaItem? { - let continuing = hubs.first(where: \.isContinueWatching) - return continuing?.items.first ?? hubs.first?.items.first + /// Angefangenes zuerst — wer die App öffnet, will meistens weiterschauen. + /// Aufgefüllt wird aus den übrigen Reihen: bei einer einzigen angefangenen + /// Folge gäbe es sonst nichts zu blättern. + private func heroItems(in hubs: [MediaRowGroup]) -> [MediaItem] { + let continuing = hubs.first(where: \.isContinueWatching)?.items ?? [] + let rest = hubs.filter { !$0.isContinueWatching }.flatMap(\.items) + + var seen = Set() + let candidates = (continuing + rest).filter { seen.insert($0.id).inserted } + + // Ohne Hintergrundbild ist der Aufmacher eine graue Fläche mit Text. + // Lieber einen Titel überspringen als eine leere Bühne zeigen — es sei + // denn, keiner hätte eines. + let withArtwork = candidates.filter { $0.art != nil || $0.seriesArt != nil } + return Array((withArtwork.isEmpty ? candidates : withArtwork).prefix(Self.heroCount)) } private func load() async { diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index 90cb736..2eaef02 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -15,7 +15,6 @@ struct LibraryView: View { @State private var page: MediaPage? @State private var isLoadingMore = false @State private var showsFilters = false - @State private var isScanning = false var body: some View { Group { @@ -142,37 +141,9 @@ struct LibraryView: View { .accessibilityLabel(Text("library.toggleLayout")) } - ToolbarItem(placement: .topBarTrailing) { - Menu { - Button { - Task { await scan() } - } label: { - Label("library.scan", systemImage: "arrow.triangle.2.circlepath") - } - .disabled(isScanning) - } label: { - Image(systemName: "ellipsis.circle") - } - } - SearchToolbarItem() } - /// Stößt einen Suchlauf auf dem Server an. - /// - /// Der Server meldet nicht, wann er fertig ist — die Liste wird deshalb - /// nach kurzer Wartezeit neu geholt. Wer gerade Dateien abgelegt hat, - /// sieht sie damit meistens schon. - private func scan() async { - guard let server = session.backend, !isScanning else { return } - isScanning = true - defer { isScanning = false } - - try? await server.refreshLibrary(library) - try? await Task.sleep(for: .seconds(3)) - await reload() - } - // MARK: - Laden private func reload() async { diff --git a/Flixy/Views/Navigation/DrawerContainer.swift b/Flixy/Views/Navigation/DrawerContainer.swift index fe95270..7a93288 100644 --- a/Flixy/Views/Navigation/DrawerContainer.swift +++ b/Flixy/Views/Navigation/DrawerContainer.swift @@ -35,7 +35,10 @@ struct DrawerContainer: View { if openness > 0 { Color.flixyScrim - .opacity(openness) + // Schwächer als früher: die Leiste darüber ist jetzt + // durchscheinend, und ein voller Schleier machte aus dem + // Milchglas wieder eine Wand. + .opacity(openness * 0.7) .ignoresSafeArea() .onTapGesture { setOpen(false) } // Ohne diese Beschriftung ist die Fläche für VoiceOver ein @@ -48,7 +51,6 @@ struct DrawerContainer: View { sidebar .frame(width: width) .frame(maxHeight: .infinity) - .background(Color.flixySurface) .offset(x: (openness - 1) * width) .accessibilityHidden(!isOpen) } diff --git a/Flixy/Views/Navigation/SidebarView.swift b/Flixy/Views/Navigation/SidebarView.swift index be21fb2..2a64640 100644 --- a/Flixy/Views/Navigation/SidebarView.swift +++ b/Flixy/Views/Navigation/SidebarView.swift @@ -40,7 +40,10 @@ struct SidebarView: View { .padding(.vertical, FlixySpacing.section) } .scrollBounceBehavior(.basedOnSize) - .background(Color.flixySurface) + // Milchig statt undurchsichtig: der Inhalt dahinter bleibt zu ahnen, + // und die Leiste wirkt wie eine Schicht darüber statt wie eine zweite + // Seite daneben. Ein Vollton stand vorher wie eine Wand im Bild. + .background(.ultraThinMaterial) } private var header: some View { diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index 254c356..15a2440 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -39,9 +39,19 @@ struct SettingsView: View { } } - Section("settings.libraries") { + Section { ForEach(session.libraries) { library in - LabeledContent(library.title, value: library.kind.label) + LibraryRow(library: library) + } + } header: { + Text("settings.libraries") + } footer: { + // Der Suchlauf stand vorher im Dreipunktmenü der Mediathek + // — einen Tipp neben „Filter" und „Ansicht wechseln". Er + // beschäftigt den Server minutenlang und gehört nicht in + // eine Reihe mit Dingen, die man im Vorbeigehen antippt. + if session.backend?.capabilities.contains(.libraryScan) == true { + Text("settings.scanExplanation") } } } @@ -233,3 +243,66 @@ extension MediaLibrary.Kind { } } } + +/// Eine Mediathek in den Einstellungen, mit dem Suchlauf daneben. +private struct LibraryRow: View { + let library: MediaLibrary + + @Environment(FlixySession.self) private var session + @Environment(\.flixyAccent) private var accent + @State private var phase: Phase = .idle + + private enum Phase { case idle, running, done } + + var body: some View { + LabeledContent { + HStack(spacing: FlixySpacing.regular) { + // Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens + // „Filme" stand sonst als „Filme — Filme" da. + if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame { + Text(library.kind.label) + .foregroundStyle(Color.flixyTextSecondary) + } + + if session.backend?.capabilities.contains(.libraryScan) == true { + Button { + Task { await scan() } + } label: { + symbol + } + .buttonStyle(.borderless) + .disabled(phase == .running) + .accessibilityLabel(Text("library.scan")) + } + } + } label: { + Text(library.title) + } + } + + @ViewBuilder + private var symbol: some View { + switch phase { + case .idle: + Image(systemName: "arrow.triangle.2.circlepath") + case .running: + ProgressView().controlSize(.small) + case .done: + Image(systemName: "checkmark").foregroundStyle(accent) + } + } + + /// Stößt einen Suchlauf auf dem Server an. + /// + /// Der Server meldet nicht, wann er fertig ist. Deshalb steht hier kein + /// Fortschritt, sondern nur ein Haken dafür, dass der Auftrag angekommen + /// ist — ein Balken, der etwas anderes behauptet, wäre erfunden. + private func scan() async { + guard let backend = session.backend, phase != .running else { return } + phase = .running + try? await backend.refreshLibrary(library) + phase = .done + try? await Task.sleep(for: .seconds(4)) + phase = .idle + } +} diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift index 57c1ab3..119f273 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift @@ -56,14 +56,7 @@ public struct JellyfinBackend: MediaBackend { // entscheidet. // „Zuletzt hinzugefügt" wird streng geholt: scheitert es, ist der // Server nicht erreichbar, und dann gehört eine Fehlermeldung hin. - let latest = try await rowItems(path: "/Users/\(account.userID)/Items", query: [ - URLQueryItem(name: "SortBy", value: "DateCreated"), - URLQueryItem(name: "SortOrder", value: "Descending"), - URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series"), - URLQueryItem(name: "Recursive", value: "true"), - URLQueryItem(name: "Limit", value: "20"), - URLQueryItem(name: "Fields", value: Self.fields), - ]) + let latest = try await latestItems(limit: 20) // Die beiden anderen nachsichtig: fällt „Als Nächstes" aus, soll // deshalb nicht die ganze Startseite leer bleiben. @@ -322,6 +315,32 @@ public struct JellyfinBackend: MediaBackend { // MARK: - Hilfen + /// Die Neuzugänge, wie der Server sie selbst versteht. + /// + /// Ein eigener Endpunkt statt einer Liste, sortiert nach Anlegedatum — + /// und das aus zwei Gründen. Der erste ist die Sortierung selbst: sie kam + /// alphabetisch zurück, nicht nach Datum. Der zweite wiegt schwerer und + /// bliebe auch bestehen, wenn die Sortierung griffe: das Anlegedatum einer + /// **Serie** ist der Tag, an dem sie in die Mediathek kam. Eine Serie, die + /// seit zwei Jahren dort steht und gestern eine neue Folge bekommen hat, + /// ist neu — nach Anlegedatum wäre sie es nie. + /// + /// Die Antwort ist hier ausnahmsweise ein nacktes Feld, kein Objekt mit + /// `Items` darin. + private func latestItems(limit: Int) async throws(MediaBackendError) -> [MediaItem] { + let items = try await client.send( + JellyfinRequest(path: "/Users/\(account.userID)/Items/Latest", query: [ + URLQueryItem(name: "Limit", value: String(limit)), + URLQueryItem(name: "Fields", value: Self.fields), + // Neue Folgen erscheinen als ihre Serie statt als zwölf + // Einzelkacheln, die alle dasselbe Plakat tragen. + URLQueryItem(name: "GroupItems", value: "true"), + ]), + as: [JellyfinItem].self + ) + return items.map { $0.asMediaItem(imageBase: images) } + } + private func rowItems( path: String, query: [URLQueryItem] diff --git a/Packages/FlixyKit/Tests/JellyfinKitTests/TranslationTests.swift b/Packages/FlixyKit/Tests/JellyfinKitTests/TranslationTests.swift index 25d3c75..07097fd 100644 --- a/Packages/FlixyKit/Tests/JellyfinKitTests/TranslationTests.swift +++ b/Packages/FlixyKit/Tests/JellyfinKitTests/TranslationTests.swift @@ -154,6 +154,22 @@ struct TranslationTests { #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 — diff --git a/Tools/JellyfinStub/README.md b/Tools/JellyfinStub/README.md new file mode 100644 index 0000000..d049c47 --- /dev/null +++ b/Tools/JellyfinStub/README.md @@ -0,0 +1,22 @@ +# Jellyfin-Attrappe + +Ein Server, der so tut, als wäre er Jellyfin — gerade so viel, dass die App +sich anmelden und stöbern kann. Gedacht fürs Ansehen der Oberfläche, nicht +fürs Prüfen der Protokollschicht; dafür gibt es Tests mit aufgezeichneten +Antworten. + +```bash +python3 Tools/JellyfinStub/server.py +``` + +Dann in der App **Mit Jellyfin anmelden** → `127.0.0.1` eintragen. Quick +Connect bestätigt sich nach drei Abfragen von selbst, damit der Bildschirm mit +dem Code sichtbar wird und die Anmeldung trotzdem durchläuft. + +Die Bilder sind einfarbige Flächen, deren Farbe aus der Kennung des Titels +entsteht. Das ist Absicht: so lässt sich auf einen Blick erkennen, ob der +Aufmacher tatsächlich weiterwandert und ob eine Kachel das Bild zeigt, das zu +ihr gehört. + +Kein echter Server wird dabei angefasst, und es gehen keine Zugangsdaten über +die Leitung. diff --git a/Tools/JellyfinStub/server.py b/Tools/JellyfinStub/server.py new file mode 100644 index 0000000..66ee29b --- /dev/null +++ b/Tools/JellyfinStub/server.py @@ -0,0 +1,136 @@ +"""Ein Jellyfin-Attrappenserver — gerade so viel, dass die App anmelden und +stöbern kann. Nur zum Ansehen der Oberfläche; kein echter Server wird berührt.""" +import json, struct, zlib, hashlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qs + +USER_ID = "user-1" +TOKEN = "stub-token" + +TITLES = [ + ("m1", "Der weite Himmel", "Movie", "Ein Mann, ein Berg, zwei Stunden Schweigen."), + ("m2", "Nachtzug nach Genf", "Movie", "Krimi in engen Abteilen."), + ("s1", "Küstenwache", "Series", "Zwölf Folgen Nieselregen und Verdacht."), + ("m3", "Die Kartografin", "Movie", "Sie zeichnet Länder, die es nicht gibt."), + ("s2", "Hinter dem Deich", "Series", "Eine Familie, ein Hof, drei Staffeln."), + ("m4", "Letzter Sommer", "Movie", "Zwei Wochen, die alles ändern."), + ("m5", "Kalte Küste", "Movie", "Ein Leuchtturm und eine Frage zu viel."), +] + +def item(key, name, kind, overview, *, resume=None, series=None): + d = { + "Id": key, "Name": name, "Type": kind, "Overview": overview, + "ProductionYear": 2021 + (len(name) % 4), + "RunTimeTicks": (90 + len(name)) * 60 * 10_000_000, + "OfficialRating": "12", + "ImageTags": {"Primary": "p-" + key}, + "BackdropImageTags": ["b-" + key], + "Genres": ["Drama"], + "UserData": {"PlayCount": 0, "Played": False, "PlaybackPositionTicks": 0}, + } + if resume: + d["UserData"]["PlaybackPositionTicks"] = int(d["RunTimeTicks"] * resume) + if series: + d.update({"SeriesId": series[0], "SeriesName": series[1], + "ParentIndexNumber": 1, "IndexNumber": series[2], + "SeriesPrimaryImageTag": "p-" + series[0]}) + return d + +def page(items): + return {"Items": items, "TotalRecordCount": len(items), "StartIndex": 0} + +def png(colour, width=960, height=540): + """Eine einfarbige Fläche. Reicht, um zu sehen, dass das Bild wechselt.""" + raw = b"".join(b"\x00" + bytes(colour) * width for _ in range(height)) + def chunk(tag, data): + return struct.pack(">I", len(data)) + tag + data + struct.pack( + ">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + return (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 6)) + + chunk(b"IEND", b"")) + +def colour_for(key): + h = hashlib.sha1(key.encode()).digest() + # Gedeckte Töne, damit weiße Schrift darauf lesbar bleibt. + return (40 + h[0] % 90, 40 + h[1] % 90, 50 + h[2] % 90) + +POLLS = {"count": 0} + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def reply(self, payload, content_type="application/json"): + body = payload if isinstance(payload, bytes) else json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + url = urlparse(self.path) + p, q = url.path, parse_qs(url.query) + + if p == "/System/Info/Public": + return self.reply({"ServerName": "Attrappe", "Version": "10.10.3", + "Id": "stub", "StartupWizardCompleted": True}) + if p == "/QuickConnect/Enabled": + return self.reply(b"true", "text/plain") + if p == "/QuickConnect/Connect": + POLLS["count"] += 1 + # Nach drei Abfragen gilt der Code als bestätigt — lange genug, + # um den Bildschirm mit dem Code zu sehen. + return self.reply({"Authenticated": POLLS["count"] >= 3, + "Secret": "s3cr3t", "Code": "428193"}) + if p == f"/Users/{USER_ID}/Views": + return self.reply(page([ + {"Id": "lib-movies", "Name": "Filme", "Type": "CollectionFolder", + "CollectionType": "movies"}, + {"Id": "lib-shows", "Name": "Serien", "Type": "CollectionFolder", + "CollectionType": "tvshows"}, + ])) + if p == f"/Users/{USER_ID}/Items/Latest": + return self.reply([item(*t) for t in TITLES[:6]]) + if p == f"/Users/{USER_ID}/Items/Resume": + return self.reply(page([ + item("e1", "Der Nebel kommt", "Episode", "Erste Folge, letzte Ruhe.", + resume=0.4, series=("s1", "Küstenwache", 3)), + item("m2", TITLES[1][1], "Movie", TITLES[1][3], resume=0.2), + ])) + if p == "/Shows/NextUp": + return self.reply(page([ + item("e2", "Das Wrack", "Episode", "Was der Sturm freilegt.", + series=("s1", "Küstenwache", 4)), + ])) + if p.startswith("/Items/") and "/Images/" in p: + key = p.split("/")[2] + return self.reply(png(colour_for(key)), "image/png") + if p == f"/Users/{USER_ID}/Items": + parent = q.get("ParentId", [""])[0] + kind = "Series" if parent == "lib-shows" else "Movie" + chosen = [t for t in TITLES if t[2] == kind] or TITLES + return self.reply(page([item(*t) for t in chosen])) + if p == "/Genres": + return self.reply(page([{"Id": "g1", "Name": "Drama", "Type": "Genre"}])) + return self.reply(page([])) + + def do_POST(self): + url = urlparse(self.path) + length = int(self.headers.get("Content-Length") or 0) + self.rfile.read(length) + if url.path in ("/Users/AuthenticateWithQuickConnect", "/Users/AuthenticateByName"): + return self.reply({ + "AccessToken": TOKEN, + "User": {"Id": USER_ID, "Name": "Testkonto", + "Policy": {"IsAdministrator": True}}, + }) + if url.path == "/QuickConnect/Initiate": + POLLS["count"] = 0 + return self.reply({"Secret": "s3cr3t", "Code": "428193"}) + return self.reply({}) + +ThreadingHTTPServer(("127.0.0.1", 8096), Handler).serve_forever()