Compare commits
84
Commits
532473b1f9
..
main
@@ -36,6 +36,9 @@ struct FlaroApp: App {
|
|||||||
@State private var network = NetworkConditions()
|
@State private var network = NetworkConditions()
|
||||||
@State private var homeLayout = HomeLayoutSettings()
|
@State private var homeLayout = HomeLayoutSettings()
|
||||||
@State private var diagnostics = PlaybackDiagnostics()
|
@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
|
@State private var downloads: DownloadCoordinator
|
||||||
|
|
||||||
private let modelContainer: ModelContainer
|
private let modelContainer: ModelContainer
|
||||||
@@ -92,6 +95,7 @@ struct FlaroApp: App {
|
|||||||
.environment(downloads)
|
.environment(downloads)
|
||||||
.environment(homeLayout)
|
.environment(homeLayout)
|
||||||
.environment(diagnostics)
|
.environment(diagnostics)
|
||||||
|
.environment(post)
|
||||||
.environment(theme)
|
.environment(theme)
|
||||||
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
|
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
|
||||||
//
|
//
|
||||||
@@ -117,6 +121,8 @@ struct FlaroApp: App {
|
|||||||
backend: session.backend,
|
backend: session.backend,
|
||||||
coordinator: downloads
|
coordinator: downloads
|
||||||
).refill()
|
).refill()
|
||||||
|
// Was im Flugzeug liegengeblieben ist, geht jetzt raus.
|
||||||
|
await post.zustellen(über: session.backend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modelContainer(modelContainer)
|
.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": {
|
"4K": {
|
||||||
"comment": "A label for a 4K video.",
|
"comment": "A label for a 4K video.",
|
||||||
"isCommentAutoGenerated": true
|
"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": {
|
"downloads.failed": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -1722,7 +1750,7 @@
|
|||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Staffel %lld · Episode %lld"
|
"value": "Staffel %lld · Folge %lld"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"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": {
|
"filter.filters": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -2122,6 +2167,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"home.downloaded": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Heruntergeladen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Downloaded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"home.empty.description": {
|
"home.empty.description": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"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": {
|
"home.rowsHeader": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -2468,6 +2545,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"languages.audioShort": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Ton"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Audio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"languages.avoidAudioDescription": {
|
"languages.avoidAudioDescription": {
|
||||||
"extractionState": "manual",
|
"extractionState": "manual",
|
||||||
"localizations": {
|
"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": {
|
"languages.headerExplanation": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"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": {
|
"languages.serverDefault": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"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": {
|
"languages.subtitlesHeader": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -2736,6 +2919,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"languages.subtitlesShort": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Untertitel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Subtitles"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"languages.title": {
|
"languages.title": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -3905,7 +4105,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player.nextEpisode": {
|
"player.nextEpisode": {
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -4041,6 +4240,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"player.previousEpisode": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Vorige Folge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Previous episode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"player.quality": {
|
"player.quality": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -5039,6 +5254,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"season.short %@": {
|
||||||
|
"comment": "A label that shows the number of the selected season.",
|
||||||
|
"isCommentAutoGenerated": true
|
||||||
|
},
|
||||||
"season.short %lld": {
|
"season.short %lld": {
|
||||||
"comment": "Platzhalter: Staffelnummer",
|
"comment": "Platzhalter: Staffelnummer",
|
||||||
"extractionState": "stale",
|
"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": {
|
"settings.storageLimit": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|||||||
@@ -196,9 +196,65 @@ struct DownloadService {
|
|||||||
quality: DownloadQuality = .original,
|
quality: DownloadQuality = .original,
|
||||||
audioStream: MediaStream? = nil
|
audioStream: MediaStream? = nil
|
||||||
) throws(DownloadRefusal) -> DownloadItem {
|
) throws(DownloadRefusal) -> DownloadItem {
|
||||||
try coordinator.enqueue(
|
let eintrag = try coordinator.enqueue(
|
||||||
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
|
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.
|
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
|
||||||
@@ -294,6 +350,7 @@ struct DownloadService {
|
|||||||
_ = try coordinator.enqueue(
|
_ = try coordinator.enqueue(
|
||||||
try plan(for: detailed, quality: quality, audioStream: ton)
|
try plan(for: detailed, quality: quality, audioStream: ton)
|
||||||
)
|
)
|
||||||
|
plakatHolen(for: detailed)
|
||||||
accepted += 1
|
accepted += 1
|
||||||
} catch DownloadRefusal.noDownloadableFile {
|
} catch DownloadRefusal.noDownloadableFile {
|
||||||
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
|
// 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
|
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
|
||||||
// verloren.
|
// verloren.
|
||||||
Task { await LibraryCache.shared.clearAll() }
|
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
|
phase = .signedOut
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,14 +475,45 @@ final class FlaroSession {
|
|||||||
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
|
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
|
||||||
// Server das, und es ist kein Grund, jemanden abzumelden.
|
// Server das, und es ist kein Grund, jemanden abzumelden.
|
||||||
//
|
//
|
||||||
// **Trotzdem mit Server:** sonst stünde beim Erneut-Versuchen
|
// **Und auch kein Grund, vor der Tür stehen zu bleiben.** Vorher
|
||||||
// kein Konto da, und das Heruntergeladene wäre unerreichbar.
|
// endete es hier bei einem Fehlerbildschirm mit zwei Knöpfen:
|
||||||
phase = .failed(message: ErrorText.describe(error))
|
// „Erneut versuchen" und „Abmelden". Beide nützen im Flugzeug
|
||||||
activeKind = .flaro
|
// 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
|
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?) {
|
private func rememberFlaro(account: FlaroAccount, token: String?) {
|
||||||
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
|
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
|
||||||
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
|
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ enum HeroArtwork {
|
|||||||
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
|
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
|
||||||
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
|
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
|
||||||
// das der Serie — und erst danach das Vorschaubild der Folge.
|
// 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>()
|
var gesehen = Set<String>()
|
||||||
return pfade
|
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 MediaKit
|
||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
@@ -19,6 +20,14 @@ import Observation
|
|||||||
final class HomeFeed {
|
final class HomeFeed {
|
||||||
private(set) var state: LoadState<[MediaRowGroup]> = .loading
|
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.
|
/// Holt die Reihen vom Server.
|
||||||
///
|
///
|
||||||
/// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe
|
/// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe
|
||||||
@@ -29,6 +38,24 @@ final class HomeFeed {
|
|||||||
state = await LoadState.load(keepingPrevious: state) {
|
state = await LoadState.load(keepingPrevious: state) {
|
||||||
try await backend.homeRows()
|
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)
|
#if os(tvOS)
|
||||||
// Das Regal über der App im Menü. Es wird gezeichnet, wenn die App
|
// 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.
|
// **nicht** läuft — also legen wir hier ab, was dort stehen soll.
|
||||||
@@ -38,10 +65,33 @@ final class HomeFeed {
|
|||||||
#endif
|
#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.
|
/// Die Reihen in der eingestellten Form.
|
||||||
func rows(for layout: HomeLayoutSettings) -> [MediaRowGroup] {
|
///
|
||||||
guard case .loaded(let alle) = state else { return [] }
|
/// `downloaded` kommt von aussen, weil `HomeFeed` nichts von Downloads
|
||||||
return layout.apply(to: prepared(alle, layout: layout))
|
/// 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
|
// MARK: - Ordnen
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ final class LanguagePreferences {
|
|||||||
static let avoidsAudioDescription = "flaro.languages.avoidsAudioDescription"
|
static let avoidsAudioDescription = "flaro.languages.avoidsAudioDescription"
|
||||||
static let perLibrary = "flaro.languages.library"
|
static let perLibrary = "flaro.languages.library"
|
||||||
static let perSeries = "flaro.languages.series"
|
static let perSeries = "flaro.languages.series"
|
||||||
|
static let perSeriesSubtitles = "flaro.languages.seriesSubtitles"
|
||||||
static let rememberedTracks = "flaro.languages.seriesTrack"
|
static let rememberedTracks = "flaro.languages.seriesTrack"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +99,14 @@ final class LanguagePreferences {
|
|||||||
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
|
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
|
||||||
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
|
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
|
||||||
/// abzulegen hiesse dort, den Fehler festzuschreiben.
|
/// 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] {
|
private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] {
|
||||||
didSet {
|
didSet {
|
||||||
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
|
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
|
||||||
@@ -117,6 +126,8 @@ final class LanguagePreferences {
|
|||||||
(defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true
|
(defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true
|
||||||
self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:]
|
self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:]
|
||||||
self.perSeries = defaults.dictionary(forKey: Key.perSeries) 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 {
|
self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap {
|
||||||
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
|
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
|
||||||
} ?? [:]
|
} ?? [:]
|
||||||
@@ -136,6 +147,34 @@ final class LanguagePreferences {
|
|||||||
perSeries[id] = code
|
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? {
|
func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? {
|
||||||
rememberedTracks[id]
|
rememberedTracks[id]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ final class PlaybackSettings {
|
|||||||
static let cellularQuality = "flaro.playback.quality.cellular"
|
static let cellularQuality = "flaro.playback.quality.cellular"
|
||||||
static let autoSkipIntro = "flaro.playback.autoSkipIntro"
|
static let autoSkipIntro = "flaro.playback.autoSkipIntro"
|
||||||
static let autoSkipCredits = "flaro.playback.autoSkipCredits"
|
static let autoSkipCredits = "flaro.playback.autoSkipCredits"
|
||||||
|
static let skipExtraScenes = "flaro.playback.skipExtraScenes"
|
||||||
static let autoPlayNext = "flaro.playback.autoPlayNext"
|
static let autoPlayNext = "flaro.playback.autoPlayNext"
|
||||||
static let locksLandscape = "flaro.playback.locksLandscape"
|
static let locksLandscape = "flaro.playback.locksLandscape"
|
||||||
static let rewindSeconds = "flaro.playback.rewindSeconds"
|
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 cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
|
||||||
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
|
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
|
||||||
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
|
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) } }
|
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
|
||||||
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
|
/// 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.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
|
||||||
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) 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.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
|
||||||
|
|
||||||
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) 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.
|
/// und wäre für VoiceOver gar nicht mehr da.
|
||||||
struct QuickPlayTile<Card: View>: View {
|
struct QuickPlayTile<Card: View>: View {
|
||||||
let item: MediaItem
|
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 —
|
/// **Nur beim Weiterschauen.** Dort ist die Absicht eindeutig: der Titel
|
||||||
/// sonst hätte man nicht gesucht. Ein Tipp, der sofort abspielt, ist auf
|
/// steht in dieser Reihe, *weil* man ihn angefangen hat.
|
||||||
/// der Startseite richtig (die Kachel stand schon da, man kennt sie) und
|
///
|
||||||
/// in einer Trefferliste falsch: der erste Treffer ist eine Vermutung des
|
/// **Überall sonst führt der Tipp auf die Seite.** Vorher war es
|
||||||
/// Servers, keine Entscheidung des Nutzers.
|
/// umgekehrt, mit dem Argument „wer eine Kachel antippt, weiss meistens
|
||||||
var opensDetails: Bool = false
|
/// 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
|
@ViewBuilder let card: () -> Card
|
||||||
|
|
||||||
@Environment(FlaroSession.self) private var session
|
@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
|
/// 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
|
/// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine
|
||||||
/// Sammlung hat nichts abzuspielen, und in der Suche ist es gewollt.
|
/// Sammlung hat nichts abzuspielen, und überall ausser beim
|
||||||
private var tapOpensDetails: Bool { isMac || opensDetails || !isPlayable }
|
/// Weiterschauen ist die Seite das Ziel.
|
||||||
|
private var tapOpensDetails: Bool { isMac || !startsPlayback || !isPlayable }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
kachel
|
kachel
|
||||||
@@ -84,6 +89,11 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
.overlay(alignment: .topTrailing) {
|
.overlay(alignment: .topTrailing) {
|
||||||
if isPlayable, isMac { playButton }
|
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
|
.onContinuousHover { phase in
|
||||||
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
|
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
|
||||||
// der iOS-Bau und läuft trotzdem auf dem Mac.
|
// der iOS-Bau und läuft trotzdem auf dem Mac.
|
||||||
@@ -99,11 +109,9 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
// Kachel steht.
|
// Kachel steht.
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if isPlayable {
|
if isPlayable {
|
||||||
// **Am Telefon und am iPad führt der Weg zur Detailseite
|
// **Nur dort, wo der Tipp abspielt.** Sonst führt er
|
||||||
// hierher.** Antippen spielt dort ab; wer stattdessen
|
// schon auf die Seite, und ein zweiter Weg dorthin im Menü
|
||||||
// nachsehen will, was das überhaupt ist, drückt lang. Am Mac
|
// wäre ein Eintrag, der nichts Neues anbietet.
|
||||||
// ist es umgekehrt: dort öffnet der Klick die Seite, und zum
|
|
||||||
// Starten gibt es den Knopf beim Überfahren.
|
|
||||||
if !tapOpensDetails {
|
if !tapOpensDetails {
|
||||||
Button("hero.details", systemImage: "info.circle") { push(route) }
|
Button("hero.details", systemImage: "info.circle") { push(route) }
|
||||||
Divider()
|
Divider()
|
||||||
@@ -199,14 +207,11 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
|
|
||||||
/// Die Kachel selbst — und was ein Antippen bedeutet.
|
/// Die Kachel selbst — und was ein Antippen bedeutet.
|
||||||
///
|
///
|
||||||
/// **Am Telefon und am iPad spielt es ab.** Wer eine Kachel antippt, weiss
|
/// **Der Tipp führt auf die Detailseite**, am Telefon wie am iPad wie am
|
||||||
/// meistens schon, was er sehen will; der Umweg über die Detailseite
|
/// Mac. Nur in „Weiterschauen" spielt er ab: dort steht der Titel,
|
||||||
/// kostete zwei Schritte für eine Absicht. Die Seite ist weiter da — über
|
/// *weil* man ihn angefangen hat. Abspielen ist sonst einen Schritt
|
||||||
/// langes Drücken, wo auch „gesehen" und „entfernen" stehen.
|
/// weiter — über den Knopf auf der Seite, über langes Drücken, oder am
|
||||||
///
|
/// Mac über den Knopf beim Überfahren.
|
||||||
/// **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.
|
|
||||||
///
|
///
|
||||||
/// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg
|
/// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg
|
||||||
/// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im
|
/// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im
|
||||||
@@ -247,6 +252,20 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
.accessibilityHidden(true)
|
.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.
|
/// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist.
|
||||||
///
|
///
|
||||||
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
|
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die Detailseite eines Titels.
|
/// Die Detailseite eines Titels.
|
||||||
@@ -11,6 +12,7 @@ struct MediaDetailView: View {
|
|||||||
let route: MediaRoute
|
let route: MediaRoute
|
||||||
|
|
||||||
@Environment(FlaroSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@State private var state: LoadState<MediaItem> = .loading
|
@State private var state: LoadState<MediaItem> = .loading
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -50,10 +52,44 @@ struct MediaDetailView: View {
|
|||||||
.task(id: route.ratingKey) { await load() }
|
.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 {
|
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 {
|
state = await LoadState.load {
|
||||||
try await server.item(id: route.ratingKey)
|
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 SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
||||||
/// einzelne Folge.
|
/// einzelne Folge.
|
||||||
@@ -67,6 +68,41 @@ struct PlayableDetailView: View {
|
|||||||
return item.versions[selectedVersion]
|
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 {
|
private var header: some View {
|
||||||
HeroBanner(
|
HeroBanner(
|
||||||
title: item.rowTitle,
|
title: item.rowTitle,
|
||||||
@@ -133,6 +169,39 @@ struct PlayableDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DownloadButton(item: item, mediaIndex: selectedVersion)
|
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 isWorking = false
|
||||||
@State private var summary: String?
|
@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 {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
@@ -54,11 +65,35 @@ struct SeriesDetailView: View {
|
|||||||
// dieselbe Einstellung zu haben, von denen einer nie gelesen
|
// dieselbe Einstellung zu haben, von denen einer nie gelesen
|
||||||
// wird.
|
// wird.
|
||||||
if item.kind == .show {
|
if item.kind == .show {
|
||||||
AudioLanguagePicker(
|
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||||
scope: .series(item.id),
|
AudioLanguagePicker(
|
||||||
title: "languages.forThisSeries"
|
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)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
.task(id: item.id) {
|
||||||
|
sprachen = await session.backend?.availableLanguages(inSeries: item.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
childList
|
childList
|
||||||
@@ -172,23 +207,33 @@ struct SeriesDetailView: View {
|
|||||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
|
||||||
case .loaded(let entries):
|
case .loaded(let entries):
|
||||||
|
let nummern = staffelnummern(in: entries)
|
||||||
|
let sichtbar = sichtbareFolgen(in: entries, staffeln: nummern)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
|
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
|
||||||
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
|
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
|
||||||
// als von allem anderen auf dieser Seite.
|
// als von allem anderen auf dieser Seite.
|
||||||
HStack {
|
HStack {
|
||||||
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
if nummern.count > 1 {
|
||||||
.font(.flaroRowHeader)
|
staffelwahl(nummern)
|
||||||
.foregroundStyle(Color.flaroTextPrimary)
|
} else {
|
||||||
|
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
||||||
|
.font(.flaroRowHeader)
|
||||||
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if entries.contains(where: { $0.kind == .episode }) {
|
if sichtbar.contains(where: { $0.kind == .episode }) {
|
||||||
selectionControls(for: entries)
|
// **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)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(entries) { entry in
|
ForEach(sichtbar) { entry in
|
||||||
row(for: entry)
|
row(for: entry)
|
||||||
Divider().overlay(Color.flaroSeparator)
|
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.
|
/// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func row(for entry: MediaItem) -> some View {
|
private func row(for entry: MediaItem) -> some View {
|
||||||
@@ -376,11 +470,31 @@ struct SeriesDetailView: View {
|
|||||||
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
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 {
|
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 {
|
children = await LoadState.load {
|
||||||
try await server.children(of: item.id)
|
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 SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
||||||
@@ -11,15 +12,26 @@ import MediaKit
|
|||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@Environment(FlaroSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(HomeLayoutSettings.self) private var layout
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@Environment(Fortschrittspost.self) private var post
|
||||||
@State private var feed = HomeFeed()
|
@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 {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
switch feed.state {
|
switch feed.state {
|
||||||
case .loading:
|
case .loading:
|
||||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
case .failed(let message):
|
case .failed(let message) where heruntergeladen.isEmpty:
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||||
} description: {
|
} description: {
|
||||||
@@ -28,8 +40,12 @@ struct HomeView: View {
|
|||||||
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
case .loaded:
|
// **Mit Downloads ist „kein Server" kein Fehlerbildschirm.**
|
||||||
let hubs = feed.rows(for: layout)
|
// 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 {
|
if hubs.isEmpty {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("home.empty.title", systemImage: "film.stack")
|
Label("home.empty.title", systemImage: "film.stack")
|
||||||
@@ -59,7 +75,18 @@ struct HomeView: View {
|
|||||||
// daneben, und genau diese Kante sah man. Auf den geschobenen
|
// daneben, und genau diese Kante sah man. Auf den geschobenen
|
||||||
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
|
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.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) }
|
.refreshable { await feed.load(from: session.backend) }
|
||||||
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
|
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
|
||||||
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
|
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
|
||||||
@@ -69,13 +96,22 @@ struct HomeView: View {
|
|||||||
private func content(hubs: [MediaRowGroup]) -> some View {
|
private func content(hubs: [MediaRowGroup]) -> some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
let featured = feed.heroItems(for: layout)
|
if ohneServer {
|
||||||
if !featured.isEmpty {
|
offlinehinweis
|
||||||
HeroCarousel(items: featured)
|
} else {
|
||||||
|
let featured = feed.heroItems(for: layout)
|
||||||
|
if !featured.isEmpty {
|
||||||
|
HeroCarousel(items: featured)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(hubs) { hub in
|
ForEach(hubs) { hub in
|
||||||
MediaRow(row: hub)
|
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)
|
.padding(.bottom, FlaroSpacing.section)
|
||||||
@@ -83,4 +119,36 @@ struct HomeView: View {
|
|||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
.flaroRevealsTopChrome()
|
.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 SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Eine waagerechte Reihe von Titeln.
|
/// Eine waagerechte Reihe von Titeln.
|
||||||
struct MediaRow: View {
|
struct MediaRow: View {
|
||||||
let row: MediaRowGroup
|
let row: MediaRowGroup
|
||||||
/// Wird durchgereicht — siehe `QuickPlayTile`.
|
/// Ob ein Tipp sofort abspielt — siehe `QuickPlayTile`.
|
||||||
var opensDetails: Bool = false
|
///
|
||||||
|
/// **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(FlaroSession.self) private var session
|
||||||
|
|
||||||
@Environment(HomeLayoutSettings.self) private var layout
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
|
||||||
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
|
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
|
||||||
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
|
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
|
||||||
@@ -32,7 +37,7 @@ struct MediaRow: View {
|
|||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
LazyHStack(spacing: FlaroSpacing.regular) {
|
LazyHStack(spacing: FlaroSpacing.regular) {
|
||||||
ForEach(row.items) { item in
|
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)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
@@ -55,8 +60,8 @@ struct MediaRow: View {
|
|||||||
if row.isContinueWatching {
|
if row.isContinueWatching {
|
||||||
LandscapeCard(
|
LandscapeCard(
|
||||||
title: item.rowTitle,
|
title: item.rowTitle,
|
||||||
subtitle: item.cardSubtitle,
|
subtitle: untertitel(for: item),
|
||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
|
||||||
path: item.rowArtworkPath,
|
path: item.rowArtworkPath,
|
||||||
width: 440,
|
width: 440,
|
||||||
height: 248
|
height: 248
|
||||||
@@ -67,8 +72,8 @@ struct MediaRow: View {
|
|||||||
} else {
|
} else {
|
||||||
PosterCard(
|
PosterCard(
|
||||||
title: item.rowTitle,
|
title: item.rowTitle,
|
||||||
subtitle: item.cardSubtitle,
|
subtitle: untertitel(for: item),
|
||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
|
||||||
path: item.posterArtworkPath,
|
path: item.posterArtworkPath,
|
||||||
width: 300,
|
width: 300,
|
||||||
height: 450
|
height: 450
|
||||||
@@ -78,4 +83,18 @@ struct MediaRow: View {
|
|||||||
.frame(width: posterWidth)
|
.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.
|
/// Filter und Sortierung einer Mediathek.
|
||||||
struct LibraryFilterSheet: View {
|
struct LibraryFilterSheet: View {
|
||||||
@Binding var query: MediaQuery
|
@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.
|
/// Die Mediathek selbst, nicht nur ihre Art.
|
||||||
///
|
///
|
||||||
/// Vorher stand hier die Art, und die Genres wurden über *irgendeine*
|
/// 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 —
|
/// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus —
|
||||||
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
|
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
|
||||||
@State private var draft: MediaQuery
|
@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._query = query
|
||||||
|
self._nurHeruntergeladene = nurHeruntergeladene
|
||||||
self.library = library
|
self.library = library
|
||||||
self._draft = State(initialValue: query.wrappedValue)
|
self._draft = State(initialValue: query.wrappedValue)
|
||||||
|
self._entwurfHeruntergeladene = State(initialValue: nurHeruntergeladene.wrappedValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -45,6 +55,7 @@ struct LibraryFilterSheet: View {
|
|||||||
|
|
||||||
Section("filter.filters") {
|
Section("filter.filters") {
|
||||||
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
|
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
|
||||||
|
Toggle("filter.downloadedOnly", isOn: $entwurfHeruntergeladene)
|
||||||
|
|
||||||
Picker("filter.genre", selection: $draft.genre) {
|
Picker("filter.genre", selection: $draft.genre) {
|
||||||
Text("filter.any").tag(String?.none)
|
Text("filter.any").tag(String?.none)
|
||||||
@@ -80,8 +91,9 @@ struct LibraryFilterSheet: View {
|
|||||||
Section {
|
Section {
|
||||||
Button("filter.reset", role: .destructive) {
|
Button("filter.reset", role: .destructive) {
|
||||||
draft = MediaQuery(sort: draft.sort)
|
draft = MediaQuery(sort: draft.sort)
|
||||||
|
entwurfHeruntergeladene = false
|
||||||
}
|
}
|
||||||
.disabled(!draft.isFiltering)
|
.disabled(!draft.isFiltering && !entwurfHeruntergeladene)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ausdrücklich, nicht nur geerbt.
|
// Ausdrücklich, nicht nur geerbt.
|
||||||
@@ -102,6 +114,7 @@ struct LibraryFilterSheet: View {
|
|||||||
}
|
}
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("action.apply") {
|
Button("action.apply") {
|
||||||
|
nurHeruntergeladene = entwurfHeruntergeladene
|
||||||
query = draft
|
query = draft
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Eine Mediathek zum Durchblättern.
|
/// 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
|
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
|
||||||
/// zwischen Raster und Liste.
|
/// zwischen Raster und Liste.
|
||||||
@Environment(HomeLayoutSettings.self) private var sizing
|
@Environment(HomeLayoutSettings.self) private var sizing
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@AppStorage("flaro.library.layout") private var layout: LibraryLayout = .grid
|
@AppStorage("flaro.library.layout") private var layout: LibraryLayout = .grid
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
@@ -25,6 +27,14 @@ struct LibraryView: View {
|
|||||||
@State private var loadMoreFailed = false
|
@State private var loadMoreFailed = false
|
||||||
@State private var isLoadingMore = false
|
@State private var isLoadingMore = false
|
||||||
@State private var showsFilters = 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 {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -48,8 +58,9 @@ struct LibraryView: View {
|
|||||||
} description: {
|
} description: {
|
||||||
Text("library.empty.description")
|
Text("library.empty.description")
|
||||||
} actions: {
|
} actions: {
|
||||||
if query.isFiltering {
|
if query.isFiltering || nurHeruntergeladene {
|
||||||
Button("library.clearFilters") {
|
Button("library.clearFilters") {
|
||||||
|
nurHeruntergeladene = false
|
||||||
query = MediaQuery(sort: query.sort)
|
query = MediaQuery(sort: query.sort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,7 +75,11 @@ struct LibraryView: View {
|
|||||||
.flaroInlineTitle()
|
.flaroInlineTitle()
|
||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
.sheet(isPresented: $showsFilters) {
|
.sheet(isPresented: $showsFilters) {
|
||||||
LibraryFilterSheet(query: $query, library: library)
|
LibraryFilterSheet(
|
||||||
|
query: $query,
|
||||||
|
nurHeruntergeladene: $nurHeruntergeladene,
|
||||||
|
library: library
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.task(id: library.id) {
|
.task(id: library.id) {
|
||||||
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
|
// 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
|
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
|
||||||
// verschiedenen Abfragen vermischen.
|
// verschiedenen Abfragen vermischen.
|
||||||
.onChange(of: query) { _, _ in Task { await reload() } }
|
.onChange(of: query) { _, _ in Task { await reload() } }
|
||||||
|
.onChange(of: nurHeruntergeladene) { _, _ in
|
||||||
|
items = []
|
||||||
|
page = nil
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
.refreshable { await reload() }
|
.refreshable { await reload() }
|
||||||
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
|
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
|
||||||
.environment(\.flaroItemChanged, { await reload() })
|
.environment(\.flaroItemChanged, { await reload() })
|
||||||
@@ -104,8 +124,12 @@ struct LibraryView: View {
|
|||||||
QuickPlayTile(item: item) {
|
QuickPlayTile(item: item) {
|
||||||
PosterCard(
|
PosterCard(
|
||||||
title: item.title,
|
title: item.title,
|
||||||
subtitle: item.cardSubtitle,
|
subtitle: nurHeruntergeladene
|
||||||
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
? 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
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -232,7 +256,7 @@ struct LibraryView: View {
|
|||||||
Button {
|
Button {
|
||||||
showsFilters = true
|
showsFilters = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: query.isFiltering
|
Image(systemName: query.isFiltering || nurHeruntergeladene
|
||||||
? "line.3.horizontal.decrease.circle.fill"
|
? "line.3.horizontal.decrease.circle.fill"
|
||||||
: "line.3.horizontal.decrease.circle")
|
: "line.3.horizontal.decrease.circle")
|
||||||
}
|
}
|
||||||
@@ -276,6 +300,16 @@ struct LibraryView: View {
|
|||||||
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
|
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
|
||||||
/// Fehlerbildschirm.
|
/// Fehlerbildschirm.
|
||||||
private func reload() async {
|
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 }
|
guard let server = session.backend else { return }
|
||||||
let schlüssel = query.cacheKey
|
let schlüssel = query.cacheKey
|
||||||
let kennung = server.identity
|
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.
|
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
|
||||||
private func loadMoreIfNeeded(reaching item: MediaItem) async {
|
private func loadMoreIfNeeded(reaching item: MediaItem) async {
|
||||||
guard
|
guard
|
||||||
@@ -385,7 +438,8 @@ private struct LibraryListRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: FlaroSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
FlaroArtwork(
|
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
|
aspectRatio: PosterCard.aspectRatio
|
||||||
)
|
)
|
||||||
.frame(width: 44)
|
.frame(width: 44)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct RootView: View {
|
|||||||
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
||||||
@Environment(NetworkConditions.self) private var network
|
@Environment(NetworkConditions.self) private var network
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@Environment(Fortschrittspost.self) private var post
|
||||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||||
|
|
||||||
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
||||||
@@ -229,6 +230,7 @@ struct RootView: View {
|
|||||||
backend: session.backend,
|
backend: session.backend,
|
||||||
liveTV: session.liveTV,
|
liveTV: session.liveTV,
|
||||||
downloads: downloads,
|
downloads: downloads,
|
||||||
|
post: post,
|
||||||
diagnostics: diagnostics,
|
diagnostics: diagnostics,
|
||||||
policy: settings.policy(for: network),
|
policy: settings.policy(for: network),
|
||||||
settings: settings,
|
settings: settings,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
|
/// 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.
|
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
|
||||||
let onActivity: () -> Void
|
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
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
private static let trackHeight: CGFloat = 5
|
private static let trackHeight: CGFloat = 5
|
||||||
@@ -71,6 +77,22 @@ struct PlaybackScrubber: View {
|
|||||||
.offset(x: gespielt - Self.thumbSize / 2)
|
.offset(x: gespielt - Self.thumbSize / 2)
|
||||||
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
|
.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)
|
.frame(height: Self.hitHeight)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.gesture(
|
.gesture(
|
||||||
@@ -117,3 +139,75 @@ struct PlaybackScrubber: View {
|
|||||||
return min(max(Double(x / width), 0), 1) * duration.seconds
|
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
|
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
||||||
/// Überspringen-Knopf.
|
/// Überspringen-Knopf.
|
||||||
private(set) var activeMarker: MediaMarker?
|
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,
|
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
|
||||||
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
||||||
private(set) var nextEpisode: MediaItem?
|
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
|
private(set) var hasReachedEnd = false
|
||||||
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
|
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
|
||||||
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
|
/// 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.
|
/// und ausgerechnet da will man zusehen, wie der Vorrat wächst.
|
||||||
private(set) var bufferedUntil: Duration?
|
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.
|
/// Was der eigene Vorratsspeicher gerade tut — in einem Satz.
|
||||||
///
|
///
|
||||||
/// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher
|
/// **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
|
/// Protokoll mit Null-Objekt wäre die dritte Möglichkeit und die
|
||||||
/// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden.
|
/// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden.
|
||||||
private let downloads: DownloadCoordinator?
|
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 diagnostics: PlaybackDiagnostics
|
||||||
private let policy: PlaybackPolicy
|
private let policy: PlaybackPolicy
|
||||||
private let settings: PlaybackSettings
|
private let settings: PlaybackSettings
|
||||||
@@ -461,6 +495,8 @@ final class PlayerModel {
|
|||||||
backend: (any MediaBackend)?,
|
backend: (any MediaBackend)?,
|
||||||
liveTV: (any LiveTVBackend)?,
|
liveTV: (any LiveTVBackend)?,
|
||||||
downloads: DownloadCoordinator? = nil,
|
downloads: DownloadCoordinator? = nil,
|
||||||
|
/// Wohin ein Fortschritt kommt, den der Server nicht annimmt.
|
||||||
|
post: Fortschrittspost? = nil,
|
||||||
diagnostics: PlaybackDiagnostics,
|
diagnostics: PlaybackDiagnostics,
|
||||||
policy: PlaybackPolicy,
|
policy: PlaybackPolicy,
|
||||||
settings: PlaybackSettings,
|
settings: PlaybackSettings,
|
||||||
@@ -474,6 +510,7 @@ final class PlayerModel {
|
|||||||
self.backend = backend
|
self.backend = backend
|
||||||
self.liveTV = liveTV
|
self.liveTV = liveTV
|
||||||
self.downloads = downloads
|
self.downloads = downloads
|
||||||
|
self.post = post
|
||||||
self.diagnostics = diagnostics
|
self.diagnostics = diagnostics
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -499,6 +536,18 @@ final class PlayerModel {
|
|||||||
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
|
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
|
||||||
if let downloads, let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
|
if let downloads, let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
|
||||||
await startLocal(url: localURL)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,6 +606,7 @@ final class PlayerModel {
|
|||||||
|
|
||||||
await load(source, decision: decision)
|
await load(source, decision: decision)
|
||||||
preloadNextEpisode()
|
preloadNextEpisode()
|
||||||
|
vorschauHolen()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stellt auf eine andere Qualitätsstufe um.
|
/// Stellt auf eine andere Qualitätsstufe um.
|
||||||
@@ -718,7 +768,58 @@ final class PlayerModel {
|
|||||||
await load(PlaybackSource(url: url, origin: .remote), decision: decision)
|
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 {
|
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 offline = downloads?.offlineMedia(forRatingKey: request.ratingKey) ?? nil
|
||||||
|
|
||||||
let decision = PlaybackDecider().decide(
|
let decision = PlaybackDecider().decide(
|
||||||
@@ -839,6 +940,26 @@ final class PlayerModel {
|
|||||||
playbackSession = target.sessionID
|
playbackSession = target.sessionID
|
||||||
playbackVersionID = target.versionID
|
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.**
|
// **Der Vorrat läuft voraus, nicht der Player.**
|
||||||
//
|
//
|
||||||
// AVPlayer entscheidet selbst, wie weit er puffert, und bei einer
|
// AVPlayer entscheidet selbst, wie weit er puffert, und bei einer
|
||||||
@@ -856,7 +977,7 @@ final class PlayerModel {
|
|||||||
return PlaybackSource(
|
return PlaybackSource(
|
||||||
url: adresse,
|
url: adresse,
|
||||||
origin: .remote,
|
origin: .remote,
|
||||||
startTime: start,
|
startTime: target.startOffset > .zero ? .zero : start,
|
||||||
// **Mit eigenem Vorrat ein kleiner Puffer.**
|
// **Mit eigenem Vorrat ein kleiner Puffer.**
|
||||||
//
|
//
|
||||||
// Er bestimmt, wie viel AVPlayer beisammenhaben will, bevor
|
// 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
|
// 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
|
// auslöst. Alles Weitere ist Warten auf Daten, und dafür gibt es den
|
||||||
// Kreisel.
|
// 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
|
currentTime = time
|
||||||
seekTarget = time
|
seekTarget = time
|
||||||
// Der Vorrat von vorhin gilt für die alte Stelle. Ihn stehen zu lassen
|
// 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)
|
NowPlaying.shared.update(from: self)
|
||||||
updateActiveMarker()
|
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.
|
/// 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.
|
/// Springt ans Ende der laufenden Marke.
|
||||||
func skipActiveMarker() {
|
func skipActiveMarker() {
|
||||||
guard let marker = activeMarker else { return }
|
guard let marker = activeMarker, let item else { return }
|
||||||
skippedMarkers.insert(marker.id)
|
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.
|
/// 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
|
let stelle = currentTime > .zero ? currentTime : request.startTime
|
||||||
guard stelle > .zero || state == .started else { return }
|
guard stelle > .zero || state == .started else { return }
|
||||||
|
|
||||||
await backend.reportProgress(PlaybackReport(
|
let bericht = PlaybackReport(
|
||||||
itemID: item.id,
|
itemID: item.id,
|
||||||
sessionID: playbackSession,
|
sessionID: playbackSession,
|
||||||
versionID: playbackVersionID,
|
versionID: playbackVersionID,
|
||||||
state: state,
|
state: state,
|
||||||
time: stelle,
|
time: stelle,
|
||||||
duration: total
|
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
|
// MARK: - Marken und nächste Folge
|
||||||
@@ -1700,14 +1889,14 @@ final class PlayerModel {
|
|||||||
guard let item else { return }
|
guard let item else { return }
|
||||||
let milliseconds = currentTime.milliseconds
|
let milliseconds = currentTime.milliseconds
|
||||||
|
|
||||||
activeMarker = item.markers.first { marker in
|
// **Was der Server schickt, gilt.** Hier stand eine eigene Regel:
|
||||||
guard marker.contains(milliseconds) else { return false }
|
// bei einem Film wurde die Abspannmarke verworfen, weil
|
||||||
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
|
// „überspringen" dort gleichbedeutend mit „beenden" war. Das ist
|
||||||
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
|
// eine Entscheidung über Marken, und die trifft der Server — er
|
||||||
// „Film beenden".
|
// schickt sie nur, wenn er einen Abspann gefunden hat, und seit
|
||||||
if marker.isCredits && item.kind != .episode { return false }
|
// ein Abspann geteilt sein darf, führt der Knopf zur
|
||||||
return true
|
// Post-Credit-Szene statt ins Nichts.
|
||||||
}
|
activeMarker = item.markers.first { $0.contains(milliseconds) }
|
||||||
|
|
||||||
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
|
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
|
||||||
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
|
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
|
||||||
@@ -1715,21 +1904,43 @@ final class PlayerModel {
|
|||||||
if shouldSkip { skipActiveMarker() }
|
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() {
|
private func preloadNextEpisode() {
|
||||||
guard let item, item.kind == .episode, let backend else { return }
|
guard let item, item.kind == .episode, let backend else { return }
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
let next = try? await backend.nextEpisode(after: item)
|
let next = try? await backend.nextEpisode(after: item)
|
||||||
self?.nextEpisode = next
|
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
|
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
|
||||||
/// Koordinator weiter.
|
/// Koordinator weiter.
|
||||||
func nextEpisodeRequest() -> PlaybackRequest? {
|
func nextEpisodeRequest() -> PlaybackRequest? { wunsch(für: nextEpisode) }
|
||||||
guard let nextEpisode else { return nil }
|
|
||||||
|
/// 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(
|
return PlaybackRequest(
|
||||||
ratingKey: nextEpisode.id,
|
ratingKey: folge.id,
|
||||||
title: nextEpisode.playbackTitle,
|
title: folge.playbackTitle,
|
||||||
mediaIndex: 0,
|
mediaIndex: 0,
|
||||||
startTime: .zero
|
startTime: .zero
|
||||||
)
|
)
|
||||||
@@ -1871,7 +2082,14 @@ final class PlayerModel {
|
|||||||
String(describing: failure),
|
String(describing: failure),
|
||||||
].joined(separator: "\n\n"))
|
].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
|
// **Nachzügler verwerfen.** Nach einem Sprung meldet der
|
||||||
// Player noch ein paar Mal die alte Stelle, bis er dort
|
// Player noch ein paar Mal die alte Stelle, bis er dort
|
||||||
// ankommt. Ungeprüft übernommen zöge das den Regler wieder
|
// ankommt. Ungeprüft übernommen zöge das den Regler wieder
|
||||||
@@ -1901,7 +2119,22 @@ final class PlayerModel {
|
|||||||
}
|
}
|
||||||
updateActiveMarker()
|
updateActiveMarker()
|
||||||
case .durationResolved(let value):
|
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)
|
NowPlaying.shared.update(from: self)
|
||||||
case .reachedEnd:
|
case .reachedEnd:
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
@@ -1943,14 +2176,52 @@ final class PlayerModel {
|
|||||||
/// Einstellungen eine Untertitelsprache steht. Wer keine eingestellt
|
/// Einstellungen eine Untertitelsprache steht. Wer keine eingestellt
|
||||||
/// hatte, schaltete die Untertitel ein und sah nichts.
|
/// hatte, schaltete die Untertitel ein und sah nichts.
|
||||||
///
|
///
|
||||||
/// **Die erste ist die richtige.** Rechnet der Server um, liegt genau
|
/// **Die erste ist nicht mehr die richtige.**
|
||||||
/// eine im Stream — nämlich die bestellte. Es gibt nichts zu verwechseln.
|
///
|
||||||
|
/// 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() {
|
private func showOrderedSubtitle() {
|
||||||
guard selectedSubtitleStream != nil, selectedSubtitleTrack == nil else { return }
|
guard let bestellt = selectedSubtitleStream, selectedSubtitleTrack == nil else { return }
|
||||||
guard let erste = subtitleTracks.first else { return }
|
guard let treffer = passendeSpur(zu: bestellt) ?? subtitleTracks.first else { return }
|
||||||
|
|
||||||
diagnostics.note("Untertitel eingeschaltet: \(erste.displayName)")
|
diagnostics.note("Untertitel eingeschaltet: \(treffer.displayName)")
|
||||||
selectSubtitleTrack(erste)
|
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.
|
/// 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
|
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
|
||||||
/// eine später gemeldete Spurliste das nicht zurücksetzen.
|
/// eine später gemeldete Spurliste das nicht zurücksetzen.
|
||||||
private func applyPreferredLanguages() {
|
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: {
|
let treffer = subtitleTracks.first(where: {
|
||||||
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
|
LanguageCode.matches(wunsch, $0.languageCode)
|
||||||
})
|
})
|
||||||
else { return }
|
else { return }
|
||||||
selectSubtitleTrack(treffer)
|
selectSubtitleTrack(treffer)
|
||||||
@@ -1984,6 +2261,14 @@ final class PlayerModel {
|
|||||||
/// Netz und ohne Einstellungen, damit sich der ZDF-Fall wörtlich
|
/// Netz und ohne Einstellungen, damit sich der ZDF-Fall wörtlich
|
||||||
/// hinschreiben und durchspielen lässt. Hier steht nur, woher der Wunsch
|
/// hinschreiben und durchspielen lässt. Hier steht nur, woher der Wunsch
|
||||||
/// kommt.
|
/// 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(
|
private func chooseAudioStream(
|
||||||
in part: MediaFile,
|
in part: MediaFile,
|
||||||
of item: MediaItem,
|
of item: MediaItem,
|
||||||
@@ -1993,10 +2278,9 @@ final class PlayerModel {
|
|||||||
subtitleStreams = part.subtitleStreams
|
subtitleStreams = part.subtitleStreams
|
||||||
// Untertitel sind eine Zutat, keine Voraussetzung: der Wunsch gilt,
|
// Untertitel sind eine Zutat, keine Voraussetzung: der Wunsch gilt,
|
||||||
// wenn es einen passenden gibt, und sonst bleibt es ohne.
|
// wenn es einen passenden gibt, und sonst bleibt es ohne.
|
||||||
selectedSubtitleStream = languages.subtitleLanguage.isEmpty ? nil
|
selectedSubtitleStream = languages.subtitleWish(titleID: titelbereich).flatMap { wunsch in
|
||||||
: subtitleStreams.first {
|
subtitleStreams.first { LanguageCode.matches(wunsch, $0.languageCode) }
|
||||||
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
guard !audioStreams.isEmpty else {
|
guard !audioStreams.isEmpty else {
|
||||||
selectedAudioStream = nil
|
selectedAudioStream = nil
|
||||||
@@ -2012,7 +2296,12 @@ final class PlayerModel {
|
|||||||
|
|
||||||
selectedAudioStream = AudioTrackChooser.choose(
|
selectedAudioStream = AudioTrackChooser.choose(
|
||||||
from: part.streams,
|
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 {
|
if let gewählt = selectedAudioStream {
|
||||||
@@ -2032,8 +2321,8 @@ final class PlayerModel {
|
|||||||
guard stream != selectedAudioStream else { return }
|
guard stream != selectedAudioStream else { return }
|
||||||
selectedAudioStream = stream
|
selectedAudioStream = stream
|
||||||
|
|
||||||
if let seriesID = item?.seriesID {
|
if let bereich = titelbereich {
|
||||||
languages.remember(stream, forSeries: seriesID)
|
languages.remember(stream, forSeries: bereich)
|
||||||
}
|
}
|
||||||
|
|
||||||
// **Der billige Weg, wenn er offensteht.** Wird die Datei unverändert
|
// **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.
|
/// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt.
|
||||||
var rememberedTrackName: String? {
|
var rememberedTrackName: String? {
|
||||||
guard let seriesID = item?.seriesID,
|
guard let bereich = titelbereich,
|
||||||
let signatur = languages.rememberedTrack(forSeries: seriesID)
|
let signatur = languages.rememberedTrack(forSeries: bereich)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return signatur.title ?? signatur.languageCode
|
return signatur.title ?? signatur.languageCode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vergisst die gemerkte Spur dieser Serie.
|
/// Vergisst die gemerkte Spur dieser Serie.
|
||||||
func forgetRememberedTrack() {
|
func forgetRememberedTrack() {
|
||||||
guard let seriesID = item?.seriesID else { return }
|
guard let bereich = titelbereich else { return }
|
||||||
languages.forgetTrack(forSeries: seriesID)
|
languages.forgetTrack(forSeries: bereich)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playerSubtitle(for item: MediaItem) -> String? {
|
private func playerSubtitle(for item: MediaItem) -> String? {
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ struct PlayerView: View {
|
|||||||
@State private var trackPicker: TrackPickerSheet.Kind?
|
@State private var trackPicker: TrackPickerSheet.Kind?
|
||||||
@State private var showsQualityChoice = false
|
@State private var showsQualityChoice = false
|
||||||
@State private var hideTask: Task<Void, Never>?
|
@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?
|
@State private var scrubbing: Double?
|
||||||
/// Welche Richtung gerade als Hinweis eingeblendet ist.
|
/// Welche Richtung gerade als Hinweis eingeblendet ist.
|
||||||
@State private var seekHint: Double?
|
@State private var seekHint: Double?
|
||||||
@@ -106,8 +115,29 @@ struct PlayerView: View {
|
|||||||
// Jetzt rechnet das Layout. Der schwarze Rand ist schlicht
|
// Jetzt rechnet das Layout. Der schwarze Rand ist schlicht
|
||||||
// der Hintergrund dahinter, und wer ihn anklickt, klickt
|
// der Hintergrund dahinter, und wer ihn anklickt, klickt
|
||||||
// nicht aufs Bild — das muss niemand mehr feststellen.
|
// 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
|
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 {
|
.overlay {
|
||||||
surface(for: model)
|
surface(for: model)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
@@ -341,6 +371,17 @@ struct PlayerView: View {
|
|||||||
.environment(settings)
|
.environment(settings)
|
||||||
.environment(\.flaroAccent, accent)
|
.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
|
.onChange(of: showsQualityChoice) { _, isShown in
|
||||||
// Nach dem Schließen die Bedienelemente stehen lassen und die
|
// Nach dem Schließen die Bedienelemente stehen lassen und die
|
||||||
// Ausblendfrist neu starten. Ohne das verschwänden sie sofort,
|
// Ausblendfrist neu starten. Ohne das verschwänden sie sofort,
|
||||||
@@ -594,7 +635,25 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func transport(for model: PlayerModel) -> some 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 {
|
Button {
|
||||||
model.skip(by: .seconds(-settings.rewindSeconds))
|
model.skip(by: .seconds(-settings.rewindSeconds))
|
||||||
scheduleControlsHide()
|
scheduleControlsHide()
|
||||||
@@ -638,6 +697,15 @@ struct PlayerView: View {
|
|||||||
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
|
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("player.forward10"))
|
.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)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
@@ -662,7 +730,9 @@ struct PlayerView: View {
|
|||||||
// springen lässt das Bild flackern und hält bei großen
|
// springen lässt das Bild flackern und hält bei großen
|
||||||
// Dateien den Puffer dauerhaft leer.
|
// Dateien den Puffer dauerhaft leer.
|
||||||
onCommit: { model.seek(to: $0) },
|
onCommit: { model.seek(to: $0) },
|
||||||
onActivity: { scheduleControlsHide() }
|
onActivity: { scheduleControlsHide() },
|
||||||
|
vorschau: model.vorschau,
|
||||||
|
blatt: { model.vorschaublatt($0) }
|
||||||
)
|
)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
@@ -683,12 +753,31 @@ struct PlayerView: View {
|
|||||||
|
|
||||||
/// Überspringen-Knopf und „Nächste Folge".
|
/// Überspringen-Knopf und „Nächste Folge".
|
||||||
@ViewBuilder
|
@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 {
|
private func overlayActions(for model: PlayerModel) -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
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 {
|
Button {
|
||||||
if let next = model.nextEpisodeRequest() {
|
if let next = model.nextEpisodeRequest() {
|
||||||
playback.play(next)
|
playback.play(next)
|
||||||
@@ -701,7 +790,9 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
} else if let marker = model.activeMarker {
|
}
|
||||||
|
|
||||||
|
if let marker = model.activeMarker {
|
||||||
Button {
|
Button {
|
||||||
model.skipActiveMarker()
|
model.skipActiveMarker()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -721,9 +812,28 @@ struct PlayerView: View {
|
|||||||
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
||||||
// die Bedienelemente eingeblendet sind.
|
// die Bedienelemente eingeblendet sind.
|
||||||
.padding(.bottom, (showsControls ? 110 : FlaroSpacing.section) + safeArea.bottom)
|
.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.activeMarker?.id)
|
||||||
.animation(.snappy, value: model.hasReachedEnd)
|
.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.
|
/// Die Abdeckung während des Umstellens.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ struct SearchView: View {
|
|||||||
// weiß aber noch nicht, was er sehen will; der erste
|
// weiß aber noch nicht, was er sehen will; der erste
|
||||||
// Treffer ist eine Vermutung des Servers. Starten geht
|
// Treffer ist eine Vermutung des Servers. Starten geht
|
||||||
// weiter, über langes Drücken.
|
// weiter, über langes Drücken.
|
||||||
MediaRow(row: hub, opensDetails: true)
|
MediaRow(row: hub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, FlaroSpacing.loose)
|
.padding(.vertical, FlaroSpacing.loose)
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
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
|
/// **Warum ein gemeinsamer Typ.** Die Frage steht auf der Serienseite, auf
|
||||||
/// und einmal bei der Mediathek, und die Antwort bedeutet beide Male dasselbe:
|
/// der Filmseite und bei der Mediathek, und die Antwort bedeutet jedes Mal
|
||||||
/// diese Ebene setzt sich gegen die darüber durch, oder sie tut es nicht. Zwei
|
/// dasselbe: diese Ebene setzt sich gegen die darüber durch, oder sie tut es
|
||||||
/// Auswahlknöpfe mit demselben Inhalt liefen unweigerlich auseinander — einer
|
/// nicht. Mehrere Auswahlknöpfe mit demselben Inhalt liefen unweigerlich
|
||||||
/// bekäme eine Sprache dazu, der andere nicht.
|
/// 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
|
/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein
|
||||||
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
|
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
|
||||||
@@ -15,25 +20,98 @@ import FlaroDesign
|
|||||||
struct AudioLanguagePicker: View {
|
struct AudioLanguagePicker: View {
|
||||||
/// Für welche Ebene gewählt wird.
|
/// Für welche Ebene gewählt wird.
|
||||||
enum Scope {
|
enum Scope {
|
||||||
|
/// Eine Serie oder ein Film — beides eine Kennung, beides ein Titel.
|
||||||
case series(String)
|
case series(String)
|
||||||
case library(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 scope: Scope
|
||||||
let title: LocalizedStringKey
|
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(LanguagePreferences.self) private var languages
|
||||||
@Environment(\.flaroAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: FlaroSpacing.regular) {
|
switch form {
|
||||||
Text(title)
|
case .zeile:
|
||||||
.font(.flaroBody)
|
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)
|
.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
|
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
|
||||||
// sollen nichts Eigenes sagen, sondern die Frage nach oben
|
// sollen nichts Eigenes sagen, sondern die Frage nach oben
|
||||||
// durchreichen.
|
// durchreichen.
|
||||||
@@ -43,42 +121,65 @@ struct AudioLanguagePicker: View {
|
|||||||
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
|
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
// **„Keine" nur beim Untertitel.** Beim Ton gibt es sie
|
||||||
|
// nicht: ein Film ohne Tonspur ist kein Wunsch, sondern ein
|
||||||
ForEach(LanguagePreferences.offeredCodes, id: \.self) { code in
|
// Fehler.
|
||||||
|
if kind == .subtitles {
|
||||||
Button {
|
Button {
|
||||||
set(code)
|
set(LanguagePreferences.noSubtitles)
|
||||||
} label: {
|
} label: {
|
||||||
Label(name(of: code), systemImage: current == code ? "checkmark" : "")
|
Label(
|
||||||
|
"languages.none",
|
||||||
|
systemImage: current == LanguagePreferences.noSubtitles
|
||||||
|
? "checkmark" : ""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
HStack(spacing: FlaroSpacing.tight) {
|
Divider()
|
||||||
Text(currentLabel)
|
|
||||||
.font(.flaroBody)
|
ForEach(options, id: \.code) { option in
|
||||||
Image(systemName: "chevron.up.chevron.down")
|
Button {
|
||||||
.font(.flaroCaption)
|
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? {
|
private var current: String? {
|
||||||
switch scope {
|
switch (scope, kind) {
|
||||||
case .series(let id): languages.audioLanguage(forSeries: id)
|
case (.series(let id), .audio): languages.audioLanguage(forSeries: id)
|
||||||
case .library(let id): languages.audioLanguage(forLibrary: 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 {
|
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?) {
|
private func set(_ code: String?) {
|
||||||
switch scope {
|
switch (scope, kind) {
|
||||||
case .series(let id): languages.setAudioLanguage(code, forSeries: id)
|
case (.series(let id), .audio): languages.setAudioLanguage(code, forSeries: id)
|
||||||
case .library(let id): languages.setAudioLanguage(code, forLibrary: 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 {
|
Section {
|
||||||
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
|
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
|
||||||
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
|
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
|
||||||
|
Toggle("settings.skipExtraScenes", isOn: $settings.skipExtraScenes)
|
||||||
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
|
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
|
||||||
} header: {
|
} header: {
|
||||||
Text("settings.episodes")
|
Text("settings.episodes")
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ public actor ArtworkLoader {
|
|||||||
using session: URLSession,
|
using session: URLSession,
|
||||||
attempts: Int = 3
|
attempts: Int = 3
|
||||||
) async -> PlatformImage? {
|
) 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 {
|
for attempt in 1...attempts {
|
||||||
if Task.isCancelled { return nil }
|
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)
|
.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
|
actions
|
||||||
}
|
}
|
||||||
.padding(.top, FlaroSpacing.tight)
|
.padding(.top, FlaroSpacing.tight)
|
||||||
|
|||||||
@@ -395,9 +395,16 @@ public final class DownloadCoordinator {
|
|||||||
forgetTransfer(item.ratingKey)
|
forgetTransfer(item.ratingKey)
|
||||||
fileStore.remove(fileName: item.localFileName)
|
fileStore.remove(fileName: item.localFileName)
|
||||||
fileStore.remove(fileName: item.artworkFileName)
|
fileStore.remove(fileName: item.artworkFileName)
|
||||||
|
let serie = item.seriesKey
|
||||||
context.delete(item)
|
context.delete(item)
|
||||||
save()
|
save()
|
||||||
reloadItems()
|
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()
|
refreshUsage()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +429,58 @@ public final class DownloadCoordinator {
|
|||||||
return fileStore.url(forFileName: fileName)
|
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? {
|
public func offlineMedia(forRatingKey key: String) -> OfflineMedia? {
|
||||||
guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil }
|
guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil }
|
||||||
return try? JSONDecoder().decode(OfflineMedia.self, from: data)
|
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) {
|
private func start(_ item: DownloadItem) {
|
||||||
guard let url = URL(string: item.sourceURLString) else {
|
guard let url = URL(string: item.sourceURLString) else {
|
||||||
fail(item, message: "Ungültige Adresse")
|
fail(item, message: "Ungültige Adresse")
|
||||||
@@ -526,6 +592,7 @@ public final class DownloadCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleCompletion(ratingKey: String, temporaryFile: URL) {
|
func handleCompletion(ratingKey: String, temporaryFile: URL) {
|
||||||
|
wartend[ratingKey] = nil
|
||||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
||||||
let sourceURL = URL(string: item.sourceURLString)
|
let sourceURL = URL(string: item.sourceURLString)
|
||||||
else { return }
|
else { return }
|
||||||
@@ -608,6 +675,49 @@ public final class DownloadCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Der Server hat die Anfrage abgelehnt.
|
/// 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) {
|
func handleServerRejection(ratingKey: String, status: Int) {
|
||||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||||
fail(item, message: String(localized: "downloads.failed.status \(status)", bundle: .module))
|
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 }
|
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
|
// `URLSessionDownloadTask` gilt auch bei 404 oder 500 als erfolgreich
|
||||||
// und legt die Fehlerseite als Datei ab. Ohne diese Prüfung wurde eine
|
// 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
|
// zwei Kilobyte große HTML-Seite als fertiger Film verbucht — in einer
|
||||||
|
|||||||
@@ -58,6 +58,29 @@ public struct DownloadFileStore: Sendable {
|
|||||||
return "\(key).\(ext)"
|
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.
|
/// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz.
|
||||||
///
|
///
|
||||||
/// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der
|
/// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der
|
||||||
|
|||||||
@@ -1,6 +1,40 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage": "en",
|
"sourceLanguage": "en",
|
||||||
"strings": {
|
"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 %@": {
|
"downloads.failed.tooSmall %@": {
|
||||||
"comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein",
|
"comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein",
|
||||||
"localizations": {
|
"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"
|
"version": "1.0"
|
||||||
|
|||||||
@@ -135,14 +135,14 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
|||||||
//
|
//
|
||||||
// Serverneutral: wo kein Cookie liegt, ist die Liste leer und es
|
// Serverneutral: wo kein Cookie liegt, ist die Liste leer und es
|
||||||
// ändert sich nichts.
|
// ä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: [
|
let asset = AVURLAsset(url: source.url, options: [
|
||||||
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
|
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
|
||||||
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
|
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
|
||||||
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
|
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
|
||||||
AVURLAssetPreferPreciseDurationAndTimingKey: false,
|
AVURLAssetPreferPreciseDurationAndTimingKey: false,
|
||||||
AVURLAssetHTTPCookiesKey: kekse,
|
AVURLAssetHTTPCookiesKey: cookies,
|
||||||
])
|
])
|
||||||
let item = AVPlayerItem(asset: asset)
|
let item = AVPlayerItem(asset: asset)
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ public actor StreamCache {
|
|||||||
configuration.urlCache = nil
|
configuration.urlCache = nil
|
||||||
configuration.httpMaximumConnectionsPerHost = 4
|
configuration.httpMaximumConnectionsPerHost = 4
|
||||||
configuration.timeoutIntervalForRequest = 30
|
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.session = URLSession(configuration: configuration)
|
||||||
|
|
||||||
self.directory = FileManager.default.temporaryDirectory
|
self.directory = FileManager.default.temporaryDirectory
|
||||||
|
|||||||
@@ -86,38 +86,79 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
// Einstellungen — „Gesehenes in Neuzugängen ausblenden" und
|
// Einstellungen — „Gesehenes in Neuzugängen ausblenden" und
|
||||||
// „Aufmacher nur aus Neuzugängen" —, und beide taten an einem
|
// „Aufmacher nur aus Neuzugängen" —, und beide taten an einem
|
||||||
// Flaro-Server deshalb nichts.
|
// Flaro-Server deshalb nichts.
|
||||||
for mediathek in mediatheken {
|
// **Nebeneinander, nicht nacheinander.**
|
||||||
let pfad = mediathek.kind == .show ? "/api/v1/series" : "/api/v1/movies"
|
//
|
||||||
let abfrage = [
|
// Hier stand eine Schleife mit `await` darin: bei vier Mediatheken
|
||||||
URLQueryItem(name: "library", value: mediathek.id),
|
// vier Rundreisen hintereinander, jede erst nach der vorigen. Die
|
||||||
URLQueryItem(name: "sort", value: "added"),
|
// Weboberfläche holt dieselben Reihen in einem `Promise.all` — und
|
||||||
URLQueryItem(name: "take", value: "24"),
|
// sie holt sogar mehr davon. Genau das war der Unterschied, den man
|
||||||
]
|
// der App ansah.
|
||||||
let titel: [MediaItem]
|
//
|
||||||
if mediathek.kind == .show {
|
// Ein Actor steht dem nicht im Weg: jede Anfrage gibt ihn beim
|
||||||
guard let seite = try? await client.send(
|
// Warten wieder frei, so wie die beiden darüber es schon tun.
|
||||||
FlaroRequest(path: pfad, query: abfrage),
|
let neuzugaenge = await withTaskGroup(of: (Int, MediaRowGroup?).self) { gruppe in
|
||||||
as: Wire.Page<Wire.Series>.self) else { continue }
|
for (platz, mediathek) in mediatheken.enumerated() {
|
||||||
titel = seite.items.map { $0.neutral() }
|
gruppe.addTask { (platz, await self.neuzugang(in: mediathek)) }
|
||||||
} 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() }
|
|
||||||
}
|
}
|
||||||
guard !titel.isEmpty else { continue }
|
|
||||||
reihen.append(MediaRowGroup(
|
// **Die Reihenfolge der Mediatheken, nicht die der Antworten.**
|
||||||
id: "latest.\(mediathek.id)",
|
// Sonst stünden die Reihen nach jedem Start woanders — je
|
||||||
title: String(
|
// nachdem, welche Abfrage zuerst zurückkam.
|
||||||
localized: "flaro.row.latestIn \(mediathek.title)",
|
var gesammelt: [(Int, MediaRowGroup?)] = []
|
||||||
bundle: .module),
|
for await ergebnis in gruppe { gesammelt.append(ergebnis) }
|
||||||
libraryID: mediathek.id,
|
return gesammelt.sorted { $0.0 < $1.0 }.compactMap(\.1)
|
||||||
items: titel))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reihen.append(contentsOf: neuzugaenge)
|
||||||
return reihen
|
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(
|
public func items(
|
||||||
inLibrary library: MediaLibrary,
|
inLibrary library: MediaLibrary,
|
||||||
query: MediaQuery,
|
query: MediaQuery,
|
||||||
@@ -198,11 +239,29 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
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 }
|
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)/\(weg)"))
|
||||||
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/next"))
|
|
||||||
guard !daten.isEmpty else { return nil }
|
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()
|
return folge.neutral()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +308,32 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
/// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein
|
/// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein
|
||||||
/// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet
|
/// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet
|
||||||
/// Rechenzeit, die auf einem NAS knapper ist.
|
/// 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? {
|
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
||||||
rawImageURL(path: path)
|
rawImageURL(path: path)
|
||||||
}
|
}
|
||||||
@@ -302,6 +387,50 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
|
|
||||||
public func preparePlayback(
|
public func preparePlayback(
|
||||||
_ intent: PlaybackIntent
|
_ 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 {
|
) async throws(MediaBackendError) -> PlaybackTarget {
|
||||||
guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else {
|
guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else {
|
||||||
throw .transport(description: "Adresse nicht baubar")
|
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
|
// Muss der Server umpacken, antwortet er auf den Download mit 202 und
|
||||||
// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere
|
// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere
|
||||||
// Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner.
|
// 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
|
// Das Anstossen besorgt der Server beim ersten Abruf selbst; hier
|
||||||
// wird nur nachgefragt, bis es so weit ist.
|
// 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)
|
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
|
// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
||||||
// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
||||||
// wäre die schlechtere Hälfte des Tauschs.
|
// 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
|
// Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen
|
||||||
// Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden
|
// Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden
|
||||||
// hiesse, den ersten Bericht wegzuwerfen.
|
// hiesse, den ersten Bericht wegzuwerfen.
|
||||||
_ = try? await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
|
do {
|
||||||
Wire.ProgressRequest(
|
try await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
|
||||||
positionSeconds: report.time.seconds,
|
Wire.ProgressRequest(
|
||||||
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
|
positionSeconds: report.time.seconds,
|
||||||
|
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Zustand melden
|
// MARK: - Zustand melden
|
||||||
|
|||||||
@@ -95,10 +95,14 @@ enum Wire {
|
|||||||
let overview: String?
|
let overview: String?
|
||||||
let posterImage: String?
|
let posterImage: String?
|
||||||
let backdropImage: String?
|
let backdropImage: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||||
|
let heroImage: String?
|
||||||
let files: [File]
|
let files: [File]
|
||||||
let state: ItemState?
|
let state: ItemState?
|
||||||
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
|
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
|
||||||
let publishAt: Date?
|
let publishAt: Date?
|
||||||
|
/// Der Abspann, sofern er im Bild gefunden wurde.
|
||||||
|
let markers: [Marker]?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Series: Decodable, Sendable {
|
struct Series: Decodable, Sendable {
|
||||||
@@ -108,6 +112,8 @@ enum Wire {
|
|||||||
let overview: String?
|
let overview: String?
|
||||||
let posterImage: String?
|
let posterImage: String?
|
||||||
let backdropImage: String?
|
let backdropImage: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||||
|
let heroImage: String?
|
||||||
let episodeCount: Int
|
let episodeCount: Int
|
||||||
let seasons: [Int]
|
let seasons: [Int]
|
||||||
let unwatchedCount: Int
|
let unwatchedCount: Int
|
||||||
@@ -127,6 +133,20 @@ enum Wire {
|
|||||||
/// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich
|
/// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich
|
||||||
/// nicht darstellbar: Titel und Bild der Serie fehlten.
|
/// nicht darstellbar: Titel und Bild der Serie fehlten.
|
||||||
let seriesId: String?
|
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".
|
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
|
||||||
@@ -140,6 +160,8 @@ enum Wire {
|
|||||||
let year: Int?
|
let year: Int?
|
||||||
let posterImage: String?
|
let posterImage: String?
|
||||||
let backdropImage: String?
|
let backdropImage: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||||
|
let heroImage: String?
|
||||||
let state: ItemState
|
let state: ItemState
|
||||||
/// Zu welcher Serie die Folge gehört.
|
/// Zu welcher Serie die Folge gehört.
|
||||||
let seriesId: String?
|
let seriesId: String?
|
||||||
@@ -178,6 +200,51 @@ enum Wire {
|
|||||||
|
|
||||||
// --- Was hinausgeht --------------------------------------------------
|
// --- 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 {
|
struct LoginRequest: Encodable, Sendable {
|
||||||
let userName: String
|
let userName: String
|
||||||
let password: String
|
let password: String
|
||||||
@@ -194,3 +261,16 @@ enum Wire {
|
|||||||
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
|
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
|
||||||
struct RatingRequest: Encodable, Sendable { let rating: Int? }
|
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) },
|
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
||||||
thumb: posterImage,
|
thumb: posterImage,
|
||||||
art: backdropImage,
|
art: backdropImage,
|
||||||
|
heroArt: heroImage,
|
||||||
playCount: state?.playCount,
|
playCount: state?.playCount,
|
||||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
lastPlayedAt: state?.lastPlayedAt,
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
isFavourite: state?.favourite ?? false,
|
isFavourite: state?.favourite ?? false,
|
||||||
isInProgress: state?.inProgress,
|
isInProgress: state?.inProgress,
|
||||||
availableFrom: publishAt,
|
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,
|
year: year,
|
||||||
thumb: posterImage,
|
thumb: posterImage,
|
||||||
art: backdropImage,
|
art: backdropImage,
|
||||||
|
heroArt: heroImage,
|
||||||
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
|
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
|
||||||
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
||||||
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
||||||
@@ -72,7 +83,16 @@ extension Wire.Episode {
|
|||||||
isFavourite: state?.favourite ?? false,
|
isFavourite: state?.favourite ?? false,
|
||||||
isInProgress: state?.inProgress,
|
isInProgress: state?.inProgress,
|
||||||
availableFrom: publishAt,
|
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.
|
// Serie ein Bild ausgesucht hatte, fand dort ein anderes vor.
|
||||||
art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage,
|
art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage,
|
||||||
seriesThumb: folge ? posterImage : nil,
|
seriesThumb: folge ? posterImage : nil,
|
||||||
|
heroArt: heroImage,
|
||||||
index: episode,
|
index: episode,
|
||||||
parentIndex: season,
|
parentIndex: season,
|
||||||
seriesTitle: folge ? title : nil,
|
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] = [
|
var fields: [String: Any] = [
|
||||||
"ItemId": report.itemID,
|
"ItemId": report.itemID,
|
||||||
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
|
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
|
||||||
@@ -126,13 +126,18 @@ extension JellyfinBackend {
|
|||||||
default: "/Sessions/Playing/Progress"
|
default: "/Sessions/Playing/Progress"
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return }
|
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return false }
|
||||||
_ = try? await client.send(JellyfinRequest(
|
do {
|
||||||
method: .post,
|
try await client.send(JellyfinRequest(
|
||||||
path: path,
|
method: .post,
|
||||||
body: body,
|
path: path,
|
||||||
timeout: .seconds(10)
|
body: body,
|
||||||
))
|
timeout: .seconds(10)
|
||||||
|
))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hilfen
|
// MARK: - Hilfen
|
||||||
|
|||||||
@@ -130,19 +130,34 @@ public func isInLocalNetwork() async -> Bool {
|
|||||||
// sind zwei verschiedene Fragen — in einer gemeinsamen Reihe steht am
|
// sind zwei verschiedene Fragen — in einer gemeinsamen Reihe steht am
|
||||||
// Ende beides durcheinander, und wer nach einem Film sucht, blättert
|
// Ende beides durcheinander, und wer nach einem Film sucht, blättert
|
||||||
// an Folgen vorbei.
|
// an Folgen vorbei.
|
||||||
for library in libraries {
|
//
|
||||||
let items = try? await latestItems(limit: 20, inLibrary: library.id)
|
// **Nebeneinander, nicht nacheinander.** Hier stand ein `await` in
|
||||||
rows.append(MediaRowGroup(
|
// der Schleife: bei vier Mediatheken vier Rundreisen hintereinander.
|
||||||
id: "latest.\(library.id)",
|
// Die Reihenfolge der Mediatheken bleibt trotzdem erhalten — sonst
|
||||||
title: String(
|
// stünden die Reihen nach jedem Start woanders.
|
||||||
localized: "row.latestIn \(library.title)",
|
let latest = await withTaskGroup(of: (Int, MediaRowGroup).self) { group in
|
||||||
bundle: .module
|
for (place, library) in libraries.enumerated() {
|
||||||
),
|
group.addTask {
|
||||||
libraryID: library.id,
|
let items = try? await self.latestItems(limit: 20, inLibrary: library.id)
|
||||||
items: items ?? []
|
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(
|
rows.append(MediaRowGroup(
|
||||||
id: "favourites",
|
id: "favourites",
|
||||||
title: String(localized: "row.favourites", bundle: .module),
|
title: String(localized: "row.favourites", bundle: .module),
|
||||||
@@ -333,6 +348,20 @@ public func isInLocalNetwork() async -> Bool {
|
|||||||
return next.first { $0.id != item.id }
|
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
|
// MARK: - Bilder
|
||||||
|
|
||||||
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
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
|
// MARK: - Aufräumen
|
||||||
|
|
||||||
/// Alles zu einem Server vergessen — beim Abmelden.
|
/// Alles zu einem Server vergessen — beim Abmelden.
|
||||||
@@ -139,6 +174,10 @@ public actor LibraryCache {
|
|||||||
directory(server: server).appending(path: "libraries.json")
|
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.
|
/// Ein Name, der als Datei taugt.
|
||||||
///
|
///
|
||||||
/// Kennungen kommen vom Server: bei Jellyfin GUIDs, bei Plex Zahlen, und
|
/// 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 —
|
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
|
||||||
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
|
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
|
||||||
/// der alles umfasst, wäre kein Nenner mehr.
|
/// der alles umfasst, wäre kein Nenner mehr.
|
||||||
public protocol MediaBackend: Sendable {
|
/// Wer Sehfortschritt entgegennimmt.
|
||||||
/// Wie der Server heißt, so wie er sich selbst nennt.
|
///
|
||||||
nonisolated var serverName: String { get }
|
/// **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
|
/// Wer dieser Server ist — für alles, was ihn über einen Start hinaus
|
||||||
/// wiedererkennen muss.
|
/// wiedererkennen muss.
|
||||||
///
|
///
|
||||||
@@ -17,6 +21,24 @@ public protocol MediaBackend: Sendable {
|
|||||||
/// beides überlebt und beim nächsten Start noch gleich lautet. Es ist
|
/// beides überlebt und beim nächsten Start noch gleich lautet. Es ist
|
||||||
/// eine Kennung und geht nirgends an die Oberfläche.
|
/// eine Kennung und geht nirgends an die Oberfläche.
|
||||||
nonisolated var identity: String { get }
|
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.
|
/// Was dieser Server darüber hinaus beherrscht.
|
||||||
nonisolated var capabilities: Set<BackendCapability> { get }
|
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.
|
/// 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.
|
/// Ein Server, der es in einer Anfrage kann, soll das dürfen.
|
||||||
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem]
|
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.
|
/// Die nächste Folge nach dieser, sofern es eine gibt.
|
||||||
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
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
|
// MARK: - Bilder
|
||||||
|
|
||||||
/// Ein Bild in der gewünschten Größe.
|
/// Ein Bild in der gewünschten Größe.
|
||||||
@@ -125,13 +164,6 @@ public protocol MediaBackend: Sendable {
|
|||||||
/// niemand mehr zusieht.
|
/// niemand mehr zusieht.
|
||||||
func endPlayback(sessionID: String) async
|
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
|
// MARK: - Zustand melden
|
||||||
|
|
||||||
func markWatched(id: String) async throws(MediaBackendError)
|
func markWatched(id: String) async throws(MediaBackendError)
|
||||||
@@ -211,6 +243,19 @@ public struct MediaPage: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public extension MediaBackend {
|
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.
|
/// Vorgabe: der Server kann es nicht.
|
||||||
///
|
///
|
||||||
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
|
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
|
||||||
@@ -227,6 +272,13 @@ public extension MediaBackend {
|
|||||||
subtitleStreamIndex: Int?
|
subtitleStreamIndex: Int?
|
||||||
) -> URL? { nil }
|
) -> 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.
|
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
|
||||||
///
|
///
|
||||||
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
|
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
|
||||||
@@ -261,3 +313,41 @@ public struct MediaFilterOption: Sendable, Equatable, Identifiable {
|
|||||||
self.title = title
|
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.
|
/// die Serie, und so heißt es jetzt auch.
|
||||||
public let seriesThumb: String?
|
public let seriesThumb: String?
|
||||||
public let seriesArt: 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
|
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
|
||||||
// Staffel.
|
// Staffel.
|
||||||
@@ -126,6 +133,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
parentThumb: String? = nil,
|
parentThumb: String? = nil,
|
||||||
seriesThumb: String? = nil,
|
seriesThumb: String? = nil,
|
||||||
seriesArt: String? = nil,
|
seriesArt: String? = nil,
|
||||||
|
heroArt: String? = nil,
|
||||||
index: Int? = nil,
|
index: Int? = nil,
|
||||||
parentIndex: Int? = nil,
|
parentIndex: Int? = nil,
|
||||||
parentTitle: String? = nil,
|
parentTitle: String? = nil,
|
||||||
@@ -166,6 +174,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
self.parentThumb = parentThumb
|
self.parentThumb = parentThumb
|
||||||
self.seriesThumb = seriesThumb
|
self.seriesThumb = seriesThumb
|
||||||
self.seriesArt = seriesArt
|
self.seriesArt = seriesArt
|
||||||
|
self.heroArt = heroArt
|
||||||
self.index = index
|
self.index = index
|
||||||
self.parentIndex = parentIndex
|
self.parentIndex = parentIndex
|
||||||
self.parentTitle = parentTitle
|
self.parentTitle = parentTitle
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public struct MediaLibrary: Sendable, Equatable, Identifiable, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Eine Reihe auf der Startseite oder in den Suchergebnissen.
|
/// 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.
|
/// Eine stabile Kennung für diese Reihe.
|
||||||
///
|
///
|
||||||
/// Sie entscheidet, welche Reihe der Nutzer ausgeblendet oder verschoben
|
/// 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.
|
/// ordnet es die Meldung keiner Wiedergabe zu.
|
||||||
public let versionID: String?
|
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.url = url
|
||||||
self.sessionID = sessionID
|
self.sessionID = sessionID
|
||||||
self.versionID = versionID
|
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.
|
/// Adressen und Zugang — alles, was ohne Netz entsteht.
|
||||||
@Suite("Flaro: Adressen und Zugang")
|
@Suite("Flaro: Adressen und Zugang")
|
||||||
struct AdressenTests {
|
struct AdressenTests {
|
||||||
private func backend(_ transport: Attrappe, keksglas: Keksglas? = nil) -> FlaroBackend {
|
private func backend(_ transport: Attrappe, cookieglas: Cookieglas? = nil) -> FlaroBackend {
|
||||||
FlaroBackend(
|
FlaroBackend(
|
||||||
client: FlaroClient(
|
client: FlaroClient(
|
||||||
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
||||||
transport: transport, cookieJar: keksglas),
|
transport: transport, cookieJar: cookieglas),
|
||||||
account: FlaroAccount(
|
account: FlaroAccount(
|
||||||
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
||||||
userID: "u1", userName: "guido", isAdministrator: true))
|
userID: "u1", userName: "guido", isAdministrator: true))
|
||||||
@@ -32,17 +32,17 @@ struct AdressenTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test("Ein Zugang wird als Cookie abgelegt")
|
@Test("Ein Zugang wird als Cookie abgelegt")
|
||||||
func tokenWirdZuKeks() async {
|
func tokenWirdZuCookie() async {
|
||||||
let glas = Keksglas()
|
let glas = Cookieglas()
|
||||||
_ = FlaroClient(
|
_ = FlaroClient(
|
||||||
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
||||||
transport: Attrappe(rumpf: "{}"), cookieJar: glas)
|
transport: Attrappe(rumpf: "{}"), cookieJar: glas)
|
||||||
|
|
||||||
let keks = glas.kekse.first
|
let cookie = glas.cookies.first
|
||||||
#expect(keks?.name == "flaro_session")
|
#expect(cookie?.name == "flaro_session")
|
||||||
#expect(keks?.value == "geheim-123")
|
#expect(cookie?.value == "geheim-123")
|
||||||
#expect(keks?.path == "/")
|
#expect(cookie?.path == "/")
|
||||||
#expect(keks?.domain.hasSuffix("nas") == true)
|
#expect(cookie?.domain.hasSuffix("nas") == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **Nach einem Neustart meldet sich niemand neu an.**
|
/// **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
|
/// allein aus der Antwort des Servers, wäre keines da — und jedes Plakat
|
||||||
/// bliebe grau, obwohl der Zugang gilt.
|
/// bliebe grau, obwohl der Zugang gilt.
|
||||||
@Test("Auch ein wiederaufgenommenes Token legt das Cookie an")
|
@Test("Auch ein wiederaufgenommenes Token legt das Cookie an")
|
||||||
func keksAuchOhneAnmeldung() async throws {
|
func cookieAuchOhneAnmeldung() async throws {
|
||||||
let glas = Keksglas()
|
let glas = Cookieglas()
|
||||||
let transport = Attrappe([
|
let transport = Attrappe([
|
||||||
("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#),
|
("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#),
|
||||||
("/api/v1/system/info", 200, Proben.info),
|
("/api/v1/system/info", 200, Proben.info),
|
||||||
@@ -62,7 +62,7 @@ struct AdressenTests {
|
|||||||
baseURL: Proben.adresse, token: "geheim-123",
|
baseURL: Proben.adresse, token: "geheim-123",
|
||||||
identity: Proben.kennung, transport: transport, cookieJar: glas)
|
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")
|
@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(
|
let (token, konto, _) = try await FlaroSignIn.signIn(
|
||||||
baseURL: Proben.adresse, userName: "guido", password: "geheim",
|
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"))
|
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
|
/// Der Speicher des Systems anzufassen hiesse, dass eine Prüfung die nächste
|
||||||
/// beeinflusst — und auf einem Entwicklerrechner auch den Browser.
|
/// 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 let sperre = NSLock()
|
||||||
private var inhalt: [HTTPCookie] = []
|
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) } }
|
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:35:00 INFO Server gestartet — 1.0.0 · DEV
|
||||||
21:55:29 INFO Server fährt herunter.
|
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 \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
ffmpeg libva2 libva-drm2 mesa-va-drivers curl \
|
ffmpeg libva2 libva-drm2 mesa-va-drivers curl \
|
||||||
|
libchromaprint-tools \
|
||||||
&& if [ "$TARGETARCH" = "amd64" ]; then \
|
&& if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \
|
apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \
|
||||||
fi \
|
fi \
|
||||||
|
|||||||
@@ -26,9 +26,20 @@ public static class Aufmacherhoehe
|
|||||||
/// <summary>Die Mitte — das, was vorher fest verdrahtet war.</summary>
|
/// <summary>Die Mitte — das, was vorher fest verdrahtet war.</summary>
|
||||||
public const string Vorgabe = "mittel";
|
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>
|
/// <remarks>
|
||||||
/// Unbekanntes wird zur Vorgabe statt zu einem Fehler: in der Spalte
|
/// Unbekanntes wird zur Vorgabe statt zu einem Fehler: in der Spalte
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace Flaro.Api.Auth;
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <c>Authorization: Bearer <token></c>. Der Server hasht das Vorgezeigte
|
/// <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.
|
/// Datenbank und ist aus ihr auch nicht zu gewinnen.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -69,8 +69,8 @@ public sealed class TokenAuthenticationHandler(
|
|||||||
var token = AusKopfzeile() ?? Request.Cookies[CookieName];
|
var token = AusKopfzeile() ?? Request.Cookies[CookieName];
|
||||||
if (string.IsNullOrEmpty(token)) return AuthenticateResult.NoResult();
|
if (string.IsNullOrEmpty(token)) return AuthenticateResult.NoResult();
|
||||||
|
|
||||||
var fingerabdruck = Secrets.Fingerprint(token);
|
var fingerfingerprint = Secrets.Fingerprint(token);
|
||||||
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerabdruck);
|
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerfingerprint);
|
||||||
|
|
||||||
var jetzt = clock.GetUtcNow();
|
var jetzt = clock.GetUtcNow();
|
||||||
if (eintrag is null || !eintrag.IsValid(jetzt)) return AuthenticateResult.Fail("Token gilt nicht.");
|
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.Core.Metadata;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
using Flaro.Metadata;
|
using Flaro.Metadata;
|
||||||
@@ -34,28 +36,55 @@ public static class ArtworkEndpoints
|
|||||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
gruppe.MapGet("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
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 serie = art == "series";
|
||||||
var kennung = serie
|
var kennung = serie
|
||||||
? await db.Series.Where(s => s.Id == id).Select(s => s.TmdbId).FirstOrDefaultAsync(ct)
|
? 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);
|
: 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)
|
? await db.Series.Where(s => s.Id == id)
|
||||||
.Select(s => new Bildstand(s.PosterImage, s.BackdropImage, s.LogoImage,
|
.Select(s => new { s.PosterImage, s.BackdropImage, s.LogoImage,
|
||||||
s.PosterPinned, s.BackdropPinned, s.LogoPinned)).FirstOrDefaultAsync(ct)
|
s.PosterPinned, s.BackdropPinned, s.LogoPinned, s.Aufmacherbilder })
|
||||||
|
.FirstOrDefaultAsync(ct)
|
||||||
: await db.Movies.Where(m => m.Id == id)
|
: await db.Movies.Where(m => m.Id == id)
|
||||||
.Select(m => new Bildstand(m.PosterImage, m.BackdropImage, m.LogoImage,
|
.Select(m => new { m.PosterImage, m.BackdropImage, m.LogoImage,
|
||||||
m.PosterPinned, m.BackdropPinned, m.LogoPinned)).FirstOrDefaultAsync(ct);
|
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();
|
if (aktuell is null) return Results.NotFound();
|
||||||
|
|
||||||
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die
|
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die
|
||||||
// Zuordnung das Problem, nicht das Bild.
|
// Zuordnung das Problem, nicht das Bild.
|
||||||
var kandidaten = kennung is { } k
|
IReadOnlyList<Bildkandidat> kandidaten = [];
|
||||||
? await tmdb.BilderAsync(k, serie, ct)
|
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));
|
return Results.Ok(new Bildauswahl(aktuell, kandidaten));
|
||||||
});
|
});
|
||||||
@@ -83,6 +112,53 @@ public static class ArtworkEndpoints
|
|||||||
return gesetzt ? Results.NoContent() : Results.NotFound();
|
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 (
|
gruppe.MapPost("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||||
string art, Guid id, string kind, HttpRequest anfrage,
|
string art, Guid id, string kind, HttpRequest anfrage,
|
||||||
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -128,8 +128,8 @@ public static class AuthEndpoints
|
|||||||
string handle, FlaroContext db, TimeProvider clock) =>
|
string handle, FlaroContext db, TimeProvider clock) =>
|
||||||
{
|
{
|
||||||
var jetzt = clock.GetUtcNow();
|
var jetzt = clock.GetUtcNow();
|
||||||
var fingerabdruck = Secrets.Fingerprint(handle);
|
var fingerfingerprint = Secrets.Fingerprint(handle);
|
||||||
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerabdruck);
|
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerfingerprint);
|
||||||
|
|
||||||
if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null)
|
if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null)
|
||||||
return Results.Ok(new CodeState("expired", 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.
|
/// lägen die Dateien woanders als die Datenbank sie vermutet.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="Bilder">Wie viel davon auf den Bildspeicher entfällt; sonst <c>null</c>.</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="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>
|
/// <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);
|
int OptimizeDays, int ImageDays);
|
||||||
|
|
||||||
/// <param name="Teile">
|
/// <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
|
/// <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.
|
/// Problem und nicht die Grösse, und das sieht man an Bytes nicht.
|
||||||
/// Additiv: <c>Bilder</c> bleibt stehen, damit kein Client bricht.
|
/// 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>
|
/// <summary>Welche Bilder gerade gesetzt sind — und welche von Hand.</summary>
|
||||||
public sealed record Bildstand(
|
public sealed record Bildstand(
|
||||||
string? Poster, string? Backdrop, string? Logo,
|
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(
|
public sealed record Bildauswahl(
|
||||||
Bildstand Current, IReadOnlyList<Flaro.Core.Metadata.Bildkandidat> Candidates);
|
Bildstand Current, IReadOnlyList<Flaro.Core.Metadata.Bildkandidat> Candidates);
|
||||||
|
|
||||||
public sealed record BildWahlRequest(string Kind, string? Url);
|
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>
|
/// <summary>Ein Titel, der noch wartet oder geplant ist.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -312,7 +322,16 @@ public sealed record ScanStatus(
|
|||||||
/// <summary>Wie viele Dateien dieser Lauf als verschwunden gemeldet hat.</summary>
|
/// <summary>Wie viele Dateien dieser Lauf als verschwunden gemeldet hat.</summary>
|
||||||
int? Missing = null,
|
int? Missing = null,
|
||||||
/// <summary>Woran der Lauf gerade ist, in einem Satz.</summary>
|
/// <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>
|
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
|
||||||
public sealed record MetadataProgress(
|
public sealed record MetadataProgress(
|
||||||
@@ -436,7 +455,45 @@ public sealed record MovieResponse(
|
|||||||
string? SortTitle = null,
|
string? SortTitle = null,
|
||||||
|
|
||||||
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
|
/// <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(
|
public sealed record CreditResponse(
|
||||||
Guid Id, string Name, string Role, string? Character, string? Image);
|
Guid Id, string Name, string Role, string? Character, string? Image);
|
||||||
@@ -498,7 +555,45 @@ public sealed record SeriesResponse(
|
|||||||
string? SortTitle = null,
|
string? SortTitle = null,
|
||||||
|
|
||||||
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
|
/// <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(
|
public sealed record EpisodeResponse(
|
||||||
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
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
|
/// Startseite meldete „Dieser Titel ist auf dem Server nicht mehr
|
||||||
/// vorhanden" — über einen Titel, den sie selbst gerade angezeigt hatte.
|
/// vorhanden" — über einen Titel, den sie selbst gerade angezeigt hatte.
|
||||||
/// </remarks>
|
/// </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);
|
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(
|
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>
|
/// <summary>Was ein Client über der Mediathek anzeigen muss.</summary>
|
||||||
///
|
///
|
||||||
@@ -532,7 +657,9 @@ public sealed record MetadataSettingsResponse(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record AttributionResponse(string Text, string LogoUrl, string Url);
|
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>
|
/// <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(
|
public sealed record StreamResponse(
|
||||||
Guid SessionId,
|
Guid SessionId,
|
||||||
string Url,
|
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,
|
string Method,
|
||||||
double StartSeconds,
|
double StartSeconds,
|
||||||
int? Height,
|
int? Height,
|
||||||
int? AudioStream,
|
int? AudioStream,
|
||||||
IReadOnlyList<SpurAuskunft> AudioStreams,
|
IReadOnlyList<SpurAuskunft> AudioStreams,
|
||||||
IReadOnlyList<SpurAuskunft> SubtitleStreams);
|
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);
|
public sealed record RatingRequest(int? Rating);
|
||||||
|
|
||||||
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
|
/// <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,
|
/// Seite und will das Bild der Serie; dort stand bisher das Folgenbild,
|
||||||
/// und wer das Serienbild selbst ausgesucht hatte, fand ein anderes vor.
|
/// und wer das Serienbild selbst ausgesucht hatte, fand ein anderes vor.
|
||||||
/// </remarks>
|
/// </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>
|
/// <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>
|
/// <summary>Namen und Passwort, die sich der Eingeladene selbst gibt.</summary>
|
||||||
public sealed record EinloesenRequest(string UserName, string Password);
|
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.");
|
return Einwand.Bitte("Ein Link, der nullmal gilt, ist keiner.");
|
||||||
|
|
||||||
// **Das Geheimnis verlässt den Server genau hier.** Gespeichert
|
// **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
|
// Link mehr bauen, und eine verlorene Einladung wird deshalb nicht
|
||||||
// wiedergefunden, sondern neu gemacht.
|
// wiedergefunden, sondern neu gemacht.
|
||||||
var token = Secrets.Create();
|
var token = Secrets.Create();
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
using Flaro.Api.Auth;
|
using Flaro.Api.Auth;
|
||||||
using Flaro.Core.Library;
|
using Flaro.Core.Library;
|
||||||
|
using Flaro.Media;
|
||||||
using Flaro.Core.Media;
|
using Flaro.Core.Media;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Flaro.Core.Playback;
|
using Flaro.Core.Playback;
|
||||||
@@ -77,6 +80,119 @@ public static class LibraryContentEndpoints
|
|||||||
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
|
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
|
||||||
/// und SQLite führt die nacheinander aus.
|
/// und SQLite führt die nacheinander aus.
|
||||||
/// </remarks>
|
/// </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)
|
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);
|
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 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) =>
|
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
|
||||||
await db.GenreLinks.AsNoTracking()
|
await db.GenreLinks.AsNoTracking()
|
||||||
.Where(l => film != null ? l.MovieId == film : l.SeriesId == serie)
|
.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
|
/// 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>.
|
/// noch niemand angefasst hat, und die Antwort trägt dort <c>null</c>.
|
||||||
/// </remarks>
|
/// </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,
|
FlaroContext db, ClaimsPrincipal wer,
|
||||||
List<Guid>? filme = null, List<Guid>? folgen = null, List<Guid>? serien = null)
|
List<Guid>? filme = null, List<Guid>? folgen = null, List<Guid>? serien = null)
|
||||||
{
|
{
|
||||||
@@ -285,11 +423,21 @@ public static class LibraryContentEndpoints
|
|||||||
statusCode: StatusCodes.Status409Conflict);
|
statusCode: StatusCodes.Status409Conflict);
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).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);
|
var zustand = koordinator.State(id);
|
||||||
if (zustand is null)
|
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 bericht = zustand.Report;
|
||||||
var metadaten = zustand.Enrichment;
|
var metadaten = zustand.Enrichment;
|
||||||
@@ -303,7 +451,8 @@ public static class LibraryContentEndpoints
|
|||||||
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
|
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
|
||||||
metadaten.Skipped, metadaten.Error),
|
metadaten.Skipped, metadaten.Error),
|
||||||
bericht?.Missing,
|
bericht?.Missing,
|
||||||
zustand.Schritt?.Text()));
|
zustand.Schritt?.Text(),
|
||||||
|
segmente));
|
||||||
}).WithName("Scanstand").RequireAuthorization();
|
}).WithName("Scanstand").RequireAuthorization();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +578,7 @@ public static class LibraryContentEndpoints
|
|||||||
// Korrektur stand „WALL·E" weiter unter E, bis man die
|
// Korrektur stand „WALL·E" weiter unter E, bis man die
|
||||||
// Seite neu lud.
|
// Seite neu lud.
|
||||||
m.SortTitleValue, m.TitlePinned, m.SortTitlePinned,
|
m.SortTitleValue, m.TitlePinned, m.SortTitlePinned,
|
||||||
|
m.Aufmacherbilder,
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -442,6 +592,7 @@ public static class LibraryContentEndpoints
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, filme: ids);
|
var stände = await ZuständeZu(db, wer, filme: ids);
|
||||||
|
var segmente = await FilmsegmenteZu(db, ids);
|
||||||
|
|
||||||
var items = kennungen.Select(m => new MovieResponse(
|
var items = kennungen.Select(m => new MovieResponse(
|
||||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||||
@@ -453,7 +604,10 @@ public static class LibraryContentEndpoints
|
|||||||
PublishAt: m.PublishAt,
|
PublishAt: m.PublishAt,
|
||||||
TitlePinned: m.TitlePinned,
|
TitlePinned: m.TitlePinned,
|
||||||
SortTitle: m.SortTitleValue,
|
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));
|
return Results.Ok(new Page<MovieResponse>(items, gesamt));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
@@ -509,6 +663,7 @@ public static class LibraryContentEndpoints
|
|||||||
PublishAt = db.Episodes.Where(f => f.SeriesId == s.Id)
|
PublishAt = db.Episodes.Where(f => f.SeriesId == s.Id)
|
||||||
.Min(f => f.PublishAt),
|
.Min(f => f.PublishAt),
|
||||||
s.SortTitleValue, s.TitlePinned, s.SortTitlePinned,
|
s.SortTitleValue, s.TitlePinned, s.SortTitlePinned,
|
||||||
|
s.Aufmacherbilder,
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -537,7 +692,8 @@ public static class LibraryContentEndpoints
|
|||||||
State: stände.GetValueOrDefault(s.Id),
|
State: stände.GetValueOrDefault(s.Id),
|
||||||
TitlePinned: s.TitlePinned,
|
TitlePinned: s.TitlePinned,
|
||||||
SortTitle: s.SortTitleValue,
|
SortTitle: s.SortTitleValue,
|
||||||
SortTitlePinned: s.SortTitlePinned);
|
SortTitlePinned: s.SortTitlePinned,
|
||||||
|
HeroImage: Aufmacherwahl.Jetziges(s.Aufmacherbilder, s.Id, DateTimeOffset.UtcNow));
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
||||||
@@ -593,13 +749,16 @@ public static class LibraryContentEndpoints
|
|||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
var ständeÄhnliche = await ZuständeZu(db, wer, filme: kennungen.ToList());
|
||||||
|
|
||||||
// In der Reihenfolge der Punkte, nicht in der der Datenbank.
|
// In der Reihenfolge der Punkte, nicht in der der Datenbank.
|
||||||
var items = kennungen
|
var items = kennungen
|
||||||
.Select(k => filme.FirstOrDefault(f => f.Id == k))
|
.Select(k => filme.FirstOrDefault(f => f.Id == k))
|
||||||
.Where(f => f != null)
|
.Where(f => f != null)
|
||||||
.Select(f => new MovieResponse(
|
.Select(f => new MovieResponse(
|
||||||
f!.Id, f.Title, f.Year, null, f.PosterImage, f.BackdropImage,
|
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))
|
OriginalTitle: f.OriginalTitle))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -639,7 +798,11 @@ public static class LibraryContentEndpoints
|
|||||||
TitlePinned: film.TitlePinned,
|
TitlePinned: film.TitlePinned,
|
||||||
SortTitle: film.SortTitleValue,
|
SortTitle: film.SortTitleValue,
|
||||||
SortTitlePinned: film.SortTitlePinned,
|
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();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/v1/series/{id:guid}", async (
|
app.MapGet("/api/v1/series/{id:guid}", async (
|
||||||
@@ -674,7 +837,10 @@ public static class LibraryContentEndpoints
|
|||||||
TitlePinned: serie.TitlePinned,
|
TitlePinned: serie.TitlePinned,
|
||||||
SortTitle: serie.SortTitleValue,
|
SortTitle: serie.SortTitleValue,
|
||||||
SortTitlePinned: serie.SortTitlePinned,
|
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();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// **Eine Folge für sich.**
|
// **Eine Folge für sich.**
|
||||||
@@ -704,6 +870,7 @@ public static class LibraryContentEndpoints
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, folgen: [id]);
|
var stände = await ZuständeZu(db, wer, folgen: [id]);
|
||||||
|
var segmente = await SegmenteZu(db, [id]);
|
||||||
|
|
||||||
var wurzel = await MediathekspfadFuer(db, wer,
|
var wurzel = await MediathekspfadFuer(db, wer,
|
||||||
await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId)
|
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(),
|
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||||
stände.GetValueOrDefault(id),
|
stände.GetValueOrDefault(id),
|
||||||
folge.PublishAt,
|
folge.PublishAt,
|
||||||
SeriesId: folge.SeriesId));
|
SeriesId: folge.SeriesId,
|
||||||
|
Markers: segmente[id].ToList()));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
|
// **Die Nachbarfolge — vorwärts wie rückwärts.** Reihenfolge ist
|
||||||
// Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann
|
// Staffel, dann Folge; nicht die Kennung: die ist zeitlich geordnet
|
||||||
// der Scan sie fand, nicht wann sie läuft.
|
// 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) =>
|
// **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()
|
if (!await db.Movies.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
|
||||||
.Where(f => f.Id == id)
|
|
||||||
.Select(f => new { f.SeriesId, f.SeasonNumber, f.EpisodeNumber })
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (diese is null) return Results.NotFound();
|
|
||||||
|
|
||||||
var naechste = await db.Episodes.AsNoTracking()
|
_ = erkennung.FilmAsync(id, umfang);
|
||||||
.Where(f => f.SeriesId == diese.SeriesId)
|
return Results.Accepted();
|
||||||
.Where(f => f.SeasonNumber > diese.SeasonNumber
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|| (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();
|
|
||||||
|
|
||||||
// Keine weitere Folge ist kein Fehler — es war die letzte.
|
// **Die Messkurve eines Films — zum Nachsehen, nicht zum Erkennen.**
|
||||||
if (naechste is null) return Results.NoContent();
|
//
|
||||||
|
// 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()
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
.Where(d => d.EpisodeId == naechste.Id)
|
.Where(f => f.EpisodeId != null && folgen.Contains(f.EpisodeId!.Value))
|
||||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel })
|
.Select(f => new { f.Id, Folge = f.EpisodeId!.Value })
|
||||||
.ToListAsync();
|
.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
|
// **Nur Folgen, die überhaupt eine Datei haben.** Eine Folge, die
|
||||||
// nichts; das Plakat der Serie sagt wenigstens, worum es geht.
|
// noch nicht da ist, kann keine Sprache beisteuern — sie als
|
||||||
// TMDB liefert Folgenbilder nur für einen Teil der Folgen, und
|
// fehlend zu zählen liesse die Schnittmenge leer werden.
|
||||||
// bei kleineren Serien für gar keine.
|
var mitDatei = dateien.Select(d => d.Folge).Distinct().Count();
|
||||||
var serienBild = await db.Series.AsNoTracking()
|
if (mitDatei == 0) return Results.Ok(new Sprachauswahl([], [], 0));
|
||||||
.Where(x => x.Id == diese.SeriesId)
|
|
||||||
.Select(x => x.PosterImage).FirstOrDefaultAsync();
|
|
||||||
|
|
||||||
return Results.Ok(new EpisodeResponse(
|
var zuFolge = dateien.ToDictionary(d => d.Id, d => d.Folge);
|
||||||
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
|
var kennungen = dateien.Select(d => d.Id).ToList();
|
||||||
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(),
|
var spuren = await db.MediaStreams.AsNoTracking()
|
||||||
stände.GetValueOrDefault(naechste.Id),
|
.Where(x => kennungen.Contains(x.MediaFileId))
|
||||||
naechste.PublishAt,
|
.Where(x => x.Kind == MediaStreamKind.Audio || x.Kind == MediaStreamKind.Subtitle)
|
||||||
SeriesId: diese.SeriesId));
|
.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();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
|
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
|
||||||
@@ -789,6 +1077,7 @@ public static class LibraryContentEndpoints
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, folgen: ids);
|
var stände = await ZuständeZu(db, wer, folgen: ids);
|
||||||
|
var segmente = await SegmenteZu(db, ids);
|
||||||
|
|
||||||
var serienBild = await db.Series.AsNoTracking()
|
var serienBild = await db.Series.AsNoTracking()
|
||||||
.Where(x => x.Id == id)
|
.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(),
|
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||||
stände.GetValueOrDefault(f.Id),
|
stände.GetValueOrDefault(f.Id),
|
||||||
f.PublishAt,
|
f.PublishAt,
|
||||||
SeriesId: id)).ToList();
|
SeriesId: id,
|
||||||
|
Markers: segmente[f.Id].ToList())).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ public static class ManagementEndpoints
|
|||||||
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
|
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
if (!Enum.TryParse<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
|
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
|
// **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad
|
||||||
// führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und
|
// 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.
|
// — nicht der angezeigte Titel, der ja gerade der falsche ist.
|
||||||
var vorschläge = await anbieter.SearchMoviesAsync(
|
var vorschläge = await anbieter.SearchMoviesAsync(
|
||||||
string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(),
|
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
|
// Die Maske trägt das Jahr aus dem Dateinamen schon ein; wer
|
||||||
// Zuordnung, die gerade korrigiert werden soll. „Doctor
|
// es herausnimmt, sagt damit, dass er es nicht weiss. Hier
|
||||||
// Strange" war fälschlich auf ein Making-of von 2022
|
// stand ein Rückfall auf eben dieses Jahr — das leere Feld
|
||||||
// gelaufen; die Suche hängte daraufhin `year=2022` an, und der
|
// hatte also keine Wirkung, und ein falsch benannter Film
|
||||||
// richtige Film von 2016 konnte gar nicht mehr auftauchen. Ein
|
// liess sich nicht finden. „Plan B (2010)" heisst bei TMDB
|
||||||
// falscher Treffer verhinderte so seine eigene Korrektur.
|
// „DivertiMento – Plan B" und ist von 2011; mit dem Jahr aus
|
||||||
year ?? film.ParsedYear);
|
// 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 =>
|
return Results.Ok(vorschläge.Select(v =>
|
||||||
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
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(
|
var vorschläge = await anbieter.SearchSeriesAsync(
|
||||||
string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(),
|
string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(),
|
||||||
// Siehe oben: aus dem Dateinamen, nicht aus der Zuordnung.
|
// Siehe oben: ein leeres Jahr ist eine Angabe, kein Fehlen.
|
||||||
year ?? serie.ParsedYear);
|
year);
|
||||||
|
|
||||||
return Results.Ok(vorschläge.Select(v =>
|
return Results.Ok(vorschläge.Select(v =>
|
||||||
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Flaro.Api.Auth;
|
using Flaro.Api.Auth;
|
||||||
|
using Flaro.Core.Library;
|
||||||
|
using Flaro.Core.Media;
|
||||||
using Flaro.Core.Playback;
|
using Flaro.Core.Playback;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -96,7 +98,30 @@ public static class PlaybackEndpoints
|
|||||||
var gemeldet = TimeSpan.FromSeconds(anfrage.PositionSeconds);
|
var gemeldet = TimeSpan.FromSeconds(anfrage.PositionSeconds);
|
||||||
if (gemeldet <= TimeSpan.Zero) gemeldet = zustand.Position;
|
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
|
// **Erst zählen, dann überschreiben.** Der Zuwachs ergibt sich aus
|
||||||
// der alten Stelle; eine Zeile später gibt es die nicht mehr.
|
// 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 })
|
.Select(x => new { x.Id, x.Title, x.PosterImage, x.BackdropImage })
|
||||||
.ToDictionaryAsync(x => x.Id);
|
.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>();
|
var liste = new List<ContinueEntry>();
|
||||||
foreach (var x in angefasst)
|
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)
|
.Where(f => f.SeriesId == x.SeriesId)
|
||||||
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
.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 });
|
.FirstOrDefault(f => zustände.GetValueOrDefault(f.Id) is not { Watched: true });
|
||||||
|
|
||||||
// Nichts offen — die Serie ist durch.
|
// Nichts offen — die Serie ist durch.
|
||||||
if (naechste is null) continue;
|
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;
|
if (!serien.TryGetValue(x.SeriesId, out var serie)) continue;
|
||||||
|
|
||||||
var zustand = zustände.GetValueOrDefault(naechste.Id);
|
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 })
|
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.MovieId, d.VersionLabel })
|
||||||
.ToListAsync();
|
.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(
|
var items = treffer.Select(m => new MovieResponse(
|
||||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||||
vorschlagsdateien.Where(d => d.MovieId == m.Id)
|
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.Id, Path.GetFileName(d.Path), d.SizeBytes, d.Container,
|
||||||
d.Duration?.TotalSeconds, [], d.VersionLabel))
|
d.Duration?.TotalSeconds, [], d.VersionLabel))
|
||||||
.ToList(),
|
.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();
|
PublishAt: m.PublishAt)).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<MovieResponse>(items, items.Count));
|
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>
|
/// <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
|
/// Folge" zeigt überwiegend Folgen ohne Stand, und ein Client, der für
|
||||||
/// jede davon einen Sonderfall braucht, vergisst ihn irgendwo.
|
/// jede davon einen Sonderfall braucht, vergisst ihn irgendwo.
|
||||||
/// </remarks>
|
/// </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);
|
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>
|
/// <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 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))
|
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);
|
.ToDictionaryAsync(m => m.Id);
|
||||||
|
|
||||||
var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.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,
|
f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage,
|
||||||
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt,
|
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt,
|
||||||
SerienBild = s.BackdropImage,
|
SerienBild = s.BackdropImage, SerienAufmacher = s.Aufmacherbilder,
|
||||||
})
|
})
|
||||||
.ToDictionaryAsync(f => f.Id);
|
.ToDictionaryAsync(f => f.Id);
|
||||||
|
|
||||||
@@ -508,7 +658,8 @@ public static class PlaybackEndpoints
|
|||||||
m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z),
|
m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z),
|
||||||
FileId: dateien.GetValueOrDefault(m.Id).Id,
|
FileId: dateien.GetValueOrDefault(m.Id).Id,
|
||||||
DurationSeconds: dateien.GetValueOrDefault(m.Id).Sekunden,
|
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))
|
else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f))
|
||||||
liste.Add(new ContinueEntry(
|
liste.Add(new ContinueEntry(
|
||||||
f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null,
|
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,
|
FileId: dateien.GetValueOrDefault(f.Id).Id,
|
||||||
DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden,
|
DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden,
|
||||||
PublishAt: f.PublishAt,
|
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;
|
return liste;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Flaro.Api.Auth;
|
using Flaro.Api.Auth;
|
||||||
using Flaro.Core;
|
using Flaro.Core;
|
||||||
using Flaro.Core.Delivery;
|
using Flaro.Core.Delivery;
|
||||||
|
using Flaro.Core.Library;
|
||||||
using Flaro.Core.Playback;
|
using Flaro.Core.Playback;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
using Flaro.Metadata;
|
using Flaro.Metadata;
|
||||||
@@ -15,7 +16,11 @@ public static class SettingsEndpoints
|
|||||||
app.MapGet("/api/v1/settings/metadata", async (FlaroContext db) =>
|
app.MapGet("/api/v1/settings/metadata", async (FlaroContext db) =>
|
||||||
{
|
{
|
||||||
var zeilen = await db.Settings.AsNoTracking()
|
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);
|
.ToDictionaryAsync(s => s.Key, s => s.Value);
|
||||||
|
|
||||||
// **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem
|
// **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem
|
||||||
@@ -30,7 +35,14 @@ public static class SettingsEndpoints
|
|||||||
Language: aufgelöst.Language,
|
Language: aufgelöst.Language,
|
||||||
// Damit in der Oberfläche steht, ob der mitgelieferte Schlüssel
|
// Damit in der Oberfläche steht, ob der mitgelieferte Schlüssel
|
||||||
// benutzt wird — und dass ein eigener ihn ablösen würde.
|
// 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);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
// **In welcher Sprache der Bestand vorliegt.**
|
// **In welcher Sprache der Bestand vorliegt.**
|
||||||
@@ -88,6 +100,15 @@ public static class SettingsEndpoints
|
|||||||
MetadataSettingsRequest anfrage, FlaroContext db) =>
|
MetadataSettingsRequest anfrage, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
if (anfrage.ApiKey is not null) await Setzen(db, DatabaseTmdbSettings.ApiKeyName, anfrage.ApiKey.Trim());
|
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)
|
if (anfrage.Language is not null)
|
||||||
{
|
{
|
||||||
var sprache = anfrage.Language.Trim();
|
var sprache = anfrage.Language.Trim();
|
||||||
@@ -152,10 +173,50 @@ public static class SettingsEndpoints
|
|||||||
Kodierstufen.Lesen(anfrage.Encoder).AlsWort(), geaendert));
|
Kodierstufen.Lesen(anfrage.Encoder).AlsWort(), geaendert));
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).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 (
|
app.MapGet("/api/v1/settings/maintenance", async (
|
||||||
Kratzplatzeinstellungen kratz, Wartungsplaneinstellungen plan) =>
|
Transcodeeinstellungen kratz, Wartungsplaneinstellungen plan) =>
|
||||||
{
|
{
|
||||||
var regeln = await kratz.RegelnAsync();
|
var regeln = await kratz.RegelnAsync();
|
||||||
var w = await plan.RegelnAsync();
|
var w = await plan.RegelnAsync();
|
||||||
@@ -169,23 +230,23 @@ public static class SettingsEndpoints
|
|||||||
|
|
||||||
app.MapPut("/api/v1/settings/maintenance", async (
|
app.MapPut("/api/v1/settings/maintenance", async (
|
||||||
MaintenanceSettingsRequest anfrage, FlaroContext db,
|
MaintenanceSettingsRequest anfrage, FlaroContext db,
|
||||||
Kratzplatzeinstellungen einstellungen, Wartungsplaneinstellungen plan) =>
|
Transcodeeinstellungen einstellungen, Wartungsplaneinstellungen plan) =>
|
||||||
{
|
{
|
||||||
var vorher = await einstellungen.RegelnAsync();
|
var vorher = await einstellungen.RegelnAsync();
|
||||||
|
|
||||||
// Was nicht geschickt wird, bleibt — ein Client, der nur ein Feld
|
// Was nicht geschickt wird, bleibt — ein Client, der nur ein Feld
|
||||||
// kennt, soll die anderen nicht zurücksetzen.
|
// kennt, soll die anderen nicht zurücksetzen.
|
||||||
var nachher = Kratzplatzregeln.Geprueft(
|
var nachher = Transcoderegeln.Geprueft(
|
||||||
anfrage.CapGb ?? vorher.ObergrenzeBytes / 1024d / 1024 / 1024,
|
anfrage.CapGb ?? vorher.ObergrenzeBytes / 1024d / 1024 / 1024,
|
||||||
anfrage.UnusedDays ?? (int)vorher.Unbenutzt.TotalDays,
|
anfrage.UnusedDays ?? (int)vorher.Unbenutzt.TotalDays,
|
||||||
anfrage.GraceMinutes ?? (int)vorher.Schonfrist.TotalMinutes);
|
anfrage.GraceMinutes ?? (int)vorher.Schonfrist.TotalMinutes);
|
||||||
|
|
||||||
var zahl = System.Globalization.CultureInfo.InvariantCulture;
|
var zahl = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
await Setzen(db, Kratzplatzeinstellungen.ObergrenzeSchluessel,
|
await Setzen(db, Transcodeeinstellungen.ObergrenzeSchluessel,
|
||||||
(nachher.ObergrenzeBytes / 1024d / 1024 / 1024).ToString("0.###", zahl));
|
(nachher.ObergrenzeBytes / 1024d / 1024 / 1024).ToString("0.###", zahl));
|
||||||
await Setzen(db, Kratzplatzeinstellungen.UnbenutztSchluessel,
|
await Setzen(db, Transcodeeinstellungen.UnbenutztSchluessel,
|
||||||
((int)nachher.Unbenutzt.TotalDays).ToString(zahl));
|
((int)nachher.Unbenutzt.TotalDays).ToString(zahl));
|
||||||
await Setzen(db, Kratzplatzeinstellungen.SchonfristSchluessel,
|
await Setzen(db, Transcodeeinstellungen.SchonfristSchluessel,
|
||||||
((int)nachher.Schonfrist.TotalMinutes).ToString(zahl));
|
((int)nachher.Schonfrist.TotalMinutes).ToString(zahl));
|
||||||
var wVorher = await plan.RegelnAsync();
|
var wVorher = await plan.RegelnAsync();
|
||||||
var wNachher = Wartungsregeln.Geprueft(
|
var wNachher = Wartungsregeln.Geprueft(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Flaro.Core.Delivery;
|
using Flaro.Core.Delivery;
|
||||||
@@ -26,6 +27,34 @@ namespace Flaro.Api.Endpoints;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class StreamEndpoints
|
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)
|
public static void MapStream(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
// **Eine Sitzung anlegen.** Der Rumpf trägt, was das Gerät kann und
|
// **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 (
|
app.MapPost("/api/v1/files/{id:guid}/stream", async (
|
||||||
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
|
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
|
||||||
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr,
|
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr,
|
||||||
Untertitelvorrat vorrat) =>
|
Untertitelvorrat vorrat, Vorschauvorrat vorschau) =>
|
||||||
{
|
{
|
||||||
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
|
||||||
@@ -55,6 +84,13 @@ public static class StreamEndpoints
|
|||||||
// Antwort statt eines Fehlers.
|
// Antwort statt eines Fehlers.
|
||||||
vorrat.Anstellen(gewuenschte.Id, vordraengeln: true);
|
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 profil = Profil(anfrage);
|
||||||
var hoehe = anfrage.Height is { } gewuenscht
|
var hoehe = anfrage.Height is { } gewuenscht
|
||||||
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
|
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
|
||||||
@@ -64,7 +100,7 @@ public static class StreamEndpoints
|
|||||||
// **Was der Browser ohnehin öffnet, geht unverändert hinaus.**
|
// **Was der Browser ohnehin öffnet, geht unverändert hinaus.**
|
||||||
//
|
//
|
||||||
// Eine MP4 mit H.264 und AAC durch ffmpeg zu schicken wäre ein
|
// 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
|
// Browser verlöre dabei das, was er von sich aus am besten
|
||||||
// kann: springen über Bereichsanfragen, ohne dass irgendwo
|
// kann: springen über Bereichsanfragen, ohne dass irgendwo
|
||||||
// etwas neu gerechnet wird.
|
// etwas neu gerechnet wird.
|
||||||
@@ -89,7 +125,7 @@ public static class StreamEndpoints
|
|||||||
erzwingen: anfrage.Transcode,
|
erzwingen: anfrage.Transcode,
|
||||||
tonversatz: Math.Clamp(anfrage.AudioOffsetMs, -5000, 5000) / 1000.0);
|
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)
|
catch (InvalidOperationException fehler)
|
||||||
{
|
{
|
||||||
@@ -99,9 +135,139 @@ public static class StreamEndpoints
|
|||||||
}
|
}
|
||||||
}).RequireAuthorization();
|
}).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.
|
// **Die Playlist.** Sie wächst, während ffmpeg schreibt.
|
||||||
app.MapGet("/api/v1/stream/{sitzung:guid}/index.m3u8", async (
|
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
|
// **Diese Datei darf nie zwischengespeichert werden**, und das ist
|
||||||
// kein Vorsichtsmass, sondern aus einem Fehler gelernt: der Film
|
// 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
|
// **Als Inhalt, nicht als Datei.** `Results.File` hängt
|
||||||
// `Last-Modified` an und lädt damit zum Zwischenspeichern ein.
|
// `Last-Modified` an und lädt damit zum Zwischenspeichern ein.
|
||||||
if (Streamkoordinator.Playlist(jetzt.Ordner) is { } liste)
|
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);
|
return Results.Text(liste, "application/vnd.apple.mpegurl", Encoding.UTF8);
|
||||||
|
}
|
||||||
|
|
||||||
// **Hört ffmpeg auf, ohne ein Segment geschrieben zu haben**,
|
// **Hört ffmpeg auf, ohne ein Segment geschrieben zu haben**,
|
||||||
// kommt auch keines mehr. Weiterzuwarten hiesse, eine halbe
|
// 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
|
// **Schliessen heisst aufräumen.** Ohne das liefe ffmpeg weiter, bis
|
||||||
// die Geduld der Sitzung abgelaufen ist — und schriebe solange den
|
// 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) =>
|
app.MapDelete("/api/v1/stream/{sitzung:guid}", (Guid sitzung, Streamkoordinator koordinator) =>
|
||||||
{
|
{
|
||||||
koordinator.Beenden(sitzung);
|
koordinator.Beenden(sitzung);
|
||||||
@@ -229,6 +407,68 @@ public static class StreamEndpoints
|
|||||||
// in dessen Zukunft, und zwar für den ganzen Rest des Films. Gemessen
|
// 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
|
// im Spieler: bei „Fortsetzen ab 8:37" stand der Satz von Sekunde 0
|
||||||
// auf dem Bild.
|
// 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 (
|
app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async (
|
||||||
Guid id, int spur, FlaroContext db, Untertitelvorrat vorrat,
|
Guid id, int spur, FlaroContext db, Untertitelvorrat vorrat,
|
||||||
CancellationToken ct, double ab = 0) =>
|
CancellationToken ct, double ab = 0) =>
|
||||||
@@ -294,6 +534,11 @@ public static class StreamEndpoints
|
|||||||
// dass es nichts zu beenden und nichts aufzuräumen gibt.
|
// dass es nichts zu beenden und nichts aufzuräumen gibt.
|
||||||
SessionId: Guid.Empty,
|
SessionId: Guid.Empty,
|
||||||
Url: $"/api/v1/files/{datei}/download",
|
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",
|
Method: "direct",
|
||||||
|
|
||||||
// **Null, nicht `ab`.** Bei einer durchgereichten Datei springt
|
// **Null, nicht `ab`.** Bei einer durchgereichten Datei springt
|
||||||
@@ -302,13 +547,10 @@ public static class StreamEndpoints
|
|||||||
StartSeconds: 0,
|
StartSeconds: 0,
|
||||||
Height: null,
|
Height: null,
|
||||||
AudioStream: spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Audio)?.Index,
|
AudioStream: spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Audio)?.Index,
|
||||||
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio)
|
AudioStreams: Spurliste(spuren.Where(s => s.Kind == MediaStreamKind.Audio)),
|
||||||
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
SubtitleStreams: Spurliste(spuren
|
||||||
.ToList(),
|
.Where(s => s.Kind == MediaStreamKind.Subtitle)
|
||||||
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
|
.Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))));
|
||||||
.Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))
|
|
||||||
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
|
||||||
.ToList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Das Geräteprofil aus der Anfrage — oder, wenn keines kam, Apples.</summary>
|
/// <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));
|
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()
|
var spuren = await db.MediaStreams.AsNoTracking()
|
||||||
.Where(s => s.MediaFileId == sitzung.DateiId)
|
.Where(s => s.MediaFileId == sitzung.DateiId)
|
||||||
.OrderBy(s => s.Index)
|
.OrderBy(s => s.Index)
|
||||||
.ToListAsync();
|
.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(
|
return new StreamResponse(
|
||||||
SessionId: sitzung.Kennung,
|
SessionId: sitzung.Kennung,
|
||||||
Url: $"/api/v1/stream/{sitzung.Kennung}/index.m3u8",
|
Url: $"/api/v1/stream/{sitzung.Kennung}/index.m3u8",
|
||||||
|
MasterUrl: hatText ? $"/api/v1/stream/{sitzung.Kennung}/master.m3u8" : null,
|
||||||
Method: sitzung.Art switch
|
Method: sitzung.Art switch
|
||||||
{
|
{
|
||||||
Stromart.Direkt => "direct",
|
Stromart.Direkt => "direct",
|
||||||
@@ -354,13 +615,10 @@ public static class StreamEndpoints
|
|||||||
StartSeconds: sitzung.Ab,
|
StartSeconds: sitzung.Ab,
|
||||||
Height: sitzung.Hoehe,
|
Height: sitzung.Hoehe,
|
||||||
AudioStream: sitzung.Tonspur,
|
AudioStream: sitzung.Tonspur,
|
||||||
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio)
|
AudioStreams: Spurliste(spuren.Where(s => s.Kind == MediaStreamKind.Audio)),
|
||||||
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
SubtitleStreams: Spurliste(spuren
|
||||||
.ToList(),
|
.Where(s => s.Kind == MediaStreamKind.Subtitle)
|
||||||
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
|
.Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))));
|
||||||
.Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))
|
|
||||||
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
|
||||||
.ToList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Bildbasierte Untertitel lassen sich nicht nach WebVTT wandeln.</summary>
|
/// <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.
|
/// nichts führt, ist schlechter als kein Eintrag.
|
||||||
/// </remarks>
|
/// </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>
|
/// <summary>Wie eine Spur im Menü heisst.</summary>
|
||||||
private static string Benennung(MediaStream spur)
|
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 sprache = Sprache(spur.Language);
|
||||||
var teile = new List<string> { sprache };
|
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
|
// wohin die Bilder wandern, musste die Compose-Datei lesen. Und wenn
|
||||||
// das `cache`-Volume vollläuft, ist das der erste Ort, an dem man
|
// das `cache`-Volume vollläuft, ist das der erste Ort, an dem man
|
||||||
// nachsieht — nicht der letzte.
|
// 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) =>
|
Speicherort Messen(string name, string variable, string pfad) =>
|
||||||
new(name, variable, pfad, Belegt(pfad), Frei(pfad),
|
new(name, variable, pfad, Belegt(pfad), Frei(pfad),
|
||||||
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null,
|
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
|
// nicht, ob das Segmente, umgepackte Filme oder Untertitel
|
||||||
// sind — und davon hängt ab, ob es ein Problem ist.
|
// sind — und davon hängt ab, ob es ein Problem ist.
|
||||||
name == "Umpacken" ? pflege.Belegung() : null);
|
name == "Umpacken" ? pflege.Belegung() : null);
|
||||||
@@ -269,7 +269,7 @@ public static class SystemEndpoints
|
|||||||
});
|
});
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
// **Den Kratzplatz aufräumen.**
|
// **Den Transcode-Cache aufräumen.**
|
||||||
//
|
//
|
||||||
// POST und nicht DELETE wie beim Bildspeicher darüber: dort wird ein
|
// POST und nicht DELETE wie beim Bildspeicher darüber: dort wird ein
|
||||||
// Ordner geleert, hier eine Regel angewandt. Etwas, das Verwaistes
|
// Ordner geleert, hier eine Regel angewandt. Etwas, das Verwaistes
|
||||||
@@ -301,8 +301,8 @@ public static class SystemEndpoints
|
|||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
app.MapPost("/api/v1/system/storage/transcode/cleanup", async (
|
app.MapPost("/api/v1/system/storage/transcode/cleanup", async (
|
||||||
Kratzplatzpflege pflege, CancellationToken ct) =>
|
Transcodepflege pflege, CancellationToken ct) =>
|
||||||
Results.Ok(await pflege.LaufenAsync(Kratzplatzpflege.Anlass.VonHand, ct)))
|
Results.Ok(await pflege.LaufenAsync(Transcodepflege.Anlass.VonHand, ct)))
|
||||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
// **Den Bildspeicher leeren.**
|
// **Den Bildspeicher leeren.**
|
||||||
|
|||||||
@@ -35,3 +35,84 @@ public sealed class DatabaseTmdbSettings(FlaroContext db) : ITmdbSettingsSource
|
|||||||
zeilen.GetValueOrDefault(LanguageName));
|
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
|
// Umpacken ist der eine, in dem der Platz wirklich gebraucht
|
||||||
// wird — und `PlatzPruefen` sagt danach die Wahrheit statt
|
// wird — und `PlatzPruefen` sagt danach die Wahrheit statt
|
||||||
// „kein Platz", obwohl zwanzig Gigabyte Altlast herumliegen.
|
// „kein Platz", obwohl zwanzig Gigabyte Altlast herumliegen.
|
||||||
await bereich.ServiceProvider.GetRequiredService<Kratzplatzpflege>()
|
await bereich.ServiceProvider.GetRequiredService<Transcodepflege>()
|
||||||
.LaufenAsync(Kratzplatzpflege.Anlass.VorDemUmpacken);
|
.LaufenAsync(Transcodepflege.Anlass.VorDemUmpacken);
|
||||||
|
|
||||||
PlatzPruefen(datei.SizeBytes);
|
PlatzPruefen(datei.SizeBytes);
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ public sealed class PackageCoordinator(
|
|||||||
if (platte.AvailableFreeSpace >= gebraucht) return;
|
if (platte.AvailableFreeSpace >= gebraucht) return;
|
||||||
|
|
||||||
throw new InvalidOperationException(
|
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 " +
|
$"{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.");
|
$"{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.
|
// und lässt einen Absturz dort den Server nicht mitnehmen.
|
||||||
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
|
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
|
||||||
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
|
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
|
||||||
builder.Services.AddSingleton<Schluesselbilder>();
|
builder.Services.AddSingleton<Keyframes>();
|
||||||
builder.Services.AddSingleton<FfmpegRemuxer>();
|
builder.Services.AddSingleton<FfmpegRemuxer>();
|
||||||
|
|
||||||
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
|
// 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.
|
// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann.
|
||||||
builder.Services.AddScoped<ITmdbSettingsSource, DatabaseTmdbSettings>();
|
builder.Services.AddScoped<ITmdbSettingsSource, DatabaseTmdbSettings>();
|
||||||
builder.Services.AddScoped<Wiedergabeeinstellungen>();
|
builder.Services.AddScoped<Wiedergabeeinstellungen>();
|
||||||
|
builder.Services.AddScoped<Saisoneinstellungen>();
|
||||||
builder.Services.AddHttpClient<TmdbClient>(c =>
|
builder.Services.AddHttpClient<TmdbClient>(c =>
|
||||||
{
|
{
|
||||||
c.BaseAddress = new Uri(TmdbClient.BaseAddress);
|
c.BaseAddress = new Uri(TmdbClient.BaseAddress);
|
||||||
@@ -98,6 +99,70 @@ builder.Services.AddHttpClient<TmdbClient>(c =>
|
|||||||
c.Timeout = TimeSpan.FromSeconds(20);
|
c.Timeout = TimeSpan.FromSeconds(20);
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<IMetadataProvider>(sp => sp.GetRequiredService<TmdbClient>());
|
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.AddSingleton(new ImageCacheOptions(pfade.Cache));
|
||||||
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
|
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
|
||||||
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
|
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<MetadataEnricher>();
|
||||||
builder.Services.AddScoped<MatchApplier>();
|
builder.Services.AddScoped<MatchApplier>();
|
||||||
builder.Services.AddSingleton<ScanCoordinator>();
|
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>();
|
builder.Services.AddSingleton<PackageCoordinator>();
|
||||||
|
|
||||||
// **Neben dem Umpacker, nicht an seiner Stelle.** Der baut eine fertige Datei
|
// **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<Streamkoordinator>();
|
||||||
builder.Services.AddSingleton<UntertitelLeser>();
|
builder.Services.AddSingleton<UntertitelLeser>();
|
||||||
builder.Services.AddSingleton<Untertitelvorrat>();
|
builder.Services.AddSingleton<Untertitelvorrat>();
|
||||||
// Der Kratzplatz räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
|
builder.Services.AddSingleton<Vorschaubilder>();
|
||||||
builder.Services.AddScoped<Kratzplatzeinstellungen>();
|
builder.Services.AddSingleton<Vorschauvorrat>();
|
||||||
builder.Services.AddSingleton<Kratzplatzpflege>();
|
// 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
|
// **Der einzige Hintergrunddienst, und mit Bedacht.** Eine Sicherung hängt
|
||||||
// an keinem Aufruf: sie soll alle drei Tage geschehen, auch wenn niemand
|
// 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
|
// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage
|
||||||
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
|
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
|
||||||
builder.Services.AddSingleton<PlaybackSessions>();
|
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.**
|
// **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
|
// sollte. Hier bekommt jede Ausnahme, die es bis nach oben schafft, einen
|
||||||
// deutschen Satz; die Einzelheiten gehen ins Tagesprotokoll, wo man sie
|
// deutschen Satz; die Einzelheiten gehen ins Tagesprotokoll, wo man sie
|
||||||
// sucht.
|
// 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 =>
|
app.UseExceptionHandler(zweig => zweig.Run(async zusammenhang =>
|
||||||
{
|
{
|
||||||
var fehler = zusammenhang.Features
|
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
|
// 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.
|
// 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>();
|
var leben = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||||
|
|
||||||
leben.ApplicationStarted.Register(() =>
|
leben.ApplicationStarted.Register(() =>
|
||||||
@@ -343,8 +451,11 @@ if (Directory.Exists(oberfläche))
|
|||||||
// Stromordner noch da und jedes Teilstück eines abgebrochenen
|
// Stromordner noch da und jedes Teilstück eines abgebrochenen
|
||||||
// Umpackens — und beim Start ist sicher, dass niemand davon noch
|
// Umpackens — und beim Start ist sicher, dass niemand davon noch
|
||||||
// gebraucht wird.
|
// gebraucht wird.
|
||||||
_ = app.Services.GetRequiredService<Kratzplatzpflege>()
|
_ = app.Services.GetRequiredService<Transcodepflege>()
|
||||||
.LaufenAsync(Kratzplatzpflege.Anlass.Start, leben.ApplicationStopping);
|
.LaufenAsync(Transcodepflege.Anlass.Start, leben.ApplicationStopping);
|
||||||
|
|
||||||
|
_ = app.Services.GetRequiredService<Sehstandpflege>()
|
||||||
|
.NachUpdateAsync(leben.ApplicationStopping);
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
_ = 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 tagebuch = bereich.ServiceProvider.GetRequiredService<Tagebuch>();
|
||||||
var melder = new Progress<Scanschritt>(schritt =>
|
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
|
// **Vermerke stehen nur in der Datei.** Sie erklären, warum
|
||||||
// etwas so zugeordnet wurde — in der Fortschrittszeile wäre das
|
// etwas so zugeordnet wurde — in der Fortschrittszeile wäre das
|
||||||
@@ -164,8 +172,16 @@ public sealed class ScanCoordinator(
|
|||||||
// Verwaiste entstehen: Dateien verschwinden, Änderungszeiten
|
// Verwaiste entstehen: Dateien verschwinden, Änderungszeiten
|
||||||
// wandern, und die dazugehörigen Pakete und Untertitel gehören
|
// wandern, und die dazugehörigen Pakete und Untertitel gehören
|
||||||
// zu einem Stand, den es nicht mehr gibt.
|
// zu einem Stand, den es nicht mehr gibt.
|
||||||
_ = bereich.ServiceProvider.GetRequiredService<Kratzplatzpflege>()
|
_ = bereich.ServiceProvider.GetRequiredService<Transcodepflege>()
|
||||||
.LaufenAsync(Kratzplatzpflege.Anlass.NachDemScan);
|
.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
|
_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>
|
/// <remarks>
|
||||||
/// <b>Daran hängt das Aufräumen.</b> Ein geschlossener Browser sagt
|
/// <b>Daran hängt das Aufräumen.</b> Ein geschlossener Browser sagt
|
||||||
/// niemandem Bescheid; bliebe die Sitzung stehen, liefe ffmpeg weiter und
|
/// 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>
|
/// </remarks>
|
||||||
public DateTimeOffset Gesehen { get; set; } = Begonnen;
|
public DateTimeOffset Gesehen { get; set; } = Begonnen;
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ public sealed class Streamkoordinator(
|
|||||||
IServiceScopeFactory scopes,
|
IServiceScopeFactory scopes,
|
||||||
FlaroPaths pfade,
|
FlaroPaths pfade,
|
||||||
FfmpegOptions ffmpeg,
|
FfmpegOptions ffmpeg,
|
||||||
Schluesselbilder schluesselbilder,
|
Keyframes schluesselbilder,
|
||||||
TimeProvider clock,
|
TimeProvider clock,
|
||||||
ILogger<Streamkoordinator> log) : IDisposable
|
ILogger<Streamkoordinator> log) : IDisposable
|
||||||
{
|
{
|
||||||
@@ -140,22 +140,22 @@ public sealed class Streamkoordinator(
|
|||||||
var stufe = await bereich.ServiceProvider
|
var stufe = await bereich.ServiceProvider
|
||||||
.GetRequiredService<Wiedergabeeinstellungen>().StufeAsync(ct);
|
.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
|
// 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
|
// 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
|
// Sekunde 12: Bild ab 0, Ton erst ab 1,98 — zwei Sekunden stummes
|
||||||
// Bild, und die Uhr des Spielers zwei Sekunden daneben.
|
// Bild, und die Uhr des Spielers zwei Sekunden daneben.
|
||||||
//
|
//
|
||||||
// Beim Umkodieren entfällt das: dort wird das Bild neu gerechnet und
|
// Beim Umkodieren entfällt das: dort wird das Bild neu gerechnet und
|
||||||
// schneidet auf die Sekunde genau. Dann vorzurunden hiesse, ohne Not
|
// schneidet auf die Sekunde genau. Dann vorzurunden hiesse, ohne Not
|
||||||
// ein Stück zurückzuspringen.
|
// 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
|
// 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
|
// fangen dort gemeinsam an; das ist die Zusage, und sie hängt an
|
||||||
// keiner Rechnung hier.
|
// keiner Rechnung hier.
|
||||||
//
|
//
|
||||||
@@ -165,9 +165,11 @@ public sealed class Streamkoordinator(
|
|||||||
// bleiben zusammen. Umgekehrt wäre es ein schlechter Tausch: einen
|
// bleiben zusammen. Umgekehrt wäre es ein schlechter Tausch: einen
|
||||||
// Sprungpunkt auf die Millisekunde zu treffen, ist zu viel verlangt.
|
// Sprungpunkt auf die Millisekunde zu treffen, ist zu viel verlangt.
|
||||||
var gewuenscht = Math.Max(0, ab);
|
var gewuenscht = Math.Max(0, ab);
|
||||||
|
var uhr = System.Diagnostics.Stopwatch.StartNew();
|
||||||
var gemeldet = plan.BildKopieren
|
var gemeldet = plan.BildKopieren
|
||||||
? await schluesselbilder.DavorAsync(datei.Path, gewuenscht, ct)
|
? await schluesselbilder.DavorAsync(datei.Path, gewuenscht, ct)
|
||||||
: gewuenscht;
|
: gewuenscht;
|
||||||
|
var suche = uhr.ElapsedMilliseconds;
|
||||||
|
|
||||||
var anfrage = new Stromanfrage(
|
var anfrage = new Stromanfrage(
|
||||||
datei.Path, ordner, gelesen, plan, tonspur, gewuenscht, Hardware(), stufe, tonversatz);
|
datei.Path, ordner, gelesen, plan, tonspur, gewuenscht, Hardware(), stufe, tonversatz);
|
||||||
@@ -183,10 +185,19 @@ public sealed class Streamkoordinator(
|
|||||||
var abbruch = new CancellationTokenSource();
|
var abbruch = new CancellationTokenSource();
|
||||||
_sitzungen[kennung] = new Eintrag(sitzung, prozess, abbruch);
|
_sitzungen[kennung] = new Eintrag(sitzung, prozess, abbruch);
|
||||||
|
|
||||||
log.LogInformation(
|
// **Mit Zeiten, und auch im Tagesprotokoll.**
|
||||||
"Strom {Kennung}: {Art} von {Datei} ab {Ab}s{Hoehe}.",
|
//
|
||||||
kennung, plan.Art, Path.GetFileName(datei.Path), sitzung.Ab,
|
// „Es dauert fünfzehn Sekunden" liess sich nicht beantworten: zwischen
|
||||||
plan.Hoehe is { } h ? $", auf {h}p" : "");
|
// 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;
|
return sitzung;
|
||||||
}
|
}
|
||||||
@@ -220,7 +231,7 @@ public sealed class Streamkoordinator(
|
|||||||
if (eintrag.Sitzung.Gesehen > grenze) continue;
|
if (eintrag.Sitzung.Gesehen > grenze) continue;
|
||||||
if (!_sitzungen.TryRemove(kennung, out var weg)) 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);
|
kennung, Geduld);
|
||||||
Abraeumen(weg);
|
Abraeumen(weg);
|
||||||
}
|
}
|
||||||
@@ -258,14 +269,34 @@ public sealed class Streamkoordinator(
|
|||||||
/// <summary>Ob die Playlist schon etwas enthält, das sich abspielen lässt.</summary>
|
/// <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;
|
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>
|
/// <summary>Welche Beschleunigung benutzt wird.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b><c>auto</c> prüft, statt zu behaupten.</b> Liegt kein
|
/// <para>
|
||||||
/// <c>/dev/dri/renderD128</c> vor — auf einem Mac, in einem Container ohne
|
/// <b><c>auto</c> fragt ffmpeg, statt die Datei anzusehen.</b> Hier stand
|
||||||
/// durchgereichtes Gerät —, läuft es in Software weiter. Langsamer, aber
|
/// <c>File.Exists("/dev/dri/renderD128")</c>, und das war keine Prüfung,
|
||||||
/// es läuft; ein ffmpeg, das mit „No VA display found" abbricht, wäre für
|
/// sondern eine Vermutung: auf dem NAS liegt das Gerät durchgereicht da,
|
||||||
/// den Zuschauer dasselbe wie ein kaputter Server.
|
/// 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>
|
/// </remarks>
|
||||||
private Beschleunigung Hardware()
|
private Beschleunigung Hardware()
|
||||||
{
|
{
|
||||||
@@ -275,10 +306,62 @@ public sealed class Streamkoordinator(
|
|||||||
{
|
{
|
||||||
"none" or "software" => Beschleunigung.Software,
|
"none" or "software" => Beschleunigung.Software,
|
||||||
"vaapi" => Beschleunigung.Vaapi,
|
"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)
|
private Process Starten(Stromanfrage anfrage, Stromsitzung sitzung)
|
||||||
{
|
{
|
||||||
var argumente = HlsArgumente.Fuer(anfrage);
|
var argumente = HlsArgumente.Fuer(anfrage);
|
||||||
@@ -313,7 +396,7 @@ public sealed class Streamkoordinator(
|
|||||||
// eine Spur.
|
// eine Spur.
|
||||||
if (text.Trim() is { Length: > 0 } warnungen)
|
if (text.Trim() is { Length: > 0 } warnungen)
|
||||||
{
|
{
|
||||||
log.LogWarning("Strom {Kennung}: ffmpeg meldet {Warnungen}",
|
log.LogWarning("Stream {Kennung}: ffmpeg meldet {Warnungen}",
|
||||||
sitzung.Kennung, warnungen);
|
sitzung.Kennung, warnungen);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,8 +404,9 @@ public sealed class Streamkoordinator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
sitzung.Fehler = text.Trim();
|
sitzung.Fehler = text.Trim();
|
||||||
log.LogWarning("Strom {Kennung}: ffmpeg endete mit {Code}: {Fehler}",
|
log.LogError("Wiedergabe gescheitert: ffmpeg endete mit {Code}. {Fehler}",
|
||||||
sitzung.Kennung, prozess.ExitCode, sitzung.Fehler);
|
prozess.ExitCode, sitzung.Fehler);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
_ = Task.Run(() => prozess.StandardOutput.ReadToEndAsync());
|
_ = Task.Run(() => prozess.StandardOutput.ReadToEndAsync());
|
||||||
@@ -350,7 +434,7 @@ public sealed class Streamkoordinator(
|
|||||||
}
|
}
|
||||||
catch (IOException fehler)
|
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); }
|
try { Directory.Delete(Wurzel, recursive: true); }
|
||||||
catch (IOException fehler)
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +472,7 @@ public sealed class Streamkoordinator(
|
|||||||
/// Löschen in <c>Abraeumen</c> an einem <c>IOException</c>, ist die
|
/// 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
|
/// Sitzung aus dem Verzeichnis heraus und der Ordner bleibt für immer
|
||||||
/// liegen. Die Altersprüfung nimmt dabei den Fall heraus, dass ein
|
/// 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.
|
/// bekommt alle paar Sekunden ein Segment geschrieben.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public long VerwaisteOrdnerWegraeumen(DateTimeOffset jetzt)
|
public long VerwaisteOrdnerWegraeumen(DateTimeOffset jetzt)
|
||||||
@@ -437,7 +521,7 @@ public sealed class Streamkoordinator(
|
|||||||
|
|
||||||
// **Auch, was ein Absturz hinterlassen hat.** Nach einem harten Ende
|
// **Auch, was ein Absturz hinterlassen hat.** Nach einem harten Ende
|
||||||
// steht der Ordner noch da; ihn beim nächsten Herunterfahren zu
|
// 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();
|
WurzelLeeren();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
namespace Flaro.Api;
|
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;
|
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>
|
/// <remarks>
|
||||||
/// Nach dem Vorbild von <see cref="Wiedergabeeinstellungen"/>: einmal je
|
/// Nach dem Vorbild von <see cref="Wiedergabeeinstellungen"/>: einmal je
|
||||||
/// Anfrage gelesen und gemerkt. Die Tabelle ist generisch, es braucht keine
|
/// Anfrage gelesen und gemerkt. Die Tabelle ist generisch, es braucht keine
|
||||||
/// Migration.
|
/// Migration.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class Kratzplatzeinstellungen(FlaroContext db)
|
public sealed class Transcodeeinstellungen(FlaroContext db)
|
||||||
{
|
{
|
||||||
public const string ObergrenzeSchluessel = "wartung.kratzplatz.paket.obergrenzeGb";
|
public const string ObergrenzeSchluessel = "wartung.kratzplatz.paket.obergrenzeGb";
|
||||||
public const string UnbenutztSchluessel = "wartung.kratzplatz.paket.unbenutztTage";
|
public const string UnbenutztSchluessel = "wartung.kratzplatz.paket.unbenutztTage";
|
||||||
public const string SchonfristSchluessel = "wartung.kratzplatz.paket.schonfristMinuten";
|
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;
|
if (_gemerkt is { } fertig) return fertig;
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ public sealed class Kratzplatzeinstellungen(FlaroContext db)
|
|||||||
|| e.Key == SchonfristSchluessel)
|
|| e.Key == SchonfristSchluessel)
|
||||||
.ToDictionaryAsync(e => e.Key, e => e.Value, ct);
|
.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),
|
Zahl(werte, ObergrenzeSchluessel, vorgabe.ObergrenzeBytes / 1024d / 1024 / 1024),
|
||||||
(int)Zahl(werte, UnbenutztSchluessel, vorgabe.Unbenutzt.TotalDays),
|
(int)Zahl(werte, UnbenutztSchluessel, vorgabe.Unbenutzt.TotalDays),
|
||||||
(int)Zahl(werte, SchonfristSchluessel, vorgabe.Schonfrist.TotalMinutes));
|
(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>
|
/// <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);
|
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>
|
/// <remarks>
|
||||||
/// <para>
|
/// <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.
|
/// nebeneinander löschten einander die Dateien unter den Händen weg.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class Kratzplatzpflege(
|
public sealed class Transcodepflege(
|
||||||
IServiceScopeFactory scopes,
|
IServiceScopeFactory scopes,
|
||||||
FlaroPaths pfade,
|
FlaroPaths pfade,
|
||||||
PackageCoordinator pakete,
|
PackageCoordinator pakete,
|
||||||
Streamkoordinator stroeme,
|
Streamkoordinator stroeme,
|
||||||
TimeProvider uhr,
|
TimeProvider uhr,
|
||||||
ILogger<Kratzplatzpflege> log)
|
ILogger<Transcodepflege> log)
|
||||||
{
|
{
|
||||||
private readonly SemaphoreSlim _einerZurZeit = new(1, 1);
|
private readonly SemaphoreSlim _einerZurZeit = new(1, 1);
|
||||||
|
|
||||||
@@ -65,11 +65,14 @@ public sealed class Kratzplatzpflege(
|
|||||||
var (paketfrei, paketzahl) = await PaketeAufraeumenAsync(jetzt, ct);
|
var (paketfrei, paketzahl) = await PaketeAufraeumenAsync(jetzt, ct);
|
||||||
var (textfrei, textzahl) = await UntertitelAufraeumenAsync(ct);
|
var (textfrei, textzahl) = await UntertitelAufraeumenAsync(ct);
|
||||||
|
|
||||||
|
var (vorschaufrei, vorschauzahl) = await VorschauAufraeumenAsync(ct);
|
||||||
|
|
||||||
var teile = new List<Teilbericht>
|
var teile = new List<Teilbericht>
|
||||||
{
|
{
|
||||||
new("stroeme", stromfrei, 0),
|
new("stroeme", stromfrei, 0),
|
||||||
new("packages", paketfrei, paketzahl),
|
new("packages", paketfrei, paketzahl),
|
||||||
new("untertitel", textfrei, textzahl),
|
new("untertitel", textfrei, textzahl),
|
||||||
|
new("vorschau", vorschaufrei, vorschauzahl),
|
||||||
};
|
};
|
||||||
|
|
||||||
var bericht = new Aufraeumbericht(
|
var bericht = new Aufraeumbericht(
|
||||||
@@ -78,7 +81,7 @@ public sealed class Kratzplatzpflege(
|
|||||||
if (bericht.Frei > 0)
|
if (bericht.Frei > 0)
|
||||||
{
|
{
|
||||||
log.LogInformation(
|
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);
|
anlass, bericht.Frei / (1024 * 1024), bericht.Dateien);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +103,7 @@ public sealed class Kratzplatzpflege(
|
|||||||
using var raum = scopes.CreateScope();
|
using var raum = scopes.CreateScope();
|
||||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||||
var regeln = await raum.ServiceProvider
|
var regeln = await raum.ServiceProvider
|
||||||
.GetRequiredService<Kratzplatzeinstellungen>().RegelnAsync(ct);
|
.GetRequiredService<Transcodeeinstellungen>().RegelnAsync(ct);
|
||||||
|
|
||||||
// **Teilstücke gehen bedingungslos.** Sie entstehen nur beim Bauen,
|
// **Teilstücke gehen bedingungslos.** Sie entstehen nur beim Bauen,
|
||||||
// und wer gerade baut, hat die Datei noch offen — eine liegengebliebene
|
// 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 })
|
.Select(f => new { f.Id, f.ModifiedAt })
|
||||||
.ToDictionaryAsync(f => f.Id, f => f.ModifiedAt.ToUnixTimeMilliseconds(), ct);
|
.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))
|
akten, bekannt, PackageCoordinator.Bauart, regeln, jetzt, pakete.IstInBenutzung))
|
||||||
{
|
{
|
||||||
frei += Loeschen(a.Pfad);
|
frei += Loeschen(a.Pfad);
|
||||||
@@ -159,6 +162,66 @@ public sealed class Kratzplatzpflege(
|
|||||||
/// lässt den Server bei jedem Start eine vierzig Gigabyte grosse Datei
|
/// lässt den Server bei jedem Start eine vierzig Gigabyte grosse Datei
|
||||||
/// neu durchlesen, um wieder nichts zu finden.
|
/// neu durchlesen, um wieder nichts zu finden.
|
||||||
/// </remarks>
|
/// </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)
|
private async Task<(long Frei, int Dateien)> UntertitelAufraeumenAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var ordner = Path.Combine(pfade.Transcode, "untertitel");
|
var ordner = Path.Combine(pfade.Transcode, "untertitel");
|
||||||
@@ -240,6 +303,7 @@ public sealed class Kratzplatzpflege(
|
|||||||
("stroeme", stroeme.Wurzel),
|
("stroeme", stroeme.Wurzel),
|
||||||
("packages", pakete.Ordner),
|
("packages", pakete.Ordner),
|
||||||
("untertitel", Path.Combine(pfade.Transcode, "untertitel")),
|
("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)))
|
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);
|
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>
|
/// <summary>Holt eine Spur — und wartet dabei höchstens <paramref name="geduld"/>.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Der Code allein holt nie ein Token ab.</b> Dafür gibt es den Abholschein,
|
/// <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
|
/// 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
|
/// Million Versuche sind nichts, und am Ende stünde ein fremdes Gerät in der
|
||||||
/// Mediathek.
|
/// Mediathek.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ namespace Flaro.Core.Auth;
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Übernommen ist die Geheimnisdisziplin.</b> Wie bei
|
/// <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
|
/// selbst bekommt man genau einmal zu sehen, beim Erstellen. Wer die
|
||||||
/// Datenbank liest, kann daraus keine Einladung bauen.
|
/// Datenbank liest, kann daraus keine Einladung bauen.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace Flaro.Core.Auth;
|
|||||||
/// <summary>Erzeugt und verfingert Geheimnisse, die Clients vorzeigen.</summary>
|
/// <summary>Erzeugt und verfingert Geheimnisse, die Clients vorzeigen.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <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
|
/// Datenbank erbeutet, hat damit Hashes und keine Zugänge. Dass das überhaupt
|
||||||
/// erwähnt werden muss: bei Medienservern liegen Token regelmässig im
|
/// erwähnt werden muss: bei Medienservern liegen Token regelmässig im
|
||||||
/// Klartext, und ein einziges Datenbankleck gibt dann jedem alles.
|
/// Klartext, und ein einziges Datenbankleck gibt dann jedem alles.
|
||||||
@@ -23,7 +23,7 @@ public static class Secrets
|
|||||||
public static string Create() =>
|
public static string Create() =>
|
||||||
Base64UrlEncode(RandomNumberGenerator.GetBytes(Bytes));
|
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) =>
|
public static string Fingerprint(string secret) =>
|
||||||
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(secret)));
|
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(secret)));
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace Flaro.Core.Delivery;
|
|||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <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,
|
/// 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,
|
/// wozu eine Datei gehört und ob sie noch gilt — sonst bliebe ihm nur,
|
||||||
/// alles zu behalten oder alles wegzuwerfen.
|
/// 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="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="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>
|
/// <param name="Schonfrist">Wie lange eine frisch gebrauchte Datei in jedem Fall bleibt.</param>
|
||||||
public sealed record Kratzplatzregeln(
|
public sealed record Transcoderegeln(
|
||||||
long ObergrenzeBytes,
|
long ObergrenzeBytes,
|
||||||
TimeSpan Unbenutzt,
|
TimeSpan Unbenutzt,
|
||||||
TimeSpan Schonfrist)
|
TimeSpan Schonfrist)
|
||||||
@@ -34,7 +34,7 @@ public sealed record Kratzplatzregeln(
|
|||||||
/// Schonfrist decken die Lücke zwischen zwei Teilanfragen und einen
|
/// Schonfrist decken die Lücke zwischen zwei Teilanfragen und einen
|
||||||
/// Neustart, der den Benutzungszähler verliert.
|
/// Neustart, der den Benutzungszähler verliert.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static Kratzplatzregeln Vorgabe { get; } =
|
public static Transcoderegeln Vorgabe { get; } =
|
||||||
new(20L * 1024 * 1024 * 1024, TimeSpan.FromDays(7), TimeSpan.FromMinutes(30));
|
new(20L * 1024 * 1024 * 1024, TimeSpan.FromDays(7), TimeSpan.FromMinutes(30));
|
||||||
|
|
||||||
/// <summary>Nimmt entgegen, was eingestellt wurde, und biegt Unsinn zurecht.</summary>
|
/// <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
|
/// Datei weg — deshalb eine Untergrenze von fünf Minuten. Negative Werte
|
||||||
/// gelten als „aus".
|
/// gelten als „aus".
|
||||||
/// </remarks>
|
/// </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),
|
new(obergrenzeGb <= 0 ? 0 : (long)(obergrenzeGb * 1024 * 1024 * 1024),
|
||||||
unbenutztTage <= 0 ? TimeSpan.Zero : TimeSpan.FromDays(Math.Min(unbenutztTage, 3650)),
|
unbenutztTage <= 0 ? TimeSpan.Zero : TimeSpan.FromDays(Math.Min(unbenutztTage, 3650)),
|
||||||
TimeSpan.FromMinutes(Math.Clamp(schonfristMinuten, 5, 1440)));
|
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">
|
/// <param name="Benutzt">
|
||||||
/// Wann sie zuletzt gebraucht wurde — die Änderungszeit der Datei selbst.
|
/// Wann sie zuletzt gebraucht wurde — die Änderungszeit der Datei selbst.
|
||||||
@@ -66,7 +66,7 @@ public readonly record struct Paketakte(
|
|||||||
Paketname.Teile? Name);
|
Paketname.Teile? Name);
|
||||||
|
|
||||||
/// <summary>Welche Dateien weggeräumt werden — reine Rechnung, ohne Platte.</summary>
|
/// <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="bekannt">Kennung → Änderungszeitpunkt der Quelle, aus der Mediathek.</param>
|
||||||
/// <param name="bauart">Wie der Server heute baut.</param>
|
/// <param name="bauart">Wie der Server heute baut.</param>
|
||||||
@@ -75,7 +75,7 @@ public static class Kratzplatzauswahl
|
|||||||
IReadOnlyList<Paketakte> akten,
|
IReadOnlyList<Paketakte> akten,
|
||||||
IReadOnlyDictionary<Guid, long> bekannt,
|
IReadOnlyDictionary<Guid, long> bekannt,
|
||||||
int bauart,
|
int bauart,
|
||||||
Kratzplatzregeln regeln,
|
Transcoderegeln regeln,
|
||||||
DateTimeOffset jetzt,
|
DateTimeOffset jetzt,
|
||||||
Func<string, bool> inBenutzung)
|
Func<string, bool> inBenutzung)
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,34 @@
|
|||||||
namespace Flaro.Core.Libraries;
|
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>
|
/// <summary>Ein Verzeichnis mit Medien, wie der Betreiber es eingerichtet hat.</summary>
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -64,3 +64,157 @@ public sealed class Credit
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public int Order { get; set; }
|
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>
|
/// </remarks>
|
||||||
public string? LogoImage { get; set; }
|
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;
|
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +515,24 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string? LogoImage { get; set; }
|
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;
|
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>
|
/// <summary>Verwaistes wird entfernt, Doppeltes zusammengelegt.</summary>
|
||||||
Aufraeumen,
|
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>
|
/// <summary>Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -70,6 +79,7 @@ public readonly record struct Scanschritt(Scanphase Phase, string Was, int Erled
|
|||||||
Scanphase.Holen => $"Hole Metadaten zu {Was}{zaehler}",
|
Scanphase.Holen => $"Hole Metadaten zu {Was}{zaehler}",
|
||||||
Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}",
|
Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}",
|
||||||
Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen",
|
Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen",
|
||||||
|
Scanphase.Segmente => $"Suche Vorspann und Abspann: {Was}{zaehler}",
|
||||||
Scanphase.Vermerk => Was,
|
Scanphase.Vermerk => Was,
|
||||||
_ => 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