Files
Flaro/Packages/FlixyKit/Sources/MediaKit/HeroSelection.swift
T
ScarriffleandClaude Opus 5 dc93d6e22e Der Aufmacher lässt sich auf Mediatheken einschränken
Mehrfachauswahl in den Einstellungen unter „Startseite": welche
Mediatheken den Aufmacher speisen dürfen. Haken statt Auswahlliste —
eine Liste könnte nur eine, und „keine" wäre dort ein Eintrag, den man
versehentlich trifft.

Dafür musste eine Reihe sagen können, woher sie kommt: `MediaRowGroup`
trägt jetzt eine `libraryID`. Jellyfin setzt sie an den „Neu in …"-Reihen
— die baut die App ohnehin je Mediathek.

Zwei Regeln, und beide sind Entscheidungen, keine Nebenwirkungen:

**Leer heisst alle.** Wer nichts ankreuzt, meint „mir ist es gleich",
nicht „nimm nichts". Ein leerer Aufmacher wäre der Fehler, den niemand
mit dieser Einstellung in Verbindung brächte.

**Reihen ohne Mediathek bleiben immer drin.** Weiterschauen, Als Nächstes
und Favoriten gehen quer durch alle, und aus welcher Mediathek eine
angefangene Folge stammt, sagt der Server an keiner Stelle. Sie
wegzuwerfen hiesse, jemandem das Angefangene zu nehmen, weil er
„Musikvideos" abgewählt hat. Der Hinweis unter der Auswahl sagt das auch
dem, der nicht in den Quelltext sieht.

Eingeschränkt wird **nur der Aufmacher**, nicht die Startseite: die
Reihe selbst bleibt stehen. Wer sie ganz loswerden will, blendet sie aus
— das ist eine andere Frage und hat ihren eigenen Schalter.

Fünf Tests an `HeroSelection.rows(_:limitedTo:)` halten die Regeln fest,
darunter der Fall einer gespeicherten Kennung, die es nicht mehr gibt.

**Plex bleibt aussen vor:** dort kommen die Reihen als fertige Hubs vom
Server, und `librarySectionID` wird in `PlexHub` nicht gelesen. Die
Auswahl schränkt dort also nichts ein. Nachzuholen, wenn Plex wieder
dran ist.

iOS und tvOS bauen, 468 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 08:24:09 +02:00

107 lines
4.8 KiB
Swift

import Foundation
/// Welche Titel oben gross gezeigt werden — und in welcher Reihenfolge.
///
/// **Warum es das als eigenen Typ gibt.** Die Auswahl soll sich zufällig
/// anfühlen und trotzdem stabil sein: dreimal am Abend dieselbe, am nächsten
/// Morgen eine andere. Das ist genau die Art Regel, die man nicht im
/// laufenden Betrieb beurteilen kann — „es wechselt zu oft" ist kein Befund,
/// den man debuggen könnte. Mit einer Liste hinein und einer heraus schon.
///
/// **Der Fehler, der es hierher gebracht hat.** Vorher wurde ein Zufallszahl-
/// erzeuger einmal je Zeitfenster aufgesetzt und dann Titel für Titel
/// abgefragt. Das Los hing damit an der **Position** in der Liste: kam ein
/// Titel dazu, verschwand einer, oder änderte sich der Fortschritt, bekamen
/// alle dahinter ein anderes Los — und der Aufmacher war nach jedem Neustart
/// ein anderer, obwohl das Zeitfenster dasselbe war.
///
/// Jetzt hängt das Los an der **Kennung** des Titels. Wer dazukommt, stört
/// niemanden; wer geht, hinterlässt eine Lücke und sonst nichts.
public enum HeroSelection {
/// Wie lange dieselbe Auswahl gilt.
public static let windowHours: Double = 6
/// Welche Reihen den Aufmacher überhaupt speisen dürfen.
///
/// **Leer heisst alle.** Eine Einstellung, bei der nichts angekreuzt ist,
/// soll nicht in einen leeren Aufmacher führen — „ich habe nichts
/// gewählt" heisst „mir ist es gleich", nicht „nimm nichts".
///
/// **Reihen ohne Mediathek bleiben immer drin.** Weiterschauen, Als
/// Nächstes und Favoriten gehen quer durch alle; welcher Mediathek eine
/// angefangene Folge entstammt, sagt der Server nicht. Sie deshalb
/// wegzuwerfen hiesse, dem Nutzer das Angefangene zu nehmen, weil er eine
/// Mediathek abgewählt hat, die damit nichts zu tun haben muss.
public static func rows(
_ rows: [MediaRowGroup],
limitedTo libraries: Set<String>
) -> [MediaRowGroup] {
guard !libraries.isEmpty else { return rows }
return rows.filter { row in
guard let libraryID = row.libraryID else { return true }
return libraries.contains(libraryID)
}
}
/// Ordnet die Titel, ohne die Reihenfolge fest vorzugeben.
///
/// **Ungleiche Lose.** Ohne Hintergrundbild ist der Aufmacher eine graue
/// Fläche mit Text, und ein Titel ohne eines gehört dort eigentlich nicht
/// hin. Ihn auszuschliessen wäre aber zu hart: bei einer Mediathek, in
/// der wenige Titel eines tragen, blieben immer dieselben drei übrig.
/// Also bekommt er ein schlechteres Los statt gar keines.
public static func ordered(
_ items: [MediaItem],
at time: Date = .now,
windowHours: Double = windowHours
) -> [MediaItem] {
let fenster = UInt64(max(0, time.timeIntervalSince1970 / (windowHours * 3_600)))
var bewertet: [(item: MediaItem, los: Double)] = []
bewertet.reserveCapacity(items.count)
for item in items {
bewertet.append((item, lot(for: item, window: fenster)))
}
// Bei gleichem Los die Kennung: zweimal dieselbe Eingabe soll zweimal
// dasselbe ergeben, sonst ist ein Fehler nicht wiederholbar.
bewertet.sort { links, rechts in
links.los == rechts.los ? links.item.id < rechts.item.id : links.los > rechts.los
}
return bewertet.map(\.item)
}
/// Das Los eines Titels in diesem Zeitfenster — zwischen 0 und 1.
static func lot(for item: MediaItem, window: UInt64) -> Double {
let hatBild = item.art != nil || item.seriesArt != nil
let gewicht = hatBild ? 1.0 : 0.2
return unitRandom(seed: stableHash(item.id) ^ window) * gewicht
}
/// Ein Streuwert, der über Programmläufe hinweg gleich bleibt.
///
/// **Nicht `hashValue`.** Swift streut Hashes je Programmlauf zufällig;
/// damit wäre die Auswahl nach jedem Start eine andere — also genau der
/// Fehler, der hier behoben werden soll, nur an einer anderen Stelle.
static func stableHash(_ text: String) -> UInt64 {
// FNV-1a: vier Zeilen, keine Abhängigkeit, und für das Mischen von
// ein paar Kacheln mehr als gut genug.
var hash: UInt64 = 0xCBF2_9CE4_8422_2325
for byte in text.utf8 {
hash ^= UInt64(byte)
hash = hash &* 0x0000_0100_0000_01B3
}
return hash
}
/// SplitMix64, auf 0..<1 gebracht.
static func unitRandom(seed: UInt64) -> Double {
var z = seed &+ 0x9E37_79B9_7F4A_7C15
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
z = z ^ (z >> 31)
// Die oberen 53 Bit: genau die, die ein `Double` verlustfrei trägt.
return Double(z >> 11) / Double(1 << 53)
}
}