Compare commits
82
Commits
2bd13f0f19
..
main
@@ -36,6 +36,9 @@ struct FlaroApp: App {
|
||||
@State private var network = NetworkConditions()
|
||||
@State private var homeLayout = HomeLayoutSettings()
|
||||
@State private var diagnostics = PlaybackDiagnostics()
|
||||
/// Fortschritt, der den Server nicht erreicht hat — siehe
|
||||
/// `Fortschrittspost`. Hier, weil er die Wiedergabe überleben muss.
|
||||
@State private var post = Fortschrittspost.shared
|
||||
@State private var downloads: DownloadCoordinator
|
||||
|
||||
private let modelContainer: ModelContainer
|
||||
@@ -92,6 +95,7 @@ struct FlaroApp: App {
|
||||
.environment(downloads)
|
||||
.environment(homeLayout)
|
||||
.environment(diagnostics)
|
||||
.environment(post)
|
||||
.environment(theme)
|
||||
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
|
||||
//
|
||||
@@ -117,6 +121,8 @@ struct FlaroApp: App {
|
||||
backend: session.backend,
|
||||
coordinator: downloads
|
||||
).refill()
|
||||
// Was im Flugzeug liegengeblieben ist, geht jetzt raus.
|
||||
await post.zustellen(über: session.backend)
|
||||
}
|
||||
}
|
||||
.modelContainer(modelContainer)
|
||||
|
||||
@@ -25,6 +25,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@: %@": {
|
||||
"comment": "A label that shows the audio or subtitle language. The argument is the language name.",
|
||||
"isCommentAutoGenerated": true,
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "%1$@: %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"4K": {
|
||||
"comment": "A label for a 4K video.",
|
||||
"isCommentAutoGenerated": true
|
||||
@@ -1241,6 +1253,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.episodeCount %lld": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld Folgen geladen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld episodes downloaded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.failed": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -1722,7 +1750,7 @@
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Staffel %lld · Episode %lld"
|
||||
"value": "Staffel %lld · Folge %lld"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
@@ -1886,6 +1914,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter.downloadedOnly": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nur Heruntergeladene"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Downloaded only"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter.filters": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -2122,6 +2167,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"home.downloaded": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Heruntergeladen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Downloaded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"home.empty.description": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -2339,6 +2400,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"home.offline": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Kein Server erreichbar. Was hier steht, stammt vom letzten Mal — ansehen lässt sich nur, was auf dem Gerät liegt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "No server reachable. What you see is from last time — only what is on this device can be played."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"home.rowsHeader": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -2468,6 +2545,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.audioShort": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Ton"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Audio"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.avoidAudioDescription": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
@@ -2621,6 +2715,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.forThisTitle": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tonsprache für diesen Titel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Audio language for this title"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.headerExplanation": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -2655,6 +2766,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.none": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Keine"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "None"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.partial %@": {
|
||||
"comment": "Eine kleine Schriftart, die",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"languages.partial %lld": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nur Sprachen, die in jeder Folge vorkommen. %lld weitere gibt es nur in einem Teil."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Only languages present in every episode. %lld more exist in some episodes only."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.serverDefault": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -2704,6 +2853,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.subtitlesForThisSeries": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Untertitel für diese Serie"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subtitles for this series"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.subtitlesForThisTitle": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Untertitel für diesen Titel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subtitles for this title"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.subtitlesHeader": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -2736,6 +2919,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.subtitlesShort": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Untertitel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subtitles"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.title": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -3905,7 +4105,6 @@
|
||||
}
|
||||
},
|
||||
"player.nextEpisode": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -4041,6 +4240,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.previousEpisode": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Vorige Folge"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Previous episode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.quality": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -5039,6 +5254,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"season.short %@": {
|
||||
"comment": "A label that shows the number of the selected season.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"season.short %lld": {
|
||||
"comment": "Platzhalter: Staffelnummer",
|
||||
"extractionState": "stale",
|
||||
@@ -5912,6 +6131,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.skipExtraScenes": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Zusätzliche Szenen mit überspringen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Skip extra scenes too"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.storageLimit": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -7874,4 +8109,4 @@
|
||||
}
|
||||
},
|
||||
"version": "1.1"
|
||||
}
|
||||
}
|
||||
@@ -196,9 +196,65 @@ struct DownloadService {
|
||||
quality: DownloadQuality = .original,
|
||||
audioStream: MediaStream? = nil
|
||||
) throws(DownloadRefusal) -> DownloadItem {
|
||||
try coordinator.enqueue(
|
||||
let eintrag = try coordinator.enqueue(
|
||||
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
|
||||
)
|
||||
plakatHolen(for: item)
|
||||
return eintrag
|
||||
}
|
||||
|
||||
/// Holt das Plakat mit herunter.
|
||||
///
|
||||
/// **Ohne Verbindung gibt es keine Adresse mehr, von der eines käme.**
|
||||
/// Ein Download ohne Bild ist offline eine graue Fläche mit einem Titel
|
||||
/// darunter — und genau dann, wenn man etwas zum Ansehen sucht, ist das
|
||||
/// Bild die halbe Auskunft. Ein paar Kilobyte je Eintrag.
|
||||
///
|
||||
/// Nebenher und ohne Fehlerweg: scheitert es, fehlt ein Bild. Den
|
||||
/// Download deshalb scheitern zu lassen wäre die schlechtere Hälfte.
|
||||
func plakatHolen(for item: MediaItem) {
|
||||
if let adresse = backend?.rawImageURL(path: item.thumb) {
|
||||
Task { await coordinator.fetchArtwork(for: item.id, from: adresse) }
|
||||
}
|
||||
|
||||
// **Und das Plakat der Serie, unter deren Schlüssel.**
|
||||
//
|
||||
// `thumb` ist bei einer Folge ihr Szenenbild — quer, und hochkant
|
||||
// beschnitten zeigt es einen zufälligen Ausschnitt. Die Downloads
|
||||
// stehen aber als Serie in der Reihe, und eine Serienkachel ist ein
|
||||
// Plakat. Ohne diesen zweiten Griff gäbe es für die Serie gar kein
|
||||
// Bild: heruntergeladen wird sie nie, nur ihre Folgen.
|
||||
if let serie = item.seriesID,
|
||||
let pfad = item.seriesThumb ?? item.parentThumb,
|
||||
let adresse = backend?.rawImageURL(path: pfad) {
|
||||
Task { await coordinator.fetchArtwork(for: serie, from: adresse) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Holt Plakate nach, die früheren Downloads fehlen.
|
||||
///
|
||||
/// **Weil bis vor Kurzem gar keines mitkam.** Der Dateiname dafür stand
|
||||
/// im Modell, gesetzt hat ihn nie jemand — alles, was vorher
|
||||
/// heruntergeladen wurde, hat kein Bild und stünde in der neuen Reihe
|
||||
/// als graue Fläche.
|
||||
///
|
||||
/// Einmal je Serverwechsel, nur für die Einträge ohne Bild; danach
|
||||
/// findet die Schleife nichts mehr.
|
||||
func plakateNachholen() async {
|
||||
guard let backend else { return }
|
||||
for eintrag in coordinator.items where fehltEinBild(eintrag) {
|
||||
guard let titel = try? await backend.item(id: eintrag.ratingKey) else { continue }
|
||||
plakatHolen(for: titel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob zu diesem Eintrag noch ein Bild fehlt — seines oder das seiner
|
||||
/// Serie.
|
||||
private func fehltEinBild(_ eintrag: DownloadItem) -> Bool {
|
||||
if coordinator.artworkURL(forRatingKey: eintrag.ratingKey) == nil { return true }
|
||||
if let serie = eintrag.seriesKey,
|
||||
coordinator.artworkURL(forRatingKey: serie) == nil { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
|
||||
@@ -294,6 +350,7 @@ struct DownloadService {
|
||||
_ = try coordinator.enqueue(
|
||||
try plan(for: detailed, quality: quality, audioStream: ton)
|
||||
)
|
||||
plakatHolen(for: detailed)
|
||||
accepted += 1
|
||||
} catch DownloadRefusal.noDownloadableFile {
|
||||
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
|
||||
|
||||
@@ -283,6 +283,9 @@ final class FlaroSession {
|
||||
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
|
||||
// verloren.
|
||||
Task { await LibraryCache.shared.clearAll() }
|
||||
// Dasselbe gilt für den Fortschritt, der noch auf den Server
|
||||
// wartet: er gehört dem Konto, das gerade gegangen ist.
|
||||
Fortschrittspost.shared.leeren()
|
||||
phase = .signedOut
|
||||
}
|
||||
|
||||
@@ -472,14 +475,45 @@ final class FlaroSession {
|
||||
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
|
||||
// Server das, und es ist kein Grund, jemanden abzumelden.
|
||||
//
|
||||
// **Trotzdem mit Server:** sonst stünde beim Erneut-Versuchen
|
||||
// kein Konto da, und das Heruntergeladene wäre unerreichbar.
|
||||
phase = .failed(message: ErrorText.describe(error))
|
||||
activeKind = .flaro
|
||||
// **Und auch kein Grund, vor der Tür stehen zu bleiben.** Vorher
|
||||
// endete es hier bei einem Fehlerbildschirm mit zwei Knöpfen:
|
||||
// „Erneut versuchen" und „Abmelden". Beide nützen im Flugzeug
|
||||
// nichts, und das Heruntergeladene lag unerreichbar dahinter.
|
||||
//
|
||||
// Der Token liegt im Schlüsselbund, das Konto in den
|
||||
// Voreinstellungen — beides reicht, um die App zu betreten.
|
||||
// Jede einzelne Anfrage scheitert dann weiterhin, und jeder
|
||||
// Bildschirm sagt das für sich.
|
||||
await adoptFlaro(client: FlaroClient(
|
||||
baseURL: baseURL, identity: flaroIdentity, token: token
|
||||
), account: gemerktesFlaroKonto(baseURL: baseURL))
|
||||
if case .failed = phase {
|
||||
// Nicht einmal eine Mediathekenliste gemerkt — dann ist die
|
||||
// App leer, und der Fehlerbildschirm ist die ehrlichere
|
||||
// Antwort.
|
||||
phase = .failed(message: ErrorText.describe(error))
|
||||
activeKind = .flaro
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Das Konto aus den Voreinstellungen, ohne den Server zu fragen.
|
||||
///
|
||||
/// Alles darin wurde beim letzten erfolgreichen Anmelden geschrieben.
|
||||
/// Es ist vielleicht veraltet — ein umbenannter Server, ein geänderter
|
||||
/// Anzeigename —, aber es ist richtiger als gar keins.
|
||||
private func gemerktesFlaroKonto(baseURL: URL) -> FlaroAccount {
|
||||
FlaroAccount(
|
||||
baseURL: baseURL,
|
||||
serverName: defaults.string(forKey: Key.flaroServerName)
|
||||
?? baseURL.host() ?? "Flaro",
|
||||
userID: defaults.string(forKey: Key.flaroUserID) ?? "",
|
||||
userName: defaults.string(forKey: Key.flaroUserName) ?? "",
|
||||
isAdministrator: defaults.bool(forKey: Key.flaroIsAdministrator)
|
||||
)
|
||||
}
|
||||
|
||||
private func rememberFlaro(account: FlaroAccount, token: String?) {
|
||||
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
|
||||
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
|
||||
|
||||
@@ -101,7 +101,10 @@ enum HeroArtwork {
|
||||
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
|
||||
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
|
||||
// das der Serie — und erst danach das Vorschaubild der Folge.
|
||||
: [item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
|
||||
// **Das Aufmacherbild zuerst.** Es ist das, was für genau diese
|
||||
// Fläche ausgesucht wurde; der Hintergrund ist die Vorgabe
|
||||
// dahinter.
|
||||
: [item.heroArt, item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
|
||||
|
||||
var gesehen = Set<String>()
|
||||
return pfade
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import FlaroDownloads
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Was auf dem Gerät liegt, als gewöhnliche Titel.
|
||||
///
|
||||
/// **Damit ein Download aussieht wie alles andere.** Bisher waren Downloads
|
||||
/// eine eigene Welt: eine eigene Liste, eine eigene Zeile, ein eigener
|
||||
/// Bereich. Wer etwas ansehen wollte, musste erst wissen, dass er dorthin
|
||||
/// wechseln muss. Als `MediaItem` fügt sich ein Download in jede Reihe und
|
||||
/// jedes Raster ein, das es ohnehin gibt — Kachel, Fortschrittsbalken,
|
||||
/// Kontextmenü inbegriffen.
|
||||
///
|
||||
/// **Und es ist die Grundlage fürs Offline-Arbeiten.** Ohne Server gibt es
|
||||
/// keine andere Quelle für einen Titel; alles, was dann noch dasteht, kommt
|
||||
/// von hier.
|
||||
@MainActor
|
||||
enum Heruntergeladenes {
|
||||
/// Die fertigen Downloads, neueste zuerst.
|
||||
///
|
||||
/// **Nur fertige.** Ein halb geladener Film ist nichts zum Ansehen; er
|
||||
/// steht im Downloads-Bereich mit seinem Balken, und dort gehört er hin.
|
||||
static func fertige(in coordinator: DownloadCoordinator) -> [DownloadItem] {
|
||||
coordinator.items
|
||||
.filter(\.isComplete)
|
||||
.sorted { ($0.completedAt ?? $0.addedAt) > ($1.completedAt ?? $1.addedAt) }
|
||||
}
|
||||
|
||||
/// Was auf dem Gerät liegt — Filme einzeln, Serien als **eine** Kachel.
|
||||
///
|
||||
/// **Zwanzig Folgen sind keine zwanzig Titel.** Vorher stand jede
|
||||
/// einzeln in der Reihe, und eine heruntergeladene Staffel schob alles
|
||||
/// andere aus dem Bild. Was man im Kopf hat, ist die Serie; welche
|
||||
/// Folgen davon da sind, ist die Frage danach — und die beantwortet die
|
||||
/// Seite der Serie.
|
||||
static func reihe(in coordinator: DownloadCoordinator) -> [MediaItem] {
|
||||
var ergebnis: [MediaItem] = []
|
||||
var gesehen = Set<String>()
|
||||
|
||||
for eintrag in fertige(in: coordinator) {
|
||||
guard let serie = eintrag.seriesKey else {
|
||||
ergebnis.append(titel(eintrag, in: coordinator))
|
||||
continue
|
||||
}
|
||||
// Die Reihenfolge der Folgen ist die der Serie: die zuletzt
|
||||
// fertige Folge bestimmt, wo die Serie steht.
|
||||
guard gesehen.insert(serie).inserted else { continue }
|
||||
ergebnis.append(self.serie(serie, in: coordinator))
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/// Eine Serie, von der Folgen auf dem Gerät liegen.
|
||||
static func serie(_ schluessel: String, in coordinator: DownloadCoordinator) -> MediaItem {
|
||||
let folgen = folgenEintraege(derSerie: schluessel, in: coordinator)
|
||||
return MediaItem(
|
||||
id: schluessel,
|
||||
kind: .show,
|
||||
title: folgen.first?.seriesTitle ?? folgen.first?.title ?? "",
|
||||
// **Erst das Plakat der Serie, dann irgendein Folgenbild.** Ein
|
||||
// Szenenbild hochkant ist ein zufälliger Ausschnitt — aber immer
|
||||
// noch besser als eine graue Fläche.
|
||||
thumb: coordinator.artworkURL(forRatingKey: schluessel)?.absoluteString
|
||||
?? folgen.lazy
|
||||
.compactMap { coordinator.artworkURL(forRatingKey: $0.ratingKey)?.absoluteString }
|
||||
.first
|
||||
)
|
||||
}
|
||||
|
||||
/// Wie viele Folgen dieser Serie hier liegen — als Zeile unter der
|
||||
/// Kachel.
|
||||
///
|
||||
/// `nil` für alles, was keine Serie mit Downloads ist: dann gilt, was
|
||||
/// sonst unter einer Kachel steht.
|
||||
static func untertitel(für titel: MediaItem, in coordinator: DownloadCoordinator) -> String? {
|
||||
guard titel.kind == .show else { return nil }
|
||||
let anzahl = folgenEintraege(derSerie: titel.id, in: coordinator).count
|
||||
guard anzahl > 0 else { return nil }
|
||||
return String(localized: "downloads.episodeCount \(anzahl)")
|
||||
}
|
||||
|
||||
/// Die heruntergeladenen Folgen einer Serie, aufsteigend sortiert.
|
||||
///
|
||||
/// Für die Seite der Serie, wenn kein Server antwortet: dann ist das
|
||||
/// hier die ganze Liste, die es gibt.
|
||||
static func folgen(derSerie schluessel: String, in coordinator: DownloadCoordinator) -> [MediaItem] {
|
||||
folgenEintraege(derSerie: schluessel, in: coordinator)
|
||||
.sorted {
|
||||
($0.seasonIndex ?? 0, $0.episodeIndex ?? 0) < ($1.seasonIndex ?? 0, $1.episodeIndex ?? 0)
|
||||
}
|
||||
.map { titel($0, in: coordinator) }
|
||||
}
|
||||
|
||||
private static func folgenEintraege(
|
||||
derSerie schluessel: String,
|
||||
in coordinator: DownloadCoordinator
|
||||
) -> [DownloadItem] {
|
||||
fertige(in: coordinator).filter { $0.seriesKey == schluessel }
|
||||
}
|
||||
|
||||
/// Ein Download als Titel.
|
||||
///
|
||||
/// **Das Bild kommt von der Platte, nicht vom Server.** `thumb` ist sonst
|
||||
/// ein Pfad, den erst ein Server zu einer Adresse macht; hier steht
|
||||
/// stattdessen die fertige lokale Adresse. Ansichten, die `rawImageURL`
|
||||
/// darauf anwenden, müssen das wissen — siehe `bildAdresse`.
|
||||
static func titel(_ eintrag: DownloadItem, in coordinator: DownloadCoordinator) -> MediaItem {
|
||||
MediaItem(
|
||||
id: eintrag.ratingKey,
|
||||
kind: eintrag.seriesKey == nil ? .movie : .episode,
|
||||
title: eintrag.title,
|
||||
duration: eintrag.durationMilliseconds,
|
||||
thumb: coordinator.artworkURL(forRatingKey: eintrag.ratingKey)?.absoluteString,
|
||||
index: eintrag.episodeIndex,
|
||||
parentIndex: eintrag.seasonIndex,
|
||||
parentTitle: eintrag.seriesTitle,
|
||||
seriesTitle: eintrag.seriesTitle,
|
||||
seriesID: eintrag.seriesKey,
|
||||
versions: [fassung(eintrag)]
|
||||
)
|
||||
}
|
||||
|
||||
/// Die Datei auf dem Gerät als Fassung.
|
||||
///
|
||||
/// **Ohne sie bleibt der Abspielknopf grau.** Die Detailseite schaltet
|
||||
/// ihn an `currentMedia?.files.first` fest, und das ist richtig: ein
|
||||
/// Titel ohne Datei lässt sich nicht abspielen. Offline *gibt* es die
|
||||
/// Datei aber — sie liegt nur woanders. Was der Server beim Herunterladen
|
||||
/// über sie gesagt hat, steht in `offlineMediaData`.
|
||||
private static func fassung(_ eintrag: DownloadItem) -> MediaVersion {
|
||||
let angaben = eintrag.offlineMediaData.flatMap {
|
||||
try? JSONDecoder().decode(OfflineMedia.self, from: $0)
|
||||
}
|
||||
return MediaVersion(
|
||||
id: eintrag.ratingKey,
|
||||
container: angaben?.container,
|
||||
videoCodec: angaben?.videoCodec,
|
||||
audioCodec: angaben?.audioCodec,
|
||||
videoResolution: angaben?.videoResolution,
|
||||
bitrate: angaben?.bitrate,
|
||||
duration: eintrag.durationMilliseconds,
|
||||
files: [MediaFile(
|
||||
id: eintrag.ratingKey,
|
||||
itemID: eintrag.ratingKey,
|
||||
container: angaben?.container,
|
||||
size: eintrag.totalBytes > 0 ? eintrag.totalBytes : nil,
|
||||
duration: eintrag.durationMilliseconds
|
||||
)]
|
||||
)
|
||||
}
|
||||
|
||||
/// Die Adresse eines Bildes, das schon eine ist.
|
||||
///
|
||||
/// **Ein Umweg, der nötig ist.** Die Kacheln bauen ihre Bildadresse über
|
||||
/// den Server (`rawImageURL`), weil `thumb` dort ein Pfad ist. Bei einem
|
||||
/// Download steht darin bereits eine `file:`-Adresse — durch denselben
|
||||
/// Bau geschickt käme Unsinn heraus.
|
||||
static func bildAdresse(_ titel: MediaItem) -> URL? {
|
||||
guard let roh = titel.thumb, roh.hasPrefix("file:") else { return nil }
|
||||
return URL(string: roh)
|
||||
}
|
||||
|
||||
/// Ob dieser Titel auf dem Gerät liegt.
|
||||
///
|
||||
/// **Eine Serie gilt als da, wenn Folgen von ihr da sind.** Die Serie
|
||||
/// selbst wird nie heruntergeladen — ihre Folgen schon, und wer die
|
||||
/// Kachel sieht, will wissen, ob er etwas davon ansehen kann.
|
||||
static func liegtBereit(_ titelID: String, in coordinator: DownloadCoordinator) -> Bool {
|
||||
coordinator.items.contains {
|
||||
$0.isComplete && ($0.ratingKey == titelID || $0.seriesKey == titelID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
import Observation
|
||||
|
||||
@@ -19,6 +20,14 @@ import Observation
|
||||
final class HomeFeed {
|
||||
private(set) var state: LoadState<[MediaRowGroup]> = .loading
|
||||
|
||||
/// Ob das Gezeigte vom letzten Mal stammt, weil der Server nicht
|
||||
/// antwortet.
|
||||
///
|
||||
/// Die Startseite ist dann **nicht** falsch, nur alt — und das muss
|
||||
/// dranstehen. Ein Stand von gestern, der sich als heute ausgibt, ist
|
||||
/// schlimmer als gar keiner.
|
||||
private(set) var ausDemSpeicher = false
|
||||
|
||||
/// Holt die Reihen vom Server.
|
||||
///
|
||||
/// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe
|
||||
@@ -29,6 +38,24 @@ final class HomeFeed {
|
||||
state = await LoadState.load(keepingPrevious: state) {
|
||||
try await backend.homeRows()
|
||||
}
|
||||
|
||||
switch state {
|
||||
case .loaded(let reihen):
|
||||
ausDemSpeicher = false
|
||||
await LibraryCache.shared.storeHomeRows(reihen, server: backend.identity)
|
||||
case .failed:
|
||||
// **Der Abzug erst hier, nicht vorweg.** Ihn sofort zu zeigen
|
||||
// hiesse, bei jedem Start eine alte Startseite aufzublitzen, die
|
||||
// eine Sekunde später ersetzt wird. Er ist ein Rettungsanker,
|
||||
// kein Beschleuniger.
|
||||
if let gemerkt = await LibraryCache.shared.homeRows(server: backend.identity),
|
||||
!gemerkt.isEmpty {
|
||||
state = .loaded(gemerkt)
|
||||
ausDemSpeicher = true
|
||||
}
|
||||
case .loading:
|
||||
break
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Das Regal über der App im Menü. Es wird gezeichnet, wenn die App
|
||||
// **nicht** läuft — also legen wir hier ab, was dort stehen soll.
|
||||
@@ -38,10 +65,33 @@ final class HomeFeed {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Die Kennung der Downloadreihe.
|
||||
///
|
||||
/// Fest und nicht vom Server: nur so lässt sie sich wie jede andere
|
||||
/// Reihe verschieben oder ausblenden.
|
||||
static let downloadsRow = "flaro.downloads"
|
||||
|
||||
/// Die Reihen in der eingestellten Form.
|
||||
func rows(for layout: HomeLayoutSettings) -> [MediaRowGroup] {
|
||||
guard case .loaded(let alle) = state else { return [] }
|
||||
return layout.apply(to: prepared(alle, layout: layout))
|
||||
///
|
||||
/// `downloaded` kommt von aussen, weil `HomeFeed` nichts von Downloads
|
||||
/// weiss und auch nichts davon wissen muss — es ist eine Reihe wie jede
|
||||
/// andere, nur mit einer anderen Quelle.
|
||||
/// **Auch ohne geladene Reihen**: scheitert der Server und liegt nichts
|
||||
/// im Speicher, bleibt immer noch, was auf dem Gerät liegt.
|
||||
func rows(for layout: HomeLayoutSettings, downloaded: [MediaItem] = []) -> [MediaRowGroup] {
|
||||
var reihen: [MediaRowGroup] = []
|
||||
if case .loaded(let alle) = state { reihen = prepared(alle, layout: layout) }
|
||||
if !downloaded.isEmpty {
|
||||
reihen.insert(
|
||||
MediaRowGroup(
|
||||
id: Self.downloadsRow,
|
||||
title: String(localized: "home.downloaded"),
|
||||
items: downloaded
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
}
|
||||
return layout.apply(to: reihen)
|
||||
}
|
||||
|
||||
// MARK: - Ordnen
|
||||
|
||||
@@ -34,6 +34,7 @@ final class LanguagePreferences {
|
||||
static let avoidsAudioDescription = "flaro.languages.avoidsAudioDescription"
|
||||
static let perLibrary = "flaro.languages.library"
|
||||
static let perSeries = "flaro.languages.series"
|
||||
static let perSeriesSubtitles = "flaro.languages.seriesSubtitles"
|
||||
static let rememberedTracks = "flaro.languages.seriesTrack"
|
||||
}
|
||||
|
||||
@@ -98,6 +99,14 @@ final class LanguagePreferences {
|
||||
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
|
||||
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
|
||||
/// abzulegen hiesse dort, den Fehler festzuschreiben.
|
||||
/// Die Untertitelsprache je Titel — Serie oder Film.
|
||||
///
|
||||
/// **Ein Topf für beide.** Die Kennung eines Films kollidiert mit keiner
|
||||
/// Serie; zwei Wörterbücher für dieselbe Frage liefen auseinander.
|
||||
private var perSeriesSubtitles: [String: String] {
|
||||
didSet { defaults.set(perSeriesSubtitles, forKey: Key.perSeriesSubtitles) }
|
||||
}
|
||||
|
||||
private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] {
|
||||
didSet {
|
||||
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
|
||||
@@ -117,6 +126,8 @@ final class LanguagePreferences {
|
||||
(defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true
|
||||
self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:]
|
||||
self.perSeries = defaults.dictionary(forKey: Key.perSeries) as? [String: String] ?? [:]
|
||||
self.perSeriesSubtitles =
|
||||
defaults.dictionary(forKey: Key.perSeriesSubtitles) as? [String: String] ?? [:]
|
||||
self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap {
|
||||
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
|
||||
} ?? [:]
|
||||
@@ -136,6 +147,34 @@ final class LanguagePreferences {
|
||||
perSeries[id] = code
|
||||
}
|
||||
|
||||
/// Was „keine Untertitel" im Speicher heisst.
|
||||
///
|
||||
/// **Abgewählt ist eine Aussage, kein Fehlen einer.** Wer sie für diese
|
||||
/// Serie abgeschaltet hat, will sie auch in Folge 7 nicht; ein fehlender
|
||||
/// Eintrag hiesse dagegen „nie etwas gesagt", und dann darf eine
|
||||
/// erzwungene Spur durchaus erscheinen.
|
||||
static let noSubtitles = "keine"
|
||||
|
||||
func subtitleLanguage(forSeries id: String) -> String? { perSeriesSubtitles[id] }
|
||||
|
||||
func setSubtitleLanguage(_ code: String?, forSeries id: String) {
|
||||
perSeriesSubtitles[id] = code
|
||||
}
|
||||
|
||||
/// Welche Untertitelsprache für diesen Titel gilt — `nil` heisst: keine.
|
||||
///
|
||||
/// **Die Ebene schlägt das Allgemeine.** Dieselbe Rangfolge wie beim Ton,
|
||||
/// nur ohne Mediathek: eine Untertitelsprache je Mediathek hat noch
|
||||
/// niemand verlangt, und eine Einstellung, die niemand sucht, ist eine
|
||||
/// Stelle mehr, an der etwas auseinanderlaufen kann.
|
||||
func subtitleWish(titleID: String?) -> String? {
|
||||
if let eigen = titleID.flatMap({ perSeriesSubtitles[$0] }) {
|
||||
return eigen == Self.noSubtitles ? nil : eigen
|
||||
}
|
||||
|
||||
return subtitleLanguage.isEmpty ? nil : subtitleLanguage
|
||||
}
|
||||
|
||||
func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? {
|
||||
rememberedTracks[id]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ final class PlaybackSettings {
|
||||
static let cellularQuality = "flaro.playback.quality.cellular"
|
||||
static let autoSkipIntro = "flaro.playback.autoSkipIntro"
|
||||
static let autoSkipCredits = "flaro.playback.autoSkipCredits"
|
||||
static let skipExtraScenes = "flaro.playback.skipExtraScenes"
|
||||
static let autoPlayNext = "flaro.playback.autoPlayNext"
|
||||
static let locksLandscape = "flaro.playback.locksLandscape"
|
||||
static let rewindSeconds = "flaro.playback.rewindSeconds"
|
||||
@@ -58,6 +59,14 @@ final class PlaybackSettings {
|
||||
var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
|
||||
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
|
||||
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
|
||||
|
||||
/// Ob „Abspann überspringen" auch über Post-Credit-Szenen hinweggeht.
|
||||
///
|
||||
/// **Eine Gerätesache, keine Servereinstellung.** Ob jemand die Szene
|
||||
/// sehen will, entscheidet er am Gerät, auf dem er sitzt — wie bei
|
||||
/// `autoSkipIntro`. Vorgabe aus: wer den Knopf drückt, landet bei der
|
||||
/// Szene und kann sie wegdrücken, wenn er mag.
|
||||
var skipExtraScenes: Bool { didSet { defaults.set(skipExtraScenes, forKey: Key.skipExtraScenes) } }
|
||||
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
|
||||
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
|
||||
|
||||
@@ -117,6 +126,7 @@ final class PlaybackSettings {
|
||||
|
||||
self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
|
||||
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false
|
||||
self.skipExtraScenes = defaults.object(forKey: Key.skipExtraScenes) as? Bool ?? false
|
||||
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
|
||||
|
||||
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true
|
||||
|
||||
@@ -17,14 +17,18 @@ import MediaKit
|
||||
/// und wäre für VoiceOver gar nicht mehr da.
|
||||
struct QuickPlayTile<Card: View>: View {
|
||||
let item: MediaItem
|
||||
/// Ob ein Tipp auf die Detailseite führt, statt abzuspielen.
|
||||
/// Ob ein Tipp sofort abspielt, statt auf die Detailseite zu führen.
|
||||
///
|
||||
/// **Für die Suche.** Dort weiß man gerade *nicht*, was man sehen will —
|
||||
/// sonst hätte man nicht gesucht. Ein Tipp, der sofort abspielt, ist auf
|
||||
/// der Startseite richtig (die Kachel stand schon da, man kennt sie) und
|
||||
/// in einer Trefferliste falsch: der erste Treffer ist eine Vermutung des
|
||||
/// Servers, keine Entscheidung des Nutzers.
|
||||
var opensDetails: Bool = false
|
||||
/// **Nur beim Weiterschauen.** Dort ist die Absicht eindeutig: der Titel
|
||||
/// steht in dieser Reihe, *weil* man ihn angefangen hat.
|
||||
///
|
||||
/// **Überall sonst führt der Tipp auf die Seite.** Vorher war es
|
||||
/// umgekehrt, mit dem Argument „wer eine Kachel antippt, weiss meistens
|
||||
/// schon, was er sehen will". Im Gebrauch stimmte das nicht: wer nur
|
||||
/// nachsehen will, wie viele Staffeln eine Serie hat, bekam den Film.
|
||||
/// Ein Umweg von einem Schritt ist ärgerlich; eine Wiedergabe, die man
|
||||
/// nicht wollte, ist schlimmer.
|
||||
var startsPlayback: Bool = false
|
||||
@ViewBuilder let card: () -> Card
|
||||
|
||||
@Environment(FlaroSession.self) private var session
|
||||
@@ -69,8 +73,9 @@ struct QuickPlayTile<Card: View>: View {
|
||||
///
|
||||
/// Drei Gründe, derselbe Ausgang: am Mac ist der Klick seit jeher der Weg
|
||||
/// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine
|
||||
/// Sammlung hat nichts abzuspielen, und in der Suche ist es gewollt.
|
||||
private var tapOpensDetails: Bool { isMac || opensDetails || !isPlayable }
|
||||
/// Sammlung hat nichts abzuspielen, und überall ausser beim
|
||||
/// Weiterschauen ist die Seite das Ziel.
|
||||
private var tapOpensDetails: Bool { isMac || !startsPlayback || !isPlayable }
|
||||
|
||||
var body: some View {
|
||||
kachel
|
||||
@@ -84,6 +89,11 @@ struct QuickPlayTile<Card: View>: View {
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if isPlayable, isMac { playButton }
|
||||
}
|
||||
// **Links, weil rechts schon etwas sitzt** — am Mac der Abspielknopf
|
||||
// beim Überfahren, unten der Fortschrittsbalken.
|
||||
.overlay(alignment: .topLeading) {
|
||||
if Heruntergeladenes.liegtBereit(item.id, in: downloads) { downloadMarke }
|
||||
}
|
||||
.onContinuousHover { phase in
|
||||
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
|
||||
// der iOS-Bau und läuft trotzdem auf dem Mac.
|
||||
@@ -99,11 +109,9 @@ struct QuickPlayTile<Card: View>: View {
|
||||
// Kachel steht.
|
||||
.contextMenu {
|
||||
if isPlayable {
|
||||
// **Am Telefon und am iPad führt der Weg zur Detailseite
|
||||
// hierher.** Antippen spielt dort ab; wer stattdessen
|
||||
// nachsehen will, was das überhaupt ist, drückt lang. Am Mac
|
||||
// ist es umgekehrt: dort öffnet der Klick die Seite, und zum
|
||||
// Starten gibt es den Knopf beim Überfahren.
|
||||
// **Nur dort, wo der Tipp abspielt.** Sonst führt er
|
||||
// schon auf die Seite, und ein zweiter Weg dorthin im Menü
|
||||
// wäre ein Eintrag, der nichts Neues anbietet.
|
||||
if !tapOpensDetails {
|
||||
Button("hero.details", systemImage: "info.circle") { push(route) }
|
||||
Divider()
|
||||
@@ -199,14 +207,11 @@ struct QuickPlayTile<Card: View>: View {
|
||||
|
||||
/// Die Kachel selbst — und was ein Antippen bedeutet.
|
||||
///
|
||||
/// **Am Telefon und am iPad spielt es ab.** Wer eine Kachel antippt, weiss
|
||||
/// meistens schon, was er sehen will; der Umweg über die Detailseite
|
||||
/// kostete zwei Schritte für eine Absicht. Die Seite ist weiter da — über
|
||||
/// langes Drücken, wo auch „gesehen" und „entfernen" stehen.
|
||||
///
|
||||
/// **Am Mac bleibt der Klick die Detailseite.** Dort gibt es den
|
||||
/// Abspielknopf beim Überfahren, und langes Drücken ist keine übliche
|
||||
/// Geste — ein Rechtsklick ist der Weg ins Menü, nicht in den Film.
|
||||
/// **Der Tipp führt auf die Detailseite**, am Telefon wie am iPad wie am
|
||||
/// Mac. Nur in „Weiterschauen" spielt er ab: dort steht der Titel,
|
||||
/// *weil* man ihn angefangen hat. Abspielen ist sonst einen Schritt
|
||||
/// weiter — über den Knopf auf der Seite, über langes Drücken, oder am
|
||||
/// Mac über den Knopf beim Überfahren.
|
||||
///
|
||||
/// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg
|
||||
/// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im
|
||||
@@ -247,6 +252,20 @@ struct QuickPlayTile<Card: View>: View {
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
/// Das Zeichen für „liegt auf diesem Gerät".
|
||||
///
|
||||
/// **Damit man es sieht, ohne danach zu suchen.** Bisher stand ein
|
||||
/// Download nur im Downloads-Bereich; in der Mediathek sah eine
|
||||
/// heruntergeladene Folge aus wie jede andere, und wer wissen wollte,
|
||||
/// was im Flugzeug läuft, musste zwei Listen vergleichen.
|
||||
private var downloadMarke: some View {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white, Color.black.opacity(0.55))
|
||||
.padding(FlaroSpacing.snug)
|
||||
.accessibilityLabel(Text("downloads.available"))
|
||||
}
|
||||
|
||||
/// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist.
|
||||
///
|
||||
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import FlaroDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Die Detailseite eines Titels.
|
||||
@@ -11,6 +12,7 @@ struct MediaDetailView: View {
|
||||
let route: MediaRoute
|
||||
|
||||
@Environment(FlaroSession.self) private var session
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@State private var state: LoadState<MediaItem> = .loading
|
||||
|
||||
var body: some View {
|
||||
@@ -50,10 +52,44 @@ struct MediaDetailView: View {
|
||||
.task(id: route.ratingKey) { await load() }
|
||||
}
|
||||
|
||||
/// Holt den Titel — vom Server, und sonst vom Gerät.
|
||||
///
|
||||
/// **Sonst führte ein Download ins Leere.** Ausserhalb von
|
||||
/// „Fortsetzen" öffnet ein Tipp auf eine Kachel diese Seite; ohne
|
||||
/// Server endete sie bei einem Fehlerbild, und die Datei, die daneben
|
||||
/// auf der Platte lag, war von hier aus nicht mehr zu erreichen.
|
||||
///
|
||||
/// Der Server bleibt massgeblich: nur wenn er nicht antwortet, tritt
|
||||
/// das Wenige ein, was ein Download über sich selbst weiss.
|
||||
private func load() async {
|
||||
guard let server = session.backend else { return }
|
||||
guard let server = session.backend else {
|
||||
state = vomGeraet.map { .loaded($0) }
|
||||
?? .failed(message: String(localized: "error.serverUnreachable"))
|
||||
return
|
||||
}
|
||||
state = await LoadState.load {
|
||||
try await server.item(id: route.ratingKey)
|
||||
}
|
||||
if case .failed(let message) = state {
|
||||
state = vomGeraet.map { .loaded($0) } ?? .failed(message: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Was über diesen Titel auf dem Gerät liegt.
|
||||
///
|
||||
/// **Auch eine Serie**, obwohl keine Serie je heruntergeladen wird: in
|
||||
/// der Downloadreihe steht sie als eine Kachel, und ein Tipp darauf
|
||||
/// führt hierher. Ohne diesen Zweig endete er ohne Verbindung im
|
||||
/// Nichts.
|
||||
private var vomGeraet: MediaItem? {
|
||||
if let eintrag = downloads.items.first(
|
||||
where: { $0.isComplete && $0.ratingKey == route.ratingKey }
|
||||
) {
|
||||
return Heruntergeladenes.titel(eintrag, in: downloads)
|
||||
}
|
||||
guard downloads.items.contains(
|
||||
where: { $0.isComplete && $0.seriesKey == route.ratingKey }
|
||||
) else { return nil }
|
||||
return Heruntergeladenes.serie(route.ratingKey, in: downloads)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import MediaKit
|
||||
import FlaroPlayback
|
||||
|
||||
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
||||
/// einzelne Folge.
|
||||
@@ -67,6 +68,41 @@ struct PlayableDetailView: View {
|
||||
return item.versions[selectedVersion]
|
||||
}
|
||||
|
||||
/// Welche Sprachen in dieser Datei liegen.
|
||||
///
|
||||
/// **Kein Serverweg nötig.** Bei einem Film ist die Datei der Titel; ihre
|
||||
/// Spuren stehen in der Antwort, die diese Seite ohnehin geholt hat. Nur
|
||||
/// bei einer Serie muss gerechnet werden, und dafür gibt es den
|
||||
/// Endpunkt.
|
||||
private var sprachen: AvailableLanguages? {
|
||||
guard let part = currentMedia?.files.first else { return nil }
|
||||
|
||||
return AvailableLanguages(
|
||||
audio: optionen(part.audioStreams),
|
||||
subtitles: optionen(part.subtitleStreams),
|
||||
partial: 0)
|
||||
}
|
||||
|
||||
/// Aus Spuren werden Sprachen — jede einmal, auch bei zwei Tonspuren.
|
||||
private func optionen(_ spuren: [MediaStream]) -> [LanguageOption] {
|
||||
var gesehen = Set<String>()
|
||||
var liste: [LanguageOption] = []
|
||||
|
||||
for spur in spuren {
|
||||
guard let roh = spur.languageCode ?? spur.language,
|
||||
let code = LanguageCode.normalized(roh),
|
||||
gesehen.insert(code).inserted
|
||||
else { continue }
|
||||
|
||||
let name = Locale.current.localizedString(forLanguageCode: code)?.capitalized
|
||||
?? spur.language
|
||||
?? code.uppercased()
|
||||
liste.append(LanguageOption(code: code, name: name))
|
||||
}
|
||||
|
||||
return liste
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HeroBanner(
|
||||
title: item.rowTitle,
|
||||
@@ -133,6 +169,39 @@ struct PlayableDetailView: View {
|
||||
}
|
||||
|
||||
DownloadButton(item: item, mediaIndex: selectedVersion)
|
||||
|
||||
// **Die Sprache vor dem Start, nicht erst mitten im Film.**
|
||||
//
|
||||
// Im Spieler umzustellen heisst, den Strom neu aufzubauen —
|
||||
// und bei einem Film, den man immer im Original sehen will,
|
||||
// jedes Mal von Neuem. Welche Spuren es gibt, steht hier
|
||||
// ohnehin schon, also gehört die Wahl neben den Knopf, der
|
||||
// sie anwendet.
|
||||
//
|
||||
// **Nur bei einem Film.** Bei einer Folge gilt, was für die
|
||||
// Serie eingestellt ist; dieselbe Frage zwanzigmal zu
|
||||
// stellen wäre keine Wahl, sondern eine Prüfung der Geduld.
|
||||
if item.kind != .episode, let angebot = sprachen {
|
||||
if !angebot.audio.isEmpty {
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.audioShort",
|
||||
kind: .audio,
|
||||
form: .knopf,
|
||||
available: angebot.audio
|
||||
)
|
||||
}
|
||||
|
||||
if !angebot.subtitles.isEmpty {
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.subtitlesShort",
|
||||
kind: .subtitles,
|
||||
form: .knopf,
|
||||
available: angebot.subtitles
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,17 @@ struct SeriesDetailView: View {
|
||||
@State private var isWorking = false
|
||||
@State private var summary: String?
|
||||
|
||||
/// Welche Staffel die Liste zeigt — `nil` heisst: die erste.
|
||||
///
|
||||
/// **Weil der Server die Folgen am Stück liefert.** Eine Serie mit zwei
|
||||
/// Staffeln ergab eine Liste, in der nach Folge 8 wieder eine Folge 1
|
||||
/// kam, ohne dass irgendwo stünde, dass dazwischen eine Staffel endet.
|
||||
@State private var gewaehlteStaffel: Int?
|
||||
|
||||
/// Welche Sprachen diese Serie durchgehend führt — `nil`, solange es
|
||||
/// niemand weiss.
|
||||
@State private var sprachen: AvailableLanguages?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||
@@ -54,11 +65,35 @@ struct SeriesDetailView: View {
|
||||
// dieselbe Einstellung zu haben, von denen einer nie gelesen
|
||||
// wird.
|
||||
if item.kind == .show {
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.forThisSeries"
|
||||
)
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.forThisSeries",
|
||||
kind: .audio,
|
||||
available: sprachen?.audio
|
||||
)
|
||||
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.subtitlesForThisSeries",
|
||||
kind: .subtitles,
|
||||
available: sprachen?.subtitles
|
||||
)
|
||||
|
||||
// **Was fehlt, wird gesagt.** Eine gekürzte Liste
|
||||
// sieht sonst aus wie die ganze Wahrheit, und wer
|
||||
// eine Sprache vermisst, die er in Staffel 1 gesehen
|
||||
// hat, hält es für einen Fehler.
|
||||
if let partial = sprachen?.partial, partial > 0 {
|
||||
Text("languages.partial \(partial)")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
.task(id: item.id) {
|
||||
sprachen = await session.backend?.availableLanguages(inSeries: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
childList
|
||||
@@ -172,23 +207,33 @@ struct SeriesDetailView: View {
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
|
||||
case .loaded(let entries):
|
||||
let nummern = staffelnummern(in: entries)
|
||||
let sichtbar = sichtbareFolgen(in: entries, staffeln: nummern)
|
||||
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
|
||||
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
|
||||
// als von allem anderen auf dieser Seite.
|
||||
HStack {
|
||||
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
||||
.font(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
if nummern.count > 1 {
|
||||
staffelwahl(nummern)
|
||||
} else {
|
||||
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
||||
.font(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
}
|
||||
Spacer()
|
||||
if entries.contains(where: { $0.kind == .episode }) {
|
||||
selectionControls(for: entries)
|
||||
if sichtbar.contains(where: { $0.kind == .episode }) {
|
||||
// **Nur die gezeigte Staffel.** „Alle auswählen" soll
|
||||
// das meinen, was dasteht — sonst lädt ein Griff
|
||||
// sechzehn Folgen herunter statt acht.
|
||||
selectionControls(for: sichtbar)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(entries) { entry in
|
||||
ForEach(sichtbar) { entry in
|
||||
row(for: entry)
|
||||
Divider().overlay(Color.flaroSeparator)
|
||||
}
|
||||
@@ -197,6 +242,55 @@ struct SeriesDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Welche Staffeln in dieser Liste vorkommen.
|
||||
///
|
||||
/// Leer, wenn die Einträge selbst Staffeln sind — dann gibt es nichts zu
|
||||
/// wählen, die Liste **ist** die Wahl.
|
||||
private func staffelnummern(in entries: [MediaItem]) -> [Int] {
|
||||
Set(entries.compactMap { $0.kind == .episode ? $0.parentIndex : nil }).sorted()
|
||||
}
|
||||
|
||||
private func sichtbareFolgen(in entries: [MediaItem], staffeln: [Int]) -> [MediaItem] {
|
||||
guard staffeln.count > 1 else { return entries }
|
||||
let gewaehlt = gewaehlteStaffel ?? staffeln[0]
|
||||
return entries.filter { $0.kind != .episode || $0.parentIndex == gewaehlt }
|
||||
}
|
||||
|
||||
/// Die Staffel als Menü — an der Stelle, an der sonst „Folgen" steht.
|
||||
///
|
||||
/// **Kein `Picker`.** Diese Seite ist eine `ScrollView`, kein `Form`;
|
||||
/// dort hat ein `Picker` kein Aussehen, auf das man sich verlassen kann.
|
||||
/// Dieselbe Begründung wie bei `AudioLanguagePicker`.
|
||||
private func staffelwahl(_ nummern: [Int]) -> some View {
|
||||
let gewaehlt = gewaehlteStaffel ?? nummern[0]
|
||||
|
||||
return Menu {
|
||||
ForEach(nummern, id: \.self) { nummer in
|
||||
Button {
|
||||
gewaehlteStaffel = nummer
|
||||
// Eine Auswahl aus der vorigen Staffel meint hier nichts
|
||||
// mehr — stehen zu lassen, was man nicht mehr sieht,
|
||||
// lädt unsichtbare Folgen herunter.
|
||||
selection.removeAll()
|
||||
} label: {
|
||||
Label(
|
||||
String(localized: "season.short \(nummer)"),
|
||||
systemImage: nummer == gewaehlt ? "checkmark" : ""
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: FlaroSpacing.tight) {
|
||||
Text("season.short \(gewaehlt)")
|
||||
.font(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen.
|
||||
@ViewBuilder
|
||||
private func row(for entry: MediaItem) -> some View {
|
||||
@@ -376,11 +470,31 @@ struct SeriesDetailView: View {
|
||||
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// Holt die Staffeln oder Folgen — und sonst, was auf dem Gerät liegt.
|
||||
///
|
||||
/// **Sonst wäre die Seite einer heruntergeladenen Serie leer.** Sie ist
|
||||
/// das Ziel, auf das die Downloadreihe zeigt; dort anzukommen und einen
|
||||
/// Fehler zu lesen, während die Folgen daneben auf der Platte liegen,
|
||||
/// wäre die Umkehrung dessen, wofür man sie geladen hat.
|
||||
private func loadChildren() async {
|
||||
guard let server = session.backend else { return }
|
||||
guard let server = session.backend else {
|
||||
children = vomGeraet.isEmpty
|
||||
? .failed(message: String(localized: "error.serverUnreachable"))
|
||||
: .loaded(vomGeraet)
|
||||
return
|
||||
}
|
||||
children = await LoadState.load {
|
||||
try await server.children(of: item.id)
|
||||
}
|
||||
if case .failed = children, !vomGeraet.isEmpty {
|
||||
children = .loaded(vomGeraet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Folgen dieser Serie, die auf dem Gerät liegen.
|
||||
private var vomGeraet: [MediaItem] {
|
||||
guard item.kind == .show else { return [] }
|
||||
return Heruntergeladenes.folgen(derSerie: item.id, in: downloads)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import FlaroDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
||||
@@ -11,15 +12,26 @@ import MediaKit
|
||||
struct HomeView: View {
|
||||
@Environment(FlaroSession.self) private var session
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(Fortschrittspost.self) private var post
|
||||
@State private var feed = HomeFeed()
|
||||
|
||||
/// Was auf dem Gerät liegt, als Reihe ganz oben.
|
||||
///
|
||||
/// Wird bei jeder Änderung an den Downloads neu berechnet, weil
|
||||
/// `DownloadCoordinator` beobachtet wird — eine fertige Folge steht
|
||||
/// ohne Neuladen der Startseite da.
|
||||
private var heruntergeladen: [MediaItem] {
|
||||
Heruntergeladenes.reihe(in: downloads)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch feed.state {
|
||||
case .loading:
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .failed(let message):
|
||||
case .failed(let message) where heruntergeladen.isEmpty:
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
@@ -28,8 +40,12 @@ struct HomeView: View {
|
||||
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
||||
}
|
||||
|
||||
case .loaded:
|
||||
let hubs = feed.rows(for: layout)
|
||||
// **Mit Downloads ist „kein Server" kein Fehlerbildschirm.**
|
||||
// Was auf dem Gerät liegt, lässt sich auch ohne ihn ansehen —
|
||||
// genau dafür wurde es heruntergeladen. Ein Fehlerbild davor zu
|
||||
// stellen hiesse, dem Nutzer etwas wegzunehmen, das er hat.
|
||||
case .failed, .loaded:
|
||||
let hubs = feed.rows(for: layout, downloaded: heruntergeladen)
|
||||
if hubs.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("home.empty.title", systemImage: "film.stack")
|
||||
@@ -59,7 +75,18 @@ struct HomeView: View {
|
||||
// daneben, und genau diese Kante sah man. Auf den geschobenen
|
||||
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.task(id: session.serverName) { await feed.load(from: session.backend) }
|
||||
.task(id: session.serverName) {
|
||||
await feed.load(from: session.backend)
|
||||
// Siehe `DownloadService.plakateNachholen`: ältere Downloads
|
||||
// haben kein Bild, und hier ist die Stelle, an der man es merken
|
||||
// würde.
|
||||
await DownloadService(backend: session.backend, coordinator: downloads)
|
||||
.plakateNachholen()
|
||||
// **Der Server hat geantwortet — dann nimmt er auch Post an.**
|
||||
// Der Start der App ist dafür der falsche Zeitpunkt: da ist oft
|
||||
// noch gar keine Verbindung.
|
||||
if !feed.ausDemSpeicher { await post.zustellen(über: session.backend) }
|
||||
}
|
||||
.refreshable { await feed.load(from: session.backend) }
|
||||
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
|
||||
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
|
||||
@@ -69,13 +96,22 @@ struct HomeView: View {
|
||||
private func content(hubs: [MediaRowGroup]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||
let featured = feed.heroItems(for: layout)
|
||||
if !featured.isEmpty {
|
||||
HeroCarousel(items: featured)
|
||||
if ohneServer {
|
||||
offlinehinweis
|
||||
} else {
|
||||
let featured = feed.heroItems(for: layout)
|
||||
if !featured.isEmpty {
|
||||
HeroCarousel(items: featured)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(hubs) { hub in
|
||||
MediaRow(row: hub)
|
||||
// **Ausgegraut, aber nicht gesperrt.** Was hier steht,
|
||||
// stammt vom letzten Mal; antippen darf man es
|
||||
// trotzdem — dann kommt ein Satz, der sagt warum, und
|
||||
// das ist ehrlicher als ein Knopf, der nichts tut.
|
||||
.opacity(ohneServer && hub.id != HomeFeed.downloadsRow ? 0.4 : 1)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, FlaroSpacing.section)
|
||||
@@ -83,4 +119,36 @@ struct HomeView: View {
|
||||
.scrollIndicators(.never)
|
||||
.flaroRevealsTopChrome()
|
||||
}
|
||||
|
||||
/// Ob gerade kein Server antwortet.
|
||||
///
|
||||
/// Zwei Wege führen hierher: gar nichts geladen, oder ein Abzug vom
|
||||
/// letzten Mal. Für den Nutzer ist es dieselbe Lage.
|
||||
private var ohneServer: Bool {
|
||||
if case .failed = feed.state { return true }
|
||||
return feed.ausDemSpeicher
|
||||
}
|
||||
|
||||
/// Statt des Aufmachers, wenn der Server fehlt.
|
||||
///
|
||||
/// **An seiner Stelle, nicht darüber.** Ein grosses Bild vom letzten Mal
|
||||
/// über einer Startseite, die nicht stimmt, wäre die falsche Betonung —
|
||||
/// und die Downloads sollen oben stehen.
|
||||
private var offlinehinweis: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: FlaroSpacing.regular) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
Text("home.offline")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(FlaroSpacing.regular)
|
||||
.background(Color.flaroElevated, in: .rect(cornerRadius: 12))
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import FlaroDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Eine waagerechte Reihe von Titeln.
|
||||
struct MediaRow: View {
|
||||
let row: MediaRowGroup
|
||||
/// Wird durchgereicht — siehe `QuickPlayTile`.
|
||||
var opensDetails: Bool = false
|
||||
/// Ob ein Tipp sofort abspielt — siehe `QuickPlayTile`.
|
||||
///
|
||||
/// **Die Reihe weiss es, nicht die Kachel.** „Weiterschauen" ist eine
|
||||
/// Eigenschaft der Reihe; eine Kachel kennt nur ihren Titel.
|
||||
private var startsPlayback: Bool { row.isContinueWatching }
|
||||
|
||||
@Environment(FlaroSession.self) private var session
|
||||
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
|
||||
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
|
||||
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
|
||||
@@ -32,7 +37,7 @@ struct MediaRow: View {
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: FlaroSpacing.regular) {
|
||||
ForEach(row.items) { item in
|
||||
QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) }
|
||||
QuickPlayTile(item: item, startsPlayback: startsPlayback) { card(for: item) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
@@ -55,8 +60,8 @@ struct MediaRow: View {
|
||||
if row.isContinueWatching {
|
||||
LandscapeCard(
|
||||
title: item.rowTitle,
|
||||
subtitle: item.cardSubtitle,
|
||||
artworkURL: session.backend?.artworkURL(
|
||||
subtitle: untertitel(for: item),
|
||||
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
|
||||
path: item.rowArtworkPath,
|
||||
width: 440,
|
||||
height: 248
|
||||
@@ -67,8 +72,8 @@ struct MediaRow: View {
|
||||
} else {
|
||||
PosterCard(
|
||||
title: item.rowTitle,
|
||||
subtitle: item.cardSubtitle,
|
||||
artworkURL: session.backend?.artworkURL(
|
||||
subtitle: untertitel(for: item),
|
||||
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
|
||||
path: item.posterArtworkPath,
|
||||
width: 300,
|
||||
height: 450
|
||||
@@ -78,4 +83,18 @@ struct MediaRow: View {
|
||||
.frame(width: posterWidth)
|
||||
}
|
||||
}
|
||||
|
||||
/// Was unter der Kachel steht.
|
||||
///
|
||||
/// **In der Downloadreihe zählt, wie viel da ist.** Eine Serie steht
|
||||
/// dort als eine Kachel; „2019" darunter beantwortet nicht die Frage,
|
||||
/// die man an dieser Stelle hat — nämlich wie viele Folgen man dabei
|
||||
/// hat.
|
||||
private func untertitel(for item: MediaItem) -> String? {
|
||||
if row.id == HomeFeed.downloadsRow,
|
||||
let zahl = Heruntergeladenes.untertitel(für: item, in: downloads) {
|
||||
return zahl
|
||||
}
|
||||
return item.cardSubtitle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import MediaKit
|
||||
/// Filter und Sortierung einer Mediathek.
|
||||
struct LibraryFilterSheet: View {
|
||||
@Binding var query: MediaQuery
|
||||
/// Steht getrennt von `query`, weil der Server ihn nicht auswerten kann —
|
||||
/// siehe `LibraryView.nurHeruntergeladene`.
|
||||
@Binding var nurHeruntergeladene: Bool
|
||||
/// Die Mediathek selbst, nicht nur ihre Art.
|
||||
///
|
||||
/// Vorher stand hier die Art, und die Genres wurden über *irgendeine*
|
||||
@@ -23,11 +26,18 @@ struct LibraryFilterSheet: View {
|
||||
/// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus —
|
||||
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
|
||||
@State private var draft: MediaQuery
|
||||
@State private var entwurfHeruntergeladene: Bool
|
||||
|
||||
init(query: Binding<MediaQuery>, library: MediaLibrary) {
|
||||
init(
|
||||
query: Binding<MediaQuery>,
|
||||
nurHeruntergeladene: Binding<Bool>,
|
||||
library: MediaLibrary
|
||||
) {
|
||||
self._query = query
|
||||
self._nurHeruntergeladene = nurHeruntergeladene
|
||||
self.library = library
|
||||
self._draft = State(initialValue: query.wrappedValue)
|
||||
self._entwurfHeruntergeladene = State(initialValue: nurHeruntergeladene.wrappedValue)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -45,6 +55,7 @@ struct LibraryFilterSheet: View {
|
||||
|
||||
Section("filter.filters") {
|
||||
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
|
||||
Toggle("filter.downloadedOnly", isOn: $entwurfHeruntergeladene)
|
||||
|
||||
Picker("filter.genre", selection: $draft.genre) {
|
||||
Text("filter.any").tag(String?.none)
|
||||
@@ -80,8 +91,9 @@ struct LibraryFilterSheet: View {
|
||||
Section {
|
||||
Button("filter.reset", role: .destructive) {
|
||||
draft = MediaQuery(sort: draft.sort)
|
||||
entwurfHeruntergeladene = false
|
||||
}
|
||||
.disabled(!draft.isFiltering)
|
||||
.disabled(!draft.isFiltering && !entwurfHeruntergeladene)
|
||||
}
|
||||
}
|
||||
// Ausdrücklich, nicht nur geerbt.
|
||||
@@ -102,6 +114,7 @@ struct LibraryFilterSheet: View {
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.apply") {
|
||||
nurHeruntergeladene = entwurfHeruntergeladene
|
||||
query = draft
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import FlaroDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Eine Mediathek zum Durchblättern.
|
||||
@@ -10,6 +11,7 @@ struct LibraryView: View {
|
||||
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
|
||||
/// zwischen Raster und Liste.
|
||||
@Environment(HomeLayoutSettings.self) private var sizing
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@AppStorage("flaro.library.layout") private var layout: LibraryLayout = .grid
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
@@ -25,6 +27,14 @@ struct LibraryView: View {
|
||||
@State private var loadMoreFailed = false
|
||||
@State private var isLoadingMore = false
|
||||
@State private var showsFilters = false
|
||||
/// Ob nur gezeigt wird, was auf diesem Gerät liegt.
|
||||
///
|
||||
/// **Kein Teil von `MediaQuery`**, und das ist der Kern der Sache: die
|
||||
/// Abfrage wertet der Server aus, und der weiss nicht, was auf diesem
|
||||
/// Gerät liegt. Steht der Filter an, wird gar nicht erst gefragt — die
|
||||
/// Liste kommt aus den Downloads. Das ist genau, vollständig, und
|
||||
/// funktioniert ohne Verbindung.
|
||||
@State private var nurHeruntergeladene = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -48,8 +58,9 @@ struct LibraryView: View {
|
||||
} description: {
|
||||
Text("library.empty.description")
|
||||
} actions: {
|
||||
if query.isFiltering {
|
||||
if query.isFiltering || nurHeruntergeladene {
|
||||
Button("library.clearFilters") {
|
||||
nurHeruntergeladene = false
|
||||
query = MediaQuery(sort: query.sort)
|
||||
}
|
||||
}
|
||||
@@ -64,7 +75,11 @@ struct LibraryView: View {
|
||||
.flaroInlineTitle()
|
||||
.toolbar { toolbar }
|
||||
.sheet(isPresented: $showsFilters) {
|
||||
LibraryFilterSheet(query: $query, library: library)
|
||||
LibraryFilterSheet(
|
||||
query: $query,
|
||||
nurHeruntergeladene: $nurHeruntergeladene,
|
||||
library: library
|
||||
)
|
||||
}
|
||||
.task(id: library.id) {
|
||||
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
|
||||
@@ -78,6 +93,11 @@ struct LibraryView: View {
|
||||
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
|
||||
// verschiedenen Abfragen vermischen.
|
||||
.onChange(of: query) { _, _ in Task { await reload() } }
|
||||
.onChange(of: nurHeruntergeladene) { _, _ in
|
||||
items = []
|
||||
page = nil
|
||||
Task { await reload() }
|
||||
}
|
||||
.refreshable { await reload() }
|
||||
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
|
||||
.environment(\.flaroItemChanged, { await reload() })
|
||||
@@ -104,8 +124,12 @@ struct LibraryView: View {
|
||||
QuickPlayTile(item: item) {
|
||||
PosterCard(
|
||||
title: item.title,
|
||||
subtitle: item.cardSubtitle,
|
||||
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
||||
subtitle: nurHeruntergeladene
|
||||
? Heruntergeladenes.untertitel(für: item, in: downloads)
|
||||
?? item.cardSubtitle
|
||||
: item.cardSubtitle,
|
||||
artworkURL: Heruntergeladenes.bildAdresse(item)
|
||||
?? session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
||||
progress: item.resumeProgress
|
||||
)
|
||||
}
|
||||
@@ -232,7 +256,7 @@ struct LibraryView: View {
|
||||
Button {
|
||||
showsFilters = true
|
||||
} label: {
|
||||
Image(systemName: query.isFiltering
|
||||
Image(systemName: query.isFiltering || nurHeruntergeladene
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
@@ -276,6 +300,16 @@ struct LibraryView: View {
|
||||
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
|
||||
/// Fehlerbildschirm.
|
||||
private func reload() async {
|
||||
// **Vor dem Server, nicht danach.** Was auf dem Gerät liegt, weiss
|
||||
// nur das Gerät — und es weiss es auch im Flugmodus.
|
||||
if nurHeruntergeladene {
|
||||
items = geraeteliste
|
||||
page = MediaPage(items: items, totalCount: items.count)
|
||||
loadMoreFailed = false
|
||||
state = .loaded(())
|
||||
return
|
||||
}
|
||||
|
||||
guard let server = session.backend else { return }
|
||||
let schlüssel = query.cacheKey
|
||||
let kennung = server.identity
|
||||
@@ -319,6 +353,25 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Downloads, die in diese Mediathek gehören.
|
||||
///
|
||||
/// **Zugeordnet über die Art der Mediathek**, weil ein Download seine
|
||||
/// Mediathek nicht kennt: er führt einen Serienschlüssel oder keinen,
|
||||
/// und mehr braucht es nicht. Eine Folge gehört in eine Serien-,
|
||||
/// ein Film in eine Filmmediathek.
|
||||
///
|
||||
/// shortcut: zwei Filmmediatheken bekämen beide dieselbe Liste — erst
|
||||
/// wenn `DownloadItem` die Mediathek mitführt, lässt sich das trennen.
|
||||
private var geraeteliste: [MediaItem] {
|
||||
Heruntergeladenes.reihe(in: downloads).filter { titel in
|
||||
switch library.kind {
|
||||
case .movie: titel.kind != .show
|
||||
case .show: titel.kind == .show
|
||||
default: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
|
||||
private func loadMoreIfNeeded(reaching item: MediaItem) async {
|
||||
guard
|
||||
@@ -385,7 +438,8 @@ private struct LibraryListRow: View {
|
||||
var body: some View {
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
FlaroArtwork(
|
||||
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
|
||||
url: Heruntergeladenes.bildAdresse(item)
|
||||
?? session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
|
||||
aspectRatio: PosterCard.aspectRatio
|
||||
)
|
||||
.frame(width: 44)
|
||||
|
||||
@@ -19,6 +19,7 @@ struct RootView: View {
|
||||
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
||||
@Environment(NetworkConditions.self) private var network
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(Fortschrittspost.self) private var post
|
||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||
|
||||
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
||||
@@ -229,6 +230,7 @@ struct RootView: View {
|
||||
backend: session.backend,
|
||||
liveTV: session.liveTV,
|
||||
downloads: downloads,
|
||||
post: post,
|
||||
diagnostics: diagnostics,
|
||||
policy: settings.policy(for: network),
|
||||
settings: settings,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import MediaKit
|
||||
|
||||
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
|
||||
///
|
||||
@@ -34,6 +35,11 @@ struct PlaybackScrubber: View {
|
||||
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
|
||||
let onActivity: () -> Void
|
||||
|
||||
/// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
|
||||
var vorschau: Vorschauplan?
|
||||
/// Woher ein Blatt kommt.
|
||||
var blatt: (Int) -> URL? = { _ in nil }
|
||||
|
||||
@Environment(\.flaroAccent) private var accent
|
||||
|
||||
private static let trackHeight: CGFloat = 5
|
||||
@@ -71,6 +77,22 @@ struct PlaybackScrubber: View {
|
||||
.offset(x: gespielt - Self.thumbSize / 2)
|
||||
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
|
||||
}
|
||||
// **Die Vorschau steht über dem Griff, nicht darunter.** Unter
|
||||
// dem Finger verdeckte sie genau die Stelle, die man sucht.
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
if let plan = vorschau,
|
||||
let gezogen = scrubbing,
|
||||
let stelle = plan.stelle(bei: gezogen) {
|
||||
Vorschaukachel(plan: plan, stelle: stelle, blatt: blatt)
|
||||
.offset(
|
||||
x: min(
|
||||
breite - plan.kachelBreite,
|
||||
max(0, gespielt - plan.kachelBreite / 2)),
|
||||
y: -Self.hitHeight / 2 - 8)
|
||||
.allowsHitTesting(false)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(height: Self.hitHeight)
|
||||
.contentShape(.rect)
|
||||
.gesture(
|
||||
@@ -117,3 +139,75 @@ struct PlaybackScrubber: View {
|
||||
return min(max(Double(x / width), 0), 1) * duration.seconds
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Standbild aus dem Film, ausgeschnitten aus einem Blatt.
|
||||
///
|
||||
/// **Ausgeschnitten, nicht einzeln geladen.** Ein Blatt trägt hundert
|
||||
/// Bilder; beim Ziehen über die Leiste wird nur der Ausschnitt verschoben,
|
||||
/// und nach dem ersten Blatt kommt kein Abruf mehr. Einzelne Bilder zu
|
||||
/// holen hiesse, bei jeder Fingerbewegung ins Netz zu greifen.
|
||||
///
|
||||
/// **Geladen wird von Hand, nicht über `FlaroArtwork`.** Jenes rechnet mit
|
||||
/// Seitenverhältnissen und schneidet selbst zu — hier wird ein Blatt
|
||||
/// gebraucht, das genau so gross dasteht, wie es ist, damit der Versatz
|
||||
/// auf den Bildpunkt stimmt.
|
||||
struct Vorschaukachel: View {
|
||||
let plan: Vorschauplan
|
||||
let stelle: Vorschauplan.Stelle
|
||||
/// Woher das Blatt kommt — der Spieler weiss, welche Datei läuft.
|
||||
let blatt: (Int) -> URL?
|
||||
|
||||
@State private var bild: PlatformImage?
|
||||
|
||||
var body: some View {
|
||||
Rechteck
|
||||
.frame(width: plan.kachelBreite, height: plan.kachelHoehe)
|
||||
.background(.black)
|
||||
.clipShape(.rect(cornerRadius: FlaroRadius.card))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: FlaroRadius.card)
|
||||
.stroke(.white.opacity(0.18), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.6), radius: 10, y: 4)
|
||||
.task(id: stelle.blatt) { await laden() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var Rechteck: some View {
|
||||
if let bild {
|
||||
// `Image(platform:)` liegt in FlaroDesign und ist paketintern;
|
||||
// dieselbe Fallunterscheidung, zwei Zeilen.
|
||||
bildAnsicht(bild)
|
||||
.resizable()
|
||||
.frame(
|
||||
width: plan.kachelBreite * Double(plan.spalten),
|
||||
height: plan.kachelHoehe * Double(plan.zeilen))
|
||||
// Der Ausschnitt entsteht durch Verschieben hinter einem
|
||||
// Rahmen in Kachelgrösse — dieselbe Rechnung wie
|
||||
// `background-position` im Web.
|
||||
.offset(
|
||||
x: -plan.kachelBreite * Double(stelle.spalte),
|
||||
y: -plan.kachelHoehe * Double(stelle.zeile))
|
||||
.frame(
|
||||
width: plan.kachelBreite, height: plan.kachelHoehe,
|
||||
alignment: .topLeading)
|
||||
.clipped()
|
||||
} else {
|
||||
Color.black
|
||||
}
|
||||
}
|
||||
|
||||
private func bildAnsicht(_ bild: PlatformImage) -> Image {
|
||||
#if canImport(UIKit)
|
||||
Image(uiImage: bild)
|
||||
#else
|
||||
Image(nsImage: bild)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func laden() async {
|
||||
guard let adresse = blatt(stelle.blatt) else { return }
|
||||
// Der Lader hält Blätter im Speicher: beim Ziehen über die Leiste
|
||||
// wird dasselbe Blatt hundertmal gebraucht.
|
||||
bild = await ArtworkLoader.shared.image(for: adresse)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +61,20 @@ final class PlayerModel {
|
||||
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
||||
/// Überspringen-Knopf.
|
||||
private(set) var activeMarker: MediaMarker?
|
||||
|
||||
/// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
|
||||
///
|
||||
/// **Einmal gefragt, bei „noch nicht" einmal nachgefasst.** Die Blätter
|
||||
/// entstehen beim Abspielen im Hintergrund; nach zehn Sekunden sind sie
|
||||
/// bei den meisten Dateien da. Weiter zu fragen hiesse, für eine
|
||||
/// Annehmlichkeit dauernd am Server zu ziehen.
|
||||
private(set) var vorschau: Vorschauplan?
|
||||
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
|
||||
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
||||
private(set) var nextEpisode: MediaItem?
|
||||
/// Die Folge davor — für den Rückwärtsknopf. `nil` heisst: es ist die
|
||||
/// erste, oder es ist gar keine Folge.
|
||||
private(set) var previousEpisode: MediaItem?
|
||||
private(set) var hasReachedEnd = false
|
||||
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
|
||||
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
|
||||
@@ -105,6 +116,26 @@ final class PlayerModel {
|
||||
/// und ausgerechnet da will man zusehen, wie der Vorrat wächst.
|
||||
private(set) var bufferedUntil: Duration?
|
||||
|
||||
/// Ab welcher Stelle des Films der laufende Strom beginnt.
|
||||
///
|
||||
/// **Null ist der Normalfall.** Dann ist der Strom der ganze Film, und
|
||||
/// die Uhr des Spielers ist die Uhr des Films.
|
||||
///
|
||||
/// **Darüber heisst: der Server hat schon gesprungen.** Flaros
|
||||
/// Abspielliste wächst von der gewünschten Stelle an vorwärts; was davor
|
||||
/// liegt, gibt es in diesem Strom nicht. Der Spieler zählt trotzdem ab
|
||||
/// null — ohne diesen Wert stünde bei „Fortsetzen ab 1:32" eine Null auf
|
||||
/// der Uhr, und genau die würde auch gemeldet.
|
||||
private var streamOffset: Duration = .zero
|
||||
|
||||
/// Bis wohin der laufende Strom reicht.
|
||||
///
|
||||
/// **Nicht dasselbe wie <see cref="duration"/>.** Das ist die Länge des
|
||||
/// Films; dies ist, was davon in diesem Strom liegt. Bei einer
|
||||
/// wachsenden Abspielliste wächst es mit. Ein Sprung darüber hinaus
|
||||
/// braucht einen neuen Strom, keinen Sprung.
|
||||
private var streamEnd: Duration?
|
||||
|
||||
/// Was der eigene Vorratsspeicher gerade tut — in einem Satz.
|
||||
///
|
||||
/// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher
|
||||
@@ -211,6 +242,9 @@ final class PlayerModel {
|
||||
/// Protokoll mit Null-Objekt wäre die dritte Möglichkeit und die
|
||||
/// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden.
|
||||
private let downloads: DownloadCoordinator?
|
||||
/// Siehe `Fortschrittspost`: was der Server nicht annimmt, wird
|
||||
/// zurückgelegt und nachgereicht.
|
||||
private let post: Fortschrittspost?
|
||||
private let diagnostics: PlaybackDiagnostics
|
||||
private let policy: PlaybackPolicy
|
||||
private let settings: PlaybackSettings
|
||||
@@ -461,6 +495,8 @@ final class PlayerModel {
|
||||
backend: (any MediaBackend)?,
|
||||
liveTV: (any LiveTVBackend)?,
|
||||
downloads: DownloadCoordinator? = nil,
|
||||
/// Wohin ein Fortschritt kommt, den der Server nicht annimmt.
|
||||
post: Fortschrittspost? = nil,
|
||||
diagnostics: PlaybackDiagnostics,
|
||||
policy: PlaybackPolicy,
|
||||
settings: PlaybackSettings,
|
||||
@@ -474,6 +510,7 @@ final class PlayerModel {
|
||||
self.backend = backend
|
||||
self.liveTV = liveTV
|
||||
self.downloads = downloads
|
||||
self.post = post
|
||||
self.diagnostics = diagnostics
|
||||
self.policy = policy
|
||||
self.settings = settings
|
||||
@@ -499,6 +536,18 @@ final class PlayerModel {
|
||||
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
|
||||
if let downloads, let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
|
||||
await startLocal(url: localURL)
|
||||
// **Die Angaben trotzdem holen, nur eben hinterher.**
|
||||
//
|
||||
// Hier wurde früher schlicht zurückgekehrt — und damit blieb
|
||||
// `item` leer. Daran hing mehr, als es aussieht: keine Marken,
|
||||
// also kein „Intro überspringen"; keine nächste Folge, also
|
||||
// keine Knöpfe zum Blättern. Bei allem, was heruntergeladen
|
||||
// war, fehlte das, und nur dort — weshalb es so lange aussah
|
||||
// wie ein Fehler in der Erkennung.
|
||||
//
|
||||
// Nebenher und nicht davor: das Bild soll sofort laufen, und
|
||||
// ohne Verbindung gibt es ohnehin nichts zu holen.
|
||||
metadatenNachladen()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -557,6 +606,7 @@ final class PlayerModel {
|
||||
|
||||
await load(source, decision: decision)
|
||||
preloadNextEpisode()
|
||||
vorschauHolen()
|
||||
}
|
||||
|
||||
/// Stellt auf eine andere Qualitätsstufe um.
|
||||
@@ -718,7 +768,58 @@ final class PlayerModel {
|
||||
await load(PlaybackSource(url: url, origin: .remote), decision: decision)
|
||||
}
|
||||
|
||||
/// Holt Titelangaben nach, während die lokale Datei schon läuft.
|
||||
///
|
||||
/// Scheitert es — kein Server, kein Netz —, bleibt es, wie es war: die
|
||||
/// Datei läuft, es gibt bloss keine Marken und keine Nachbarfolgen.
|
||||
/// Welche Datei läuft — die Vorschau hängt an ihr, nicht am Titel.
|
||||
///
|
||||
/// `MediaVersion.id` ist die Kennung der Mediendatei; eine Fassung
|
||||
/// besteht aus genau einer.
|
||||
private var vorschaudatei: String?
|
||||
|
||||
/// Holt den Vorschauplan — einmal sofort, einmal nach zehn Sekunden.
|
||||
private func vorschauHolen() {
|
||||
guard vorschau == nil, let backend, let item else { return }
|
||||
guard let datei = version(of: item)?.id else { return }
|
||||
vorschaudatei = datei
|
||||
|
||||
Task { [weak self] in
|
||||
for warten in [Duration.zero, .seconds(10)] {
|
||||
if warten > .zero { try? await Task.sleep(for: warten) }
|
||||
guard let self, self.vorschau == nil else { return }
|
||||
if let plan = await backend.vorschauplan(fileId: datei) {
|
||||
self.vorschau = plan
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Woher ein Vorschaublatt kommt.
|
||||
func vorschaublatt(_ nummer: Int) -> URL? {
|
||||
guard let backend, let datei = vorschaudatei else { return nil }
|
||||
return backend.vorschaublatt(fileId: datei, blatt: nummer)
|
||||
}
|
||||
|
||||
private func metadatenNachladen() {
|
||||
guard item == nil, let backend else { return }
|
||||
Task { [weak self] in
|
||||
guard let geholt = try? await backend.item(id: self?.request.ratingKey ?? "") else { return }
|
||||
guard let self, self.item == nil else { return }
|
||||
self.item = geholt
|
||||
self.updateActiveMarker()
|
||||
self.preloadNextEpisode()
|
||||
self.vorschauHolen()
|
||||
}
|
||||
}
|
||||
|
||||
private func startLocal(url: URL) async {
|
||||
// Eine Datei auf dem Gerät ist der ganze Film; es gibt keinen
|
||||
// Vorlauf, den der Spieler aufrechnen müsste.
|
||||
streamOffset = .zero
|
||||
streamEnd = nil
|
||||
|
||||
let offline = downloads?.offlineMedia(forRatingKey: request.ratingKey) ?? nil
|
||||
|
||||
let decision = PlaybackDecider().decide(
|
||||
@@ -839,6 +940,26 @@ final class PlayerModel {
|
||||
playbackSession = target.sessionID
|
||||
playbackVersionID = target.versionID
|
||||
|
||||
// **Wer schon gesprungen ist, springt nicht noch einmal.**
|
||||
//
|
||||
// Flaro legt den Strom an der gewünschten Stelle an; Jellyfin
|
||||
// liefert den ganzen Film und lässt den Spieler springen. Beides
|
||||
// zugleich hiesse bei einundzwanzig Minuten Fortschritt, bei
|
||||
// zweiundvierzig zu landen.
|
||||
streamOffset = target.startOffset
|
||||
streamEnd = nil
|
||||
|
||||
// **Die Länge kommt aus der Datei, nicht vom Player.**
|
||||
//
|
||||
// Eine wachsende Abspielliste hat für AVPlayer keine Länge — sie
|
||||
// trägt kein `EXT-X-ENDLIST`, also bleibt die Dauer unbestimmt
|
||||
// und `durationResolved` kommt nie. Ohne diese Zeile blieb
|
||||
// `duration` auf nichts, und die Ansicht rechnet dann mit einer
|
||||
// Sekunde: der Regler stand bei 1:50:07 am Anschlag.
|
||||
if let bekannt = part.duration ?? item.duration {
|
||||
duration = .milliseconds(bekannt)
|
||||
}
|
||||
|
||||
// **Der Vorrat läuft voraus, nicht der Player.**
|
||||
//
|
||||
// AVPlayer entscheidet selbst, wie weit er puffert, und bei einer
|
||||
@@ -856,7 +977,7 @@ final class PlayerModel {
|
||||
return PlaybackSource(
|
||||
url: adresse,
|
||||
origin: .remote,
|
||||
startTime: start,
|
||||
startTime: target.startOffset > .zero ? .zero : start,
|
||||
// **Mit eigenem Vorrat ein kleiner Puffer.**
|
||||
//
|
||||
// Er bestimmt, wie viel AVPlayer beisammenhaben will, bevor
|
||||
@@ -1504,6 +1625,10 @@ final class PlayerModel {
|
||||
// Der Sprung **ist** angekommen, in dem Moment, in dem man ihn
|
||||
// auslöst. Alles Weitere ist Warten auf Daten, und dafür gibt es den
|
||||
// Kreisel.
|
||||
// **Vor dem Aufräumen entscheiden.** Die Antwort hängt daran, wie
|
||||
// weit der Vorrat reicht — und der wird gleich verworfen.
|
||||
let neuerStrom = ausserhalbDesStroms(time)
|
||||
|
||||
currentTime = time
|
||||
seekTarget = time
|
||||
// Der Vorrat von vorhin gilt für die alte Stelle. Ihn stehen zu lassen
|
||||
@@ -1512,7 +1637,55 @@ final class PlayerModel {
|
||||
NowPlaying.shared.update(from: self)
|
||||
updateActiveMarker()
|
||||
|
||||
Task { await engine.seek(to: time) }
|
||||
// **Ausserhalb dieses Stroms springt nicht der Spieler, sondern der
|
||||
// Server.**
|
||||
//
|
||||
// Flaros Abspielliste beginnt an der gewünschten Stelle und wächst
|
||||
// von dort vorwärts. Was davor liegt und was noch nicht gerechnet
|
||||
// ist, gibt es darin nicht; ein Sprung dorthin ginge ins Leere und
|
||||
// der Spieler bliebe mit einem Kreisel stehen. Also wird der Strom
|
||||
// neu aufgebaut — `restart` steigt bei `currentTime` wieder ein, und
|
||||
// das ist genau die Stelle, die eben gesetzt wurde.
|
||||
if neuerStrom {
|
||||
Task { await restart() }
|
||||
return
|
||||
}
|
||||
|
||||
Task { await engine.seek(to: time - streamOffset) }
|
||||
}
|
||||
|
||||
/// Ob diese Stelle in dem Strom liegt, der gerade läuft.
|
||||
///
|
||||
/// **Innerhalb des Erzeugten springt der Spieler selbst.** Dafür eine
|
||||
/// neue Sitzung zu starten hiesse, Sekunden zu warten, um fünf Sekunden
|
||||
/// zurückzugehen.
|
||||
private func ausserhalbDesStroms(_ time: Duration) -> Bool {
|
||||
// **Eine Datei mit bekannter Länge: überallhin.** `streamEnd` steht
|
||||
// nur, wenn der Player eine Dauer ermitteln konnte, und das kann er
|
||||
// genau dann, wenn das Ganze schon dasteht — eine Mediendatei oder
|
||||
// eine fertige Abspielliste.
|
||||
if streamEnd != nil, streamOffset == .zero { return false }
|
||||
|
||||
// Vor dem Anfang des Stroms gibt es in ihm nichts.
|
||||
if time < streamOffset { return true }
|
||||
|
||||
// **Eine wachsende Liste reicht so weit, wie Daten da sind.**
|
||||
//
|
||||
// Hier stand ein Vergleich mit `streamEnd`, und der ging nie auf:
|
||||
// bei einer wachsenden Liste bleibt die Dauer unbestimmt, also ist
|
||||
// `streamEnd` nichts, und jeder Sprung galt als innerhalb. AVPlayer
|
||||
// bekam ein Ziel, zu dem es keine Schnipsel gibt, verwarf es, und
|
||||
// der Regler sprang zurück — beim Ziehen sichtbar, im Pausieren
|
||||
// nicht, weil die Liste dort weiterwuchs und ihn irgendwann einholte.
|
||||
//
|
||||
// **Weiss niemand, wie weit es reicht, wird neu gebaut.** Ein neuer
|
||||
// Strom dauert ein paar Sekunden; ein Sprung, der nichts tut, ist
|
||||
// kaputt.
|
||||
guard let bis = bufferedUntil else { return true }
|
||||
|
||||
// Eine halbe Sekunde Nachsicht an der Kante: dort einen neuen Strom
|
||||
// zu bauen wäre Aufwand für nichts.
|
||||
return time > bis + .milliseconds(500)
|
||||
}
|
||||
|
||||
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
|
||||
@@ -1523,9 +1696,20 @@ final class PlayerModel {
|
||||
|
||||
/// Springt ans Ende der laufenden Marke.
|
||||
func skipActiveMarker() {
|
||||
guard let marker = activeMarker else { return }
|
||||
guard let marker = activeMarker, let item else { return }
|
||||
skippedMarkers.insert(marker.id)
|
||||
seek(to: .milliseconds(marker.endMilliseconds))
|
||||
|
||||
// **Mit dem Schalter in einem Zug bis ans Ende.** Sonst endet der
|
||||
// Sprung an dieser Marke, also bei der Post-Credit-Szene — wer sie
|
||||
// nicht sehen will, stellt es einmal um und drückt einmal.
|
||||
let ziel = settings.skipExtraScenes
|
||||
? item.markers
|
||||
.filter { $0.kind == marker.kind && $0.startMilliseconds >= marker.startMilliseconds }
|
||||
.map(\.endMilliseconds)
|
||||
.max() ?? marker.endMilliseconds
|
||||
: marker.endMilliseconds
|
||||
|
||||
seek(to: .milliseconds(ziel))
|
||||
}
|
||||
|
||||
/// Ob es bei der Tonspur überhaupt etwas zu wählen gibt.
|
||||
@@ -1684,14 +1868,19 @@ final class PlayerModel {
|
||||
let stelle = currentTime > .zero ? currentTime : request.startTime
|
||||
guard stelle > .zero || state == .started else { return }
|
||||
|
||||
await backend.reportProgress(PlaybackReport(
|
||||
let bericht = PlaybackReport(
|
||||
itemID: item.id,
|
||||
sessionID: playbackSession,
|
||||
versionID: playbackVersionID,
|
||||
state: state,
|
||||
time: stelle,
|
||||
duration: total
|
||||
))
|
||||
)
|
||||
// **Was nicht ankommt, wird zurückgelegt.** Ohne das war eine Folge,
|
||||
// die man offline zu Ende gesehen hat, für den Server nie gelaufen.
|
||||
if await backend.reportProgress(bericht) == false {
|
||||
post?.zuruecklegen(bericht, server: backend.identity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Marken und nächste Folge
|
||||
@@ -1700,14 +1889,14 @@ final class PlayerModel {
|
||||
guard let item else { return }
|
||||
let milliseconds = currentTime.milliseconds
|
||||
|
||||
activeMarker = item.markers.first { marker in
|
||||
guard marker.contains(milliseconds) else { return false }
|
||||
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
|
||||
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
|
||||
// „Film beenden".
|
||||
if marker.isCredits && item.kind != .episode { return false }
|
||||
return true
|
||||
}
|
||||
// **Was der Server schickt, gilt.** Hier stand eine eigene Regel:
|
||||
// bei einem Film wurde die Abspannmarke verworfen, weil
|
||||
// „überspringen" dort gleichbedeutend mit „beenden" war. Das ist
|
||||
// eine Entscheidung über Marken, und die trifft der Server — er
|
||||
// schickt sie nur, wenn er einen Abspann gefunden hat, und seit
|
||||
// ein Abspann geteilt sein darf, führt der Knopf zur
|
||||
// Post-Credit-Szene statt ins Nichts.
|
||||
activeMarker = item.markers.first { $0.contains(milliseconds) }
|
||||
|
||||
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
|
||||
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
|
||||
@@ -1715,21 +1904,43 @@ final class PlayerModel {
|
||||
if shouldSkip { skipActiveMarker() }
|
||||
}
|
||||
|
||||
/// **Beide Nachbarn, nicht nur der nächste.**
|
||||
///
|
||||
/// Die nächste Folge wurde schon immer vorgeladen — für den Knopf am
|
||||
/// Ende. Seit es im Spieler Knöpfe zum Blättern gibt, gilt dasselbe für
|
||||
/// die vorige: wer eine zu weit gesprungen ist, soll zurück können, ohne
|
||||
/// den Spieler zu verlassen.
|
||||
private func preloadNextEpisode() {
|
||||
guard let item, item.kind == .episode, let backend else { return }
|
||||
Task { [weak self] in
|
||||
let next = try? await backend.nextEpisode(after: item)
|
||||
self?.nextEpisode = next
|
||||
}
|
||||
Task { [weak self] in
|
||||
let vorige = try? await backend.previousEpisode(before: item)
|
||||
self?.previousEpisode = vorige
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
|
||||
/// Koordinator weiter.
|
||||
func nextEpisodeRequest() -> PlaybackRequest? {
|
||||
guard let nextEpisode else { return nil }
|
||||
func nextEpisodeRequest() -> PlaybackRequest? { wunsch(für: nextEpisode) }
|
||||
|
||||
/// Dasselbe rückwärts.
|
||||
func previousEpisodeRequest() -> PlaybackRequest? { wunsch(für: previousEpisode) }
|
||||
|
||||
/// **Von vorn, nicht von der gemerkten Stelle.**
|
||||
///
|
||||
/// Wer blättert, will die Folge sehen. Beim Vorwärtsblättern ist sie
|
||||
/// ohnehin meist ungesehen; beim Zurückblättern stünde man sonst im
|
||||
/// Abspann der Folge, die man gerade noch einmal anfangen wollte. Wer
|
||||
/// fortsetzen will, nimmt den Weg über die Serienseite — dort steht die
|
||||
/// Stelle dabei.
|
||||
private func wunsch(für folge: MediaItem?) -> PlaybackRequest? {
|
||||
guard let folge else { return nil }
|
||||
return PlaybackRequest(
|
||||
ratingKey: nextEpisode.id,
|
||||
title: nextEpisode.playbackTitle,
|
||||
ratingKey: folge.id,
|
||||
title: folge.playbackTitle,
|
||||
mediaIndex: 0,
|
||||
startTime: .zero
|
||||
)
|
||||
@@ -1871,7 +2082,14 @@ final class PlayerModel {
|
||||
String(describing: failure),
|
||||
].joined(separator: "\n\n"))
|
||||
}
|
||||
case .timeChanged(let time):
|
||||
case .timeChanged(let stromzeit):
|
||||
// **Die Uhr des Spielers ist nicht die Uhr des Films.**
|
||||
//
|
||||
// Beginnt der Strom bei 1:32, zählt der Spieler trotzdem
|
||||
// ab null. Ab hier wird in Filmzeit gerechnet — alles
|
||||
// darunter, von der Anzeige bis zur Fortschrittsmeldung,
|
||||
// meint denselben Punkt im Film.
|
||||
let time = streamOffset + stromzeit
|
||||
// **Nachzügler verwerfen.** Nach einem Sprung meldet der
|
||||
// Player noch ein paar Mal die alte Stelle, bis er dort
|
||||
// ankommt. Ungeprüft übernommen zöge das den Regler wieder
|
||||
@@ -1901,7 +2119,22 @@ final class PlayerModel {
|
||||
}
|
||||
updateActiveMarker()
|
||||
case .durationResolved(let value):
|
||||
duration = value
|
||||
streamEnd = streamOffset + value
|
||||
// **Die Länge des Films, nicht die des Stroms.**
|
||||
//
|
||||
// Eine wachsende Abspielliste meldet anfangs ein paar
|
||||
// Sekunden. Als Filmlänge genommen stünde der Regler
|
||||
// sofort am Ende, und ein Sprung nach vorn wäre
|
||||
// unmöglich.
|
||||
// **Die Datei weiss es genau, der Titel oft gar nicht.**
|
||||
// `item.duration` kommt aus der Liste, und dort stehen
|
||||
// keine Dateien — bei einem Titel, der aus einer Reihe
|
||||
// geöffnet wurde, ist es nichts. Dann blieb nur die Länge
|
||||
// des Stroms, und die ist am Anfang ein paar Sekunden:
|
||||
// Regler am Ende, obwohl noch zwanzig Minuten kommen.
|
||||
duration = currentPart?.duration.map { Duration.milliseconds($0) }
|
||||
?? item?.duration.map { Duration.milliseconds($0) }
|
||||
?? streamEnd
|
||||
NowPlaying.shared.update(from: self)
|
||||
case .reachedEnd:
|
||||
isPlaying = false
|
||||
@@ -1943,14 +2176,52 @@ final class PlayerModel {
|
||||
/// Einstellungen eine Untertitelsprache steht. Wer keine eingestellt
|
||||
/// hatte, schaltete die Untertitel ein und sah nichts.
|
||||
///
|
||||
/// **Die erste ist die richtige.** Rechnet der Server um, liegt genau
|
||||
/// eine im Stream — nämlich die bestellte. Es gibt nichts zu verwechseln.
|
||||
/// **Die erste ist nicht mehr die richtige.**
|
||||
///
|
||||
/// Das galt, solange der Server genau eine Spur auslieferte — die
|
||||
/// bestellte. Flaro legt seit dem Strom **alle** Textspuren als eigene
|
||||
/// Spuren in die Abspielliste, und dann ist die erste schlicht die
|
||||
/// erste: wer Deutsch wählte, sah Englisch.
|
||||
///
|
||||
/// Gesucht wird deshalb die Spur, die zur Bestellung passt — erst über
|
||||
/// das Sprachkürzel, dann über den Namen. Findet sich keine, bleibt es
|
||||
/// bei der ersten: bei einem umgerechneten Strom gibt es nur sie, und
|
||||
/// gar keinen Untertitel zu zeigen wäre schlechter als den einen.
|
||||
private func showOrderedSubtitle() {
|
||||
guard selectedSubtitleStream != nil, selectedSubtitleTrack == nil else { return }
|
||||
guard let erste = subtitleTracks.first else { return }
|
||||
guard let bestellt = selectedSubtitleStream, selectedSubtitleTrack == nil else { return }
|
||||
guard let treffer = passendeSpur(zu: bestellt) ?? subtitleTracks.first else { return }
|
||||
|
||||
diagnostics.note("Untertitel eingeschaltet: \(erste.displayName)")
|
||||
selectSubtitleTrack(erste)
|
||||
diagnostics.note("Untertitel eingeschaltet: \(treffer.displayName)")
|
||||
selectSubtitleTrack(treffer)
|
||||
}
|
||||
|
||||
/// Die Spur im Strom, die zu der bestellten aus den Metadaten gehört.
|
||||
private func passendeSpur(zu bestellt: MediaStream) -> MediaTrack? {
|
||||
// **Die Sprache zuerst.** Sie steht in der Abspielliste als
|
||||
// `LANGUAGE` und ist das, was ein Spieler verlässlich durchreicht.
|
||||
if let code = bestellt.languageCode ?? bestellt.language,
|
||||
let treffer = subtitleTracks.first(where: {
|
||||
LanguageCode.matches(code, $0.languageCode)
|
||||
&& $0.isForced == bestellt.isForced
|
||||
})
|
||||
{
|
||||
return treffer
|
||||
}
|
||||
|
||||
// **Dann der Name.** Zwei Spuren in derselben Sprache — Dialoge und
|
||||
// Schilder etwa — unterscheiden sich nur darüber.
|
||||
if let name = bestellt.displayTitle,
|
||||
let treffer = subtitleTracks.first(where: { $0.displayName == name })
|
||||
{
|
||||
return treffer
|
||||
}
|
||||
|
||||
// Ohne Sprachkürzel bleibt die Sprache allein.
|
||||
if let code = bestellt.languageCode ?? bestellt.language {
|
||||
return subtitleTracks.first { LanguageCode.matches(code, $0.languageCode) }
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Wählt die Untertitelspur, sofern eine gewünscht ist.
|
||||
@@ -1964,9 +2235,15 @@ final class PlayerModel {
|
||||
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
|
||||
/// eine später gemeldete Spurliste das nicht zurücksetzen.
|
||||
private func applyPreferredLanguages() {
|
||||
guard selectedSubtitleTrack == nil, !languages.subtitleLanguage.isEmpty,
|
||||
// **Die Ebene schlägt das Allgemeine.** Hier stand nur die
|
||||
// allgemeine Sprache; eine Serie, die man im Original mit deutschen
|
||||
// Untertiteln sieht, liess sich damit nicht einstellen, ohne sie für
|
||||
// alles andere auch einzuschalten. Bei einer Folge gilt die Serie,
|
||||
// bei einem Film der Titel selbst.
|
||||
guard selectedSubtitleTrack == nil,
|
||||
let wunsch = languages.subtitleWish(titleID: titelbereich),
|
||||
let treffer = subtitleTracks.first(where: {
|
||||
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
|
||||
LanguageCode.matches(wunsch, $0.languageCode)
|
||||
})
|
||||
else { return }
|
||||
selectSubtitleTrack(treffer)
|
||||
@@ -1984,6 +2261,14 @@ final class PlayerModel {
|
||||
/// Netz und ohne Einstellungen, damit sich der ZDF-Fall wörtlich
|
||||
/// hinschreiben und durchspielen lässt. Hier steht nur, woher der Wunsch
|
||||
/// kommt.
|
||||
/// Unter welcher Kennung die Vorwahl dieses Titels liegt.
|
||||
///
|
||||
/// **Bei einer Folge die Serie, bei einem Film er selbst.** „Diese Serie
|
||||
/// auf Japanisch" gilt für alle Folgen; „dieser Film auf Englisch" gilt
|
||||
/// für ihn. Beides ist dieselbe Frage auf der Ebene, die der Benutzer
|
||||
/// vor sich hatte, als er sie beantwortet hat.
|
||||
private var titelbereich: String? { item?.seriesID ?? item?.id }
|
||||
|
||||
private func chooseAudioStream(
|
||||
in part: MediaFile,
|
||||
of item: MediaItem,
|
||||
@@ -1993,10 +2278,9 @@ final class PlayerModel {
|
||||
subtitleStreams = part.subtitleStreams
|
||||
// Untertitel sind eine Zutat, keine Voraussetzung: der Wunsch gilt,
|
||||
// wenn es einen passenden gibt, und sonst bleibt es ohne.
|
||||
selectedSubtitleStream = languages.subtitleLanguage.isEmpty ? nil
|
||||
: subtitleStreams.first {
|
||||
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
|
||||
}
|
||||
selectedSubtitleStream = languages.subtitleWish(titleID: titelbereich).flatMap { wunsch in
|
||||
subtitleStreams.first { LanguageCode.matches(wunsch, $0.languageCode) }
|
||||
}
|
||||
|
||||
guard !audioStreams.isEmpty else {
|
||||
selectedAudioStream = nil
|
||||
@@ -2012,7 +2296,12 @@ final class PlayerModel {
|
||||
|
||||
selectedAudioStream = AudioTrackChooser.choose(
|
||||
from: part.streams,
|
||||
wish: languages.wish(seriesID: item.seriesID, libraryID: libraryID)
|
||||
// **Bei einem Film seine eigene Kennung.** Hier stand nur
|
||||
// `item.seriesID`, und die ist bei einem Film nichts: die
|
||||
// Vorwahl auf der Filmseite wurde abgelegt und nie wieder
|
||||
// gelesen. Untertitel gingen durch, Ton nicht — weil die beiden
|
||||
// an verschiedenen Stellen nachsehen.
|
||||
wish: languages.wish(seriesID: titelbereich, libraryID: libraryID)
|
||||
)
|
||||
|
||||
if let gewählt = selectedAudioStream {
|
||||
@@ -2032,8 +2321,8 @@ final class PlayerModel {
|
||||
guard stream != selectedAudioStream else { return }
|
||||
selectedAudioStream = stream
|
||||
|
||||
if let seriesID = item?.seriesID {
|
||||
languages.remember(stream, forSeries: seriesID)
|
||||
if let bereich = titelbereich {
|
||||
languages.remember(stream, forSeries: bereich)
|
||||
}
|
||||
|
||||
// **Der billige Weg, wenn er offensteht.** Wird die Datei unverändert
|
||||
@@ -2081,16 +2370,16 @@ final class PlayerModel {
|
||||
|
||||
/// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt.
|
||||
var rememberedTrackName: String? {
|
||||
guard let seriesID = item?.seriesID,
|
||||
let signatur = languages.rememberedTrack(forSeries: seriesID)
|
||||
guard let bereich = titelbereich,
|
||||
let signatur = languages.rememberedTrack(forSeries: bereich)
|
||||
else { return nil }
|
||||
return signatur.title ?? signatur.languageCode
|
||||
}
|
||||
|
||||
/// Vergisst die gemerkte Spur dieser Serie.
|
||||
func forgetRememberedTrack() {
|
||||
guard let seriesID = item?.seriesID else { return }
|
||||
languages.forgetTrack(forSeries: seriesID)
|
||||
guard let bereich = titelbereich else { return }
|
||||
languages.forgetTrack(forSeries: bereich)
|
||||
}
|
||||
|
||||
private func playerSubtitle(for item: MediaItem) -> String? {
|
||||
|
||||
@@ -52,6 +52,15 @@ struct PlayerView: View {
|
||||
@State private var trackPicker: TrackPickerSheet.Kind?
|
||||
@State private var showsQualityChoice = false
|
||||
@State private var hideTask: Task<Void, Never>?
|
||||
/// Ob die Marken-Knöpfe inzwischen an der Bedienung hängen.
|
||||
///
|
||||
/// **Erst für sich, dann mit der Bedienung.** Eine neue Marke zeigt
|
||||
/// ihre Knöpfe von selbst — sonst müsste man erst das Bild antippen,
|
||||
/// um das Intro überspringen zu können, und dann hätte man die
|
||||
/// Bedienung ohnehin. Sobald man sie aber einmal eingeblendet hat,
|
||||
/// gehören die Knöpfe dazu und gehen mit ihr. Keine Frist, kein
|
||||
/// Zeitgeber: der Zuschauer sagt selbst, wann er genug gesehen hat.
|
||||
@State private var markenGebunden = false
|
||||
@State private var scrubbing: Double?
|
||||
/// Welche Richtung gerade als Hinweis eingeblendet ist.
|
||||
@State private var seekHint: Double?
|
||||
@@ -106,8 +115,29 @@ struct PlayerView: View {
|
||||
// Jetzt rechnet das Layout. Der schwarze Rand ist schlicht
|
||||
// der Hintergrund dahinter, und wer ihn anklickt, klickt
|
||||
// nicht aufs Bild — das muss niemand mehr feststellen.
|
||||
// **Im Vollbild über alles, sonst nicht unter die
|
||||
// Insel.**
|
||||
//
|
||||
// Bei 21∶9 auf einem Telefon reicht das Bild über die
|
||||
// ganze Breite — und damit hinter die Dynamic Island, die
|
||||
// links einen Streifen davon verdeckt. Wer Vollbild
|
||||
// wählt, hat sich fürs Beschneiden entschieden; dort ist
|
||||
// Rand an Rand folgerichtig. Im normalen Modus ist der
|
||||
// Anspruch gerade, dass nichts fehlt — dann darf auch
|
||||
// nichts verdeckt sein. Plex macht es genauso.
|
||||
Color.clear
|
||||
.ignoresSafeArea()
|
||||
//
|
||||
// **Ein Modifikator mit leeren Kanten, kein `if`.**
|
||||
// Hier stand ein eigener `ViewModifier`, der
|
||||
// zwischen `ignoresSafeArea()` und gar nichts
|
||||
// umschaltete — und genau das ist in SwiftUI ein
|
||||
// Zweig: beim Umschalten wurde der Baum darunter
|
||||
// neu aufgebaut, die Bildebene mit ihm, und der
|
||||
// Spieler stand ohne sie da. Schwarz, sobald
|
||||
// jemand auf „Bildfüllend" wechselte. `edges: []`
|
||||
// ignoriert nichts und ist doch derselbe
|
||||
// Modifikator — die Ansicht bleibt dieselbe.
|
||||
.ignoresSafeArea(.container, edges: model.videoFit == .fill ? .all : [])
|
||||
.overlay {
|
||||
surface(for: model)
|
||||
.contentShape(.rect)
|
||||
@@ -341,6 +371,17 @@ struct PlayerView: View {
|
||||
.environment(settings)
|
||||
.environment(\.flaroAccent, accent)
|
||||
}
|
||||
// Eine neue Marke ist ein neues Angebot: sie steht wieder für sich,
|
||||
// bis die Bedienung das nächste Mal auftaucht.
|
||||
.onChange(of: model.activeMarker?.id, initial: true) { _, _ in
|
||||
markenGebunden = false
|
||||
}
|
||||
// **Der Übergang zählt, nicht der Zustand.** Beim Start liegt die
|
||||
// Bedienung ohnehin da; das als „einmal gezeigt" zu werten hiesse,
|
||||
// dem Intro seinen Knopf zu nehmen, bevor es ihn je hatte.
|
||||
.onChange(of: showsControls) { vorher, jetzt in
|
||||
if !vorher && jetzt { markenGebunden = true }
|
||||
}
|
||||
.onChange(of: showsQualityChoice) { _, isShown in
|
||||
// Nach dem Schließen die Bedienelemente stehen lassen und die
|
||||
// Ausblendfrist neu starten. Ohne das verschwänden sie sofort,
|
||||
@@ -594,7 +635,25 @@ struct PlayerView: View {
|
||||
}
|
||||
|
||||
private func transport(for model: PlayerModel) -> some View {
|
||||
HStack(spacing: FlaroSpacing.section * 2) {
|
||||
// **Enger, seit zwei Knöpfe dazukommen.** Bei fünf Knöpfen in einer
|
||||
// Reihe wird der alte Abstand auf einem Telefon im Hochformat zu
|
||||
// breit; bei drei bleibt er, wie er war.
|
||||
let folgen = model.previousEpisode != nil || model.nextEpisode != nil
|
||||
|
||||
return HStack(spacing: folgen ? FlaroSpacing.section : FlaroSpacing.section * 2) {
|
||||
// **Eine Folge zurück — nur bei Serien, nur wenn es eine gibt.**
|
||||
//
|
||||
// Bei einem Film bleibt die Reihe, wie sie war: ein Knopf, der
|
||||
// nie etwas tut, ist schlechter als keiner.
|
||||
if model.previousEpisode != nil {
|
||||
Button {
|
||||
if let wunsch = model.previousEpisodeRequest() { playback.play(wunsch) }
|
||||
} label: {
|
||||
Image(systemName: "backward.end.fill").font(.title2)
|
||||
}
|
||||
.accessibilityLabel(Text("player.previousEpisode"))
|
||||
}
|
||||
|
||||
Button {
|
||||
model.skip(by: .seconds(-settings.rewindSeconds))
|
||||
scheduleControlsHide()
|
||||
@@ -638,6 +697,15 @@ struct PlayerView: View {
|
||||
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
|
||||
}
|
||||
.accessibilityLabel(Text("player.forward10"))
|
||||
|
||||
if model.nextEpisode != nil {
|
||||
Button {
|
||||
if let wunsch = model.nextEpisodeRequest() { playback.play(wunsch) }
|
||||
} label: {
|
||||
Image(systemName: "forward.end.fill").font(.title2)
|
||||
}
|
||||
.accessibilityLabel(Text("player.nextEpisode"))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
@@ -662,7 +730,9 @@ struct PlayerView: View {
|
||||
// springen lässt das Bild flackern und hält bei großen
|
||||
// Dateien den Puffer dauerhaft leer.
|
||||
onCommit: { model.seek(to: $0) },
|
||||
onActivity: { scheduleControlsHide() }
|
||||
onActivity: { scheduleControlsHide() },
|
||||
vorschau: model.vorschau,
|
||||
blatt: { model.vorschaublatt($0) }
|
||||
)
|
||||
|
||||
HStack {
|
||||
@@ -683,12 +753,31 @@ struct PlayerView: View {
|
||||
|
||||
/// Überspringen-Knopf und „Nächste Folge".
|
||||
@ViewBuilder
|
||||
/// Ob jetzt „Nächste Folge" dransteht.
|
||||
///
|
||||
/// Am Ende immer, im Abspann auch — dort ist es die Antwort auf die
|
||||
/// Frage, die der Abspann stellt. Gibt es keine nächste Folge, bleibt
|
||||
/// es beim Überspringen.
|
||||
private func naechsteAnbieten(_ model: PlayerModel) -> Bool {
|
||||
guard model.nextEpisodeRequest() != nil else { return false }
|
||||
return model.hasReachedEnd || model.activeMarker?.isCredits == true
|
||||
}
|
||||
|
||||
private func overlayActions(for model: PlayerModel) -> some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
if model.hasReachedEnd, model.nextEpisodeRequest() != nil {
|
||||
// **Im Abspann ist „überspringen" die falsche Frage — aber
|
||||
// nicht die einzige.**
|
||||
//
|
||||
// Was man dort meistens will, ist die nächste Folge; ans
|
||||
// Ende dieser zu springen bringt niemanden weiter. Wer den
|
||||
// Abspann aber sehen wollte und es sich anders überlegt,
|
||||
// soll ihn trotzdem überspringen können. Zwei Antworten,
|
||||
// zwei Knöpfe — welche gemeint ist, weiss nur der
|
||||
// Zuschauer.
|
||||
if naechsteAnbieten(model) {
|
||||
Button {
|
||||
if let next = model.nextEpisodeRequest() {
|
||||
playback.play(next)
|
||||
@@ -701,7 +790,9 @@ struct PlayerView: View {
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
} else if let marker = model.activeMarker {
|
||||
}
|
||||
|
||||
if let marker = model.activeMarker {
|
||||
Button {
|
||||
model.skipActiveMarker()
|
||||
} label: {
|
||||
@@ -721,9 +812,28 @@ struct PlayerView: View {
|
||||
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
||||
// die Bedienelemente eingeblendet sind.
|
||||
.padding(.bottom, (showsControls ? 110 : FlaroSpacing.section) + safeArea.bottom)
|
||||
// **Am Ende bleibt es stehen, sonst nicht.**
|
||||
//
|
||||
// Ist der Film aus, ist der Knopf die einzige Frage, die noch
|
||||
// offen ist — der darf warten. Mitten in einer Marke ist er ein
|
||||
// Angebot, und ein Angebot, das nicht angenommen wird, nimmt
|
||||
// man zurück.
|
||||
.opacity(markenSichtbar(model) ? 1 : 0)
|
||||
.allowsHitTesting(markenSichtbar(model))
|
||||
}
|
||||
.animation(.snappy, value: model.activeMarker?.id)
|
||||
.animation(.snappy, value: model.hasReachedEnd)
|
||||
.animation(.easeIn(duration: 0.28), value: markenGebunden)
|
||||
}
|
||||
|
||||
/// **Dieselbe Regel für Vorspann und Abspann, Web wie App.**
|
||||
///
|
||||
/// Sichtbar, solange die Knöpfe noch für sich stehen; danach genau
|
||||
/// dann, wenn die Bedienung da ist. Am Ende des Films bleiben sie
|
||||
/// trotzdem: dort ist „Nächste Folge" die einzige noch offene Frage,
|
||||
/// und es gibt kein Bild mehr, das sie verdecken könnten.
|
||||
private func markenSichtbar(_ model: PlayerModel) -> Bool {
|
||||
model.hasReachedEnd || showsControls || !markenGebunden
|
||||
}
|
||||
|
||||
/// Die Abdeckung während des Umstellens.
|
||||
|
||||
@@ -48,7 +48,7 @@ struct SearchView: View {
|
||||
// weiß aber noch nicht, was er sehen will; der erste
|
||||
// Treffer ist eine Vermutung des Servers. Starten geht
|
||||
// weiter, über langes Drücken.
|
||||
MediaRow(row: hub, opensDetails: true)
|
||||
MediaRow(row: hub)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, FlaroSpacing.loose)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import MediaKit
|
||||
|
||||
/// „Welche Tonsprache gilt hier?" — an genau zwei Orten dieselbe Frage.
|
||||
/// „Welche Sprache gilt hier?" — an mehreren Orten dieselbe Frage.
|
||||
///
|
||||
/// **Warum ein gemeinsamer Typ.** Die Frage steht einmal auf der Serienseite
|
||||
/// und einmal bei der Mediathek, und die Antwort bedeutet beide Male dasselbe:
|
||||
/// diese Ebene setzt sich gegen die darüber durch, oder sie tut es nicht. Zwei
|
||||
/// Auswahlknöpfe mit demselben Inhalt liefen unweigerlich auseinander — einer
|
||||
/// bekäme eine Sprache dazu, der andere nicht.
|
||||
/// **Warum ein gemeinsamer Typ.** Die Frage steht auf der Serienseite, auf
|
||||
/// der Filmseite und bei der Mediathek, und die Antwort bedeutet jedes Mal
|
||||
/// dasselbe: diese Ebene setzt sich gegen die darüber durch, oder sie tut es
|
||||
/// nicht. Mehrere Auswahlknöpfe mit demselben Inhalt liefen unweigerlich
|
||||
/// auseinander — einer bekäme eine Sprache dazu, der andere nicht.
|
||||
///
|
||||
/// **Ton und Untertitel im selben Typ.** Sie unterscheiden sich in genau
|
||||
/// zwei Punkten: woher der Wert kommt, und ob es „keine" gibt. Ein zweiter
|
||||
/// Typ dafür wäre dieselbe Datei mit zwei geänderten Zeilen.
|
||||
///
|
||||
/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein
|
||||
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
|
||||
@@ -15,25 +20,98 @@ import FlaroDesign
|
||||
struct AudioLanguagePicker: View {
|
||||
/// Für welche Ebene gewählt wird.
|
||||
enum Scope {
|
||||
/// Eine Serie oder ein Film — beides eine Kennung, beides ein Titel.
|
||||
case series(String)
|
||||
case library(String)
|
||||
}
|
||||
|
||||
/// Ton oder Text.
|
||||
enum Kind {
|
||||
case audio
|
||||
case subtitles
|
||||
}
|
||||
|
||||
/// Wie der Knopf aussieht.
|
||||
enum Form {
|
||||
/// Eine Zeile: Beschriftung links, Wahl rechts. Für Listen.
|
||||
case zeile
|
||||
/// Ein Knopf wie „Ton: Deutsch". Für die Reihe neben „Abspielen".
|
||||
case knopf
|
||||
}
|
||||
|
||||
let scope: Scope
|
||||
let title: LocalizedStringKey
|
||||
var kind: Kind = .audio
|
||||
var form: Form = .zeile
|
||||
|
||||
/// Welche Sprachen es wirklich gibt — `nil` heisst: alle anbieten.
|
||||
///
|
||||
/// **Eine Liste mit zwanzig Sprachen für eine Serie mit zweien** ist
|
||||
/// keine Auswahl, sondern eine Suche. Weiss niemand, welche es gibt,
|
||||
/// bleibt es bei der vollen Liste: eine zu lange ist besser als eine
|
||||
/// leere.
|
||||
var available: [LanguageOption]?
|
||||
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.flaroAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
Text(title)
|
||||
.font(.flaroBody)
|
||||
switch form {
|
||||
case .zeile:
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
Text(title)
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
|
||||
Spacer(minLength: FlaroSpacing.regular)
|
||||
|
||||
menü { auswahl }
|
||||
}
|
||||
|
||||
case .knopf:
|
||||
// **Beschriftung und Wert in einem.** Neben „Abspielen" ist kein
|
||||
// Platz für eine Zeile mit Spalten; „Ton: Deutsch" sagt dasselbe
|
||||
// in einem Wort mehr.
|
||||
menü {
|
||||
HStack(spacing: FlaroSpacing.tight) {
|
||||
Image(systemName: kind == .audio ? "waveform" : "captions.bubble")
|
||||
Text("\(Text(title)): \(currentLabel)")
|
||||
.font(.flaroCardTitle)
|
||||
// **Eine Zeile, keine Säule.** Umbrochen wird die
|
||||
// Reihe, nicht der einzelne Knopf — sonst steht
|
||||
// „Untertitel" wieder Buchstabe für Buchstabe
|
||||
// untereinander.
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.flaroCaption)
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.regular)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
.background(Color.flaroElevated, in: Capsule())
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: FlaroSpacing.regular)
|
||||
/// Das Menü selbst — zweimal dieselben Einträge, zwei Aufhänger.
|
||||
private func menü(@ViewBuilder label: () -> some View) -> some View {
|
||||
Menu { auswahlEintraege } label: { label() }
|
||||
}
|
||||
|
||||
Menu {
|
||||
private var auswahl: some View {
|
||||
HStack(spacing: FlaroSpacing.tight) {
|
||||
Text(currentLabel)
|
||||
.font(.flaroBody)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.flaroCaption)
|
||||
}
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var auswahlEintraege: some View {
|
||||
Group {
|
||||
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
|
||||
// sollen nichts Eigenes sagen, sondern die Frage nach oben
|
||||
// durchreichen.
|
||||
@@ -43,42 +121,65 @@ struct AudioLanguagePicker: View {
|
||||
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(LanguagePreferences.offeredCodes, id: \.self) { code in
|
||||
// **„Keine" nur beim Untertitel.** Beim Ton gibt es sie
|
||||
// nicht: ein Film ohne Tonspur ist kein Wunsch, sondern ein
|
||||
// Fehler.
|
||||
if kind == .subtitles {
|
||||
Button {
|
||||
set(code)
|
||||
set(LanguagePreferences.noSubtitles)
|
||||
} label: {
|
||||
Label(name(of: code), systemImage: current == code ? "checkmark" : "")
|
||||
Label(
|
||||
"languages.none",
|
||||
systemImage: current == LanguagePreferences.noSubtitles
|
||||
? "checkmark" : ""
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: FlaroSpacing.tight) {
|
||||
Text(currentLabel)
|
||||
.font(.flaroBody)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.flaroCaption)
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(options, id: \.code) { option in
|
||||
Button {
|
||||
set(option.code)
|
||||
} label: {
|
||||
Label(
|
||||
option.name,
|
||||
systemImage: current == option.code ? "checkmark" : ""
|
||||
)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Was zur Wahl steht — das Vorhandene, sonst die allgemeine Liste.
|
||||
private var options: [LanguageOption] {
|
||||
available ?? LanguagePreferences.offeredCodes.map {
|
||||
LanguageOption(code: $0, name: name(of: $0))
|
||||
}
|
||||
}
|
||||
|
||||
private var current: String? {
|
||||
switch scope {
|
||||
case .series(let id): languages.audioLanguage(forSeries: id)
|
||||
case .library(let id): languages.audioLanguage(forLibrary: id)
|
||||
switch (scope, kind) {
|
||||
case (.series(let id), .audio): languages.audioLanguage(forSeries: id)
|
||||
case (.series(let id), .subtitles): languages.subtitleLanguage(forSeries: id)
|
||||
case (.library(let id), .audio): languages.audioLanguage(forLibrary: id)
|
||||
// Je Mediathek gibt es keine Untertitelsprache — siehe `subtitleWish`.
|
||||
case (.library, .subtitles): nil
|
||||
}
|
||||
}
|
||||
|
||||
private var currentLabel: String {
|
||||
current.map(name(of:)) ?? String(localized: "languages.inherit")
|
||||
guard let current else { return String(localized: "languages.inherit") }
|
||||
if current == LanguagePreferences.noSubtitles { return String(localized: "languages.none") }
|
||||
return options.first { $0.code == current }?.name ?? name(of: current)
|
||||
}
|
||||
|
||||
private func set(_ code: String?) {
|
||||
switch scope {
|
||||
case .series(let id): languages.setAudioLanguage(code, forSeries: id)
|
||||
case .library(let id): languages.setAudioLanguage(code, forLibrary: id)
|
||||
switch (scope, kind) {
|
||||
case (.series(let id), .audio): languages.setAudioLanguage(code, forSeries: id)
|
||||
case (.series(let id), .subtitles): languages.setSubtitleLanguage(code, forSeries: id)
|
||||
case (.library(let id), .audio): languages.setAudioLanguage(code, forLibrary: id)
|
||||
case (.library, .subtitles): break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ struct PlaybackSettingsView: View {
|
||||
Section {
|
||||
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
|
||||
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
|
||||
Toggle("settings.skipExtraScenes", isOn: $settings.skipExtraScenes)
|
||||
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
|
||||
} header: {
|
||||
Text("settings.episodes")
|
||||
|
||||
@@ -101,6 +101,19 @@ public actor ArtworkLoader {
|
||||
using session: URLSession,
|
||||
attempts: Int = 3
|
||||
) async -> PlatformImage? {
|
||||
// **Eine Datei auf der Platte ist kein Server.**
|
||||
//
|
||||
// Hier stand nur der Weg über HTTP, und die Antwort einer
|
||||
// `file:`-Adresse ist keine `HTTPURLResponse` — die Prüfung darunter
|
||||
// gab also `nil` zurück, immer. Sichtbar wurde das erst, als die
|
||||
// Downloads ihre Plakate mitbrachten: sie lagen auf der Platte, und
|
||||
// die Kacheln blieben trotzdem grau. Kein Wiederholen, kein
|
||||
// Zeitablauf: eine Datei ist entweder da oder nicht.
|
||||
if url.isFileURL {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return PlatformImage(data: data)
|
||||
}
|
||||
|
||||
for attempt in 1...attempts {
|
||||
if Task.isCancelled { return nil }
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Eine Reihe, die umbricht statt zu quetschen.
|
||||
///
|
||||
/// **Der Anlass.** Im Kopf einer Detailseite stehen Abspielen, Von vorn,
|
||||
/// Herunterladen und zwei Sprachknöpfe nebeneinander. In einem `HStack`
|
||||
/// teilen sie sich die Breite — auf einem Telefon wurde daraus eine Reihe
|
||||
/// von Säulen aus einzelnen Buchstaben, untereinander gesetzt. Lesbar war
|
||||
/// nichts mehr, und angetippt hätte man trotzdem das Richtige.
|
||||
///
|
||||
/// **Umbrechen ist die Antwort, nicht Weglassen.** Ein Knopf, der auf einem
|
||||
/// schmalen Gerät verschwindet, ist eine zweite Oberfläche, die gepflegt
|
||||
/// werden will. Zwei Zeilen sind kein Mangel; sie sind, was dort hinpasst.
|
||||
///
|
||||
/// **Warum eigener `Layout` und kein `ViewThatFits`.** Das wählt zwischen
|
||||
/// fertigen Entwürfen — man müsste jede Aufteilung von Hand hinschreiben,
|
||||
/// und bei fünf Knöpfen sind das fünf. Hier wird gerechnet.
|
||||
public struct Flusszeile: Layout {
|
||||
private let abstand: CGFloat
|
||||
private let zeilenabstand: CGFloat
|
||||
|
||||
public init(abstand: CGFloat = FlaroSpacing.regular, zeilenabstand: CGFloat? = nil) {
|
||||
self.abstand = abstand
|
||||
self.zeilenabstand = zeilenabstand ?? abstand
|
||||
}
|
||||
|
||||
public func sizeThatFits(
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout Void
|
||||
) -> CGSize {
|
||||
let breite = proposal.width ?? .infinity
|
||||
let zeilen = umbrechen(subviews, in: breite)
|
||||
|
||||
let hoehe = zeilen.reduce(CGFloat.zero) { $0 + $1.hoehe }
|
||||
+ CGFloat(max(0, zeilen.count - 1)) * zeilenabstand
|
||||
|
||||
// **Die breiteste Zeile, nicht die angebotene Breite.** Sonst nimmt
|
||||
// die Reihe das ganze Fenster ein, auch wenn zwei Knöpfe darin
|
||||
// stehen — und alles daneben rutscht weg.
|
||||
let genutzt = zeilen.map(\.breite).max() ?? 0
|
||||
return CGSize(width: min(genutzt, breite), height: hoehe)
|
||||
}
|
||||
|
||||
public func placeSubviews(
|
||||
in bounds: CGRect,
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout Void
|
||||
) {
|
||||
var y = bounds.minY
|
||||
|
||||
for zeile in umbrechen(subviews, in: bounds.width) {
|
||||
var x = bounds.minX
|
||||
|
||||
for index in zeile.teile {
|
||||
let groesse = subviews[index].sizeThatFits(.unspecified)
|
||||
subviews[index].place(
|
||||
at: CGPoint(x: x, y: y + (zeile.hoehe - groesse.height) / 2),
|
||||
proposal: ProposedViewSize(groesse)
|
||||
)
|
||||
x += groesse.width + abstand
|
||||
}
|
||||
|
||||
y += zeile.hoehe + zeilenabstand
|
||||
}
|
||||
}
|
||||
|
||||
private struct Zeile {
|
||||
var teile: [Int] = []
|
||||
var breite: CGFloat = 0
|
||||
var hoehe: CGFloat = 0
|
||||
}
|
||||
|
||||
private func umbrechen(_ subviews: Subviews, in breite: CGFloat) -> [Zeile] {
|
||||
var zeilen: [Zeile] = []
|
||||
var jetzt = Zeile()
|
||||
|
||||
for index in subviews.indices {
|
||||
let groesse = subviews[index].sizeThatFits(.unspecified)
|
||||
let dazu = jetzt.teile.isEmpty ? groesse.width : jetzt.breite + abstand + groesse.width
|
||||
|
||||
// **Das erste Element bleibt immer stehen.** Ein Knopf, der
|
||||
// allein schon breiter ist als die Zeile, käme sonst in eine
|
||||
// leere Zeile und von dort in die nächste, endlos.
|
||||
if !jetzt.teile.isEmpty, dazu > breite {
|
||||
zeilen.append(jetzt)
|
||||
jetzt = Zeile()
|
||||
jetzt.teile = [index]
|
||||
jetzt.breite = groesse.width
|
||||
jetzt.hoehe = groesse.height
|
||||
continue
|
||||
}
|
||||
|
||||
jetzt.teile.append(index)
|
||||
jetzt.breite = dazu
|
||||
jetzt.hoehe = max(jetzt.hoehe, groesse.height)
|
||||
}
|
||||
|
||||
if !jetzt.teile.isEmpty { zeilen.append(jetzt) }
|
||||
return zeilen
|
||||
}
|
||||
}
|
||||
@@ -291,7 +291,10 @@ public struct HeroBanner<Actions: View>: View {
|
||||
.frame(maxWidth: 640 * scale, alignment: .leading)
|
||||
}
|
||||
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
// **Umbrechen statt quetschen.** In einem `HStack` teilen sich
|
||||
// fünf Knöpfe die Breite; auf einem Telefon wurde daraus eine
|
||||
// Reihe von Säulen aus einzelnen Buchstaben.
|
||||
Flusszeile {
|
||||
actions
|
||||
}
|
||||
.padding(.top, FlaroSpacing.tight)
|
||||
|
||||
@@ -395,9 +395,16 @@ public final class DownloadCoordinator {
|
||||
forgetTransfer(item.ratingKey)
|
||||
fileStore.remove(fileName: item.localFileName)
|
||||
fileStore.remove(fileName: item.artworkFileName)
|
||||
let serie = item.seriesKey
|
||||
context.delete(item)
|
||||
save()
|
||||
reloadItems()
|
||||
|
||||
// **Das Serienplakat geht mit der letzten Folge.** Es hängt an
|
||||
// keinem Eintrag — ohne diese Zeile bliebe es für immer liegen.
|
||||
if let serie, !items.contains(where: { $0.seriesKey == serie }) {
|
||||
fileStore.remove(fileName: fileStore.artworkFileName(forRatingKey: serie))
|
||||
}
|
||||
refreshUsage()
|
||||
}
|
||||
|
||||
@@ -422,6 +429,58 @@ public final class DownloadCoordinator {
|
||||
return fileStore.url(forFileName: fileName)
|
||||
}
|
||||
|
||||
/// Das lokale Plakat eines Downloads — `nil`, wenn keines geholt wurde.
|
||||
///
|
||||
/// **Weil eine Reihe ohne Bilder keine Reihe ist.** Ohne Verbindung gibt
|
||||
/// es keine Adresse, von der ein Plakat nachzuladen wäre; was nicht beim
|
||||
/// Herunterladen mitkam, bleibt eine graue Fläche.
|
||||
/// **Nach der Datei gefragt, nicht nach dem Eintrag.** Hier wurde erst
|
||||
/// der Download gesucht und dann sein Dateiname gelesen — damit gab es
|
||||
/// nur Bilder zu Dingen, die selbst heruntergeladen wurden. Das Plakat
|
||||
/// einer **Serie** gehört aber keiner einzelnen Folge; es liegt unter
|
||||
/// dem Serienschlüssel, zu dem es keinen Eintrag gibt und nie geben
|
||||
/// wird.
|
||||
public func artworkURL(forRatingKey key: String) -> URL? {
|
||||
let name = fileStore.artworkFileName(forRatingKey: key)
|
||||
guard fileStore.exists(fileName: name) else { return nil }
|
||||
return fileStore.url(forFileName: name)
|
||||
}
|
||||
|
||||
/// Holt das Plakat zu einem Eintrag nach und legt es ab.
|
||||
///
|
||||
/// **Beim Anlegen, nicht erst beim Fertigwerden.** Die Liste zeigt den
|
||||
/// Eintrag, sobald er in der Warteschlange steht; bis dahin wäre sonst
|
||||
/// nichts zu sehen. Es sind ein paar Kilobyte.
|
||||
/// Der Schlüssel muss zu keinem Download gehören: unter dem Schlüssel
|
||||
/// einer Serie liegt ihr Plakat, obwohl die Serie selbst nie
|
||||
/// heruntergeladen wird.
|
||||
public func fetchArtwork(for ratingKey: String, from url: URL) async {
|
||||
let name = fileStore.artworkFileName(forRatingKey: ratingKey)
|
||||
guard !fileStore.exists(fileName: name) else {
|
||||
vermerken(name, bei: ratingKey)
|
||||
return
|
||||
}
|
||||
|
||||
guard let (daten, antwort) = try? await URLSession.shared.data(from: url),
|
||||
(antwort as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? true,
|
||||
!daten.isEmpty
|
||||
else { return }
|
||||
|
||||
guard fileStore.store(daten, as: name) != nil else { return }
|
||||
vermerken(name, bei: ratingKey)
|
||||
}
|
||||
|
||||
/// Hält am Eintrag fest, dass es ein Bild gibt — damit es beim Entfernen
|
||||
/// mitgeht. Zum Serienschlüssel gibt es keinen Eintrag; dann bleibt die
|
||||
/// Datei stehen, bis die letzte Folge der Serie geht.
|
||||
private func vermerken(_ name: String, bei ratingKey: String) {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
||||
item.artworkFileName != name
|
||||
else { return }
|
||||
item.artworkFileName = name
|
||||
save()
|
||||
}
|
||||
|
||||
public func offlineMedia(forRatingKey key: String) -> OfflineMedia? {
|
||||
guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil }
|
||||
return try? JSONDecoder().decode(OfflineMedia.self, from: data)
|
||||
@@ -458,6 +517,13 @@ public final class DownloadCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie oft je Eintrag schon auf ein Umpacken gewartet wurde.
|
||||
///
|
||||
/// **Im Arbeitsspeicher und nicht im Eintrag.** Nach einem Neustart
|
||||
/// beginnt das Zählen von vorn — und das ist richtig: der Server hat
|
||||
/// inzwischen vermutlich fertig gepackt.
|
||||
private var wartend: [String: Int] = [:]
|
||||
|
||||
private func start(_ item: DownloadItem) {
|
||||
guard let url = URL(string: item.sourceURLString) else {
|
||||
fail(item, message: "Ungültige Adresse")
|
||||
@@ -526,6 +592,7 @@ public final class DownloadCoordinator {
|
||||
}
|
||||
|
||||
func handleCompletion(ratingKey: String, temporaryFile: URL) {
|
||||
wartend[ratingKey] = nil
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
||||
let sourceURL = URL(string: item.sourceURLString)
|
||||
else { return }
|
||||
@@ -608,6 +675,49 @@ public final class DownloadCoordinator {
|
||||
}
|
||||
|
||||
/// Der Server hat die Anfrage abgelehnt.
|
||||
/// Wie oft auf ein Umpacken gewartet wird, bevor aufgegeben wird.
|
||||
///
|
||||
/// **Grosszügig, und das mit Absicht.** Ein Dreistünder in 4K braucht auf
|
||||
/// einem NAS Minuten; abzubrechen, während die Arbeit läuft, wäre die
|
||||
/// eine Antwort, die niemandem hilft. Zwanzig Versuche im Abstand von
|
||||
/// fünfzehn Sekunden sind fünf Minuten.
|
||||
private static let hoechstensWarten = 20
|
||||
private static let wartezeit = Duration.seconds(15)
|
||||
|
||||
/// Der Server packt noch um — später noch einmal fragen.
|
||||
///
|
||||
/// **Nicht sofort.** Ein Umpacken dauert Minuten, und im Sekundentakt
|
||||
/// nachzufragen hiesse, dieselbe Datei hundertmal anzufangen und wieder
|
||||
/// wegzuwerfen.
|
||||
func handleNotReady(ratingKey: String) {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||
|
||||
let versuche = (wartend[ratingKey] ?? 0) + 1
|
||||
wartend[ratingKey] = versuche
|
||||
|
||||
guard versuche <= Self.hoechstensWarten else {
|
||||
wartend[ratingKey] = nil
|
||||
fail(item, message: String(localized: "downloads.failed.notReady", bundle: .module))
|
||||
Task { await startNextIfPossible() }
|
||||
return
|
||||
}
|
||||
|
||||
// **Zurück in die Warteschlange, nicht in den Fehler.** Es ist
|
||||
// nichts schiefgegangen; es ist nur noch nicht fertig.
|
||||
item.state = .waiting
|
||||
save()
|
||||
|
||||
// **Nicht `resume`.** Das nimmt nur auf, was angehalten oder
|
||||
// gescheitert ist — ein wartender Eintrag lief dort in die
|
||||
// Schutzklausel und blieb für immer auf „wartet" stehen, während der
|
||||
// Server längst fertig gepackt hatte. Die Warteschlange abzuarbeiten
|
||||
// ist das, was hier gebraucht wird: `.waiting` steht darin.
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(for: Self.wartezeit)
|
||||
await self?.startNextIfPossible()
|
||||
}
|
||||
}
|
||||
|
||||
func handleServerRejection(ratingKey: String, status: Int) {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||
fail(item, message: String(localized: "downloads.failed.status \(status)", bundle: .module))
|
||||
@@ -740,6 +850,21 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch
|
||||
) {
|
||||
guard let key = downloadTask.taskDescription else { return }
|
||||
|
||||
// **202 heisst: noch nicht, komm wieder.**
|
||||
//
|
||||
// Der Server packt eine Datei um, die so nicht ausgeliefert werden
|
||||
// kann, und antwortet währenddessen mit 202 und einem kurzen Text.
|
||||
// Das ist ein Erfolgscode, also ging er durch die Prüfung darunter
|
||||
// glatt hindurch: 163 Byte Antworttext lagen danach als Film auf dem
|
||||
// Gerät, „in einer Sekunde heruntergeladen".
|
||||
if let response = downloadTask.response as? HTTPURLResponse,
|
||||
response.statusCode == 202 {
|
||||
Task { @MainActor [weak coordinator] in
|
||||
coordinator?.handleNotReady(ratingKey: key)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// `URLSessionDownloadTask` gilt auch bei 404 oder 500 als erfolgreich
|
||||
// und legt die Fehlerseite als Datei ab. Ohne diese Prüfung wurde eine
|
||||
// zwei Kilobyte große HTML-Seite als fertiger Film verbucht — in einer
|
||||
|
||||
@@ -58,6 +58,29 @@ public struct DownloadFileStore: Sendable {
|
||||
return "\(key).\(ext)"
|
||||
}
|
||||
|
||||
/// Der Name des Plakats zu einem Titel.
|
||||
///
|
||||
/// **Eine eigene Datei, kein Anhängsel am Film.** Sie ist ein paar
|
||||
/// Kilobyte gross und wird gebraucht, solange der Eintrag steht — auch
|
||||
/// während der Film noch lädt und noch gar keine Datei hat.
|
||||
public func artworkFileName(forRatingKey key: String) -> String { "\(key).bild" }
|
||||
|
||||
/// Legt ein geholtes Plakat ab. Schlägt es fehl, gibt es eben keines.
|
||||
///
|
||||
/// **Wirft nicht.** Ein Download ohne Bild ist ein Download; ein
|
||||
/// Download, der am Bild scheitert, wäre keiner.
|
||||
@discardableResult
|
||||
public func store(_ data: Data, as name: String) -> URL? {
|
||||
let ziel = url(forFileName: name)
|
||||
do {
|
||||
try createRootIfNeeded()
|
||||
try data.write(to: ziel, options: .atomic)
|
||||
return ziel
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz.
|
||||
///
|
||||
/// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"downloads.failed.notReady": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server packt diese Datei noch um. Versuch es in ein paar Minuten noch einmal."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server is still repackaging this file. Try again in a few minutes."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.failed.status %lld": {
|
||||
"comment": "Der Server hat die Download-Anfrage abgelehnt",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server refused the download with error %lld."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.failed.tooSmall %@": {
|
||||
"comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein",
|
||||
"localizations": {
|
||||
@@ -34,23 +68,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.failed.status %lld": {
|
||||
"comment": "Der Server hat die Download-Anfrage abgelehnt",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server refused the download with error %lld."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
|
||||
@@ -135,14 +135,14 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
||||
//
|
||||
// Serverneutral: wo kein Cookie liegt, ist die Liste leer und es
|
||||
// ändert sich nichts.
|
||||
let kekse = HTTPCookieStorage.shared.cookies(for: source.url) ?? []
|
||||
let cookies = HTTPCookieStorage.shared.cookies(for: source.url) ?? []
|
||||
|
||||
let asset = AVURLAsset(url: source.url, options: [
|
||||
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
|
||||
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
|
||||
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
|
||||
AVURLAssetPreferPreciseDurationAndTimingKey: false,
|
||||
AVURLAssetHTTPCookiesKey: kekse,
|
||||
AVURLAssetHTTPCookiesKey: cookies,
|
||||
])
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
|
||||
|
||||
@@ -80,6 +80,17 @@ public actor StreamCache {
|
||||
configuration.urlCache = nil
|
||||
configuration.httpMaximumConnectionsPerHost = 4
|
||||
configuration.timeoutIntervalForRequest = 30
|
||||
|
||||
// **Die Anmeldung muss mit.**
|
||||
//
|
||||
// Eine flüchtige Sitzung bringt ihren eigenen, leeren Cookie-Speicher
|
||||
// mit — jede Anfrage von hier ging also unangemeldet hinaus. Bei
|
||||
// Jellyfin fiel das nie auf: dort steht der Schlüssel in der Adresse.
|
||||
// Flaro meldet über ein Cookie an, und im Protokoll stand genau das:
|
||||
// `FlaroToken was challenged`, einmal je Wiedergabe, gefolgt von
|
||||
// zwölf Sekunden Stille und einer zweiten Bestellung.
|
||||
configuration.httpCookieStorage = HTTPCookieStorage.shared
|
||||
configuration.httpShouldSetCookies = true
|
||||
self.session = URLSession(configuration: configuration)
|
||||
|
||||
self.directory = FileManager.default.temporaryDirectory
|
||||
|
||||
@@ -86,38 +86,79 @@ public actor FlaroBackend: MediaBackend {
|
||||
// Einstellungen — „Gesehenes in Neuzugängen ausblenden" und
|
||||
// „Aufmacher nur aus Neuzugängen" —, und beide taten an einem
|
||||
// Flaro-Server deshalb nichts.
|
||||
for mediathek in mediatheken {
|
||||
let pfad = mediathek.kind == .show ? "/api/v1/series" : "/api/v1/movies"
|
||||
let abfrage = [
|
||||
URLQueryItem(name: "library", value: mediathek.id),
|
||||
URLQueryItem(name: "sort", value: "added"),
|
||||
URLQueryItem(name: "take", value: "24"),
|
||||
]
|
||||
let titel: [MediaItem]
|
||||
if mediathek.kind == .show {
|
||||
guard let seite = try? await client.send(
|
||||
FlaroRequest(path: pfad, query: abfrage),
|
||||
as: Wire.Page<Wire.Series>.self) else { continue }
|
||||
titel = seite.items.map { $0.neutral() }
|
||||
} else {
|
||||
guard let seite = try? await client.send(
|
||||
FlaroRequest(path: pfad, query: abfrage),
|
||||
as: Wire.Page<Wire.Movie>.self) else { continue }
|
||||
titel = seite.items.map { $0.neutral() }
|
||||
// **Nebeneinander, nicht nacheinander.**
|
||||
//
|
||||
// Hier stand eine Schleife mit `await` darin: bei vier Mediatheken
|
||||
// vier Rundreisen hintereinander, jede erst nach der vorigen. Die
|
||||
// Weboberfläche holt dieselben Reihen in einem `Promise.all` — und
|
||||
// sie holt sogar mehr davon. Genau das war der Unterschied, den man
|
||||
// der App ansah.
|
||||
//
|
||||
// Ein Actor steht dem nicht im Weg: jede Anfrage gibt ihn beim
|
||||
// Warten wieder frei, so wie die beiden darüber es schon tun.
|
||||
let neuzugaenge = await withTaskGroup(of: (Int, MediaRowGroup?).self) { gruppe in
|
||||
for (platz, mediathek) in mediatheken.enumerated() {
|
||||
gruppe.addTask { (platz, await self.neuzugang(in: mediathek)) }
|
||||
}
|
||||
guard !titel.isEmpty else { continue }
|
||||
reihen.append(MediaRowGroup(
|
||||
id: "latest.\(mediathek.id)",
|
||||
title: String(
|
||||
localized: "flaro.row.latestIn \(mediathek.title)",
|
||||
bundle: .module),
|
||||
libraryID: mediathek.id,
|
||||
items: titel))
|
||||
|
||||
// **Die Reihenfolge der Mediatheken, nicht die der Antworten.**
|
||||
// Sonst stünden die Reihen nach jedem Start woanders — je
|
||||
// nachdem, welche Abfrage zuerst zurückkam.
|
||||
var gesammelt: [(Int, MediaRowGroup?)] = []
|
||||
for await ergebnis in gruppe { gesammelt.append(ergebnis) }
|
||||
return gesammelt.sorted { $0.0 < $1.0 }.compactMap(\.1)
|
||||
}
|
||||
|
||||
reihen.append(contentsOf: neuzugaenge)
|
||||
return reihen
|
||||
}
|
||||
|
||||
/// Die Reihe „Zuletzt hinzugefügt" einer Mediathek — oder nichts.
|
||||
///
|
||||
/// **Ein Fehler bei einer Mediathek nimmt nicht die anderen mit.** Die
|
||||
/// Startseite mit drei von vier Reihen ist besser als gar keine.
|
||||
private func neuzugang(in mediathek: MediaLibrary) async -> MediaRowGroup? {
|
||||
let pfad = mediathek.kind == .show ? "/api/v1/series" : "/api/v1/movies"
|
||||
let abfrage = [
|
||||
URLQueryItem(name: "library", value: mediathek.id),
|
||||
URLQueryItem(name: "sort", value: "added"),
|
||||
URLQueryItem(name: "take", value: "24"),
|
||||
]
|
||||
|
||||
let titel: [MediaItem]
|
||||
if mediathek.kind == .show {
|
||||
guard let seite = try? await client.send(
|
||||
FlaroRequest(path: pfad, query: abfrage),
|
||||
as: Wire.Page<Wire.Series>.self) else { return nil }
|
||||
titel = seite.items.map { $0.neutral() }
|
||||
} else {
|
||||
guard let seite = try? await client.send(
|
||||
FlaroRequest(path: pfad, query: abfrage),
|
||||
as: Wire.Page<Wire.Movie>.self) else { return nil }
|
||||
titel = seite.items.map { $0.neutral() }
|
||||
}
|
||||
|
||||
guard !titel.isEmpty else { return nil }
|
||||
|
||||
return MediaRowGroup(
|
||||
id: "latest.\(mediathek.id)",
|
||||
title: String(localized: "flaro.row.latestIn \(mediathek.title)", bundle: .module),
|
||||
libraryID: mediathek.id,
|
||||
items: titel)
|
||||
}
|
||||
|
||||
public func availableLanguages(inSeries id: String) async -> AvailableLanguages? {
|
||||
guard let roh = try? await client.send(
|
||||
FlaroRequest(path: "/api/v1/series/\(id)/languages"),
|
||||
as: Wire.Sprachauswahl.self)
|
||||
else { return nil }
|
||||
|
||||
return AvailableLanguages(
|
||||
audio: roh.audio.map { LanguageOption(code: $0.code, name: $0.name) },
|
||||
subtitles: roh.subtitles.map { LanguageOption(code: $0.code, name: $0.name) },
|
||||
partial: roh.partial)
|
||||
}
|
||||
|
||||
public func items(
|
||||
inLibrary library: MediaLibrary,
|
||||
query: MediaQuery,
|
||||
@@ -198,11 +239,29 @@ public actor FlaroBackend: MediaBackend {
|
||||
}
|
||||
|
||||
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||
try await nachbar(von: item, weg: "next")
|
||||
}
|
||||
|
||||
public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||
try await nachbar(von: item, weg: "previous")
|
||||
}
|
||||
|
||||
/// Die Folge vor oder nach dieser.
|
||||
///
|
||||
/// 204 heisst: es gibt keine. Kein Fehler, nur das Ende der Serie —
|
||||
/// oder ihr Anfang.
|
||||
private func nachbar(von item: MediaItem, weg: String) async throws(MediaBackendError) -> MediaItem? {
|
||||
guard item.kind == .episode else { return nil }
|
||||
// 204 heisst: es war die letzte. Kein Fehler, nur kein Nachfolger.
|
||||
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/next"))
|
||||
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/\(weg)"))
|
||||
guard !daten.isEmpty else { return nil }
|
||||
guard let folge = try? JSONDecoder().decode(Wire.Episode.self, from: daten) else { return nil }
|
||||
// **Der eingerichtete Dekoder, nicht ein frischer.** Ein frischer
|
||||
// kennt das Datumsformat des Servers nicht; sobald die Nachbarfolge
|
||||
// ein `publishAt` oder ein `lastPlayedAt` mitbringt — und mit
|
||||
// eingeschalteter Prüfung bringt sie es —, scheiterte das Lesen.
|
||||
// Das `try?` schluckte den Fehler, und von aussen sah es aus, als
|
||||
// gäbe es keine nächste Folge: der Knopf blieb weg.
|
||||
guard let folge = try? FlaroClient.decoder.decode(
|
||||
Wire.Episode.self, from: daten) else { return nil }
|
||||
return folge.neutral()
|
||||
}
|
||||
|
||||
@@ -249,6 +308,32 @@ public actor FlaroBackend: MediaBackend {
|
||||
/// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein
|
||||
/// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet
|
||||
/// Rechenzeit, die auf einem NAS knapper ist.
|
||||
/// Wie die Vorschaubilder einer Datei liegen.
|
||||
///
|
||||
/// **Der Server erzeugt sie beim Abspielen.** Steht noch nichts da,
|
||||
/// antwortet er mit `fertig: false` — dann gibt es hier nichts, und
|
||||
/// der Spieler fragt später noch einmal.
|
||||
public func vorschauplan(fileId: String) async -> Vorschauplan? {
|
||||
guard let auskunft = try? await client.send(
|
||||
FlaroRequest(path: "/api/v1/files/\(fileId)/vorschau"),
|
||||
as: Wire.Vorschau.self), auskunft.fertig
|
||||
else { return nil }
|
||||
|
||||
return Vorschauplan(
|
||||
intervall: auskunft.intervall,
|
||||
spalten: auskunft.spalten,
|
||||
zeilen: auskunft.zeilen,
|
||||
kachelBreite: Double(auskunft.kachelBreite),
|
||||
kachelHoehe: Double(auskunft.kachelHoehe),
|
||||
blaetter: auskunft.blaetter)
|
||||
}
|
||||
|
||||
/// **Ohne Kopfzeilen, wie bei den Plakaten.** Die Anmeldung hängt am
|
||||
/// Cookie; der Bildlader holt die Adresse roh und kommt damit durch.
|
||||
public nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL? {
|
||||
client.url(path: "/api/v1/files/\(fileId)/vorschau/\(blatt).jpg")
|
||||
}
|
||||
|
||||
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
||||
rawImageURL(path: path)
|
||||
}
|
||||
@@ -302,6 +387,50 @@ public actor FlaroBackend: MediaBackend {
|
||||
|
||||
public func preparePlayback(
|
||||
_ intent: PlaybackIntent
|
||||
) async throws(MediaBackendError) -> PlaybackTarget {
|
||||
// **Erst der Strom, und nur bei dessen Scheitern die fertige Datei.**
|
||||
//
|
||||
// Hier stand ausschliesslich der zweite Weg: `/download`, und dann
|
||||
// im Sekundentakt nachfragen, bis die ganze MP4 auf der Platte liegt.
|
||||
// Gemessen an einem Spielfilm waren das dreissig Sekunden Schwarz,
|
||||
// während die Weboberfläche nach einer Sekunde lief — die holt sich
|
||||
// eine HLS-Sitzung, und deren erstes Stück ist beim Umpacken in
|
||||
// deutlich unter einer Sekunde da.
|
||||
if let strom = try? await client.send(
|
||||
.json(.post, "/api/v1/files/\(intent.file.id)/stream", Wire.StreamRequest(
|
||||
height: intent.transcode?.resolution.flatMap(Self.hoehe),
|
||||
// Ohne diese Angabe wählt der Server, und bei einem
|
||||
// umgerechneten Strom kommt danach nur noch eine Spur an.
|
||||
audioStream: intent.audioStreamIndex,
|
||||
startSeconds: max(0, intent.startTime.seconds),
|
||||
// Nur wenn der Spieler es ausdrücklich verlangt: das Profil
|
||||
// entscheidet der Server, und er entscheidet es besser.
|
||||
transcode: intent.transcode?.prefersRemux == false
|
||||
&& intent.transcode?.maximumBitrate != nil)),
|
||||
as: Wire.Stromauskunft.self),
|
||||
|
||||
// **Die Hauptliste, wo es sie gibt.** Nur dort stehen die
|
||||
// Textspuren; die Medienliste trägt bloss Bild und Ton.
|
||||
let adresse = client.url(path: strom.masterUrl ?? strom.url)
|
||||
{
|
||||
return PlaybackTarget(
|
||||
url: adresse,
|
||||
// Eine Direktwiedergabe legt nichts an, das aufzuräumen wäre.
|
||||
sessionID: strom.method == "direct" ? nil : strom.sessionId,
|
||||
versionID: intent.file.id,
|
||||
startOffset: .seconds(strom.startSeconds))
|
||||
}
|
||||
|
||||
return try await mitgenommeneDatei(intent)
|
||||
}
|
||||
|
||||
/// Der alte Weg: warten, bis die fertige Datei auf der Platte liegt.
|
||||
///
|
||||
/// **Bleibt als Rückfall.** Er ist langsam, aber er kommt ohne
|
||||
/// Stromsitzung aus — und wenn die nicht zustande kommt, ist eine späte
|
||||
/// Wiedergabe besser als keine.
|
||||
private func mitgenommeneDatei(
|
||||
_ intent: PlaybackIntent
|
||||
) async throws(MediaBackendError) -> PlaybackTarget {
|
||||
guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else {
|
||||
throw .transport(description: "Adresse nicht baubar")
|
||||
@@ -318,8 +447,6 @@ public actor FlaroBackend: MediaBackend {
|
||||
// Muss der Server umpacken, antwortet er auf den Download mit 202 und
|
||||
// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere
|
||||
// Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner.
|
||||
// Genau so sah es beim ersten Lauf im Simulator aus: „Stelle wird
|
||||
// wiederhergestellt", 0:00, für immer.
|
||||
//
|
||||
// Das Anstossen besorgt der Server beim ersten Abruf selbst; hier
|
||||
// wird nur nachgefragt, bis es so weit ist.
|
||||
@@ -347,16 +474,27 @@ public actor FlaroBackend: MediaBackend {
|
||||
}
|
||||
}
|
||||
|
||||
// **Keine Sitzung.** Flaro legt für eine Wiedergabe nichts an, das
|
||||
// hinterher aufzuräumen wäre: die Datei geht unverändert hinaus oder
|
||||
// liegt umgepackt im Zwischenspeicher. Es gibt deshalb nichts, was
|
||||
// weiterrechnet, wenn niemand mehr zusieht.
|
||||
return PlaybackTarget(url: adresse, sessionID: nil, versionID: intent.file.id)
|
||||
}
|
||||
|
||||
public func endPlayback(sessionID: String) async {}
|
||||
/// „1080p" wird zu 1080 — der Server kennt nur Zahlen.
|
||||
private static func hoehe(_ aufloesung: String) -> Int? {
|
||||
Int(aufloesung.filter(\.isNumber))
|
||||
}
|
||||
|
||||
public func reportProgress(_ report: PlaybackReport) async {
|
||||
/// Schliesst die Sitzung, damit nichts weiterrechnet.
|
||||
///
|
||||
/// **Das war eine leere Methode**, und sie passte zu einer Zeit, in der
|
||||
/// es gar keine Sitzung gab: eine fertige Datei auf der Platte rechnet
|
||||
/// nicht weiter. Mit dem Strom gibt es wieder einen Prozess, der ohne
|
||||
/// diesen Aufruf den ganzen Film zu Ende schreibt, während längst
|
||||
/// niemand mehr zusieht.
|
||||
public func endPlayback(sessionID: String) async {
|
||||
_ = try? await client.send(FlaroRequest(
|
||||
method: .delete, path: "/api/v1/stream/\(sessionID)"))
|
||||
}
|
||||
|
||||
public func reportProgress(_ report: PlaybackReport) async -> Bool {
|
||||
// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
||||
// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
||||
// wäre die schlechtere Hälfte des Tauschs.
|
||||
@@ -364,10 +502,15 @@ public actor FlaroBackend: MediaBackend {
|
||||
// Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen
|
||||
// Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden
|
||||
// hiesse, den ersten Bericht wegzuwerfen.
|
||||
_ = try? await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
|
||||
Wire.ProgressRequest(
|
||||
positionSeconds: report.time.seconds,
|
||||
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
|
||||
do {
|
||||
try await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
|
||||
Wire.ProgressRequest(
|
||||
positionSeconds: report.time.seconds,
|
||||
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Zustand melden
|
||||
|
||||
@@ -95,10 +95,14 @@ enum Wire {
|
||||
let overview: String?
|
||||
let posterImage: String?
|
||||
let backdropImage: String?
|
||||
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||
let heroImage: String?
|
||||
let files: [File]
|
||||
let state: ItemState?
|
||||
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
|
||||
let publishAt: Date?
|
||||
/// Der Abspann, sofern er im Bild gefunden wurde.
|
||||
let markers: [Marker]?
|
||||
}
|
||||
|
||||
struct Series: Decodable, Sendable {
|
||||
@@ -108,6 +112,8 @@ enum Wire {
|
||||
let overview: String?
|
||||
let posterImage: String?
|
||||
let backdropImage: String?
|
||||
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||
let heroImage: String?
|
||||
let episodeCount: Int
|
||||
let seasons: [Int]
|
||||
let unwatchedCount: Int
|
||||
@@ -127,6 +133,20 @@ enum Wire {
|
||||
/// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich
|
||||
/// nicht darstellbar: Titel und Bild der Serie fehlten.
|
||||
let seriesId: String?
|
||||
/// Vorspann und Abspann, sofern der Server sie gefunden hat.
|
||||
///
|
||||
/// Fehlt bei älteren Servern ganz — deshalb optional und nicht eine
|
||||
/// leere Liste: ein `[]` im Vertrag zwänge jeden Server, das Feld zu
|
||||
/// schicken.
|
||||
let markers: [Marker]?
|
||||
}
|
||||
|
||||
/// Ein überspringbarer Abschnitt.
|
||||
struct Marker: Decodable, Sendable {
|
||||
/// „intro" oder „credits" — dieselben Wörter wie bei Jellyfin.
|
||||
let kind: String
|
||||
let startSeconds: Double
|
||||
let endSeconds: Double
|
||||
}
|
||||
|
||||
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
|
||||
@@ -140,6 +160,8 @@ enum Wire {
|
||||
let year: Int?
|
||||
let posterImage: String?
|
||||
let backdropImage: String?
|
||||
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||
let heroImage: String?
|
||||
let state: ItemState
|
||||
/// Zu welcher Serie die Folge gehört.
|
||||
let seriesId: String?
|
||||
@@ -178,6 +200,51 @@ enum Wire {
|
||||
|
||||
// --- Was hinausgeht --------------------------------------------------
|
||||
|
||||
/// Was das Gerät kann und was gewünscht ist.
|
||||
///
|
||||
/// **Ohne Codec-Angaben nimmt der Server Apples Profil an**, und das ist
|
||||
/// hier genau richtig: dieses Gerät *ist* Apple. Eine eigene Liste wäre
|
||||
/// eine zweite Wahrheit neben der, die der Server ohnehin pflegt.
|
||||
struct StreamRequest: Encodable, Sendable {
|
||||
var height: Int?
|
||||
var audioStream: Int?
|
||||
var startSeconds: Double
|
||||
var transcode: Bool
|
||||
}
|
||||
|
||||
struct Stromspur: Decodable, Sendable {
|
||||
let index: Int
|
||||
let label: String
|
||||
let language: String?
|
||||
let isDefault: Bool
|
||||
}
|
||||
|
||||
struct Stromauskunft: Decodable, Sendable {
|
||||
let sessionId: String
|
||||
let url: String
|
||||
/// Dieselbe Wiedergabe mit Untertitelspuren — fehlt bei Direktwiedergabe.
|
||||
let masterUrl: String?
|
||||
/// „direct", „remux" oder „transcode".
|
||||
let method: String
|
||||
/// Ab welcher Sekunde des Films dieser Strom beginnt.
|
||||
let startSeconds: Double
|
||||
let height: Int?
|
||||
let audioStream: Int?
|
||||
let audioStreams: [Stromspur]
|
||||
let subtitleStreams: [Stromspur]
|
||||
}
|
||||
|
||||
struct Sprachangabe: Decodable, Sendable {
|
||||
let code: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct Sprachauswahl: Decodable, Sendable {
|
||||
let audio: [Sprachangabe]
|
||||
let subtitles: [Sprachangabe]
|
||||
let partial: Int
|
||||
}
|
||||
|
||||
struct LoginRequest: Encodable, Sendable {
|
||||
let userName: String
|
||||
let password: String
|
||||
@@ -194,3 +261,16 @@ enum Wire {
|
||||
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
|
||||
struct RatingRequest: Encodable, Sendable { let rating: Int? }
|
||||
}
|
||||
|
||||
extension Wire {
|
||||
/// Was der Server über die Vorschaubilder sagt.
|
||||
struct Vorschau: Decodable, Sendable {
|
||||
let fertig: Bool
|
||||
let intervall: Double
|
||||
let spalten: Int
|
||||
let zeilen: Int
|
||||
let kachelBreite: Int
|
||||
let kachelHoehe: Int
|
||||
let blaetter: Int
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,23 @@ extension Wire.Movie {
|
||||
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
||||
thumb: posterImage,
|
||||
art: backdropImage,
|
||||
heroArt: heroImage,
|
||||
playCount: state?.playCount,
|
||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||
lastPlayedAt: state?.lastPlayedAt,
|
||||
isFavourite: state?.favourite ?? false,
|
||||
isInProgress: state?.inProgress,
|
||||
availableFrom: publishAt,
|
||||
versions: [Wire.fassung(id: id, files: files)]
|
||||
versions: [Wire.fassung(id: id, files: files)],
|
||||
// **Nur der Abspann.** Einen Vorspann gibt es bei einem Film
|
||||
// nicht zu finden: erkannt wird er daran, dass er sich über
|
||||
// die Folgen einer Staffel wiederholt.
|
||||
markers: (markers ?? []).map {
|
||||
MediaMarker(
|
||||
kind: MediaMarker.Kind(rawValue: $0.kind) ?? .unknown,
|
||||
startMilliseconds: Int($0.startSeconds * 1000),
|
||||
endMilliseconds: Int($0.endSeconds * 1000))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +47,7 @@ extension Wire.Series {
|
||||
year: year,
|
||||
thumb: posterImage,
|
||||
art: backdropImage,
|
||||
heroArt: heroImage,
|
||||
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
|
||||
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
||||
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
||||
@@ -72,7 +83,16 @@ extension Wire.Episode {
|
||||
isFavourite: state?.favourite ?? false,
|
||||
isInProgress: state?.inProgress,
|
||||
availableFrom: publishAt,
|
||||
versions: [Wire.fassung(id: id, files: files)]
|
||||
versions: [Wire.fassung(id: id, files: files)],
|
||||
// **Vorspann und Abspann, wenn der Server sie kennt.**
|
||||
// Ältere Server schicken das Feld gar nicht; dann ist die
|
||||
// Liste leer und die Spieler zeigen schlicht keinen Knopf.
|
||||
markers: (markers ?? []).map {
|
||||
MediaMarker(
|
||||
kind: MediaMarker.Kind(rawValue: $0.kind) ?? .unknown,
|
||||
startMilliseconds: Int($0.startSeconds * 1000),
|
||||
endMilliseconds: Int($0.endSeconds * 1000))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -98,6 +118,7 @@ extension Wire.Started {
|
||||
// Serie ein Bild ausgesucht hatte, fand dort ein anderes vor.
|
||||
art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage,
|
||||
seriesThumb: folge ? posterImage : nil,
|
||||
heroArt: heroImage,
|
||||
index: episode,
|
||||
parentIndex: season,
|
||||
seriesTitle: folge ? title : nil,
|
||||
|
||||
@@ -106,7 +106,7 @@ extension JellyfinBackend {
|
||||
))
|
||||
}
|
||||
|
||||
public func reportProgress(_ report: PlaybackReport) async {
|
||||
public func reportProgress(_ report: PlaybackReport) async -> Bool {
|
||||
var fields: [String: Any] = [
|
||||
"ItemId": report.itemID,
|
||||
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
|
||||
@@ -126,13 +126,18 @@ extension JellyfinBackend {
|
||||
default: "/Sessions/Playing/Progress"
|
||||
}
|
||||
|
||||
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return }
|
||||
_ = try? await client.send(JellyfinRequest(
|
||||
method: .post,
|
||||
path: path,
|
||||
body: body,
|
||||
timeout: .seconds(10)
|
||||
))
|
||||
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return false }
|
||||
do {
|
||||
try await client.send(JellyfinRequest(
|
||||
method: .post,
|
||||
path: path,
|
||||
body: body,
|
||||
timeout: .seconds(10)
|
||||
))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hilfen
|
||||
|
||||
@@ -130,19 +130,34 @@ public func isInLocalNetwork() async -> Bool {
|
||||
// sind zwei verschiedene Fragen — in einer gemeinsamen Reihe steht am
|
||||
// Ende beides durcheinander, und wer nach einem Film sucht, blättert
|
||||
// an Folgen vorbei.
|
||||
for library in libraries {
|
||||
let items = try? await latestItems(limit: 20, inLibrary: library.id)
|
||||
rows.append(MediaRowGroup(
|
||||
id: "latest.\(library.id)",
|
||||
title: String(
|
||||
localized: "row.latestIn \(library.title)",
|
||||
bundle: .module
|
||||
),
|
||||
libraryID: library.id,
|
||||
items: items ?? []
|
||||
))
|
||||
//
|
||||
// **Nebeneinander, nicht nacheinander.** Hier stand ein `await` in
|
||||
// der Schleife: bei vier Mediatheken vier Rundreisen hintereinander.
|
||||
// Die Reihenfolge der Mediatheken bleibt trotzdem erhalten — sonst
|
||||
// stünden die Reihen nach jedem Start woanders.
|
||||
let latest = await withTaskGroup(of: (Int, MediaRowGroup).self) { group in
|
||||
for (place, library) in libraries.enumerated() {
|
||||
group.addTask {
|
||||
let items = try? await self.latestItems(limit: 20, inLibrary: library.id)
|
||||
return (place, MediaRowGroup(
|
||||
id: "latest.\(library.id)",
|
||||
title: String(
|
||||
localized: "row.latestIn \(library.title)",
|
||||
bundle: .module
|
||||
),
|
||||
libraryID: library.id,
|
||||
items: items ?? []
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
var collected: [(Int, MediaRowGroup)] = []
|
||||
for await result in group { collected.append(result) }
|
||||
return collected.sorted { $0.0 < $1.0 }.map(\.1)
|
||||
}
|
||||
|
||||
rows.append(contentsOf: latest)
|
||||
|
||||
rows.append(MediaRowGroup(
|
||||
id: "favourites",
|
||||
title: String(localized: "row.favourites", bundle: .module),
|
||||
@@ -333,6 +348,20 @@ public func isInLocalNetwork() async -> Bool {
|
||||
return next.first { $0.id != item.id }
|
||||
}
|
||||
|
||||
/// Die Folge davor.
|
||||
///
|
||||
/// **Über die Folgenliste der Serie, nicht über einen eigenen
|
||||
/// Endpunkt.** Jellyfin hat ein „NextUp", aber kein Gegenstück dazu —
|
||||
/// und die Liste gibt es ohnehin, weil die Serienseite sie zeigt. Eine
|
||||
/// Anfrage für einen Knopf, der selten gedrückt wird, ist der bessere
|
||||
/// Tausch gegenüber gar keinem Knopf.
|
||||
public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||
guard let seriesID = item.seriesID else { return nil }
|
||||
let folgen = try await episodes(inSeries: seriesID)
|
||||
guard let stelle = folgen.firstIndex(where: { $0.id == item.id }), stelle > 0 else { return nil }
|
||||
return folgen[stelle - 1]
|
||||
}
|
||||
|
||||
// MARK: - Bilder
|
||||
|
||||
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
/// Fortschritt, der den Server nicht erreicht hat.
|
||||
///
|
||||
/// **Was ohne sie verlorenging.** Eine Folge im Flugzeug zur Hälfte
|
||||
/// anzusehen, war für den Server ein Vorgang, der nie stattgefunden hat:
|
||||
/// jede Meldung lief in die tote Leitung, und beim nächsten Verbinden stand
|
||||
/// der Titel wieder bei null — auf dem Fernseher, im Web, überall. Genau
|
||||
/// dieser Fall ist der Grund, aus dem man etwas herunterlädt.
|
||||
///
|
||||
/// **Eine Meldung je Titel, die letzte gewinnt.** Wer eine Stunde schaut,
|
||||
/// erzeugt an die hundert Meldungen; nachzureichen ist davon genau eine
|
||||
/// sinnvoll — die, an der er aufgehört hat. Alles andere wäre ein Protokoll
|
||||
/// der Vergangenheit, das der Server erst wieder zusammenrechnen müsste.
|
||||
///
|
||||
/// Auf der Platte, nicht im Speicher: wer die App im Flugmodus beendet, hat
|
||||
/// sonst umsonst geschaut.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class Fortschrittspost {
|
||||
/// Der Briefkasten der App.
|
||||
///
|
||||
/// Einer, wie bei `LibraryCache`: zwei Instanzen auf dieselbe Datei
|
||||
/// würden einander überschreiben. Der Bauweg mit eigener Ablage bleibt
|
||||
/// für Prüfungen offen.
|
||||
public static let shared = Fortschrittspost()
|
||||
|
||||
/// Ein zurückgelegter Stand.
|
||||
public struct Eintrag: Codable, Sendable, Equatable {
|
||||
/// Zu welchem Server er gehört.
|
||||
///
|
||||
/// **Sonst ginge er an den falschen.** Wer sich abmeldet und an
|
||||
/// einem anderen Server anmeldet, bekäme sonst fremde Kennungen
|
||||
/// mit dem eigenen Sehfortschritt gemeldet.
|
||||
let server: String
|
||||
let titelID: String
|
||||
let sekunden: Double
|
||||
let laufzeitSekunden: Double?
|
||||
let zeitpunkt: Date
|
||||
}
|
||||
|
||||
private let ablage: URL
|
||||
private let logger = Logger(subsystem: "app.flaro", category: "Fortschrittspost")
|
||||
private var offen: [String: Eintrag]
|
||||
|
||||
/// Wie viele Stände noch auf einen Server warten.
|
||||
public var wartend: Int { offen.count }
|
||||
|
||||
public init(ablage: URL? = nil) {
|
||||
self.ablage = ablage ?? FileManager.default
|
||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appending(path: "flaro-fortschritt.json")
|
||||
self.offen = Self.lesen(from: self.ablage) ?? [:]
|
||||
}
|
||||
|
||||
/// Legt einen Stand zurück, weil er nicht ankam.
|
||||
public func zuruecklegen(_ report: PlaybackReport, server: String) {
|
||||
offen[report.itemID] = Eintrag(
|
||||
server: server,
|
||||
titelID: report.itemID,
|
||||
sekunden: report.time.seconds,
|
||||
laufzeitSekunden: report.duration > .zero ? report.duration.seconds : nil,
|
||||
zeitpunkt: Date()
|
||||
)
|
||||
schreiben()
|
||||
}
|
||||
|
||||
/// Versucht, alles Liegengebliebene loszuwerden.
|
||||
///
|
||||
/// **Was weiterhin scheitert, bleibt liegen.** Ein halb geleerter
|
||||
/// Briefkasten ist kein Fehler — beim nächsten Versuch geht der Rest.
|
||||
///
|
||||
/// Nacheinander und nicht nebeneinander: es sind selten mehr als eine
|
||||
/// Handvoll, und zwanzig gleichzeitige Anfragen an einen Server, der
|
||||
/// gerade erst wieder da ist, wären der falsche erste Gruß.
|
||||
public func zustellen(über backend: (any Fortschrittsempfaenger)?) async {
|
||||
guard let backend, !offen.isEmpty else { return }
|
||||
let kennung = backend.identity
|
||||
|
||||
// **Nicht ewig aufheben.** Ein Stand von vor einem halben Jahr sagt
|
||||
// nichts mehr darüber, wo jemand heute weiterschauen will — und ein
|
||||
// Server, der nie wiederkommt, hinterliesse sonst eine Datei, die
|
||||
// nur wächst.
|
||||
let frist = Date().addingTimeInterval(-30 * 24 * 60 * 60)
|
||||
offen = offen.filter { $0.value.zeitpunkt > frist }
|
||||
|
||||
for eintrag in offen.values.sorted(by: { $0.zeitpunkt < $1.zeitpunkt })
|
||||
where eintrag.server == kennung {
|
||||
let angekommen = await backend.reportProgress(PlaybackReport(
|
||||
itemID: eintrag.titelID,
|
||||
state: .stopped,
|
||||
time: .seconds(eintrag.sekunden),
|
||||
duration: .seconds(eintrag.laufzeitSekunden ?? 0)
|
||||
))
|
||||
guard angekommen else { break }
|
||||
// **Nur den, der tatsächlich ankam.** Währenddessen kann
|
||||
// derselbe Titel neu zurückgelegt worden sein; den zu
|
||||
// entfernen hiesse, den neueren Stand wegzuwerfen.
|
||||
if offen[eintrag.titelID] == eintrag { offen[eintrag.titelID] = nil }
|
||||
}
|
||||
schreiben()
|
||||
}
|
||||
|
||||
/// Alles vergessen — beim Abmelden.
|
||||
public func leeren() {
|
||||
offen = [:]
|
||||
try? FileManager.default.removeItem(at: ablage)
|
||||
}
|
||||
|
||||
// MARK: - Platte
|
||||
|
||||
private func schreiben() {
|
||||
do {
|
||||
try JSONEncoder().encode(offen).write(to: ablage, options: .atomic)
|
||||
} catch {
|
||||
logger.warning("Nicht gespeichert: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func lesen(from url: URL) -> [String: Eintrag]? {
|
||||
guard let daten = try? Data(contentsOf: url) else { return nil }
|
||||
return try? JSONDecoder().decode([String: Eintrag].self, from: daten)
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,41 @@ public actor LibraryCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Reihen der Startseite.
|
||||
///
|
||||
/// **Weil es sonst nichts gibt, womit sie sich füllen liesse.** Die
|
||||
/// Mediathekenliste und die Titel darin liegen schon auf der Platte; die
|
||||
/// Startseite dagegen entstand bei jedem Aufruf neu am Server, und ohne
|
||||
/// ihn blieb sie leer. Im Flugzeug ist das der Unterschied zwischen
|
||||
/// „sehen, was es gibt" und einem Fehlerbildschirm.
|
||||
public func homeRows(server: String) -> [MediaRowGroup]? {
|
||||
guard let daten = try? Data(contentsOf: homeRowsFile(server: server)) else { return nil }
|
||||
return try? JSONDecoder().decode([MediaRowGroup].self, from: daten)
|
||||
}
|
||||
|
||||
public func storeHomeRows(_ rows: [MediaRowGroup], server: String) {
|
||||
// Je Reihe eine Bildschirmbreite, mehr sieht ohnehin niemand, bevor
|
||||
// der Server wieder antwortet.
|
||||
let gekürzt = rows.map {
|
||||
MediaRowGroup(
|
||||
id: $0.id,
|
||||
title: $0.title,
|
||||
moreKey: $0.moreKey,
|
||||
libraryID: $0.libraryID,
|
||||
items: $0.items.prefix(20).map(\.listEssentials)
|
||||
)
|
||||
}
|
||||
let ziel = homeRowsFile(server: server)
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: ziel.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try JSONEncoder().encode(gekürzt).write(to: ziel, options: .atomic)
|
||||
} catch {
|
||||
logger.warning("Startseite nicht gespeichert: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Aufräumen
|
||||
|
||||
/// Alles zu einem Server vergessen — beim Abmelden.
|
||||
@@ -139,6 +174,10 @@ public actor LibraryCache {
|
||||
directory(server: server).appending(path: "libraries.json")
|
||||
}
|
||||
|
||||
private func homeRowsFile(server: String) -> URL {
|
||||
directory(server: server).appending(path: "home.json")
|
||||
}
|
||||
|
||||
/// Ein Name, der als Datei taugt.
|
||||
///
|
||||
/// Kennungen kommen vom Server: bei Jellyfin GUIDs, bei Plex Zahlen, und
|
||||
|
||||
@@ -6,9 +6,13 @@ import Foundation
|
||||
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
|
||||
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
|
||||
/// der alles umfasst, wäre kein Nenner mehr.
|
||||
public protocol MediaBackend: Sendable {
|
||||
/// Wie der Server heißt, so wie er sich selbst nennt.
|
||||
nonisolated var serverName: String { get }
|
||||
/// Wer Sehfortschritt entgegennimmt.
|
||||
///
|
||||
/// **Ein Ausschnitt aus `MediaBackend`, und zwar genau der, den die
|
||||
/// `Fortschrittspost` braucht.** Ohne ihn müsste jeder, der das Nachreichen
|
||||
/// prüfen will, einen vollständigen Server nachbauen — vierzig Methoden für
|
||||
/// die zwei, die zur Sache gehören.
|
||||
public protocol Fortschrittsempfaenger: Sendable {
|
||||
/// Wer dieser Server ist — für alles, was ihn über einen Start hinaus
|
||||
/// wiedererkennen muss.
|
||||
///
|
||||
@@ -17,6 +21,24 @@ public protocol MediaBackend: Sendable {
|
||||
/// beides überlebt und beim nächsten Start noch gleich lautet. Es ist
|
||||
/// eine Kennung und geht nirgends an die Oberfläche.
|
||||
nonisolated var identity: String { get }
|
||||
|
||||
/// Meldet, wo man gerade steht.
|
||||
///
|
||||
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
||||
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
||||
/// wäre die schlechtere Hälfte des Tauschs.
|
||||
///
|
||||
/// **Gibt aber zurück, ob sie ankam.** Im Flugzeug geht jede einzelne
|
||||
/// verloren, und am Ende stünde ein zur Hälfte gesehener Film auf dem
|
||||
/// Server weiter bei null. Der Aufrufer legt sie dann beiseite und
|
||||
/// meldet sie nach — siehe `Fortschrittspost`.
|
||||
@discardableResult
|
||||
func reportProgress(_ report: PlaybackReport) async -> Bool
|
||||
}
|
||||
|
||||
public protocol MediaBackend: Sendable, Fortschrittsempfaenger {
|
||||
/// Wie der Server heißt, so wie er sich selbst nennt.
|
||||
nonisolated var serverName: String { get }
|
||||
/// Was dieser Server darüber hinaus beherrscht.
|
||||
nonisolated var capabilities: Set<BackendCapability> { get }
|
||||
|
||||
@@ -60,9 +82,26 @@ public protocol MediaBackend: Sendable {
|
||||
/// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel.
|
||||
/// Ein Server, der es in einer Anfrage kann, soll das dürfen.
|
||||
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem]
|
||||
|
||||
/// Welche Sprachen eine Serie durchgehend führt — für die Vorwahl.
|
||||
func availableLanguages(inSeries id: String) async -> AvailableLanguages?
|
||||
/// Die nächste Folge nach dieser, sofern es eine gibt.
|
||||
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
||||
|
||||
/// Die Folge davor — für den Rückwärtsknopf im Spieler.
|
||||
///
|
||||
/// **Mit Vorgabe, anders als die nächste.** Die nächste Folge braucht
|
||||
/// jeder Server: ohne sie gibt es kein Weiterlaufen am Ende. Die vorige
|
||||
/// ist eine Bequemlichkeit, und ein Server, der sie nicht kennt, soll
|
||||
/// deshalb nicht unbaubar werden.
|
||||
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
||||
|
||||
/// Wie die Vorschaubilder einer Datei liegen — `nil`, wenn es keine gibt.
|
||||
func vorschauplan(fileId: String) async -> Vorschauplan?
|
||||
|
||||
/// Die Adresse eines Vorschaublattes.
|
||||
nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL?
|
||||
|
||||
// MARK: - Bilder
|
||||
|
||||
/// Ein Bild in der gewünschten Größe.
|
||||
@@ -125,13 +164,6 @@ public protocol MediaBackend: Sendable {
|
||||
/// niemand mehr zusieht.
|
||||
func endPlayback(sessionID: String) async
|
||||
|
||||
/// Meldet, wo man gerade steht.
|
||||
///
|
||||
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
||||
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
||||
/// wäre die schlechtere Hälfte des Tauschs.
|
||||
func reportProgress(_ report: PlaybackReport) async
|
||||
|
||||
// MARK: - Zustand melden
|
||||
|
||||
func markWatched(id: String) async throws(MediaBackendError)
|
||||
@@ -211,6 +243,19 @@ public struct MediaPage: Sendable, Equatable {
|
||||
}
|
||||
|
||||
public extension MediaBackend {
|
||||
/// Vorgabe: kein Weg zurück.
|
||||
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? { nil }
|
||||
|
||||
/// Wie die Vorschaubilder einer Datei liegen — `nil`, wenn es keine gibt.
|
||||
///
|
||||
/// **Mit Vorgabe, damit nicht jeder Server es können muss.** Jellyfin
|
||||
/// hat sein eigenes Trickplay mit anderem Zuschnitt; bis das angebunden
|
||||
/// ist, sagt es hier schlicht nichts.
|
||||
func vorschauplan(fileId: String) async -> Vorschauplan? { nil }
|
||||
|
||||
/// Die Adresse eines Vorschaublattes.
|
||||
nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL? { nil }
|
||||
|
||||
/// Vorgabe: der Server kann es nicht.
|
||||
///
|
||||
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
|
||||
@@ -227,6 +272,13 @@ public extension MediaBackend {
|
||||
subtitleStreamIndex: Int?
|
||||
) -> URL? { nil }
|
||||
|
||||
/// Welche Sprachen eine Serie durchgehend führt.
|
||||
///
|
||||
/// **`nil` heisst: der Server weiss es nicht.** Dann bleibt es bei der
|
||||
/// vollen Liste — eine leere Auswahl anzubieten wäre schlimmer als eine
|
||||
/// zu lange.
|
||||
func availableLanguages(inSeries id: String) async -> AvailableLanguages? { nil }
|
||||
|
||||
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
|
||||
///
|
||||
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
|
||||
@@ -261,3 +313,41 @@ public struct MediaFilterOption: Sendable, Equatable, Identifiable {
|
||||
self.title = title
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Eine Sprache, wie sie in einem Menü steht.
|
||||
public struct LanguageOption: Sendable, Equatable, Identifiable, Hashable {
|
||||
public let code: String
|
||||
public let name: String
|
||||
|
||||
public var id: String { code }
|
||||
|
||||
public init(code: String, name: String) {
|
||||
self.code = code
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
/// Welche Sprachen ein Titel durchgehend führt.
|
||||
///
|
||||
/// **Durchgehend, nicht irgendwo.** Bei einer Serie ist das die
|
||||
/// Schnittmenge über alle Folgen: eine Sprache anzubieten, die es nur in
|
||||
/// Staffel 1 gibt, hiesse eine Einstellung für die Serie, die ab Staffel 2
|
||||
/// stillschweigend etwas anderes tut.
|
||||
public struct AvailableLanguages: Sendable, Equatable {
|
||||
public let audio: [LanguageOption]
|
||||
public let subtitles: [LanguageOption]
|
||||
|
||||
/// Wie viele Sprachen es nur in einem Teil der Folgen gibt.
|
||||
///
|
||||
/// **Damit die Oberfläche es sagen kann.** Eine gekürzte Liste sieht
|
||||
/// sonst aus wie die ganze Wahrheit, und wer eine Sprache vermisst, die
|
||||
/// er in Staffel 1 gesehen hat, hält es für einen Fehler.
|
||||
public let partial: Int
|
||||
|
||||
public init(audio: [LanguageOption], subtitles: [LanguageOption], partial: Int) {
|
||||
self.audio = audio
|
||||
self.subtitles = subtitles
|
||||
self.partial = partial
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,13 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
||||
/// die Serie, und so heißt es jetzt auch.
|
||||
public let seriesThumb: String?
|
||||
public let seriesArt: String?
|
||||
/// Das Bild, das jetzt im Aufmacher gilt.
|
||||
///
|
||||
/// **Vom Server gewählt, nicht hier.** Wer zu einem Titel mehrere
|
||||
/// Aufnahmen ausgesucht hat, bekommt alle paar Stunden eine andere — und
|
||||
/// zwar überall dieselbe. `nil` heisst: keine ausgesucht, dann gilt das
|
||||
/// Hintergrundbild.
|
||||
public let heroArt: String?
|
||||
|
||||
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
|
||||
// Staffel.
|
||||
@@ -126,6 +133,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
||||
parentThumb: String? = nil,
|
||||
seriesThumb: String? = nil,
|
||||
seriesArt: String? = nil,
|
||||
heroArt: String? = nil,
|
||||
index: Int? = nil,
|
||||
parentIndex: Int? = nil,
|
||||
parentTitle: String? = nil,
|
||||
@@ -166,6 +174,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
||||
self.parentThumb = parentThumb
|
||||
self.seriesThumb = seriesThumb
|
||||
self.seriesArt = seriesArt
|
||||
self.heroArt = heroArt
|
||||
self.index = index
|
||||
self.parentIndex = parentIndex
|
||||
self.parentTitle = parentTitle
|
||||
|
||||
@@ -49,7 +49,7 @@ public struct MediaLibrary: Sendable, Equatable, Identifiable, Codable {
|
||||
}
|
||||
|
||||
/// Eine Reihe auf der Startseite oder in den Suchergebnissen.
|
||||
public struct MediaRowGroup: Sendable, Equatable, Identifiable {
|
||||
public struct MediaRowGroup: Sendable, Equatable, Identifiable, Codable {
|
||||
/// Eine stabile Kennung für diese Reihe.
|
||||
///
|
||||
/// Sie entscheidet, welche Reihe der Nutzer ausgeblendet oder verschoben
|
||||
|
||||
@@ -112,10 +112,29 @@ public struct PlaybackTarget: Sendable, Equatable {
|
||||
/// ordnet es die Meldung keiner Wiedergabe zu.
|
||||
public let versionID: String?
|
||||
|
||||
public init(url: URL, sessionID: String? = nil, versionID: String? = nil) {
|
||||
/// Ab welcher Stelle des Films dieser Strom beginnt.
|
||||
///
|
||||
/// **Null heisst: der Strom ist der ganze Film.** Dann springt der
|
||||
/// Spieler selbst, und das ist der Normalfall — bei Jellyfin immer.
|
||||
///
|
||||
/// **Ein Wert darüber heisst: der Server hat schon gesprungen.** Flaros
|
||||
/// Abspielliste wächst von der gewünschten Stelle an vorwärts; was davor
|
||||
/// liegt, gibt es in diesem Strom nicht. Der Spieler zählt trotzdem ab
|
||||
/// null — ohne diesen Wert ginge jede angezeigte Zeit und jede gemeldete
|
||||
/// Stelle um den Vorlauf daneben, und ein Sprung dorthin wäre ein Sprung
|
||||
/// ins Leere.
|
||||
public let startOffset: Duration
|
||||
|
||||
public init(
|
||||
url: URL,
|
||||
sessionID: String? = nil,
|
||||
versionID: String? = nil,
|
||||
startOffset: Duration = .zero
|
||||
) {
|
||||
self.url = url
|
||||
self.sessionID = sessionID
|
||||
self.versionID = versionID
|
||||
self.startOffset = startOffset
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
/// Wie die Vorschaubilder eines Titels zusammengesetzt sind.
|
||||
///
|
||||
/// **Dieselbe Rechnung wie im Server und im Web.** Dort heisst der Typ
|
||||
/// genauso; die Kachel zu einer Sekunde wird an drei Stellen ausgerechnet,
|
||||
/// und wenn eine davon abweicht, sieht man es nicht als Fehler — man hält
|
||||
/// die Vorschau für ein ungenaues Bild.
|
||||
///
|
||||
/// **Die Kachelmasse stehen nicht fest.** Skaliert wird auf eine feste
|
||||
/// Breite, die Höhe folgt dem Seitenverhältnis des Films. Deshalb misst
|
||||
/// der Server sie und teilt sie mit, statt dass sie hier stünden.
|
||||
public struct Vorschauplan: Sendable, Equatable, Decodable {
|
||||
public let intervall: Double
|
||||
public let spalten: Int
|
||||
public let zeilen: Int
|
||||
public let kachelBreite: Double
|
||||
public let kachelHoehe: Double
|
||||
public let blaetter: Int
|
||||
|
||||
public init(
|
||||
intervall: Double, spalten: Int, zeilen: Int,
|
||||
kachelBreite: Double, kachelHoehe: Double, blaetter: Int
|
||||
) {
|
||||
self.intervall = intervall
|
||||
self.spalten = spalten
|
||||
self.zeilen = zeilen
|
||||
self.kachelBreite = kachelBreite
|
||||
self.kachelHoehe = kachelHoehe
|
||||
self.blaetter = blaetter
|
||||
}
|
||||
|
||||
/// Wie eine Kachel adressiert wird: welches Blatt, und wo darin.
|
||||
public struct Stelle: Sendable, Equatable {
|
||||
public let blatt: Int
|
||||
public let spalte: Int
|
||||
public let zeile: Int
|
||||
}
|
||||
|
||||
public var proBlatt: Int { spalten * zeilen }
|
||||
|
||||
/// Welche Kachel die Stelle zeigt — oder nichts.
|
||||
///
|
||||
/// **Hinter dem letzten Bild bleibt das letzte stehen.** Das Raster
|
||||
/// endet dort, wo ffmpeg aufgehört hat; eine Sekunde darüber hinaus —
|
||||
/// bei ungenauer Laufzeit durchaus möglich — soll die Vorschau nicht
|
||||
/// verschwinden.
|
||||
public func stelle(bei sekunde: Double) -> Stelle? {
|
||||
guard blaetter > 0, spalten > 0, zeilen > 0, intervall > 0 else { return nil }
|
||||
|
||||
let nummer = min(Int(max(0, sekunde) / intervall), proBlatt * blaetter - 1)
|
||||
let rest = nummer % proBlatt
|
||||
return Stelle(blatt: nummer / proBlatt, spalte: rest % spalten, zeile: rest / spalten)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import FlaroDesign
|
||||
|
||||
/// Ein Bild von der Platte muss ankommen.
|
||||
///
|
||||
/// **Der Fehler, für den diese Prüfung steht.** Der Lader verlangte von jeder
|
||||
/// Antwort eine `HTTPURLResponse` — eine `file:`-Adresse antwortet aber mit
|
||||
/// einer schlichten `URLResponse`, und die Prüfung gab darauf `nil` zurück.
|
||||
/// Sichtbar wurde das erst, als die Downloads ihre Plakate mitbrachten: die
|
||||
/// Dateien lagen da, und die Kacheln blieben trotzdem grau.
|
||||
@Suite("Bilder von der Platte")
|
||||
struct ArtworkLoaderTests {
|
||||
/// Ein gültiges PNG, ein Bildpunkt gross.
|
||||
private static let winzigesPNG = Data(base64Encoded: """
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmM\
|
||||
IQAAAABJRU5ErkJggg==
|
||||
""")!
|
||||
|
||||
private func ablegen(_ daten: Data, als name: String) throws -> URL {
|
||||
let ziel = FileManager.default.temporaryDirectory
|
||||
.appending(path: "artwork-\(UUID().uuidString)-\(name)")
|
||||
try daten.write(to: ziel)
|
||||
return ziel
|
||||
}
|
||||
|
||||
@Test("Eine Datei wird gelesen, nicht abgefragt")
|
||||
func dateiWirdGelesen() async throws {
|
||||
let datei = try ablegen(Self.winzigesPNG, als: "plakat.bild")
|
||||
defer { try? FileManager.default.removeItem(at: datei) }
|
||||
|
||||
let bild = await ArtworkLoader.shared.image(for: datei)
|
||||
#expect(bild != nil)
|
||||
}
|
||||
|
||||
@Test("Eine Datei, die es nicht gibt, ergibt kein Bild")
|
||||
func fehlendeDatei() async {
|
||||
let nirgends = FileManager.default.temporaryDirectory
|
||||
.appending(path: "gibt-es-nicht-\(UUID().uuidString).bild")
|
||||
let bild = await ArtworkLoader.shared.image(for: nirgends)
|
||||
#expect(bild == nil)
|
||||
}
|
||||
|
||||
@Test("Was keine Bilddaten sind, ergibt kein Bild")
|
||||
func keinBild() async throws {
|
||||
let datei = try ablegen(Data("kein Bild, nur Text".utf8), als: "text.bild")
|
||||
defer { try? FileManager.default.removeItem(at: datei) }
|
||||
|
||||
let bild = await ArtworkLoader.shared.image(for: datei)
|
||||
#expect(bild == nil)
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import MediaKit
|
||||
/// Adressen und Zugang — alles, was ohne Netz entsteht.
|
||||
@Suite("Flaro: Adressen und Zugang")
|
||||
struct AdressenTests {
|
||||
private func backend(_ transport: Attrappe, keksglas: Keksglas? = nil) -> FlaroBackend {
|
||||
private func backend(_ transport: Attrappe, cookieglas: Cookieglas? = nil) -> FlaroBackend {
|
||||
FlaroBackend(
|
||||
client: FlaroClient(
|
||||
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
||||
transport: transport, cookieJar: keksglas),
|
||||
transport: transport, cookieJar: cookieglas),
|
||||
account: FlaroAccount(
|
||||
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
||||
userID: "u1", userName: "guido", isAdministrator: true))
|
||||
@@ -32,17 +32,17 @@ struct AdressenTests {
|
||||
}
|
||||
|
||||
@Test("Ein Zugang wird als Cookie abgelegt")
|
||||
func tokenWirdZuKeks() async {
|
||||
let glas = Keksglas()
|
||||
func tokenWirdZuCookie() async {
|
||||
let glas = Cookieglas()
|
||||
_ = FlaroClient(
|
||||
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
||||
transport: Attrappe(rumpf: "{}"), cookieJar: glas)
|
||||
|
||||
let keks = glas.kekse.first
|
||||
#expect(keks?.name == "flaro_session")
|
||||
#expect(keks?.value == "geheim-123")
|
||||
#expect(keks?.path == "/")
|
||||
#expect(keks?.domain.hasSuffix("nas") == true)
|
||||
let cookie = glas.cookies.first
|
||||
#expect(cookie?.name == "flaro_session")
|
||||
#expect(cookie?.value == "geheim-123")
|
||||
#expect(cookie?.path == "/")
|
||||
#expect(cookie?.domain.hasSuffix("nas") == true)
|
||||
}
|
||||
|
||||
/// **Nach einem Neustart meldet sich niemand neu an.**
|
||||
@@ -51,8 +51,8 @@ struct AdressenTests {
|
||||
/// allein aus der Antwort des Servers, wäre keines da — und jedes Plakat
|
||||
/// bliebe grau, obwohl der Zugang gilt.
|
||||
@Test("Auch ein wiederaufgenommenes Token legt das Cookie an")
|
||||
func keksAuchOhneAnmeldung() async throws {
|
||||
let glas = Keksglas()
|
||||
func cookieAuchOhneAnmeldung() async throws {
|
||||
let glas = Cookieglas()
|
||||
let transport = Attrappe([
|
||||
("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#),
|
||||
("/api/v1/system/info", 200, Proben.info),
|
||||
@@ -62,7 +62,7 @@ struct AdressenTests {
|
||||
baseURL: Proben.adresse, token: "geheim-123",
|
||||
identity: Proben.kennung, transport: transport, cookieJar: glas)
|
||||
|
||||
#expect(glas.kekse.contains { $0.name == "flaro_session" && $0.value == "geheim-123" })
|
||||
#expect(glas.cookies.contains { $0.name == "flaro_session" && $0.value == "geheim-123" })
|
||||
}
|
||||
|
||||
@Test("Die Download-Adresse zeigt auf die Datei, nicht auf den Titel")
|
||||
@@ -104,7 +104,7 @@ struct AdressenTests {
|
||||
|
||||
let (token, konto, _) = try await FlaroSignIn.signIn(
|
||||
baseURL: Proben.adresse, userName: "guido", password: "geheim",
|
||||
identity: Proben.kennung, transport: transport, cookieJar: Keksglas())
|
||||
identity: Proben.kennung, transport: transport, cookieJar: Cookieglas())
|
||||
|
||||
let rumpf = try #require(transport.rumpf(von: "/api/v1/auth/login"))
|
||||
|
||||
|
||||
@@ -46,15 +46,15 @@ final class Attrappe: FlaroTransport, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Keksglas, das nur mitschreibt.
|
||||
/// Ein Cookieglas, das nur mitschreibt.
|
||||
///
|
||||
/// Der Speicher des Systems anzufassen hiesse, dass eine Prüfung die nächste
|
||||
/// beeinflusst — und auf einem Entwicklerrechner auch den Browser.
|
||||
final class Keksglas: FlaroCookieJar, @unchecked Sendable {
|
||||
final class Cookieglas: FlaroCookieJar, @unchecked Sendable {
|
||||
private let sperre = NSLock()
|
||||
private var inhalt: [HTTPCookie] = []
|
||||
|
||||
var kekse: [HTTPCookie] { sperre.withLock { inhalt } }
|
||||
var cookies: [HTTPCookie] { sperre.withLock { inhalt } }
|
||||
|
||||
func store(_ cookie: HTTPCookie) { sperre.withLock { inhalt.append(cookie) } }
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import MediaKit
|
||||
@testable import FlaroServerKit
|
||||
|
||||
/// Vorspann und Abspann auf dem Weg vom Server in den Spieler.
|
||||
///
|
||||
/// **Der Anlass:** im Web erschien der Überspringen-Knopf, in der App nie.
|
||||
/// Dazwischen liegt genau diese Übersetzung — und ohne Prüfung liesse sich
|
||||
/// nicht sagen, ob sie der Grund ist oder etwas dahinter.
|
||||
@Suite("Flaro: Marken")
|
||||
struct MarkenTests {
|
||||
private static let folge = """
|
||||
{
|
||||
"id": "folge-1", "season": 1, "episode": 6,
|
||||
"title": "Bestimmung", "overview": null, "stillImage": null,
|
||||
"files": [{
|
||||
"id": "datei-1", "fileName": "F.mkv", "sizeBytes": 1,
|
||||
"container": "mkv", "durationSeconds": 2700, "streams": []
|
||||
}],
|
||||
"state": null, "publishAt": null, "seriesId": "serie-1",
|
||||
"markers": [
|
||||
{ "kind": "intro", "startSeconds": 30.5, "endSeconds": 65 },
|
||||
{ "kind": "credits", "startSeconds": 2500, "endSeconds": 2700 }
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
@Test("Eine Folge bringt Vorspann und Abspann mit")
|
||||
func folgeMitMarken() throws {
|
||||
let roh = try #require(
|
||||
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(Self.folge.utf8)))
|
||||
let titel = roh.neutral()
|
||||
|
||||
#expect(titel.markers.count == 2)
|
||||
|
||||
let intro = try #require(titel.markers.first { $0.kind == .intro })
|
||||
#expect(intro.startMilliseconds == 30_500)
|
||||
#expect(intro.endMilliseconds == 65_000)
|
||||
#expect(intro.contains(45_000))
|
||||
|
||||
let abspann = try #require(titel.markers.first { $0.kind == .credits })
|
||||
#expect(abspann.startMilliseconds == 2_500_000)
|
||||
}
|
||||
|
||||
/// Ein älterer Server schickt das Feld gar nicht — das ist kein Fehler.
|
||||
@Test("Ohne Marken bleibt die Liste leer")
|
||||
func ohneMarken() throws {
|
||||
let ohne = """
|
||||
{
|
||||
"id": "folge-1", "season": 1, "episode": 6,
|
||||
"title": "Bestimmung", "overview": null, "stillImage": null,
|
||||
"files": [], "state": null, "publishAt": null, "seriesId": "serie-1"
|
||||
}
|
||||
"""
|
||||
let roh = try #require(
|
||||
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(ohne.utf8)))
|
||||
|
||||
#expect(roh.neutral().markers.isEmpty)
|
||||
}
|
||||
|
||||
/// Ein unbekanntes Wort wirft die Marke nicht weg — sie zählt nur nicht.
|
||||
@Test("Eine unbekannte Art wird nicht zur falschen")
|
||||
func unbekannteArt() throws {
|
||||
let fremd = Self.folge.replacingOccurrences(of: "\"intro\"", with: "\"werbung\"")
|
||||
let roh = try #require(
|
||||
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(fremd.utf8)))
|
||||
let titel = roh.neutral()
|
||||
|
||||
#expect(titel.markers.count == 2)
|
||||
#expect(titel.markers.first?.kind == .unknown)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import MediaKit
|
||||
@testable import FlaroServerKit
|
||||
|
||||
/// Die Folge nach dieser — über die Staffelgrenze hinweg.
|
||||
///
|
||||
/// **Der Anlass.** Im Web stand am Abspann „Nächste Folge", in der App nur
|
||||
/// „Abspann überspringen". Der Server war es nicht: beide fragen denselben
|
||||
/// Endpunkt, und er ordnet nach Staffel und dann nach Nummer, findet also
|
||||
/// auch die erste Folge der nächsten Staffel.
|
||||
///
|
||||
/// **Es war der Dekoder.** `nachbar` baute sich einen frischen
|
||||
/// `JSONDecoder()`, und der kennt das Datumsformat des Servers nicht.
|
||||
/// Solange die Nachbarfolge weder `publishAt` noch `lastPlayedAt` hatte,
|
||||
/// ging es gut — mit eingeschalteter Prüfung hat sie ein `publishAt`. Das
|
||||
/// `try?` schluckte den Fehler, und von aussen sah es aus, als gäbe es
|
||||
/// keine nächste Folge.
|
||||
@Suite("Flaro: Nachbarfolge")
|
||||
struct NachbarfolgeTests {
|
||||
private func backend(_ transport: any FlaroTransport) -> FlaroBackend {
|
||||
FlaroBackend(
|
||||
client: FlaroClient(
|
||||
baseURL: Proben.adresse, identity: Proben.kennung, token: "t",
|
||||
transport: transport, cookieJar: nil),
|
||||
account: FlaroAccount(
|
||||
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
||||
userID: "u1", userName: "guido", isAdministrator: false))
|
||||
}
|
||||
|
||||
private static func folge(
|
||||
id: String, staffel: Int, nummer: Int,
|
||||
publishAt: String = "null", zuletzt: String = "null"
|
||||
) -> String {
|
||||
"""
|
||||
{
|
||||
"id": "\(id)", "season": \(staffel), "episode": \(nummer),
|
||||
"title": "Neuanfang", "overview": null, "stillImage": null,
|
||||
"files": [{
|
||||
"id": "d-\(id)", "fileName": "F.mkv", "sizeBytes": 1,
|
||||
"container": "mkv", "durationSeconds": 2700, "streams": []
|
||||
}],
|
||||
"state": { "positionSeconds": 0, "runtimeSeconds": 2700,
|
||||
"watched": false, "playCount": 0, "favourite": false,
|
||||
"rating": null, "dismissed": false,
|
||||
"lastPlayedAt": \(zuletzt), "inProgress": false },
|
||||
"publishAt": \(publishAt), "seriesId": "serie-1", "markers": []
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private static let folgeJetzt = MediaItem(
|
||||
id: "s1e10", kind: .episode, title: "Finale", index: 10, parentIndex: 1)
|
||||
|
||||
/// Das Staffelfinale: die nächste Folge ist die erste der nächsten Staffel.
|
||||
@Test("Über die Staffelgrenze hinweg")
|
||||
func ueberDieStaffelgrenze() async throws {
|
||||
let backend = backend(Attrappe(rumpf: Self.folge(id: "s2e1", staffel: 2, nummer: 1)))
|
||||
|
||||
let naechste = try #require(await backend.nextEpisode(after: Self.folgeJetzt))
|
||||
|
||||
#expect(naechste.id == "s2e1")
|
||||
#expect(naechste.parentIndex == 2)
|
||||
#expect(naechste.index == 1)
|
||||
}
|
||||
|
||||
/// **Der Fehler selbst.** Mit `publishAt` und `lastPlayedAt` scheiterte
|
||||
/// das Lesen stillschweigend, und der Knopf blieb weg. Mit dem frischen
|
||||
/// `JSONDecoder()` von vorher ist diese Prüfung rot.
|
||||
@Test("Auch mit Datumsangaben — daran scheiterte es")
|
||||
func mitDatumsangaben() async throws {
|
||||
let backend = backend(Attrappe(rumpf: Self.folge(
|
||||
id: "s2e1", staffel: 2, nummer: 1,
|
||||
publishAt: "\"2026-10-01T18:00:00Z\"",
|
||||
zuletzt: "\"2026-10-11T21:14:03.482Z\"")))
|
||||
|
||||
let naechste = try #require(await backend.nextEpisode(after: Self.folgeJetzt))
|
||||
|
||||
#expect(naechste.id == "s2e1")
|
||||
#expect(naechste.availableFrom != nil)
|
||||
}
|
||||
|
||||
/// Nach der letzten Folge kommt keine — 204, und das ist kein Fehler.
|
||||
@Test("Hinter der letzten Folge kommt nichts")
|
||||
func hinterDerLetzten() async throws {
|
||||
let backend = backend(Attrappe(rumpf: "", status: 204))
|
||||
|
||||
#expect(try await backend.nextEpisode(after: Self.folgeJetzt) == nil)
|
||||
}
|
||||
|
||||
/// Ein Film hat keine Nachbarn — und fragt gar nicht erst.
|
||||
@Test("Ein Film fragt nicht nach Nachbarn")
|
||||
func filmFragtNicht() async throws {
|
||||
let attrappe = Attrappe(rumpf: Self.folge(id: "x", staffel: 1, nummer: 1))
|
||||
let film = MediaItem(id: "f1", kind: .movie, title: "Arrival")
|
||||
|
||||
#expect(try await backend(attrappe).nextEpisode(after: film) == nil)
|
||||
#expect(attrappe.anfragen.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import MediaKit
|
||||
|
||||
/// Ein Server, der auf Ansage annimmt oder nicht.
|
||||
private final class Briefkasten: Fortschrittsempfaenger, @unchecked Sendable {
|
||||
nonisolated let identity: String
|
||||
/// Ob Meldungen angenommen werden.
|
||||
var nimmtAn: Bool
|
||||
private(set) var angekommen: [PlaybackReport] = []
|
||||
|
||||
init(identity: String = "server-a", nimmtAn: Bool = true) {
|
||||
self.identity = identity
|
||||
self.nimmtAn = nimmtAn
|
||||
}
|
||||
|
||||
func reportProgress(_ report: PlaybackReport) async -> Bool {
|
||||
guard nimmtAn else { return false }
|
||||
angekommen.append(report)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func bericht(_ titel: String, sekunden: Double) -> PlaybackReport {
|
||||
PlaybackReport(
|
||||
itemID: titel,
|
||||
state: .stopped,
|
||||
time: .seconds(sekunden),
|
||||
duration: .seconds(3600)
|
||||
)
|
||||
}
|
||||
|
||||
@Suite("Nachgereichter Fortschritt")
|
||||
@MainActor
|
||||
struct FortschrittspostTests {
|
||||
/// Eine eigene Datei je Prüfung — sonst sähe die zweite, was die erste
|
||||
/// liegen gelassen hat.
|
||||
private func ablage() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appending(path: "post-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
@Test("Was ankam, wird nicht aufgehoben")
|
||||
func nichtsLiegenLassen() async {
|
||||
let post = Fortschrittspost(ablage: ablage())
|
||||
let server = Briefkasten()
|
||||
|
||||
post.zuruecklegen(bericht("film", sekunden: 600), server: server.identity)
|
||||
#expect(post.wartend == 1)
|
||||
|
||||
await post.zustellen(über: server)
|
||||
#expect(post.wartend == 0)
|
||||
#expect(server.angekommen.count == 1)
|
||||
#expect(server.angekommen.first?.itemID == "film")
|
||||
}
|
||||
|
||||
@Test("Was nicht ankommt, bleibt liegen")
|
||||
func liegenbleiben() async {
|
||||
let post = Fortschrittspost(ablage: ablage())
|
||||
let server = Briefkasten(nimmtAn: false)
|
||||
|
||||
post.zuruecklegen(bericht("film", sekunden: 600), server: server.identity)
|
||||
await post.zustellen(über: server)
|
||||
#expect(post.wartend == 1)
|
||||
|
||||
server.nimmtAn = true
|
||||
await post.zustellen(über: server)
|
||||
#expect(post.wartend == 0)
|
||||
}
|
||||
|
||||
@Test("Je Titel nur der letzte Stand")
|
||||
func nurDerLetzteStand() async {
|
||||
let post = Fortschrittspost(ablage: ablage())
|
||||
let server = Briefkasten()
|
||||
|
||||
post.zuruecklegen(bericht("film", sekunden: 60), server: server.identity)
|
||||
post.zuruecklegen(bericht("film", sekunden: 1800), server: server.identity)
|
||||
#expect(post.wartend == 1)
|
||||
|
||||
await post.zustellen(über: server)
|
||||
#expect(server.angekommen.count == 1)
|
||||
#expect(server.angekommen.first?.time == .seconds(1800))
|
||||
}
|
||||
|
||||
@Test("Ein fremder Server bekommt nichts")
|
||||
func nichtAnDenFalschen() async {
|
||||
let post = Fortschrittspost(ablage: ablage())
|
||||
post.zuruecklegen(bericht("film", sekunden: 600), server: "server-a")
|
||||
|
||||
let anderer = Briefkasten(identity: "server-b")
|
||||
await post.zustellen(über: anderer)
|
||||
#expect(anderer.angekommen.isEmpty)
|
||||
#expect(post.wartend == 1)
|
||||
}
|
||||
|
||||
@Test("Der Briefkasten überlebt den Neustart")
|
||||
func aufDerPlatte() async {
|
||||
let datei = ablage()
|
||||
let erste = Fortschrittspost(ablage: datei)
|
||||
erste.zuruecklegen(bericht("folge", sekunden: 900), server: "server-a")
|
||||
|
||||
let zweite = Fortschrittspost(ablage: datei)
|
||||
#expect(zweite.wartend == 1)
|
||||
|
||||
let server = Briefkasten()
|
||||
await zweite.zustellen(über: server)
|
||||
#expect(server.angekommen.first?.time == .seconds(900))
|
||||
}
|
||||
|
||||
@Test("Leeren wirft alles weg")
|
||||
func leeren() async {
|
||||
let post = Fortschrittspost(ablage: ablage())
|
||||
post.zuruecklegen(bericht("film", sekunden: 600), server: "server-a")
|
||||
post.leeren()
|
||||
#expect(post.wartend == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import MediaKit
|
||||
|
||||
@Suite("Die gemerkte Startseite")
|
||||
struct LibraryCacheHomeTests {
|
||||
private func speicher() -> LibraryCache {
|
||||
LibraryCache(root: FileManager.default.temporaryDirectory
|
||||
.appending(path: "cache-\(UUID().uuidString)", directoryHint: .isDirectory))
|
||||
}
|
||||
|
||||
private func titel(_ nummer: Int) -> MediaItem {
|
||||
MediaItem(id: "t\(nummer)", kind: .movie, title: "Titel \(nummer)", thumb: "/bild/\(nummer)")
|
||||
}
|
||||
|
||||
@Test("Was gespeichert wurde, kommt zurück")
|
||||
func hinUndZurueck() async {
|
||||
let cache = speicher()
|
||||
let reihen = [
|
||||
MediaRowGroup(id: "continue", title: "Weiterschauen",
|
||||
libraryID: "mediathek", items: [titel(1), titel(2)]),
|
||||
MediaRowGroup(id: "latest.1", title: "Neu", moreKey: "/mehr", items: [titel(3)]),
|
||||
]
|
||||
|
||||
await cache.storeHomeRows(reihen, server: "server-a")
|
||||
let gemerkt = await cache.homeRows(server: "server-a")
|
||||
|
||||
#expect(gemerkt?.map(\.id) == ["continue", "latest.1"])
|
||||
#expect(gemerkt?.first?.title == "Weiterschauen")
|
||||
#expect(gemerkt?.first?.libraryID == "mediathek")
|
||||
#expect(gemerkt?.last?.moreKey == "/mehr")
|
||||
#expect(gemerkt?.first?.items.map(\.id) == ["t1", "t2"])
|
||||
// Ohne den Bildpfad wäre der Abzug wertlos: die Plakate liegen
|
||||
// bereits auf der Platte, nur findet sie dann niemand.
|
||||
#expect(gemerkt?.first?.items.first?.thumb == "/bild/1")
|
||||
}
|
||||
|
||||
@Test("Ein anderer Server hat seine eigene Startseite")
|
||||
func getrenntJeServer() async {
|
||||
let cache = speicher()
|
||||
await cache.storeHomeRows(
|
||||
[MediaRowGroup(id: "continue", title: "Weiterschauen", items: [titel(1)])],
|
||||
server: "server-a"
|
||||
)
|
||||
let fremd = await cache.homeRows(server: "server-b")
|
||||
#expect(fremd == nil)
|
||||
}
|
||||
|
||||
@Test("Ohne gespeicherten Stand kommt nichts")
|
||||
func nochNichtsDa() async {
|
||||
let gemerkt = await speicher().homeRows(server: "server-a")
|
||||
#expect(gemerkt == nil)
|
||||
}
|
||||
|
||||
@Test("Lange Reihen werden gekürzt")
|
||||
func nichtAlles() async {
|
||||
let cache = speicher()
|
||||
let lang = (1...80).map(titel)
|
||||
await cache.storeHomeRows(
|
||||
[MediaRowGroup(id: "latest", title: "Neu", items: lang)], server: "server-a"
|
||||
)
|
||||
let gemerkt = await cache.homeRows(server: "server-a")
|
||||
#expect(gemerkt?.first?.items.count == 20)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
# Den Abspann im Bild finden — Entwurf
|
||||
|
||||
Der Tonabgleich findet Vorspann und Abspann, **weil sie sich wiederholen**:
|
||||
dieselbe Musik in Folge 1 und Folge 2. Ein Film hat nichts, womit er sich
|
||||
vergleichen liesse. Sein Abspann kommt genau einmal vor.
|
||||
|
||||
Was bleibt, ist das Bild. Dieser Entwurf sagt, was daran messbar ist, was es
|
||||
kostet, und wo das Verfahren danebenliegt.
|
||||
|
||||
Kapitelmarken sind hier bewusst **nicht** der Weg: sie sind exakt, wo sie
|
||||
stehen, aber bei einer gemischten Sammlung stehen sie selten.
|
||||
|
||||
---
|
||||
|
||||
## 1 · Das Signal, nachgemessen
|
||||
|
||||
Ein Abspann ist dunkel. Das ist keine Vermutung — nachgestellt mit einem
|
||||
erzeugten Film (90 Sekunden Bild, danach 40 Sekunden weisse Schrift auf
|
||||
schwarzem Grund), abgetastet mit zwei Bildern je Sekunde:
|
||||
|
||||
| Stelle | mittlere Helligkeit (`YAVG`, 0–255) |
|
||||
|---|---|
|
||||
| 0–89,5 s (Handlung) | **125** |
|
||||
| ab 90,0 s (Abspann) | **18** |
|
||||
|
||||
Der Sprung liegt auf dem Bild genau, an dem der Abspann anfängt. Kein
|
||||
Übergang, keine Grauzone — und das ist der Punkt: gesucht wird nicht „ein
|
||||
dunkles Bild", sondern **die Kante**.
|
||||
|
||||
Dieselbe Richtung geht Jellyfins Intro Skipper. Dort heisst es
|
||||
„Add support for using black frames to locate ending credits"; die Suche
|
||||
läuft rückwärts vom Ende und hört auf, wenn der Bereich auf vier Sekunden
|
||||
geschrumpft ist.
|
||||
|
||||
## 2 · Was es kostet
|
||||
|
||||
Das Rechnen ist umsonst, das **Dekodieren** ist der Preis. Gemessen an einer
|
||||
erzeugten Datei, 1080p, h264, 180 Sekunden:
|
||||
|
||||
| Weg | Zeit |
|
||||
|---|---|
|
||||
| Jedes zweite Bild dekodieren (`fps=2`) | **2,89 s** |
|
||||
| Nur Schlüsselbilder (`-skip_frame nokey`) | **0,24 s** |
|
||||
|
||||
Hochgerechnet auf die letzten 15 Minuten eines Films: der erste Weg kostet
|
||||
rund anderthalb Minuten je Film, der zweite ein paar Sekunden. *Das ist eine
|
||||
Hochrechnung, keine Messung an echtem Material.*
|
||||
|
||||
Es geht noch billiger, und so macht es Jellyfin: **nicht am Stück abtasten,
|
||||
sondern zielen.** Binäre Suche mit Sonden von zwei Sekunden zwischen „sicher
|
||||
Handlung" und „sicher Abspann" — zehn Sonden genügen für eine Genauigkeit von
|
||||
vier Sekunden, und zehn mal zwei Sekunden Dekodierung sind nichts.
|
||||
|
||||
**Vorschlag: beides.** Erst eine grobe Reihe von Schlüsselbildern über die
|
||||
letzten zwanzig Minuten (ein Durchlauf, wenige Sekunden) — die sagt, *ob* es
|
||||
überhaupt eine dunkle Strecke am Ende gibt und ungefähr wo. Dann eine binäre
|
||||
Suche mit kurzen Sonden auf die Kante. Der erste Schritt verhindert, dass die
|
||||
binäre Suche in einem Film ohne dunklen Abspann wild herumspringt; der zweite
|
||||
macht sie genau.
|
||||
|
||||
## 3 · Der Zuschnitt
|
||||
|
||||
Dieselbe Trennung wie beim Tonabgleich: **das Schwierige rein, das Dreckige
|
||||
dünn.**
|
||||
|
||||
**`Bildabtaster` (Flaro.Media)** — der einzige Teil, der ffmpeg anfasst.
|
||||
|
||||
```
|
||||
helligkeiten(pfad, ab, bis, schritt) -> [(sekunde, helligkeit)]
|
||||
istDunkel(pfad, ab, dauer) -> bool
|
||||
```
|
||||
|
||||
Beides über `signalstats` beziehungsweise `blackdetect`, als eigener Prozess
|
||||
wie `FfprobeRunner`. Fehlt ffmpeg oder scheitert der Lauf: leere Antwort,
|
||||
keine Marke, kein Abbruch.
|
||||
|
||||
**`Abspannkante` (Flaro.Core)** — reine Rechnung, ohne Datei:
|
||||
|
||||
```
|
||||
Suchen(messpunkte, laufzeit, regeln) -> Kante?
|
||||
```
|
||||
|
||||
Nimmt die Reihe aus Schritt eins und gibt zurück, wo die dunkle Strecke
|
||||
anfängt — oder nichts. Hier liegen alle Schwellen, und hier liegen die
|
||||
Prüfungen. Dieselbe Rolle wie `Tonabgleich`.
|
||||
|
||||
**`Markenerkennung`** bekommt einen zweiten Zweig: Serien über den Ton,
|
||||
Filme über das Bild. Die Tabelle, der Vertrag, die Knöpfe in App und Web
|
||||
stehen schon — ein Abspann ist ein Abspann, egal woher er kommt.
|
||||
|
||||
Dafür braucht `Marke` eine Spalte `MovieId` neben `EpisodeId` (heute nur
|
||||
Folgen), und `MovieResponse` die Marken, wie `EpisodeResponse` sie schon hat.
|
||||
|
||||
## 4 · Die Schwellen
|
||||
|
||||
Alles hier ist eine Setzung und gehört an **eine** Stelle, damit man sie
|
||||
gemeinsam verschieben kann:
|
||||
|
||||
| Grösse | Vorschlag | Warum |
|
||||
|---|---|---|
|
||||
| Helligkeit „dunkel" | `YAVG < 35` | Gemessen: Abspann 18, Handlung 125. Fünfunddreissig liegt weit von beiden. |
|
||||
| Anteil dunkler Bilder in einer Sonde | 85 % | Eine Schrifttafel blitzt hell auf; eine einzelne helle Sonde darf die Strecke nicht brechen. |
|
||||
| Kürzester Abspann | 45 s | Darunter lohnt kein Knopf, und zufällige Dunkelheit ist häufiger. |
|
||||
| Längster Abspann | 12 min | Darüber ist es kein Abspann, sondern ein Fehlfund. |
|
||||
| Suchbereich | letzte 20 min | Bei einem Dreistünder wäre „die letzten 10 %" zu viel, bei einem Kurzfilm zu wenig. |
|
||||
| Genauigkeit | 4 s | Wie bei Jellyfin. Feiner zu suchen kostet Sonden und bringt niemandem etwas. |
|
||||
|
||||
## 5 · Wo es danebenliegt — und was dann passiert
|
||||
|
||||
**Das muss vorher dastehen, nicht hinterher.**
|
||||
|
||||
- **Abspann über laufendem Bild.** Marvel, Pixar, jede zweite Komödie: die
|
||||
Namen laufen über eine Szene. Keine dunkle Strecke, kein Fund. → Kein
|
||||
Knopf. Das ist die richtige Antwort; ein geratener Knopf schnitte Handlung
|
||||
weg.
|
||||
- **Dunkle Schlussszene.** Ein Film, der im Dunkeln endet, bekommt die Kante
|
||||
zu früh. Dagegen hilft die Mindestlänge und die Obergrenze — und dass die
|
||||
Kante die **früheste** dunkle Stelle einer durchgehenden Strecke bis zum
|
||||
Ende sein muss, nicht irgendeine.
|
||||
- **Schwarzblende mitten im Film.** Kommt vor, hält aber selten bis zum Ende
|
||||
durch. Die Forderung „bis zum Dateiende dunkel" fängt das.
|
||||
- **Szene nach dem Abspann.** Dann ist die Strecke unterbrochen, und der Fund
|
||||
fällt heraus. Lieber keiner als einer, der über die Szene springt.
|
||||
|
||||
Grobe Erwartung, **ungemessen**: bei Serien greift das Verfahren oft, bei
|
||||
Kinofilmen vielleicht bei der Hälfte. Mehr lässt sich erst sagen, wenn es an
|
||||
echtem Material gelaufen ist — und dafür braucht es einen Weg, die Funde
|
||||
nachzusehen.
|
||||
|
||||
## 6 · Nachsehen können
|
||||
|
||||
Ohne das wird aus dem Verfahren eine Blackbox, die manchmal recht hat.
|
||||
Vorschlag: auf der Detailseite steht bei einem Film mit Marke, **wo** sie
|
||||
liegt, mit einem Knopf „stimmt nicht" — der die Marke löscht und den Titel
|
||||
für weitere Läufe sperrt. Drei Zeilen Oberfläche, und man hat eine Messung
|
||||
statt eines Gefühls.
|
||||
|
||||
## 7 · Stufen
|
||||
|
||||
| | Schritt | Prüfbar an |
|
||||
|---|---|---|
|
||||
| 1 | `Abspannkante` als reine Rechnung samt Prüfungen | erfundenen Messreihen |
|
||||
| 2 | `Bildabtaster` über ffmpeg | der erzeugten Prüfdatei aus Abschnitt 1 |
|
||||
| 3 | `Marke.MovieId`, Wanderung, Marken im Filmvertrag | Serverprüfungen |
|
||||
| 4 | Filme im Lauf nach dem Scan | am Bestand, im Protokoll |
|
||||
| 5 | „stimmt nicht" auf der Detailseite | von Hand |
|
||||
|
||||
## Prüfliste
|
||||
|
||||
- **V.1** Ein Film mit schwarzem Abspann bekommt eine Marke, und sie liegt
|
||||
innerhalb von fünf Sekunden an der richtigen Stelle.
|
||||
- **V.2** Ein Film mit Abspann über laufendem Bild bekommt **keine** Marke.
|
||||
- **V.3** Ein Film, der im Dunkeln endet, ohne Abspann, bekommt keine Marke.
|
||||
- **V.4** Der Lauf über hundert Filme belastet die Platte nicht länger als
|
||||
der Scan selbst.
|
||||
- **V.5** Der Überspringen-Knopf erscheint bei einem Film genauso wie bei
|
||||
einer Folge.
|
||||
- **V.6** Mit erkanntem Abspann gilt ein Film als gesehen, sobald er anfängt
|
||||
— dieselbe Regel wie bei Folgen, sie steht schon.
|
||||
|
||||
---
|
||||
|
||||
## Quellen
|
||||
|
||||
- [intro-skipper auf GitHub](https://github.com/intro-skipper/intro-skipper)
|
||||
- [Intro Skipper, README](https://cdn.jsdelivr.net/gh/intro-skipper/intro-skipper@10.11/README.md)
|
||||
- [Forenbeitrag zu Abspann-Erkennung](https://forum.jellyfin.org/t-how-to-skip-ending-credits-with-confusedpolarbear-intro-skipper)
|
||||
Binary file not shown.
@@ -1,2 +1,5 @@
|
||||
21:35:00 INFO Server gestartet — 1.0.0 · DEV
|
||||
21:55:29 INFO Server fährt herunter.
|
||||
22:00:35 INFO Server gestartet — 1.0.0 · DEV
|
||||
22:11:06 INFO Server fährt herunter.
|
||||
22:14:53 INFO Server gestartet — 1.0.0 · DEV
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
06:38:47 INFO Server fährt herunter.
|
||||
06:44:02 INFO Server gestartet — 1.0.0 · DEV
|
||||
06:57:23 INFO Server fährt herunter.
|
||||
06:59:45 INFO Server gestartet — 1.0.0 · DEV
|
||||
17:38:34 INFO Server fährt herunter.
|
||||
17:57:11 INFO Server gestartet — 1.0.0 · DEV
|
||||
19:05:40 INFO Server fährt herunter.
|
||||
19:08:22 INFO Server gestartet — 1.0.0 · DEV
|
||||
21:03:32 INFO Server fährt herunter.
|
||||
21:08:04 INFO Server gestartet — 1.0.0 · DEV
|
||||
21:10:04 INFO Wartung — Sicherung: flaro-2026-10-08-1910.db, 0 MB
|
||||
21:10:04 INFO Wartung — Optimieren: nichts zusammenzupacken
|
||||
21:10:04 INFO Wartung — Bilder: kein Bildspeicher da
|
||||
21:45:40 INFO Server fährt herunter.
|
||||
21:45:44 INFO Server gestartet — 1.0.0 · DEV
|
||||
22:06:03 INFO Server fährt herunter.
|
||||
22:12:27 INFO Server gestartet — 1.0.0 · DEV
|
||||
@@ -0,0 +1 @@
|
||||
21:17:00 INFO Wartung — Sicherung: flaro-2026-10-11-1917.db, 0 MB
|
||||
@@ -0,0 +1 @@
|
||||
21:26:01 INFO Wartung — Sicherung: flaro-2026-10-14-1926.db, 0 MB
|
||||
@@ -0,0 +1,2 @@
|
||||
21:10:21 INFO Wartung — Optimieren: nichts zusammenzupacken
|
||||
21:10:21 INFO Wartung — Bilder: kein Bildspeicher da
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,23 @@
|
||||
<Project>
|
||||
|
||||
<!--
|
||||
Eine kaputte Protokollvorlage ist kein Schönheitsfehler.
|
||||
|
||||
`log.LogInformation("… {Folgen} … {Folgen}.", a, b, c, d, e)` wirft zur
|
||||
Laufzeit — jeder Platzhalter verbraucht ein Argument, auch ein
|
||||
wiederholter. Geschieht das in einem Hintergrundlauf, reisst die
|
||||
Ausnahme den ganzen Lauf mit, und alles, was er bis dahin gefunden
|
||||
hatte, ist verloren. Genau so ist es der Markenerkennung ergangen.
|
||||
|
||||
CA2017 hatte das längst gemeldet — als Warnung unter hundert anderen
|
||||
Zeilen, und damit übersehen. Als Fehler ist es nicht zu übersehen.
|
||||
|
||||
Nur diese eine Regel, nicht alle Warnungen: ein pauschales
|
||||
`TreatWarningsAsErrors` wäre eine andere Entscheidung und gehört nicht
|
||||
nebenbei getroffen.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);CA2017</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -49,6 +49,7 @@ ARG TARGETARCH
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ffmpeg libva2 libva-drm2 mesa-va-drivers curl \
|
||||
libchromaprint-tools \
|
||||
&& if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \
|
||||
fi \
|
||||
|
||||
@@ -26,9 +26,20 @@ public static class Aufmacherhoehe
|
||||
/// <summary>Die Mitte — das, was vorher fest verdrahtet war.</summary>
|
||||
public const string Vorgabe = "mittel";
|
||||
|
||||
private static readonly string[] Stufen = ["niedrig", "mittel", "hoch", "sehrhoch"];
|
||||
/// <remarks>
|
||||
/// <b>Die fünfte kam nach.</b> Am Telefon war selbst „sehr hoch" zu
|
||||
/// flach — dort überschrieb eine feste Regel die Einstellung ganz. Das
|
||||
/// ist behoben; die zusätzliche Stufe gibt darüber hinaus Luft.
|
||||
///
|
||||
/// <b>Und eine sechste darunter.</b> „Niedrig" war mit 62 vh nicht
|
||||
/// niedrig, sondern mittel mit anderem Namen — die Spanne reichte nur
|
||||
/// nach oben. Wer den Aufmacher als Kopfzeile will und nicht als halbe
|
||||
/// Seite, hatte keine Stufe dafür.
|
||||
/// </remarks>
|
||||
private static readonly string[] Stufen =
|
||||
["flach", "niedrig", "mittel", "hoch", "sehrhoch", "voll"];
|
||||
|
||||
/// <summary>Macht aus dem Gespeicherten eine der vier Stufen.</summary>
|
||||
/// <summary>Macht aus dem Gespeicherten eine der Stufen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Unbekanntes wird zur Vorgabe statt zu einem Fehler: in der Spalte
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Flaro.Api.Auth;
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>Authorization: Bearer <token></c>. Der Server hasht das Vorgezeigte
|
||||
/// und sucht den Fingerabdruck — das Token selbst steht nirgends in der
|
||||
/// und sucht den Fingerfingerprint — das Token selbst steht nirgends in der
|
||||
/// Datenbank und ist aus ihr auch nicht zu gewinnen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -69,8 +69,8 @@ public sealed class TokenAuthenticationHandler(
|
||||
var token = AusKopfzeile() ?? Request.Cookies[CookieName];
|
||||
if (string.IsNullOrEmpty(token)) return AuthenticateResult.NoResult();
|
||||
|
||||
var fingerabdruck = Secrets.Fingerprint(token);
|
||||
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerabdruck);
|
||||
var fingerfingerprint = Secrets.Fingerprint(token);
|
||||
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerfingerprint);
|
||||
|
||||
var jetzt = clock.GetUtcNow();
|
||||
if (eintrag is null || !eintrag.IsValid(jetzt)) return AuthenticateResult.Fail("Token gilt nicht.");
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Globalization;
|
||||
using Flaro.Core.Media;
|
||||
using Flaro.Core.Metadata;
|
||||
using Flaro.Data;
|
||||
using Flaro.Metadata;
|
||||
@@ -34,28 +36,55 @@ public static class ArtworkEndpoints
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
gruppe.MapGet("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||
string art, Guid id, FlaroContext db, TmdbClient tmdb, CancellationToken ct) =>
|
||||
string art, Guid id, FlaroContext db, TmdbClient tmdb, FanartClient fanart,
|
||||
ITmdbSettingsSource spracheVon, CancellationToken ct) =>
|
||||
{
|
||||
var serie = art == "series";
|
||||
var kennung = serie
|
||||
? await db.Series.Where(s => s.Id == id).Select(s => s.TmdbId).FirstOrDefaultAsync(ct)
|
||||
: await db.Movies.Where(m => m.Id == id).Select(m => m.TmdbId).FirstOrDefaultAsync(ct);
|
||||
|
||||
var aktuell = serie
|
||||
var roh = serie
|
||||
? await db.Series.Where(s => s.Id == id)
|
||||
.Select(s => new Bildstand(s.PosterImage, s.BackdropImage, s.LogoImage,
|
||||
s.PosterPinned, s.BackdropPinned, s.LogoPinned)).FirstOrDefaultAsync(ct)
|
||||
.Select(s => new { s.PosterImage, s.BackdropImage, s.LogoImage,
|
||||
s.PosterPinned, s.BackdropPinned, s.LogoPinned, s.Aufmacherbilder })
|
||||
.FirstOrDefaultAsync(ct)
|
||||
: await db.Movies.Where(m => m.Id == id)
|
||||
.Select(m => new Bildstand(m.PosterImage, m.BackdropImage, m.LogoImage,
|
||||
m.PosterPinned, m.BackdropPinned, m.LogoPinned)).FirstOrDefaultAsync(ct);
|
||||
.Select(m => new { m.PosterImage, m.BackdropImage, m.LogoImage,
|
||||
m.PosterPinned, m.BackdropPinned, m.LogoPinned, m.Aufmacherbilder })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
var aktuell = roh is null ? null : new Bildstand(
|
||||
roh.PosterImage, roh.BackdropImage, roh.LogoImage,
|
||||
roh.PosterPinned, roh.BackdropPinned, roh.LogoPinned,
|
||||
Aufmacherwahl.Lesen(roh.Aufmacherbilder));
|
||||
|
||||
if (aktuell is null) return Results.NotFound();
|
||||
|
||||
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die
|
||||
// Zuordnung das Problem, nicht das Bild.
|
||||
var kandidaten = kennung is { } k
|
||||
? await tmdb.BilderAsync(k, serie, ct)
|
||||
: [];
|
||||
IReadOnlyList<Bildkandidat> kandidaten = [];
|
||||
if (kennung is { } k)
|
||||
{
|
||||
var ausTmdb = await tmdb.BilderAsync(k, serie, ct);
|
||||
|
||||
// **fanart.tv nur bei Filmen.** Deren Serienweg verlangt eine
|
||||
// TVDB-Kennung, und die speichert Flaro nicht. Lieber eine
|
||||
// Beschränkung, die man sieht, als eine, die stillschweigend
|
||||
// nichts tut.
|
||||
var ausFanart = serie
|
||||
? []
|
||||
: await fanart.BilderAsync(k.ToString(CultureInfo.InvariantCulture), ct);
|
||||
|
||||
var sprache = (await spracheVon.GetAsync(ct)).Language;
|
||||
var kuerzel = sprache.Split('-')[0];
|
||||
|
||||
// **Gemeinsam geordnet, nicht hintereinandergehängt.** Ein
|
||||
// deutsches Plakat von fanart.tv gehört vor ein sprachloses
|
||||
// von TMDB — das ist der Grund, warum es die zweite Quelle
|
||||
// überhaupt gibt.
|
||||
kandidaten = Bildrang.Geordnet(ausTmdb.Concat(ausFanart), kuerzel).ToList();
|
||||
}
|
||||
|
||||
return Results.Ok(new Bildauswahl(aktuell, kandidaten));
|
||||
});
|
||||
@@ -83,6 +112,53 @@ public static class ArtworkEndpoints
|
||||
return gesetzt ? Results.NoContent() : Results.NotFound();
|
||||
});
|
||||
|
||||
// **Die Mehrfachauswahl fürs Karussell.**
|
||||
//
|
||||
// Poster, Hintergrund und Schriftzug bleiben einzeln: sie stehen
|
||||
// hinter Kacheln und Kopfzeilen, und dort soll nichts wechseln. Der
|
||||
// Aufmacher dagegen ist eine Bühne — wer fünf Aufnahmen ausgesucht
|
||||
// hat, soll sie auch zu sehen bekommen.
|
||||
gruppe.MapPut("/{art:regex(^movies|series$)}/{id:guid}/artwork/aufmacher", async (
|
||||
string art, Guid id, AufmacherWahlRequest anfrage,
|
||||
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||
{
|
||||
if (anfrage.Urls.Count > Aufmacherwahl.Hoechstens)
|
||||
return Einwand.Bitte($"Höchstens {Aufmacherwahl.Hoechstens} Bilder.");
|
||||
|
||||
// Erst alle in den Zwischenspeicher, dann setzen: sonst zeigte
|
||||
// die Auswahl auf etwas, das es hier nicht gibt. Was sich nicht
|
||||
// laden lässt, fällt heraus — ein einzelnes totes Bild soll
|
||||
// nicht die ganze Auswahl verhindern.
|
||||
var schluessel = new List<string>();
|
||||
foreach (var url in anfrage.Urls.Take(Aufmacherwahl.Hoechstens))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) continue;
|
||||
|
||||
// **Was schon hier liegt, bleibt liegen.** Der Client
|
||||
// schickt eine Mischung: die bereits gewählten Bilder als
|
||||
// Zwischenspeicherschlüssel und die neuen als Adresse. Einen
|
||||
// Schlüssel noch einmal herunterladen zu wollen hiesse, eine
|
||||
// Adresse daraus zu raten, die es nicht gibt.
|
||||
if (!url.Contains("://", StringComparison.Ordinal))
|
||||
{
|
||||
schluessel.Add(url);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await ablage.StoreAsync(new RemoteImage(url), ct) is { } k) schluessel.Add(k);
|
||||
}
|
||||
|
||||
var zeile = Aufmacherwahl.Schreiben(schluessel);
|
||||
|
||||
var betroffen = art == "series"
|
||||
? await db.Series.Where(s => s.Id == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Aufmacherbilder, zeile), ct)
|
||||
: await db.Movies.Where(m => m.Id == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Aufmacherbilder, zeile), ct);
|
||||
|
||||
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
|
||||
});
|
||||
|
||||
gruppe.MapPost("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||
string art, Guid id, string kind, HttpRequest anfrage,
|
||||
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||
|
||||
@@ -128,8 +128,8 @@ public static class AuthEndpoints
|
||||
string handle, FlaroContext db, TimeProvider clock) =>
|
||||
{
|
||||
var jetzt = clock.GetUtcNow();
|
||||
var fingerabdruck = Secrets.Fingerprint(handle);
|
||||
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerabdruck);
|
||||
var fingerfingerprint = Secrets.Fingerprint(handle);
|
||||
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerfingerprint);
|
||||
|
||||
if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null)
|
||||
return Results.Ok(new CodeState("expired", null));
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed record SystemInfo(
|
||||
/// lägen die Dateien woanders als die Datenbank sie vermutet.
|
||||
/// </param>
|
||||
/// <param name="Bilder">Wie viel davon auf den Bildspeicher entfällt; sonst <c>null</c>.</param>
|
||||
/// <summary>Wie viel auf dem Kratzplatz liegen bleiben darf.</summary>
|
||||
/// <summary>Wie viel auf dem Transcode-Cache liegen bleiben darf.</summary>
|
||||
///
|
||||
/// <param name="CapGb">Obergrenze in Gigabyte; <c>0</c> heisst unbegrenzt.</param>
|
||||
/// <param name="UnusedDays">Ab wann Unbenutztes auch unterhalb der Grenze geht; <c>0</c> heisst nie.</param>
|
||||
@@ -42,7 +42,7 @@ public sealed record MaintenanceSettingsResponse(
|
||||
int OptimizeDays, int ImageDays);
|
||||
|
||||
/// <param name="Teile">
|
||||
/// Die Unterordner, wo es welche gibt — heute nur beim Kratzplatz.
|
||||
/// Die Unterordner, wo es welche gibt — heute nur beim Transcode-Cache.
|
||||
/// <b>Mit Dateizahl</b>, denn bei den Untertiteln ist die Anzahl das
|
||||
/// Problem und nicht die Grösse, und das sieht man an Bytes nicht.
|
||||
/// Additiv: <c>Bilder</c> bleibt stehen, damit kein Client bricht.
|
||||
@@ -224,13 +224,23 @@ public sealed record SprachStand(string? Language, int Translated, int Total);
|
||||
/// <summary>Welche Bilder gerade gesetzt sind — und welche von Hand.</summary>
|
||||
public sealed record Bildstand(
|
||||
string? Poster, string? Backdrop, string? Logo,
|
||||
bool PosterPinned, bool BackdropPinned, bool LogoPinned);
|
||||
bool PosterPinned, bool BackdropPinned, bool LogoPinned,
|
||||
/// <summary>Die für den Aufmacher gewählten Bilder, höchstens fünf.</summary>
|
||||
IReadOnlyList<string>? Heroes = null);
|
||||
|
||||
public sealed record Bildauswahl(
|
||||
Bildstand Current, IReadOnlyList<Flaro.Core.Metadata.Bildkandidat> Candidates);
|
||||
|
||||
public sealed record BildWahlRequest(string Kind, string? Url);
|
||||
|
||||
/// <summary>Die Mehrfachauswahl fürs Aufmacher-Karussell.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Eine Liste und kein einzelner Wert: hier wird nicht ein Bild gesetzt,
|
||||
/// sondern die ganze Auswahl ersetzt. Eine leere Liste nimmt sie zurück.
|
||||
/// </remarks>
|
||||
public sealed record AufmacherWahlRequest(IReadOnlyList<string> Urls);
|
||||
|
||||
/// <summary>Ein Titel, der noch wartet oder geplant ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
@@ -312,7 +322,16 @@ public sealed record ScanStatus(
|
||||
/// <summary>Wie viele Dateien dieser Lauf als verschwunden gemeldet hat.</summary>
|
||||
int? Missing = null,
|
||||
/// <summary>Woran der Lauf gerade ist, in einem Satz.</summary>
|
||||
string? Step = null);
|
||||
string? Step = null,
|
||||
/// <summary>Woran die Segmentsuche gerade ist — <c>null</c>, wenn keine läuft.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Ein eigenes Feld, nicht <see cref="Step"/>.</b> Die Segmentsuche
|
||||
/// hängt sich hinter den Scan und läuft weiter, wenn der längst
|
||||
/// „fertig" meldet. In dasselbe Feld geschrieben sähe der Scan wieder
|
||||
/// laufend aus, und die Verwaltung käme nie zur Ruhe.
|
||||
/// </remarks>
|
||||
string? Segmente = null);
|
||||
|
||||
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
|
||||
public sealed record MetadataProgress(
|
||||
@@ -436,7 +455,45 @@ public sealed record MovieResponse(
|
||||
string? SortTitle = null,
|
||||
|
||||
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
|
||||
bool SortTitlePinned = false);
|
||||
bool SortTitlePinned = false,
|
||||
|
||||
/// <summary>Bewertungen von anderswo — leer, wenn keine Quelle eingerichtet ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Neben <c>Rating</c>, nicht statt dessen.</b> Das Feld ist die
|
||||
/// Wertung von TMDB und bleibt, wo es ist; ein Client, der nur das
|
||||
/// kennt, zeigt weiter dasselbe wie bisher.
|
||||
/// </remarks>
|
||||
IReadOnlyList<Fremdwertungsantwort>? ExternalRatings = null,
|
||||
|
||||
/// <summary>Zu welcher Mediathek der Titel gehört.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit die Detailseite einen einzelnen Titel auffrischen kann.</b>
|
||||
/// Das Auffrischen hängt an der Mediathek, nicht am Titel; ohne diese
|
||||
/// Kennung müsste der Client erst alle Mediatheken durchgehen, um zu
|
||||
/// erfahren, wohin dieser eine Film gehört.
|
||||
/// </remarks>
|
||||
Guid? LibraryId = null,
|
||||
/// <summary>Das Bild, das jetzt im Aufmacher gilt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Vom Server gewählt, nicht vom Client.</b> Wer mehrere Aufnahmen
|
||||
/// ausgesucht hat, bekommt alle fünf Stunden eine andere — und zwar
|
||||
/// überall dieselbe: im Web, in der App, nach einem Neustart. Siehe
|
||||
/// <c>Aufmacherwahl</c>. <c>null</c> heisst „keine ausgesucht"; dann
|
||||
/// gilt wie bisher der Hintergrund.
|
||||
/// </remarks>
|
||||
string? HeroImage = null,
|
||||
|
||||
/// <summary>Der Abspann, sofern er im Bild gefunden wurde.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Bei einem Film gibt es keinen Vorspann zu finden — erkannt wird er
|
||||
/// daran, dass er sich über die Folgen einer Staffel wiederholt, und
|
||||
/// ein Film hat nichts, womit er sich vergleichen liesse.
|
||||
/// </remarks>
|
||||
IReadOnlyList<MarkerResponse>? Markers = null);
|
||||
|
||||
public sealed record CreditResponse(
|
||||
Guid Id, string Name, string Role, string? Character, string? Image);
|
||||
@@ -498,7 +555,45 @@ public sealed record SeriesResponse(
|
||||
string? SortTitle = null,
|
||||
|
||||
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
|
||||
bool SortTitlePinned = false);
|
||||
bool SortTitlePinned = false,
|
||||
|
||||
/// <summary>Bewertungen von anderswo — leer, wenn keine Quelle eingerichtet ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Neben <c>Rating</c>, nicht statt dessen.</b> Das Feld ist die
|
||||
/// Wertung von TMDB und bleibt, wo es ist; ein Client, der nur das
|
||||
/// kennt, zeigt weiter dasselbe wie bisher.
|
||||
/// </remarks>
|
||||
IReadOnlyList<Fremdwertungsantwort>? ExternalRatings = null,
|
||||
|
||||
/// <summary>Zu welcher Mediathek der Titel gehört.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit die Detailseite einen einzelnen Titel auffrischen kann.</b>
|
||||
/// Das Auffrischen hängt an der Mediathek, nicht am Titel; ohne diese
|
||||
/// Kennung müsste der Client erst alle Mediatheken durchgehen, um zu
|
||||
/// erfahren, wohin dieser eine Film gehört.
|
||||
/// </remarks>
|
||||
Guid? LibraryId = null,
|
||||
/// <summary>Das Bild, das jetzt im Aufmacher gilt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Vom Server gewählt, nicht vom Client.</b> Wer mehrere Aufnahmen
|
||||
/// ausgesucht hat, bekommt alle fünf Stunden eine andere — und zwar
|
||||
/// überall dieselbe: im Web, in der App, nach einem Neustart. Siehe
|
||||
/// <c>Aufmacherwahl</c>. <c>null</c> heisst „keine ausgesucht"; dann
|
||||
/// gilt wie bisher der Hintergrund.
|
||||
/// </remarks>
|
||||
string? HeroImage = null);
|
||||
|
||||
/// <summary>Ein überspringbarer Abschnitt — Vorspann oder Abspann.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <c>Kind</c> ist <c>"intro"</c> oder <c>"credits"</c>: dieselben Wörter,
|
||||
/// die Jellyfin benutzt. Die Apps kennen sie bereits, und zwei Namen für
|
||||
/// dieselbe Sache wären zwei Übersetzungen an der Grenze jeder App.
|
||||
/// </remarks>
|
||||
public sealed record MarkerResponse(string Kind, double StartSeconds, double EndSeconds);
|
||||
|
||||
public sealed record EpisodeResponse(
|
||||
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
||||
@@ -515,12 +610,42 @@ public sealed record EpisodeResponse(
|
||||
/// Startseite meldete „Dieser Titel ist auf dem Server nicht mehr
|
||||
/// vorhanden" — über einen Titel, den sie selbst gerade angezeigt hatte.
|
||||
/// </remarks>
|
||||
Guid? SeriesId = null);
|
||||
Guid? SeriesId = null,
|
||||
|
||||
/// <summary>Vorspann und Abspann, sofern erkannt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Leer heisst „nicht erkannt", nicht „gibt es nicht". Wer sie sucht,
|
||||
/// stösst den Lauf an; bis er durch ist, bleibt die Liste leer und die
|
||||
/// Spieler zeigen schlicht keinen Knopf.
|
||||
/// </remarks>
|
||||
IReadOnlyList<MarkerResponse>? Markers = null);
|
||||
|
||||
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
|
||||
|
||||
/// <summary>Die saisonale Reihe — mit ihrem Namen, weil er wechselt.</summary>
|
||||
///
|
||||
/// <param name="Occasion">
|
||||
/// Was über der Reihe steht. <c>null</c> heisst: heute hat nichts Saison,
|
||||
/// oder der Anlass findet nichts — dann zeigt der Client gar keine Reihe.
|
||||
/// </param>
|
||||
public sealed record Saisonreihe(string? Occasion, Page<MovieResponse> Items);
|
||||
|
||||
/// <param name="HasFanartKey">
|
||||
/// Ob eine zweite Bildquelle eingerichtet ist. Auch hier kommt der Schlüssel
|
||||
/// selbst nicht zurück — zum Anzeigen genügt, dass einer da ist.
|
||||
/// </param>
|
||||
public sealed record MetadataSettingsResponse(
|
||||
bool HasApiKey, string Language, bool UsingProjectKey);
|
||||
bool HasApiKey, string Language, bool UsingProjectKey,
|
||||
bool HasFanartKey = false, bool HasOmdbKey = false,
|
||||
|
||||
/// <summary>Ob die Seite von Rotten Tomatoes gelesen werden darf.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Kein Schlüssel, sondern ein Schalter.</b> Dort gibt es keine
|
||||
/// Zugänge zu vergeben; eingeschaltet wird das Auslesen der Seite.
|
||||
/// </remarks>
|
||||
bool RottenTomatoes = false);
|
||||
|
||||
/// <summary>Was ein Client über der Mediathek anzeigen muss.</summary>
|
||||
///
|
||||
@@ -532,7 +657,9 @@ public sealed record MetadataSettingsResponse(
|
||||
/// </remarks>
|
||||
public sealed record AttributionResponse(string Text, string LogoUrl, string Url);
|
||||
|
||||
public sealed record MetadataSettingsRequest(string? ApiKey, string? Language);
|
||||
public sealed record MetadataSettingsRequest(
|
||||
string? ApiKey, string? Language, string? FanartApiKey = null, string? OmdbApiKey = null,
|
||||
bool? RottenTomatoes = null);
|
||||
|
||||
/// <summary>Was der Server über die Wiedergabe entscheidet.</summary>
|
||||
///
|
||||
@@ -709,12 +836,30 @@ public sealed record SpurAuskunft(int Index, string Label, string? Language, boo
|
||||
public sealed record StreamResponse(
|
||||
Guid SessionId,
|
||||
string Url,
|
||||
|
||||
/// <summary>Dieselbe Wiedergabe als Hauptliste, mit Untertitelspuren.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Für Abspieler, die ihre Untertitel aus HLS beziehen.</b> Der
|
||||
/// Browser hängt sie als eigene Spur ans Videoelement und braucht nur
|
||||
/// <see cref="Url"/>; AVPlayer kennt diesen Weg nicht — dort gibt es
|
||||
/// keine Möglichkeit, einer laufenden Wiedergabe eine Textspur von
|
||||
/// aussen beizulegen. Bei einer Direktwiedergabe steht hier nichts: dort
|
||||
/// liegen die Spuren in der Datei selbst.
|
||||
/// </remarks>
|
||||
string? MasterUrl,
|
||||
string Method,
|
||||
double StartSeconds,
|
||||
int? Height,
|
||||
int? AudioStream,
|
||||
IReadOnlyList<SpurAuskunft> AudioStreams,
|
||||
IReadOnlyList<SpurAuskunft> SubtitleStreams);
|
||||
/// <summary>Eine Bewertung von anderswo, fertig zum Hinschreiben.</summary>
|
||||
///
|
||||
/// <param name="Source">„imdb", „rottenTomatoes", „metacritic".</param>
|
||||
/// <param name="Display">Was dastehen soll — in der Form, die man von der Quelle kennt.</param>
|
||||
public sealed record Fremdwertungsantwort(string Source, double Value, string Display);
|
||||
|
||||
public sealed record RatingRequest(int? Rating);
|
||||
|
||||
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
|
||||
@@ -786,7 +931,10 @@ public sealed record ContinueEntry(
|
||||
/// Seite und will das Bild der Serie; dort stand bisher das Folgenbild,
|
||||
/// und wer das Serienbild selbst ausgesucht hatte, fand ein anderes vor.
|
||||
/// </remarks>
|
||||
string? SeriesBackdropImage = null);
|
||||
string? SeriesBackdropImage = null,
|
||||
|
||||
/// <summary>Das Bild, das jetzt im Aufmacher gilt — siehe <c>Aufmacherwahl</c>.</summary>
|
||||
string? HeroImage = null);
|
||||
|
||||
/// <summary>Was jemand geschaut hat — nur für ihn selbst.</summary>
|
||||
///
|
||||
@@ -904,3 +1052,46 @@ public sealed record Einladungsschau(string ServerName, string InvitedBy, DateTi
|
||||
|
||||
/// <summary>Namen und Passwort, die sich der Eingeladene selbst gibt.</summary>
|
||||
public sealed record EinloesenRequest(string UserName, string Password);
|
||||
|
||||
/// <summary>Eine Sprache, wie sie in einem Menü steht.</summary>
|
||||
///
|
||||
/// <param name="Code">Das Kürzel, unter dem die Einstellung abgelegt wird.</param>
|
||||
/// <param name="Name">Wie sie dasteht — „Deutsch", „English".</param>
|
||||
public sealed record Sprachangabe(string Code, string Name);
|
||||
|
||||
/// <summary>Welche Sprachen eine Serie durchgehend führt.</summary>
|
||||
///
|
||||
/// <param name="Audio">Tonsprachen, die in jeder Folge vorkommen.</param>
|
||||
/// <param name="Subtitles">Textsprachen, die in jeder Folge vorkommen.</param>
|
||||
/// <param name="Partial">
|
||||
/// Wie viele Sprachen es nur in einem Teil der Folgen gibt.
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit die Oberfläche es sagen kann, statt es zu verschweigen.</b> Ohne
|
||||
/// diese Zahl sähe eine gekürzte Liste aus wie die ganze Wahrheit — und wer
|
||||
/// eine Sprache vermisst, die er in Staffel 1 gesehen hat, hielte es für
|
||||
/// einen Fehler.
|
||||
/// </remarks>
|
||||
/// </param>
|
||||
public sealed record Sprachauswahl(
|
||||
IReadOnlyList<Sprachangabe> Audio,
|
||||
IReadOnlyList<Sprachangabe> Subtitles,
|
||||
int Partial);
|
||||
|
||||
/// <summary>Wie die Vorschaubilder eines Titels zusammengesetzt sind.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Kachelmasse stehen nicht fest.</b> Skaliert wird auf eine feste
|
||||
/// Breite, die Höhe folgt dem Seitenverhältnis — ein Breitbildfilm ergibt
|
||||
/// flachere Kacheln. Deshalb werden sie gemessen und mitgeteilt; geraten
|
||||
/// wäre jede Kachel um ein paar Bildpunkte verschoben, und das sieht man
|
||||
/// nicht als Fehler, sondern hält es für ein unscharfes Bild.
|
||||
/// </remarks>
|
||||
public sealed record VorschauAuskunft(
|
||||
bool Fertig,
|
||||
double Intervall,
|
||||
int Spalten,
|
||||
int Zeilen,
|
||||
int KachelBreite,
|
||||
int KachelHoehe,
|
||||
int Blaetter);
|
||||
|
||||
@@ -43,7 +43,7 @@ public static class EinladungEndpoints
|
||||
return Einwand.Bitte("Ein Link, der nullmal gilt, ist keiner.");
|
||||
|
||||
// **Das Geheimnis verlässt den Server genau hier.** Gespeichert
|
||||
// wird nur sein Fingerabdruck; aus der Datenbank lässt sich kein
|
||||
// wird nur sein Fingerfingerprint; aus der Datenbank lässt sich kein
|
||||
// Link mehr bauen, und eine verlorene Einladung wird deshalb nicht
|
||||
// wiedergefunden, sondern neu gemacht.
|
||||
var token = Secrets.Create();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Flaro.Api.Auth;
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Media;
|
||||
using Flaro.Core.Media;
|
||||
using System.Security.Claims;
|
||||
using Flaro.Core.Playback;
|
||||
@@ -77,6 +80,119 @@ public static class LibraryContentEndpoints
|
||||
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
|
||||
/// und SQLite führt die nacheinander aus.
|
||||
/// </remarks>
|
||||
/// <summary>Die Folge vor oder nach dieser.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine Rechnung, zwei Wege.</b> Vorwärts und rückwärts unterscheiden
|
||||
/// sich in zwei Zeichen — als zwei Endpunkte abgeschrieben liefen sie
|
||||
/// auseinander, sobald einer von beiden etwas dazubekommt.
|
||||
/// </remarks>
|
||||
private static async Task<IResult> Nachbarfolge(
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db, bool vorwaerts)
|
||||
{
|
||||
var diese = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.Id == id)
|
||||
.Select(f => new { f.SeriesId, f.SeasonNumber, f.EpisodeNumber })
|
||||
.FirstOrDefaultAsync();
|
||||
if (diese is null) return Results.NotFound();
|
||||
|
||||
var geschwister = db.Episodes.AsNoTracking().Where(f => f.SeriesId == diese.SeriesId);
|
||||
|
||||
var gefiltert = vorwaerts
|
||||
? geschwister
|
||||
.Where(f => f.SeasonNumber > diese.SeasonNumber
|
||||
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
|
||||
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
||||
: geschwister
|
||||
.Where(f => f.SeasonNumber < diese.SeasonNumber
|
||||
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber < diese.EpisodeNumber))
|
||||
.OrderByDescending(f => f.SeasonNumber).ThenByDescending(f => f.EpisodeNumber);
|
||||
|
||||
var nachbar = await gefiltert
|
||||
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
// Keine weitere Folge ist kein Fehler — es war die erste oder die letzte.
|
||||
if (nachbar is null) return Results.NoContent();
|
||||
|
||||
var dateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(d => d.EpisodeId == nachbar.Id)
|
||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel })
|
||||
.ToListAsync();
|
||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||
var stände = await ZuständeZu(db, wer, folgen: [nachbar.Id]);
|
||||
var segmente = await SegmenteZu(db, [nachbar.Id]);
|
||||
|
||||
// **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
|
||||
// nichts; das Plakat der Serie sagt wenigstens, worum es geht.
|
||||
// TMDB liefert Folgenbilder nur für einen Teil der Folgen, und bei
|
||||
// kleineren Serien für gar keine.
|
||||
var serienBild = await db.Series.AsNoTracking()
|
||||
.Where(x => x.Id == diese.SeriesId)
|
||||
.Select(x => x.PosterImage).FirstOrDefaultAsync();
|
||||
|
||||
return Results.Ok(new EpisodeResponse(
|
||||
nachbar.Id, nachbar.SeasonNumber, nachbar.EpisodeNumber,
|
||||
nachbar.Title, nachbar.Overview, nachbar.StillImage ?? serienBild,
|
||||
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
|
||||
stände.GetValueOrDefault(nachbar.Id),
|
||||
nachbar.PublishAt,
|
||||
SeriesId: diese.SeriesId,
|
||||
Markers: segmente[nachbar.Id].ToList()));
|
||||
}
|
||||
|
||||
/// <summary>Die Segmente zu einer Reihe von Folgen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Als Nachschlagewerk und nicht je Folge einzeln: eine Staffel mit
|
||||
/// zwanzig Folgen wären sonst zwanzig Abfragen für zwanzig kurze Zeilen.
|
||||
/// </remarks>
|
||||
private static async Task<ILookup<Guid, MarkerResponse>> SegmenteZu(
|
||||
FlaroContext db, IReadOnlyCollection<Guid> folgen)
|
||||
{
|
||||
if (folgen.Count == 0)
|
||||
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
|
||||
|
||||
var roh = await db.Segmente.AsNoTracking()
|
||||
.Where(m => m.EpisodeId != null && folgen.Contains(m.EpisodeId!.Value))
|
||||
// In Zeitfolge: ein geteilter Abspann kommt als zwei Segmente, und
|
||||
// die Spieler nehmen die erste passende.
|
||||
.OrderBy(m => m.VonSekunden)
|
||||
.Select(m => new { Id = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
|
||||
.ToListAsync();
|
||||
|
||||
return roh.ToLookup(
|
||||
m => m.Id,
|
||||
m => new MarkerResponse(
|
||||
m.Art == Segmentart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
|
||||
}
|
||||
|
||||
/// <summary>Dasselbe für Filme.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Getrennt und nicht über eine gemeinsame Kennung: ein Segment gehört
|
||||
/// entweder zu einer Folge oder zu einem Film, und eine Abfrage, die
|
||||
/// beides zugleich sucht, fände zu einer Filmkennung auch eine Folge
|
||||
/// mit derselben — unmöglich heute, aber nichts hält es fest.
|
||||
/// </remarks>
|
||||
private static async Task<ILookup<Guid, MarkerResponse>> FilmsegmenteZu(
|
||||
FlaroContext db, IReadOnlyCollection<Guid> filme)
|
||||
{
|
||||
if (filme.Count == 0)
|
||||
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
|
||||
|
||||
var roh = await db.Segmente.AsNoTracking()
|
||||
.Where(m => m.MovieId != null && filme.Contains(m.MovieId!.Value))
|
||||
.OrderBy(m => m.VonSekunden)
|
||||
.Select(m => new { Id = m.MovieId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
|
||||
.ToListAsync();
|
||||
|
||||
return roh.ToLookup(
|
||||
m => m.Id,
|
||||
m => new MarkerResponse(
|
||||
m.Art == Segmentart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
|
||||
}
|
||||
|
||||
private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien)
|
||||
{
|
||||
if (dateien.Count == 0) return Array.Empty<(Guid, MediaStreamResponse)>().ToLookup(x => x.Item1, x => x.Item2);
|
||||
@@ -168,6 +284,21 @@ public static class LibraryContentEndpoints
|
||||
}
|
||||
|
||||
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
|
||||
/// <summary>Die Bewertungen von anderswo zu einem Titel.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Nur beim Einzelabruf.</b> In einer Liste mit fünfzig Kacheln
|
||||
/// stünden sie nirgends, kosteten aber fünfzig Abfragen — dieselbe
|
||||
/// Überlegung wie bei Genres und Besetzung.
|
||||
/// </remarks>
|
||||
private static async Task<IReadOnlyList<Fremdwertungsantwort>> FremdwertungenZu(
|
||||
FlaroContext db, Guid? film, Guid? serie) =>
|
||||
await db.Fremdbewertungen.AsNoTracking()
|
||||
.Where(b => film != null ? b.MovieId == film : b.SeriesId == serie)
|
||||
.OrderByDescending(b => b.Wert)
|
||||
.Select(b => new Fremdwertungsantwort(b.Quelle, b.Wert, b.Anzeige))
|
||||
.ToListAsync();
|
||||
|
||||
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
|
||||
await db.GenreLinks.AsNoTracking()
|
||||
.Where(l => film != null ? l.MovieId == film : l.SeriesId == serie)
|
||||
@@ -192,7 +323,14 @@ public static class LibraryContentEndpoints
|
||||
/// Stand — das ist kein Fehler, sondern der Normalfall für alles, was
|
||||
/// noch niemand angefasst hat, und die Antwort trägt dort <c>null</c>.
|
||||
/// </remarks>
|
||||
private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
|
||||
/// <remarks>
|
||||
/// <b>Nicht mehr privat.</b> Drei Listen ausserhalb dieser Klasse gaben
|
||||
/// `State: null` zurück, obwohl die Stände in derselben Datenbank
|
||||
/// liegen: Vorschläge, saisonale Reihe und ähnliche Titel. Ein halb
|
||||
/// gesehener Film stand dort ohne Balken, während dieselbe Kachel in der
|
||||
/// Mediathek einen hatte.
|
||||
/// </remarks>
|
||||
internal static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
|
||||
FlaroContext db, ClaimsPrincipal wer,
|
||||
List<Guid>? filme = null, List<Guid>? folgen = null, List<Guid>? serien = null)
|
||||
{
|
||||
@@ -285,11 +423,21 @@ public static class LibraryContentEndpoints
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapGet("/api/v1/libraries/{id:guid}/scan", (Guid id, ScanCoordinator koordinator) =>
|
||||
app.MapGet("/api/v1/libraries/{id:guid}/scan",
|
||||
(Guid id, ScanCoordinator koordinator, Segmenterkennung erkennung) =>
|
||||
{
|
||||
// **Auch ohne Scan kann etwas laufen.** Die Segmentsuche lässt
|
||||
// sich von Hand anstossen; ohne diese Zeile stünde die
|
||||
// Verwaltung dann auf „idle", während der Server stundenlang
|
||||
// arbeitet.
|
||||
var segmente = erkennung.Stand(id)?.Text();
|
||||
|
||||
var zustand = koordinator.State(id);
|
||||
if (zustand is null)
|
||||
return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null, null, null));
|
||||
return Results.Ok(new ScanStatus(
|
||||
segmente is null ? "idle" : "marken",
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
Segmente: segmente));
|
||||
|
||||
var bericht = zustand.Report;
|
||||
var metadaten = zustand.Enrichment;
|
||||
@@ -303,7 +451,8 @@ public static class LibraryContentEndpoints
|
||||
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
|
||||
metadaten.Skipped, metadaten.Error),
|
||||
bericht?.Missing,
|
||||
zustand.Schritt?.Text()));
|
||||
zustand.Schritt?.Text(),
|
||||
segmente));
|
||||
}).WithName("Scanstand").RequireAuthorization();
|
||||
}
|
||||
|
||||
@@ -429,6 +578,7 @@ public static class LibraryContentEndpoints
|
||||
// Korrektur stand „WALL·E" weiter unter E, bis man die
|
||||
// Seite neu lud.
|
||||
m.SortTitleValue, m.TitlePinned, m.SortTitlePinned,
|
||||
m.Aufmacherbilder,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
@@ -442,6 +592,7 @@ public static class LibraryContentEndpoints
|
||||
.ToListAsync();
|
||||
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
||||
var stände = await ZuständeZu(db, wer, filme: ids);
|
||||
var segmente = await FilmsegmenteZu(db, ids);
|
||||
|
||||
var items = kennungen.Select(m => new MovieResponse(
|
||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||
@@ -453,7 +604,10 @@ public static class LibraryContentEndpoints
|
||||
PublishAt: m.PublishAt,
|
||||
TitlePinned: m.TitlePinned,
|
||||
SortTitle: m.SortTitleValue,
|
||||
SortTitlePinned: m.SortTitlePinned)).ToList();
|
||||
SortTitlePinned: m.SortTitlePinned,
|
||||
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow),
|
||||
Markers: segmente[m.Id].ToList()))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new Page<MovieResponse>(items, gesamt));
|
||||
}).RequireAuthorization();
|
||||
@@ -509,6 +663,7 @@ public static class LibraryContentEndpoints
|
||||
PublishAt = db.Episodes.Where(f => f.SeriesId == s.Id)
|
||||
.Min(f => f.PublishAt),
|
||||
s.SortTitleValue, s.TitlePinned, s.SortTitlePinned,
|
||||
s.Aufmacherbilder,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
@@ -537,7 +692,8 @@ public static class LibraryContentEndpoints
|
||||
State: stände.GetValueOrDefault(s.Id),
|
||||
TitlePinned: s.TitlePinned,
|
||||
SortTitle: s.SortTitleValue,
|
||||
SortTitlePinned: s.SortTitlePinned);
|
||||
SortTitlePinned: s.SortTitlePinned,
|
||||
HeroImage: Aufmacherwahl.Jetziges(s.Aufmacherbilder, s.Id, DateTimeOffset.UtcNow));
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
||||
@@ -593,13 +749,16 @@ public static class LibraryContentEndpoints
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var ständeÄhnliche = await ZuständeZu(db, wer, filme: kennungen.ToList());
|
||||
|
||||
// In der Reihenfolge der Punkte, nicht in der der Datenbank.
|
||||
var items = kennungen
|
||||
.Select(k => filme.FirstOrDefault(f => f.Id == k))
|
||||
.Where(f => f != null)
|
||||
.Select(f => new MovieResponse(
|
||||
f!.Id, f.Title, f.Year, null, f.PosterImage, f.BackdropImage,
|
||||
[], null, f.LogoImage, f.RuntimeMinutes, f.Rating, null, f.ContentRating,
|
||||
[], ständeÄhnliche.GetValueOrDefault(f.Id),
|
||||
f.LogoImage, f.RuntimeMinutes, f.Rating, null, f.ContentRating,
|
||||
OriginalTitle: f.OriginalTitle))
|
||||
.ToList();
|
||||
|
||||
@@ -639,7 +798,11 @@ public static class LibraryContentEndpoints
|
||||
TitlePinned: film.TitlePinned,
|
||||
SortTitle: film.SortTitleValue,
|
||||
SortTitlePinned: film.SortTitlePinned,
|
||||
PublishAt: film.PublishAt));
|
||||
PublishAt: film.PublishAt,
|
||||
ExternalRatings: await FremdwertungenZu(db, film: id, serie: null),
|
||||
LibraryId: film.LibraryId,
|
||||
HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow),
|
||||
Markers: (await FilmsegmenteZu(db, [id]))[id].ToList()));
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/series/{id:guid}", async (
|
||||
@@ -674,7 +837,10 @@ public static class LibraryContentEndpoints
|
||||
TitlePinned: serie.TitlePinned,
|
||||
SortTitle: serie.SortTitleValue,
|
||||
SortTitlePinned: serie.SortTitlePinned,
|
||||
PublishAt: folgen.Min(f => f.PublishAt)));
|
||||
PublishAt: folgen.Min(f => f.PublishAt),
|
||||
ExternalRatings: await FremdwertungenZu(db, film: null, serie: id),
|
||||
LibraryId: serie.LibraryId,
|
||||
HeroImage: Aufmacherwahl.Jetziges(serie.Aufmacherbilder, id, DateTimeOffset.UtcNow)));
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Eine Folge für sich.**
|
||||
@@ -704,6 +870,7 @@ public static class LibraryContentEndpoints
|
||||
.ToListAsync();
|
||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||
var stände = await ZuständeZu(db, wer, folgen: [id]);
|
||||
var segmente = await SegmenteZu(db, [id]);
|
||||
|
||||
var wurzel = await MediathekspfadFuer(db, wer,
|
||||
await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId)
|
||||
@@ -715,54 +882,175 @@ public static class LibraryContentEndpoints
|
||||
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||
stände.GetValueOrDefault(id),
|
||||
folge.PublishAt,
|
||||
SeriesId: folge.SeriesId));
|
||||
SeriesId: folge.SeriesId,
|
||||
Markers: segmente[id].ToList()));
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
|
||||
// Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann
|
||||
// der Scan sie fand, nicht wann sie läuft.
|
||||
app.MapGet("/api/v1/episodes/{id:guid}/next", async (
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||
// **Die Nachbarfolge — vorwärts wie rückwärts.** Reihenfolge ist
|
||||
// Staffel, dann Folge; nicht die Kennung: die ist zeitlich geordnet
|
||||
// und sagt, wann der Scan sie fand, nicht wann sie läuft.
|
||||
//
|
||||
// **Beide Richtungen, seit es im Spieler Knöpfe dafür gibt.** Wer
|
||||
// eine Folge zu weit gesprungen ist, soll zurück können, ohne über
|
||||
// die Serienseite zu gehen.
|
||||
// **Vorspann und Abspann suchen — auf Zuruf.**
|
||||
//
|
||||
// Der Lauf dauert Minuten: er liest von jeder Folge einer Staffel
|
||||
// ein Stück Ton und vergleicht die Abdrücke. Deshalb 202 und ein
|
||||
// Hintergrundlauf statt einer Antwort, auf die jemand wartet.
|
||||
// Dasselbe für einen Film — dort sucht der Server im Bild.
|
||||
app.MapPost("/api/v1/movies/{id:guid}/marken", async (
|
||||
Guid id, FlaroContext db, Segmenterkennung erkennung,
|
||||
Segmentumfang umfang = Segmentumfang.Alles) =>
|
||||
{
|
||||
var diese = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.Id == id)
|
||||
.Select(f => new { f.SeriesId, f.SeasonNumber, f.EpisodeNumber })
|
||||
.FirstOrDefaultAsync();
|
||||
if (diese is null) return Results.NotFound();
|
||||
if (!await db.Movies.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
|
||||
|
||||
var naechste = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.SeriesId == diese.SeriesId)
|
||||
.Where(f => f.SeasonNumber > diese.SeasonNumber
|
||||
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
|
||||
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
||||
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
|
||||
.FirstOrDefaultAsync();
|
||||
_ = erkennung.FilmAsync(id, umfang);
|
||||
return Results.Accepted();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// Keine weitere Folge ist kein Fehler — es war die letzte.
|
||||
if (naechste is null) return Results.NoContent();
|
||||
// **Die Messkurve eines Films — zum Nachsehen, nicht zum Erkennen.**
|
||||
//
|
||||
// Die Erkennung sagt „kein Abspann" und nennt die Gründe; was sie
|
||||
// nicht sagen kann, ist, wie die Kurve aussieht. Genau die braucht
|
||||
// es, bevor eine neue Schwelle gesetzt wird — zum Beispiel für die
|
||||
// Frage, ob bei Outtakes im Abspann wenigstens die Ränder dunkel
|
||||
// bleiben.
|
||||
app.MapGet("/api/v1/movies/{id:guid}/markenkurve", async (
|
||||
Guid id, FlaroContext db, Frameabtaster abtaster, CancellationToken ct) =>
|
||||
{
|
||||
var film = await db.Movies.AsNoTracking()
|
||||
.Where(m => m.Id == id)
|
||||
.Join(db.MediaFiles.AsNoTracking(), m => m.Id, d => d.MovieId,
|
||||
(m, d) => new { m.Title, d.Path, d.Duration })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (film?.Duration is not { TotalSeconds: > 0 } laufzeit)
|
||||
return Einwand.Bitte("Zu diesem Film gibt es keine Datei mit Laufzeit.");
|
||||
|
||||
var ab = Math.Max(0, laufzeit.TotalSeconds - Segmenterkennung.Suchbereich);
|
||||
var kurve = await abtaster.KurveAsync(film.Path, ab, laufzeit.TotalSeconds - ab, ct);
|
||||
|
||||
var text = new StringBuilder("sekunde;gesamt;links;rechts\n");
|
||||
foreach (var punkt in kurve)
|
||||
{
|
||||
text.Append(CultureInfo.InvariantCulture,
|
||||
$"{punkt.Sekunde:0.###};{punkt.Gesamt:0.#};{punkt.Links:0.#};{punkt.Rechts:0.#}\n");
|
||||
}
|
||||
|
||||
var name = string.Concat((film.Title ?? "film")
|
||||
.Select(c => char.IsLetterOrDigit(c) ? c : '-'));
|
||||
|
||||
return Results.File(Encoding.UTF8.GetBytes(text.ToString()),
|
||||
"text/csv", $"markenkurve-{name}.csv");
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Ein Segment wieder wegnehmen.**
|
||||
//
|
||||
// Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal
|
||||
// recht hat: ein Fehlgriff bliebe stehen, und niemand könnte etwas
|
||||
// dagegen tun ausser die Datenbank anzufassen.
|
||||
app.MapDelete("/api/v1/movies/{id:guid}/marken", async (Guid id, FlaroContext db) =>
|
||||
{
|
||||
var weg = await db.Segmente.Where(m => m.MovieId == id).ExecuteDeleteAsync();
|
||||
// Der Vermerk bleibt: „gesucht" stimmt weiterhin, und ohne ihn
|
||||
// legte der nächste Scan dieselbe falsches Segment wieder an.
|
||||
return weg > 0 ? Results.NoContent() : Results.NotFound();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Bei einem einzelnen Titel ist „alles" die Vorgabe.** Wer den
|
||||
// Knopf bei genau dieser Serie drückt, ist mit genau dieser Serie
|
||||
// unzufrieden; ihn dann nach Lücken suchen zu lassen hiesse, ihm
|
||||
// die Antwort zu verweigern, die er haben will.
|
||||
app.MapPost("/api/v1/series/{id:guid}/marken", async (
|
||||
Guid id, FlaroContext db, Segmenterkennung erkennung,
|
||||
Segmentumfang umfang = Segmentumfang.Alles) =>
|
||||
{
|
||||
if (!await db.Series.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
|
||||
|
||||
return erkennung.Starten(id, umfang)
|
||||
? Results.Accepted()
|
||||
: Einwand.Belegt("Für diese Serie werden gerade Segmente gesucht.");
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Eine ganze Mediathek auf einmal.** Je Serie einen Knopf zu
|
||||
// drücken ist bei fünfundneunzig Serien kein Angebot — und nach
|
||||
// jeder Änderung an der Erkennung will man ohnehin alles noch
|
||||
// einmal. Der Lauf kann Stunden dauern und läuft deshalb im
|
||||
// Hintergrund weiter; die Antwort kommt sofort.
|
||||
app.MapPost("/api/v1/libraries/{id:guid}/marken", async (
|
||||
Guid id, FlaroContext db, Segmenterkennung erkennung,
|
||||
Segmentumfang umfang = Segmentumfang.Neue) =>
|
||||
{
|
||||
if (!await db.Libraries.AsNoTracking().AnyAsync(x => x.Id == id))
|
||||
return Results.NotFound();
|
||||
|
||||
erkennung.MediathekStarten(id, umfang);
|
||||
return Results.Accepted();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapGet("/api/v1/episodes/{id:guid}/next", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||
Nachbarfolge(id, wer, db, vorwaerts: true)).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/episodes/{id:guid}/previous", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||
Nachbarfolge(id, wer, db, vorwaerts: false)).RequireAuthorization();
|
||||
|
||||
// **Welche Sprachen eine Serie durchgehend hat.**
|
||||
//
|
||||
// Für die Vorwahl auf der Serienseite. Die Folgen einzeln abzufragen
|
||||
// wären bei zwanzig Folgen zwanzig Rundreisen, und am Ende müsste
|
||||
// der Client dieselbe Rechnung anstellen.
|
||||
//
|
||||
// **Die Schnittmenge, nicht die Vereinigung.** Eine Sprache
|
||||
// anzubieten, die es nur in Staffel 1 gibt, hiesse: eine Einstellung
|
||||
// für die Serie, die ab Staffel 2 stillschweigend etwas anderes tut.
|
||||
// Was dabei wegfällt, wird gezählt, damit die Oberfläche es sagen
|
||||
// kann statt es zu verschweigen.
|
||||
app.MapGet("/api/v1/series/{id:guid}/languages", async (Guid id, FlaroContext db) =>
|
||||
{
|
||||
if (!await db.Series.AnyAsync(x => x.Id == id)) return Results.NotFound();
|
||||
|
||||
var folgen = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.SeriesId == id).Select(f => f.Id).ToListAsync();
|
||||
|
||||
var dateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(d => d.EpisodeId == naechste.Id)
|
||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel })
|
||||
.Where(f => f.EpisodeId != null && folgen.Contains(f.EpisodeId!.Value))
|
||||
.Select(f => new { f.Id, Folge = f.EpisodeId!.Value })
|
||||
.ToListAsync();
|
||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||
var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]);
|
||||
|
||||
// **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
|
||||
// nichts; das Plakat der Serie sagt wenigstens, worum es geht.
|
||||
// TMDB liefert Folgenbilder nur für einen Teil der Folgen, und
|
||||
// bei kleineren Serien für gar keine.
|
||||
var serienBild = await db.Series.AsNoTracking()
|
||||
.Where(x => x.Id == diese.SeriesId)
|
||||
.Select(x => x.PosterImage).FirstOrDefaultAsync();
|
||||
// **Nur Folgen, die überhaupt eine Datei haben.** Eine Folge, die
|
||||
// noch nicht da ist, kann keine Sprache beisteuern — sie als
|
||||
// fehlend zu zählen liesse die Schnittmenge leer werden.
|
||||
var mitDatei = dateien.Select(d => d.Folge).Distinct().Count();
|
||||
if (mitDatei == 0) return Results.Ok(new Sprachauswahl([], [], 0));
|
||||
|
||||
return Results.Ok(new EpisodeResponse(
|
||||
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
|
||||
naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild,
|
||||
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
|
||||
stände.GetValueOrDefault(naechste.Id),
|
||||
naechste.PublishAt,
|
||||
SeriesId: diese.SeriesId));
|
||||
var zuFolge = dateien.ToDictionary(d => d.Id, d => d.Folge);
|
||||
var kennungen = dateien.Select(d => d.Id).ToList();
|
||||
|
||||
var spuren = await db.MediaStreams.AsNoTracking()
|
||||
.Where(x => kennungen.Contains(x.MediaFileId))
|
||||
.Where(x => x.Kind == MediaStreamKind.Audio || x.Kind == MediaStreamKind.Subtitle)
|
||||
.Select(x => new { x.MediaFileId, x.Kind, x.Language, x.Codec })
|
||||
.ToListAsync();
|
||||
|
||||
var (ton, tonFehlt) = Sprachschnitt.Von(
|
||||
spuren.Where(x => x.Kind == MediaStreamKind.Audio)
|
||||
.Select(x => (zuFolge[x.MediaFileId], x.Language)),
|
||||
mitDatei);
|
||||
|
||||
var (text, textFehlt) = Sprachschnitt.Von(
|
||||
spuren.Where(x => x.Kind == MediaStreamKind.Subtitle)
|
||||
// Bildbasierte Untertitel lassen sich nicht nach WebVTT
|
||||
// wandeln; sie anzubieten wäre eine Zusage, die niemand
|
||||
// einlösen kann.
|
||||
.Where(x => x.Codec is null || !Untertitelvorrat.IstBild(x.Codec))
|
||||
.Select(x => (zuFolge[x.MediaFileId], x.Language)),
|
||||
mitDatei);
|
||||
|
||||
return Results.Ok(new Sprachauswahl(
|
||||
[.. ton.Select(x => new Sprachangabe(x.Code, x.Name))],
|
||||
[.. text.Select(x => new Sprachangabe(x.Code, x.Name))],
|
||||
tonFehlt + textFehlt));
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
|
||||
@@ -789,6 +1077,7 @@ public static class LibraryContentEndpoints
|
||||
.ToListAsync();
|
||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||
var stände = await ZuständeZu(db, wer, folgen: ids);
|
||||
var segmente = await SegmenteZu(db, ids);
|
||||
|
||||
var serienBild = await db.Series.AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
@@ -801,7 +1090,8 @@ public static class LibraryContentEndpoints
|
||||
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||
stände.GetValueOrDefault(f.Id),
|
||||
f.PublishAt,
|
||||
SeriesId: id)).ToList();
|
||||
SeriesId: id,
|
||||
Markers: segmente[f.Id].ToList())).ToList();
|
||||
|
||||
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
||||
}).RequireAuthorization();
|
||||
|
||||
@@ -470,7 +470,7 @@ public static class ManagementEndpoints
|
||||
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
|
||||
{
|
||||
if (!Enum.TryParse<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
|
||||
return Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows.");
|
||||
return Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows, Videos.");
|
||||
|
||||
// **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad
|
||||
// führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und
|
||||
|
||||
@@ -253,15 +253,21 @@ public static class MatchEndpoints
|
||||
// — nicht der angezeigte Titel, der ja gerade der falsche ist.
|
||||
var vorschläge = await anbieter.SearchMoviesAsync(
|
||||
string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(),
|
||||
// **Das Jahr aus dem Dateinamen, nicht das aus der Zuordnung.**
|
||||
// **Kein Jahr heisst: ohne Jahr suchen.**
|
||||
//
|
||||
// Hier stand `film.Year` — und das kommt von genau der
|
||||
// Zuordnung, die gerade korrigiert werden soll. „Doctor
|
||||
// Strange" war fälschlich auf ein Making-of von 2022
|
||||
// gelaufen; die Suche hängte daraufhin `year=2022` an, und der
|
||||
// richtige Film von 2016 konnte gar nicht mehr auftauchen. Ein
|
||||
// falscher Treffer verhinderte so seine eigene Korrektur.
|
||||
year ?? film.ParsedYear);
|
||||
// Die Maske trägt das Jahr aus dem Dateinamen schon ein; wer
|
||||
// es herausnimmt, sagt damit, dass er es nicht weiss. Hier
|
||||
// stand ein Rückfall auf eben dieses Jahr — das leere Feld
|
||||
// hatte also keine Wirkung, und ein falsch benannter Film
|
||||
// liess sich nicht finden. „Plan B (2010)" heisst bei TMDB
|
||||
// „DivertiMento – Plan B" und ist von 2011; mit dem Jahr aus
|
||||
// dem Dateinamen kam nie ein Treffer.
|
||||
//
|
||||
// Davor stand hier `film.Year`, also das Jahr aus der
|
||||
// Zuordnung, die gerade korrigiert werden soll — ein falscher
|
||||
// Treffer verhinderte so seine eigene Korrektur. Auch das ist
|
||||
// damit erledigt.
|
||||
year);
|
||||
|
||||
return Results.Ok(vorschläge.Select(v =>
|
||||
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
||||
@@ -319,8 +325,8 @@ public static class MatchEndpoints
|
||||
|
||||
var vorschläge = await anbieter.SearchSeriesAsync(
|
||||
string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(),
|
||||
// Siehe oben: aus dem Dateinamen, nicht aus der Zuordnung.
|
||||
year ?? serie.ParsedYear);
|
||||
// Siehe oben: ein leeres Jahr ist eine Angabe, kein Fehlen.
|
||||
year);
|
||||
|
||||
return Results.Ok(vorschläge.Select(v =>
|
||||
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using Flaro.Api.Auth;
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Core.Media;
|
||||
using Flaro.Core.Playback;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -96,7 +98,30 @@ public static class PlaybackEndpoints
|
||||
var gemeldet = TimeSpan.FromSeconds(anfrage.PositionSeconds);
|
||||
if (gemeldet <= TimeSpan.Zero) gemeldet = zustand.Position;
|
||||
|
||||
var ergebnis = ProgressRule.Apply(gemeldet, laenge, regeln);
|
||||
// **Der Abspann, wenn er erkannt wurde.** Er zieht die Schwelle
|
||||
// fürs Häkchen nach vorn — siehe `ProgressRule.Apply`.
|
||||
//
|
||||
// **Das früheste Stück, nicht irgendeines.** Teilt eine
|
||||
// Post-Credit-Szene den Abspann, gibt es zwei Zeilen; ohne
|
||||
// `OrderBy` entschied die Datenbank, welche gilt. Gemeint ist
|
||||
// immer die erste — dort ist die Handlung vorbei.
|
||||
var abspannAb = zustand.EpisodeId is { } folge
|
||||
? await db.Segmente.AsNoTracking()
|
||||
.Where(m => m.EpisodeId == folge && m.Art == Segmentart.Abspann)
|
||||
.OrderBy(m => m.VonSekunden)
|
||||
.Select(m => (double?)m.VonSekunden)
|
||||
.FirstOrDefaultAsync()
|
||||
: zustand.MovieId is { } film
|
||||
? await db.Segmente.AsNoTracking()
|
||||
.Where(m => m.MovieId == film && m.Art == Segmentart.Abspann)
|
||||
.OrderBy(m => m.VonSekunden)
|
||||
.Select(m => (double?)m.VonSekunden)
|
||||
.FirstOrDefaultAsync()
|
||||
: null;
|
||||
|
||||
var ergebnis = ProgressRule.Apply(
|
||||
gemeldet, laenge, regeln,
|
||||
abspannAb is { } sek ? TimeSpan.FromSeconds(sek) : null);
|
||||
|
||||
// **Erst zählen, dann überschreiben.** Der Zuwachs ergibt sich aus
|
||||
// der alten Stelle; eine Zeile später gibt es die nicht mehr.
|
||||
@@ -346,18 +371,52 @@ public static class PlaybackEndpoints
|
||||
.Select(x => new { x.Id, x.Title, x.PosterImage, x.BackdropImage })
|
||||
.ToDictionaryAsync(x => x.Id);
|
||||
|
||||
// **Weggelegt gilt hier für die ganze Serie.**
|
||||
//
|
||||
// Das Weglegen einer einzelnen Folge nützt in dieser Reihe
|
||||
// nichts: beim nächsten Laden rückt die übernächste nach, und
|
||||
// man legt zwanzigmal weg. Gemeint ist ohnehin „diese Serie
|
||||
// nicht vorschlagen". Der Zustand dafür gibt es schon — eine
|
||||
// Zeile mit `SeriesId` —, er wurde hier nur nie gelesen.
|
||||
var serienzustaende = await db.UserItemStates.AsNoTracking()
|
||||
.Where(z => z.UserId == benutzer && z.SeriesId != null
|
||||
&& serienIds.Contains(z.SeriesId!.Value))
|
||||
.ToDictionaryAsync(z => z.SeriesId!.Value);
|
||||
|
||||
var liste = new List<ContinueEntry>();
|
||||
foreach (var x in angefasst)
|
||||
{
|
||||
var naechste = folgen
|
||||
if (serienzustaende.GetValueOrDefault(x.SeriesId) is { Dismissed: true }) continue;
|
||||
|
||||
var derSerie = folgen
|
||||
.Where(f => f.SeriesId == x.SeriesId)
|
||||
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
||||
.ToList();
|
||||
|
||||
// **Die Reihe fängt dort an, wo der Benutzer steht — nicht
|
||||
// am Anfang der Serie.**
|
||||
//
|
||||
// Hier stand „die erste Folge ohne Häkchen". Wer bei Folge 2
|
||||
// mittendrin ist und Folge 1 nie abgehakt hat — weil er sie
|
||||
// nicht ganz zu Ende gesehen hat, oder weil er gleich bei
|
||||
// Folge 2 eingestiegen ist —, bekam Folge 1 vorgeschlagen.
|
||||
// Und das Abhaken von Folge 1 half nicht: dann kam Folge 2,
|
||||
// obwohl die längst in „Weiterschauen" steht.
|
||||
//
|
||||
// Massgeblich ist die **letzte** angefasste Folge. Ist sie
|
||||
// noch offen, läuft sie — dann gehört die Serie nach
|
||||
// „Weiterschauen" und nicht hierher. Ist sie durch, ist die
|
||||
// Folge danach dran.
|
||||
var letzte = derSerie.FindLastIndex(
|
||||
f => Angefasst(zustände.GetValueOrDefault(f.Id)));
|
||||
if (letzte < 0) continue;
|
||||
if (zustände.GetValueOrDefault(derSerie[letzte].Id) is not { Watched: true }) continue;
|
||||
|
||||
var naechste = derSerie.Skip(letzte + 1)
|
||||
.FirstOrDefault(f => zustände.GetValueOrDefault(f.Id) is not { Watched: true });
|
||||
|
||||
// Nichts offen — die Serie ist durch.
|
||||
if (naechste is null) continue;
|
||||
// Mittendrin: steht bereits in „Weiterschauen".
|
||||
if (zustände.GetValueOrDefault(naechste.Id) is { InProgress: true }) continue;
|
||||
if (!serien.TryGetValue(x.SeriesId, out var serie)) continue;
|
||||
|
||||
var zustand = zustände.GetValueOrDefault(naechste.Id);
|
||||
@@ -450,6 +509,13 @@ public static class PlaybackEndpoints
|
||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.MovieId, d.VersionLabel })
|
||||
.ToListAsync();
|
||||
|
||||
// **Mit Stand.** Hier stand `null`, und damit fehlte der
|
||||
// Fortschrittsbalken ausgerechnet in einer Reihe, die aus dem
|
||||
// Sehverhalten gebaut wird — ein halb gesehener Film sah dort
|
||||
// aus wie ein unangetasteter.
|
||||
var vorschlagsstände = await LibraryContentEndpoints.ZuständeZu(
|
||||
db, wer, filme: vorschlagIds);
|
||||
|
||||
var items = treffer.Select(m => new MovieResponse(
|
||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||
vorschlagsdateien.Where(d => d.MovieId == m.Id)
|
||||
@@ -457,11 +523,83 @@ public static class PlaybackEndpoints
|
||||
d.Id, Path.GetFileName(d.Path), d.SizeBytes, d.Container,
|
||||
d.Duration?.TotalSeconds, [], d.VersionLabel))
|
||||
.ToList(),
|
||||
null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
|
||||
vorschlagsstände.GetValueOrDefault(m.Id),
|
||||
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
|
||||
PublishAt: m.PublishAt)).ToList();
|
||||
|
||||
return Results.Ok(new Page<MovieResponse>(items, items.Count));
|
||||
});
|
||||
|
||||
/* ---------- Was gerade Saison hat ---------- */
|
||||
|
||||
// **Eine Reihe, die nur zu ihrer Zeit dasteht.**
|
||||
//
|
||||
// Nach Stichwörtern der Metadatenquelle, nicht nach Genres: „Stirb
|
||||
// langsam" ist kein Familienfilm und trägt trotzdem „christmas".
|
||||
// Eine Weihnachtsreihe nach Genres enthielte jeden
|
||||
// Zeichentrickfilm und diesen Film nicht.
|
||||
//
|
||||
// **Leer heisst leer.** Findet der Anlass nichts, kommt eine leere
|
||||
// Seite ohne Namen zurück — der Client zeigt dann gar keine Reihe,
|
||||
// statt einer mit Überschrift und nichts darunter.
|
||||
start.MapGet("/seasonal", async (
|
||||
ClaimsPrincipal wer, FlaroContext db, Saisoneinstellungen einstellungen,
|
||||
TimeProvider uhr, CancellationToken ct, int take = 24) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
|
||||
var heute = uhr.GetUtcNow();
|
||||
var anlass = (await einstellungen.AnlaesseAsync(ct)).FirstOrDefault(a => a.Gilt(heute));
|
||||
if (anlass is null) return Results.Ok(new Saisonreihe(null, new Page<MovieResponse>([], 0)));
|
||||
|
||||
var stichworte = anlass.Stichworte.Select(w => w.ToLowerInvariant()).ToList();
|
||||
var genres = anlass.Genres;
|
||||
|
||||
var treffer = await db.Movies.AsNoTracking()
|
||||
.Where(m => db.Stichwortlinks.Any(l => l.MovieId == m.Id
|
||||
&& db.Stichworte.Any(w => w.Id == l.StichwortId && stichworte.Contains(w.Name)))
|
||||
|| db.GenreLinks.Any(l => l.MovieId == m.Id
|
||||
&& db.Genres.Any(g => g.Id == l.GenreId && genres.Contains(g.Name))))
|
||||
// **Stichworttreffer vor Genretreffer.** Ein Film, der
|
||||
// „christmas" trägt, gehört sicherer in die Reihe als einer,
|
||||
// der nur im passenden Genre liegt.
|
||||
.Select(m => new
|
||||
{
|
||||
Film = m,
|
||||
Sicher = db.Stichwortlinks.Count(l => l.MovieId == m.Id
|
||||
&& db.Stichworte.Any(w => w.Id == l.StichwortId && stichworte.Contains(w.Name))),
|
||||
})
|
||||
.OrderByDescending(x => x.Sicher)
|
||||
.ThenByDescending(x => x.Film.Rating ?? 0)
|
||||
.Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||
.Select(x => x.Film)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (treffer.Count == 0) return Results.Ok(new Saisonreihe(null, new Page<MovieResponse>([], 0)));
|
||||
|
||||
var saisonIds = treffer.Select(m => m.Id).ToList();
|
||||
var saisondateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(d => d.MovieId != null && saisonIds.Contains(d.MovieId!.Value))
|
||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.MovieId, d.VersionLabel })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var saisonstände = await LibraryContentEndpoints.ZuständeZu(
|
||||
db, wer, filme: saisonIds);
|
||||
|
||||
var saisonitems = treffer.Select(m => new MovieResponse(
|
||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||
saisondateien.Where(d => d.MovieId == m.Id)
|
||||
.Select(d => new MediaFileResponse(
|
||||
d.Id, Path.GetFileName(d.Path), d.SizeBytes, d.Container,
|
||||
d.Duration?.TotalSeconds, [], d.VersionLabel))
|
||||
.ToList(),
|
||||
saisonstände.GetValueOrDefault(m.Id),
|
||||
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
|
||||
PublishAt: m.PublishAt)).ToList();
|
||||
|
||||
return Results.Ok(new Saisonreihe(
|
||||
anlass.Name, new Page<MovieResponse>(saisonitems, saisonitems.Count)));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Der Zustand eines Titels, den noch niemand angefasst hat.</summary>
|
||||
@@ -471,6 +609,18 @@ public static class PlaybackEndpoints
|
||||
/// Folge" zeigt überwiegend Folgen ohne Stand, und ein Client, der für
|
||||
/// jede davon einen Sonderfall braucht, vergisst ihn irgendwo.
|
||||
/// </remarks>
|
||||
/// <summary>Ob der Benutzer diese Folge überhaupt schon angefasst hat.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Eine Zeile allein genügt nicht: sie entsteht auch, wenn jemand nur
|
||||
/// einen Stern vergibt. Angefasst heisst gesehen, angefangen oder
|
||||
/// ausdrücklich weggelegt.
|
||||
/// </remarks>
|
||||
private static bool Angefasst(UserItemState? zustand) =>
|
||||
zustand is not null
|
||||
&& (zustand.Watched || zustand.InProgress || zustand.Dismissed
|
||||
|| zustand.Position > TimeSpan.Zero);
|
||||
|
||||
private static ItemStateResponse Leer() => new(0, null, false, 0, false, null, false, null);
|
||||
|
||||
/// <summary>Macht aus Zuständen Einträge mit Titel und Bild.</summary>
|
||||
@@ -485,7 +635,7 @@ public static class PlaybackEndpoints
|
||||
var folgen = zustände.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
|
||||
|
||||
var filmdaten = await db.Movies.AsNoTracking().Where(m => filme.Contains(m.Id))
|
||||
.Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage, m.PublishAt })
|
||||
.Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage, m.PublishAt, m.Aufmacherbilder })
|
||||
.ToDictionaryAsync(m => m.Id);
|
||||
|
||||
var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.Id))
|
||||
@@ -493,7 +643,7 @@ public static class PlaybackEndpoints
|
||||
{
|
||||
f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage,
|
||||
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt,
|
||||
SerienBild = s.BackdropImage,
|
||||
SerienBild = s.BackdropImage, SerienAufmacher = s.Aufmacherbilder,
|
||||
})
|
||||
.ToDictionaryAsync(f => f.Id);
|
||||
|
||||
@@ -508,7 +658,8 @@ public static class PlaybackEndpoints
|
||||
m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z),
|
||||
FileId: dateien.GetValueOrDefault(m.Id).Id,
|
||||
DurationSeconds: dateien.GetValueOrDefault(m.Id).Sekunden,
|
||||
PublishAt: m.PublishAt));
|
||||
PublishAt: m.PublishAt,
|
||||
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow)));
|
||||
else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f))
|
||||
liste.Add(new ContinueEntry(
|
||||
f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null,
|
||||
@@ -517,7 +668,12 @@ public static class PlaybackEndpoints
|
||||
FileId: dateien.GetValueOrDefault(f.Id).Id,
|
||||
DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden,
|
||||
PublishAt: f.PublishAt,
|
||||
SeriesBackdropImage: f.SerienBild));
|
||||
SeriesBackdropImage: f.SerienBild,
|
||||
// **Das der Serie, nicht das der Folge.** Der Aufmacher
|
||||
// zeigt, worum es geht; eine einzelne Folge hat dafür
|
||||
// selten ein eigenes Bild und nie fünf.
|
||||
HeroImage: Aufmacherwahl.Jetziges(
|
||||
f.SerienAufmacher, f.SerienId, DateTimeOffset.UtcNow)));
|
||||
}
|
||||
return liste;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Flaro.Api.Auth;
|
||||
using Flaro.Core;
|
||||
using Flaro.Core.Delivery;
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Core.Playback;
|
||||
using Flaro.Data;
|
||||
using Flaro.Metadata;
|
||||
@@ -15,7 +16,11 @@ public static class SettingsEndpoints
|
||||
app.MapGet("/api/v1/settings/metadata", async (FlaroContext db) =>
|
||||
{
|
||||
var zeilen = await db.Settings.AsNoTracking()
|
||||
.Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName || s.Key == DatabaseTmdbSettings.LanguageName)
|
||||
.Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName
|
||||
|| s.Key == DatabaseTmdbSettings.LanguageName
|
||||
|| s.Key == DatabaseFanartSettings.ApiKeyName
|
||||
|| s.Key == DatabaseOmdbSettings.ApiKeyName
|
||||
|| s.Key == DatabaseRottenTomatoesSettings.SchalterName)
|
||||
.ToDictionaryAsync(s => s.Key, s => s.Value);
|
||||
|
||||
// **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem
|
||||
@@ -30,7 +35,14 @@ public static class SettingsEndpoints
|
||||
Language: aufgelöst.Language,
|
||||
// Damit in der Oberfläche steht, ob der mitgelieferte Schlüssel
|
||||
// benutzt wird — und dass ein eigener ihn ablösen würde.
|
||||
UsingProjectKey: aufgelöst.UsingProjectKey));
|
||||
UsingProjectKey: aufgelöst.UsingProjectKey,
|
||||
HasFanartKey: !string.IsNullOrWhiteSpace(
|
||||
zeilen.GetValueOrDefault(DatabaseFanartSettings.ApiKeyName)),
|
||||
HasOmdbKey: !string.IsNullOrWhiteSpace(
|
||||
zeilen.GetValueOrDefault(DatabaseOmdbSettings.ApiKeyName)),
|
||||
RottenTomatoes: string.Equals(
|
||||
zeilen.GetValueOrDefault(DatabaseRottenTomatoesSettings.SchalterName),
|
||||
"true", StringComparison.OrdinalIgnoreCase)));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **In welcher Sprache der Bestand vorliegt.**
|
||||
@@ -88,6 +100,15 @@ public static class SettingsEndpoints
|
||||
MetadataSettingsRequest anfrage, FlaroContext db) =>
|
||||
{
|
||||
if (anfrage.ApiKey is not null) await Setzen(db, DatabaseTmdbSettings.ApiKeyName, anfrage.ApiKey.Trim());
|
||||
if (anfrage.FanartApiKey is not null)
|
||||
await Setzen(db, DatabaseFanartSettings.ApiKeyName, anfrage.FanartApiKey.Trim());
|
||||
if (anfrage.OmdbApiKey is not null)
|
||||
await Setzen(db, DatabaseOmdbSettings.ApiKeyName, anfrage.OmdbApiKey.Trim());
|
||||
|
||||
// **Ein Schalter, kein Schlüssel.** Rotten Tomatoes vergibt keine
|
||||
// Zugänge; eingeschaltet wird hier das Lesen ihrer Seite.
|
||||
if (anfrage.RottenTomatoes is { } an)
|
||||
await Setzen(db, DatabaseRottenTomatoesSettings.SchalterName, an ? "true" : "false");
|
||||
if (anfrage.Language is not null)
|
||||
{
|
||||
var sprache = anfrage.Language.Trim();
|
||||
@@ -152,10 +173,50 @@ public static class SettingsEndpoints
|
||||
Kodierstufen.Lesen(anfrage.Encoder).AlsWort(), geaendert));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
/* ---------- Wartung: was auf dem Kratzplatz liegen bleiben darf ---------- */
|
||||
// **Die Quellen einmal fragen und sagen, was zurückkam.**
|
||||
//
|
||||
// Vorher stand nach dem Eintragen eines Schlüssels nur „Hinterlegt"
|
||||
// da. Ob er stimmt und ob der Dienst antwortet, liess sich nur
|
||||
// herausfinden, indem man die Metadaten über achthundert Titel
|
||||
// auffrischte und danach nachsah, ob sich etwas geändert hat.
|
||||
app.MapPost("/api/v1/settings/metadata/test", async (
|
||||
Quellenprobe probe, CancellationToken ct) =>
|
||||
Results.Ok(await probe.LaufenAsync(ct)))
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
/* ---------- Saisonale Reihen ---------- */
|
||||
|
||||
// **Einstellbar und nicht eingebaut.** Halloween und Weihnachten
|
||||
// kennt jeder; der 1. August ist Schweizer Nationalfeiertag, der
|
||||
// 4. Juli amerikanischer, und der Geburtstag des Betreibers steht
|
||||
// in keiner Bibliothek.
|
||||
app.MapGet("/api/v1/settings/seasonal", async (Saisoneinstellungen einstellungen) =>
|
||||
Results.Ok(await einstellungen.AnlaesseAsync()))
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapPut("/api/v1/settings/seasonal", async (
|
||||
List<Saisonanlass> anlaesse, FlaroContext db) =>
|
||||
{
|
||||
// **Unbrauchbares wird abgewiesen, nicht stillschweigend
|
||||
// geschluckt.** Ein Anlass ohne Stichwörter fände nie etwas, und
|
||||
// wer ihn eingetragen hat, suchte den Fehler woanders.
|
||||
var schlecht = anlaesse.Where(a => !a.Brauchbar).ToList();
|
||||
if (schlecht.Count > 0)
|
||||
{
|
||||
return Einwand.Bitte(
|
||||
$"„{schlecht[0].Name}“ ist unvollständig. Jeder Anlass braucht einen Namen, "
|
||||
+ "einen Zeitraum in der Form MM-TT und mindestens ein Stichwort oder Genre.");
|
||||
}
|
||||
|
||||
await Setzen(db, Saisoneinstellungen.Schluessel, Saisoneinstellungen.Schreiben(anlaesse));
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(anlaesse);
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
/* ---------- Wartung: was auf dem Transcode-Cache liegen bleiben darf ---------- */
|
||||
|
||||
app.MapGet("/api/v1/settings/maintenance", async (
|
||||
Kratzplatzeinstellungen kratz, Wartungsplaneinstellungen plan) =>
|
||||
Transcodeeinstellungen kratz, Wartungsplaneinstellungen plan) =>
|
||||
{
|
||||
var regeln = await kratz.RegelnAsync();
|
||||
var w = await plan.RegelnAsync();
|
||||
@@ -169,23 +230,23 @@ public static class SettingsEndpoints
|
||||
|
||||
app.MapPut("/api/v1/settings/maintenance", async (
|
||||
MaintenanceSettingsRequest anfrage, FlaroContext db,
|
||||
Kratzplatzeinstellungen einstellungen, Wartungsplaneinstellungen plan) =>
|
||||
Transcodeeinstellungen einstellungen, Wartungsplaneinstellungen plan) =>
|
||||
{
|
||||
var vorher = await einstellungen.RegelnAsync();
|
||||
|
||||
// Was nicht geschickt wird, bleibt — ein Client, der nur ein Feld
|
||||
// kennt, soll die anderen nicht zurücksetzen.
|
||||
var nachher = Kratzplatzregeln.Geprueft(
|
||||
var nachher = Transcoderegeln.Geprueft(
|
||||
anfrage.CapGb ?? vorher.ObergrenzeBytes / 1024d / 1024 / 1024,
|
||||
anfrage.UnusedDays ?? (int)vorher.Unbenutzt.TotalDays,
|
||||
anfrage.GraceMinutes ?? (int)vorher.Schonfrist.TotalMinutes);
|
||||
|
||||
var zahl = System.Globalization.CultureInfo.InvariantCulture;
|
||||
await Setzen(db, Kratzplatzeinstellungen.ObergrenzeSchluessel,
|
||||
await Setzen(db, Transcodeeinstellungen.ObergrenzeSchluessel,
|
||||
(nachher.ObergrenzeBytes / 1024d / 1024 / 1024).ToString("0.###", zahl));
|
||||
await Setzen(db, Kratzplatzeinstellungen.UnbenutztSchluessel,
|
||||
await Setzen(db, Transcodeeinstellungen.UnbenutztSchluessel,
|
||||
((int)nachher.Unbenutzt.TotalDays).ToString(zahl));
|
||||
await Setzen(db, Kratzplatzeinstellungen.SchonfristSchluessel,
|
||||
await Setzen(db, Transcodeeinstellungen.SchonfristSchluessel,
|
||||
((int)nachher.Schonfrist.TotalMinutes).ToString(zahl));
|
||||
var wVorher = await plan.RegelnAsync();
|
||||
var wNachher = Wartungsregeln.Geprueft(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Flaro.Core.Delivery;
|
||||
@@ -26,6 +27,34 @@ namespace Flaro.Api.Endpoints;
|
||||
/// </remarks>
|
||||
public static class StreamEndpoints
|
||||
{
|
||||
/// <summary>Wie die Untertitelgruppe in der Hauptliste heisst.</summary>
|
||||
private const string Untertitelgruppe = "untertitel";
|
||||
|
||||
/// <summary>Was in der Hauptliste als Bandbreite steht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine Zahl, die niemand liest, und trotzdem Pflicht.</b> HLS
|
||||
/// verlangt sie auf jedem <c>EXT-X-STREAM-INF</c>, damit ein Spieler
|
||||
/// zwischen Stufen wählen kann. Es gibt hier nur eine Stufe — die Wahl
|
||||
/// trifft der Benutzer vorher im Menü —, also ist jede Angabe so gut wie
|
||||
/// jede andere. Acht Megabit sind reichlich und schrecken keinen Spieler
|
||||
/// ab, der sie für eine Mindestanforderung hält.
|
||||
/// </remarks>
|
||||
private const int Bandbreite = 8_000_000;
|
||||
|
||||
/// <summary>Was in einer Abspielliste zwischen Anführungszeichen stehen darf.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// In einem M3U-Attribut gibt es kein Maskieren: ein Anführungszeichen
|
||||
/// beendet den Wert, und der Rest der Zeile wird zu Unsinn. Ein Titel wie
|
||||
/// <c>Kommentar "Regie"</c> stünde sonst als halbe Zeile da.
|
||||
/// </remarks>
|
||||
private static string M3uText(string roh) =>
|
||||
roh.Replace("\"", "'", StringComparison.Ordinal)
|
||||
.Replace("\n", " ", StringComparison.Ordinal)
|
||||
.Replace("\r", " ", StringComparison.Ordinal)
|
||||
.Replace(",", " ", StringComparison.Ordinal);
|
||||
|
||||
public static void MapStream(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// **Eine Sitzung anlegen.** Der Rumpf trägt, was das Gerät kann und
|
||||
@@ -33,7 +62,7 @@ public static class StreamEndpoints
|
||||
app.MapPost("/api/v1/files/{id:guid}/stream", async (
|
||||
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
|
||||
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr,
|
||||
Untertitelvorrat vorrat) =>
|
||||
Untertitelvorrat vorrat, Vorschauvorrat vorschau) =>
|
||||
{
|
||||
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
|
||||
@@ -55,6 +84,13 @@ public static class StreamEndpoints
|
||||
// Antwort statt eines Fehlers.
|
||||
vorrat.Anstellen(gewuenschte.Id, vordraengeln: true);
|
||||
|
||||
// **Und die Vorschaubilder gleich mit.** Denselben Grund: sie
|
||||
// entstehen aus einem Durchlauf über die Datei, und bis jemand
|
||||
// zur Zeitleiste greift, ist der längst durch. Auf Vorrat über
|
||||
// den ganzen Bestand wären es Gigabyte für Titel, die niemand
|
||||
// anfasst.
|
||||
vorschau.Anstellen(gewuenschte.Id);
|
||||
|
||||
var profil = Profil(anfrage);
|
||||
var hoehe = anfrage.Height is { } gewuenscht
|
||||
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
|
||||
@@ -64,7 +100,7 @@ public static class StreamEndpoints
|
||||
// **Was der Browser ohnehin öffnet, geht unverändert hinaus.**
|
||||
//
|
||||
// Eine MP4 mit H.264 und AAC durch ffmpeg zu schicken wäre ein
|
||||
// Prozess und ein Kratzplatzordner für nichts — und der
|
||||
// Prozess und ein Transcodeordner für nichts — und der
|
||||
// Browser verlöre dabei das, was er von sich aus am besten
|
||||
// kann: springen über Bereichsanfragen, ohne dass irgendwo
|
||||
// etwas neu gerechnet wird.
|
||||
@@ -89,7 +125,7 @@ public static class StreamEndpoints
|
||||
erzwingen: anfrage.Transcode,
|
||||
tonversatz: Math.Clamp(anfrage.AudioOffsetMs, -5000, 5000) / 1000.0);
|
||||
|
||||
return Results.Ok(await Auskunft(db, sitzung));
|
||||
return Results.Ok(await Auskunft(db, vorrat, sitzung));
|
||||
}
|
||||
catch (InvalidOperationException fehler)
|
||||
{
|
||||
@@ -99,9 +135,139 @@ public static class StreamEndpoints
|
||||
}
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Die Hauptliste — für Abspieler, die ihre Untertitel aus HLS
|
||||
// beziehen.**
|
||||
//
|
||||
// `index.m3u8` ist eine Medienliste: eine Reihe von Schnipseln, sonst
|
||||
// nichts. Der Browser braucht nicht mehr, weil er die Untertitel als
|
||||
// eigene Spur ans Videoelement gehängt bekommt. AVPlayer kann das
|
||||
// nicht — dort gibt es keinen Weg, einer laufenden Wiedergabe eine
|
||||
// Textspur von aussen beizulegen. Was er findet, findet er in der
|
||||
// Abspielliste oder gar nicht.
|
||||
//
|
||||
// Also hier: dieselbe Medienliste als Bildspur, daneben je eine
|
||||
// Untertitelspur. Für den Browser ändert sich nichts; er holt
|
||||
// weiterhin `index.m3u8`.
|
||||
app.MapGet("/api/v1/stream/{sitzung:guid}/master.m3u8", async (
|
||||
Guid sitzung, HttpResponse antwort, Streamkoordinator koordinator,
|
||||
FlaroContext db, Untertitelvorrat vorrat, CancellationToken ct) =>
|
||||
{
|
||||
// Dieselbe Begründung wie bei der Medienliste darunter: eine
|
||||
// Liste, die sich ändern kann, darf nicht zwischengespeichert
|
||||
// werden.
|
||||
antwort.Headers.CacheControl = "no-store, no-cache, must-revalidate";
|
||||
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
|
||||
koordinator.Lebenszeichen(sitzung);
|
||||
|
||||
var spuren = await db.MediaStreams.AsNoTracking()
|
||||
.Where(x => x.MediaFileId == laufend.DateiId && x.Kind == MediaStreamKind.Subtitle)
|
||||
.OrderBy(x => x.Index)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var datei = await db.MediaFiles.AsNoTracking()
|
||||
.FirstOrDefaultAsync(f => f.Id == laufend.DateiId, ct);
|
||||
|
||||
// **Nur, was jetzt sofort abholbar ist.**
|
||||
//
|
||||
// Untertitel werden beim ersten Bedarf aus der Datei gezogen; bei
|
||||
// einem Dreistünder auf einem NAS dauert das Minuten. Der Browser
|
||||
// verträgt das, er fragt die Spur einzeln an und wartet. AVPlayer
|
||||
// nicht: eine Spur, die in der Hauptliste steht und beim Abruf
|
||||
// noch nicht da ist, bricht ihm die **ganze** Wiedergabe ab, nicht
|
||||
// bloss den Untertitel.
|
||||
//
|
||||
// Das Füllen ist beim Anlegen der Sitzung schon angestossen — beim
|
||||
// nächsten Abspielen stehen sie drin.
|
||||
var text = datei is null ? [] : spuren
|
||||
.Where(x => x.Codec is null || !Untertitelvorrat.IstBild(x.Codec))
|
||||
.Where(x => vorrat.Liegtvor(datei.Id, datei.ModifiedAt, x.Index))
|
||||
.ToList();
|
||||
|
||||
var liste = new StringBuilder();
|
||||
liste.AppendLine("#EXTM3U");
|
||||
liste.AppendLine("#EXT-X-VERSION:6");
|
||||
|
||||
foreach (var spur in text)
|
||||
{
|
||||
// **Nichts ist voreingestellt.** `DEFAULT=YES` hiesse: dieser
|
||||
// Untertitel läuft, ohne dass jemand ihn bestellt hat. Was
|
||||
// gezeigt wird, entscheidet der Spieler.
|
||||
liste.Append("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"")
|
||||
.Append(Untertitelgruppe)
|
||||
.Append("\",NAME=\"").Append(M3uText(Benennung(spur))).Append('"');
|
||||
|
||||
if (spur.Language is { Length: > 0 } sprache)
|
||||
liste.Append(",LANGUAGE=\"").Append(M3uText(sprache)).Append('"');
|
||||
|
||||
liste.Append(",DEFAULT=NO,AUTOSELECT=NO,FORCED=")
|
||||
.Append(spur.IsForced ? "YES" : "NO")
|
||||
.Append(",URI=\"untertitel/").Append(spur.Index).Append(".m3u8\"")
|
||||
.AppendLine();
|
||||
}
|
||||
|
||||
// **BANDWIDTH ist Pflicht, auch wenn es nur eine Stufe gibt.**
|
||||
// Eine Hauptliste ohne diese Angabe weist AVPlayer ab.
|
||||
liste.Append("#EXT-X-STREAM-INF:BANDWIDTH=").Append(Bandbreite);
|
||||
if (text.Count > 0) liste.Append(",SUBTITLES=\"").Append(Untertitelgruppe).Append('"');
|
||||
liste.AppendLine();
|
||||
liste.AppendLine("index.m3u8");
|
||||
|
||||
return Results.Text(liste.ToString(), "application/vnd.apple.mpegurl", Encoding.UTF8);
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Eine Untertitelspur als Abspielliste.**
|
||||
//
|
||||
// HLS kennt Text nur als eigene Spur mit eigener Liste. Unsere Spur
|
||||
// ist eine einzige WebVTT-Datei über den ganzen Film — also eine
|
||||
// Liste mit genau einem Stück, so lang wie der Rest des Films.
|
||||
//
|
||||
// **`ab` muss mit.** Die Zeitstempel in der Datei zählen ab
|
||||
// Dateianfang, der Strom ab null. Ohne diesen Wert läge bei
|
||||
// „Fortsetzen ab 8:37" jeder Satz acht Minuten in der Zukunft.
|
||||
app.MapGet("/api/v1/stream/{sitzung:guid}/untertitel/{spur:int}.m3u8", async (
|
||||
Guid sitzung, int spur, HttpResponse antwort,
|
||||
Streamkoordinator koordinator, FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
antwort.Headers.CacheControl = "no-store, no-cache, must-revalidate";
|
||||
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
|
||||
koordinator.Lebenszeichen(sitzung);
|
||||
|
||||
var textspuren = await Untertitelvorrat.TextspurenAsync(db, laufend.DateiId, ct);
|
||||
if (!textspuren.Contains(spur)) return Results.NotFound();
|
||||
|
||||
var datei = await db.MediaFiles.AsNoTracking()
|
||||
.FirstOrDefaultAsync(f => f.Id == laufend.DateiId, ct);
|
||||
if (datei is null) return Results.NotFound();
|
||||
|
||||
// Ohne bekannte Laufzeit eine grosszügige Länge: zu kurz hiesse,
|
||||
// dass der Rest des Films ohne Untertitel dasteht.
|
||||
var rest = datei.Duration is { TotalSeconds: > 0 } dauer
|
||||
? Math.Max(1, dauer.TotalSeconds - laufend.Ab)
|
||||
: 86400;
|
||||
|
||||
var ab = laufend.Ab.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
var laenge = rest.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
|
||||
var liste =
|
||||
$"""
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:{Math.Ceiling(rest)}
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXTINF:{laenge},
|
||||
/api/v1/files/{laufend.DateiId}/subtitles/{spur}.vtt?ab={ab}
|
||||
#EXT-X-ENDLIST
|
||||
|
||||
""";
|
||||
|
||||
return Results.Text(liste, "application/vnd.apple.mpegurl", Encoding.UTF8);
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Die Playlist.** Sie wächst, während ffmpeg schreibt.
|
||||
app.MapGet("/api/v1/stream/{sitzung:guid}/index.m3u8", async (
|
||||
Guid sitzung, HttpResponse antwort, Streamkoordinator koordinator, CancellationToken ct) =>
|
||||
Guid sitzung, HttpResponse antwort, Streamkoordinator koordinator,
|
||||
Tagebuch tagebuch, CancellationToken ct) =>
|
||||
{
|
||||
// **Diese Datei darf nie zwischengespeichert werden**, und das ist
|
||||
// kein Vorsichtsmass, sondern aus einem Fehler gelernt: der Film
|
||||
@@ -143,7 +309,19 @@ public static class StreamEndpoints
|
||||
// **Als Inhalt, nicht als Datei.** `Results.File` hängt
|
||||
// `Last-Modified` an und lädt damit zum Zwischenspeichern ein.
|
||||
if (Streamkoordinator.Playlist(jetzt.Ordner) is { } liste)
|
||||
{
|
||||
// **Die zweite Hälfte der Wartezeit.** Die erste steht
|
||||
// beim Anlegen der Sitzung; hier steht, wie lange ffmpeg
|
||||
// danach bis zum ersten Stück gebraucht hat. Nur wenn es
|
||||
// auffällt — vier Zeilen je Wiedergabe wären Lärm.
|
||||
if (versuch >= 8)
|
||||
{
|
||||
tagebuch.Schreiben(
|
||||
$"Wiedergabe: erstes Stück nach {versuch * 250} ms.");
|
||||
}
|
||||
|
||||
return Results.Text(liste, "application/vnd.apple.mpegurl", Encoding.UTF8);
|
||||
}
|
||||
|
||||
// **Hört ffmpeg auf, ohne ein Segment geschrieben zu haben**,
|
||||
// kommt auch keines mehr. Weiterzuwarten hiesse, eine halbe
|
||||
@@ -197,7 +375,7 @@ public static class StreamEndpoints
|
||||
|
||||
// **Schliessen heisst aufräumen.** Ohne das liefe ffmpeg weiter, bis
|
||||
// die Geduld der Sitzung abgelaufen ist — und schriebe solange den
|
||||
// ganzen Film auf den Kratzplatz.
|
||||
// ganzen Film auf den Transcode-Cache.
|
||||
app.MapDelete("/api/v1/stream/{sitzung:guid}", (Guid sitzung, Streamkoordinator koordinator) =>
|
||||
{
|
||||
koordinator.Beenden(sitzung);
|
||||
@@ -229,6 +407,68 @@ public static class StreamEndpoints
|
||||
// in dessen Zukunft, und zwar für den ganzen Rest des Films. Gemessen
|
||||
// im Spieler: bei „Fortsetzen ab 8:37" stand der Satz von Sekunde 0
|
||||
// auf dem Bild.
|
||||
// **Die Vorschau beim Überfahren der Zeitleiste.**
|
||||
//
|
||||
// Zwei Endpunkte: die Auskunft sagt, wie die Blätter aussehen, das
|
||||
// Blatt liefert die Bilder. Der Spieler holt die Auskunft einmal
|
||||
// und danach nur noch Blätter — beim Fahren über die Leiste wird
|
||||
// kein einziger Abruf mehr fällig, nur der Ausschnitt verschoben.
|
||||
app.MapGet("/api/v1/files/{id:guid}/vorschau", async (
|
||||
Guid id, FlaroContext db, Vorschauvorrat vorschau, TimeProvider uhr,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||
if (datei is null || !File.Exists(datei.Path))
|
||||
return Einwand.Fehlt("Diese Datei liegt nicht mehr da.");
|
||||
|
||||
// **Mit Freigabeprüfung, anders als beim Untertitel.** Ein
|
||||
// Standbild zeigt den Inhalt — bei einem Titel, der erst
|
||||
// nächste Woche gilt, wäre es genau das, was die Freigabe
|
||||
// verhindern soll.
|
||||
if (await DeliveryEndpoints.FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht)
|
||||
return DeliveryEndpoints.NochNicht(nochNicht);
|
||||
|
||||
// Wer fragt, will sie auch: anstellen, falls noch nichts da ist.
|
||||
vorschau.Anstellen(id);
|
||||
|
||||
var plan = vorschau.Liegtvor(id, datei.ModifiedAt);
|
||||
return Results.Ok(new VorschauAuskunft(
|
||||
plan is not null,
|
||||
plan?.Intervall ?? Vorschaubilder.Intervall,
|
||||
plan?.Spalten ?? Vorschaubilder.Spalten,
|
||||
plan?.Zeilen ?? Vorschaubilder.Zeilen,
|
||||
plan?.KachelBreite ?? 0,
|
||||
plan?.KachelHoehe ?? 0,
|
||||
plan?.Blaetter ?? 0));
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/files/{id:guid}/vorschau/{nummer:int}.jpg", async (
|
||||
Guid id, int nummer, FlaroContext db, Vorschauvorrat vorschau, TimeProvider uhr,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||
if (datei is null) return Einwand.Fehlt("Diese Datei liegt nicht mehr da.");
|
||||
|
||||
if (await DeliveryEndpoints.FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht)
|
||||
return DeliveryEndpoints.NochNicht(nochNicht);
|
||||
|
||||
var blatt = vorschau.Blatt(id, datei.ModifiedAt, nummer);
|
||||
return blatt is null
|
||||
? Einwand.Fehlt("Dieses Blatt gibt es nicht.")
|
||||
: Results.File(blatt, "image/jpeg");
|
||||
})
|
||||
// **Für immer gültig.** Der Dateiname trägt die Änderungszeit der
|
||||
// Mediendatei; wird sie ersetzt, ändert sich die Adresse. Dasselbe
|
||||
// Versprechen wie bei den Bildern aus `ImageEndpoints`.
|
||||
.AddEndpointFilter(async (zusammenhang, weiter) =>
|
||||
{
|
||||
var antwort = await weiter(zusammenhang);
|
||||
zusammenhang.HttpContext.Response.Headers.CacheControl =
|
||||
"private, max-age=31536000, immutable";
|
||||
return antwort;
|
||||
})
|
||||
.RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async (
|
||||
Guid id, int spur, FlaroContext db, Untertitelvorrat vorrat,
|
||||
CancellationToken ct, double ab = 0) =>
|
||||
@@ -294,6 +534,11 @@ public static class StreamEndpoints
|
||||
// dass es nichts zu beenden und nichts aufzuräumen gibt.
|
||||
SessionId: Guid.Empty,
|
||||
Url: $"/api/v1/files/{datei}/download",
|
||||
|
||||
// **Keine Hauptliste.** Eine durchgereichte Datei bringt ihre
|
||||
// Spuren selbst mit; eine Liste darum herum wäre eine Hülle um
|
||||
// nichts.
|
||||
MasterUrl: null,
|
||||
Method: "direct",
|
||||
|
||||
// **Null, nicht `ab`.** Bei einer durchgereichten Datei springt
|
||||
@@ -302,13 +547,10 @@ public static class StreamEndpoints
|
||||
StartSeconds: 0,
|
||||
Height: null,
|
||||
AudioStream: spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Audio)?.Index,
|
||||
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio)
|
||||
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
||||
.ToList(),
|
||||
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
|
||||
.Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))
|
||||
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
||||
.ToList());
|
||||
AudioStreams: Spurliste(spuren.Where(s => s.Kind == MediaStreamKind.Audio)),
|
||||
SubtitleStreams: Spurliste(spuren
|
||||
.Where(s => s.Kind == MediaStreamKind.Subtitle)
|
||||
.Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))));
|
||||
}
|
||||
|
||||
/// <summary>Das Geräteprofil aus der Anfrage — oder, wenn keines kam, Apples.</summary>
|
||||
@@ -335,16 +577,35 @@ public static class StreamEndpoints
|
||||
new HashSet<string>(anfrage.AudioCodecs, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static async Task<StreamResponse> Auskunft(FlaroContext db, Stromsitzung sitzung)
|
||||
private static async Task<StreamResponse> Auskunft(
|
||||
FlaroContext db, Untertitelvorrat vorrat, Stromsitzung sitzung)
|
||||
{
|
||||
var spuren = await db.MediaStreams.AsNoTracking()
|
||||
.Where(s => s.MediaFileId == sitzung.DateiId)
|
||||
.OrderBy(s => s.Index)
|
||||
.ToListAsync();
|
||||
|
||||
// **Die Hauptliste nur, wenn etwas drinsteht.**
|
||||
//
|
||||
// Sie gibt es allein wegen der Untertitelspuren; ohne sie ist sie
|
||||
// eine Hülle um die Medienliste. Und eine Hülle ist nicht umsonst:
|
||||
// eine Hauptliste ohne `CODECS` muss der Spieler erst aufmachen, um
|
||||
// zu sehen, was darin liegt. Gemessen an einem Film, dessen Spuren
|
||||
// noch nicht herausgezogen waren: zwölf Sekunden vergingen, ohne dass
|
||||
// die Medienliste auch nur abgeholt wurde, dann gab die App auf und
|
||||
// bestellte neu.
|
||||
var datei = await db.MediaFiles.AsNoTracking()
|
||||
.FirstOrDefaultAsync(f => f.Id == sitzung.DateiId);
|
||||
|
||||
var hatText = datei is not null && spuren
|
||||
.Where(x => x.Kind == MediaStreamKind.Subtitle)
|
||||
.Where(x => x.Codec is null || !Untertitelvorrat.IstBild(x.Codec))
|
||||
.Any(x => vorrat.Liegtvor(datei.Id, datei.ModifiedAt, x.Index));
|
||||
|
||||
return new StreamResponse(
|
||||
SessionId: sitzung.Kennung,
|
||||
Url: $"/api/v1/stream/{sitzung.Kennung}/index.m3u8",
|
||||
MasterUrl: hatText ? $"/api/v1/stream/{sitzung.Kennung}/master.m3u8" : null,
|
||||
Method: sitzung.Art switch
|
||||
{
|
||||
Stromart.Direkt => "direct",
|
||||
@@ -354,13 +615,10 @@ public static class StreamEndpoints
|
||||
StartSeconds: sitzung.Ab,
|
||||
Height: sitzung.Hoehe,
|
||||
AudioStream: sitzung.Tonspur,
|
||||
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio)
|
||||
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
||||
.ToList(),
|
||||
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
|
||||
.Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))
|
||||
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
||||
.ToList());
|
||||
AudioStreams: Spurliste(spuren.Where(s => s.Kind == MediaStreamKind.Audio)),
|
||||
SubtitleStreams: Spurliste(spuren
|
||||
.Where(s => s.Kind == MediaStreamKind.Subtitle)
|
||||
.Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))));
|
||||
}
|
||||
|
||||
/// <summary>Bildbasierte Untertitel lassen sich nicht nach WebVTT wandeln.</summary>
|
||||
@@ -371,10 +629,36 @@ public static class StreamEndpoints
|
||||
/// nichts führt, ist schlechter als kein Eintrag.
|
||||
/// </remarks>
|
||||
|
||||
/// <summary>Beschriftet eine Spurliste — unterscheidbar.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Das Auftrennen gleicher Namen steht in <c>Benennung</c>: die Apps
|
||||
/// zeigen dieselben Listen, und zweimal formuliert driften sie
|
||||
/// auseinander.
|
||||
/// </remarks>
|
||||
private static List<SpurAuskunft> Spurliste(IEnumerable<MediaStream> spuren)
|
||||
{
|
||||
var liste = spuren.ToList();
|
||||
var namen = Flaro.Core.Media.Benennung.Unterscheiden(
|
||||
liste.Select(s => new Flaro.Core.Media.Benennung.Spurzeile(
|
||||
Benennung(s), s.Codec, s.Index)).ToList());
|
||||
|
||||
return liste
|
||||
.Select((s, i) => new SpurAuskunft(s.Index, namen[i], s.Language, s.IsDefault))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Wie eine Spur im Menü heisst.</summary>
|
||||
private static string Benennung(MediaStream spur)
|
||||
{
|
||||
if (spur.Title is { Length: > 0 } eigen) return eigen;
|
||||
// **Ein Spurname, der nur die Sprache wiederholt, ist keiner.**
|
||||
// Viele Dateien tragen als Titel schlicht „deu" — durchgereicht
|
||||
// stand genau das in der Auswahl, roh und dreibuchstabig, obwohl
|
||||
// die Tabelle darunter „Deutsch" daraus gemacht hätte.
|
||||
var istKuerzel = spur.Language is { Length: > 0 } kennung
|
||||
&& string.Equals(spur.Title?.Trim(), kennung.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!istKuerzel && spur.Title is { Length: > 0 } eigen) return eigen;
|
||||
|
||||
var sprache = Sprache(spur.Language);
|
||||
var teile = new List<string> { sprache };
|
||||
|
||||
@@ -251,12 +251,12 @@ public static class SystemEndpoints
|
||||
// wohin die Bilder wandern, musste die Compose-Datei lesen. Und wenn
|
||||
// das `cache`-Volume vollläuft, ist das der erste Ort, an dem man
|
||||
// nachsieht — nicht der letzte.
|
||||
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade, Kratzplatzpflege pflege) =>
|
||||
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade, Transcodepflege pflege) =>
|
||||
{
|
||||
Speicherort Messen(string name, string variable, string pfad) =>
|
||||
new(name, variable, pfad, Belegt(pfad), Frei(pfad),
|
||||
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null,
|
||||
// **Beim Kratzplatz aufgeschlüsselt.** „39 GB belegt" sagt
|
||||
// **Beim Transcode-Cache aufgeschlüsselt.** „39 GB belegt" sagt
|
||||
// nicht, ob das Segmente, umgepackte Filme oder Untertitel
|
||||
// sind — und davon hängt ab, ob es ein Problem ist.
|
||||
name == "Umpacken" ? pflege.Belegung() : null);
|
||||
@@ -269,7 +269,7 @@ public static class SystemEndpoints
|
||||
});
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Den Kratzplatz aufräumen.**
|
||||
// **Den Transcode-Cache aufräumen.**
|
||||
//
|
||||
// POST und nicht DELETE wie beim Bildspeicher darüber: dort wird ein
|
||||
// Ordner geleert, hier eine Regel angewandt. Etwas, das Verwaistes
|
||||
@@ -301,8 +301,8 @@ public static class SystemEndpoints
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapPost("/api/v1/system/storage/transcode/cleanup", async (
|
||||
Kratzplatzpflege pflege, CancellationToken ct) =>
|
||||
Results.Ok(await pflege.LaufenAsync(Kratzplatzpflege.Anlass.VonHand, ct)))
|
||||
Transcodepflege pflege, CancellationToken ct) =>
|
||||
Results.Ok(await pflege.LaufenAsync(Transcodepflege.Anlass.VonHand, ct)))
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Den Bildspeicher leeren.**
|
||||
|
||||
@@ -35,3 +35,84 @@ public sealed class DatabaseTmdbSettings(FlaroContext db) : ITmdbSettingsSource
|
||||
zeilen.GetValueOrDefault(LanguageName));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Der Schlüssel für fanart.tv, aus der Datenbank.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Kein mitgelieferter.</b> Bei TMDB gibt es einen Projektschlüssel als
|
||||
/// Rückfall; fanart.tv verlangt einen persönlichen, und einen fremden
|
||||
/// mitzuliefern hiesse, ihn fremd zu benutzen. Ohne eigenen Schlüssel ist
|
||||
/// die Quelle schlicht aus.
|
||||
/// </remarks>
|
||||
public sealed class DatabaseFanartSettings(FlaroContext db) : IFanartSettingsSource
|
||||
{
|
||||
public const string ApiKeyName = "metadata.fanart.apiKey";
|
||||
|
||||
private string? _gemerkt;
|
||||
private bool _gelesen;
|
||||
|
||||
public async Task<string?> SchluesselAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_gelesen) return _gemerkt;
|
||||
|
||||
var zeile = await db.Settings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Key == ApiKeyName, ct);
|
||||
|
||||
_gelesen = true;
|
||||
return _gemerkt = string.IsNullOrWhiteSpace(zeile?.Value) ? null : zeile.Value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Der Schlüssel für OMDb, aus der Datenbank.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Wie bei fanart.tv: kein mitgelieferter. OMDb begrenzt je Schlüssel auf
|
||||
/// tausend Abrufe am Tag — einen fremden mitzuliefern hiesse, dessen
|
||||
/// Kontingent zu verbrauchen.
|
||||
/// </remarks>
|
||||
public sealed class DatabaseOmdbSettings(FlaroContext db) : IOmdbSettingsSource
|
||||
{
|
||||
public const string ApiKeyName = "metadata.omdb.apiKey";
|
||||
|
||||
private string? _gemerkt;
|
||||
private bool _gelesen;
|
||||
|
||||
public async Task<string?> SchluesselAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_gelesen) return _gemerkt;
|
||||
|
||||
var zeile = await db.Settings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Key == ApiKeyName, ct);
|
||||
|
||||
_gelesen = true;
|
||||
return _gemerkt = string.IsNullOrWhiteSpace(zeile?.Value) ? null : zeile.Value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ob die Seite von Rotten Tomatoes gelesen werden darf.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Kein Schlüssel, sondern ein Schalter — und der steht auf aus.</b>
|
||||
/// Rotten Tomatoes vergibt keine Zugänge; die beiden Werte stehen nur auf
|
||||
/// der Seite selbst. Das ist Auslesen einer fremden Webseite: es bricht,
|
||||
/// wenn dort umgebaut wird, und es geschieht ohne Einverständnis des
|
||||
/// Betreibers. Eine solche Quelle gehört nicht stillschweigend
|
||||
/// eingeschaltet, sondern bewusst.
|
||||
/// </remarks>
|
||||
public sealed class DatabaseRottenTomatoesSettings(FlaroContext db) : IRottenTomatoesSettingsSource
|
||||
{
|
||||
public const string SchalterName = "metadata.rottenTomatoes.enabled";
|
||||
|
||||
private bool? _gemerkt;
|
||||
|
||||
public async Task<bool> EingeschaltetAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_gemerkt is { } schon) return schon;
|
||||
|
||||
var zeile = await db.Settings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Key == SchalterName, ct);
|
||||
|
||||
return (_gemerkt = string.Equals(zeile?.Value, "true", StringComparison.OrdinalIgnoreCase))
|
||||
.Value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,8 +177,8 @@ public sealed class PackageCoordinator(
|
||||
// Umpacken ist der eine, in dem der Platz wirklich gebraucht
|
||||
// wird — und `PlatzPruefen` sagt danach die Wahrheit statt
|
||||
// „kein Platz", obwohl zwanzig Gigabyte Altlast herumliegen.
|
||||
await bereich.ServiceProvider.GetRequiredService<Kratzplatzpflege>()
|
||||
.LaufenAsync(Kratzplatzpflege.Anlass.VorDemUmpacken);
|
||||
await bereich.ServiceProvider.GetRequiredService<Transcodepflege>()
|
||||
.LaufenAsync(Transcodepflege.Anlass.VorDemUmpacken);
|
||||
|
||||
PlatzPruefen(datei.SizeBytes);
|
||||
|
||||
@@ -237,7 +237,7 @@ public sealed class PackageCoordinator(
|
||||
if (platte.AvailableFreeSpace >= gebraucht) return;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Auf dem Kratzplatz ({pfade.Transcode}) sind noch " +
|
||||
$"Auf dem Transcode-Cache ({pfade.Transcode}) sind noch " +
|
||||
$"{platte.AvailableFreeSpace / (1024 * 1024)} MB frei, gebraucht werden etwa " +
|
||||
$"{gebraucht / (1024 * 1024)} MB. Räume dort auf oder hänge ein grösseres Volume ein.");
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ builder.Services.AddRateLimiter(o =>
|
||||
// und lässt einen Absturz dort den Server nicht mitnehmen.
|
||||
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
|
||||
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
|
||||
builder.Services.AddSingleton<Schluesselbilder>();
|
||||
builder.Services.AddSingleton<Keyframes>();
|
||||
builder.Services.AddSingleton<FfmpegRemuxer>();
|
||||
|
||||
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
|
||||
@@ -91,6 +91,7 @@ builder.Services.AddScoped<Uebersetzer>();
|
||||
// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann.
|
||||
builder.Services.AddScoped<ITmdbSettingsSource, DatabaseTmdbSettings>();
|
||||
builder.Services.AddScoped<Wiedergabeeinstellungen>();
|
||||
builder.Services.AddScoped<Saisoneinstellungen>();
|
||||
builder.Services.AddHttpClient<TmdbClient>(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(TmdbClient.BaseAddress);
|
||||
@@ -98,6 +99,70 @@ builder.Services.AddHttpClient<TmdbClient>(c =>
|
||||
c.Timeout = TimeSpan.FromSeconds(20);
|
||||
});
|
||||
builder.Services.AddScoped<IMetadataProvider>(sp => sp.GetRequiredService<TmdbClient>());
|
||||
|
||||
// **Eine zweite Bildquelle, und ausdrücklich keine zweite Metadatenquelle.**
|
||||
// fanart.tv liefert Plakate mit Sprachangabe — genau das, was TMDB für
|
||||
// manche Filme nicht hat. Titel, Beschreibung und Besetzung kommen
|
||||
// weiterhin von einer Stelle; zwei Anbieter für dieselbe Angabe wären zwei
|
||||
// Wahrheiten.
|
||||
builder.Services.AddScoped<IFanartSettingsSource, DatabaseFanartSettings>();
|
||||
builder.Services.AddHttpClient<FanartClient>(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(FanartClient.BaseAddress);
|
||||
c.Timeout = TimeSpan.FromSeconds(20);
|
||||
});
|
||||
builder.Services.AddScoped<IBildquelle>(sp => sp.GetRequiredService<FanartClient>());
|
||||
|
||||
// **Und eine für Bewertungen.** IMDbs eigene Schnittstelle kostet Geld,
|
||||
// Rotten Tomatoes vergibt Zugänge nur an Partner, Letterboxd hat gar keine
|
||||
// offene — OMDb liefert alle drei über die IMDb-Kennung, die ohnehin schon
|
||||
// an jedem Film steht.
|
||||
builder.Services.AddScoped<IOmdbSettingsSource, DatabaseOmdbSettings>();
|
||||
builder.Services.AddHttpClient<OmdbClient>(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(OmdbClient.BaseAddress);
|
||||
c.Timeout = TimeSpan.FromSeconds(20);
|
||||
});
|
||||
|
||||
// **Rotten Tomatoes dazu, aber nur auf Wunsch.** Dort gibt es keine
|
||||
// Schnittstelle — die beiden Werte stehen auf der Seite selbst, und OMDb
|
||||
// gibt davon nur den der Kritik weiter. Das Auslesen einer fremden Webseite
|
||||
// bricht, wenn dort umgebaut wird; deshalb ein Schalter, und der steht aus.
|
||||
builder.Services.AddScoped<IRottenTomatoesSettingsSource, DatabaseRottenTomatoesSettings>();
|
||||
|
||||
// **Eine Anfrage je Sekunde, prozessweit.** Achthundert Titel in einem
|
||||
// Schwall von einer Adresse wären eine Zumutung und würden gesperrt. Der
|
||||
// Scan dauert dadurch länger; das ist der Preis fürs Mitbenutzen.
|
||||
builder.Services.AddSingleton(new Anklopfbremse(TimeSpan.FromSeconds(1)));
|
||||
builder.Services.AddHttpClient<RottenTomatoesClient>(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(RottenTomatoesClient.BaseAddress);
|
||||
c.Timeout = TimeSpan.FromSeconds(20);
|
||||
// Ohne Browserkennung antwortet die Seite mit einer Sperrseite statt
|
||||
// mit dem Film.
|
||||
c.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
+ "Chrome/131.0.0.0 Safari/537.36");
|
||||
});
|
||||
|
||||
// **Die Reihenfolge ist die Rangfolge.** Beide führen den Wert der Kritik;
|
||||
// OMDb aus zweiter Hand, Rotten Tomatoes als Quelle selbst. Wer hinten
|
||||
// steht, überschreibt.
|
||||
builder.Services.AddScoped<IBewertungsquelle>(sp => new Bewertungsquellen(
|
||||
[
|
||||
sp.GetRequiredService<OmdbClient>(),
|
||||
sp.GetRequiredService<RottenTomatoesClient>(),
|
||||
]));
|
||||
|
||||
// **Damit man sieht, ob ein Schlüssel wirkt.** „Hinterlegt" ist keine
|
||||
// Auskunft darüber, ob der Dienst antwortet.
|
||||
builder.Services.AddScoped<Quellenprobe>();
|
||||
|
||||
// **Damit ein festgehaltenes Ergebnis nicht ewig falsch bleibt.** Ob ein
|
||||
// Titel in „Fortsetzen" steht, ist eine gespeicherte Spalte — berechnet
|
||||
// wird sie nur, wenn eine Fortschrittsmeldung eintrifft. Nach einem Update
|
||||
// einmal durchrechnen fängt genau das ab.
|
||||
builder.Services.AddSingleton<Sehstandpflege>();
|
||||
builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache));
|
||||
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
|
||||
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
|
||||
@@ -105,6 +170,13 @@ builder.Services.AddScoped<IImageReader>(sp => sp.GetRequiredService<ImageCache>
|
||||
builder.Services.AddScoped<MetadataEnricher>();
|
||||
builder.Services.AddScoped<MatchApplier>();
|
||||
builder.Services.AddSingleton<ScanCoordinator>();
|
||||
// Vorspann und Abspann: der Leser ist zustandslos, die Erkennung hält den
|
||||
// Vermerk, welche Serie gerade abgetastet wird.
|
||||
builder.Services.AddSingleton<Fingerprintleser>();
|
||||
// Bei Filmen führt kein Ton zum Abspann — nur das Bild.
|
||||
builder.Services.AddSingleton<Frameabtaster>();
|
||||
builder.Services.AddSingleton<Kapitelleser>();
|
||||
builder.Services.AddSingleton<Segmenterkennung>();
|
||||
builder.Services.AddSingleton<PackageCoordinator>();
|
||||
|
||||
// **Neben dem Umpacker, nicht an seiner Stelle.** Der baut eine fertige Datei
|
||||
@@ -112,9 +184,11 @@ builder.Services.AddSingleton<PackageCoordinator>();
|
||||
builder.Services.AddSingleton<Streamkoordinator>();
|
||||
builder.Services.AddSingleton<UntertitelLeser>();
|
||||
builder.Services.AddSingleton<Untertitelvorrat>();
|
||||
// Der Kratzplatz räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
|
||||
builder.Services.AddScoped<Kratzplatzeinstellungen>();
|
||||
builder.Services.AddSingleton<Kratzplatzpflege>();
|
||||
builder.Services.AddSingleton<Vorschaubilder>();
|
||||
builder.Services.AddSingleton<Vorschauvorrat>();
|
||||
// Der Transcode-Cache räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
|
||||
builder.Services.AddScoped<Transcodeeinstellungen>();
|
||||
builder.Services.AddSingleton<Transcodepflege>();
|
||||
|
||||
// **Der einzige Hintergrunddienst, und mit Bedacht.** Eine Sicherung hängt
|
||||
// an keinem Aufruf: sie soll alle drei Tage geschehen, auch wenn niemand
|
||||
@@ -125,7 +199,20 @@ builder.Services.AddHostedService(sp => sp.GetRequiredService<Wartungsplan>());
|
||||
// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage
|
||||
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
|
||||
builder.Services.AddSingleton<PlaybackSessions>();
|
||||
builder.Services.AddSingleton<Tagebuch>();
|
||||
// **Von Hand gebaut, nicht aus dem Behälter geholt.** Die Protokollsenke
|
||||
// braucht ihn, bevor der Behälter steht; ein `BuildServiceProvider()` dafür
|
||||
// legte eine zweite Ausgabe an — zwei Tagebücher mit zwei Schlössern, die
|
||||
// sich beim Schreiben ins Gehege kommen.
|
||||
var tagebuch = new Tagebuch(pfade, TimeProvider.System);
|
||||
builder.Services.AddSingleton(tagebuch);
|
||||
|
||||
// **Was der Server ohnehin meldet, gehört auch ins Tagesprotokoll.**
|
||||
//
|
||||
// „Scan gestartet", „Strom angelegt", „Untertitel gezogen" standen längst
|
||||
// im Quelltext — sie gingen nur an die Container-Ausgabe, wo sie zwischen
|
||||
// den EF-Abfragen untergehen. Die Senke nimmt nur Flaros eigene Kategorien;
|
||||
// `Microsoft.*` bliebe sonst mit einer Zeile je Datenbankabfrage darin.
|
||||
builder.Logging.AddProvider(new Tagebuchsenke(tagebuch));
|
||||
|
||||
// **Die Schlüssel der Datenverschlüsselung gehören ins Volume.**
|
||||
//
|
||||
@@ -182,6 +269,28 @@ using (var bereich = app.Services.CreateScope())
|
||||
// sollte. Hier bekommt jede Ausnahme, die es bis nach oben schafft, einen
|
||||
// deutschen Satz; die Einzelheiten gehen ins Tagesprotokoll, wo man sie
|
||||
// sucht.
|
||||
// **Jede Fünfhunderter-Antwort wird vermerkt, egal woher sie kommt.**
|
||||
//
|
||||
// Der Haken darunter fängt nur Ausnahmen. Ein Endpunkt, der von sich aus
|
||||
// `Results.Problem(... 500)` zurückgibt, läuft daran vorbei — und genau so
|
||||
// eine Antwort stand in der App („der Server hat den Download mit Fehler
|
||||
// 500 abgelehnt"), während im Protokoll nichts davon zu finden war. Eine
|
||||
// Fehlermeldung, die der Benutzer sieht und der Betreiber nicht, ist eine
|
||||
// Sackgasse; dieselbe Lehre wie beim ffmpeg-Fehler vor ein paar Tagen.
|
||||
app.Use(async (zusammenhang, weiter) =>
|
||||
{
|
||||
await weiter(zusammenhang);
|
||||
|
||||
if (zusammenhang.Response.StatusCode >= 500)
|
||||
{
|
||||
zusammenhang.RequestServices.GetRequiredService<Tagebuch>().Schreiben(
|
||||
$"{zusammenhang.Response.StatusCode} auf "
|
||||
+ $"{zusammenhang.Request.Method} {zusammenhang.Request.Path}"
|
||||
+ zusammenhang.Request.QueryString,
|
||||
Tagebuch.Art.Fehler);
|
||||
}
|
||||
});
|
||||
|
||||
app.UseExceptionHandler(zweig => zweig.Run(async zusammenhang =>
|
||||
{
|
||||
var fehler = zusammenhang.Features
|
||||
@@ -318,7 +427,6 @@ if (Directory.Exists(oberfläche))
|
||||
// macht den Unterschied zwischen „dauert eben" und „stürzt ab". Steht mitten
|
||||
// im Scan ein Start, weiss man es beim nächsten Blick ins Protokoll.
|
||||
{
|
||||
var tagebuch = app.Services.GetRequiredService<Tagebuch>();
|
||||
var leben = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||
|
||||
leben.ApplicationStarted.Register(() =>
|
||||
@@ -343,8 +451,11 @@ if (Directory.Exists(oberfläche))
|
||||
// Stromordner noch da und jedes Teilstück eines abgebrochenen
|
||||
// Umpackens — und beim Start ist sicher, dass niemand davon noch
|
||||
// gebraucht wird.
|
||||
_ = app.Services.GetRequiredService<Kratzplatzpflege>()
|
||||
.LaufenAsync(Kratzplatzpflege.Anlass.Start, leben.ApplicationStopping);
|
||||
_ = app.Services.GetRequiredService<Transcodepflege>()
|
||||
.LaufenAsync(Transcodepflege.Anlass.Start, leben.ApplicationStopping);
|
||||
|
||||
_ = app.Services.GetRequiredService<Sehstandpflege>()
|
||||
.NachUpdateAsync(leben.ApplicationStopping);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using Flaro.Core.Metadata;
|
||||
using Flaro.Metadata;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Was eine Quelle auf Nachfrage geantwortet hat.</summary>
|
||||
///
|
||||
/// <param name="Source">„tmdb", „fanart" oder „omdb".</param>
|
||||
/// <param name="Configured">Ob überhaupt ein Zugang hinterlegt ist.</param>
|
||||
/// <param name="Ok">Ob die Probe etwas Brauchbares ergeben hat.</param>
|
||||
/// <param name="Detail">Ein Satz, der sagt, was herauskam.</param>
|
||||
public sealed record Quellenbefund(string Source, bool Configured, bool Ok, string Detail);
|
||||
|
||||
/// <summary>Fragt die Quellen einmal und sagt, was zurückkam.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Der Anlass.</b> Nach dem Eintragen eines Schlüssels stand in der
|
||||
/// Oberfläche „Hinterlegt" — und das war alles. Ob er stimmt, ob der Dienst
|
||||
/// antwortet und ob zu einem Film überhaupt etwas zu holen ist, liess sich
|
||||
/// nicht feststellen; man trug ein, frischte die Metadaten über
|
||||
/// achthundert Titel auf und sah danach nach, ob sich etwas geändert hatte.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Mit einem bekannten Film, nicht mit einem aus der Mediathek.</b> „The
|
||||
/// Matrix" hat bei allen drei Quellen Einträge; ein Film aus dem eigenen
|
||||
/// Bestand könnte schlicht unbekannt sein, und dann wüsste man wieder
|
||||
/// nicht, ob es am Schlüssel liegt oder am Film.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Quellenprobe(
|
||||
TmdbClient tmdb,
|
||||
FanartClient fanart,
|
||||
OmdbClient omdb,
|
||||
RottenTomatoesClient tomaten,
|
||||
ITmdbSettingsSource spracheVon)
|
||||
{
|
||||
/// <summary>„The Matrix" — bei allen Quellen vorhanden.</summary>
|
||||
private const int ProbeTmdbId = 603;
|
||||
private const string ProbeImdbId = "tt0133093";
|
||||
private const string ProbeTitel = "The Matrix";
|
||||
private const int ProbeJahr = 1999;
|
||||
|
||||
public async Task<IReadOnlyList<Quellenbefund>> LaufenAsync(CancellationToken ct = default) =>
|
||||
[
|
||||
await TmdbProbe(ct),
|
||||
await FanartProbe(ct),
|
||||
await OmdbProbe(ct),
|
||||
await TomatenProbe(ct),
|
||||
];
|
||||
|
||||
private async Task<Quellenbefund> TmdbProbe(CancellationToken ct)
|
||||
{
|
||||
if (!await tmdb.IsConfiguredAsync(ct))
|
||||
return new("tmdb", false, false, "Kein Zugang hinterlegt.");
|
||||
|
||||
var film = await tmdb.FindMovieAsync("The Matrix", 1999, ct);
|
||||
|
||||
return film is null
|
||||
? new("tmdb", true, false,
|
||||
"Der Dienst hat nicht geantwortet oder den Zugang abgelehnt.")
|
||||
: new("tmdb", true, true,
|
||||
$"Erreichbar — Probeabruf ergab {film.Title} ({film.Year}).");
|
||||
}
|
||||
|
||||
private async Task<Quellenbefund> FanartProbe(CancellationToken ct)
|
||||
{
|
||||
if (!await fanart.EingerichtetAsync(ct))
|
||||
return new("fanart", false, false, "Kein Schlüssel hinterlegt — die Quelle ist aus.");
|
||||
|
||||
var bilder = await fanart.FilmbilderAsync(ProbeTmdbId, ct);
|
||||
var plakate = bilder.Where(b => b.Kind == "poster").ToList();
|
||||
|
||||
if (plakate.Count == 0)
|
||||
{
|
||||
return new("fanart", true, false,
|
||||
"Erreicht, aber nichts bekommen. Stimmt der Schlüssel?");
|
||||
}
|
||||
|
||||
// **Die Zahl in der eingestellten Sprache ist die eigentliche
|
||||
// Auskunft.** „42 Plakate" sagt nur, dass etwas kam; „davon 3
|
||||
// deutsche" sagt, ob die Quelle den Zweck erfüllt, für den sie da ist.
|
||||
var sprache = (await spracheVon.GetAsync(ct)).Language.Split('-')[0];
|
||||
var passend = plakate.Count(b =>
|
||||
string.Equals(b.Language, sprache, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return new("fanart", true, true,
|
||||
$"Erreichbar — {plakate.Count} Plakate zu The Matrix, davon {passend} in {sprache}.");
|
||||
}
|
||||
|
||||
private async Task<Quellenbefund> OmdbProbe(CancellationToken ct)
|
||||
{
|
||||
if (!await omdb.EingerichtetAsync(ct))
|
||||
return new("omdb", false, false, "Kein Schlüssel hinterlegt — die Quelle ist aus.");
|
||||
|
||||
var wertungen = await omdb.HolenAsync(
|
||||
new Wertungsfrage(ProbeImdbId, ProbeTitel, ProbeJahr), ct);
|
||||
|
||||
if (wertungen.Count == 0)
|
||||
{
|
||||
return new("omdb", true, false,
|
||||
"Erreicht, aber nichts bekommen. Stimmt der Schlüssel, "
|
||||
+ "oder ist das Tageskontingent aufgebraucht?");
|
||||
}
|
||||
|
||||
return new("omdb", true, true, $"Erreichbar — zu The Matrix: {Aufzaehlung(wertungen)}.");
|
||||
}
|
||||
|
||||
/// <summary>Die Seite von Rotten Tomatoes, falls das Lesen erlaubt wurde.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Hier ist die Probe mehr als eine Höflichkeit.</b> Bei den anderen
|
||||
/// beantwortet sie die Frage, ob ein Schlüssel stimmt; hier beantwortet
|
||||
/// sie die Frage, ob das Auslesen überhaupt noch geht. Wird die Seite
|
||||
/// umgebaut oder der Zugriff gesperrt, merkt man es genau hier — und
|
||||
/// sonst nirgends.
|
||||
/// </remarks>
|
||||
private async Task<Quellenbefund> TomatenProbe(CancellationToken ct)
|
||||
{
|
||||
if (!await tomaten.EingerichtetAsync(ct))
|
||||
return new("rottenTomatoes", false, false, "Ausgeschaltet.");
|
||||
|
||||
var wertungen = await tomaten.HolenAsync(
|
||||
new Wertungsfrage(ProbeImdbId, ProbeTitel, ProbeJahr), ct);
|
||||
|
||||
if (wertungen.Count == 0)
|
||||
{
|
||||
return new("rottenTomatoes", true, false,
|
||||
"Nichts bekommen. Entweder ist der Zugriff gesperrt, "
|
||||
+ "oder die Seite ist umgebaut worden.");
|
||||
}
|
||||
|
||||
return new("rottenTomatoes", true, true,
|
||||
$"Erreichbar — zu The Matrix: {Aufzaehlung(wertungen)}.");
|
||||
}
|
||||
|
||||
private static string Aufzaehlung(IEnumerable<Fremdwertung> wertungen) =>
|
||||
string.Join(", ", wertungen.Select(w => $"{Kurz(w.Quelle)} {w.Anzeige}"));
|
||||
|
||||
private static string Kurz(string quelle) => quelle switch
|
||||
{
|
||||
"imdb" => "IMDb",
|
||||
"rottenTomatoes" => "RT Kritik",
|
||||
"rottenTomatoesAudience" => "RT Publikum",
|
||||
"metacritic" => "MC",
|
||||
_ => quelle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json;
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Die saisonalen Anlässe, aus den Einstellungen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Als JSON in einer Zeile und nicht als Tabelle.</b> Das hier ist
|
||||
/// Konfiguration, kein Bestand: eine Handvoll Einträge, die zusammen
|
||||
/// gelesen und zusammen geschrieben werden. Eine Tabelle dafür hiesse eine
|
||||
/// Migration für etwas, das niemand einzeln abfragt.
|
||||
/// </remarks>
|
||||
public sealed class Saisoneinstellungen(FlaroContext db)
|
||||
{
|
||||
public const string Schluessel = "startseite.saisonal.anlaesse";
|
||||
|
||||
private IReadOnlyList<Saisonanlass>? _gemerkt;
|
||||
|
||||
public async Task<IReadOnlyList<Saisonanlass>> AnlaesseAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_gemerkt is { } fertig) return fertig;
|
||||
|
||||
var zeile = await db.Settings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Key == Schluessel, ct);
|
||||
|
||||
return _gemerkt = Lesen(zeile?.Value);
|
||||
}
|
||||
|
||||
/// <summary>Liest die gespeicherte Liste — oder gibt die Vorgabe zurück.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Unlesbares ist kein Grund, die Startseite zu verlieren.</b> Steht
|
||||
/// dort Unsinn, gilt die Vorgabe; eine Ausnahme hier nähme dem Server
|
||||
/// die ganze Reihe und nicht nur den falschen Eintrag.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<Saisonanlass> Lesen(string? roh)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(roh)) return Saisonanlass.Vorgabe;
|
||||
|
||||
try
|
||||
{
|
||||
var gelesen = JsonSerializer.Deserialize<List<Saisonanlass>>(
|
||||
roh, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
// Eine leere Liste ist eine Entscheidung — „keine saisonalen
|
||||
// Reihen" — und nicht dasselbe wie „nichts eingestellt".
|
||||
return gelesen is null ? Saisonanlass.Vorgabe : gelesen.Where(a => a.Brauchbar).ToList();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Saisonanlass.Vorgabe;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Schreiben(IEnumerable<Saisonanlass> anlaesse) =>
|
||||
JsonSerializer.Serialize(
|
||||
anlaesse.Where(a => a.Brauchbar).ToList(),
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
}
|
||||
@@ -99,7 +99,15 @@ public sealed class ScanCoordinator(
|
||||
var tagebuch = bereich.ServiceProvider.GetRequiredService<Tagebuch>();
|
||||
var melder = new Progress<Scanschritt>(schritt =>
|
||||
{
|
||||
tagebuch.Schreiben(schritt.Text());
|
||||
// **Nicht jede Zeile ins Protokoll.**
|
||||
//
|
||||
// „Hole Metadaten zu Phineas und Ferb 4×23 — 1334 von 1347"
|
||||
// ist Fortschritt, keine Auskunft: ein Auffrischen schrieb
|
||||
// vierzehnhundert solche Zeilen und schob alles andere aus
|
||||
// der Ansicht. Was bleibt, sind die Vermerke — sie erklären,
|
||||
// warum etwas so zugeordnet wurde — und die Zusammenfassung
|
||||
// am Ende.
|
||||
if (schritt.Phase == Scanphase.Vermerk) tagebuch.Schreiben(schritt.Text());
|
||||
|
||||
// **Vermerke stehen nur in der Datei.** Sie erklären, warum
|
||||
// etwas so zugeordnet wurde — in der Fortschrittszeile wäre das
|
||||
@@ -164,8 +172,16 @@ public sealed class ScanCoordinator(
|
||||
// Verwaiste entstehen: Dateien verschwinden, Änderungszeiten
|
||||
// wandern, und die dazugehörigen Pakete und Untertitel gehören
|
||||
// zu einem Stand, den es nicht mehr gibt.
|
||||
_ = bereich.ServiceProvider.GetRequiredService<Kratzplatzpflege>()
|
||||
.LaufenAsync(Kratzplatzpflege.Anlass.NachDemScan);
|
||||
_ = bereich.ServiceProvider.GetRequiredService<Transcodepflege>()
|
||||
.LaufenAsync(Transcodepflege.Anlass.NachDemScan);
|
||||
|
||||
// **Und zuletzt die Segmente.** Ein Scan ist der Augenblick, in
|
||||
// dem neue Folgen dazukommen — und eine Folge ohne Vorspann-
|
||||
// Segment bekommt nie eine, wenn niemand danach fragt. Der Lauf
|
||||
// hängt sich hinten an und nimmt sich Serie für Serie vor;
|
||||
// gerechnet wird nur, wo noch nichts steht.
|
||||
_ = bereich.ServiceProvider.GetRequiredService<Segmenterkennung>()
|
||||
.NachScanAsync(libraryId);
|
||||
|
||||
_zustand[libraryId] = _zustand[libraryId] with
|
||||
{
|
||||
|
||||
@@ -0,0 +1,988 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Core.Media;
|
||||
using Flaro.Data;
|
||||
using Flaro.Media;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Wie weit ein Segmentlauf greift.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>„Schon durchsucht" und „hat ein Segment" sind zwei verschiedene
|
||||
/// Dinge</b>, und genau dazwischen fehlte bisher die Unterscheidung. Ein
|
||||
/// Film, bei dem die Suche einmal nichts fand, trägt einen Vermerk und
|
||||
/// kein Segment: der nächste Lauf übersprang ihn, obwohl er der
|
||||
/// interessanteste Fall ist.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nach jeder Verbesserung an der Erkennung will man genau diese Titel
|
||||
/// noch einmal — und eben nicht die vierhundert, bei denen längst etwas
|
||||
/// steht.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum Segmentumfang
|
||||
{
|
||||
/// <summary>Nur, wo noch nie gesucht wurde. Der Lauf nach einem Scan.</summary>
|
||||
Neue,
|
||||
|
||||
/// <summary>
|
||||
/// Auch, wo gesucht und nichts gefunden wurde — aber nicht, wo schon
|
||||
/// ein Segment steht.
|
||||
/// </summary>
|
||||
Luecken,
|
||||
|
||||
/// <summary>Alles, auch was längst ein Segment hat.</summary>
|
||||
Alles,
|
||||
}
|
||||
|
||||
/// <summary>Sucht Vorspann und Abspann einer Staffel.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Beides mit derselben Rechnung, nur in einem anderen Fenster.</b>
|
||||
/// <see cref="Fingerprintabgleich"/> sucht das längste Stück, das zwei Tonabdrücke
|
||||
/// teilen — was dabei herauskommt, entscheidet allein, welchen Ausschnitt
|
||||
/// der Folge man ihm gibt. Vorn ist es der Vorspann, hinten der Abspann.
|
||||
/// Eine zweite Erkennung dafür zu schreiben hiesse, dieselbe Suche zweimal
|
||||
/// zu haben.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Je Staffel, nicht je Serie.</b> Serien wechseln ihren Vorspann
|
||||
/// zwischen Staffeln — neue Besetzung, neues Lied. Über alle Staffeln
|
||||
/// verglichen fände man entweder nichts oder das Falsche.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Im Hintergrund, mit einem Zustand zum Nachsehen.</b> Eine Staffel
|
||||
/// abzutasten dauert Minuten; das in einer Anfrage zu tun hiesse, auf ein
|
||||
/// Zeitlimit zu laufen und am Ende nichts zu haben.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Segmenterkennung(
|
||||
IServiceScopeFactory scopes,
|
||||
TimeProvider clock,
|
||||
ILogger<Segmenterkennung> log)
|
||||
{
|
||||
/// <summary>Wie viel vom Anfang nach einem Vorspann abgesucht wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Fünfzehn Minuten. Ein Vorspann liegt fast immer in den ersten fünf;
|
||||
/// manche Serie stellt aber eine Viertelstunde Handlung davor. Weiter
|
||||
/// zu suchen kostet Zeit und schafft vor allem Gelegenheiten, zwei
|
||||
/// zufällig ähnliche Takte für einen Vorspann zu halten.
|
||||
/// </remarks>
|
||||
public const double Introfenster = 15 * 60;
|
||||
|
||||
/// <summary>Wie viel vom Ende nach einem Abspann abgesucht wird.</summary>
|
||||
public const double Abspannfenster = 10 * 60;
|
||||
|
||||
/// <summary>Wie weit vom Ende eines Films nach dem Abspann gesucht wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Zwanzig Minuten. „Die letzten zehn Prozent" wäre bei einem
|
||||
/// Dreistünder zu viel und bei einem Kurzfilm zu wenig.
|
||||
/// </remarks>
|
||||
public const double Suchbereich = 20 * 60;
|
||||
|
||||
/// <summary>Was ein gefundenes Stück höchstens lang sein darf.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die wichtigste Schranke.</b> Zwei Folgen, die dieselbe Datei
|
||||
/// zweimal enthalten — ein Scanfehler —, teilen vierzig Minuten. Ohne
|
||||
/// Obergrenze stünde dann ein Überspringen-Knopf über dem halben Film.
|
||||
/// </remarks>
|
||||
public const double Hoechstlaenge = 5 * 60;
|
||||
|
||||
/// <summary>Dasselbe für den Abspann — und deutlich grosszügiger.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Fünf Minuten sind für einen Abspann keine Obergrenze, sondern
|
||||
/// die Regel.</b> Eine deutsche Serienproduktion listet Sprecher,
|
||||
/// Synchronregie und Studio hinterher; sechs Minuten kommen vor. Mit
|
||||
/// derselben Schranke wie beim Vorspann wurde der Fund gemacht und
|
||||
/// gleich wieder weggeworfen — gefunden, verworfen, und am Ende stand
|
||||
/// kein Knopf da, obwohl die Erkennung gearbeitet hatte.
|
||||
///
|
||||
/// Weiter als das Suchfenster kann ein Segment ohnehin nicht reichen;
|
||||
/// mehr als dieses Fenster zu erlauben hiesse nichts zu erlauben.
|
||||
/// </remarks>
|
||||
public const double Hoechstabspann = Abspannfenster;
|
||||
|
||||
/// <summary>Welche Staffeln gerade abgetastet werden.</summary>
|
||||
private readonly ConcurrentDictionary<Guid, bool> _laufend = new();
|
||||
|
||||
public bool Laeuft(Guid serieId) => _laufend.ContainsKey(serieId);
|
||||
|
||||
/// <summary>Woran der Lauf je Mediathek gerade ist — für die Verwaltung.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Ein eigener Zustand, nicht der des Scans.</b> Der
|
||||
/// <c>ScanCoordinator</c> setzt seinen Lauf auf „fertig", sobald er die
|
||||
/// Segmenterkennung angestossen hat — in dasselbe Feld zu schreiben
|
||||
/// liesse einen beendeten Scan wieder laufend aussehen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nur der letzte Schritt</b>, wie bei <c>ScanState.Schritt</c>: eine
|
||||
/// Liste über viertausend Folgen wäre ein Leck, und wer die
|
||||
/// Vorgeschichte braucht, liest das Protokoll.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private readonly ConcurrentDictionary<Guid, Scanschritt> _stand = new();
|
||||
|
||||
public Scanschritt? Stand(Guid mediathek) =>
|
||||
_stand.TryGetValue(mediathek, out var schritt) ? schritt : null;
|
||||
|
||||
/// <summary>Startet den Lauf für eine Serie — oder sagt, dass schon einer läuft.</summary>
|
||||
public bool Starten(Guid serieId, Segmentumfang umfang = Segmentumfang.Neue)
|
||||
{
|
||||
if (!_laufend.TryAdd(serieId, true)) return false;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await LaufenAsync(serieId, umfang, CancellationToken.None);
|
||||
}
|
||||
catch (Exception fehler)
|
||||
{
|
||||
log.LogWarning(fehler, "Segmentsuche für {Serie} abgebrochen: {Grund}",
|
||||
serieId, Fehlertext.FuerMenschen(fehler));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_laufend.TryRemove(serieId, out _);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Nach einem Scan: jede Serie der Mediathek, der noch Segmente fehlen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Hier, weil es sonst nie passiert.</b> Eine neue Folge bekommt
|
||||
/// kein Segment, wenn niemand danach fragt — und niemand fragt, weil man
|
||||
/// nicht weiss, dass es etwas zu fragen gibt. Der Scan ist der
|
||||
/// Augenblick, in dem neue Folgen entstehen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Eine nach der anderen, nicht alle zugleich.</b> Jede liest von
|
||||
/// jeder ihrer Folgen ein Stück Ton; zwanzig Serien gleichzeitig hiesse
|
||||
/// zwanzig Leseköpfe auf derselben Platte. Beim ersten Mal über einen
|
||||
/// grossen Bestand dauert das entsprechend — danach findet der Lauf
|
||||
/// nichts mehr zu tun und ist in Sekunden vorbei.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task NachScanAsync(
|
||||
Guid mediathek, Segmentumfang umfang = Segmentumfang.Neue,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
var leser = raum.ServiceProvider.GetRequiredService<Fingerprintleser>();
|
||||
|
||||
var mediathekname = await db.Libraries.AsNoTracking()
|
||||
.Where(x => x.Id == mediathek).Select(x => x.Name).FirstOrDefaultAsync(ct)
|
||||
?? "Mediathek";
|
||||
|
||||
var serien = await db.Series.AsNoTracking()
|
||||
.Where(x => x.LibraryId == mediathek)
|
||||
.Select(x => new { x.Id, x.Title })
|
||||
.OrderBy(x => x.Title)
|
||||
.ToListAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// **Ohne fpcalc nur die Serien überspringen, nicht alles.** Der
|
||||
// Bildweg braucht es nicht; ein Server ohne fpcalc konnte früher
|
||||
// trotzdem keinen einzigen Filmabspann finden, weil diese
|
||||
// Prüfung ganz oben stand und für beides galt.
|
||||
if (serien.Count > 0 && await leser.VorhandenAsync(ct))
|
||||
{
|
||||
await SerienAsync(db, mediathekname, serien
|
||||
.Select(s => (s.Id, s.Title)).ToList(), mediathek, umfang, ct);
|
||||
}
|
||||
else if (serien.Count > 0)
|
||||
{
|
||||
log.LogInformation(
|
||||
"Kein fpcalc vorhanden — in „{Mediathek}“ bleibt der Vorspann ungesucht.",
|
||||
mediathekname);
|
||||
}
|
||||
|
||||
// **Nicht mehr hinter einem `return` für serienlose Mediatheken.**
|
||||
// Stand hier früher `if (serien.Count == 0) return;`, und damit
|
||||
// kam eine reine Filmmediathek nie bis zu dieser Zeile: der
|
||||
// Abspann eines Films wurde nach einem Scan nie gesucht.
|
||||
await FilmeAsync(db, mediathekname, mediathek, umfang, ct,
|
||||
schritt => _stand[mediathek] = schritt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_stand.TryRemove(mediathek, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Alle Serien einer Mediathek, eine nach der anderen.</summary>
|
||||
private async Task SerienAsync(
|
||||
FlaroContext db, string mediathekname, IReadOnlyList<(Guid Id, string Titel)> serien,
|
||||
Guid mediathek, Segmentumfang umfang, CancellationToken ct)
|
||||
{
|
||||
var folgen = await db.Episodes.AsNoTracking()
|
||||
.CountAsync(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek), ct);
|
||||
|
||||
log.LogInformation(
|
||||
"Segmentsuche in „{Mediathek}“: {Serien} Serie(n), {Folgen} Folge(n). "
|
||||
+ "Das kann Stunden dauern.", mediathekname, serien.Count, folgen);
|
||||
|
||||
var segmente = 0;
|
||||
var ohneFund = 0;
|
||||
var angefasst = 0;
|
||||
|
||||
for (var i = 0; i < serien.Count; i++)
|
||||
{
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
var (id, titel) = serien[i];
|
||||
var nummer = i + 1;
|
||||
|
||||
// Der Zähler gehört der Mediathek, der Name der Folge: so steht
|
||||
// in der Zeile beides — wie weit, und woran gerade.
|
||||
void Melden(Scanschritt schritt) =>
|
||||
_stand[mediathek] = schritt with { Erledigt = nummer, Gesamt = serien.Count };
|
||||
|
||||
Melden(new Scanschritt(Scanphase.Segmente, titel));
|
||||
|
||||
if (!_laufend.TryAdd(id, true)) continue;
|
||||
try
|
||||
{
|
||||
var stand = await LaufenAsync(id, umfang, ct, Melden);
|
||||
segmente += stand.Segmente;
|
||||
if (!stand.Angefasst) continue;
|
||||
angefasst++;
|
||||
if (stand.Segmente == 0) ohneFund++;
|
||||
}
|
||||
catch (Exception fehler)
|
||||
{
|
||||
// **Mit dem innersten Text.** Das Tagebuch hängt nur
|
||||
// `Message` an, und bei EF Core steht dort „See the inner
|
||||
// exception for details." — eine Zeile, die ausdrücklich
|
||||
// sagt, dass sie die Auskunft nicht enthält.
|
||||
log.LogWarning(fehler, "Segmentsuche für „{Serie}“ abgebrochen: {Grund}",
|
||||
titel, Fehlertext.FuerMenschen(fehler));
|
||||
}
|
||||
finally { _laufend.TryRemove(id, out _); }
|
||||
}
|
||||
|
||||
log.LogInformation(
|
||||
"Segmentsuche in „{Mediathek}“ durch: {Segmente} Segment(n) in {Angefasst} von "
|
||||
+ "{Serien} Serie(n) gesucht, {Ohne} ohne Fund.",
|
||||
mediathekname, segmente, angefasst, serien.Count, ohneFund);
|
||||
}
|
||||
|
||||
/// <summary>Startet den Lauf für eine ganze Mediathek — im Hintergrund.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Knopf darf nicht warten.</b> Über fünfundneunzig Serien
|
||||
/// dauert das Stunden; eine Anfrage so lange offen zu halten hiesse,
|
||||
/// sie in ein Zeitlimit laufen zu lassen. Wie es steht, sieht man im
|
||||
/// Tagesprotokoll — und an den Folgen selbst, bei denen danach eine
|
||||
/// Segment steht.
|
||||
/// </remarks>
|
||||
public void MediathekStarten(Guid mediathek, Segmentumfang umfang)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await NachScanAsync(mediathek, umfang, CancellationToken.None); }
|
||||
catch (Exception fehler)
|
||||
{
|
||||
log.LogWarning(fehler, "Segmentlauf für Mediathek {Kennung} abgebrochen: {Grund}",
|
||||
mediathek, Fehlertext.FuerMenschen(fehler));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Die Filme einer Mediathek, denen noch ein Abspann fehlt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Getrennt von den Serien, weil der Weg ein anderer ist — und weil
|
||||
/// eine Mediathek immer nur eines von beidem enthält. Die Schleife
|
||||
/// läuft dann ins Leere, und das kostet eine Abfrage.
|
||||
/// </remarks>
|
||||
private async Task FilmeAsync(
|
||||
FlaroContext db, string mediathekname, Guid mediathek, Segmentumfang umfang,
|
||||
CancellationToken ct, Action<Scanschritt> melden)
|
||||
{
|
||||
// **In C# verzweigt, nicht im Ausdruck.** Drei Bedingungen in einem
|
||||
// `Where` mit einem Aufzählungswert übersetzt EF Core zwar meist,
|
||||
// aber „meist" ist hier kein Argument: so ist sicher, was in SQL
|
||||
// ankommt.
|
||||
var abfrage = db.Movies.AsNoTracking().Where(m => m.LibraryId == mediathek);
|
||||
abfrage = umfang switch
|
||||
{
|
||||
Segmentumfang.Alles => abfrage,
|
||||
// Die Lücke: gesucht wurde vielleicht, gefunden nichts.
|
||||
Segmentumfang.Luecken => abfrage.Where(m =>
|
||||
!db.Segmente.Any(x => x.MovieId == m.Id && x.Art == Segmentart.Abspann)),
|
||||
_ => abfrage.Where(m =>
|
||||
!db.Segmentsuchen.Any(x => x.MovieId == m.Id && x.Art == Segmentart.Abspann)),
|
||||
};
|
||||
|
||||
var offen = await abfrage
|
||||
.Select(m => new { m.Id, m.Title, m.Year })
|
||||
.OrderBy(m => m.Title)
|
||||
.ToListAsync(ct);
|
||||
if (offen.Count == 0) return;
|
||||
|
||||
log.LogInformation("Abspannsuche in „{Mediathek}“: {Anzahl} Film(e).",
|
||||
mediathekname, offen.Count);
|
||||
|
||||
var gefunden = 0;
|
||||
for (var i = 0; i < offen.Count; i++)
|
||||
{
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
var film = offen[i];
|
||||
var titel = Filmname(film.Title, film.Year);
|
||||
melden(new Scanschritt(Scanphase.Segmente, titel, i + 1, offen.Count));
|
||||
|
||||
try { if (await FilmAsync(film.Id, umfang, ct)) gefunden++; }
|
||||
catch (Exception fehler)
|
||||
{
|
||||
log.LogWarning(fehler, "Abspannsuche für „{Film}“ abgebrochen: {Grund}",
|
||||
titel, Fehlertext.FuerMenschen(fehler));
|
||||
}
|
||||
}
|
||||
|
||||
log.LogInformation("Abspann in {Gefunden} von {Anzahl} Film(en) gefunden.",
|
||||
gefunden, offen.Count);
|
||||
}
|
||||
|
||||
/// <summary>„Arrival (2016)" — der Name, unter dem ein Film im Protokoll steht.</summary>
|
||||
private static string Filmname(string? titel, int? jahr)
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(titel) ? "Ohne Titel" : titel;
|
||||
return jahr is { } j ? $"{name} ({j})" : name;
|
||||
}
|
||||
|
||||
/// <summary>Sucht den Abspann eines Films — im Bild.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum hier ein anderer Weg.</b> Bei einer Folge verrät sich der
|
||||
/// Abspann dadurch, dass er in jeder Folge derselbe ist. Ein Film hat
|
||||
/// nichts, womit er sich vergleichen liesse — sein Abspann kommt genau
|
||||
/// einmal vor. Was bleibt, ist, dass er dunkel ist und bis zum Ende
|
||||
/// durchhält.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Erst grob, dann zielen.</b> Ein Überblick über die letzten zwanzig
|
||||
/// Minuten sagt, <i>ob</i> es eine dunkle Strecke gibt; er kommt aus
|
||||
/// Keyframesn und ist deshalb billig, aber auf fünf bis zehn
|
||||
/// Sekunden ungenau. Die Kante holt danach eine Handvoll kurzer Sonden.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<bool> FilmAsync(
|
||||
Guid filmId, Segmentumfang umfang, CancellationToken ct = default)
|
||||
{
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
var abtaster = raum.ServiceProvider.GetRequiredService<Frameabtaster>();
|
||||
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
|
||||
|
||||
var film = await db.Movies.AsNoTracking()
|
||||
.Where(m => m.Id == filmId)
|
||||
.Join(db.MediaFiles.AsNoTracking(), m => m.Id, d => d.MovieId,
|
||||
(m, d) => new { m.Id, m.Title, m.Year, d.Path, d.Duration })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// **Drei Gründe, nicht einer.** Hier stand dreimal dasselbe `false`
|
||||
// — keine Datei, schon durchsucht, nichts gefunden —, und von
|
||||
// aussen waren die drei nicht zu unterscheiden. „Warum hat dieser
|
||||
// Film keinen Abspann?" liess sich damit nicht beantworten.
|
||||
if (film is null)
|
||||
{
|
||||
log.LogInformation("Abspann: zu {Kennung} liegt keine Datei — übersprungen.", filmId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var titel = Filmname(film.Title, film.Year);
|
||||
|
||||
if (film.Duration is not { TotalSeconds: > 0 } laufzeit)
|
||||
{
|
||||
log.LogInformation("„{Film}“: keine Laufzeit in der Datei — übersprungen.", titel);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (umfang == Segmentumfang.Luecken && await db.Segmente.AsNoTracking()
|
||||
.AnyAsync(m => m.MovieId == filmId && m.Art == Segmentart.Abspann, ct))
|
||||
{
|
||||
log.LogInformation("„{Film}“: hat schon einen Abspann — übersprungen.", titel);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (umfang == Segmentumfang.Neue && await db.Segmentsuchen.AsNoTracking()
|
||||
.AnyAsync(x => x.MovieId == filmId && x.Art == Segmentart.Abspann, ct))
|
||||
{
|
||||
log.LogInformation("„{Film}“: schon durchsucht — übersprungen.", titel);
|
||||
return false;
|
||||
}
|
||||
|
||||
await VermerkenAsync(db, filmId, Segmentart.Abspann, ct);
|
||||
|
||||
var gefunden = await BildabspannAsync(
|
||||
db, abtaster, kapitelleser, filmId, istFilm: true, titel, film.Path,
|
||||
laufzeit.TotalSeconds, ct);
|
||||
|
||||
if (!gefunden)
|
||||
{
|
||||
// Nichts gefunden ist ein Ergebnis. Eine altes Segment, die jetzt
|
||||
// nicht mehr bestätigt wird, gehört weg — sonst bliebe ein
|
||||
// Fehlgriff für immer stehen.
|
||||
var alt = await db.Segmente
|
||||
.FirstOrDefaultAsync(m => m.MovieId == filmId && m.Art == Segmentart.Abspann, ct);
|
||||
if (alt is not null) db.Segmente.Remove(alt);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return gefunden;
|
||||
}
|
||||
|
||||
/// <summary>Sucht den Abspann im Bild — bei einem Film wie bei einer Folge.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Dunkel und bis zum Ende durchhalten</b> — mehr braucht es nicht,
|
||||
/// und mehr hat ein Abspann auch nicht gemeinsam. Erst ein grober
|
||||
/// Überblick über die letzten zwanzig Minuten aus Keyframesn
|
||||
/// (billig, aber auf fünf bis zehn Sekunden ungenau), dann eine Handvoll
|
||||
/// kurzer Sonden für die Kante.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Warum das auch bei Folgen gebraucht wird.</b> Der Fingerprintabgleich lebt
|
||||
/// davon, dass der Abspann in jeder Folge derselbe ist — und genau das
|
||||
/// stimmt beim Staffelfinale oft nicht: anderer Schluss, andere Musik,
|
||||
/// kein Treffer. Das Bild weiss davon nichts; dort sieht das Finale aus
|
||||
/// wie jede andere Folge.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="name">
|
||||
/// Wie der Titel im Protokoll heissen soll — „Arrival (2016)" oder
|
||||
/// „Biohackers S02E01". Diese Stelle kennt sonst nur den Dateipfad, und
|
||||
/// ein Pfad beantwortet die Frage „welche Folge war das?" nicht.
|
||||
/// </param>
|
||||
private async Task<bool> BildabspannAsync(
|
||||
FlaroContext db, Frameabtaster abtaster, Kapitelleser kapitelleser,
|
||||
Guid titelId, bool istFilm, string name, string pfad, double laufzeit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var regeln = Abspannregeln.Vorgabe;
|
||||
|
||||
// **Erst fragen, ob die Datei es selbst weiss.** Ein Kapitel kostet
|
||||
// Millisekunden — ffprobe liest nur den Kopf —, die Helligkeit
|
||||
// zwanzig Minuten Keyframes. Und es findet, woran die
|
||||
// Helligkeit scheitert: einen Abspann über laufendem Bild, wie bei
|
||||
// „Alice im Wunderland" oder den Outtakes in „(T)Raumschiff
|
||||
// Surprise".
|
||||
var kapitel = await kapitelleser.LesenAsync(pfad, ct);
|
||||
if (Abspannkapitel.Suchen(kapitel, laufzeit, regeln) is { } ausKapitel)
|
||||
{
|
||||
log.LogInformation("„{Titel}“: Abspann laut Kapitel ab {Stelle:h\\:mm\\:ss}.",
|
||||
name, TimeSpan.FromSeconds(ausKapitel));
|
||||
|
||||
return await SchreibenAsync(db, titelId, istFilm, Segmentart.Abspann,
|
||||
ausKapitel, laufzeit, ct);
|
||||
}
|
||||
|
||||
var ab = Math.Max(0, laufzeit - Suchbereich);
|
||||
var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct);
|
||||
|
||||
// **Ein Lauf ohne Fund soll sagen, warum.** „Kein Abspann erkannt"
|
||||
// allein ist keine Auskunft: ob die Datei zu wenige Keyframes
|
||||
// hat, ob der Abspann zu hell war oder ob am Schluss eine Tafel
|
||||
// steht, sieht man nur an diesen drei Zahlen — und sonst gar nicht.
|
||||
var stücke = Abspannkante.Suchen(proben, regeln, out var grund);
|
||||
if (stücke.Count == 0)
|
||||
{
|
||||
// **Mit der Kapitellage.** Ob eine Datei gar keine Kapitel hat
|
||||
// oder nur namenlose, entscheidet, ob sich an diesem Weg
|
||||
// weiterbauen lohnt — und das sieht man nur an vielen Dateien.
|
||||
log.LogInformation(
|
||||
"„{Titel}“: kein Abspann — {Grund}. {Proben} Probe(n), "
|
||||
+ "letzte Probe {Letzte:0.#} % dunkel (nötig: {Noetig} %). {Kapitel}",
|
||||
name, grund, proben.Count,
|
||||
proben.Count > 0 ? proben[^1].Dunkelanteil : double.NaN,
|
||||
regeln.Mindestanteil, Kapitellage(kapitel));
|
||||
return false;
|
||||
}
|
||||
|
||||
// **Nur die erste Kante wird nachgemessen.** Sie ist die, die der
|
||||
// Zuschauer zuerst sieht, und die, an der „gilt als gesehen" hängt.
|
||||
// Die übrigen Grenzen liegen an Keyframesn und sind damit auf
|
||||
// fünf bis zehn Sekunden genau — dafür lohnt keine Sonde.
|
||||
var hell = Abspannkante.HellDavor(proben, stücke[0].Von, regeln) ?? ab;
|
||||
var kante = await Abspannkante.VerfeinernAsync(
|
||||
hell, stücke[0].Von, regeln.Mindestanteil,
|
||||
wo => abtaster.DunkelanteilAsync(pfad, wo, ct));
|
||||
|
||||
var fein = stücke.ToList();
|
||||
fein[0] = fein[0] with { Von = kante };
|
||||
|
||||
if (fein.Count == 1)
|
||||
{
|
||||
log.LogInformation("„{Titel}“: Abspann im Bild ab {Stelle:h\\:mm\\:ss}.",
|
||||
name, TimeSpan.FromSeconds(kante));
|
||||
}
|
||||
else
|
||||
{
|
||||
log.LogInformation(
|
||||
"„{Titel}“: Abspann im Bild in {Stücke} Stücken ab {Stelle:h\\:mm\\:ss} "
|
||||
+ "— dazwischen {Lücken}.",
|
||||
name, fein.Count, TimeSpan.FromSeconds(kante),
|
||||
string.Join(", ", fein.Zip(fein.Skip(1),
|
||||
(a, b) => $"{Zeit(a.Bis)}–{Zeit(b.Von)}")));
|
||||
}
|
||||
|
||||
return await SchreibenAsync(db, titelId, istFilm, Segmentart.Abspann, fein, ct);
|
||||
}
|
||||
|
||||
/// <summary>Was ein Serienlauf ergeben hat — für die Zusammenfassung.</summary>
|
||||
///
|
||||
/// <param name="Segmente">Wie viele Segmente geschrieben wurden.</param>
|
||||
/// <param name="Angefasst">
|
||||
/// Ob überhaupt gesucht wurde. Eine Serie, deren Staffeln alle schon
|
||||
/// durchsucht sind, zählt nicht als „ohne Fund" — sie wurde gar nicht
|
||||
/// erst gefragt, und beides in einen Topf zu werfen machte die
|
||||
/// Zusammenfassung wertlos.
|
||||
/// </param>
|
||||
private readonly record struct Serienstand(int Segmente, bool Angefasst);
|
||||
|
||||
private async Task<Serienstand> LaufenAsync(
|
||||
Guid serieId, Segmentumfang umfang, CancellationToken ct,
|
||||
Action<Scanschritt>? melden = null)
|
||||
{
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
var leser = raum.ServiceProvider.GetRequiredService<Fingerprintleser>();
|
||||
var abtaster = raum.ServiceProvider.GetRequiredService<Frameabtaster>();
|
||||
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
|
||||
|
||||
var serienname = await db.Series.AsNoTracking()
|
||||
.Where(x => x.Id == serieId).Select(x => x.Title).FirstOrDefaultAsync(ct)
|
||||
?? "Ohne Titel";
|
||||
|
||||
if (!await leser.VorhandenAsync(ct))
|
||||
{
|
||||
log.LogInformation(
|
||||
"Kein fpcalc vorhanden — ohne es lässt sich kein Vorspann finden.");
|
||||
return new Serienstand(0, Angefasst: false);
|
||||
}
|
||||
|
||||
// Datei und Laufzeit je Folge. Ohne beides ist eine Folge für den
|
||||
// Abgleich unbrauchbar: ohne Datei nichts zu lesen, ohne Laufzeit
|
||||
// kein Fenster fürs Ende.
|
||||
var folgen = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.SeriesId == serieId)
|
||||
.Join(db.MediaFiles.AsNoTracking(), f => f.Id, d => d.EpisodeId,
|
||||
(f, d) => new
|
||||
{
|
||||
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, d.Path, d.Duration,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
// **Was schon einmal gesucht wurde, und mit wie vielen Folgen.**
|
||||
// Nicht „was gefunden wurde": eine Staffel ohne Abspann sähe sonst
|
||||
// bei jedem Scan aus wie eine, in der noch niemand nachgesehen hat.
|
||||
var gesucht = await db.Segmentsuchen.AsNoTracking()
|
||||
.Where(x => x.SeriesId == serieId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// **Und was tatsächlich an den Folgen hängt.** Für „nur die
|
||||
// Lücken" reicht der Vermerk nicht: er sagt, dass gesucht wurde,
|
||||
// nicht ob etwas herauskam.
|
||||
var vorhanden = await db.Segmente.AsNoTracking()
|
||||
.Where(m => m.EpisodeId != null
|
||||
&& db.Episodes.Any(f => f.Id == m.EpisodeId && f.SeriesId == serieId))
|
||||
.Select(m => new { Folge = m.EpisodeId!.Value, m.Art })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var staffeln = folgen.GroupBy(f => f.SeasonNumber).OrderBy(g => g.Key).ToList();
|
||||
|
||||
log.LogInformation("„{Serie}“: {Staffeln} Staffel(n), {Folgen} Folge(n).",
|
||||
serienname, staffeln.Count, folgen.Count);
|
||||
|
||||
// Getrennt gezählt: „14 Segmente" beantwortet nicht, ob diese Serie
|
||||
// ihre Vorspänne bekommen hat — und das ist die häufigere Frage.
|
||||
var gefundenIntro = 0;
|
||||
var gefundenAbspann = 0;
|
||||
var bildfunde = 0;
|
||||
var angefasst = false;
|
||||
|
||||
foreach (var staffel in staffeln)
|
||||
{
|
||||
var geordnet = staffel.OrderBy(f => f.EpisodeNumber).ToList();
|
||||
string Name(int i) => Folgenname(
|
||||
serienname, geordnet[i].SeasonNumber, geordnet[i].EpisodeNumber, geordnet[i].Title);
|
||||
|
||||
// **Einmal lesen, zweimal schneiden.** fpcalc kann nicht
|
||||
// springen — es liest immer von vorn. Also die ganze Spur je
|
||||
// Folge, und Vorspann wie Abspann sind danach zwei Ausschnitte
|
||||
// aus demselben Fingerprint. Das ist nicht nur richtig, sondern
|
||||
// billiger als zweimal lesen.
|
||||
// Eine Art fehlt dieser Staffel, sobald irgendeine ihrer Folgen
|
||||
// sie nicht hat. Der Fingerprintabgleich ist Sache der ganzen Staffel,
|
||||
// nicht der einzelnen Folge — für eine Lücke läuft sie ganz.
|
||||
bool Luecke(Segmentart art) => geordnet.Exists(
|
||||
f => !vorhanden.Exists(v => v.Folge == f.Id && v.Art == art));
|
||||
|
||||
var fehlt = new[] { Segmentart.Intro, Segmentart.Abspann }
|
||||
.Where(art => umfang == Segmentumfang.Alles
|
||||
|| gesucht.FirstOrDefault(
|
||||
x => x.Staffel == staffel.Key && x.Art == art) is not { } lauf
|
||||
|| lauf.Folgen != geordnet.Count
|
||||
|| (umfang == Segmentumfang.Luecken && Luecke(art)))
|
||||
.ToList();
|
||||
if (fehlt.Count == 0) continue;
|
||||
angefasst = true;
|
||||
|
||||
// **Der Fingerprintabgleich braucht etwas zum Vergleichen.** Eine
|
||||
// Staffel mit einer einzigen Folge — eine laufende Serie, von
|
||||
// der erst die Premiere da ist — hat das nicht. Früher wurde
|
||||
// sie hier übersprungen, und zwar ganz; seit es den Bildweg
|
||||
// gibt, ist das zu früh: der braucht keine zweite Folge.
|
||||
var ganze = new Dictionary<Guid, IReadOnlyList<uint>>();
|
||||
if (geordnet.Count >= 2)
|
||||
{
|
||||
for (var i = 0; i < geordnet.Count; i++)
|
||||
{
|
||||
melden?.Invoke(new Scanschritt(Scanphase.Segmente, Name(i)));
|
||||
var fingerprint = await leser.LesenAsync(geordnet[i].Path, ct);
|
||||
if (fingerprint.Count > 0) ganze[geordnet[i].Id] = fingerprint;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
log.LogInformation(
|
||||
"„{Serie}“, Staffel {Staffel}: nur eine Folge — Fingerprintabgleich entfällt, "
|
||||
+ "es bleibt das Bild.", serienname, staffel.Key);
|
||||
}
|
||||
|
||||
// Ohne zwei Abdrücke gibt es nichts abzugleichen; der Bildweg
|
||||
// unten läuft trotzdem.
|
||||
var tonarten = ganze.Count >= 2 ? fehlt : [];
|
||||
|
||||
foreach (var art in tonarten)
|
||||
{
|
||||
var fingerprints = new List<(Guid Schlüssel, IReadOnlyList<uint> Fingerprint)>();
|
||||
var fensteranfang = new Dictionary<Guid, double>();
|
||||
|
||||
foreach (var folge in geordnet)
|
||||
{
|
||||
if (!ganze.TryGetValue(folge.Id, out var ganz)) continue;
|
||||
|
||||
var (vonIndex, anzahl) = Ausschnitt(art, ganz.Count);
|
||||
if (anzahl < 2) continue;
|
||||
|
||||
fingerprints.Add((folge.Id, ganz.Skip(vonIndex).Take(anzahl).ToList()));
|
||||
fensteranfang[folge.Id] = vonIndex * Fingerprintleser.SekundenJeFingerprint;
|
||||
}
|
||||
|
||||
if (fingerprints.Count < 2) continue;
|
||||
|
||||
var treffer = Fingerprintabgleich.ReiheAbgleichen(
|
||||
fingerprints, Fingerprintleser.SekundenJeFingerprint);
|
||||
|
||||
// **Vermerken, dass gesucht wurde — vor dem Ergebnis.**
|
||||
// Auch ein Lauf ohne Fund ist ein Lauf; ihn nicht zu
|
||||
// vermerken hiesse, ihn beim nächsten Scan zu wiederholen.
|
||||
await VermerkenAsync(db, serieId, staffel.Key, art, geordnet.Count, ct);
|
||||
|
||||
var grenze = art == Segmentart.Intro ? Hoechstlaenge : Hoechstabspann;
|
||||
|
||||
foreach (var (folgenId, stelle) in treffer)
|
||||
{
|
||||
if (stelle.Bis - stelle.Von > grenze) continue;
|
||||
|
||||
// **Die Zeiten liegen im Fenster, nicht in der Folge.**
|
||||
// Beim Abspann fängt das Fenster zehn Minuten vor dem
|
||||
// Ende an; ohne diesen Aufschlag stünde der Knopf bei
|
||||
// Minute zwei.
|
||||
var versatz = fensteranfang.GetValueOrDefault(folgenId);
|
||||
if (!await SchreibenAsync(db, folgenId, istFilm: false, art,
|
||||
stelle.Von + versatz, stelle.Bis + versatz, ct)) continue;
|
||||
|
||||
if (art == Segmentart.Intro) gefundenIntro++; else gefundenAbspann++;
|
||||
}
|
||||
}
|
||||
|
||||
var nummern = geordnet.Select(f => f.Id).ToList();
|
||||
var ueberBild = new HashSet<Guid>();
|
||||
|
||||
if (fehlt.Contains(Segmentart.Abspann))
|
||||
{
|
||||
// Auch wenn der Fingerprintabgleich gar nicht erst lief: gesucht wurde,
|
||||
// und ohne Vermerk liefe der Bildweg bei jedem Scan von Neuem.
|
||||
await VermerkenAsync(db, serieId, staffel.Key, Segmentart.Abspann,
|
||||
geordnet.Count, ct);
|
||||
|
||||
// **Was der Ton nicht fand, sucht das Bild.**
|
||||
//
|
||||
// Der Abgleich lebt davon, dass der Abspann in jeder Folge
|
||||
// derselbe ist — und beim Staffelfinale stimmt das oft nicht:
|
||||
// anderer Schluss, andere Musik, kein Treffer. Genau dort fiel
|
||||
// die letzte Folge jeder Staffel durch, und nur dort. Das Bild
|
||||
// weiss davon nichts; dort sieht das Finale aus wie jede
|
||||
// andere Folge.
|
||||
//
|
||||
// **Nur für die Lücken.** Der Überblick dekodiert zwanzig
|
||||
// Minuten Keyframes; bei einer Staffel ohne Lücke läuft
|
||||
// er gar nicht, mit Lücke ein- bis zweimal. Erst speichern,
|
||||
// sonst sieht die Abfrage die eben geschriebenen Segmente nicht.
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var mitAbspann = await db.Segmente.AsNoTracking()
|
||||
.Where(m => m.Art == Segmentart.Abspann
|
||||
&& m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value))
|
||||
.Select(m => m.EpisodeId!.Value)
|
||||
.ToListAsync(ct);
|
||||
|
||||
for (var i = 0; i < geordnet.Count; i++)
|
||||
{
|
||||
var folge = geordnet[i];
|
||||
if (mitAbspann.Contains(folge.Id)) continue;
|
||||
if (folge.Duration is not { TotalSeconds: > 0 } dauer) continue;
|
||||
|
||||
melden?.Invoke(new Scanschritt(Scanphase.Segmente, Name(i)));
|
||||
|
||||
if (await BildabspannAsync(db, abtaster, kapitelleser, folge.Id,
|
||||
istFilm: false, Name(i), folge.Path, dauer.TotalSeconds, ct))
|
||||
{
|
||||
ueberBild.Add(folge.Id);
|
||||
gefundenAbspann++;
|
||||
bildfunde++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await StaffelMeldenAsync(db, serienname, staffel.Key, geordnet
|
||||
.Select((f, i) => (f.Id, Name: Name(i))).ToList(), ueberBild, ct);
|
||||
}
|
||||
|
||||
// Immer speichern: auch ein Lauf ohne Fund hinterlässt Vermerke,
|
||||
// und genau die verhindern den nächsten vergeblichen Lauf.
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
if (angefasst)
|
||||
{
|
||||
log.LogInformation(
|
||||
"„{Serie}“ fertig: {Intro} Vorspann, {Abspann} Abspann{Bild}.",
|
||||
serienname, gefundenIntro, gefundenAbspann,
|
||||
bildfunde > 0 ? $", davon {bildfunde} über das Bild" : "");
|
||||
}
|
||||
else
|
||||
{
|
||||
log.LogInformation("„{Serie}“: nichts zu tun, alle Staffeln schon durchsucht.",
|
||||
serienname);
|
||||
}
|
||||
|
||||
return new Serienstand(gefundenIntro + gefundenAbspann, angefasst);
|
||||
}
|
||||
|
||||
/// <summary>Was eine Staffel ergeben hat — erst zusammengefasst, dann je Folge.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Aus der Datenbank gelesen, nicht mitgezählt.</b> Beim Zählen
|
||||
/// während des Laufs stünde im Protokoll, was dieser Lauf geschrieben
|
||||
/// hat; hier steht, was danach wirklich an der Folge hängt. Nur das
|
||||
/// beantwortet „warum kommt bei dieser Folge kein Knopf?".
|
||||
/// </remarks>
|
||||
private async Task StaffelMeldenAsync(
|
||||
FlaroContext db, string serienname, int staffel,
|
||||
IReadOnlyList<(Guid Id, string Name)> folgen, IReadOnlySet<Guid> ueberBild,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var nummern = folgen.Select(f => f.Id).ToList();
|
||||
var segmente = await db.Segmente.AsNoTracking()
|
||||
.Where(m => m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value))
|
||||
.Select(m => new { Folge = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var mitIntro = segmente.Count(m => m.Art == Segmentart.Intro);
|
||||
var mitAbspann = segmente.Count(m => m.Art == Segmentart.Abspann);
|
||||
|
||||
log.LogInformation(
|
||||
// **Jeder Platzhalter verbraucht ein Argument, auch ein
|
||||
// wiederholter.** Hier stand `{Folgen}` zweimal und wurde
|
||||
// einmal bedient; der Logger warf eine Ausnahme, und die riss
|
||||
// den ganzen Serienlauf mit — gefunden war dann alles umsonst.
|
||||
// CA2017 meldet das jetzt als Fehler, siehe Directory.Build.props.
|
||||
"„{Serie}“, Staffel {Staffel}: {Intro} Vorspann und {Abspann} Abspann "
|
||||
+ "in {Folgen} Folge(n).",
|
||||
serienname, staffel, mitIntro, mitAbspann, folgen.Count);
|
||||
|
||||
foreach (var (id, name) in folgen)
|
||||
{
|
||||
var intro = segmente.FirstOrDefault(m => m.Folge == id && m.Art == Segmentart.Intro);
|
||||
var abspann = segmente.FirstOrDefault(m => m.Folge == id && m.Art == Segmentart.Abspann);
|
||||
|
||||
log.LogInformation("{Folge}: {Vorspann}, {Abspann}.",
|
||||
name,
|
||||
intro is null ? "kein Vorspann"
|
||||
: $"Vorspann {Zeit(intro.VonSekunden)}–{Zeit(intro.BisSekunden)}",
|
||||
abspann is null ? "kein Abspann"
|
||||
: $"Abspann ab {Zeit(abspann.VonSekunden)}"
|
||||
+ (ueberBild.Contains(id) ? " (über das Bild)" : ""));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Was die Datei an Kapiteln mitbringt — in einem Halbsatz.</summary>
|
||||
private static string Kapitellage(IReadOnlyList<Kapitel> kapitel)
|
||||
{
|
||||
if (kapitel.Count == 0) return "Keine Kapitel.";
|
||||
|
||||
var benannt = kapitel.Count(k => !string.IsNullOrWhiteSpace(k.Titel));
|
||||
return benannt == 0
|
||||
? $"{kapitel.Count} Kapitel, alle ohne Namen."
|
||||
: $"{kapitel.Count} Kapitel, letztes „{kapitel[^1].Titel}“.";
|
||||
}
|
||||
|
||||
/// <summary>„41:12" oder „1:52:10" — kurz genug für eine Protokollzeile.</summary>
|
||||
private static string Zeit(double sekunden)
|
||||
{
|
||||
var spanne = TimeSpan.FromSeconds(Math.Max(0, sekunden));
|
||||
return spanne.TotalHours >= 1 ? spanne.ToString(@"h\:mm\:ss") : spanne.ToString(@"m\:ss");
|
||||
}
|
||||
|
||||
/// <summary>„Biohackers S02E01 · Neuanfang" — so steht eine Folge im Protokoll.</summary>
|
||||
private static string Folgenname(string serie, int staffel, int folge, string? titel)
|
||||
{
|
||||
var kopf = $"{serie} S{staffel:00}E{folge:00}";
|
||||
return string.IsNullOrWhiteSpace(titel) ? kopf : $"{kopf} · {titel}";
|
||||
}
|
||||
|
||||
/// <summary>Welcher Ausschnitt des Fingerprints abgetastet wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// In Werten, nicht in Sekunden: der Fingerprint ist eine Reihe, und
|
||||
/// geschnitten wird an einer Stelle darin. Die Laufzeit steht nicht mehr
|
||||
/// in der Rechnung — die Länge des Fingerprints ist sie.
|
||||
/// </remarks>
|
||||
private static (int Von, int Anzahl) Ausschnitt(Segmentart art, int werte)
|
||||
{
|
||||
var fenster = (int)Math.Round(
|
||||
(art == Segmentart.Intro ? Introfenster : Abspannfenster)
|
||||
/ Fingerprintleser.SekundenJeFingerprint);
|
||||
|
||||
if (fenster >= werte) return (0, werte);
|
||||
return art == Segmentart.Intro ? (0, fenster) : (werte - fenster, fenster);
|
||||
}
|
||||
|
||||
/// <summary>Hält fest, dass bei diesem Film gesucht wurde.</summary>
|
||||
private async Task VermerkenAsync(
|
||||
FlaroContext db, Guid filmId, Segmentart art, CancellationToken ct)
|
||||
{
|
||||
var alt = db.Segmentsuchen.Local
|
||||
.FirstOrDefault(x => x.MovieId == filmId && x.Art == art)
|
||||
?? await db.Segmentsuchen
|
||||
.FirstOrDefaultAsync(x => x.MovieId == filmId && x.Art == art, ct);
|
||||
|
||||
if (alt is not null) { alt.GelaufenAm = clock.GetUtcNow(); return; }
|
||||
|
||||
db.Segmentsuchen.Add(new Segmentsuche
|
||||
{
|
||||
MovieId = filmId,
|
||||
Art = art,
|
||||
Folgen = 1,
|
||||
GelaufenAm = clock.GetUtcNow(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Hält fest, dass in dieser Staffel gesucht wurde.</summary>
|
||||
private async Task VermerkenAsync(
|
||||
FlaroContext db, Guid serieId, int staffel, Segmentart art, int folgen, CancellationToken ct)
|
||||
{
|
||||
// **Erst im Änderungsspeicher nachsehen, dann in der Datenbank.**
|
||||
//
|
||||
// Eine Abfrage sieht nicht, was derselbe Lauf eben erst hinzugefügt
|
||||
// und noch nicht gespeichert hat. Seit der Bildweg dazukam, wird
|
||||
// für dieselbe Staffel zweimal vermerkt — einmal nach dem
|
||||
// Fingerprintabgleich, einmal vor dem Bild —, und die zweite Abfrage fand
|
||||
// nichts, legte eine zweite Zeile an und brach beim Speichern am
|
||||
// eindeutigen Schlüssel ab. Die Serie verlor dabei alles, was in
|
||||
// diesem Lauf gefunden worden war.
|
||||
var alt = db.Segmentsuchen.Local
|
||||
.FirstOrDefault(x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art)
|
||||
?? await db.Segmentsuchen
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art, ct);
|
||||
|
||||
if (alt is not null)
|
||||
{
|
||||
alt.Folgen = folgen;
|
||||
alt.GelaufenAm = clock.GetUtcNow();
|
||||
return;
|
||||
}
|
||||
|
||||
db.Segmentsuchen.Add(new Segmentsuche
|
||||
{
|
||||
SeriesId = serieId,
|
||||
Staffel = staffel,
|
||||
Art = art,
|
||||
Folgen = folgen,
|
||||
GelaufenAm = clock.GetUtcNow(),
|
||||
});
|
||||
}
|
||||
|
||||
private Task<bool> SchreibenAsync(
|
||||
FlaroContext db, Guid titelId, bool istFilm, Segmentart art,
|
||||
double von, double bis, CancellationToken ct) =>
|
||||
SchreibenAsync(db, titelId, istFilm, art, [new Abspannstück(von, bis)], ct);
|
||||
|
||||
/// <summary>Setzt die Segmente einer Art auf genau diese Stücke.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Ersetzen, nicht umschreiben.</b> Hier stand ein Abgleich auf
|
||||
/// genau eine Zeile je Titel und Art — das ging, solange ein Abspann
|
||||
/// eine zusammenhängende Strecke war. Seit eine Post-Credit-Szene ihn
|
||||
/// teilen darf, sind es ein bis zwei Zeilen, und beim nächsten Lauf
|
||||
/// vielleicht wieder eine. Die alten zu löschen und die neuen
|
||||
/// anzulegen ist der einzige Weg, der in beide Richtungen stimmt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>In derselben Transaktion</b> — gespeichert wird weiter oben, und
|
||||
/// bis dahin stehen Löschung und Anlage zusammen. Dazwischen gibt es
|
||||
/// keinen Augenblick, in dem der Titel gar kein Segment hätte.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<bool> SchreibenAsync(
|
||||
FlaroContext db, Guid titelId, bool istFilm, Segmentart art,
|
||||
IReadOnlyList<Abspannstück> stücke, CancellationToken ct)
|
||||
{
|
||||
if (stücke.Count == 0) return false;
|
||||
|
||||
var alte = istFilm
|
||||
? await db.Segmente.Where(m => m.MovieId == titelId && m.Art == art).ToListAsync(ct)
|
||||
: await db.Segmente.Where(m => m.EpisodeId == titelId && m.Art == art).ToListAsync(ct);
|
||||
|
||||
db.Segmente.RemoveRange(alte);
|
||||
|
||||
foreach (var stück in stücke)
|
||||
{
|
||||
db.Segmente.Add(new Segment
|
||||
{
|
||||
EpisodeId = istFilm ? null : titelId,
|
||||
MovieId = istFilm ? titelId : null,
|
||||
Art = art,
|
||||
VonSekunden = stück.Von,
|
||||
BisSekunden = stück.Bis,
|
||||
ErkanntAm = clock.GetUtcNow(),
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Flaro.Core.Playback;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Bringt die gemerkten Sehstände wieder mit der Wirklichkeit in Einklang.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Der Anlass.</b> Ein Film stand bei 1:32, und in „Fortsetzen" tauchte
|
||||
/// er trotzdem nicht auf — bis er einmal neu gestartet wurde. Der Grund:
|
||||
/// die Reihe fragt nicht die Regel, sondern deren festgehaltenes Ergebnis,
|
||||
/// die Spalte <c>InProgress</c>. Die Bruchrechnung „Stelle kleiner als
|
||||
/// Anteil der Laufzeit" lässt sich nicht nach SQL übersetzen, deshalb steht
|
||||
/// sie als Wahrheitswert in der Zeile.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Und berechnet wird sie nur, wenn eine Meldung eintrifft.</b> Die
|
||||
/// zerstörerische Meldung von vorgestern — zu kurze Laufzeit, Stelle
|
||||
/// beschnitten, Häkchen gesetzt — hat dort <c>false</c> hinterlassen. Die
|
||||
/// Korrektur am Server verhindert neuen Schaden, räumt den alten aber
|
||||
/// nicht weg: dafür müsste jemand jeden betroffenen Titel einmal anspielen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Zwei Schritte, und die Reihenfolge zählt.</b> Erst die Laufzeit aus
|
||||
/// der Datei nachtragen, dann neu bewerten — eine Neubewertung auf Basis
|
||||
/// eines Stummels käme wieder zu <c>false</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Sehstandpflege(
|
||||
IServiceScopeFactory scopes,
|
||||
TimeProvider uhr,
|
||||
ILogger<Sehstandpflege> log)
|
||||
{
|
||||
/// <summary>Unter diesem Schlüssel steht, für welchen Stand es schon lief.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Einmal je Abbild.</b> Nach jedem Update einmal durchrechnen ist
|
||||
/// billig — es ist ein Durchlauf über eine Tabelle mit ein paar tausend
|
||||
/// Zeilen — und fängt genau diese Art Fehler ab: eine Regel ändert sich,
|
||||
/// und das Festgehaltene bleibt stehen.
|
||||
/// </remarks>
|
||||
public const string Schluessel = "wartung.sehstaende.stand";
|
||||
|
||||
public async Task<int> NachUpdateAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
|
||||
var stand = await db.Settings.AsNoTracking().FirstOrDefaultAsync(s => s.Key == Schluessel, ct);
|
||||
if (stand?.Value == Buildkennung.Build) return 0;
|
||||
|
||||
var geaendert = await LaufenAsync(ct);
|
||||
|
||||
var zeile = await db.Settings.FindAsync([Schluessel], ct);
|
||||
if (zeile is null) db.Settings.Add(new Core.ServerSetting { Key = Schluessel, Value = Buildkennung.Build });
|
||||
else zeile.Value = Buildkennung.Build;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
if (geaendert > 0)
|
||||
{
|
||||
log.LogInformation(
|
||||
"Sehstände nachgerechnet: {Anzahl} Eintrag/Einträge stehen jetzt richtig.", geaendert);
|
||||
}
|
||||
|
||||
return geaendert;
|
||||
}
|
||||
|
||||
/// <summary>Trägt fehlende Laufzeiten nach und bewertet neu.</summary>
|
||||
public async Task<int> LaufenAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
var regeln = await raum.ServiceProvider
|
||||
.GetRequiredService<Wiedergabeeinstellungen>().RegelnAsync();
|
||||
|
||||
var jetzt = uhr.GetUtcNow();
|
||||
const int Stapel = 500;
|
||||
var geaendert = 0;
|
||||
|
||||
for (var sprung = 0; ; sprung += Stapel)
|
||||
{
|
||||
var zeilen = await db.UserItemStates
|
||||
.OrderBy(z => z.Id).Skip(sprung).Take(Stapel).ToListAsync(ct);
|
||||
if (zeilen.Count == 0) break;
|
||||
|
||||
// **Die Laufzeiten in einem Zug, nicht je Zeile.** Fünfhundert
|
||||
// Einzelabfragen für fünfhundert Zeilen wären bei achttausend
|
||||
// Sehständen sechzehntausend Rundreisen.
|
||||
var filme = zeilen.Where(z => z.MovieId is not null).Select(z => z.MovieId!.Value).ToList();
|
||||
var folgen = zeilen.Where(z => z.EpisodeId is not null).Select(z => z.EpisodeId!.Value).ToList();
|
||||
|
||||
var filmlaufzeit = await db.MediaFiles.AsNoTracking()
|
||||
.Where(d => d.MovieId != null && filme.Contains(d.MovieId!.Value))
|
||||
.GroupBy(d => d.MovieId!.Value)
|
||||
.Select(g => new { Id = g.Key, Dauer = g.Max(d => d.Duration) })
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Dauer, ct);
|
||||
|
||||
var folgenlaufzeit = await db.MediaFiles.AsNoTracking()
|
||||
.Where(d => d.EpisodeId != null && folgen.Contains(d.EpisodeId!.Value))
|
||||
.GroupBy(d => d.EpisodeId!.Value)
|
||||
.Select(g => new { Id = g.Key, Dauer = g.Max(d => d.Duration) })
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Dauer, ct);
|
||||
|
||||
foreach (var z in zeilen)
|
||||
{
|
||||
var gemessen = z.MovieId is { } f
|
||||
? filmlaufzeit.GetValueOrDefault(f)
|
||||
: z.EpisodeId is { } e ? folgenlaufzeit.GetValueOrDefault(e) : null;
|
||||
|
||||
var vorher = (z.Runtime, z.InProgress);
|
||||
|
||||
// **Die gemessene Laufzeit gewinnt.** Steht in der Zeile ein
|
||||
// Stummel, weil ein Client ihn einmal gemeldet hat, bleibt
|
||||
// der sonst für immer stehen.
|
||||
if (gemessen is { Ticks: > 0 }) z.Runtime = gemessen;
|
||||
|
||||
z.InProgress = ProgressRule.BelongsInContinueWatching(z, regeln);
|
||||
|
||||
if (vorher == (z.Runtime, z.InProgress)) continue;
|
||||
|
||||
// **`UpdatedAt` bleibt, wo es war.** Danach ist „Fortsetzen"
|
||||
// sortiert; eine Reparatur, die alles auf heute setzt,
|
||||
// mischte die Reihenfolge neu und sähe aus wie ein Fehler.
|
||||
geaendert++;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
if (zeilen.Count < Stapel) break;
|
||||
}
|
||||
|
||||
return geaendert;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public sealed record Stromsitzung(
|
||||
/// <remarks>
|
||||
/// <b>Daran hängt das Aufräumen.</b> Ein geschlossener Browser sagt
|
||||
/// niemandem Bescheid; bliebe die Sitzung stehen, liefe ffmpeg weiter und
|
||||
/// schriebe den ganzen Film auf den Kratzplatz.
|
||||
/// schriebe den ganzen Film auf den Transcode-Cache.
|
||||
/// </remarks>
|
||||
public DateTimeOffset Gesehen { get; set; } = Begonnen;
|
||||
|
||||
@@ -61,7 +61,7 @@ public sealed class Streamkoordinator(
|
||||
IServiceScopeFactory scopes,
|
||||
FlaroPaths pfade,
|
||||
FfmpegOptions ffmpeg,
|
||||
Schluesselbilder schluesselbilder,
|
||||
Keyframes schluesselbilder,
|
||||
TimeProvider clock,
|
||||
ILogger<Streamkoordinator> log) : IDisposable
|
||||
{
|
||||
@@ -140,22 +140,22 @@ public sealed class Streamkoordinator(
|
||||
var stufe = await bereich.ServiceProvider
|
||||
.GetRequiredService<Wiedergabeeinstellungen>().StufeAsync(ct);
|
||||
|
||||
// **Beim Kopieren wird auf das Schlüsselbild gerundet.**
|
||||
// **Beim Kopieren wird auf das Keyframe gerundet.**
|
||||
//
|
||||
// Ein kopierter Bildstrom lässt sich nicht mitten in einer Bildgruppe
|
||||
// aufschneiden; ffmpeg beginnt beim Schlüsselbild davor, während der
|
||||
// aufschneiden; ffmpeg beginnt beim Keyframe davor, während der
|
||||
// Ton neu gerechnet wird und genau beim Wunsch anfängt. Gemessen an
|
||||
// einer Quelle mit Schlüsselbildern alle fünf Sekunden, Sprung auf
|
||||
// einer Quelle mit Keyframesn alle fünf Sekunden, Sprung auf
|
||||
// Sekunde 12: Bild ab 0, Ton erst ab 1,98 — zwei Sekunden stummes
|
||||
// Bild, und die Uhr des Spielers zwei Sekunden daneben.
|
||||
//
|
||||
// Beim Umkodieren entfällt das: dort wird das Bild neu gerechnet und
|
||||
// schneidet auf die Sekunde genau. Dann vorzurunden hiesse, ohne Not
|
||||
// ein Stück zurückzuspringen.
|
||||
// **Gesprungen wird auf den Wunsch, gemeldet wird das Schlüsselbild.**
|
||||
// **Gesprungen wird auf den Wunsch, gemeldet wird das Keyframe.**
|
||||
//
|
||||
// Beim Kopieren springt ffmpeg ungenau — siehe `HlsArgumente` — und
|
||||
// landet damit auf dem Schlüsselbild vor dem Wunsch. Bild und Ton
|
||||
// landet damit auf dem Keyframe vor dem Wunsch. Bild und Ton
|
||||
// fangen dort gemeinsam an; das ist die Zusage, und sie hängt an
|
||||
// keiner Rechnung hier.
|
||||
//
|
||||
@@ -165,9 +165,11 @@ public sealed class Streamkoordinator(
|
||||
// bleiben zusammen. Umgekehrt wäre es ein schlechter Tausch: einen
|
||||
// Sprungpunkt auf die Millisekunde zu treffen, ist zu viel verlangt.
|
||||
var gewuenscht = Math.Max(0, ab);
|
||||
var uhr = System.Diagnostics.Stopwatch.StartNew();
|
||||
var gemeldet = plan.BildKopieren
|
||||
? await schluesselbilder.DavorAsync(datei.Path, gewuenscht, ct)
|
||||
: gewuenscht;
|
||||
var suche = uhr.ElapsedMilliseconds;
|
||||
|
||||
var anfrage = new Stromanfrage(
|
||||
datei.Path, ordner, gelesen, plan, tonspur, gewuenscht, Hardware(), stufe, tonversatz);
|
||||
@@ -183,10 +185,19 @@ public sealed class Streamkoordinator(
|
||||
var abbruch = new CancellationTokenSource();
|
||||
_sitzungen[kennung] = new Eintrag(sitzung, prozess, abbruch);
|
||||
|
||||
log.LogInformation(
|
||||
"Strom {Kennung}: {Art} von {Datei} ab {Ab}s{Hoehe}.",
|
||||
kennung, plan.Art, Path.GetFileName(datei.Path), sitzung.Ab,
|
||||
plan.Hoehe is { } h ? $", auf {h}p" : "");
|
||||
// **Mit Zeiten, und auch im Tagesprotokoll.**
|
||||
//
|
||||
// „Es dauert fünfzehn Sekunden" liess sich nicht beantworten: zwischen
|
||||
// dem Antippen und dem ersten Bild liegen die Keyframesuche, der
|
||||
// Start von ffmpeg und das erste Segment, und keine dieser Strecken
|
||||
// war irgendwo abzulesen. Eine Zeile je Wiedergabe ist kein Lärm —
|
||||
// sie ist genau das, wonach man im Protokoll sucht.
|
||||
var meldung =
|
||||
$"Wiedergabe: {plan.Art} von {Path.GetFileName(datei.Path)} ab {sitzung.Ab:0.#}s"
|
||||
+ (plan.Hoehe is { } h ? $", auf {h}p" : "")
|
||||
+ $" — Keyframesuche {suche} ms, Vorbereitung {uhr.ElapsedMilliseconds} ms.";
|
||||
|
||||
log.LogInformation("{Meldung}", meldung);
|
||||
|
||||
return sitzung;
|
||||
}
|
||||
@@ -220,7 +231,7 @@ public sealed class Streamkoordinator(
|
||||
if (eintrag.Sitzung.Gesehen > grenze) continue;
|
||||
if (!_sitzungen.TryRemove(kennung, out var weg)) continue;
|
||||
|
||||
log.LogInformation("Strom {Kennung} wurde seit {Geduld} nicht abgeholt — beendet.",
|
||||
log.LogInformation("Stream {Kennung} wurde seit {Geduld} nicht abgeholt — beendet.",
|
||||
kennung, Geduld);
|
||||
Abraeumen(weg);
|
||||
}
|
||||
@@ -258,14 +269,34 @@ public sealed class Streamkoordinator(
|
||||
/// <summary>Ob die Playlist schon etwas enthält, das sich abspielen lässt.</summary>
|
||||
public static bool PlaylistBereit(string ordner) => Playlist(ordner) is not null;
|
||||
|
||||
/// <summary>Das Gerät, über das VAAPI läuft.</summary>
|
||||
private const string Vaapigeraet = "/dev/dri/renderD128";
|
||||
|
||||
/// <summary>Das Ergebnis der einmaligen Probe — <c>null</c>, solange keine lief.</summary>
|
||||
private bool? _vaapiLaeuft;
|
||||
|
||||
/// <summary>Welche Beschleunigung benutzt wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b><c>auto</c> prüft, statt zu behaupten.</b> Liegt kein
|
||||
/// <c>/dev/dri/renderD128</c> vor — auf einem Mac, in einem Container ohne
|
||||
/// durchgereichtes Gerät —, läuft es in Software weiter. Langsamer, aber
|
||||
/// es läuft; ein ffmpeg, das mit „No VA display found" abbricht, wäre für
|
||||
/// den Zuschauer dasselbe wie ein kaputter Server.
|
||||
/// <para>
|
||||
/// <b><c>auto</c> fragt ffmpeg, statt die Datei anzusehen.</b> Hier stand
|
||||
/// <c>File.Exists("/dev/dri/renderD128")</c>, und das war keine Prüfung,
|
||||
/// sondern eine Vermutung: auf dem NAS liegt das Gerät durchgereicht da,
|
||||
/// aber dahinter steht keine VA-Anzeige. ffmpeg bricht dann schon an der
|
||||
/// globalen Option ab — <c>No VA display found for device</c>, Rückgabe
|
||||
/// 234 —, und für den Zuschauer sieht das aus wie ein kaputter Server.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Einmal je Lauf.</b> Die Probe kostet einen ffmpeg-Start; sie vor
|
||||
/// jeder Wiedergabe zu wiederholen wäre Aufwand für eine Antwort, die
|
||||
/// sich nicht ändert. Wechselt die Hardware, wechselt auch der
|
||||
/// Container.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><c>FLARO_HWACCEL=vaapi</c> überspringt sie</b> — wer es
|
||||
/// ausdrücklich verlangt, hat einen Grund, und ein Fehlschlag steht dann
|
||||
/// im Protokoll.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private Beschleunigung Hardware()
|
||||
{
|
||||
@@ -275,10 +306,62 @@ public sealed class Streamkoordinator(
|
||||
{
|
||||
"none" or "software" => Beschleunigung.Software,
|
||||
"vaapi" => Beschleunigung.Vaapi,
|
||||
_ => File.Exists("/dev/dri/renderD128") ? Beschleunigung.Vaapi : Beschleunigung.Software,
|
||||
_ => (_vaapiLaeuft ??= VaapiLaeuft()) ? Beschleunigung.Vaapi : Beschleunigung.Software,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Lässt ffmpeg das Gerät einmal wirklich öffnen.</summary>
|
||||
private bool VaapiLaeuft()
|
||||
{
|
||||
if (!File.Exists(Vaapigeraet)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
var start = new ProcessStartInfo(ffmpeg.Ffmpeg)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
// Ein Bild aus dem Nichts, nirgendwohin. Es geht allein darum, ob
|
||||
// die Zeile vor dem ersten Bild durchgeht.
|
||||
foreach (var a in new[]
|
||||
{
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-vaapi_device", Vaapigeraet,
|
||||
"-f", "lavfi", "-i", "nullsrc=s=64x64:d=0.1",
|
||||
"-frames:v", "1", "-f", "null", "-",
|
||||
})
|
||||
{
|
||||
start.ArgumentList.Add(a);
|
||||
}
|
||||
|
||||
using var prozess = Process.Start(start);
|
||||
if (prozess is null) return false;
|
||||
|
||||
// **Mit Frist.** Ein hängender Prozess hielte sonst die erste
|
||||
// Wiedergabe nach dem Start auf.
|
||||
if (!prozess.WaitForExit(5000))
|
||||
{
|
||||
try { prozess.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
|
||||
log.LogWarning("Die VAAPI-Probe antwortete nicht; es läuft in Software.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prozess.ExitCode == 0) return true;
|
||||
|
||||
log.LogInformation(
|
||||
"VAAPI ist nicht benutzbar ({Code}); es läuft in Software. {Grund}",
|
||||
prozess.ExitCode, prozess.StandardError.ReadToEnd().Trim());
|
||||
return false;
|
||||
}
|
||||
catch (Exception fehler) when (fehler is System.ComponentModel.Win32Exception or IOException)
|
||||
{
|
||||
log.LogWarning(fehler, "Die VAAPI-Probe liess sich nicht starten; es läuft in Software.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Process Starten(Stromanfrage anfrage, Stromsitzung sitzung)
|
||||
{
|
||||
var argumente = HlsArgumente.Fuer(anfrage);
|
||||
@@ -313,7 +396,7 @@ public sealed class Streamkoordinator(
|
||||
// eine Spur.
|
||||
if (text.Trim() is { Length: > 0 } warnungen)
|
||||
{
|
||||
log.LogWarning("Strom {Kennung}: ffmpeg meldet {Warnungen}",
|
||||
log.LogWarning("Stream {Kennung}: ffmpeg meldet {Warnungen}",
|
||||
sitzung.Kennung, warnungen);
|
||||
}
|
||||
|
||||
@@ -321,8 +404,9 @@ public sealed class Streamkoordinator(
|
||||
}
|
||||
|
||||
sitzung.Fehler = text.Trim();
|
||||
log.LogWarning("Strom {Kennung}: ffmpeg endete mit {Code}: {Fehler}",
|
||||
sitzung.Kennung, prozess.ExitCode, sitzung.Fehler);
|
||||
log.LogError("Wiedergabe gescheitert: ffmpeg endete mit {Code}. {Fehler}",
|
||||
prozess.ExitCode, sitzung.Fehler);
|
||||
|
||||
});
|
||||
|
||||
_ = Task.Run(() => prozess.StandardOutput.ReadToEndAsync());
|
||||
@@ -350,7 +434,7 @@ public sealed class Streamkoordinator(
|
||||
}
|
||||
catch (IOException fehler)
|
||||
{
|
||||
log.LogWarning(fehler, "Der Ordner zu Strom {Kennung} blieb liegen.", eintrag.Sitzung.Kennung);
|
||||
log.LogWarning(fehler, "Der Ordner zu Stream {Kennung} blieb liegen.", eintrag.Sitzung.Kennung);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +458,7 @@ public sealed class Streamkoordinator(
|
||||
try { Directory.Delete(Wurzel, recursive: true); }
|
||||
catch (IOException fehler)
|
||||
{
|
||||
log.LogDebug(fehler, "Der Stromordner {Pfad} liess sich nicht leeren.", Wurzel);
|
||||
log.LogDebug(fehler, "Der Streamordner {Pfad} liess sich nicht leeren.", Wurzel);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -388,7 +472,7 @@ public sealed class Streamkoordinator(
|
||||
/// Löschen in <c>Abraeumen</c> an einem <c>IOException</c>, ist die
|
||||
/// Sitzung aus dem Verzeichnis heraus und der Ordner bleibt für immer
|
||||
/// liegen. Die Altersprüfung nimmt dabei den Fall heraus, dass ein
|
||||
/// zweiter Prozess denselben Kratzplatz benutzt: ein aktiver Ordner
|
||||
/// zweiter Prozess denselben Transcode-Cache benutzt: ein aktiver Ordner
|
||||
/// bekommt alle paar Sekunden ein Segment geschrieben.
|
||||
/// </remarks>
|
||||
public long VerwaisteOrdnerWegraeumen(DateTimeOffset jetzt)
|
||||
@@ -437,7 +521,7 @@ public sealed class Streamkoordinator(
|
||||
|
||||
// **Auch, was ein Absturz hinterlassen hat.** Nach einem harten Ende
|
||||
// steht der Ordner noch da; ihn beim nächsten Herunterfahren zu
|
||||
// übersehen hiesse, dass der Kratzplatz nur wächst.
|
||||
// übersehen hiesse, dass der Transcode-Cache nur wächst.
|
||||
WurzelLeeren();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
@@ -175,3 +176,60 @@ public sealed class Tagebuch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Leitet, was der Server ohnehin meldet, ins Tagesprotokoll.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Statt an jeder Stelle zweimal zu schreiben.</b> Das Protokoll kannte
|
||||
/// nur, was jemand ausdrücklich hineinschrieb: Start, Ende, ein paar Fehler.
|
||||
/// „Scan gestartet", „Strom angelegt", „Untertitel gezogen" stehen längst im
|
||||
/// Quelltext — sie gingen nur an <c>ILogger</c> und damit in die
|
||||
/// Container-Ausgabe, wo sie zwischen EF-Abfragen untergehen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nur die eigenen Kategorien.</b> <c>Microsoft.*</c> und
|
||||
/// <c>System.*</c> bleiben draussen — dort kommt je Datenbankabfrage eine
|
||||
/// Zeile, und das Protokoll wäre nach einem Scan unlesbar.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Tagebuchsenke(Tagebuch tagebuch) : ILoggerProvider
|
||||
{
|
||||
public ILogger CreateLogger(string kategorie) =>
|
||||
kategorie.StartsWith("Flaro", StringComparison.Ordinal)
|
||||
? new Schreiber(tagebuch, Kurzname(kategorie))
|
||||
: NullLogger.Instance;
|
||||
|
||||
public void Dispose() { }
|
||||
|
||||
/// <summary>„Flaro.Api.Streamkoordinator" wird zu „Streamkoordinator".</summary>
|
||||
private static string Kurzname(string kategorie) =>
|
||||
kategorie[(kategorie.LastIndexOf('.') + 1)..];
|
||||
|
||||
private sealed class Schreiber(Tagebuch tagebuch, string woher) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
// Debug und Trace bleiben draussen: die sind fürs Entwickeln da.
|
||||
public bool IsEnabled(LogLevel stufe) => stufe >= LogLevel.Information;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel stufe, EventId kennung, TState zustand, Exception? fehler,
|
||||
Func<TState, Exception?, string> text)
|
||||
{
|
||||
if (!IsEnabled(stufe)) return;
|
||||
|
||||
var art = stufe switch
|
||||
{
|
||||
>= LogLevel.Error => Tagebuch.Art.Fehler,
|
||||
LogLevel.Warning => Tagebuch.Art.Warnung,
|
||||
_ => Tagebuch.Art.Info,
|
||||
};
|
||||
|
||||
var satz = text(zustand, fehler);
|
||||
if (fehler is not null) satz += $" — {fehler.Message}";
|
||||
|
||||
tagebuch.Schreiben($"{woher}: {satz}", art);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -4,22 +4,22 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Die Regeln fürs Aufräumen des Kratzplatzes, aus den Einstellungen.</summary>
|
||||
/// <summary>Die Regeln fürs Aufräumen des Transcodes, aus den Einstellungen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Nach dem Vorbild von <see cref="Wiedergabeeinstellungen"/>: einmal je
|
||||
/// Anfrage gelesen und gemerkt. Die Tabelle ist generisch, es braucht keine
|
||||
/// Migration.
|
||||
/// </remarks>
|
||||
public sealed class Kratzplatzeinstellungen(FlaroContext db)
|
||||
public sealed class Transcodeeinstellungen(FlaroContext db)
|
||||
{
|
||||
public const string ObergrenzeSchluessel = "wartung.kratzplatz.paket.obergrenzeGb";
|
||||
public const string UnbenutztSchluessel = "wartung.kratzplatz.paket.unbenutztTage";
|
||||
public const string SchonfristSchluessel = "wartung.kratzplatz.paket.schonfristMinuten";
|
||||
|
||||
private Kratzplatzregeln? _gemerkt;
|
||||
private Transcoderegeln? _gemerkt;
|
||||
|
||||
public async Task<Kratzplatzregeln> RegelnAsync(CancellationToken ct = default)
|
||||
public async Task<Transcoderegeln> RegelnAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_gemerkt is { } fertig) return fertig;
|
||||
|
||||
@@ -29,9 +29,9 @@ public sealed class Kratzplatzeinstellungen(FlaroContext db)
|
||||
|| e.Key == SchonfristSchluessel)
|
||||
.ToDictionaryAsync(e => e.Key, e => e.Value, ct);
|
||||
|
||||
var vorgabe = Kratzplatzregeln.Vorgabe;
|
||||
var vorgabe = Transcoderegeln.Vorgabe;
|
||||
|
||||
_gemerkt = Kratzplatzregeln.Geprueft(
|
||||
_gemerkt = Transcoderegeln.Geprueft(
|
||||
Zahl(werte, ObergrenzeSchluessel, vorgabe.ObergrenzeBytes / 1024d / 1024 / 1024),
|
||||
(int)Zahl(werte, UnbenutztSchluessel, vorgabe.Unbenutzt.TotalDays),
|
||||
(int)Zahl(werte, SchonfristSchluessel, vorgabe.Schonfrist.TotalMinutes));
|
||||
@@ -11,7 +11,7 @@ public sealed record Aufraeumbericht(
|
||||
/// <param name="Name">Der Unterordner — <c>stroeme</c>, <c>packages</c>, <c>untertitel</c>.</param>
|
||||
public sealed record Teilbericht(string Name, long Frei, int Dateien);
|
||||
|
||||
/// <summary>Räumt den Kratzplatz auf.</summary>
|
||||
/// <summary>Räumt den Transcode-Cache auf.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -31,13 +31,13 @@ public sealed record Teilbericht(string Name, long Frei, int Dateien);
|
||||
/// nebeneinander löschten einander die Dateien unter den Händen weg.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Kratzplatzpflege(
|
||||
public sealed class Transcodepflege(
|
||||
IServiceScopeFactory scopes,
|
||||
FlaroPaths pfade,
|
||||
PackageCoordinator pakete,
|
||||
Streamkoordinator stroeme,
|
||||
TimeProvider uhr,
|
||||
ILogger<Kratzplatzpflege> log)
|
||||
ILogger<Transcodepflege> log)
|
||||
{
|
||||
private readonly SemaphoreSlim _einerZurZeit = new(1, 1);
|
||||
|
||||
@@ -65,11 +65,14 @@ public sealed class Kratzplatzpflege(
|
||||
var (paketfrei, paketzahl) = await PaketeAufraeumenAsync(jetzt, ct);
|
||||
var (textfrei, textzahl) = await UntertitelAufraeumenAsync(ct);
|
||||
|
||||
var (vorschaufrei, vorschauzahl) = await VorschauAufraeumenAsync(ct);
|
||||
|
||||
var teile = new List<Teilbericht>
|
||||
{
|
||||
new("stroeme", stromfrei, 0),
|
||||
new("packages", paketfrei, paketzahl),
|
||||
new("untertitel", textfrei, textzahl),
|
||||
new("vorschau", vorschaufrei, vorschauzahl),
|
||||
};
|
||||
|
||||
var bericht = new Aufraeumbericht(
|
||||
@@ -78,7 +81,7 @@ public sealed class Kratzplatzpflege(
|
||||
if (bericht.Frei > 0)
|
||||
{
|
||||
log.LogInformation(
|
||||
"Kratzplatz aufgeräumt ({Anlass}): {Mb} MB frei, {Dateien} Datei(en).",
|
||||
"Transcode-Cache aufgeräumt ({Anlass}): {Mb} MB frei, {Dateien} Datei(en).",
|
||||
anlass, bericht.Frei / (1024 * 1024), bericht.Dateien);
|
||||
}
|
||||
|
||||
@@ -100,7 +103,7 @@ public sealed class Kratzplatzpflege(
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
var regeln = await raum.ServiceProvider
|
||||
.GetRequiredService<Kratzplatzeinstellungen>().RegelnAsync(ct);
|
||||
.GetRequiredService<Transcodeeinstellungen>().RegelnAsync(ct);
|
||||
|
||||
// **Teilstücke gehen bedingungslos.** Sie entstehen nur beim Bauen,
|
||||
// und wer gerade baut, hat die Datei noch offen — eine liegengebliebene
|
||||
@@ -137,7 +140,7 @@ public sealed class Kratzplatzpflege(
|
||||
.Select(f => new { f.Id, f.ModifiedAt })
|
||||
.ToDictionaryAsync(f => f.Id, f => f.ModifiedAt.ToUnixTimeMilliseconds(), ct);
|
||||
|
||||
foreach (var a in Kratzplatzauswahl.Wegzuwerfen(
|
||||
foreach (var a in Transcodeauswahl.Wegzuwerfen(
|
||||
akten, bekannt, PackageCoordinator.Bauart, regeln, jetzt, pakete.IstInBenutzung))
|
||||
{
|
||||
frei += Loeschen(a.Pfad);
|
||||
@@ -159,6 +162,66 @@ public sealed class Kratzplatzpflege(
|
||||
/// lässt den Server bei jedem Start eine vierzig Gigabyte grosse Datei
|
||||
/// neu durchlesen, um wieder nichts zu finden.
|
||||
/// </remarks>
|
||||
/// <summary>Vorschaublätter, zu denen es keine Datei mehr gibt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Nur verwaist, wie bei den Untertiteln.</b> Weder Alter noch
|
||||
/// Obergrenze: Blätter entstehen beim Abspielen, und wer einen Film
|
||||
/// zweimal im Jahr ansieht, soll beim zweiten Mal nicht wieder warten.
|
||||
/// Sie sind klein — ein ganzer Film liegt bei wenigen Megabyte.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Verwaist heisst: der Namensanfang passt zu nichts.</b> Er trägt
|
||||
/// Kennung und Änderungszeit der Mediendatei; wird sie ersetzt oder
|
||||
/// verschwindet sie, gehören die Blätter zu niemandem mehr. Dafür
|
||||
/// braucht es keine Tabelle.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<(long Frei, int Dateien)> VorschauAufraeumenAsync(CancellationToken ct)
|
||||
{
|
||||
var ordner = Path.Combine(pfade.Transcode, "vorschau");
|
||||
if (!Directory.Exists(ordner)) return (0, 0);
|
||||
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
|
||||
var gefunden = new List<(string Pfad, Guid Id, long Ms)>();
|
||||
foreach (var pfad in Directory.EnumerateFiles(ordner))
|
||||
{
|
||||
var teile = Path.GetFileNameWithoutExtension(pfad).Split('-');
|
||||
if (teile.Length < 2
|
||||
|| !Guid.TryParseExact(teile[0], "N", out var id)
|
||||
|| !long.TryParse(teile[1], out var ms))
|
||||
{
|
||||
// Was hier nicht hingehört, gehört weg.
|
||||
gefunden.Add((pfad, Guid.Empty, -1));
|
||||
continue;
|
||||
}
|
||||
|
||||
gefunden.Add((pfad, id, ms));
|
||||
}
|
||||
|
||||
if (gefunden.Count == 0) return (0, 0);
|
||||
|
||||
var kennungen = gefunden.Where(g => g.Ms >= 0).Select(g => g.Id).Distinct().ToList();
|
||||
var dateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(f => kennungen.Contains(f.Id))
|
||||
.Select(f => new { f.Id, f.ModifiedAt })
|
||||
.ToDictionaryAsync(f => f.Id, f => f.ModifiedAt.ToUnixTimeMilliseconds(), ct);
|
||||
|
||||
long frei = 0;
|
||||
var anzahl = 0;
|
||||
foreach (var g in gefunden)
|
||||
{
|
||||
if (g.Ms >= 0 && dateien.TryGetValue(g.Id, out var ms) && ms == g.Ms) continue;
|
||||
frei += Loeschen(g.Pfad);
|
||||
anzahl++;
|
||||
}
|
||||
|
||||
return (frei, anzahl);
|
||||
}
|
||||
|
||||
private async Task<(long Frei, int Dateien)> UntertitelAufraeumenAsync(CancellationToken ct)
|
||||
{
|
||||
var ordner = Path.Combine(pfade.Transcode, "untertitel");
|
||||
@@ -240,6 +303,7 @@ public sealed class Kratzplatzpflege(
|
||||
("stroeme", stroeme.Wurzel),
|
||||
("packages", pakete.Ordner),
|
||||
("untertitel", Path.Combine(pfade.Transcode, "untertitel")),
|
||||
("vorschau", Path.Combine(pfade.Transcode, "vorschau")),
|
||||
};
|
||||
|
||||
return ordner.Select(o => new Teilbericht(o.Item1, Streamkoordinator.Belegung(o.Item2), Zaehlen(o.Item2)))
|
||||
@@ -137,6 +137,25 @@ public sealed class Untertitelvorrat(
|
||||
if (offen > 0) log.LogInformation("Untertitel: {Anzahl} Datei(en) werden im Hintergrund gelesen.", offen);
|
||||
}
|
||||
|
||||
/// <summary>Ob diese Spur jetzt sofort ausgeliefert werden kann.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Ohne zu warten und ohne anzustossen.</b> Für die HLS-Hauptliste:
|
||||
/// was dort steht, muss ein Spieler in dem Augenblick abholen können, in
|
||||
/// dem er es liest. AVPlayer nimmt ein „gleich" nicht an — eine Spur,
|
||||
/// die beim Abruf noch nicht da ist, lässt ihn die ganze Wiedergabe
|
||||
/// abbrechen, nicht bloss den Untertitel.
|
||||
///
|
||||
/// <b>Eine leere Datei zählt nicht.</b> Sie ist das Ergebnis „diese Spur
|
||||
/// hat keinen Text"; sie anzubieten wäre ein Menüeintrag, der zu nichts
|
||||
/// führt.
|
||||
/// </remarks>
|
||||
public bool Liegtvor(Guid dateiId, DateTimeOffset geaendert, int spur)
|
||||
{
|
||||
var ziel = Pfad(dateiId, geaendert, spur);
|
||||
return File.Exists(ziel) && new FileInfo(ziel).Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>Holt eine Spur — und wartet dabei höchstens <paramref name="geduld"/>.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using Flaro.Core.Media;
|
||||
using Flaro.Data;
|
||||
using Flaro.Media;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Erzeugt Vorschaubilder im Hintergrund, eine Datei nach der anderen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Beim Abspielen, nicht auf Vorrat über den Bestand.</b> Bei 453
|
||||
/// Filmen und 4213 Folgen wäre ein Lauf über alles ein Abend ffmpeg und
|
||||
/// mehrere Gigabyte für Titel, die niemand anfasst. Wer einen Titel
|
||||
/// startet, braucht die Bilder aber selten in den ersten Sekunden —
|
||||
/// bis zum ersten Griff zur Leiste ist der Lauf längst durch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Eine zur Zeit.</b> Jeder Lauf liest eine Mediendatei von vorn bis
|
||||
/// hinten durch; zwei gleichzeitig hiesse zwei Leseköpfe auf derselben
|
||||
/// Platte, und nebenher läuft vielleicht noch ein Umrechnen.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Vorschauvorrat(
|
||||
IServiceScopeFactory scopes,
|
||||
FlaroPaths pfade,
|
||||
Vorschaubilder bilder,
|
||||
ILogger<Vorschauvorrat> log)
|
||||
{
|
||||
private readonly LinkedList<Guid> _reihe = new();
|
||||
private readonly Lock _schloss = new();
|
||||
private bool _arbeitet;
|
||||
|
||||
/// <summary>Welche Datei gerade dran ist — für die Auskunft.</summary>
|
||||
public Guid? Laufende { get; private set; }
|
||||
|
||||
private string Ordner => Path.Combine(pfade.Transcode, "vorschau");
|
||||
|
||||
/// <summary>Der gemeinsame Namensanfang der Blätter einer Datei.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Mit der Änderungszeit, und das ist der Trick.</b> Wird die Datei
|
||||
/// ersetzt, ändert sich der Anfang — die alten Blätter gehören dann
|
||||
/// zu niemandem mehr, und das Aufräumen erkennt sie daran, ohne dass
|
||||
/// irgendwo eine Tabelle mitgeführt werden müsste. Dasselbe Schema
|
||||
/// wie beim Untertitelvorrat.
|
||||
/// </remarks>
|
||||
public static string Praefix(Guid dateiId, DateTimeOffset geaendert) =>
|
||||
$"{dateiId:N}-{geaendert.ToUnixTimeMilliseconds()}";
|
||||
|
||||
/// <summary>Was schon dasteht — oder nichts.</summary>
|
||||
public Vorschauplan? Liegtvor(Guid dateiId, DateTimeOffset geaendert)
|
||||
{
|
||||
var praefix = Praefix(dateiId, geaendert);
|
||||
if (!Directory.Exists(Ordner)) return null;
|
||||
|
||||
var blaetter = Directory.GetFiles(Ordner, $"{praefix}-*.jpg").Length;
|
||||
if (blaetter == 0) return null;
|
||||
|
||||
// Die Masse stehen in der Begleitdatei: sie hängen am
|
||||
// Seitenverhältnis des Films und lassen sich nicht herleiten.
|
||||
var masse = Path.Combine(Ordner, $"{praefix}.masse");
|
||||
if (!File.Exists(masse)) return null;
|
||||
|
||||
var teile = File.ReadAllText(masse).Split('x');
|
||||
if (teile.Length != 2
|
||||
|| !int.TryParse(teile[0], out var breite)
|
||||
|| !int.TryParse(teile[1], out var hoehe))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Vorschauplan(
|
||||
Vorschaubilder.Intervall, Vorschaubilder.Spalten, Vorschaubilder.Zeilen,
|
||||
breite, hoehe, blaetter);
|
||||
}
|
||||
|
||||
/// <summary>Stellt eine Datei an, wenn ihre Bilder noch fehlen.</summary>
|
||||
public void Anstellen(Guid dateiId)
|
||||
{
|
||||
lock (_schloss)
|
||||
{
|
||||
if (Laufende == dateiId || _reihe.Contains(dateiId)) return;
|
||||
_reihe.AddLast(dateiId);
|
||||
if (_arbeitet) return;
|
||||
_arbeitet = true;
|
||||
}
|
||||
|
||||
_ = Task.Run(AbarbeitenAsync);
|
||||
}
|
||||
|
||||
private async Task AbarbeitenAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Guid naechste;
|
||||
lock (_schloss)
|
||||
{
|
||||
if (_reihe.First is null) { _arbeitet = false; Laufende = null; return; }
|
||||
naechste = _reihe.First.Value;
|
||||
_reihe.RemoveFirst();
|
||||
Laufende = naechste;
|
||||
}
|
||||
|
||||
try { await ZiehenAsync(naechste); }
|
||||
catch (Exception fehler)
|
||||
{
|
||||
log.LogWarning(fehler, "Vorschau für {Datei} abgebrochen: {Grund}",
|
||||
naechste, Fehlertext.FuerMenschen(fehler));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ZiehenAsync(Guid dateiId)
|
||||
{
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
|
||||
var datei = await db.MediaFiles.AsNoTracking()
|
||||
.Where(f => f.Id == dateiId)
|
||||
.Select(f => new { f.Path, f.ModifiedAt })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (datei is null || !File.Exists(datei.Path)) return;
|
||||
|
||||
var praefix = Praefix(dateiId, datei.ModifiedAt);
|
||||
if (Liegtvor(dateiId, datei.ModifiedAt) is not null) return;
|
||||
|
||||
var plan = await bilder.ErzeugenAsync(datei.Path, Ordner, praefix);
|
||||
if (plan is not { } fertig) return;
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(Ordner, $"{praefix}.masse"),
|
||||
$"{fertig.KachelBreite}x{fertig.KachelHoehe}");
|
||||
|
||||
log.LogInformation(
|
||||
"Vorschau für {Datei}: {Blaetter} Blatt/Blätter, Kachel {Breite}×{Hoehe}.",
|
||||
Path.GetFileName(datei.Path), fertig.Blaetter, fertig.KachelBreite, fertig.KachelHoehe);
|
||||
}
|
||||
|
||||
/// <summary>Der Pfad eines Blattes — oder <c>null</c>, wenn es das nicht gibt.</summary>
|
||||
public string? Blatt(Guid dateiId, DateTimeOffset geaendert, int nummer)
|
||||
{
|
||||
if (nummer is < 0 or > 999) return null;
|
||||
|
||||
var pfad = Path.Combine(Ordner, $"{Praefix(dateiId, geaendert)}-{nummer:000}.jpg");
|
||||
return File.Exists(pfad) ? pfad : null;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ namespace Flaro.Core.Auth;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Der Code allein holt nie ein Token ab.</b> Dafür gibt es den Abholschein,
|
||||
/// den nur das anfragende Gerät kennt und der hier nur als Fingerabdruck
|
||||
/// den nur das anfragende Gerät kennt und der hier nur als Fingerfingerprint
|
||||
/// liegt. Ohne ihn könnte jemand sechsstellige Zahlen durchprobieren — eine
|
||||
/// Million Versuche sind nichts, und am Ende stünde ein fremdes Gerät in der
|
||||
/// Mediathek.
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace Flaro.Core.Auth;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Übernommen ist die Geheimnisdisziplin.</b> Wie bei
|
||||
/// <see cref="DeviceToken"/> liegt hier nur der Fingerabdruck; den Link
|
||||
/// <see cref="DeviceToken"/> liegt hier nur der Fingerfingerprint; den Link
|
||||
/// selbst bekommt man genau einmal zu sehen, beim Erstellen. Wer die
|
||||
/// Datenbank liest, kann daraus keine Einladung bauen.
|
||||
/// </para>
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Flaro.Core.Auth;
|
||||
/// <summary>Erzeugt und verfingert Geheimnisse, die Clients vorzeigen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Abgelegt wird nur der Fingerabdruck, nie das Geheimnis.</b> Wer die
|
||||
/// <b>Abgelegt wird nur der Fingerfingerprint, nie das Geheimnis.</b> Wer die
|
||||
/// Datenbank erbeutet, hat damit Hashes und keine Zugänge. Dass das überhaupt
|
||||
/// erwähnt werden muss: bei Medienservern liegen Token regelmässig im
|
||||
/// Klartext, und ein einziges Datenbankleck gibt dann jedem alles.
|
||||
@@ -23,7 +23,7 @@ public static class Secrets
|
||||
public static string Create() =>
|
||||
Base64UrlEncode(RandomNumberGenerator.GetBytes(Bytes));
|
||||
|
||||
/// <summary>Der Fingerabdruck, wie er in der Datenbank steht.</summary>
|
||||
/// <summary>Der Fingerfingerprint, wie er in der Datenbank steht.</summary>
|
||||
public static string Fingerprint(string secret) =>
|
||||
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(secret)));
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Flaro.Core.Delivery;
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum der Name alles trägt.</b> Im Kratzplatz liegen Dateien, zu denen
|
||||
/// <b>Warum der Name alles trägt.</b> Im Transcode-Cache liegen Dateien, zu denen
|
||||
/// es keine Tabelle gibt. Ein Aufräumer muss allein am Namen erkennen können,
|
||||
/// wozu eine Datei gehört und ob sie noch gilt — sonst bliebe ihm nur,
|
||||
/// alles zu behalten oder alles wegzuwerfen.
|
||||
|
||||
+6
-6
@@ -21,7 +21,7 @@ namespace Flaro.Core.Delivery;
|
||||
/// <param name="ObergrenzeBytes">Wie viel insgesamt liegen bleiben darf; <c>0</c> heisst unbegrenzt.</param>
|
||||
/// <param name="Unbenutzt">Ab wann eine unbenutzte Datei auch unterhalb der Grenze geht; <c>Zero</c> heisst nie.</param>
|
||||
/// <param name="Schonfrist">Wie lange eine frisch gebrauchte Datei in jedem Fall bleibt.</param>
|
||||
public sealed record Kratzplatzregeln(
|
||||
public sealed record Transcoderegeln(
|
||||
long ObergrenzeBytes,
|
||||
TimeSpan Unbenutzt,
|
||||
TimeSpan Schonfrist)
|
||||
@@ -34,7 +34,7 @@ public sealed record Kratzplatzregeln(
|
||||
/// Schonfrist decken die Lücke zwischen zwei Teilanfragen und einen
|
||||
/// Neustart, der den Benutzungszähler verliert.
|
||||
/// </remarks>
|
||||
public static Kratzplatzregeln Vorgabe { get; } =
|
||||
public static Transcoderegeln Vorgabe { get; } =
|
||||
new(20L * 1024 * 1024 * 1024, TimeSpan.FromDays(7), TimeSpan.FromMinutes(30));
|
||||
|
||||
/// <summary>Nimmt entgegen, was eingestellt wurde, und biegt Unsinn zurecht.</summary>
|
||||
@@ -45,13 +45,13 @@ public sealed record Kratzplatzregeln(
|
||||
/// Datei weg — deshalb eine Untergrenze von fünf Minuten. Negative Werte
|
||||
/// gelten als „aus".
|
||||
/// </remarks>
|
||||
public static Kratzplatzregeln Geprueft(double obergrenzeGb, int unbenutztTage, int schonfristMinuten) =>
|
||||
public static Transcoderegeln Geprueft(double obergrenzeGb, int unbenutztTage, int schonfristMinuten) =>
|
||||
new(obergrenzeGb <= 0 ? 0 : (long)(obergrenzeGb * 1024 * 1024 * 1024),
|
||||
unbenutztTage <= 0 ? TimeSpan.Zero : TimeSpan.FromDays(Math.Min(unbenutztTage, 3650)),
|
||||
TimeSpan.FromMinutes(Math.Clamp(schonfristMinuten, 5, 1440)));
|
||||
}
|
||||
|
||||
/// <summary>Eine Datei im Kratzplatz, so wie sie dort liegt.</summary>
|
||||
/// <summary>Eine Datei im Transcode-Cache, so wie sie dort liegt.</summary>
|
||||
///
|
||||
/// <param name="Benutzt">
|
||||
/// Wann sie zuletzt gebraucht wurde — die Änderungszeit der Datei selbst.
|
||||
@@ -66,7 +66,7 @@ public readonly record struct Paketakte(
|
||||
Paketname.Teile? Name);
|
||||
|
||||
/// <summary>Welche Dateien weggeräumt werden — reine Rechnung, ohne Platte.</summary>
|
||||
public static class Kratzplatzauswahl
|
||||
public static class Transcodeauswahl
|
||||
{
|
||||
/// <param name="bekannt">Kennung → Änderungszeitpunkt der Quelle, aus der Mediathek.</param>
|
||||
/// <param name="bauart">Wie der Server heute baut.</param>
|
||||
@@ -75,7 +75,7 @@ public static class Kratzplatzauswahl
|
||||
IReadOnlyList<Paketakte> akten,
|
||||
IReadOnlyDictionary<Guid, long> bekannt,
|
||||
int bauart,
|
||||
Kratzplatzregeln regeln,
|
||||
Transcoderegeln regeln,
|
||||
DateTimeOffset jetzt,
|
||||
Func<string, bool> inBenutzung)
|
||||
{
|
||||
@@ -1,6 +1,34 @@
|
||||
namespace Flaro.Core.Libraries;
|
||||
|
||||
public enum LibraryKind { Movies, Shows }
|
||||
public enum LibraryKind
|
||||
{
|
||||
/// <summary>Filme, mit Abgleich bei der Metadatenquelle.</summary>
|
||||
Movies,
|
||||
|
||||
/// <summary>Serien mit Staffeln und Folgen.</summary>
|
||||
Shows,
|
||||
|
||||
/// <summary>Einzelne Videos, ohne Abgleich.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Für alles, was in keinem Katalog steht.</b> Urlaubsaufnahmen,
|
||||
/// Mitschnitte, Vorträge. Der Dateiname ist der Titel — kein Zerlegen
|
||||
/// nach „Titel (Jahr)", kein Zusammenlegen zweier Dateien zu einer
|
||||
/// Fassung, keine Anfrage bei TMDB. Jedes dieser Verfahren ist bei
|
||||
/// Filmen richtig und hier falsch: „Sommer 2019 Teil 2" ist kein Film
|
||||
/// von 2019, und zwei Aufnahmen desselben Tages sind nicht zwei
|
||||
/// Fassungen derselben Sache.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Der Unterordner wird zur Kategorie.</b> Statt eine eigene
|
||||
/// Gruppierung zu bauen, bekommt jedes Video den Namen seines Ordners
|
||||
/// als Genre. Damit greifen Filter, Kategorienansicht und Suche ohne
|
||||
/// eine einzige neue Zeile in der Oberfläche.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Videos,
|
||||
}
|
||||
|
||||
/// <summary>Ein Verzeichnis mit Medien, wie der Betreiber es eingerichtet hat.</summary>
|
||||
///
|
||||
|
||||
@@ -64,3 +64,157 @@ public sealed class Credit
|
||||
/// </remarks>
|
||||
public int Order { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Eine Bewertung von anderswo — IMDb, Rotten Tomatoes, Metacritic.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Eine Tabelle und kein Feld je Quelle.</b> <c>Movie.Rating</c> ist ein
|
||||
/// Skalar ohne Quellenangabe, und das war richtig, solange es eine Quelle
|
||||
/// gab. Für drei weitere wären es drei Spalten — und bei der vierten wieder
|
||||
/// eine Migration. So ist jede Quelle eine Zeile.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Zwei Werte, und beide gebraucht.</b> <see cref="Wert"/> ist auf 0 bis
|
||||
/// 10 gebracht, damit sich Quellen vergleichen und sortieren lassen;
|
||||
/// <see cref="Anzeige"/> ist, was dastehen soll. Rotten Tomatoes in „8,7 von
|
||||
/// 10" umzurechnen wäre eine Lüge über die Quelle — dort steht 87 %, und
|
||||
/// genau das kennt man von dort.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Fremdbewertung
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||
public Guid? MovieId { get; init; }
|
||||
public Guid? SeriesId { get; init; }
|
||||
|
||||
/// <summary>„imdb", „rottenTomatoes" oder „metacritic".</summary>
|
||||
public required string Quelle { get; set; }
|
||||
|
||||
/// <summary>Auf 0 bis 10 gebracht — zum Vergleichen, nicht zum Anzeigen.</summary>
|
||||
public double Wert { get; set; }
|
||||
|
||||
/// <summary>Was dastehen soll: „7,4", „87 %", „62".</summary>
|
||||
public required string Anzeige { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Ein Stichwort der Metadatenquelle, einmal je Name.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Feiner als ein Genre, und deshalb eine eigene Tabelle.</b> „Stirb
|
||||
/// langsam" ist kein Familienfilm und trägt trotzdem „christmas". Eine
|
||||
/// Weihnachtsreihe nach Genres enthielte jeden Zeichentrickfilm und diesen
|
||||
/// Film nicht — nach Stichwörtern stimmt es.
|
||||
///
|
||||
/// Kleingeschrieben abgelegt: TMDB liefert „Christmas" und „christmas" je
|
||||
/// nach Eintrag, und zwei Schreibweisen wären zwei Stichwörter.
|
||||
/// </remarks>
|
||||
public sealed class Stichwort
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||
public required string Name { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Verbindet ein Stichwort mit einem Film oder einer Serie.</summary>
|
||||
public sealed class Stichwortlink
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||
public required Guid StichwortId { get; init; }
|
||||
public Guid? MovieId { get; init; }
|
||||
public Guid? SeriesId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Was für ein Abschnitt ein Segment bezeichnet.</summary>
|
||||
public enum Segmentart
|
||||
{
|
||||
/// <summary>Der Vorspann.</summary>
|
||||
Intro,
|
||||
/// <summary>Der Abspann.</summary>
|
||||
Abspann,
|
||||
}
|
||||
|
||||
/// <summary>Ein überspringbarer Abschnitt einer Folge.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Nur bei Folgen, nicht bei Filmen.</b> Erkannt wird ein Vorspann
|
||||
/// dadurch, dass er sich wiederholt — über die Folgen einer Staffel hinweg.
|
||||
/// Ein Film hat nichts, womit er sich vergleichen liesse, und sein Abspann
|
||||
/// ist nicht derselbe wie der eines anderen Films.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Zeiten in Sekunden, nicht in Bildern.</b> Dieselbe Folge liegt
|
||||
/// vielleicht in zwei Fassungen mit verschiedener Bildrate vor; die Stelle
|
||||
/// im Film ist dieselbe.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Segment
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||
/// <summary>Zu welcher Folge sie gehört — oder zu welchem Film.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Genau eines von beiden.</b> Gefunden werden sie auf
|
||||
/// verschiedenen Wegen: bei einer Folge am Ton, weil der Vorspann sich
|
||||
/// über die Staffel wiederholt, bei einem Film am Bild, weil ein Film
|
||||
/// nichts hat, womit er sich vergleichen liesse. Was dabei herauskommt,
|
||||
/// ist dasselbe — eine Stelle, an der man springen darf.
|
||||
/// </remarks>
|
||||
public Guid? EpisodeId { get; init; }
|
||||
public Guid? MovieId { get; init; }
|
||||
public required Segmentart Art { get; init; }
|
||||
public required double VonSekunden { get; set; }
|
||||
public required double BisSekunden { get; set; }
|
||||
|
||||
/// <summary>Wann sie gefunden wurde.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Damit ein späterer Lauf weiss, was er nicht noch einmal rechnen muss.
|
||||
/// Eine Staffel abzutasten dauert Minuten; sie bei jedem Scan erneut
|
||||
/// abzutasten wäre eine Dauerlast ohne Ertrag.
|
||||
/// </remarks>
|
||||
public DateTimeOffset ErkanntAm { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Dass in einer Staffel schon einmal gesucht wurde.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Weil „nichts gefunden" auch ein Ergebnis ist.</b> Ohne diese Zeile
|
||||
/// liess sich nur ablesen, was gefunden wurde. Eine Staffel ohne Abspann —
|
||||
/// es gibt genug davon — sah damit bei jedem Scan aus wie eine, in der noch
|
||||
/// niemand nachgesehen hat, und wurde wieder und wieder abgetastet. Das
|
||||
/// heisst: bei jedem Scan jede Tonspur jeder Folge noch einmal von der
|
||||
/// Platte lesen, für immer, ohne Ertrag.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Die Folgenzahl ist der Auslöser.</b> Kommt eine Folge dazu, passt die
|
||||
/// Zahl nicht mehr und es wird neu gerechnet — genau dann, wenn es etwas
|
||||
/// Neues zu vergleichen gibt. Ein Zeitstempel allein täte das nicht: eine
|
||||
/// Staffel, die sich nie ändert, hat nie einen Grund für einen zweiten
|
||||
/// Lauf.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Segmentsuche
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||
|
||||
/// <summary>Eine Staffel einer Serie — oder ein Film.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Genau eines von beiden, wie bei <see cref="Segment"/>. Bei einer
|
||||
/// Staffel steht die Nummer daneben; ein Film hat keine.
|
||||
/// </remarks>
|
||||
public Guid? SeriesId { get; init; }
|
||||
public int? Staffel { get; init; }
|
||||
public Guid? MovieId { get; init; }
|
||||
|
||||
public required Segmentart Art { get; init; }
|
||||
|
||||
/// <summary>Wie viele Folgen die Staffel hatte, als gesucht wurde.</summary>
|
||||
///
|
||||
/// <remarks>Bei einem Film immer eins — es gibt nichts zu zählen.</remarks>
|
||||
public required int Folgen { get; set; }
|
||||
|
||||
public DateTimeOffset GelaufenAm { get; set; }
|
||||
}
|
||||
|
||||
@@ -302,6 +302,24 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert
|
||||
/// </remarks>
|
||||
public string? LogoImage { get; set; }
|
||||
|
||||
/// <summary>Weitere Bilder für den Aufmacher, durch Komma getrennt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum nicht eine Tabelle.</b> Es sind höchstens fünf
|
||||
/// Zwischenspeicherschlüssel je Titel, sie werden immer zusammen
|
||||
/// gelesen und immer zusammen gesetzt. Eine Tabelle dafür wäre eine
|
||||
/// Verknüpfung, ein Löschverhalten und eine Reihenfolgespalte — für
|
||||
/// eine Liste, die in eine Zeile passt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Getrennt vom Hintergrund.</b> Der bleibt, was er ist: das eine
|
||||
/// Bild hinter Kacheln und Kopfzeilen. Hier stehen die grossen
|
||||
/// Aufnahmen für das Karussell, und nur die wechseln.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? Aufmacherbilder { get; set; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
@@ -497,6 +515,24 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert
|
||||
/// </remarks>
|
||||
public string? LogoImage { get; set; }
|
||||
|
||||
/// <summary>Weitere Bilder für den Aufmacher, durch Komma getrennt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum nicht eine Tabelle.</b> Es sind höchstens fünf
|
||||
/// Zwischenspeicherschlüssel je Titel, sie werden immer zusammen
|
||||
/// gelesen und immer zusammen gesetzt. Eine Tabelle dafür wäre eine
|
||||
/// Verknüpfung, ein Löschverhalten und eine Reihenfolgespalte — für
|
||||
/// eine Liste, die in eine Zeile passt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Getrennt vom Hintergrund.</b> Der bleibt, was er ist: das eine
|
||||
/// Bild hinter Kacheln und Kopfzeilen. Hier stehen die grossen
|
||||
/// Aufnahmen für das Karussell, und nur die wechseln.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? Aufmacherbilder { get; set; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Flaro.Core.Library;
|
||||
|
||||
/// <summary>Ein Anlass, zu dem eine eigene Reihe auf der Startseite steht.</summary>
|
||||
///
|
||||
/// <param name="Name">Was über der Reihe steht — „Zu Halloween".</param>
|
||||
/// <param name="Von">Tag im Jahr, ab dem sie erscheint, als „MM-TT".</param>
|
||||
/// <param name="Bis">Tag im Jahr, bis zu dem sie bleibt, als „MM-TT".</param>
|
||||
/// <param name="Stichworte">Stichwörter der Metadatenquelle, kleingeschrieben.</param>
|
||||
/// <param name="Genres">Genres, falls Stichwörter allein zu wenig hergeben.</param>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Einstellbar und nicht eingebaut.</b> Halloween und Weihnachten kennt
|
||||
/// jeder; der 1. August ist Schweizer Nationalfeiertag, der 4. Juli
|
||||
/// amerikanischer, und der Geburtstag des Betreibers steht in keiner
|
||||
/// Bibliothek. Eine feste Liste im Quelltext hiesse: für jeden Anlass eine
|
||||
/// neue Fassung.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Stichwörter zuerst, Genres als Zugabe.</b> „Stirb langsam" ist kein
|
||||
/// Familienfilm und trägt trotzdem „christmas" — eine Weihnachtsreihe nach
|
||||
/// Genres enthielte jeden Zeichentrickfilm und diesen Film nicht.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record Saisonanlass(
|
||||
string Name,
|
||||
string Von,
|
||||
string Bis,
|
||||
IReadOnlyList<string> Stichworte,
|
||||
IReadOnlyList<string> Genres)
|
||||
{
|
||||
/// <summary>Was ohne eigene Einstellung dasteht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Vier, und bewusst nicht mehr.</b> Eine lange Vorgabeliste wäre
|
||||
/// eine Meinung darüber, was jemand feiert. Diese vier sind weit genug
|
||||
/// verbreitet, um als Beispiel zu taugen, und zeigen zugleich die drei
|
||||
/// Formen: ein Zeitraum im Jahr, einer über den Jahreswechsel, und
|
||||
/// einer, der sich auf Genres stützt.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<Saisonanlass> Vorgabe { get; } =
|
||||
[
|
||||
new("Zu Halloween", "10-01", "11-01",
|
||||
["halloween", "haunted house", "witch", "slasher", "zombie"],
|
||||
["Horror"]),
|
||||
new("Zur Weihnachtszeit", "12-01", "12-27",
|
||||
["christmas", "santa claus", "christmas eve"],
|
||||
[]),
|
||||
new("Zum Jahreswechsel", "12-28", "01-02",
|
||||
["new year's eve", "new year"],
|
||||
[]),
|
||||
new("Für den Sommer", "06-15", "08-31",
|
||||
["summer", "beach", "summer vacation", "road trip"],
|
||||
[]),
|
||||
];
|
||||
|
||||
/// <summary>Ob dieser Anlass an diesem Tag gilt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Über den Jahreswechsel hinweg.</b> „28.12. bis 02.01." ist kein
|
||||
/// Fehler, sondern der Normalfall für Silvester — ein Vergleich, der nur
|
||||
/// „von ≤ heute ≤ bis" kennt, fände diesen Zeitraum nie.
|
||||
/// </remarks>
|
||||
public bool Gilt(DateTimeOffset tag)
|
||||
{
|
||||
if (Tagnummer(Von) is not { } von || Tagnummer(Bis) is not { } bis) return false;
|
||||
|
||||
var heute = tag.Month * 100 + tag.Day;
|
||||
return von <= bis
|
||||
? heute >= von && heute <= bis
|
||||
: heute >= von || heute <= bis;
|
||||
}
|
||||
|
||||
/// <summary>„12-24" wird zu 1224 — oder zu <c>null</c>, wenn es keiner ist.</summary>
|
||||
private static int? Tagnummer(string roh)
|
||||
{
|
||||
var teile = (roh ?? "").Split('-');
|
||||
if (teile.Length != 2) return null;
|
||||
if (!int.TryParse(teile[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var monat)) return null;
|
||||
if (!int.TryParse(teile[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var tag)) return null;
|
||||
if (monat is < 1 or > 12 || tag is < 1 or > 31) return null;
|
||||
|
||||
return monat * 100 + tag;
|
||||
}
|
||||
|
||||
/// <summary>Ob der Anlass überhaupt etwas finden kann.</summary>
|
||||
public bool Brauchbar =>
|
||||
!string.IsNullOrWhiteSpace(Name)
|
||||
&& Tagnummer(Von) is not null && Tagnummer(Bis) is not null
|
||||
&& (Stichworte.Count > 0 || Genres.Count > 0);
|
||||
}
|
||||
@@ -21,6 +21,15 @@ public enum Scanphase
|
||||
/// <summary>Verwaistes wird entfernt, Doppeltes zusammengelegt.</summary>
|
||||
Aufraeumen,
|
||||
|
||||
/// <summary>Vorspann und Abspann werden gesucht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Derselbe Weg wie beim Scan, und aus demselben Grund: der Lauf geht
|
||||
/// über Stunden, und ohne eine Zeile, die mitzählt, sieht die
|
||||
/// Verwaltung minutenlang so aus, als täte sich nichts.
|
||||
/// </remarks>
|
||||
Segmente,
|
||||
|
||||
/// <summary>Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
@@ -70,6 +79,7 @@ public readonly record struct Scanschritt(Scanphase Phase, string Was, int Erled
|
||||
Scanphase.Holen => $"Hole Metadaten zu {Was}{zaehler}",
|
||||
Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}",
|
||||
Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen",
|
||||
Scanphase.Segmente => $"Suche Vorspann und Abspann: {Was}{zaehler}",
|
||||
Scanphase.Vermerk => Was,
|
||||
_ => Was,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user