Compare commits
178
Commits
fbf4a29461
..
main
+19
-10
@@ -1,20 +1,29 @@
|
||||
# Baut das Flaro-Image und legt es in die Registry dieses Gitea.
|
||||
#
|
||||
# **Warum hier und nicht auf dem Arbeitsrechner.** Ein Bau von Hand heisst:
|
||||
# jemand muss daran denken, muss die richtige Architektur angeben und muss
|
||||
# angemeldet sein. Hier passiert es bei jedem Push von selbst, immer für
|
||||
# beide Architekturen, und die Anmeldung besorgt Gitea.
|
||||
# **Zur Zeit schläft diese Datei.** Gebaut wird auf dem Arbeitsrechner mit
|
||||
# `Tools/image-veroeffentlichen.sh` — der Rechner ist für die Apps ohnehin
|
||||
# nötig, weil Xcode nirgendwo sonst läuft.
|
||||
#
|
||||
# **Voraussetzung ist ein Runner.** Ohne einen registrierten Act-Runner
|
||||
# läuft diese Datei nie an — dann bleibt der Weg von Hand aus
|
||||
# `Tools/image-veroeffentlichen.sh`.
|
||||
# **Weil Gitea nicht selbst baut.** Es verteilt Arbeit an einen Act-Runner,
|
||||
# und der müsste auf dem NAS laufen. Dort scheitert er: sein Zweck ist das
|
||||
# Bauen von Images, also braucht er Zugriff auf Docker, und die Docker-App
|
||||
# des UGREEN lehnt den Einhängepunkt `/var/run/docker.sock` ab. Übrig
|
||||
# blieben `privileged: true` oder der Weg über SSH an der Oberfläche vorbei
|
||||
# — beides gibt dem Runner faktisch Root auf dem NAS.
|
||||
#
|
||||
# **Deshalb nur `workflow_dispatch`.** Mit einem Push-Auslöser legt Gitea
|
||||
# bei jedem Push einen Lauf an, den niemand abarbeitet; nach einer Woche
|
||||
# steht die Actions-Seite voll mit Leichen. Kommt irgendwann ein Runner
|
||||
# dazu, lässt sich der Lauf von Hand starten — und der Auslöser unten ist
|
||||
# mit drei Zeilen wieder da.
|
||||
|
||||
name: Image bauen und veröffentlichen
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
# Wieder automatisch, sobald ein Runner existiert:
|
||||
# push:
|
||||
# branches: [main]
|
||||
# tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m11.0721 5.13152c.3321-.84203 1.5237-.84203 1.8558 0l1.6803 4.26031 4.2603 1.68027c.842.3321.842 1.5237 0 1.8558l-4.2603 1.6803-1.6803 4.2603c-.3321.842-1.5237.842-1.8558 0l-1.68027-4.2603-4.26031-1.6803c-.84203-.3321-.84203-1.5237 0-1.8558l4.26031-1.68027zm.9279 1.73562-1.2928 3.27806c-.1014.2571-.3049.4606-.562.562l-3.27806 1.2928 3.27806 1.2928c.2571.1014.4606.3049.562.562l1.2928 3.2781 1.2928-3.2781c.1014-.2571.3049-.4606.562-.562l3.2781-1.2928-3.2781-1.2928c-.2571-.1014-.4606-.3049-.562-.562z" fill="rgb(0,0,0)" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 673 B |
@@ -1 +0,0 @@
|
||||
<svg id="Layer_1" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-name="Layer 1"><linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="-52.588" x2="390.826" y1="178.158" y2="434.163"><stop offset="0" stop-color="#55d6ff"/><stop offset="1" stop-color="#ce55ff"/></linearGradient><path d="m485.036 256a54.706 54.706 0 0 1 -27.636 47.868l-347.527 200.644a54.742 54.742 0 0 1 -55.272 0 54.713 54.713 0 0 1 -27.637-47.868v-401.288a54.718 54.718 0 0 1 27.636-47.869 54.75 54.75 0 0 1 55.273 0l347.527 200.645a54.708 54.708 0 0 1 27.636 47.868z" fill="url(#linear-gradient)"/></svg>
|
||||
|
Before Width: | Height: | Size: 675 B |
@@ -2,7 +2,7 @@
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"automatic-gradient" : "display-p3:0.44346,0.27892,0.96783,1.00000"
|
||||
"automatic-gradient" : "display-p3:0.56409,0.38672,0.96783,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -17,15 +17,15 @@
|
||||
"layers" : [
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "display-p3:0.73918,0.46760,0.96783,1.00000"
|
||||
"automatic-gradient" : "display-p3:0.51566,0.11264,0.96783,1.00000"
|
||||
},
|
||||
"image-name" : "play.svg",
|
||||
"name" : "play",
|
||||
"image-name" : "flare (1).svg",
|
||||
"name" : "flare (1)",
|
||||
"position" : {
|
||||
"scale" : 1.18,
|
||||
"scale" : 100,
|
||||
"translation-in-points" : [
|
||||
49.7265625,
|
||||
1.468530916781674e-05
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ struct FlaroApp: App {
|
||||
@State private var network = NetworkConditions()
|
||||
@State private var homeLayout = HomeLayoutSettings()
|
||||
@State private var diagnostics = PlaybackDiagnostics()
|
||||
/// Fortschritt, der den Server nicht erreicht hat — siehe
|
||||
/// `Fortschrittspost`. Hier, weil er die Wiedergabe überleben muss.
|
||||
@State private var post = Fortschrittspost.shared
|
||||
@State private var downloads: DownloadCoordinator
|
||||
|
||||
private let modelContainer: ModelContainer
|
||||
@@ -92,6 +95,7 @@ struct FlaroApp: App {
|
||||
.environment(downloads)
|
||||
.environment(homeLayout)
|
||||
.environment(diagnostics)
|
||||
.environment(post)
|
||||
.environment(theme)
|
||||
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
|
||||
//
|
||||
@@ -117,6 +121,8 @@ struct FlaroApp: App {
|
||||
backend: session.backend,
|
||||
coordinator: downloads
|
||||
).refill()
|
||||
// Was im Flugzeug liegengeblieben ist, geht jetzt raus.
|
||||
await post.zustellen(über: session.backend)
|
||||
}
|
||||
}
|
||||
.modelContainer(modelContainer)
|
||||
|
||||
@@ -25,6 +25,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@: %@": {
|
||||
"comment": "A label that shows the audio or subtitle language. The argument is the language name.",
|
||||
"isCommentAutoGenerated": true,
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "%1$@: %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"4K": {
|
||||
"comment": "A label for a 4K video.",
|
||||
"isCommentAutoGenerated": true
|
||||
@@ -1241,6 +1253,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.episodeCount %lld": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld Folgen geladen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld episodes downloaded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.failed": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -1722,7 +1750,7 @@
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Staffel %lld · Episode %lld"
|
||||
"value": "Staffel %lld · Folge %lld"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
@@ -1886,6 +1914,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter.downloadedOnly": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nur Heruntergeladene"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Downloaded only"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter.filters": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -2122,6 +2167,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"home.downloaded": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Heruntergeladen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Downloaded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"home.empty.description": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -2339,6 +2400,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"home.offline": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Kein Server erreichbar. Was hier steht, stammt vom letzten Mal — ansehen lässt sich nur, was auf dem Gerät liegt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "No server reachable. What you see is from last time — only what is on this device can be played."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"home.rowsHeader": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -2468,6 +2545,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.audioShort": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Ton"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Audio"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.avoidAudioDescription": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
@@ -2621,6 +2715,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.forThisTitle": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tonsprache für diesen Titel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Audio language for this title"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.headerExplanation": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -2655,6 +2766,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.none": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Keine"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "None"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.partial %@": {
|
||||
"comment": "Eine kleine Schriftart, die",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"languages.partial %lld": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nur Sprachen, die in jeder Folge vorkommen. %lld weitere gibt es nur in einem Teil."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Only languages present in every episode. %lld more exist in some episodes only."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.serverDefault": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -2704,6 +2853,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.subtitlesForThisSeries": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Untertitel für diese Serie"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subtitles for this series"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.subtitlesForThisTitle": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Untertitel für diesen Titel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subtitles for this title"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.subtitlesHeader": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -2736,6 +2919,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.subtitlesShort": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Untertitel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subtitles"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.title": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -3905,7 +4105,6 @@
|
||||
}
|
||||
},
|
||||
"player.nextEpisode": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -4041,6 +4240,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.previousEpisode": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Vorige Folge"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Previous episode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.quality": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -5039,6 +5254,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"season.short %@": {
|
||||
"comment": "A label that shows the number of the selected season.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"season.short %lld": {
|
||||
"comment": "Platzhalter: Staffelnummer",
|
||||
"extractionState": "stale",
|
||||
@@ -5912,6 +6131,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.skipExtraScenes": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Zusätzliche Szenen mit überspringen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Skip extra scenes too"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.storageLimit": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -7837,6 +8072,40 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"upcoming.from %@": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Ab %@ Uhr"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "From %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"upcoming.title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Bald ist es soweit"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Coming soon"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.1"
|
||||
|
||||
@@ -196,9 +196,65 @@ struct DownloadService {
|
||||
quality: DownloadQuality = .original,
|
||||
audioStream: MediaStream? = nil
|
||||
) throws(DownloadRefusal) -> DownloadItem {
|
||||
try coordinator.enqueue(
|
||||
let eintrag = try coordinator.enqueue(
|
||||
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
|
||||
)
|
||||
plakatHolen(for: item)
|
||||
return eintrag
|
||||
}
|
||||
|
||||
/// Holt das Plakat mit herunter.
|
||||
///
|
||||
/// **Ohne Verbindung gibt es keine Adresse mehr, von der eines käme.**
|
||||
/// Ein Download ohne Bild ist offline eine graue Fläche mit einem Titel
|
||||
/// darunter — und genau dann, wenn man etwas zum Ansehen sucht, ist das
|
||||
/// Bild die halbe Auskunft. Ein paar Kilobyte je Eintrag.
|
||||
///
|
||||
/// Nebenher und ohne Fehlerweg: scheitert es, fehlt ein Bild. Den
|
||||
/// Download deshalb scheitern zu lassen wäre die schlechtere Hälfte.
|
||||
func plakatHolen(for item: MediaItem) {
|
||||
if let adresse = backend?.rawImageURL(path: item.thumb) {
|
||||
Task { await coordinator.fetchArtwork(for: item.id, from: adresse) }
|
||||
}
|
||||
|
||||
// **Und das Plakat der Serie, unter deren Schlüssel.**
|
||||
//
|
||||
// `thumb` ist bei einer Folge ihr Szenenbild — quer, und hochkant
|
||||
// beschnitten zeigt es einen zufälligen Ausschnitt. Die Downloads
|
||||
// stehen aber als Serie in der Reihe, und eine Serienkachel ist ein
|
||||
// Plakat. Ohne diesen zweiten Griff gäbe es für die Serie gar kein
|
||||
// Bild: heruntergeladen wird sie nie, nur ihre Folgen.
|
||||
if let serie = item.seriesID,
|
||||
let pfad = item.seriesThumb ?? item.parentThumb,
|
||||
let adresse = backend?.rawImageURL(path: pfad) {
|
||||
Task { await coordinator.fetchArtwork(for: serie, from: adresse) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Holt Plakate nach, die früheren Downloads fehlen.
|
||||
///
|
||||
/// **Weil bis vor Kurzem gar keines mitkam.** Der Dateiname dafür stand
|
||||
/// im Modell, gesetzt hat ihn nie jemand — alles, was vorher
|
||||
/// heruntergeladen wurde, hat kein Bild und stünde in der neuen Reihe
|
||||
/// als graue Fläche.
|
||||
///
|
||||
/// Einmal je Serverwechsel, nur für die Einträge ohne Bild; danach
|
||||
/// findet die Schleife nichts mehr.
|
||||
func plakateNachholen() async {
|
||||
guard let backend else { return }
|
||||
for eintrag in coordinator.items where fehltEinBild(eintrag) {
|
||||
guard let titel = try? await backend.item(id: eintrag.ratingKey) else { continue }
|
||||
plakatHolen(for: titel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob zu diesem Eintrag noch ein Bild fehlt — seines oder das seiner
|
||||
/// Serie.
|
||||
private func fehltEinBild(_ eintrag: DownloadItem) -> Bool {
|
||||
if coordinator.artworkURL(forRatingKey: eintrag.ratingKey) == nil { return true }
|
||||
if let serie = eintrag.seriesKey,
|
||||
coordinator.artworkURL(forRatingKey: serie) == nil { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
|
||||
@@ -294,6 +350,7 @@ struct DownloadService {
|
||||
_ = try coordinator.enqueue(
|
||||
try plan(for: detailed, quality: quality, audioStream: ton)
|
||||
)
|
||||
plakatHolen(for: detailed)
|
||||
accepted += 1
|
||||
} catch DownloadRefusal.noDownloadableFile {
|
||||
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
|
||||
|
||||
@@ -130,6 +130,16 @@ final class FlaroSession {
|
||||
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
||||
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
|
||||
|
||||
/// Das Profilbild des angemeldeten Kontos, als Adresse.
|
||||
///
|
||||
/// **Nur Flaro hat eines.** Jellyfin gibt kein Profilbild heraus; dort
|
||||
/// bleibt es beim Buchstaben, und das ist keine Lücke, sondern der Stand
|
||||
/// der Dinge.
|
||||
var accountAvatarURL: URL? {
|
||||
guard let schluessel = flaroAccount?.avatarImage else { return nil }
|
||||
return backend?.rawImageURL(path: schluessel)
|
||||
}
|
||||
|
||||
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
|
||||
/// erscheinen gar nicht erst.
|
||||
private(set) var libraries: [MediaLibrary] = []
|
||||
@@ -273,6 +283,9 @@ final class FlaroSession {
|
||||
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
|
||||
// verloren.
|
||||
Task { await LibraryCache.shared.clearAll() }
|
||||
// Dasselbe gilt für den Fortschritt, der noch auf den Server
|
||||
// wartet: er gehört dem Konto, das gerade gegangen ist.
|
||||
Fortschrittspost.shared.leeren()
|
||||
phase = .signedOut
|
||||
}
|
||||
|
||||
@@ -462,14 +475,45 @@ final class FlaroSession {
|
||||
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
|
||||
// Server das, und es ist kein Grund, jemanden abzumelden.
|
||||
//
|
||||
// **Trotzdem mit Server:** sonst stünde beim Erneut-Versuchen
|
||||
// kein Konto da, und das Heruntergeladene wäre unerreichbar.
|
||||
phase = .failed(message: ErrorText.describe(error))
|
||||
activeKind = .flaro
|
||||
// **Und auch kein Grund, vor der Tür stehen zu bleiben.** Vorher
|
||||
// endete es hier bei einem Fehlerbildschirm mit zwei Knöpfen:
|
||||
// „Erneut versuchen" und „Abmelden". Beide nützen im Flugzeug
|
||||
// nichts, und das Heruntergeladene lag unerreichbar dahinter.
|
||||
//
|
||||
// Der Token liegt im Schlüsselbund, das Konto in den
|
||||
// Voreinstellungen — beides reicht, um die App zu betreten.
|
||||
// Jede einzelne Anfrage scheitert dann weiterhin, und jeder
|
||||
// Bildschirm sagt das für sich.
|
||||
await adoptFlaro(client: FlaroClient(
|
||||
baseURL: baseURL, identity: flaroIdentity, token: token
|
||||
), account: gemerktesFlaroKonto(baseURL: baseURL))
|
||||
if case .failed = phase {
|
||||
// Nicht einmal eine Mediathekenliste gemerkt — dann ist die
|
||||
// App leer, und der Fehlerbildschirm ist die ehrlichere
|
||||
// Antwort.
|
||||
phase = .failed(message: ErrorText.describe(error))
|
||||
activeKind = .flaro
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Das Konto aus den Voreinstellungen, ohne den Server zu fragen.
|
||||
///
|
||||
/// Alles darin wurde beim letzten erfolgreichen Anmelden geschrieben.
|
||||
/// Es ist vielleicht veraltet — ein umbenannter Server, ein geänderter
|
||||
/// Anzeigename —, aber es ist richtiger als gar keins.
|
||||
private func gemerktesFlaroKonto(baseURL: URL) -> FlaroAccount {
|
||||
FlaroAccount(
|
||||
baseURL: baseURL,
|
||||
serverName: defaults.string(forKey: Key.flaroServerName)
|
||||
?? baseURL.host() ?? "Flaro",
|
||||
userID: defaults.string(forKey: Key.flaroUserID) ?? "",
|
||||
userName: defaults.string(forKey: Key.flaroUserName) ?? "",
|
||||
isAdministrator: defaults.bool(forKey: Key.flaroIsAdministrator)
|
||||
)
|
||||
}
|
||||
|
||||
private func rememberFlaro(account: FlaroAccount, token: String?) {
|
||||
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
|
||||
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
|
||||
|
||||
@@ -101,7 +101,10 @@ enum HeroArtwork {
|
||||
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
|
||||
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
|
||||
// das der Serie — und erst danach das Vorschaubild der Folge.
|
||||
: [item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
|
||||
// **Das Aufmacherbild zuerst.** Es ist das, was für genau diese
|
||||
// Fläche ausgesucht wurde; der Hintergrund ist die Vorgabe
|
||||
// dahinter.
|
||||
: [item.heroArt, item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
|
||||
|
||||
var gesehen = Set<String>()
|
||||
return pfade
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import FlaroDownloads
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Was auf dem Gerät liegt, als gewöhnliche Titel.
|
||||
///
|
||||
/// **Damit ein Download aussieht wie alles andere.** Bisher waren Downloads
|
||||
/// eine eigene Welt: eine eigene Liste, eine eigene Zeile, ein eigener
|
||||
/// Bereich. Wer etwas ansehen wollte, musste erst wissen, dass er dorthin
|
||||
/// wechseln muss. Als `MediaItem` fügt sich ein Download in jede Reihe und
|
||||
/// jedes Raster ein, das es ohnehin gibt — Kachel, Fortschrittsbalken,
|
||||
/// Kontextmenü inbegriffen.
|
||||
///
|
||||
/// **Und es ist die Grundlage fürs Offline-Arbeiten.** Ohne Server gibt es
|
||||
/// keine andere Quelle für einen Titel; alles, was dann noch dasteht, kommt
|
||||
/// von hier.
|
||||
@MainActor
|
||||
enum Heruntergeladenes {
|
||||
/// Die fertigen Downloads, neueste zuerst.
|
||||
///
|
||||
/// **Nur fertige.** Ein halb geladener Film ist nichts zum Ansehen; er
|
||||
/// steht im Downloads-Bereich mit seinem Balken, und dort gehört er hin.
|
||||
static func fertige(in coordinator: DownloadCoordinator) -> [DownloadItem] {
|
||||
coordinator.items
|
||||
.filter(\.isComplete)
|
||||
.sorted { ($0.completedAt ?? $0.addedAt) > ($1.completedAt ?? $1.addedAt) }
|
||||
}
|
||||
|
||||
/// Was auf dem Gerät liegt — Filme einzeln, Serien als **eine** Kachel.
|
||||
///
|
||||
/// **Zwanzig Folgen sind keine zwanzig Titel.** Vorher stand jede
|
||||
/// einzeln in der Reihe, und eine heruntergeladene Staffel schob alles
|
||||
/// andere aus dem Bild. Was man im Kopf hat, ist die Serie; welche
|
||||
/// Folgen davon da sind, ist die Frage danach — und die beantwortet die
|
||||
/// Seite der Serie.
|
||||
static func reihe(in coordinator: DownloadCoordinator) -> [MediaItem] {
|
||||
var ergebnis: [MediaItem] = []
|
||||
var gesehen = Set<String>()
|
||||
|
||||
for eintrag in fertige(in: coordinator) {
|
||||
guard let serie = eintrag.seriesKey else {
|
||||
ergebnis.append(titel(eintrag, in: coordinator))
|
||||
continue
|
||||
}
|
||||
// Die Reihenfolge der Folgen ist die der Serie: die zuletzt
|
||||
// fertige Folge bestimmt, wo die Serie steht.
|
||||
guard gesehen.insert(serie).inserted else { continue }
|
||||
ergebnis.append(self.serie(serie, in: coordinator))
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/// Eine Serie, von der Folgen auf dem Gerät liegen.
|
||||
static func serie(_ schluessel: String, in coordinator: DownloadCoordinator) -> MediaItem {
|
||||
let folgen = folgenEintraege(derSerie: schluessel, in: coordinator)
|
||||
return MediaItem(
|
||||
id: schluessel,
|
||||
kind: .show,
|
||||
title: folgen.first?.seriesTitle ?? folgen.first?.title ?? "",
|
||||
// **Erst das Plakat der Serie, dann irgendein Folgenbild.** Ein
|
||||
// Szenenbild hochkant ist ein zufälliger Ausschnitt — aber immer
|
||||
// noch besser als eine graue Fläche.
|
||||
thumb: coordinator.artworkURL(forRatingKey: schluessel)?.absoluteString
|
||||
?? folgen.lazy
|
||||
.compactMap { coordinator.artworkURL(forRatingKey: $0.ratingKey)?.absoluteString }
|
||||
.first
|
||||
)
|
||||
}
|
||||
|
||||
/// Wie viele Folgen dieser Serie hier liegen — als Zeile unter der
|
||||
/// Kachel.
|
||||
///
|
||||
/// `nil` für alles, was keine Serie mit Downloads ist: dann gilt, was
|
||||
/// sonst unter einer Kachel steht.
|
||||
static func untertitel(für titel: MediaItem, in coordinator: DownloadCoordinator) -> String? {
|
||||
guard titel.kind == .show else { return nil }
|
||||
let anzahl = folgenEintraege(derSerie: titel.id, in: coordinator).count
|
||||
guard anzahl > 0 else { return nil }
|
||||
return String(localized: "downloads.episodeCount \(anzahl)")
|
||||
}
|
||||
|
||||
/// Die heruntergeladenen Folgen einer Serie, aufsteigend sortiert.
|
||||
///
|
||||
/// Für die Seite der Serie, wenn kein Server antwortet: dann ist das
|
||||
/// hier die ganze Liste, die es gibt.
|
||||
static func folgen(derSerie schluessel: String, in coordinator: DownloadCoordinator) -> [MediaItem] {
|
||||
folgenEintraege(derSerie: schluessel, in: coordinator)
|
||||
.sorted {
|
||||
($0.seasonIndex ?? 0, $0.episodeIndex ?? 0) < ($1.seasonIndex ?? 0, $1.episodeIndex ?? 0)
|
||||
}
|
||||
.map { titel($0, in: coordinator) }
|
||||
}
|
||||
|
||||
private static func folgenEintraege(
|
||||
derSerie schluessel: String,
|
||||
in coordinator: DownloadCoordinator
|
||||
) -> [DownloadItem] {
|
||||
fertige(in: coordinator).filter { $0.seriesKey == schluessel }
|
||||
}
|
||||
|
||||
/// Ein Download als Titel.
|
||||
///
|
||||
/// **Das Bild kommt von der Platte, nicht vom Server.** `thumb` ist sonst
|
||||
/// ein Pfad, den erst ein Server zu einer Adresse macht; hier steht
|
||||
/// stattdessen die fertige lokale Adresse. Ansichten, die `rawImageURL`
|
||||
/// darauf anwenden, müssen das wissen — siehe `bildAdresse`.
|
||||
static func titel(_ eintrag: DownloadItem, in coordinator: DownloadCoordinator) -> MediaItem {
|
||||
MediaItem(
|
||||
id: eintrag.ratingKey,
|
||||
kind: eintrag.seriesKey == nil ? .movie : .episode,
|
||||
title: eintrag.title,
|
||||
duration: eintrag.durationMilliseconds,
|
||||
thumb: coordinator.artworkURL(forRatingKey: eintrag.ratingKey)?.absoluteString,
|
||||
index: eintrag.episodeIndex,
|
||||
parentIndex: eintrag.seasonIndex,
|
||||
parentTitle: eintrag.seriesTitle,
|
||||
seriesTitle: eintrag.seriesTitle,
|
||||
seriesID: eintrag.seriesKey,
|
||||
versions: [fassung(eintrag)]
|
||||
)
|
||||
}
|
||||
|
||||
/// Die Datei auf dem Gerät als Fassung.
|
||||
///
|
||||
/// **Ohne sie bleibt der Abspielknopf grau.** Die Detailseite schaltet
|
||||
/// ihn an `currentMedia?.files.first` fest, und das ist richtig: ein
|
||||
/// Titel ohne Datei lässt sich nicht abspielen. Offline *gibt* es die
|
||||
/// Datei aber — sie liegt nur woanders. Was der Server beim Herunterladen
|
||||
/// über sie gesagt hat, steht in `offlineMediaData`.
|
||||
private static func fassung(_ eintrag: DownloadItem) -> MediaVersion {
|
||||
let angaben = eintrag.offlineMediaData.flatMap {
|
||||
try? JSONDecoder().decode(OfflineMedia.self, from: $0)
|
||||
}
|
||||
return MediaVersion(
|
||||
id: eintrag.ratingKey,
|
||||
container: angaben?.container,
|
||||
videoCodec: angaben?.videoCodec,
|
||||
audioCodec: angaben?.audioCodec,
|
||||
videoResolution: angaben?.videoResolution,
|
||||
bitrate: angaben?.bitrate,
|
||||
duration: eintrag.durationMilliseconds,
|
||||
files: [MediaFile(
|
||||
id: eintrag.ratingKey,
|
||||
itemID: eintrag.ratingKey,
|
||||
container: angaben?.container,
|
||||
size: eintrag.totalBytes > 0 ? eintrag.totalBytes : nil,
|
||||
duration: eintrag.durationMilliseconds
|
||||
)]
|
||||
)
|
||||
}
|
||||
|
||||
/// Die Adresse eines Bildes, das schon eine ist.
|
||||
///
|
||||
/// **Ein Umweg, der nötig ist.** Die Kacheln bauen ihre Bildadresse über
|
||||
/// den Server (`rawImageURL`), weil `thumb` dort ein Pfad ist. Bei einem
|
||||
/// Download steht darin bereits eine `file:`-Adresse — durch denselben
|
||||
/// Bau geschickt käme Unsinn heraus.
|
||||
static func bildAdresse(_ titel: MediaItem) -> URL? {
|
||||
guard let roh = titel.thumb, roh.hasPrefix("file:") else { return nil }
|
||||
return URL(string: roh)
|
||||
}
|
||||
|
||||
/// Ob dieser Titel auf dem Gerät liegt.
|
||||
///
|
||||
/// **Eine Serie gilt als da, wenn Folgen von ihr da sind.** Die Serie
|
||||
/// selbst wird nie heruntergeladen — ihre Folgen schon, und wer die
|
||||
/// Kachel sieht, will wissen, ob er etwas davon ansehen kann.
|
||||
static func liegtBereit(_ titelID: String, in coordinator: DownloadCoordinator) -> Bool {
|
||||
coordinator.items.contains {
|
||||
$0.isComplete && ($0.ratingKey == titelID || $0.seriesKey == titelID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
import Observation
|
||||
|
||||
@@ -19,6 +20,14 @@ import Observation
|
||||
final class HomeFeed {
|
||||
private(set) var state: LoadState<[MediaRowGroup]> = .loading
|
||||
|
||||
/// Ob das Gezeigte vom letzten Mal stammt, weil der Server nicht
|
||||
/// antwortet.
|
||||
///
|
||||
/// Die Startseite ist dann **nicht** falsch, nur alt — und das muss
|
||||
/// dranstehen. Ein Stand von gestern, der sich als heute ausgibt, ist
|
||||
/// schlimmer als gar keiner.
|
||||
private(set) var ausDemSpeicher = false
|
||||
|
||||
/// Holt die Reihen vom Server.
|
||||
///
|
||||
/// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe
|
||||
@@ -29,6 +38,24 @@ final class HomeFeed {
|
||||
state = await LoadState.load(keepingPrevious: state) {
|
||||
try await backend.homeRows()
|
||||
}
|
||||
|
||||
switch state {
|
||||
case .loaded(let reihen):
|
||||
ausDemSpeicher = false
|
||||
await LibraryCache.shared.storeHomeRows(reihen, server: backend.identity)
|
||||
case .failed:
|
||||
// **Der Abzug erst hier, nicht vorweg.** Ihn sofort zu zeigen
|
||||
// hiesse, bei jedem Start eine alte Startseite aufzublitzen, die
|
||||
// eine Sekunde später ersetzt wird. Er ist ein Rettungsanker,
|
||||
// kein Beschleuniger.
|
||||
if let gemerkt = await LibraryCache.shared.homeRows(server: backend.identity),
|
||||
!gemerkt.isEmpty {
|
||||
state = .loaded(gemerkt)
|
||||
ausDemSpeicher = true
|
||||
}
|
||||
case .loading:
|
||||
break
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Das Regal über der App im Menü. Es wird gezeichnet, wenn die App
|
||||
// **nicht** läuft — also legen wir hier ab, was dort stehen soll.
|
||||
@@ -38,10 +65,33 @@ final class HomeFeed {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Die Kennung der Downloadreihe.
|
||||
///
|
||||
/// Fest und nicht vom Server: nur so lässt sie sich wie jede andere
|
||||
/// Reihe verschieben oder ausblenden.
|
||||
static let downloadsRow = "flaro.downloads"
|
||||
|
||||
/// Die Reihen in der eingestellten Form.
|
||||
func rows(for layout: HomeLayoutSettings) -> [MediaRowGroup] {
|
||||
guard case .loaded(let alle) = state else { return [] }
|
||||
return layout.apply(to: prepared(alle, layout: layout))
|
||||
///
|
||||
/// `downloaded` kommt von aussen, weil `HomeFeed` nichts von Downloads
|
||||
/// weiss und auch nichts davon wissen muss — es ist eine Reihe wie jede
|
||||
/// andere, nur mit einer anderen Quelle.
|
||||
/// **Auch ohne geladene Reihen**: scheitert der Server und liegt nichts
|
||||
/// im Speicher, bleibt immer noch, was auf dem Gerät liegt.
|
||||
func rows(for layout: HomeLayoutSettings, downloaded: [MediaItem] = []) -> [MediaRowGroup] {
|
||||
var reihen: [MediaRowGroup] = []
|
||||
if case .loaded(let alle) = state { reihen = prepared(alle, layout: layout) }
|
||||
if !downloaded.isEmpty {
|
||||
reihen.insert(
|
||||
MediaRowGroup(
|
||||
id: Self.downloadsRow,
|
||||
title: String(localized: "home.downloaded"),
|
||||
items: downloaded
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
}
|
||||
return layout.apply(to: reihen)
|
||||
}
|
||||
|
||||
// MARK: - Ordnen
|
||||
@@ -155,8 +205,8 @@ final class HomeFeed {
|
||||
case .continueFirst:
|
||||
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
|
||||
// Angefangenes bleibt vorn, wechselt aber untereinander.
|
||||
let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil })
|
||||
+ HeroSelection.ordered(unique.filter { $0.watchProgress == nil })
|
||||
let ordered = HeroSelection.ordered(unique.filter(\.isResumable))
|
||||
+ HeroSelection.ordered(unique.filter { !$0.isResumable })
|
||||
return Array(ordered.prefix(layout.heroCount))
|
||||
case .latestOnly:
|
||||
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
|
||||
|
||||
@@ -34,6 +34,7 @@ final class LanguagePreferences {
|
||||
static let avoidsAudioDescription = "flaro.languages.avoidsAudioDescription"
|
||||
static let perLibrary = "flaro.languages.library"
|
||||
static let perSeries = "flaro.languages.series"
|
||||
static let perSeriesSubtitles = "flaro.languages.seriesSubtitles"
|
||||
static let rememberedTracks = "flaro.languages.seriesTrack"
|
||||
}
|
||||
|
||||
@@ -98,6 +99,14 @@ final class LanguagePreferences {
|
||||
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
|
||||
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
|
||||
/// abzulegen hiesse dort, den Fehler festzuschreiben.
|
||||
/// Die Untertitelsprache je Titel — Serie oder Film.
|
||||
///
|
||||
/// **Ein Topf für beide.** Die Kennung eines Films kollidiert mit keiner
|
||||
/// Serie; zwei Wörterbücher für dieselbe Frage liefen auseinander.
|
||||
private var perSeriesSubtitles: [String: String] {
|
||||
didSet { defaults.set(perSeriesSubtitles, forKey: Key.perSeriesSubtitles) }
|
||||
}
|
||||
|
||||
private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] {
|
||||
didSet {
|
||||
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
|
||||
@@ -117,6 +126,8 @@ final class LanguagePreferences {
|
||||
(defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true
|
||||
self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:]
|
||||
self.perSeries = defaults.dictionary(forKey: Key.perSeries) as? [String: String] ?? [:]
|
||||
self.perSeriesSubtitles =
|
||||
defaults.dictionary(forKey: Key.perSeriesSubtitles) as? [String: String] ?? [:]
|
||||
self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap {
|
||||
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
|
||||
} ?? [:]
|
||||
@@ -136,6 +147,34 @@ final class LanguagePreferences {
|
||||
perSeries[id] = code
|
||||
}
|
||||
|
||||
/// Was „keine Untertitel" im Speicher heisst.
|
||||
///
|
||||
/// **Abgewählt ist eine Aussage, kein Fehlen einer.** Wer sie für diese
|
||||
/// Serie abgeschaltet hat, will sie auch in Folge 7 nicht; ein fehlender
|
||||
/// Eintrag hiesse dagegen „nie etwas gesagt", und dann darf eine
|
||||
/// erzwungene Spur durchaus erscheinen.
|
||||
static let noSubtitles = "keine"
|
||||
|
||||
func subtitleLanguage(forSeries id: String) -> String? { perSeriesSubtitles[id] }
|
||||
|
||||
func setSubtitleLanguage(_ code: String?, forSeries id: String) {
|
||||
perSeriesSubtitles[id] = code
|
||||
}
|
||||
|
||||
/// Welche Untertitelsprache für diesen Titel gilt — `nil` heisst: keine.
|
||||
///
|
||||
/// **Die Ebene schlägt das Allgemeine.** Dieselbe Rangfolge wie beim Ton,
|
||||
/// nur ohne Mediathek: eine Untertitelsprache je Mediathek hat noch
|
||||
/// niemand verlangt, und eine Einstellung, die niemand sucht, ist eine
|
||||
/// Stelle mehr, an der etwas auseinanderlaufen kann.
|
||||
func subtitleWish(titleID: String?) -> String? {
|
||||
if let eigen = titleID.flatMap({ perSeriesSubtitles[$0] }) {
|
||||
return eigen == Self.noSubtitles ? nil : eigen
|
||||
}
|
||||
|
||||
return subtitleLanguage.isEmpty ? nil : subtitleLanguage
|
||||
}
|
||||
|
||||
func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? {
|
||||
rememberedTracks[id]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ final class PlaybackSettings {
|
||||
static let cellularQuality = "flaro.playback.quality.cellular"
|
||||
static let autoSkipIntro = "flaro.playback.autoSkipIntro"
|
||||
static let autoSkipCredits = "flaro.playback.autoSkipCredits"
|
||||
static let skipExtraScenes = "flaro.playback.skipExtraScenes"
|
||||
static let autoPlayNext = "flaro.playback.autoPlayNext"
|
||||
static let locksLandscape = "flaro.playback.locksLandscape"
|
||||
static let rewindSeconds = "flaro.playback.rewindSeconds"
|
||||
@@ -58,6 +59,14 @@ final class PlaybackSettings {
|
||||
var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
|
||||
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
|
||||
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
|
||||
|
||||
/// Ob „Abspann überspringen" auch über Post-Credit-Szenen hinweggeht.
|
||||
///
|
||||
/// **Eine Gerätesache, keine Servereinstellung.** Ob jemand die Szene
|
||||
/// sehen will, entscheidet er am Gerät, auf dem er sitzt — wie bei
|
||||
/// `autoSkipIntro`. Vorgabe aus: wer den Knopf drückt, landet bei der
|
||||
/// Szene und kann sie wegdrücken, wenn er mag.
|
||||
var skipExtraScenes: Bool { didSet { defaults.set(skipExtraScenes, forKey: Key.skipExtraScenes) } }
|
||||
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
|
||||
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
|
||||
|
||||
@@ -117,6 +126,7 @@ final class PlaybackSettings {
|
||||
|
||||
self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
|
||||
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false
|
||||
self.skipExtraScenes = defaults.object(forKey: Key.skipExtraScenes) as? Bool ?? false
|
||||
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
|
||||
|
||||
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true
|
||||
|
||||
@@ -61,7 +61,7 @@ enum TopShelfWriter {
|
||||
width: posterWidth,
|
||||
height: posterHeight
|
||||
),
|
||||
progress: item.watchProgress
|
||||
progress: item.resumeProgress
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -44,7 +44,7 @@ struct ItemActionEntries: View {
|
||||
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
|
||||
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
|
||||
// Fehler bei sich.
|
||||
if item.watchProgress != nil {
|
||||
if item.isResumable {
|
||||
Button {
|
||||
run { try await $0.removeFromContinueWatching(item) }
|
||||
} label: {
|
||||
|
||||
@@ -17,14 +17,18 @@ import MediaKit
|
||||
/// und wäre für VoiceOver gar nicht mehr da.
|
||||
struct QuickPlayTile<Card: View>: View {
|
||||
let item: MediaItem
|
||||
/// Ob ein Tipp auf die Detailseite führt, statt abzuspielen.
|
||||
/// Ob ein Tipp sofort abspielt, statt auf die Detailseite zu führen.
|
||||
///
|
||||
/// **Für die Suche.** Dort weiß man gerade *nicht*, was man sehen will —
|
||||
/// sonst hätte man nicht gesucht. Ein Tipp, der sofort abspielt, ist auf
|
||||
/// der Startseite richtig (die Kachel stand schon da, man kennt sie) und
|
||||
/// in einer Trefferliste falsch: der erste Treffer ist eine Vermutung des
|
||||
/// Servers, keine Entscheidung des Nutzers.
|
||||
var opensDetails: Bool = false
|
||||
/// **Nur beim Weiterschauen.** Dort ist die Absicht eindeutig: der Titel
|
||||
/// steht in dieser Reihe, *weil* man ihn angefangen hat.
|
||||
///
|
||||
/// **Überall sonst führt der Tipp auf die Seite.** Vorher war es
|
||||
/// umgekehrt, mit dem Argument „wer eine Kachel antippt, weiss meistens
|
||||
/// schon, was er sehen will". Im Gebrauch stimmte das nicht: wer nur
|
||||
/// nachsehen will, wie viele Staffeln eine Serie hat, bekam den Film.
|
||||
/// Ein Umweg von einem Schritt ist ärgerlich; eine Wiedergabe, die man
|
||||
/// nicht wollte, ist schlimmer.
|
||||
var startsPlayback: Bool = false
|
||||
@ViewBuilder let card: () -> Card
|
||||
|
||||
@Environment(FlaroSession.self) private var session
|
||||
@@ -57,7 +61,11 @@ struct QuickPlayTile<Card: View>: View {
|
||||
/// Ob es überhaupt etwas zu starten gibt.
|
||||
///
|
||||
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
|
||||
private var isPlayable: Bool { item.kind != .collection }
|
||||
/// Ein geplanter Titel ebenso: der Server lehnt ihn bis zum Termin ab,
|
||||
/// und ein Tipp auf die Kachel startete trotzdem — gemessen am Simulator,
|
||||
/// der Spieler kam bis „Wird vorbereitet …". Dort führt der Tipp jetzt
|
||||
/// auf die Seite, wo der Termin steht.
|
||||
private var isPlayable: Bool { item.kind != .collection && !item.isUpcoming }
|
||||
|
||||
private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) }
|
||||
|
||||
@@ -65,8 +73,9 @@ struct QuickPlayTile<Card: View>: View {
|
||||
///
|
||||
/// Drei Gründe, derselbe Ausgang: am Mac ist der Klick seit jeher der Weg
|
||||
/// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine
|
||||
/// Sammlung hat nichts abzuspielen, und in der Suche ist es gewollt.
|
||||
private var tapOpensDetails: Bool { isMac || opensDetails || !isPlayable }
|
||||
/// Sammlung hat nichts abzuspielen, und überall ausser beim
|
||||
/// Weiterschauen ist die Seite das Ziel.
|
||||
private var tapOpensDetails: Bool { isMac || !startsPlayback || !isPlayable }
|
||||
|
||||
var body: some View {
|
||||
kachel
|
||||
@@ -80,6 +89,11 @@ struct QuickPlayTile<Card: View>: View {
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if isPlayable, isMac { playButton }
|
||||
}
|
||||
// **Links, weil rechts schon etwas sitzt** — am Mac der Abspielknopf
|
||||
// beim Überfahren, unten der Fortschrittsbalken.
|
||||
.overlay(alignment: .topLeading) {
|
||||
if Heruntergeladenes.liegtBereit(item.id, in: downloads) { downloadMarke }
|
||||
}
|
||||
.onContinuousHover { phase in
|
||||
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
|
||||
// der iOS-Bau und läuft trotzdem auf dem Mac.
|
||||
@@ -95,11 +109,9 @@ struct QuickPlayTile<Card: View>: View {
|
||||
// Kachel steht.
|
||||
.contextMenu {
|
||||
if isPlayable {
|
||||
// **Am Telefon und am iPad führt der Weg zur Detailseite
|
||||
// hierher.** Antippen spielt dort ab; wer stattdessen
|
||||
// nachsehen will, was das überhaupt ist, drückt lang. Am Mac
|
||||
// ist es umgekehrt: dort öffnet der Klick die Seite, und zum
|
||||
// Starten gibt es den Knopf beim Überfahren.
|
||||
// **Nur dort, wo der Tipp abspielt.** Sonst führt er
|
||||
// schon auf die Seite, und ein zweiter Weg dorthin im Menü
|
||||
// wäre ein Eintrag, der nichts Neues anbietet.
|
||||
if !tapOpensDetails {
|
||||
Button("hero.details", systemImage: "info.circle") { push(route) }
|
||||
Divider()
|
||||
@@ -195,14 +207,11 @@ struct QuickPlayTile<Card: View>: View {
|
||||
|
||||
/// Die Kachel selbst — und was ein Antippen bedeutet.
|
||||
///
|
||||
/// **Am Telefon und am iPad spielt es ab.** Wer eine Kachel antippt, weiss
|
||||
/// meistens schon, was er sehen will; der Umweg über die Detailseite
|
||||
/// kostete zwei Schritte für eine Absicht. Die Seite ist weiter da — über
|
||||
/// langes Drücken, wo auch „gesehen" und „entfernen" stehen.
|
||||
///
|
||||
/// **Am Mac bleibt der Klick die Detailseite.** Dort gibt es den
|
||||
/// Abspielknopf beim Überfahren, und langes Drücken ist keine übliche
|
||||
/// Geste — ein Rechtsklick ist der Weg ins Menü, nicht in den Film.
|
||||
/// **Der Tipp führt auf die Detailseite**, am Telefon wie am iPad wie am
|
||||
/// Mac. Nur in „Weiterschauen" spielt er ab: dort steht der Titel,
|
||||
/// *weil* man ihn angefangen hat. Abspielen ist sonst einen Schritt
|
||||
/// weiter — über den Knopf auf der Seite, über langes Drücken, oder am
|
||||
/// Mac über den Knopf beim Überfahren.
|
||||
///
|
||||
/// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg
|
||||
/// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im
|
||||
@@ -243,6 +252,20 @@ struct QuickPlayTile<Card: View>: View {
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
/// Das Zeichen für „liegt auf diesem Gerät".
|
||||
///
|
||||
/// **Damit man es sieht, ohne danach zu suchen.** Bisher stand ein
|
||||
/// Download nur im Downloads-Bereich; in der Mediathek sah eine
|
||||
/// heruntergeladene Folge aus wie jede andere, und wer wissen wollte,
|
||||
/// was im Flugzeug läuft, musste zwei Listen vergleichen.
|
||||
private var downloadMarke: some View {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white, Color.black.opacity(0.55))
|
||||
.padding(FlaroSpacing.snug)
|
||||
.accessibilityLabel(Text("downloads.available"))
|
||||
}
|
||||
|
||||
/// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist.
|
||||
///
|
||||
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import MediaKit
|
||||
|
||||
/// Steht dort, wo sonst „Abspielen" stünde.
|
||||
///
|
||||
/// **Ein Knopf, der nichts tut, ist schlimmer als keiner.** Ein geplanter
|
||||
/// Titel steht in der Mediathek, damit man weiss, dass er kommt — das ist der
|
||||
/// Sinn des Planens. Der Server lehnt ihn bis zum Termin ab; stünde der Knopf
|
||||
/// trotzdem da, wäre die Reihenfolge: draufdrücken, freuen, Absage lesen.
|
||||
///
|
||||
/// Ein Baustein für Aufmacher und Detailseite, weil sonst an der einen Stelle
|
||||
/// der Termin stünde und an der anderen weiter der Knopf.
|
||||
struct UpcomingNotice: View {
|
||||
@Environment(\.flaroAccent) private var accent
|
||||
|
||||
let date: Date
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
Image(systemName: "clock")
|
||||
.font(.flaroCardTitle)
|
||||
.foregroundStyle(accent)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("upcoming.title")
|
||||
.font(.flaroCardTitle)
|
||||
Text("upcoming.from \(Self.formatted(date))")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.loose)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
/// „Dienstag, 6. Oktober 2026 um 18:00" — in der Sprache des Geräts.
|
||||
static func formatted(_ date: Date) -> String {
|
||||
date.formatted(
|
||||
.dateTime.weekday(.wide).day().month(.wide).year().hour().minute())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import FlaroDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Die Detailseite eines Titels.
|
||||
@@ -11,6 +12,7 @@ struct MediaDetailView: View {
|
||||
let route: MediaRoute
|
||||
|
||||
@Environment(FlaroSession.self) private var session
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@State private var state: LoadState<MediaItem> = .loading
|
||||
|
||||
var body: some View {
|
||||
@@ -50,10 +52,44 @@ struct MediaDetailView: View {
|
||||
.task(id: route.ratingKey) { await load() }
|
||||
}
|
||||
|
||||
/// Holt den Titel — vom Server, und sonst vom Gerät.
|
||||
///
|
||||
/// **Sonst führte ein Download ins Leere.** Ausserhalb von
|
||||
/// „Fortsetzen" öffnet ein Tipp auf eine Kachel diese Seite; ohne
|
||||
/// Server endete sie bei einem Fehlerbild, und die Datei, die daneben
|
||||
/// auf der Platte lag, war von hier aus nicht mehr zu erreichen.
|
||||
///
|
||||
/// Der Server bleibt massgeblich: nur wenn er nicht antwortet, tritt
|
||||
/// das Wenige ein, was ein Download über sich selbst weiss.
|
||||
private func load() async {
|
||||
guard let server = session.backend else { return }
|
||||
guard let server = session.backend else {
|
||||
state = vomGeraet.map { .loaded($0) }
|
||||
?? .failed(message: String(localized: "error.serverUnreachable"))
|
||||
return
|
||||
}
|
||||
state = await LoadState.load {
|
||||
try await server.item(id: route.ratingKey)
|
||||
}
|
||||
if case .failed(let message) = state {
|
||||
state = vomGeraet.map { .loaded($0) } ?? .failed(message: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Was über diesen Titel auf dem Gerät liegt.
|
||||
///
|
||||
/// **Auch eine Serie**, obwohl keine Serie je heruntergeladen wird: in
|
||||
/// der Downloadreihe steht sie als eine Kachel, und ein Tipp darauf
|
||||
/// führt hierher. Ohne diesen Zweig endete er ohne Verbindung im
|
||||
/// Nichts.
|
||||
private var vomGeraet: MediaItem? {
|
||||
if let eintrag = downloads.items.first(
|
||||
where: { $0.isComplete && $0.ratingKey == route.ratingKey }
|
||||
) {
|
||||
return Heruntergeladenes.titel(eintrag, in: downloads)
|
||||
}
|
||||
guard downloads.items.contains(
|
||||
where: { $0.isComplete && $0.seriesKey == route.ratingKey }
|
||||
) else { return nil }
|
||||
return Heruntergeladenes.serie(route.ratingKey, in: downloads)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import MediaKit
|
||||
import FlaroPlayback
|
||||
|
||||
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
||||
/// einzelne Folge.
|
||||
@@ -67,6 +68,41 @@ struct PlayableDetailView: View {
|
||||
return item.versions[selectedVersion]
|
||||
}
|
||||
|
||||
/// Welche Sprachen in dieser Datei liegen.
|
||||
///
|
||||
/// **Kein Serverweg nötig.** Bei einem Film ist die Datei der Titel; ihre
|
||||
/// Spuren stehen in der Antwort, die diese Seite ohnehin geholt hat. Nur
|
||||
/// bei einer Serie muss gerechnet werden, und dafür gibt es den
|
||||
/// Endpunkt.
|
||||
private var sprachen: AvailableLanguages? {
|
||||
guard let part = currentMedia?.files.first else { return nil }
|
||||
|
||||
return AvailableLanguages(
|
||||
audio: optionen(part.audioStreams),
|
||||
subtitles: optionen(part.subtitleStreams),
|
||||
partial: 0)
|
||||
}
|
||||
|
||||
/// Aus Spuren werden Sprachen — jede einmal, auch bei zwei Tonspuren.
|
||||
private func optionen(_ spuren: [MediaStream]) -> [LanguageOption] {
|
||||
var gesehen = Set<String>()
|
||||
var liste: [LanguageOption] = []
|
||||
|
||||
for spur in spuren {
|
||||
guard let roh = spur.languageCode ?? spur.language,
|
||||
let code = LanguageCode.normalized(roh),
|
||||
gesehen.insert(code).inserted
|
||||
else { continue }
|
||||
|
||||
let name = Locale.current.localizedString(forLanguageCode: code)?.capitalized
|
||||
?? spur.language
|
||||
?? code.uppercased()
|
||||
liste.append(LanguageOption(code: code, name: name))
|
||||
}
|
||||
|
||||
return liste
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HeroBanner(
|
||||
title: item.rowTitle,
|
||||
@@ -97,35 +133,76 @@ struct PlayableDetailView: View {
|
||||
// Karussell auf dem Telefon quer ist.
|
||||
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
|
||||
) {
|
||||
Button {
|
||||
playback.play(item, mediaIndex: selectedVersion)
|
||||
} label: {
|
||||
Label(
|
||||
item.watchProgress == nil ? "hero.play" : "hero.resume",
|
||||
systemImage: "play.fill"
|
||||
)
|
||||
.font(.flaroCardTitle)
|
||||
.padding(.horizontal, FlaroSpacing.loose)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
.disabled(currentMedia?.files.first == nil)
|
||||
|
||||
if let progress = item.watchProgress, progress > 0 {
|
||||
// Geplant heisst sichtbar, nicht abspielbar. Weder Abspielen
|
||||
// noch Herunterladen — der Server lehnt beides bis zum Termin ab.
|
||||
if item.isUpcoming, let ab = item.availableFrom {
|
||||
UpcomingNotice(date: ab)
|
||||
} else {
|
||||
Button {
|
||||
playback.play(item, mediaIndex: selectedVersion, resuming: false)
|
||||
playback.play(item, mediaIndex: selectedVersion)
|
||||
} label: {
|
||||
Label("detail.playFromStart", systemImage: "gobackward")
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.flaroCardTitle)
|
||||
.padding(FlaroSpacing.snug)
|
||||
Label(
|
||||
item.isResumable ? "hero.resume" : "hero.play",
|
||||
systemImage: "play.fill"
|
||||
)
|
||||
.font(.flaroCardTitle)
|
||||
.padding(.horizontal, FlaroSpacing.loose)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.flaroTextSecondary)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
.disabled(currentMedia?.files.first == nil)
|
||||
|
||||
DownloadButton(item: item, mediaIndex: selectedVersion)
|
||||
// „Von vorn" nur, wenn es ein „weiter" gibt — dieselbe
|
||||
// Entscheidung wie beim Knopf daneben.
|
||||
if item.isResumable {
|
||||
Button {
|
||||
playback.play(item, mediaIndex: selectedVersion, resuming: false)
|
||||
} label: {
|
||||
Label("detail.playFromStart", systemImage: "gobackward")
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.flaroCardTitle)
|
||||
.padding(FlaroSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.flaroTextSecondary)
|
||||
}
|
||||
|
||||
DownloadButton(item: item, mediaIndex: selectedVersion)
|
||||
|
||||
// **Die Sprache vor dem Start, nicht erst mitten im Film.**
|
||||
//
|
||||
// Im Spieler umzustellen heisst, den Strom neu aufzubauen —
|
||||
// und bei einem Film, den man immer im Original sehen will,
|
||||
// jedes Mal von Neuem. Welche Spuren es gibt, steht hier
|
||||
// ohnehin schon, also gehört die Wahl neben den Knopf, der
|
||||
// sie anwendet.
|
||||
//
|
||||
// **Nur bei einem Film.** Bei einer Folge gilt, was für die
|
||||
// Serie eingestellt ist; dieselbe Frage zwanzigmal zu
|
||||
// stellen wäre keine Wahl, sondern eine Prüfung der Geduld.
|
||||
if item.kind != .episode, let angebot = sprachen {
|
||||
if !angebot.audio.isEmpty {
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.audioShort",
|
||||
kind: .audio,
|
||||
form: .knopf,
|
||||
available: angebot.audio
|
||||
)
|
||||
}
|
||||
|
||||
if !angebot.subtitles.isEmpty {
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.subtitlesShort",
|
||||
kind: .subtitles,
|
||||
form: .knopf,
|
||||
available: angebot.subtitles
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,17 @@ struct SeriesDetailView: View {
|
||||
@State private var isWorking = false
|
||||
@State private var summary: String?
|
||||
|
||||
/// Welche Staffel die Liste zeigt — `nil` heisst: die erste.
|
||||
///
|
||||
/// **Weil der Server die Folgen am Stück liefert.** Eine Serie mit zwei
|
||||
/// Staffeln ergab eine Liste, in der nach Folge 8 wieder eine Folge 1
|
||||
/// kam, ohne dass irgendwo stünde, dass dazwischen eine Staffel endet.
|
||||
@State private var gewaehlteStaffel: Int?
|
||||
|
||||
/// Welche Sprachen diese Serie durchgehend führt — `nil`, solange es
|
||||
/// niemand weiss.
|
||||
@State private var sprachen: AvailableLanguages?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||
@@ -54,11 +65,35 @@ struct SeriesDetailView: View {
|
||||
// dieselbe Einstellung zu haben, von denen einer nie gelesen
|
||||
// wird.
|
||||
if item.kind == .show {
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.forThisSeries"
|
||||
)
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.forThisSeries",
|
||||
kind: .audio,
|
||||
available: sprachen?.audio
|
||||
)
|
||||
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.subtitlesForThisSeries",
|
||||
kind: .subtitles,
|
||||
available: sprachen?.subtitles
|
||||
)
|
||||
|
||||
// **Was fehlt, wird gesagt.** Eine gekürzte Liste
|
||||
// sieht sonst aus wie die ganze Wahrheit, und wer
|
||||
// eine Sprache vermisst, die er in Staffel 1 gesehen
|
||||
// hat, hält es für einen Fehler.
|
||||
if let partial = sprachen?.partial, partial > 0 {
|
||||
Text("languages.partial \(partial)")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
.task(id: item.id) {
|
||||
sprachen = await session.backend?.availableLanguages(inSeries: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
childList
|
||||
@@ -172,23 +207,33 @@ struct SeriesDetailView: View {
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
|
||||
case .loaded(let entries):
|
||||
let nummern = staffelnummern(in: entries)
|
||||
let sichtbar = sichtbareFolgen(in: entries, staffeln: nummern)
|
||||
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
|
||||
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
|
||||
// als von allem anderen auf dieser Seite.
|
||||
HStack {
|
||||
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
||||
.font(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
if nummern.count > 1 {
|
||||
staffelwahl(nummern)
|
||||
} else {
|
||||
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
||||
.font(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
}
|
||||
Spacer()
|
||||
if entries.contains(where: { $0.kind == .episode }) {
|
||||
selectionControls(for: entries)
|
||||
if sichtbar.contains(where: { $0.kind == .episode }) {
|
||||
// **Nur die gezeigte Staffel.** „Alle auswählen" soll
|
||||
// das meinen, was dasteht — sonst lädt ein Griff
|
||||
// sechzehn Folgen herunter statt acht.
|
||||
selectionControls(for: sichtbar)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(entries) { entry in
|
||||
ForEach(sichtbar) { entry in
|
||||
row(for: entry)
|
||||
Divider().overlay(Color.flaroSeparator)
|
||||
}
|
||||
@@ -197,6 +242,55 @@ struct SeriesDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Welche Staffeln in dieser Liste vorkommen.
|
||||
///
|
||||
/// Leer, wenn die Einträge selbst Staffeln sind — dann gibt es nichts zu
|
||||
/// wählen, die Liste **ist** die Wahl.
|
||||
private func staffelnummern(in entries: [MediaItem]) -> [Int] {
|
||||
Set(entries.compactMap { $0.kind == .episode ? $0.parentIndex : nil }).sorted()
|
||||
}
|
||||
|
||||
private func sichtbareFolgen(in entries: [MediaItem], staffeln: [Int]) -> [MediaItem] {
|
||||
guard staffeln.count > 1 else { return entries }
|
||||
let gewaehlt = gewaehlteStaffel ?? staffeln[0]
|
||||
return entries.filter { $0.kind != .episode || $0.parentIndex == gewaehlt }
|
||||
}
|
||||
|
||||
/// Die Staffel als Menü — an der Stelle, an der sonst „Folgen" steht.
|
||||
///
|
||||
/// **Kein `Picker`.** Diese Seite ist eine `ScrollView`, kein `Form`;
|
||||
/// dort hat ein `Picker` kein Aussehen, auf das man sich verlassen kann.
|
||||
/// Dieselbe Begründung wie bei `AudioLanguagePicker`.
|
||||
private func staffelwahl(_ nummern: [Int]) -> some View {
|
||||
let gewaehlt = gewaehlteStaffel ?? nummern[0]
|
||||
|
||||
return Menu {
|
||||
ForEach(nummern, id: \.self) { nummer in
|
||||
Button {
|
||||
gewaehlteStaffel = nummer
|
||||
// Eine Auswahl aus der vorigen Staffel meint hier nichts
|
||||
// mehr — stehen zu lassen, was man nicht mehr sieht,
|
||||
// lädt unsichtbare Folgen herunter.
|
||||
selection.removeAll()
|
||||
} label: {
|
||||
Label(
|
||||
String(localized: "season.short \(nummer)"),
|
||||
systemImage: nummer == gewaehlt ? "checkmark" : ""
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: FlaroSpacing.tight) {
|
||||
Text("season.short \(gewaehlt)")
|
||||
.font(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen.
|
||||
@ViewBuilder
|
||||
private func row(for entry: MediaItem) -> some View {
|
||||
@@ -376,11 +470,31 @@ struct SeriesDetailView: View {
|
||||
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// Holt die Staffeln oder Folgen — und sonst, was auf dem Gerät liegt.
|
||||
///
|
||||
/// **Sonst wäre die Seite einer heruntergeladenen Serie leer.** Sie ist
|
||||
/// das Ziel, auf das die Downloadreihe zeigt; dort anzukommen und einen
|
||||
/// Fehler zu lesen, während die Folgen daneben auf der Platte liegen,
|
||||
/// wäre die Umkehrung dessen, wofür man sie geladen hat.
|
||||
private func loadChildren() async {
|
||||
guard let server = session.backend else { return }
|
||||
guard let server = session.backend else {
|
||||
children = vomGeraet.isEmpty
|
||||
? .failed(message: String(localized: "error.serverUnreachable"))
|
||||
: .loaded(vomGeraet)
|
||||
return
|
||||
}
|
||||
children = await LoadState.load {
|
||||
try await server.children(of: item.id)
|
||||
}
|
||||
if case .failed = children, !vomGeraet.isEmpty {
|
||||
children = .loaded(vomGeraet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Folgen dieser Serie, die auf dem Gerät liegen.
|
||||
private var vomGeraet: [MediaItem] {
|
||||
guard item.kind == .show else { return [] }
|
||||
return Heruntergeladenes.folgen(derSerie: item.id, in: downloads)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,7 +517,7 @@ private struct ChildRow: View {
|
||||
.frame(width: item.kind == .season ? 48 : 96)
|
||||
.clipShape(.rect(cornerRadius: 4))
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress = item.watchProgress {
|
||||
if let progress = item.resumeProgress {
|
||||
WatchProgressBar(fraction: progress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,21 +47,28 @@ struct HeroSection: View {
|
||||
// Bildschirm leer.
|
||||
fillsAvailableSpace: sizeClass != .compact
|
||||
) {
|
||||
Button {
|
||||
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher
|
||||
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert
|
||||
// neue Folgen als ihre Serie —, schickte der direkte Weg
|
||||
// deren eigene Kennung an den Player, und der scheiterte mit
|
||||
// „keine abspielbare Datei". Eine Serie hat keine Fassungen.
|
||||
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
||||
} label: {
|
||||
Label(playLabel, systemImage: "play.fill")
|
||||
.font(.flaroCardTitle)
|
||||
.padding(.horizontal, FlaroSpacing.loose)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
// Geplant heisst sichtbar, nicht abspielbar — statt des Knopfes
|
||||
// der Termin.
|
||||
if item.isUpcoming, let ab = item.availableFrom {
|
||||
UpcomingNotice(date: ab)
|
||||
} else {
|
||||
Button {
|
||||
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
||||
// Aufmacher eine Serie — und Jellyfins „Zuletzt
|
||||
// hinzugefügt" liefert neue Folgen als ihre Serie —,
|
||||
// schickte der direkte Weg deren eigene Kennung an den
|
||||
// Player, und der scheiterte mit „keine abspielbare
|
||||
// Datei". Eine Serie hat keine Fassungen.
|
||||
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
||||
} label: {
|
||||
Label(playLabel, systemImage: "play.fill")
|
||||
.font(.flaroCardTitle)
|
||||
.padding(.horizontal, FlaroSpacing.loose)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
|
||||
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
||||
Label("hero.details", systemImage: "info.circle")
|
||||
@@ -77,7 +84,7 @@ struct HeroSection: View {
|
||||
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
||||
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
||||
private var playLabel: LocalizedStringKey {
|
||||
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
||||
item.isResumable ? "hero.resume" : "hero.play"
|
||||
}
|
||||
|
||||
private var metadata: String? { Self.metadata(for: item) }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import FlaroDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
||||
@@ -11,15 +12,26 @@ import MediaKit
|
||||
struct HomeView: View {
|
||||
@Environment(FlaroSession.self) private var session
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(Fortschrittspost.self) private var post
|
||||
@State private var feed = HomeFeed()
|
||||
|
||||
/// Was auf dem Gerät liegt, als Reihe ganz oben.
|
||||
///
|
||||
/// Wird bei jeder Änderung an den Downloads neu berechnet, weil
|
||||
/// `DownloadCoordinator` beobachtet wird — eine fertige Folge steht
|
||||
/// ohne Neuladen der Startseite da.
|
||||
private var heruntergeladen: [MediaItem] {
|
||||
Heruntergeladenes.reihe(in: downloads)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch feed.state {
|
||||
case .loading:
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .failed(let message):
|
||||
case .failed(let message) where heruntergeladen.isEmpty:
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
@@ -28,8 +40,12 @@ struct HomeView: View {
|
||||
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
||||
}
|
||||
|
||||
case .loaded:
|
||||
let hubs = feed.rows(for: layout)
|
||||
// **Mit Downloads ist „kein Server" kein Fehlerbildschirm.**
|
||||
// Was auf dem Gerät liegt, lässt sich auch ohne ihn ansehen —
|
||||
// genau dafür wurde es heruntergeladen. Ein Fehlerbild davor zu
|
||||
// stellen hiesse, dem Nutzer etwas wegzunehmen, das er hat.
|
||||
case .failed, .loaded:
|
||||
let hubs = feed.rows(for: layout, downloaded: heruntergeladen)
|
||||
if hubs.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("home.empty.title", systemImage: "film.stack")
|
||||
@@ -59,7 +75,18 @@ struct HomeView: View {
|
||||
// daneben, und genau diese Kante sah man. Auf den geschobenen
|
||||
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.task(id: session.serverName) { await feed.load(from: session.backend) }
|
||||
.task(id: session.serverName) {
|
||||
await feed.load(from: session.backend)
|
||||
// Siehe `DownloadService.plakateNachholen`: ältere Downloads
|
||||
// haben kein Bild, und hier ist die Stelle, an der man es merken
|
||||
// würde.
|
||||
await DownloadService(backend: session.backend, coordinator: downloads)
|
||||
.plakateNachholen()
|
||||
// **Der Server hat geantwortet — dann nimmt er auch Post an.**
|
||||
// Der Start der App ist dafür der falsche Zeitpunkt: da ist oft
|
||||
// noch gar keine Verbindung.
|
||||
if !feed.ausDemSpeicher { await post.zustellen(über: session.backend) }
|
||||
}
|
||||
.refreshable { await feed.load(from: session.backend) }
|
||||
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
|
||||
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
|
||||
@@ -69,13 +96,22 @@ struct HomeView: View {
|
||||
private func content(hubs: [MediaRowGroup]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||
let featured = feed.heroItems(for: layout)
|
||||
if !featured.isEmpty {
|
||||
HeroCarousel(items: featured)
|
||||
if ohneServer {
|
||||
offlinehinweis
|
||||
} else {
|
||||
let featured = feed.heroItems(for: layout)
|
||||
if !featured.isEmpty {
|
||||
HeroCarousel(items: featured)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(hubs) { hub in
|
||||
MediaRow(row: hub)
|
||||
// **Ausgegraut, aber nicht gesperrt.** Was hier steht,
|
||||
// stammt vom letzten Mal; antippen darf man es
|
||||
// trotzdem — dann kommt ein Satz, der sagt warum, und
|
||||
// das ist ehrlicher als ein Knopf, der nichts tut.
|
||||
.opacity(ohneServer && hub.id != HomeFeed.downloadsRow ? 0.4 : 1)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, FlaroSpacing.section)
|
||||
@@ -83,4 +119,36 @@ struct HomeView: View {
|
||||
.scrollIndicators(.never)
|
||||
.flaroRevealsTopChrome()
|
||||
}
|
||||
|
||||
/// Ob gerade kein Server antwortet.
|
||||
///
|
||||
/// Zwei Wege führen hierher: gar nichts geladen, oder ein Abzug vom
|
||||
/// letzten Mal. Für den Nutzer ist es dieselbe Lage.
|
||||
private var ohneServer: Bool {
|
||||
if case .failed = feed.state { return true }
|
||||
return feed.ausDemSpeicher
|
||||
}
|
||||
|
||||
/// Statt des Aufmachers, wenn der Server fehlt.
|
||||
///
|
||||
/// **An seiner Stelle, nicht darüber.** Ein grosses Bild vom letzten Mal
|
||||
/// über einer Startseite, die nicht stimmt, wäre die falsche Betonung —
|
||||
/// und die Downloads sollen oben stehen.
|
||||
private var offlinehinweis: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: FlaroSpacing.regular) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
Text("home.offline")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(FlaroSpacing.regular)
|
||||
.background(Color.flaroElevated, in: .rect(cornerRadius: 12))
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import FlaroDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Eine waagerechte Reihe von Titeln.
|
||||
struct MediaRow: View {
|
||||
let row: MediaRowGroup
|
||||
/// Wird durchgereicht — siehe `QuickPlayTile`.
|
||||
var opensDetails: Bool = false
|
||||
/// Ob ein Tipp sofort abspielt — siehe `QuickPlayTile`.
|
||||
///
|
||||
/// **Die Reihe weiss es, nicht die Kachel.** „Weiterschauen" ist eine
|
||||
/// Eigenschaft der Reihe; eine Kachel kennt nur ihren Titel.
|
||||
private var startsPlayback: Bool { row.isContinueWatching }
|
||||
|
||||
@Environment(FlaroSession.self) private var session
|
||||
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
|
||||
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
|
||||
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
|
||||
@@ -32,7 +37,7 @@ struct MediaRow: View {
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: FlaroSpacing.regular) {
|
||||
ForEach(row.items) { item in
|
||||
QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) }
|
||||
QuickPlayTile(item: item, startsPlayback: startsPlayback) { card(for: item) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
@@ -55,27 +60,41 @@ struct MediaRow: View {
|
||||
if row.isContinueWatching {
|
||||
LandscapeCard(
|
||||
title: item.rowTitle,
|
||||
subtitle: item.cardSubtitle,
|
||||
artworkURL: session.backend?.artworkURL(
|
||||
subtitle: untertitel(for: item),
|
||||
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
|
||||
path: item.rowArtworkPath,
|
||||
width: 440,
|
||||
height: 248
|
||||
),
|
||||
progress: item.watchProgress
|
||||
progress: item.resumeProgress
|
||||
)
|
||||
.frame(width: landscapeWidth)
|
||||
} else {
|
||||
PosterCard(
|
||||
title: item.rowTitle,
|
||||
subtitle: item.cardSubtitle,
|
||||
artworkURL: session.backend?.artworkURL(
|
||||
subtitle: untertitel(for: item),
|
||||
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
|
||||
path: item.posterArtworkPath,
|
||||
width: 300,
|
||||
height: 450
|
||||
),
|
||||
progress: item.watchProgress
|
||||
progress: item.resumeProgress
|
||||
)
|
||||
.frame(width: posterWidth)
|
||||
}
|
||||
}
|
||||
|
||||
/// Was unter der Kachel steht.
|
||||
///
|
||||
/// **In der Downloadreihe zählt, wie viel da ist.** Eine Serie steht
|
||||
/// dort als eine Kachel; „2019" darunter beantwortet nicht die Frage,
|
||||
/// die man an dieser Stelle hat — nämlich wie viele Folgen man dabei
|
||||
/// hat.
|
||||
private func untertitel(for item: MediaItem) -> String? {
|
||||
if row.id == HomeFeed.downloadsRow,
|
||||
let zahl = Heruntergeladenes.untertitel(für: item, in: downloads) {
|
||||
return zahl
|
||||
}
|
||||
return item.cardSubtitle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import MediaKit
|
||||
/// Filter und Sortierung einer Mediathek.
|
||||
struct LibraryFilterSheet: View {
|
||||
@Binding var query: MediaQuery
|
||||
/// Steht getrennt von `query`, weil der Server ihn nicht auswerten kann —
|
||||
/// siehe `LibraryView.nurHeruntergeladene`.
|
||||
@Binding var nurHeruntergeladene: Bool
|
||||
/// Die Mediathek selbst, nicht nur ihre Art.
|
||||
///
|
||||
/// Vorher stand hier die Art, und die Genres wurden über *irgendeine*
|
||||
@@ -23,11 +26,18 @@ struct LibraryFilterSheet: View {
|
||||
/// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus —
|
||||
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
|
||||
@State private var draft: MediaQuery
|
||||
@State private var entwurfHeruntergeladene: Bool
|
||||
|
||||
init(query: Binding<MediaQuery>, library: MediaLibrary) {
|
||||
init(
|
||||
query: Binding<MediaQuery>,
|
||||
nurHeruntergeladene: Binding<Bool>,
|
||||
library: MediaLibrary
|
||||
) {
|
||||
self._query = query
|
||||
self._nurHeruntergeladene = nurHeruntergeladene
|
||||
self.library = library
|
||||
self._draft = State(initialValue: query.wrappedValue)
|
||||
self._entwurfHeruntergeladene = State(initialValue: nurHeruntergeladene.wrappedValue)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -45,6 +55,7 @@ struct LibraryFilterSheet: View {
|
||||
|
||||
Section("filter.filters") {
|
||||
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
|
||||
Toggle("filter.downloadedOnly", isOn: $entwurfHeruntergeladene)
|
||||
|
||||
Picker("filter.genre", selection: $draft.genre) {
|
||||
Text("filter.any").tag(String?.none)
|
||||
@@ -80,8 +91,9 @@ struct LibraryFilterSheet: View {
|
||||
Section {
|
||||
Button("filter.reset", role: .destructive) {
|
||||
draft = MediaQuery(sort: draft.sort)
|
||||
entwurfHeruntergeladene = false
|
||||
}
|
||||
.disabled(!draft.isFiltering)
|
||||
.disabled(!draft.isFiltering && !entwurfHeruntergeladene)
|
||||
}
|
||||
}
|
||||
// Ausdrücklich, nicht nur geerbt.
|
||||
@@ -102,6 +114,7 @@ struct LibraryFilterSheet: View {
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.apply") {
|
||||
nurHeruntergeladene = entwurfHeruntergeladene
|
||||
query = draft
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import FlaroDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Eine Mediathek zum Durchblättern.
|
||||
@@ -10,6 +11,7 @@ struct LibraryView: View {
|
||||
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
|
||||
/// zwischen Raster und Liste.
|
||||
@Environment(HomeLayoutSettings.self) private var sizing
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@AppStorage("flaro.library.layout") private var layout: LibraryLayout = .grid
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
@@ -25,6 +27,14 @@ struct LibraryView: View {
|
||||
@State private var loadMoreFailed = false
|
||||
@State private var isLoadingMore = false
|
||||
@State private var showsFilters = false
|
||||
/// Ob nur gezeigt wird, was auf diesem Gerät liegt.
|
||||
///
|
||||
/// **Kein Teil von `MediaQuery`**, und das ist der Kern der Sache: die
|
||||
/// Abfrage wertet der Server aus, und der weiss nicht, was auf diesem
|
||||
/// Gerät liegt. Steht der Filter an, wird gar nicht erst gefragt — die
|
||||
/// Liste kommt aus den Downloads. Das ist genau, vollständig, und
|
||||
/// funktioniert ohne Verbindung.
|
||||
@State private var nurHeruntergeladene = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -48,8 +58,9 @@ struct LibraryView: View {
|
||||
} description: {
|
||||
Text("library.empty.description")
|
||||
} actions: {
|
||||
if query.isFiltering {
|
||||
if query.isFiltering || nurHeruntergeladene {
|
||||
Button("library.clearFilters") {
|
||||
nurHeruntergeladene = false
|
||||
query = MediaQuery(sort: query.sort)
|
||||
}
|
||||
}
|
||||
@@ -64,7 +75,11 @@ struct LibraryView: View {
|
||||
.flaroInlineTitle()
|
||||
.toolbar { toolbar }
|
||||
.sheet(isPresented: $showsFilters) {
|
||||
LibraryFilterSheet(query: $query, library: library)
|
||||
LibraryFilterSheet(
|
||||
query: $query,
|
||||
nurHeruntergeladene: $nurHeruntergeladene,
|
||||
library: library
|
||||
)
|
||||
}
|
||||
.task(id: library.id) {
|
||||
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
|
||||
@@ -78,6 +93,11 @@ struct LibraryView: View {
|
||||
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
|
||||
// verschiedenen Abfragen vermischen.
|
||||
.onChange(of: query) { _, _ in Task { await reload() } }
|
||||
.onChange(of: nurHeruntergeladene) { _, _ in
|
||||
items = []
|
||||
page = nil
|
||||
Task { await reload() }
|
||||
}
|
||||
.refreshable { await reload() }
|
||||
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
|
||||
.environment(\.flaroItemChanged, { await reload() })
|
||||
@@ -104,9 +124,13 @@ struct LibraryView: View {
|
||||
QuickPlayTile(item: item) {
|
||||
PosterCard(
|
||||
title: item.title,
|
||||
subtitle: item.cardSubtitle,
|
||||
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
||||
progress: item.watchProgress
|
||||
subtitle: nurHeruntergeladene
|
||||
? Heruntergeladenes.untertitel(für: item, in: downloads)
|
||||
?? item.cardSubtitle
|
||||
: item.cardSubtitle,
|
||||
artworkURL: Heruntergeladenes.bildAdresse(item)
|
||||
?? session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
||||
progress: item.resumeProgress
|
||||
)
|
||||
}
|
||||
.task { await loadMoreIfNeeded(reaching: item) }
|
||||
@@ -232,7 +256,7 @@ struct LibraryView: View {
|
||||
Button {
|
||||
showsFilters = true
|
||||
} label: {
|
||||
Image(systemName: query.isFiltering
|
||||
Image(systemName: query.isFiltering || nurHeruntergeladene
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
@@ -276,6 +300,16 @@ struct LibraryView: View {
|
||||
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
|
||||
/// Fehlerbildschirm.
|
||||
private func reload() async {
|
||||
// **Vor dem Server, nicht danach.** Was auf dem Gerät liegt, weiss
|
||||
// nur das Gerät — und es weiss es auch im Flugmodus.
|
||||
if nurHeruntergeladene {
|
||||
items = geraeteliste
|
||||
page = MediaPage(items: items, totalCount: items.count)
|
||||
loadMoreFailed = false
|
||||
state = .loaded(())
|
||||
return
|
||||
}
|
||||
|
||||
guard let server = session.backend else { return }
|
||||
let schlüssel = query.cacheKey
|
||||
let kennung = server.identity
|
||||
@@ -319,6 +353,25 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Downloads, die in diese Mediathek gehören.
|
||||
///
|
||||
/// **Zugeordnet über die Art der Mediathek**, weil ein Download seine
|
||||
/// Mediathek nicht kennt: er führt einen Serienschlüssel oder keinen,
|
||||
/// und mehr braucht es nicht. Eine Folge gehört in eine Serien-,
|
||||
/// ein Film in eine Filmmediathek.
|
||||
///
|
||||
/// shortcut: zwei Filmmediatheken bekämen beide dieselbe Liste — erst
|
||||
/// wenn `DownloadItem` die Mediathek mitführt, lässt sich das trennen.
|
||||
private var geraeteliste: [MediaItem] {
|
||||
Heruntergeladenes.reihe(in: downloads).filter { titel in
|
||||
switch library.kind {
|
||||
case .movie: titel.kind != .show
|
||||
case .show: titel.kind == .show
|
||||
default: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
|
||||
private func loadMoreIfNeeded(reaching item: MediaItem) async {
|
||||
guard
|
||||
@@ -385,7 +438,8 @@ private struct LibraryListRow: View {
|
||||
var body: some View {
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
FlaroArtwork(
|
||||
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
|
||||
url: Heruntergeladenes.bildAdresse(item)
|
||||
?? session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
|
||||
aspectRatio: PosterCard.aspectRatio
|
||||
)
|
||||
.frame(width: 44)
|
||||
|
||||
@@ -1,24 +1,46 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
|
||||
/// Der erste Buchstabe auf einer Scheibe.
|
||||
/// Das eigene Bild auf einer Scheibe — oder der erste Buchstabe.
|
||||
///
|
||||
/// **Kein Platzhalterbild.** Jellyfin gibt kein Profilbild heraus, ohne dass
|
||||
/// eines hinterlegt ist, und am Konto führt die App heute keines mit. Ein
|
||||
/// Buchstabe ist ehrlicher als eine graue Silhouette, die bei jedem gleich
|
||||
/// aussieht — und man erkennt an ihm wenigstens, mit welchem Konto man
|
||||
/// angemeldet ist.
|
||||
/// **Der Buchstabe ist keine Notlösung.** Jellyfin gibt kein Profilbild
|
||||
/// heraus, und auch an einem Flaro-Konto muss keines hängen. Ein Buchstabe
|
||||
/// ist ehrlicher als eine graue Silhouette, die bei jedem gleich aussieht —
|
||||
/// und man erkennt an ihm wenigstens, mit welchem Konto man angemeldet ist.
|
||||
///
|
||||
/// Stand bis eben in `AccountBar` und wird jetzt zweimal gebraucht: dort in
|
||||
/// der Seitenleiste und auf dem iPhone oben rechts, wo seit dem Wegfall des
|
||||
/// Einstellungen-Tabs der Weg dorthin liegt.
|
||||
/// Steht ein Bild im Konto, tritt es an seine Stelle. Lädt es nicht, kommt
|
||||
/// der Buchstabe zurück: eine leere Scheibe sagt weniger als ein P.
|
||||
///
|
||||
/// Stand einmal in `AccountBar` und wird inzwischen dreimal gebraucht: dort in
|
||||
/// der Seitenleiste, auf dem iPhone oben rechts und in der Werkzeugleiste.
|
||||
struct AccountAvatar: View {
|
||||
let name: String
|
||||
var diameter: CGFloat = 26
|
||||
/// Das Bild des Kontos, falls eines hinterlegt ist.
|
||||
var image: URL?
|
||||
|
||||
@Environment(\.flaroAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
buchstabe
|
||||
.frame(width: diameter, height: diameter)
|
||||
.background(accent, in: .circle)
|
||||
.overlay {
|
||||
if let image {
|
||||
AsyncImage(url: image) { bild in
|
||||
bild.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
// Nichts: darunter liegt der Buchstabe und füllt die
|
||||
// Lücke, bis das Bild da ist.
|
||||
Color.clear
|
||||
}
|
||||
.frame(width: diameter, height: diameter)
|
||||
.clipShape(.circle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var buchstabe: some View {
|
||||
Text(name.prefix(1).uppercased())
|
||||
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
|
||||
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
|
||||
@@ -26,8 +48,6 @@ struct AccountAvatar: View {
|
||||
// Fehler.
|
||||
.font(.system(size: diameter * 0.46, weight: .semibold))
|
||||
.foregroundStyle(Color.flaroCanvas)
|
||||
.frame(width: diameter, height: diameter)
|
||||
.background(accent, in: .circle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +73,8 @@ struct AccountToolbarButton: View {
|
||||
|
||||
var body: some View {
|
||||
Button(action: openSettings) {
|
||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
|
||||
image: session.accountAvatarURL)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("sidebar.settings"))
|
||||
|
||||
@@ -78,7 +78,7 @@ struct AccountBar: View {
|
||||
isExpanded.toggle()
|
||||
} label: {
|
||||
HStack(spacing: FlaroSpacing.snug) {
|
||||
AccountAvatar(name: title)
|
||||
AccountAvatar(name: title, image: session.accountAvatarURL)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title)
|
||||
|
||||
@@ -19,6 +19,7 @@ struct RootView: View {
|
||||
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
||||
@Environment(NetworkConditions.self) private var network
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(Fortschrittspost.self) private var post
|
||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||
|
||||
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
||||
@@ -229,6 +230,7 @@ struct RootView: View {
|
||||
backend: session.backend,
|
||||
liveTV: session.liveTV,
|
||||
downloads: downloads,
|
||||
post: post,
|
||||
diagnostics: diagnostics,
|
||||
policy: settings.policy(for: network),
|
||||
settings: settings,
|
||||
@@ -514,7 +516,8 @@ struct RootView: View {
|
||||
Button {
|
||||
showsSettings = true
|
||||
} label: {
|
||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
|
||||
image: session.accountAvatarURL)
|
||||
.padding(FlaroSpacing.snug)
|
||||
// Damit er auch über einem hellen Aufmacher steht.
|
||||
.background(.black.opacity(0.28), in: .circle)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import MediaKit
|
||||
|
||||
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
|
||||
///
|
||||
@@ -34,6 +35,11 @@ struct PlaybackScrubber: View {
|
||||
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
|
||||
let onActivity: () -> Void
|
||||
|
||||
/// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
|
||||
var vorschau: Vorschauplan?
|
||||
/// Woher ein Blatt kommt.
|
||||
var blatt: (Int) -> URL? = { _ in nil }
|
||||
|
||||
@Environment(\.flaroAccent) private var accent
|
||||
|
||||
private static let trackHeight: CGFloat = 5
|
||||
@@ -71,6 +77,22 @@ struct PlaybackScrubber: View {
|
||||
.offset(x: gespielt - Self.thumbSize / 2)
|
||||
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
|
||||
}
|
||||
// **Die Vorschau steht über dem Griff, nicht darunter.** Unter
|
||||
// dem Finger verdeckte sie genau die Stelle, die man sucht.
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
if let plan = vorschau,
|
||||
let gezogen = scrubbing,
|
||||
let stelle = plan.stelle(bei: gezogen) {
|
||||
Vorschaukachel(plan: plan, stelle: stelle, blatt: blatt)
|
||||
.offset(
|
||||
x: min(
|
||||
breite - plan.kachelBreite,
|
||||
max(0, gespielt - plan.kachelBreite / 2)),
|
||||
y: -Self.hitHeight / 2 - 8)
|
||||
.allowsHitTesting(false)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(height: Self.hitHeight)
|
||||
.contentShape(.rect)
|
||||
.gesture(
|
||||
@@ -117,3 +139,75 @@ struct PlaybackScrubber: View {
|
||||
return min(max(Double(x / width), 0), 1) * duration.seconds
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Standbild aus dem Film, ausgeschnitten aus einem Blatt.
|
||||
///
|
||||
/// **Ausgeschnitten, nicht einzeln geladen.** Ein Blatt trägt hundert
|
||||
/// Bilder; beim Ziehen über die Leiste wird nur der Ausschnitt verschoben,
|
||||
/// und nach dem ersten Blatt kommt kein Abruf mehr. Einzelne Bilder zu
|
||||
/// holen hiesse, bei jeder Fingerbewegung ins Netz zu greifen.
|
||||
///
|
||||
/// **Geladen wird von Hand, nicht über `FlaroArtwork`.** Jenes rechnet mit
|
||||
/// Seitenverhältnissen und schneidet selbst zu — hier wird ein Blatt
|
||||
/// gebraucht, das genau so gross dasteht, wie es ist, damit der Versatz
|
||||
/// auf den Bildpunkt stimmt.
|
||||
struct Vorschaukachel: View {
|
||||
let plan: Vorschauplan
|
||||
let stelle: Vorschauplan.Stelle
|
||||
/// Woher das Blatt kommt — der Spieler weiss, welche Datei läuft.
|
||||
let blatt: (Int) -> URL?
|
||||
|
||||
@State private var bild: PlatformImage?
|
||||
|
||||
var body: some View {
|
||||
Rechteck
|
||||
.frame(width: plan.kachelBreite, height: plan.kachelHoehe)
|
||||
.background(.black)
|
||||
.clipShape(.rect(cornerRadius: FlaroRadius.card))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: FlaroRadius.card)
|
||||
.stroke(.white.opacity(0.18), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.6), radius: 10, y: 4)
|
||||
.task(id: stelle.blatt) { await laden() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var Rechteck: some View {
|
||||
if let bild {
|
||||
// `Image(platform:)` liegt in FlaroDesign und ist paketintern;
|
||||
// dieselbe Fallunterscheidung, zwei Zeilen.
|
||||
bildAnsicht(bild)
|
||||
.resizable()
|
||||
.frame(
|
||||
width: plan.kachelBreite * Double(plan.spalten),
|
||||
height: plan.kachelHoehe * Double(plan.zeilen))
|
||||
// Der Ausschnitt entsteht durch Verschieben hinter einem
|
||||
// Rahmen in Kachelgrösse — dieselbe Rechnung wie
|
||||
// `background-position` im Web.
|
||||
.offset(
|
||||
x: -plan.kachelBreite * Double(stelle.spalte),
|
||||
y: -plan.kachelHoehe * Double(stelle.zeile))
|
||||
.frame(
|
||||
width: plan.kachelBreite, height: plan.kachelHoehe,
|
||||
alignment: .topLeading)
|
||||
.clipped()
|
||||
} else {
|
||||
Color.black
|
||||
}
|
||||
}
|
||||
|
||||
private func bildAnsicht(_ bild: PlatformImage) -> Image {
|
||||
#if canImport(UIKit)
|
||||
Image(uiImage: bild)
|
||||
#else
|
||||
Image(nsImage: bild)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func laden() async {
|
||||
guard let adresse = blatt(stelle.blatt) else { return }
|
||||
// Der Lader hält Blätter im Speicher: beim Ziehen über die Leiste
|
||||
// wird dasselbe Blatt hundertmal gebraucht.
|
||||
bild = await ArtworkLoader.shared.image(for: adresse)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +61,20 @@ final class PlayerModel {
|
||||
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
||||
/// Überspringen-Knopf.
|
||||
private(set) var activeMarker: MediaMarker?
|
||||
|
||||
/// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
|
||||
///
|
||||
/// **Einmal gefragt, bei „noch nicht" einmal nachgefasst.** Die Blätter
|
||||
/// entstehen beim Abspielen im Hintergrund; nach zehn Sekunden sind sie
|
||||
/// bei den meisten Dateien da. Weiter zu fragen hiesse, für eine
|
||||
/// Annehmlichkeit dauernd am Server zu ziehen.
|
||||
private(set) var vorschau: Vorschauplan?
|
||||
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
|
||||
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
||||
private(set) var nextEpisode: MediaItem?
|
||||
/// Die Folge davor — für den Rückwärtsknopf. `nil` heisst: es ist die
|
||||
/// erste, oder es ist gar keine Folge.
|
||||
private(set) var previousEpisode: MediaItem?
|
||||
private(set) var hasReachedEnd = false
|
||||
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
|
||||
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
|
||||
@@ -105,6 +116,26 @@ final class PlayerModel {
|
||||
/// und ausgerechnet da will man zusehen, wie der Vorrat wächst.
|
||||
private(set) var bufferedUntil: Duration?
|
||||
|
||||
/// Ab welcher Stelle des Films der laufende Strom beginnt.
|
||||
///
|
||||
/// **Null ist der Normalfall.** Dann ist der Strom der ganze Film, und
|
||||
/// die Uhr des Spielers ist die Uhr des Films.
|
||||
///
|
||||
/// **Darüber heisst: der Server hat schon gesprungen.** Flaros
|
||||
/// Abspielliste wächst von der gewünschten Stelle an vorwärts; was davor
|
||||
/// liegt, gibt es in diesem Strom nicht. Der Spieler zählt trotzdem ab
|
||||
/// null — ohne diesen Wert stünde bei „Fortsetzen ab 1:32" eine Null auf
|
||||
/// der Uhr, und genau die würde auch gemeldet.
|
||||
private var streamOffset: Duration = .zero
|
||||
|
||||
/// Bis wohin der laufende Strom reicht.
|
||||
///
|
||||
/// **Nicht dasselbe wie <see cref="duration"/>.** Das ist die Länge des
|
||||
/// Films; dies ist, was davon in diesem Strom liegt. Bei einer
|
||||
/// wachsenden Abspielliste wächst es mit. Ein Sprung darüber hinaus
|
||||
/// braucht einen neuen Strom, keinen Sprung.
|
||||
private var streamEnd: Duration?
|
||||
|
||||
/// Was der eigene Vorratsspeicher gerade tut — in einem Satz.
|
||||
///
|
||||
/// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher
|
||||
@@ -211,6 +242,9 @@ final class PlayerModel {
|
||||
/// Protokoll mit Null-Objekt wäre die dritte Möglichkeit und die
|
||||
/// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden.
|
||||
private let downloads: DownloadCoordinator?
|
||||
/// Siehe `Fortschrittspost`: was der Server nicht annimmt, wird
|
||||
/// zurückgelegt und nachgereicht.
|
||||
private let post: Fortschrittspost?
|
||||
private let diagnostics: PlaybackDiagnostics
|
||||
private let policy: PlaybackPolicy
|
||||
private let settings: PlaybackSettings
|
||||
@@ -461,6 +495,8 @@ final class PlayerModel {
|
||||
backend: (any MediaBackend)?,
|
||||
liveTV: (any LiveTVBackend)?,
|
||||
downloads: DownloadCoordinator? = nil,
|
||||
/// Wohin ein Fortschritt kommt, den der Server nicht annimmt.
|
||||
post: Fortschrittspost? = nil,
|
||||
diagnostics: PlaybackDiagnostics,
|
||||
policy: PlaybackPolicy,
|
||||
settings: PlaybackSettings,
|
||||
@@ -474,6 +510,7 @@ final class PlayerModel {
|
||||
self.backend = backend
|
||||
self.liveTV = liveTV
|
||||
self.downloads = downloads
|
||||
self.post = post
|
||||
self.diagnostics = diagnostics
|
||||
self.policy = policy
|
||||
self.settings = settings
|
||||
@@ -499,6 +536,18 @@ final class PlayerModel {
|
||||
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
|
||||
if let downloads, let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
|
||||
await startLocal(url: localURL)
|
||||
// **Die Angaben trotzdem holen, nur eben hinterher.**
|
||||
//
|
||||
// Hier wurde früher schlicht zurückgekehrt — und damit blieb
|
||||
// `item` leer. Daran hing mehr, als es aussieht: keine Marken,
|
||||
// also kein „Intro überspringen"; keine nächste Folge, also
|
||||
// keine Knöpfe zum Blättern. Bei allem, was heruntergeladen
|
||||
// war, fehlte das, und nur dort — weshalb es so lange aussah
|
||||
// wie ein Fehler in der Erkennung.
|
||||
//
|
||||
// Nebenher und nicht davor: das Bild soll sofort laufen, und
|
||||
// ohne Verbindung gibt es ohnehin nichts zu holen.
|
||||
metadatenNachladen()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -557,6 +606,7 @@ final class PlayerModel {
|
||||
|
||||
await load(source, decision: decision)
|
||||
preloadNextEpisode()
|
||||
vorschauHolen()
|
||||
}
|
||||
|
||||
/// Stellt auf eine andere Qualitätsstufe um.
|
||||
@@ -718,7 +768,58 @@ final class PlayerModel {
|
||||
await load(PlaybackSource(url: url, origin: .remote), decision: decision)
|
||||
}
|
||||
|
||||
/// Holt Titelangaben nach, während die lokale Datei schon läuft.
|
||||
///
|
||||
/// Scheitert es — kein Server, kein Netz —, bleibt es, wie es war: die
|
||||
/// Datei läuft, es gibt bloss keine Marken und keine Nachbarfolgen.
|
||||
/// Welche Datei läuft — die Vorschau hängt an ihr, nicht am Titel.
|
||||
///
|
||||
/// `MediaVersion.id` ist die Kennung der Mediendatei; eine Fassung
|
||||
/// besteht aus genau einer.
|
||||
private var vorschaudatei: String?
|
||||
|
||||
/// Holt den Vorschauplan — einmal sofort, einmal nach zehn Sekunden.
|
||||
private func vorschauHolen() {
|
||||
guard vorschau == nil, let backend, let item else { return }
|
||||
guard let datei = version(of: item)?.id else { return }
|
||||
vorschaudatei = datei
|
||||
|
||||
Task { [weak self] in
|
||||
for warten in [Duration.zero, .seconds(10)] {
|
||||
if warten > .zero { try? await Task.sleep(for: warten) }
|
||||
guard let self, self.vorschau == nil else { return }
|
||||
if let plan = await backend.vorschauplan(fileId: datei) {
|
||||
self.vorschau = plan
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Woher ein Vorschaublatt kommt.
|
||||
func vorschaublatt(_ nummer: Int) -> URL? {
|
||||
guard let backend, let datei = vorschaudatei else { return nil }
|
||||
return backend.vorschaublatt(fileId: datei, blatt: nummer)
|
||||
}
|
||||
|
||||
private func metadatenNachladen() {
|
||||
guard item == nil, let backend else { return }
|
||||
Task { [weak self] in
|
||||
guard let geholt = try? await backend.item(id: self?.request.ratingKey ?? "") else { return }
|
||||
guard let self, self.item == nil else { return }
|
||||
self.item = geholt
|
||||
self.updateActiveMarker()
|
||||
self.preloadNextEpisode()
|
||||
self.vorschauHolen()
|
||||
}
|
||||
}
|
||||
|
||||
private func startLocal(url: URL) async {
|
||||
// Eine Datei auf dem Gerät ist der ganze Film; es gibt keinen
|
||||
// Vorlauf, den der Spieler aufrechnen müsste.
|
||||
streamOffset = .zero
|
||||
streamEnd = nil
|
||||
|
||||
let offline = downloads?.offlineMedia(forRatingKey: request.ratingKey) ?? nil
|
||||
|
||||
let decision = PlaybackDecider().decide(
|
||||
@@ -839,6 +940,26 @@ final class PlayerModel {
|
||||
playbackSession = target.sessionID
|
||||
playbackVersionID = target.versionID
|
||||
|
||||
// **Wer schon gesprungen ist, springt nicht noch einmal.**
|
||||
//
|
||||
// Flaro legt den Strom an der gewünschten Stelle an; Jellyfin
|
||||
// liefert den ganzen Film und lässt den Spieler springen. Beides
|
||||
// zugleich hiesse bei einundzwanzig Minuten Fortschritt, bei
|
||||
// zweiundvierzig zu landen.
|
||||
streamOffset = target.startOffset
|
||||
streamEnd = nil
|
||||
|
||||
// **Die Länge kommt aus der Datei, nicht vom Player.**
|
||||
//
|
||||
// Eine wachsende Abspielliste hat für AVPlayer keine Länge — sie
|
||||
// trägt kein `EXT-X-ENDLIST`, also bleibt die Dauer unbestimmt
|
||||
// und `durationResolved` kommt nie. Ohne diese Zeile blieb
|
||||
// `duration` auf nichts, und die Ansicht rechnet dann mit einer
|
||||
// Sekunde: der Regler stand bei 1:50:07 am Anschlag.
|
||||
if let bekannt = part.duration ?? item.duration {
|
||||
duration = .milliseconds(bekannt)
|
||||
}
|
||||
|
||||
// **Der Vorrat läuft voraus, nicht der Player.**
|
||||
//
|
||||
// AVPlayer entscheidet selbst, wie weit er puffert, und bei einer
|
||||
@@ -856,7 +977,7 @@ final class PlayerModel {
|
||||
return PlaybackSource(
|
||||
url: adresse,
|
||||
origin: .remote,
|
||||
startTime: start,
|
||||
startTime: target.startOffset > .zero ? .zero : start,
|
||||
// **Mit eigenem Vorrat ein kleiner Puffer.**
|
||||
//
|
||||
// Er bestimmt, wie viel AVPlayer beisammenhaben will, bevor
|
||||
@@ -1504,6 +1625,10 @@ final class PlayerModel {
|
||||
// Der Sprung **ist** angekommen, in dem Moment, in dem man ihn
|
||||
// auslöst. Alles Weitere ist Warten auf Daten, und dafür gibt es den
|
||||
// Kreisel.
|
||||
// **Vor dem Aufräumen entscheiden.** Die Antwort hängt daran, wie
|
||||
// weit der Vorrat reicht — und der wird gleich verworfen.
|
||||
let neuerStrom = ausserhalbDesStroms(time)
|
||||
|
||||
currentTime = time
|
||||
seekTarget = time
|
||||
// Der Vorrat von vorhin gilt für die alte Stelle. Ihn stehen zu lassen
|
||||
@@ -1512,7 +1637,55 @@ final class PlayerModel {
|
||||
NowPlaying.shared.update(from: self)
|
||||
updateActiveMarker()
|
||||
|
||||
Task { await engine.seek(to: time) }
|
||||
// **Ausserhalb dieses Stroms springt nicht der Spieler, sondern der
|
||||
// Server.**
|
||||
//
|
||||
// Flaros Abspielliste beginnt an der gewünschten Stelle und wächst
|
||||
// von dort vorwärts. Was davor liegt und was noch nicht gerechnet
|
||||
// ist, gibt es darin nicht; ein Sprung dorthin ginge ins Leere und
|
||||
// der Spieler bliebe mit einem Kreisel stehen. Also wird der Strom
|
||||
// neu aufgebaut — `restart` steigt bei `currentTime` wieder ein, und
|
||||
// das ist genau die Stelle, die eben gesetzt wurde.
|
||||
if neuerStrom {
|
||||
Task { await restart() }
|
||||
return
|
||||
}
|
||||
|
||||
Task { await engine.seek(to: time - streamOffset) }
|
||||
}
|
||||
|
||||
/// Ob diese Stelle in dem Strom liegt, der gerade läuft.
|
||||
///
|
||||
/// **Innerhalb des Erzeugten springt der Spieler selbst.** Dafür eine
|
||||
/// neue Sitzung zu starten hiesse, Sekunden zu warten, um fünf Sekunden
|
||||
/// zurückzugehen.
|
||||
private func ausserhalbDesStroms(_ time: Duration) -> Bool {
|
||||
// **Eine Datei mit bekannter Länge: überallhin.** `streamEnd` steht
|
||||
// nur, wenn der Player eine Dauer ermitteln konnte, und das kann er
|
||||
// genau dann, wenn das Ganze schon dasteht — eine Mediendatei oder
|
||||
// eine fertige Abspielliste.
|
||||
if streamEnd != nil, streamOffset == .zero { return false }
|
||||
|
||||
// Vor dem Anfang des Stroms gibt es in ihm nichts.
|
||||
if time < streamOffset { return true }
|
||||
|
||||
// **Eine wachsende Liste reicht so weit, wie Daten da sind.**
|
||||
//
|
||||
// Hier stand ein Vergleich mit `streamEnd`, und der ging nie auf:
|
||||
// bei einer wachsenden Liste bleibt die Dauer unbestimmt, also ist
|
||||
// `streamEnd` nichts, und jeder Sprung galt als innerhalb. AVPlayer
|
||||
// bekam ein Ziel, zu dem es keine Schnipsel gibt, verwarf es, und
|
||||
// der Regler sprang zurück — beim Ziehen sichtbar, im Pausieren
|
||||
// nicht, weil die Liste dort weiterwuchs und ihn irgendwann einholte.
|
||||
//
|
||||
// **Weiss niemand, wie weit es reicht, wird neu gebaut.** Ein neuer
|
||||
// Strom dauert ein paar Sekunden; ein Sprung, der nichts tut, ist
|
||||
// kaputt.
|
||||
guard let bis = bufferedUntil else { return true }
|
||||
|
||||
// Eine halbe Sekunde Nachsicht an der Kante: dort einen neuen Strom
|
||||
// zu bauen wäre Aufwand für nichts.
|
||||
return time > bis + .milliseconds(500)
|
||||
}
|
||||
|
||||
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
|
||||
@@ -1523,9 +1696,20 @@ final class PlayerModel {
|
||||
|
||||
/// Springt ans Ende der laufenden Marke.
|
||||
func skipActiveMarker() {
|
||||
guard let marker = activeMarker else { return }
|
||||
guard let marker = activeMarker, let item else { return }
|
||||
skippedMarkers.insert(marker.id)
|
||||
seek(to: .milliseconds(marker.endMilliseconds))
|
||||
|
||||
// **Mit dem Schalter in einem Zug bis ans Ende.** Sonst endet der
|
||||
// Sprung an dieser Marke, also bei der Post-Credit-Szene — wer sie
|
||||
// nicht sehen will, stellt es einmal um und drückt einmal.
|
||||
let ziel = settings.skipExtraScenes
|
||||
? item.markers
|
||||
.filter { $0.kind == marker.kind && $0.startMilliseconds >= marker.startMilliseconds }
|
||||
.map(\.endMilliseconds)
|
||||
.max() ?? marker.endMilliseconds
|
||||
: marker.endMilliseconds
|
||||
|
||||
seek(to: .milliseconds(ziel))
|
||||
}
|
||||
|
||||
/// Ob es bei der Tonspur überhaupt etwas zu wählen gibt.
|
||||
@@ -1660,26 +1844,43 @@ final class PlayerModel {
|
||||
private func report(state: PlaybackReportState) async {
|
||||
guard let backend, let item else { return }
|
||||
|
||||
// Die Laufzeit der Engine ist die genauere, aber sie steht erst ein
|
||||
// paar Sekunden nach dem Öffnen fest. Für die erste Meldung tut es die
|
||||
// des Servers — sonst fiele ausgerechnet der Bericht aus, der die
|
||||
// Sitzung anlegt, und der Titel erschiene nirgends als „läuft gerade".
|
||||
guard let total = duration ?? item.duration.map({ Duration.milliseconds($0) })
|
||||
// **Die Laufzeit des Titels schlägt die der Engine.**
|
||||
//
|
||||
// Vorher war es umgekehrt, mit dem Argument, die Engine messe
|
||||
// genauer. Sie misst aber am Strom und nicht am Film: bei einem
|
||||
// gerechneten Strom ist das eine Playlist, die noch wächst, und
|
||||
// anfangs sieht die Engine davon nur einen Stummel. Gemeldet hat sie
|
||||
// ihn als Laufzeit — und daraus machte der Server die Laufzeit des
|
||||
// Films: Stelle zusammengestaucht, Häkchen „gesehen" gesetzt, Titel
|
||||
// aus „Weiterschauen" verschwunden. Gemessen an einem Film, der bei
|
||||
// 46 Minuten stand und danach wieder bei null anfing.
|
||||
guard let total = item.duration.map({ Duration.milliseconds($0) }) ?? duration
|
||||
else { return }
|
||||
|
||||
// Beim Beginn steht die Zeit noch auf null — das ist keine fehlende
|
||||
// Angabe, sondern der Anfang. Alle anderen Meldungen ohne Zeit wären
|
||||
// dagegen wertlos und würden einen gemerkten Fortschritt überschreiben.
|
||||
guard currentTime > .zero || state == .started else { return }
|
||||
// **Keine Null melden, die der Spieler gar nicht meint.**
|
||||
//
|
||||
// Beim Öffnen steht die Zeit der Engine auf null, obwohl der Spieler
|
||||
// gleich an einer ganz anderen Stelle einsteigt. Diese Meldung darf
|
||||
// nicht ausfallen — erst sie legt beim Server die Sitzung an, unter
|
||||
// der der Titel als „läuft gerade" erscheint. Sie muss nur die
|
||||
// Stelle nennen, die gemeint ist, statt der, die noch nicht gesetzt
|
||||
// ist.
|
||||
let stelle = currentTime > .zero ? currentTime : request.startTime
|
||||
guard stelle > .zero || state == .started else { return }
|
||||
|
||||
await backend.reportProgress(PlaybackReport(
|
||||
let bericht = PlaybackReport(
|
||||
itemID: item.id,
|
||||
sessionID: playbackSession,
|
||||
versionID: playbackVersionID,
|
||||
state: state,
|
||||
time: currentTime,
|
||||
time: stelle,
|
||||
duration: total
|
||||
))
|
||||
)
|
||||
// **Was nicht ankommt, wird zurückgelegt.** Ohne das war eine Folge,
|
||||
// die man offline zu Ende gesehen hat, für den Server nie gelaufen.
|
||||
if await backend.reportProgress(bericht) == false {
|
||||
post?.zuruecklegen(bericht, server: backend.identity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Marken und nächste Folge
|
||||
@@ -1688,14 +1889,14 @@ final class PlayerModel {
|
||||
guard let item else { return }
|
||||
let milliseconds = currentTime.milliseconds
|
||||
|
||||
activeMarker = item.markers.first { marker in
|
||||
guard marker.contains(milliseconds) else { return false }
|
||||
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
|
||||
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
|
||||
// „Film beenden".
|
||||
if marker.isCredits && item.kind != .episode { return false }
|
||||
return true
|
||||
}
|
||||
// **Was der Server schickt, gilt.** Hier stand eine eigene Regel:
|
||||
// bei einem Film wurde die Abspannmarke verworfen, weil
|
||||
// „überspringen" dort gleichbedeutend mit „beenden" war. Das ist
|
||||
// eine Entscheidung über Marken, und die trifft der Server — er
|
||||
// schickt sie nur, wenn er einen Abspann gefunden hat, und seit
|
||||
// ein Abspann geteilt sein darf, führt der Knopf zur
|
||||
// Post-Credit-Szene statt ins Nichts.
|
||||
activeMarker = item.markers.first { $0.contains(milliseconds) }
|
||||
|
||||
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
|
||||
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
|
||||
@@ -1703,21 +1904,43 @@ final class PlayerModel {
|
||||
if shouldSkip { skipActiveMarker() }
|
||||
}
|
||||
|
||||
/// **Beide Nachbarn, nicht nur der nächste.**
|
||||
///
|
||||
/// Die nächste Folge wurde schon immer vorgeladen — für den Knopf am
|
||||
/// Ende. Seit es im Spieler Knöpfe zum Blättern gibt, gilt dasselbe für
|
||||
/// die vorige: wer eine zu weit gesprungen ist, soll zurück können, ohne
|
||||
/// den Spieler zu verlassen.
|
||||
private func preloadNextEpisode() {
|
||||
guard let item, item.kind == .episode, let backend else { return }
|
||||
Task { [weak self] in
|
||||
let next = try? await backend.nextEpisode(after: item)
|
||||
self?.nextEpisode = next
|
||||
}
|
||||
Task { [weak self] in
|
||||
let vorige = try? await backend.previousEpisode(before: item)
|
||||
self?.previousEpisode = vorige
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
|
||||
/// Koordinator weiter.
|
||||
func nextEpisodeRequest() -> PlaybackRequest? {
|
||||
guard let nextEpisode else { return nil }
|
||||
func nextEpisodeRequest() -> PlaybackRequest? { wunsch(für: nextEpisode) }
|
||||
|
||||
/// Dasselbe rückwärts.
|
||||
func previousEpisodeRequest() -> PlaybackRequest? { wunsch(für: previousEpisode) }
|
||||
|
||||
/// **Von vorn, nicht von der gemerkten Stelle.**
|
||||
///
|
||||
/// Wer blättert, will die Folge sehen. Beim Vorwärtsblättern ist sie
|
||||
/// ohnehin meist ungesehen; beim Zurückblättern stünde man sonst im
|
||||
/// Abspann der Folge, die man gerade noch einmal anfangen wollte. Wer
|
||||
/// fortsetzen will, nimmt den Weg über die Serienseite — dort steht die
|
||||
/// Stelle dabei.
|
||||
private func wunsch(für folge: MediaItem?) -> PlaybackRequest? {
|
||||
guard let folge else { return nil }
|
||||
return PlaybackRequest(
|
||||
ratingKey: nextEpisode.id,
|
||||
title: nextEpisode.playbackTitle,
|
||||
ratingKey: folge.id,
|
||||
title: folge.playbackTitle,
|
||||
mediaIndex: 0,
|
||||
startTime: .zero
|
||||
)
|
||||
@@ -1859,7 +2082,14 @@ final class PlayerModel {
|
||||
String(describing: failure),
|
||||
].joined(separator: "\n\n"))
|
||||
}
|
||||
case .timeChanged(let time):
|
||||
case .timeChanged(let stromzeit):
|
||||
// **Die Uhr des Spielers ist nicht die Uhr des Films.**
|
||||
//
|
||||
// Beginnt der Strom bei 1:32, zählt der Spieler trotzdem
|
||||
// ab null. Ab hier wird in Filmzeit gerechnet — alles
|
||||
// darunter, von der Anzeige bis zur Fortschrittsmeldung,
|
||||
// meint denselben Punkt im Film.
|
||||
let time = streamOffset + stromzeit
|
||||
// **Nachzügler verwerfen.** Nach einem Sprung meldet der
|
||||
// Player noch ein paar Mal die alte Stelle, bis er dort
|
||||
// ankommt. Ungeprüft übernommen zöge das den Regler wieder
|
||||
@@ -1889,7 +2119,22 @@ final class PlayerModel {
|
||||
}
|
||||
updateActiveMarker()
|
||||
case .durationResolved(let value):
|
||||
duration = value
|
||||
streamEnd = streamOffset + value
|
||||
// **Die Länge des Films, nicht die des Stroms.**
|
||||
//
|
||||
// Eine wachsende Abspielliste meldet anfangs ein paar
|
||||
// Sekunden. Als Filmlänge genommen stünde der Regler
|
||||
// sofort am Ende, und ein Sprung nach vorn wäre
|
||||
// unmöglich.
|
||||
// **Die Datei weiss es genau, der Titel oft gar nicht.**
|
||||
// `item.duration` kommt aus der Liste, und dort stehen
|
||||
// keine Dateien — bei einem Titel, der aus einer Reihe
|
||||
// geöffnet wurde, ist es nichts. Dann blieb nur die Länge
|
||||
// des Stroms, und die ist am Anfang ein paar Sekunden:
|
||||
// Regler am Ende, obwohl noch zwanzig Minuten kommen.
|
||||
duration = currentPart?.duration.map { Duration.milliseconds($0) }
|
||||
?? item?.duration.map { Duration.milliseconds($0) }
|
||||
?? streamEnd
|
||||
NowPlaying.shared.update(from: self)
|
||||
case .reachedEnd:
|
||||
isPlaying = false
|
||||
@@ -1931,14 +2176,52 @@ final class PlayerModel {
|
||||
/// Einstellungen eine Untertitelsprache steht. Wer keine eingestellt
|
||||
/// hatte, schaltete die Untertitel ein und sah nichts.
|
||||
///
|
||||
/// **Die erste ist die richtige.** Rechnet der Server um, liegt genau
|
||||
/// eine im Stream — nämlich die bestellte. Es gibt nichts zu verwechseln.
|
||||
/// **Die erste ist nicht mehr die richtige.**
|
||||
///
|
||||
/// Das galt, solange der Server genau eine Spur auslieferte — die
|
||||
/// bestellte. Flaro legt seit dem Strom **alle** Textspuren als eigene
|
||||
/// Spuren in die Abspielliste, und dann ist die erste schlicht die
|
||||
/// erste: wer Deutsch wählte, sah Englisch.
|
||||
///
|
||||
/// Gesucht wird deshalb die Spur, die zur Bestellung passt — erst über
|
||||
/// das Sprachkürzel, dann über den Namen. Findet sich keine, bleibt es
|
||||
/// bei der ersten: bei einem umgerechneten Strom gibt es nur sie, und
|
||||
/// gar keinen Untertitel zu zeigen wäre schlechter als den einen.
|
||||
private func showOrderedSubtitle() {
|
||||
guard selectedSubtitleStream != nil, selectedSubtitleTrack == nil else { return }
|
||||
guard let erste = subtitleTracks.first else { return }
|
||||
guard let bestellt = selectedSubtitleStream, selectedSubtitleTrack == nil else { return }
|
||||
guard let treffer = passendeSpur(zu: bestellt) ?? subtitleTracks.first else { return }
|
||||
|
||||
diagnostics.note("Untertitel eingeschaltet: \(erste.displayName)")
|
||||
selectSubtitleTrack(erste)
|
||||
diagnostics.note("Untertitel eingeschaltet: \(treffer.displayName)")
|
||||
selectSubtitleTrack(treffer)
|
||||
}
|
||||
|
||||
/// Die Spur im Strom, die zu der bestellten aus den Metadaten gehört.
|
||||
private func passendeSpur(zu bestellt: MediaStream) -> MediaTrack? {
|
||||
// **Die Sprache zuerst.** Sie steht in der Abspielliste als
|
||||
// `LANGUAGE` und ist das, was ein Spieler verlässlich durchreicht.
|
||||
if let code = bestellt.languageCode ?? bestellt.language,
|
||||
let treffer = subtitleTracks.first(where: {
|
||||
LanguageCode.matches(code, $0.languageCode)
|
||||
&& $0.isForced == bestellt.isForced
|
||||
})
|
||||
{
|
||||
return treffer
|
||||
}
|
||||
|
||||
// **Dann der Name.** Zwei Spuren in derselben Sprache — Dialoge und
|
||||
// Schilder etwa — unterscheiden sich nur darüber.
|
||||
if let name = bestellt.displayTitle,
|
||||
let treffer = subtitleTracks.first(where: { $0.displayName == name })
|
||||
{
|
||||
return treffer
|
||||
}
|
||||
|
||||
// Ohne Sprachkürzel bleibt die Sprache allein.
|
||||
if let code = bestellt.languageCode ?? bestellt.language {
|
||||
return subtitleTracks.first { LanguageCode.matches(code, $0.languageCode) }
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Wählt die Untertitelspur, sofern eine gewünscht ist.
|
||||
@@ -1952,9 +2235,15 @@ final class PlayerModel {
|
||||
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
|
||||
/// eine später gemeldete Spurliste das nicht zurücksetzen.
|
||||
private func applyPreferredLanguages() {
|
||||
guard selectedSubtitleTrack == nil, !languages.subtitleLanguage.isEmpty,
|
||||
// **Die Ebene schlägt das Allgemeine.** Hier stand nur die
|
||||
// allgemeine Sprache; eine Serie, die man im Original mit deutschen
|
||||
// Untertiteln sieht, liess sich damit nicht einstellen, ohne sie für
|
||||
// alles andere auch einzuschalten. Bei einer Folge gilt die Serie,
|
||||
// bei einem Film der Titel selbst.
|
||||
guard selectedSubtitleTrack == nil,
|
||||
let wunsch = languages.subtitleWish(titleID: titelbereich),
|
||||
let treffer = subtitleTracks.first(where: {
|
||||
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
|
||||
LanguageCode.matches(wunsch, $0.languageCode)
|
||||
})
|
||||
else { return }
|
||||
selectSubtitleTrack(treffer)
|
||||
@@ -1972,6 +2261,14 @@ final class PlayerModel {
|
||||
/// Netz und ohne Einstellungen, damit sich der ZDF-Fall wörtlich
|
||||
/// hinschreiben und durchspielen lässt. Hier steht nur, woher der Wunsch
|
||||
/// kommt.
|
||||
/// Unter welcher Kennung die Vorwahl dieses Titels liegt.
|
||||
///
|
||||
/// **Bei einer Folge die Serie, bei einem Film er selbst.** „Diese Serie
|
||||
/// auf Japanisch" gilt für alle Folgen; „dieser Film auf Englisch" gilt
|
||||
/// für ihn. Beides ist dieselbe Frage auf der Ebene, die der Benutzer
|
||||
/// vor sich hatte, als er sie beantwortet hat.
|
||||
private var titelbereich: String? { item?.seriesID ?? item?.id }
|
||||
|
||||
private func chooseAudioStream(
|
||||
in part: MediaFile,
|
||||
of item: MediaItem,
|
||||
@@ -1981,10 +2278,9 @@ final class PlayerModel {
|
||||
subtitleStreams = part.subtitleStreams
|
||||
// Untertitel sind eine Zutat, keine Voraussetzung: der Wunsch gilt,
|
||||
// wenn es einen passenden gibt, und sonst bleibt es ohne.
|
||||
selectedSubtitleStream = languages.subtitleLanguage.isEmpty ? nil
|
||||
: subtitleStreams.first {
|
||||
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
|
||||
}
|
||||
selectedSubtitleStream = languages.subtitleWish(titleID: titelbereich).flatMap { wunsch in
|
||||
subtitleStreams.first { LanguageCode.matches(wunsch, $0.languageCode) }
|
||||
}
|
||||
|
||||
guard !audioStreams.isEmpty else {
|
||||
selectedAudioStream = nil
|
||||
@@ -2000,7 +2296,12 @@ final class PlayerModel {
|
||||
|
||||
selectedAudioStream = AudioTrackChooser.choose(
|
||||
from: part.streams,
|
||||
wish: languages.wish(seriesID: item.seriesID, libraryID: libraryID)
|
||||
// **Bei einem Film seine eigene Kennung.** Hier stand nur
|
||||
// `item.seriesID`, und die ist bei einem Film nichts: die
|
||||
// Vorwahl auf der Filmseite wurde abgelegt und nie wieder
|
||||
// gelesen. Untertitel gingen durch, Ton nicht — weil die beiden
|
||||
// an verschiedenen Stellen nachsehen.
|
||||
wish: languages.wish(seriesID: titelbereich, libraryID: libraryID)
|
||||
)
|
||||
|
||||
if let gewählt = selectedAudioStream {
|
||||
@@ -2020,8 +2321,8 @@ final class PlayerModel {
|
||||
guard stream != selectedAudioStream else { return }
|
||||
selectedAudioStream = stream
|
||||
|
||||
if let seriesID = item?.seriesID {
|
||||
languages.remember(stream, forSeries: seriesID)
|
||||
if let bereich = titelbereich {
|
||||
languages.remember(stream, forSeries: bereich)
|
||||
}
|
||||
|
||||
// **Der billige Weg, wenn er offensteht.** Wird die Datei unverändert
|
||||
@@ -2069,16 +2370,16 @@ final class PlayerModel {
|
||||
|
||||
/// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt.
|
||||
var rememberedTrackName: String? {
|
||||
guard let seriesID = item?.seriesID,
|
||||
let signatur = languages.rememberedTrack(forSeries: seriesID)
|
||||
guard let bereich = titelbereich,
|
||||
let signatur = languages.rememberedTrack(forSeries: bereich)
|
||||
else { return nil }
|
||||
return signatur.title ?? signatur.languageCode
|
||||
}
|
||||
|
||||
/// Vergisst die gemerkte Spur dieser Serie.
|
||||
func forgetRememberedTrack() {
|
||||
guard let seriesID = item?.seriesID else { return }
|
||||
languages.forgetTrack(forSeries: seriesID)
|
||||
guard let bereich = titelbereich else { return }
|
||||
languages.forgetTrack(forSeries: bereich)
|
||||
}
|
||||
|
||||
private func playerSubtitle(for item: MediaItem) -> String? {
|
||||
|
||||
@@ -52,6 +52,15 @@ struct PlayerView: View {
|
||||
@State private var trackPicker: TrackPickerSheet.Kind?
|
||||
@State private var showsQualityChoice = false
|
||||
@State private var hideTask: Task<Void, Never>?
|
||||
/// Ob die Marken-Knöpfe inzwischen an der Bedienung hängen.
|
||||
///
|
||||
/// **Erst für sich, dann mit der Bedienung.** Eine neue Marke zeigt
|
||||
/// ihre Knöpfe von selbst — sonst müsste man erst das Bild antippen,
|
||||
/// um das Intro überspringen zu können, und dann hätte man die
|
||||
/// Bedienung ohnehin. Sobald man sie aber einmal eingeblendet hat,
|
||||
/// gehören die Knöpfe dazu und gehen mit ihr. Keine Frist, kein
|
||||
/// Zeitgeber: der Zuschauer sagt selbst, wann er genug gesehen hat.
|
||||
@State private var markenGebunden = false
|
||||
@State private var scrubbing: Double?
|
||||
/// Welche Richtung gerade als Hinweis eingeblendet ist.
|
||||
@State private var seekHint: Double?
|
||||
@@ -106,8 +115,29 @@ struct PlayerView: View {
|
||||
// Jetzt rechnet das Layout. Der schwarze Rand ist schlicht
|
||||
// der Hintergrund dahinter, und wer ihn anklickt, klickt
|
||||
// nicht aufs Bild — das muss niemand mehr feststellen.
|
||||
// **Im Vollbild über alles, sonst nicht unter die
|
||||
// Insel.**
|
||||
//
|
||||
// Bei 21∶9 auf einem Telefon reicht das Bild über die
|
||||
// ganze Breite — und damit hinter die Dynamic Island, die
|
||||
// links einen Streifen davon verdeckt. Wer Vollbild
|
||||
// wählt, hat sich fürs Beschneiden entschieden; dort ist
|
||||
// Rand an Rand folgerichtig. Im normalen Modus ist der
|
||||
// Anspruch gerade, dass nichts fehlt — dann darf auch
|
||||
// nichts verdeckt sein. Plex macht es genauso.
|
||||
Color.clear
|
||||
.ignoresSafeArea()
|
||||
//
|
||||
// **Ein Modifikator mit leeren Kanten, kein `if`.**
|
||||
// Hier stand ein eigener `ViewModifier`, der
|
||||
// zwischen `ignoresSafeArea()` und gar nichts
|
||||
// umschaltete — und genau das ist in SwiftUI ein
|
||||
// Zweig: beim Umschalten wurde der Baum darunter
|
||||
// neu aufgebaut, die Bildebene mit ihm, und der
|
||||
// Spieler stand ohne sie da. Schwarz, sobald
|
||||
// jemand auf „Bildfüllend" wechselte. `edges: []`
|
||||
// ignoriert nichts und ist doch derselbe
|
||||
// Modifikator — die Ansicht bleibt dieselbe.
|
||||
.ignoresSafeArea(.container, edges: model.videoFit == .fill ? .all : [])
|
||||
.overlay {
|
||||
surface(for: model)
|
||||
.contentShape(.rect)
|
||||
@@ -341,6 +371,17 @@ struct PlayerView: View {
|
||||
.environment(settings)
|
||||
.environment(\.flaroAccent, accent)
|
||||
}
|
||||
// Eine neue Marke ist ein neues Angebot: sie steht wieder für sich,
|
||||
// bis die Bedienung das nächste Mal auftaucht.
|
||||
.onChange(of: model.activeMarker?.id, initial: true) { _, _ in
|
||||
markenGebunden = false
|
||||
}
|
||||
// **Der Übergang zählt, nicht der Zustand.** Beim Start liegt die
|
||||
// Bedienung ohnehin da; das als „einmal gezeigt" zu werten hiesse,
|
||||
// dem Intro seinen Knopf zu nehmen, bevor es ihn je hatte.
|
||||
.onChange(of: showsControls) { vorher, jetzt in
|
||||
if !vorher && jetzt { markenGebunden = true }
|
||||
}
|
||||
.onChange(of: showsQualityChoice) { _, isShown in
|
||||
// Nach dem Schließen die Bedienelemente stehen lassen und die
|
||||
// Ausblendfrist neu starten. Ohne das verschwänden sie sofort,
|
||||
@@ -594,7 +635,25 @@ struct PlayerView: View {
|
||||
}
|
||||
|
||||
private func transport(for model: PlayerModel) -> some View {
|
||||
HStack(spacing: FlaroSpacing.section * 2) {
|
||||
// **Enger, seit zwei Knöpfe dazukommen.** Bei fünf Knöpfen in einer
|
||||
// Reihe wird der alte Abstand auf einem Telefon im Hochformat zu
|
||||
// breit; bei drei bleibt er, wie er war.
|
||||
let folgen = model.previousEpisode != nil || model.nextEpisode != nil
|
||||
|
||||
return HStack(spacing: folgen ? FlaroSpacing.section : FlaroSpacing.section * 2) {
|
||||
// **Eine Folge zurück — nur bei Serien, nur wenn es eine gibt.**
|
||||
//
|
||||
// Bei einem Film bleibt die Reihe, wie sie war: ein Knopf, der
|
||||
// nie etwas tut, ist schlechter als keiner.
|
||||
if model.previousEpisode != nil {
|
||||
Button {
|
||||
if let wunsch = model.previousEpisodeRequest() { playback.play(wunsch) }
|
||||
} label: {
|
||||
Image(systemName: "backward.end.fill").font(.title2)
|
||||
}
|
||||
.accessibilityLabel(Text("player.previousEpisode"))
|
||||
}
|
||||
|
||||
Button {
|
||||
model.skip(by: .seconds(-settings.rewindSeconds))
|
||||
scheduleControlsHide()
|
||||
@@ -638,6 +697,15 @@ struct PlayerView: View {
|
||||
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
|
||||
}
|
||||
.accessibilityLabel(Text("player.forward10"))
|
||||
|
||||
if model.nextEpisode != nil {
|
||||
Button {
|
||||
if let wunsch = model.nextEpisodeRequest() { playback.play(wunsch) }
|
||||
} label: {
|
||||
Image(systemName: "forward.end.fill").font(.title2)
|
||||
}
|
||||
.accessibilityLabel(Text("player.nextEpisode"))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
@@ -662,7 +730,9 @@ struct PlayerView: View {
|
||||
// springen lässt das Bild flackern und hält bei großen
|
||||
// Dateien den Puffer dauerhaft leer.
|
||||
onCommit: { model.seek(to: $0) },
|
||||
onActivity: { scheduleControlsHide() }
|
||||
onActivity: { scheduleControlsHide() },
|
||||
vorschau: model.vorschau,
|
||||
blatt: { model.vorschaublatt($0) }
|
||||
)
|
||||
|
||||
HStack {
|
||||
@@ -683,12 +753,31 @@ struct PlayerView: View {
|
||||
|
||||
/// Überspringen-Knopf und „Nächste Folge".
|
||||
@ViewBuilder
|
||||
/// Ob jetzt „Nächste Folge" dransteht.
|
||||
///
|
||||
/// Am Ende immer, im Abspann auch — dort ist es die Antwort auf die
|
||||
/// Frage, die der Abspann stellt. Gibt es keine nächste Folge, bleibt
|
||||
/// es beim Überspringen.
|
||||
private func naechsteAnbieten(_ model: PlayerModel) -> Bool {
|
||||
guard model.nextEpisodeRequest() != nil else { return false }
|
||||
return model.hasReachedEnd || model.activeMarker?.isCredits == true
|
||||
}
|
||||
|
||||
private func overlayActions(for model: PlayerModel) -> some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
if model.hasReachedEnd, model.nextEpisodeRequest() != nil {
|
||||
// **Im Abspann ist „überspringen" die falsche Frage — aber
|
||||
// nicht die einzige.**
|
||||
//
|
||||
// Was man dort meistens will, ist die nächste Folge; ans
|
||||
// Ende dieser zu springen bringt niemanden weiter. Wer den
|
||||
// Abspann aber sehen wollte und es sich anders überlegt,
|
||||
// soll ihn trotzdem überspringen können. Zwei Antworten,
|
||||
// zwei Knöpfe — welche gemeint ist, weiss nur der
|
||||
// Zuschauer.
|
||||
if naechsteAnbieten(model) {
|
||||
Button {
|
||||
if let next = model.nextEpisodeRequest() {
|
||||
playback.play(next)
|
||||
@@ -701,7 +790,9 @@ struct PlayerView: View {
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
} else if let marker = model.activeMarker {
|
||||
}
|
||||
|
||||
if let marker = model.activeMarker {
|
||||
Button {
|
||||
model.skipActiveMarker()
|
||||
} label: {
|
||||
@@ -721,9 +812,28 @@ struct PlayerView: View {
|
||||
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
||||
// die Bedienelemente eingeblendet sind.
|
||||
.padding(.bottom, (showsControls ? 110 : FlaroSpacing.section) + safeArea.bottom)
|
||||
// **Am Ende bleibt es stehen, sonst nicht.**
|
||||
//
|
||||
// Ist der Film aus, ist der Knopf die einzige Frage, die noch
|
||||
// offen ist — der darf warten. Mitten in einer Marke ist er ein
|
||||
// Angebot, und ein Angebot, das nicht angenommen wird, nimmt
|
||||
// man zurück.
|
||||
.opacity(markenSichtbar(model) ? 1 : 0)
|
||||
.allowsHitTesting(markenSichtbar(model))
|
||||
}
|
||||
.animation(.snappy, value: model.activeMarker?.id)
|
||||
.animation(.snappy, value: model.hasReachedEnd)
|
||||
.animation(.easeIn(duration: 0.28), value: markenGebunden)
|
||||
}
|
||||
|
||||
/// **Dieselbe Regel für Vorspann und Abspann, Web wie App.**
|
||||
///
|
||||
/// Sichtbar, solange die Knöpfe noch für sich stehen; danach genau
|
||||
/// dann, wenn die Bedienung da ist. Am Ende des Films bleiben sie
|
||||
/// trotzdem: dort ist „Nächste Folge" die einzige noch offene Frage,
|
||||
/// und es gibt kein Bild mehr, das sie verdecken könnten.
|
||||
private func markenSichtbar(_ model: PlayerModel) -> Bool {
|
||||
model.hasReachedEnd || showsControls || !markenGebunden
|
||||
}
|
||||
|
||||
/// Die Abdeckung während des Umstellens.
|
||||
|
||||
@@ -48,7 +48,7 @@ struct SearchView: View {
|
||||
// weiß aber noch nicht, was er sehen will; der erste
|
||||
// Treffer ist eine Vermutung des Servers. Starten geht
|
||||
// weiter, über langes Drücken.
|
||||
MediaRow(row: hub, opensDetails: true)
|
||||
MediaRow(row: hub)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, FlaroSpacing.loose)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import MediaKit
|
||||
|
||||
/// „Welche Tonsprache gilt hier?" — an genau zwei Orten dieselbe Frage.
|
||||
/// „Welche Sprache gilt hier?" — an mehreren Orten dieselbe Frage.
|
||||
///
|
||||
/// **Warum ein gemeinsamer Typ.** Die Frage steht einmal auf der Serienseite
|
||||
/// und einmal bei der Mediathek, und die Antwort bedeutet beide Male dasselbe:
|
||||
/// diese Ebene setzt sich gegen die darüber durch, oder sie tut es nicht. Zwei
|
||||
/// Auswahlknöpfe mit demselben Inhalt liefen unweigerlich auseinander — einer
|
||||
/// bekäme eine Sprache dazu, der andere nicht.
|
||||
/// **Warum ein gemeinsamer Typ.** Die Frage steht auf der Serienseite, auf
|
||||
/// der Filmseite und bei der Mediathek, und die Antwort bedeutet jedes Mal
|
||||
/// dasselbe: diese Ebene setzt sich gegen die darüber durch, oder sie tut es
|
||||
/// nicht. Mehrere Auswahlknöpfe mit demselben Inhalt liefen unweigerlich
|
||||
/// auseinander — einer bekäme eine Sprache dazu, der andere nicht.
|
||||
///
|
||||
/// **Ton und Untertitel im selben Typ.** Sie unterscheiden sich in genau
|
||||
/// zwei Punkten: woher der Wert kommt, und ob es „keine" gibt. Ein zweiter
|
||||
/// Typ dafür wäre dieselbe Datei mit zwei geänderten Zeilen.
|
||||
///
|
||||
/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein
|
||||
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
|
||||
@@ -15,25 +20,98 @@ import FlaroDesign
|
||||
struct AudioLanguagePicker: View {
|
||||
/// Für welche Ebene gewählt wird.
|
||||
enum Scope {
|
||||
/// Eine Serie oder ein Film — beides eine Kennung, beides ein Titel.
|
||||
case series(String)
|
||||
case library(String)
|
||||
}
|
||||
|
||||
/// Ton oder Text.
|
||||
enum Kind {
|
||||
case audio
|
||||
case subtitles
|
||||
}
|
||||
|
||||
/// Wie der Knopf aussieht.
|
||||
enum Form {
|
||||
/// Eine Zeile: Beschriftung links, Wahl rechts. Für Listen.
|
||||
case zeile
|
||||
/// Ein Knopf wie „Ton: Deutsch". Für die Reihe neben „Abspielen".
|
||||
case knopf
|
||||
}
|
||||
|
||||
let scope: Scope
|
||||
let title: LocalizedStringKey
|
||||
var kind: Kind = .audio
|
||||
var form: Form = .zeile
|
||||
|
||||
/// Welche Sprachen es wirklich gibt — `nil` heisst: alle anbieten.
|
||||
///
|
||||
/// **Eine Liste mit zwanzig Sprachen für eine Serie mit zweien** ist
|
||||
/// keine Auswahl, sondern eine Suche. Weiss niemand, welche es gibt,
|
||||
/// bleibt es bei der vollen Liste: eine zu lange ist besser als eine
|
||||
/// leere.
|
||||
var available: [LanguageOption]?
|
||||
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.flaroAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
Text(title)
|
||||
.font(.flaroBody)
|
||||
switch form {
|
||||
case .zeile:
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
Text(title)
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
|
||||
Spacer(minLength: FlaroSpacing.regular)
|
||||
|
||||
menü { auswahl }
|
||||
}
|
||||
|
||||
case .knopf:
|
||||
// **Beschriftung und Wert in einem.** Neben „Abspielen" ist kein
|
||||
// Platz für eine Zeile mit Spalten; „Ton: Deutsch" sagt dasselbe
|
||||
// in einem Wort mehr.
|
||||
menü {
|
||||
HStack(spacing: FlaroSpacing.tight) {
|
||||
Image(systemName: kind == .audio ? "waveform" : "captions.bubble")
|
||||
Text("\(Text(title)): \(currentLabel)")
|
||||
.font(.flaroCardTitle)
|
||||
// **Eine Zeile, keine Säule.** Umbrochen wird die
|
||||
// Reihe, nicht der einzelne Knopf — sonst steht
|
||||
// „Untertitel" wieder Buchstabe für Buchstabe
|
||||
// untereinander.
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.flaroCaption)
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.regular)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
.background(Color.flaroElevated, in: Capsule())
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: FlaroSpacing.regular)
|
||||
/// Das Menü selbst — zweimal dieselben Einträge, zwei Aufhänger.
|
||||
private func menü(@ViewBuilder label: () -> some View) -> some View {
|
||||
Menu { auswahlEintraege } label: { label() }
|
||||
}
|
||||
|
||||
Menu {
|
||||
private var auswahl: some View {
|
||||
HStack(spacing: FlaroSpacing.tight) {
|
||||
Text(currentLabel)
|
||||
.font(.flaroBody)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.flaroCaption)
|
||||
}
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var auswahlEintraege: some View {
|
||||
Group {
|
||||
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
|
||||
// sollen nichts Eigenes sagen, sondern die Frage nach oben
|
||||
// durchreichen.
|
||||
@@ -43,42 +121,65 @@ struct AudioLanguagePicker: View {
|
||||
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(LanguagePreferences.offeredCodes, id: \.self) { code in
|
||||
// **„Keine" nur beim Untertitel.** Beim Ton gibt es sie
|
||||
// nicht: ein Film ohne Tonspur ist kein Wunsch, sondern ein
|
||||
// Fehler.
|
||||
if kind == .subtitles {
|
||||
Button {
|
||||
set(code)
|
||||
set(LanguagePreferences.noSubtitles)
|
||||
} label: {
|
||||
Label(name(of: code), systemImage: current == code ? "checkmark" : "")
|
||||
Label(
|
||||
"languages.none",
|
||||
systemImage: current == LanguagePreferences.noSubtitles
|
||||
? "checkmark" : ""
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: FlaroSpacing.tight) {
|
||||
Text(currentLabel)
|
||||
.font(.flaroBody)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.flaroCaption)
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(options, id: \.code) { option in
|
||||
Button {
|
||||
set(option.code)
|
||||
} label: {
|
||||
Label(
|
||||
option.name,
|
||||
systemImage: current == option.code ? "checkmark" : ""
|
||||
)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Was zur Wahl steht — das Vorhandene, sonst die allgemeine Liste.
|
||||
private var options: [LanguageOption] {
|
||||
available ?? LanguagePreferences.offeredCodes.map {
|
||||
LanguageOption(code: $0, name: name(of: $0))
|
||||
}
|
||||
}
|
||||
|
||||
private var current: String? {
|
||||
switch scope {
|
||||
case .series(let id): languages.audioLanguage(forSeries: id)
|
||||
case .library(let id): languages.audioLanguage(forLibrary: id)
|
||||
switch (scope, kind) {
|
||||
case (.series(let id), .audio): languages.audioLanguage(forSeries: id)
|
||||
case (.series(let id), .subtitles): languages.subtitleLanguage(forSeries: id)
|
||||
case (.library(let id), .audio): languages.audioLanguage(forLibrary: id)
|
||||
// Je Mediathek gibt es keine Untertitelsprache — siehe `subtitleWish`.
|
||||
case (.library, .subtitles): nil
|
||||
}
|
||||
}
|
||||
|
||||
private var currentLabel: String {
|
||||
current.map(name(of:)) ?? String(localized: "languages.inherit")
|
||||
guard let current else { return String(localized: "languages.inherit") }
|
||||
if current == LanguagePreferences.noSubtitles { return String(localized: "languages.none") }
|
||||
return options.first { $0.code == current }?.name ?? name(of: current)
|
||||
}
|
||||
|
||||
private func set(_ code: String?) {
|
||||
switch scope {
|
||||
case .series(let id): languages.setAudioLanguage(code, forSeries: id)
|
||||
case .library(let id): languages.setAudioLanguage(code, forLibrary: id)
|
||||
switch (scope, kind) {
|
||||
case (.series(let id), .audio): languages.setAudioLanguage(code, forSeries: id)
|
||||
case (.series(let id), .subtitles): languages.setSubtitleLanguage(code, forSeries: id)
|
||||
case (.library(let id), .audio): languages.setAudioLanguage(code, forLibrary: id)
|
||||
case (.library, .subtitles): break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ struct PlaybackSettingsView: View {
|
||||
Section {
|
||||
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
|
||||
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
|
||||
Toggle("settings.skipExtraScenes", isOn: $settings.skipExtraScenes)
|
||||
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
|
||||
} header: {
|
||||
Text("settings.episodes")
|
||||
|
||||
@@ -44,7 +44,7 @@ struct TVHeroSection: View {
|
||||
|
||||
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde.
|
||||
private var abspielTitel: LocalizedStringKey {
|
||||
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
||||
item.isResumable ? "hero.resume" : "hero.play"
|
||||
}
|
||||
|
||||
private var metadaten: String? {
|
||||
|
||||
@@ -55,7 +55,7 @@ struct TVMediaRow: View {
|
||||
artworkURL: session.backend?.artworkURL(
|
||||
path: item.rowArtworkPath, width: 880, height: 496
|
||||
),
|
||||
progress: item.watchProgress,
|
||||
progress: item.resumeProgress,
|
||||
onFocusChange: { if $0 { onFocus(item) } }
|
||||
) { onSelect(item) }
|
||||
} else {
|
||||
@@ -65,7 +65,7 @@ struct TVMediaRow: View {
|
||||
artworkURL: session.backend?.artworkURL(
|
||||
path: item.thumb, width: 560, height: 840
|
||||
),
|
||||
progress: item.watchProgress,
|
||||
progress: item.resumeProgress,
|
||||
onFocusChange: { if $0 { onFocus(item) } }
|
||||
) { onSelect(item) }
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ struct TVLibraryView: View {
|
||||
artworkURL: session.backend?.artworkURL(
|
||||
path: item.posterArtworkPath, width: 560, height: 840
|
||||
),
|
||||
progress: item.watchProgress
|
||||
progress: item.resumeProgress
|
||||
) {
|
||||
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
||||
// Raster eine Serie, hat die keine Fassungen; der
|
||||
|
||||
@@ -101,6 +101,19 @@ public actor ArtworkLoader {
|
||||
using session: URLSession,
|
||||
attempts: Int = 3
|
||||
) async -> PlatformImage? {
|
||||
// **Eine Datei auf der Platte ist kein Server.**
|
||||
//
|
||||
// Hier stand nur der Weg über HTTP, und die Antwort einer
|
||||
// `file:`-Adresse ist keine `HTTPURLResponse` — die Prüfung darunter
|
||||
// gab also `nil` zurück, immer. Sichtbar wurde das erst, als die
|
||||
// Downloads ihre Plakate mitbrachten: sie lagen auf der Platte, und
|
||||
// die Kacheln blieben trotzdem grau. Kein Wiederholen, kein
|
||||
// Zeitablauf: eine Datei ist entweder da oder nicht.
|
||||
if url.isFileURL {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return PlatformImage(data: data)
|
||||
}
|
||||
|
||||
for attempt in 1...attempts {
|
||||
if Task.isCancelled { return nil }
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Eine Reihe, die umbricht statt zu quetschen.
|
||||
///
|
||||
/// **Der Anlass.** Im Kopf einer Detailseite stehen Abspielen, Von vorn,
|
||||
/// Herunterladen und zwei Sprachknöpfe nebeneinander. In einem `HStack`
|
||||
/// teilen sie sich die Breite — auf einem Telefon wurde daraus eine Reihe
|
||||
/// von Säulen aus einzelnen Buchstaben, untereinander gesetzt. Lesbar war
|
||||
/// nichts mehr, und angetippt hätte man trotzdem das Richtige.
|
||||
///
|
||||
/// **Umbrechen ist die Antwort, nicht Weglassen.** Ein Knopf, der auf einem
|
||||
/// schmalen Gerät verschwindet, ist eine zweite Oberfläche, die gepflegt
|
||||
/// werden will. Zwei Zeilen sind kein Mangel; sie sind, was dort hinpasst.
|
||||
///
|
||||
/// **Warum eigener `Layout` und kein `ViewThatFits`.** Das wählt zwischen
|
||||
/// fertigen Entwürfen — man müsste jede Aufteilung von Hand hinschreiben,
|
||||
/// und bei fünf Knöpfen sind das fünf. Hier wird gerechnet.
|
||||
public struct Flusszeile: Layout {
|
||||
private let abstand: CGFloat
|
||||
private let zeilenabstand: CGFloat
|
||||
|
||||
public init(abstand: CGFloat = FlaroSpacing.regular, zeilenabstand: CGFloat? = nil) {
|
||||
self.abstand = abstand
|
||||
self.zeilenabstand = zeilenabstand ?? abstand
|
||||
}
|
||||
|
||||
public func sizeThatFits(
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout Void
|
||||
) -> CGSize {
|
||||
let breite = proposal.width ?? .infinity
|
||||
let zeilen = umbrechen(subviews, in: breite)
|
||||
|
||||
let hoehe = zeilen.reduce(CGFloat.zero) { $0 + $1.hoehe }
|
||||
+ CGFloat(max(0, zeilen.count - 1)) * zeilenabstand
|
||||
|
||||
// **Die breiteste Zeile, nicht die angebotene Breite.** Sonst nimmt
|
||||
// die Reihe das ganze Fenster ein, auch wenn zwei Knöpfe darin
|
||||
// stehen — und alles daneben rutscht weg.
|
||||
let genutzt = zeilen.map(\.breite).max() ?? 0
|
||||
return CGSize(width: min(genutzt, breite), height: hoehe)
|
||||
}
|
||||
|
||||
public func placeSubviews(
|
||||
in bounds: CGRect,
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout Void
|
||||
) {
|
||||
var y = bounds.minY
|
||||
|
||||
for zeile in umbrechen(subviews, in: bounds.width) {
|
||||
var x = bounds.minX
|
||||
|
||||
for index in zeile.teile {
|
||||
let groesse = subviews[index].sizeThatFits(.unspecified)
|
||||
subviews[index].place(
|
||||
at: CGPoint(x: x, y: y + (zeile.hoehe - groesse.height) / 2),
|
||||
proposal: ProposedViewSize(groesse)
|
||||
)
|
||||
x += groesse.width + abstand
|
||||
}
|
||||
|
||||
y += zeile.hoehe + zeilenabstand
|
||||
}
|
||||
}
|
||||
|
||||
private struct Zeile {
|
||||
var teile: [Int] = []
|
||||
var breite: CGFloat = 0
|
||||
var hoehe: CGFloat = 0
|
||||
}
|
||||
|
||||
private func umbrechen(_ subviews: Subviews, in breite: CGFloat) -> [Zeile] {
|
||||
var zeilen: [Zeile] = []
|
||||
var jetzt = Zeile()
|
||||
|
||||
for index in subviews.indices {
|
||||
let groesse = subviews[index].sizeThatFits(.unspecified)
|
||||
let dazu = jetzt.teile.isEmpty ? groesse.width : jetzt.breite + abstand + groesse.width
|
||||
|
||||
// **Das erste Element bleibt immer stehen.** Ein Knopf, der
|
||||
// allein schon breiter ist als die Zeile, käme sonst in eine
|
||||
// leere Zeile und von dort in die nächste, endlos.
|
||||
if !jetzt.teile.isEmpty, dazu > breite {
|
||||
zeilen.append(jetzt)
|
||||
jetzt = Zeile()
|
||||
jetzt.teile = [index]
|
||||
jetzt.breite = groesse.width
|
||||
jetzt.hoehe = groesse.height
|
||||
continue
|
||||
}
|
||||
|
||||
jetzt.teile.append(index)
|
||||
jetzt.breite = dazu
|
||||
jetzt.hoehe = max(jetzt.hoehe, groesse.height)
|
||||
}
|
||||
|
||||
if !jetzt.teile.isEmpty { zeilen.append(jetzt) }
|
||||
return zeilen
|
||||
}
|
||||
}
|
||||
@@ -291,7 +291,10 @@ public struct HeroBanner<Actions: View>: View {
|
||||
.frame(maxWidth: 640 * scale, alignment: .leading)
|
||||
}
|
||||
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
// **Umbrechen statt quetschen.** In einem `HStack` teilen sich
|
||||
// fünf Knöpfe die Breite; auf einem Telefon wurde daraus eine
|
||||
// Reihe von Säulen aus einzelnen Buchstaben.
|
||||
Flusszeile {
|
||||
actions
|
||||
}
|
||||
.padding(.top, FlaroSpacing.tight)
|
||||
|
||||
@@ -395,9 +395,16 @@ public final class DownloadCoordinator {
|
||||
forgetTransfer(item.ratingKey)
|
||||
fileStore.remove(fileName: item.localFileName)
|
||||
fileStore.remove(fileName: item.artworkFileName)
|
||||
let serie = item.seriesKey
|
||||
context.delete(item)
|
||||
save()
|
||||
reloadItems()
|
||||
|
||||
// **Das Serienplakat geht mit der letzten Folge.** Es hängt an
|
||||
// keinem Eintrag — ohne diese Zeile bliebe es für immer liegen.
|
||||
if let serie, !items.contains(where: { $0.seriesKey == serie }) {
|
||||
fileStore.remove(fileName: fileStore.artworkFileName(forRatingKey: serie))
|
||||
}
|
||||
refreshUsage()
|
||||
}
|
||||
|
||||
@@ -422,6 +429,58 @@ public final class DownloadCoordinator {
|
||||
return fileStore.url(forFileName: fileName)
|
||||
}
|
||||
|
||||
/// Das lokale Plakat eines Downloads — `nil`, wenn keines geholt wurde.
|
||||
///
|
||||
/// **Weil eine Reihe ohne Bilder keine Reihe ist.** Ohne Verbindung gibt
|
||||
/// es keine Adresse, von der ein Plakat nachzuladen wäre; was nicht beim
|
||||
/// Herunterladen mitkam, bleibt eine graue Fläche.
|
||||
/// **Nach der Datei gefragt, nicht nach dem Eintrag.** Hier wurde erst
|
||||
/// der Download gesucht und dann sein Dateiname gelesen — damit gab es
|
||||
/// nur Bilder zu Dingen, die selbst heruntergeladen wurden. Das Plakat
|
||||
/// einer **Serie** gehört aber keiner einzelnen Folge; es liegt unter
|
||||
/// dem Serienschlüssel, zu dem es keinen Eintrag gibt und nie geben
|
||||
/// wird.
|
||||
public func artworkURL(forRatingKey key: String) -> URL? {
|
||||
let name = fileStore.artworkFileName(forRatingKey: key)
|
||||
guard fileStore.exists(fileName: name) else { return nil }
|
||||
return fileStore.url(forFileName: name)
|
||||
}
|
||||
|
||||
/// Holt das Plakat zu einem Eintrag nach und legt es ab.
|
||||
///
|
||||
/// **Beim Anlegen, nicht erst beim Fertigwerden.** Die Liste zeigt den
|
||||
/// Eintrag, sobald er in der Warteschlange steht; bis dahin wäre sonst
|
||||
/// nichts zu sehen. Es sind ein paar Kilobyte.
|
||||
/// Der Schlüssel muss zu keinem Download gehören: unter dem Schlüssel
|
||||
/// einer Serie liegt ihr Plakat, obwohl die Serie selbst nie
|
||||
/// heruntergeladen wird.
|
||||
public func fetchArtwork(for ratingKey: String, from url: URL) async {
|
||||
let name = fileStore.artworkFileName(forRatingKey: ratingKey)
|
||||
guard !fileStore.exists(fileName: name) else {
|
||||
vermerken(name, bei: ratingKey)
|
||||
return
|
||||
}
|
||||
|
||||
guard let (daten, antwort) = try? await URLSession.shared.data(from: url),
|
||||
(antwort as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? true,
|
||||
!daten.isEmpty
|
||||
else { return }
|
||||
|
||||
guard fileStore.store(daten, as: name) != nil else { return }
|
||||
vermerken(name, bei: ratingKey)
|
||||
}
|
||||
|
||||
/// Hält am Eintrag fest, dass es ein Bild gibt — damit es beim Entfernen
|
||||
/// mitgeht. Zum Serienschlüssel gibt es keinen Eintrag; dann bleibt die
|
||||
/// Datei stehen, bis die letzte Folge der Serie geht.
|
||||
private func vermerken(_ name: String, bei ratingKey: String) {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
||||
item.artworkFileName != name
|
||||
else { return }
|
||||
item.artworkFileName = name
|
||||
save()
|
||||
}
|
||||
|
||||
public func offlineMedia(forRatingKey key: String) -> OfflineMedia? {
|
||||
guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil }
|
||||
return try? JSONDecoder().decode(OfflineMedia.self, from: data)
|
||||
@@ -458,6 +517,13 @@ public final class DownloadCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie oft je Eintrag schon auf ein Umpacken gewartet wurde.
|
||||
///
|
||||
/// **Im Arbeitsspeicher und nicht im Eintrag.** Nach einem Neustart
|
||||
/// beginnt das Zählen von vorn — und das ist richtig: der Server hat
|
||||
/// inzwischen vermutlich fertig gepackt.
|
||||
private var wartend: [String: Int] = [:]
|
||||
|
||||
private func start(_ item: DownloadItem) {
|
||||
guard let url = URL(string: item.sourceURLString) else {
|
||||
fail(item, message: "Ungültige Adresse")
|
||||
@@ -526,6 +592,7 @@ public final class DownloadCoordinator {
|
||||
}
|
||||
|
||||
func handleCompletion(ratingKey: String, temporaryFile: URL) {
|
||||
wartend[ratingKey] = nil
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
||||
let sourceURL = URL(string: item.sourceURLString)
|
||||
else { return }
|
||||
@@ -608,6 +675,49 @@ public final class DownloadCoordinator {
|
||||
}
|
||||
|
||||
/// Der Server hat die Anfrage abgelehnt.
|
||||
/// Wie oft auf ein Umpacken gewartet wird, bevor aufgegeben wird.
|
||||
///
|
||||
/// **Grosszügig, und das mit Absicht.** Ein Dreistünder in 4K braucht auf
|
||||
/// einem NAS Minuten; abzubrechen, während die Arbeit läuft, wäre die
|
||||
/// eine Antwort, die niemandem hilft. Zwanzig Versuche im Abstand von
|
||||
/// fünfzehn Sekunden sind fünf Minuten.
|
||||
private static let hoechstensWarten = 20
|
||||
private static let wartezeit = Duration.seconds(15)
|
||||
|
||||
/// Der Server packt noch um — später noch einmal fragen.
|
||||
///
|
||||
/// **Nicht sofort.** Ein Umpacken dauert Minuten, und im Sekundentakt
|
||||
/// nachzufragen hiesse, dieselbe Datei hundertmal anzufangen und wieder
|
||||
/// wegzuwerfen.
|
||||
func handleNotReady(ratingKey: String) {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||
|
||||
let versuche = (wartend[ratingKey] ?? 0) + 1
|
||||
wartend[ratingKey] = versuche
|
||||
|
||||
guard versuche <= Self.hoechstensWarten else {
|
||||
wartend[ratingKey] = nil
|
||||
fail(item, message: String(localized: "downloads.failed.notReady", bundle: .module))
|
||||
Task { await startNextIfPossible() }
|
||||
return
|
||||
}
|
||||
|
||||
// **Zurück in die Warteschlange, nicht in den Fehler.** Es ist
|
||||
// nichts schiefgegangen; es ist nur noch nicht fertig.
|
||||
item.state = .waiting
|
||||
save()
|
||||
|
||||
// **Nicht `resume`.** Das nimmt nur auf, was angehalten oder
|
||||
// gescheitert ist — ein wartender Eintrag lief dort in die
|
||||
// Schutzklausel und blieb für immer auf „wartet" stehen, während der
|
||||
// Server längst fertig gepackt hatte. Die Warteschlange abzuarbeiten
|
||||
// ist das, was hier gebraucht wird: `.waiting` steht darin.
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(for: Self.wartezeit)
|
||||
await self?.startNextIfPossible()
|
||||
}
|
||||
}
|
||||
|
||||
func handleServerRejection(ratingKey: String, status: Int) {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||
fail(item, message: String(localized: "downloads.failed.status \(status)", bundle: .module))
|
||||
@@ -740,6 +850,21 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch
|
||||
) {
|
||||
guard let key = downloadTask.taskDescription else { return }
|
||||
|
||||
// **202 heisst: noch nicht, komm wieder.**
|
||||
//
|
||||
// Der Server packt eine Datei um, die so nicht ausgeliefert werden
|
||||
// kann, und antwortet währenddessen mit 202 und einem kurzen Text.
|
||||
// Das ist ein Erfolgscode, also ging er durch die Prüfung darunter
|
||||
// glatt hindurch: 163 Byte Antworttext lagen danach als Film auf dem
|
||||
// Gerät, „in einer Sekunde heruntergeladen".
|
||||
if let response = downloadTask.response as? HTTPURLResponse,
|
||||
response.statusCode == 202 {
|
||||
Task { @MainActor [weak coordinator] in
|
||||
coordinator?.handleNotReady(ratingKey: key)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// `URLSessionDownloadTask` gilt auch bei 404 oder 500 als erfolgreich
|
||||
// und legt die Fehlerseite als Datei ab. Ohne diese Prüfung wurde eine
|
||||
// zwei Kilobyte große HTML-Seite als fertiger Film verbucht — in einer
|
||||
|
||||
@@ -58,6 +58,29 @@ public struct DownloadFileStore: Sendable {
|
||||
return "\(key).\(ext)"
|
||||
}
|
||||
|
||||
/// Der Name des Plakats zu einem Titel.
|
||||
///
|
||||
/// **Eine eigene Datei, kein Anhängsel am Film.** Sie ist ein paar
|
||||
/// Kilobyte gross und wird gebraucht, solange der Eintrag steht — auch
|
||||
/// während der Film noch lädt und noch gar keine Datei hat.
|
||||
public func artworkFileName(forRatingKey key: String) -> String { "\(key).bild" }
|
||||
|
||||
/// Legt ein geholtes Plakat ab. Schlägt es fehl, gibt es eben keines.
|
||||
///
|
||||
/// **Wirft nicht.** Ein Download ohne Bild ist ein Download; ein
|
||||
/// Download, der am Bild scheitert, wäre keiner.
|
||||
@discardableResult
|
||||
public func store(_ data: Data, as name: String) -> URL? {
|
||||
let ziel = url(forFileName: name)
|
||||
do {
|
||||
try createRootIfNeeded()
|
||||
try data.write(to: ziel, options: .atomic)
|
||||
return ziel
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz.
|
||||
///
|
||||
/// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"downloads.failed.notReady": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server packt diese Datei noch um. Versuch es in ein paar Minuten noch einmal."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server is still repackaging this file. Try again in a few minutes."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.failed.status %lld": {
|
||||
"comment": "Der Server hat die Download-Anfrage abgelehnt",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server refused the download with error %lld."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.failed.tooSmall %@": {
|
||||
"comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein",
|
||||
"localizations": {
|
||||
@@ -34,23 +68,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.failed.status %lld": {
|
||||
"comment": "Der Server hat die Download-Anfrage abgelehnt",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server refused the download with error %lld."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
|
||||
@@ -135,14 +135,14 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
||||
//
|
||||
// Serverneutral: wo kein Cookie liegt, ist die Liste leer und es
|
||||
// ändert sich nichts.
|
||||
let kekse = HTTPCookieStorage.shared.cookies(for: source.url) ?? []
|
||||
let cookies = HTTPCookieStorage.shared.cookies(for: source.url) ?? []
|
||||
|
||||
let asset = AVURLAsset(url: source.url, options: [
|
||||
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
|
||||
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
|
||||
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
|
||||
AVURLAssetPreferPreciseDurationAndTimingKey: false,
|
||||
AVURLAssetHTTPCookiesKey: kekse,
|
||||
AVURLAssetHTTPCookiesKey: cookies,
|
||||
])
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
|
||||
|
||||
@@ -80,6 +80,17 @@ public actor StreamCache {
|
||||
configuration.urlCache = nil
|
||||
configuration.httpMaximumConnectionsPerHost = 4
|
||||
configuration.timeoutIntervalForRequest = 30
|
||||
|
||||
// **Die Anmeldung muss mit.**
|
||||
//
|
||||
// Eine flüchtige Sitzung bringt ihren eigenen, leeren Cookie-Speicher
|
||||
// mit — jede Anfrage von hier ging also unangemeldet hinaus. Bei
|
||||
// Jellyfin fiel das nie auf: dort steht der Schlüssel in der Adresse.
|
||||
// Flaro meldet über ein Cookie an, und im Protokoll stand genau das:
|
||||
// `FlaroToken was challenged`, einmal je Wiedergabe, gefolgt von
|
||||
// zwölf Sekunden Stille und einer zweiten Bestellung.
|
||||
configuration.httpCookieStorage = HTTPCookieStorage.shared
|
||||
configuration.httpShouldSetCookies = true
|
||||
self.session = URLSession(configuration: configuration)
|
||||
|
||||
self.directory = FileManager.default.temporaryDirectory
|
||||
|
||||
@@ -54,13 +54,12 @@ public actor FlaroBackend: MediaBackend {
|
||||
}
|
||||
|
||||
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
|
||||
// **Vier Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der eine
|
||||
// fertige Startseite liefert, und das ist Absicht: welche Reihen eine
|
||||
// App zeigt, entscheidet die App. Der Server liefert die Zutaten.
|
||||
// **Mehrere Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der
|
||||
// eine fertige Startseite liefert, und das ist Absicht: welche Reihen
|
||||
// eine App zeigt, entscheidet die App. Der Server liefert die Zutaten.
|
||||
async let weiter = reihe(pfad: "/api/v1/home/continue")
|
||||
async let naechste = reihe(pfad: "/api/v1/home/next")
|
||||
async let filme = seite(pfad: "/api/v1/movies", take: 24, as: Wire.Movie.self)
|
||||
async let serien = seite(pfad: "/api/v1/series", take: 24, as: Wire.Series.self)
|
||||
let mediatheken = (try? await libraries()) ?? []
|
||||
|
||||
var reihen: [MediaRowGroup] = []
|
||||
|
||||
@@ -76,21 +75,90 @@ public actor FlaroBackend: MediaBackend {
|
||||
title: String(localized: "flaro.row.nextUp", bundle: .module),
|
||||
items: eintraege))
|
||||
}
|
||||
if let gefunden = try? await filme, !gefunden.items.isEmpty {
|
||||
reihen.append(MediaRowGroup(
|
||||
id: "flaro.movies",
|
||||
title: String(localized: "flaro.row.movies", bundle: .module),
|
||||
items: gefunden.items.map { $0.neutral() }))
|
||||
}
|
||||
if let gefunden = try? await serien, !gefunden.items.isEmpty {
|
||||
reihen.append(MediaRowGroup(
|
||||
id: "flaro.series",
|
||||
title: String(localized: "flaro.row.series", bundle: .module),
|
||||
items: gefunden.items.map { $0.neutral() }))
|
||||
|
||||
// **Je Mediathek eine Reihe mit dem zuletzt Hinzugefügten.**
|
||||
//
|
||||
// Vorher standen hier zwei Reihen, „Filme" und „Serien", mit dem
|
||||
// Anfang der Mediathek in der Reihenfolge der Datenbank — also immer
|
||||
// demselben. Drei Dinge waren damit falsch: es war kein Neuzugang,
|
||||
// es warf getrennte Mediatheken wieder zusammen, und die Kennungen
|
||||
// begannen nicht mit `latest`. An dieser Vorsilbe hängen zwei
|
||||
// Einstellungen — „Gesehenes in Neuzugängen ausblenden" und
|
||||
// „Aufmacher nur aus Neuzugängen" —, und beide taten an einem
|
||||
// Flaro-Server deshalb nichts.
|
||||
// **Nebeneinander, nicht nacheinander.**
|
||||
//
|
||||
// Hier stand eine Schleife mit `await` darin: bei vier Mediatheken
|
||||
// vier Rundreisen hintereinander, jede erst nach der vorigen. Die
|
||||
// Weboberfläche holt dieselben Reihen in einem `Promise.all` — und
|
||||
// sie holt sogar mehr davon. Genau das war der Unterschied, den man
|
||||
// der App ansah.
|
||||
//
|
||||
// Ein Actor steht dem nicht im Weg: jede Anfrage gibt ihn beim
|
||||
// Warten wieder frei, so wie die beiden darüber es schon tun.
|
||||
let neuzugaenge = await withTaskGroup(of: (Int, MediaRowGroup?).self) { gruppe in
|
||||
for (platz, mediathek) in mediatheken.enumerated() {
|
||||
gruppe.addTask { (platz, await self.neuzugang(in: mediathek)) }
|
||||
}
|
||||
|
||||
// **Die Reihenfolge der Mediatheken, nicht die der Antworten.**
|
||||
// Sonst stünden die Reihen nach jedem Start woanders — je
|
||||
// nachdem, welche Abfrage zuerst zurückkam.
|
||||
var gesammelt: [(Int, MediaRowGroup?)] = []
|
||||
for await ergebnis in gruppe { gesammelt.append(ergebnis) }
|
||||
return gesammelt.sorted { $0.0 < $1.0 }.compactMap(\.1)
|
||||
}
|
||||
|
||||
reihen.append(contentsOf: neuzugaenge)
|
||||
return reihen
|
||||
}
|
||||
|
||||
/// Die Reihe „Zuletzt hinzugefügt" einer Mediathek — oder nichts.
|
||||
///
|
||||
/// **Ein Fehler bei einer Mediathek nimmt nicht die anderen mit.** Die
|
||||
/// Startseite mit drei von vier Reihen ist besser als gar keine.
|
||||
private func neuzugang(in mediathek: MediaLibrary) async -> MediaRowGroup? {
|
||||
let pfad = mediathek.kind == .show ? "/api/v1/series" : "/api/v1/movies"
|
||||
let abfrage = [
|
||||
URLQueryItem(name: "library", value: mediathek.id),
|
||||
URLQueryItem(name: "sort", value: "added"),
|
||||
URLQueryItem(name: "take", value: "24"),
|
||||
]
|
||||
|
||||
let titel: [MediaItem]
|
||||
if mediathek.kind == .show {
|
||||
guard let seite = try? await client.send(
|
||||
FlaroRequest(path: pfad, query: abfrage),
|
||||
as: Wire.Page<Wire.Series>.self) else { return nil }
|
||||
titel = seite.items.map { $0.neutral() }
|
||||
} else {
|
||||
guard let seite = try? await client.send(
|
||||
FlaroRequest(path: pfad, query: abfrage),
|
||||
as: Wire.Page<Wire.Movie>.self) else { return nil }
|
||||
titel = seite.items.map { $0.neutral() }
|
||||
}
|
||||
|
||||
guard !titel.isEmpty else { return nil }
|
||||
|
||||
return MediaRowGroup(
|
||||
id: "latest.\(mediathek.id)",
|
||||
title: String(localized: "flaro.row.latestIn \(mediathek.title)", bundle: .module),
|
||||
libraryID: mediathek.id,
|
||||
items: titel)
|
||||
}
|
||||
|
||||
public func availableLanguages(inSeries id: String) async -> AvailableLanguages? {
|
||||
guard let roh = try? await client.send(
|
||||
FlaroRequest(path: "/api/v1/series/\(id)/languages"),
|
||||
as: Wire.Sprachauswahl.self)
|
||||
else { return nil }
|
||||
|
||||
return AvailableLanguages(
|
||||
audio: roh.audio.map { LanguageOption(code: $0.code, name: $0.name) },
|
||||
subtitles: roh.subtitles.map { LanguageOption(code: $0.code, name: $0.name) },
|
||||
partial: roh.partial)
|
||||
}
|
||||
|
||||
public func items(
|
||||
inLibrary library: MediaLibrary,
|
||||
query: MediaQuery,
|
||||
@@ -135,6 +203,21 @@ public actor FlaroBackend: MediaBackend {
|
||||
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
|
||||
return serie.neutral()
|
||||
}
|
||||
// **Und zuletzt eine Folge.**
|
||||
//
|
||||
// Sie fehlte, und das war der Grund für „Dieser Titel ist auf dem
|
||||
// Server nicht mehr vorhanden" direkt von der Startseite: in
|
||||
// „Weiterschauen" steht eine Folge mit ihrer eigenen Kennung, und die
|
||||
// ist weder ein Film noch eine Serie.
|
||||
if let folge = try? await client.send(
|
||||
FlaroRequest(path: "/api/v1/episodes/\(id)"), as: Wire.Episode.self) {
|
||||
var serie: Wire.Series?
|
||||
if let kennung = folge.seriesId {
|
||||
serie = try? await client.send(
|
||||
FlaroRequest(path: "/api/v1/series/\(kennung)"), as: Wire.Series.self)
|
||||
}
|
||||
return folge.neutral(series: serie)
|
||||
}
|
||||
throw .notFound
|
||||
}
|
||||
|
||||
@@ -156,11 +239,29 @@ public actor FlaroBackend: MediaBackend {
|
||||
}
|
||||
|
||||
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||
try await nachbar(von: item, weg: "next")
|
||||
}
|
||||
|
||||
public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||
try await nachbar(von: item, weg: "previous")
|
||||
}
|
||||
|
||||
/// Die Folge vor oder nach dieser.
|
||||
///
|
||||
/// 204 heisst: es gibt keine. Kein Fehler, nur das Ende der Serie —
|
||||
/// oder ihr Anfang.
|
||||
private func nachbar(von item: MediaItem, weg: String) async throws(MediaBackendError) -> MediaItem? {
|
||||
guard item.kind == .episode else { return nil }
|
||||
// 204 heisst: es war die letzte. Kein Fehler, nur kein Nachfolger.
|
||||
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/next"))
|
||||
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/\(weg)"))
|
||||
guard !daten.isEmpty else { return nil }
|
||||
guard let folge = try? JSONDecoder().decode(Wire.Episode.self, from: daten) else { return nil }
|
||||
// **Der eingerichtete Dekoder, nicht ein frischer.** Ein frischer
|
||||
// kennt das Datumsformat des Servers nicht; sobald die Nachbarfolge
|
||||
// ein `publishAt` oder ein `lastPlayedAt` mitbringt — und mit
|
||||
// eingeschalteter Prüfung bringt sie es —, scheiterte das Lesen.
|
||||
// Das `try?` schluckte den Fehler, und von aussen sah es aus, als
|
||||
// gäbe es keine nächste Folge: der Knopf blieb weg.
|
||||
guard let folge = try? FlaroClient.decoder.decode(
|
||||
Wire.Episode.self, from: daten) else { return nil }
|
||||
return folge.neutral()
|
||||
}
|
||||
|
||||
@@ -207,6 +308,32 @@ public actor FlaroBackend: MediaBackend {
|
||||
/// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein
|
||||
/// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet
|
||||
/// Rechenzeit, die auf einem NAS knapper ist.
|
||||
/// Wie die Vorschaubilder einer Datei liegen.
|
||||
///
|
||||
/// **Der Server erzeugt sie beim Abspielen.** Steht noch nichts da,
|
||||
/// antwortet er mit `fertig: false` — dann gibt es hier nichts, und
|
||||
/// der Spieler fragt später noch einmal.
|
||||
public func vorschauplan(fileId: String) async -> Vorschauplan? {
|
||||
guard let auskunft = try? await client.send(
|
||||
FlaroRequest(path: "/api/v1/files/\(fileId)/vorschau"),
|
||||
as: Wire.Vorschau.self), auskunft.fertig
|
||||
else { return nil }
|
||||
|
||||
return Vorschauplan(
|
||||
intervall: auskunft.intervall,
|
||||
spalten: auskunft.spalten,
|
||||
zeilen: auskunft.zeilen,
|
||||
kachelBreite: Double(auskunft.kachelBreite),
|
||||
kachelHoehe: Double(auskunft.kachelHoehe),
|
||||
blaetter: auskunft.blaetter)
|
||||
}
|
||||
|
||||
/// **Ohne Kopfzeilen, wie bei den Plakaten.** Die Anmeldung hängt am
|
||||
/// Cookie; der Bildlader holt die Adresse roh und kommt damit durch.
|
||||
public nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL? {
|
||||
client.url(path: "/api/v1/files/\(fileId)/vorschau/\(blatt).jpg")
|
||||
}
|
||||
|
||||
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
||||
rawImageURL(path: path)
|
||||
}
|
||||
@@ -260,6 +387,50 @@ public actor FlaroBackend: MediaBackend {
|
||||
|
||||
public func preparePlayback(
|
||||
_ intent: PlaybackIntent
|
||||
) async throws(MediaBackendError) -> PlaybackTarget {
|
||||
// **Erst der Strom, und nur bei dessen Scheitern die fertige Datei.**
|
||||
//
|
||||
// Hier stand ausschliesslich der zweite Weg: `/download`, und dann
|
||||
// im Sekundentakt nachfragen, bis die ganze MP4 auf der Platte liegt.
|
||||
// Gemessen an einem Spielfilm waren das dreissig Sekunden Schwarz,
|
||||
// während die Weboberfläche nach einer Sekunde lief — die holt sich
|
||||
// eine HLS-Sitzung, und deren erstes Stück ist beim Umpacken in
|
||||
// deutlich unter einer Sekunde da.
|
||||
if let strom = try? await client.send(
|
||||
.json(.post, "/api/v1/files/\(intent.file.id)/stream", Wire.StreamRequest(
|
||||
height: intent.transcode?.resolution.flatMap(Self.hoehe),
|
||||
// Ohne diese Angabe wählt der Server, und bei einem
|
||||
// umgerechneten Strom kommt danach nur noch eine Spur an.
|
||||
audioStream: intent.audioStreamIndex,
|
||||
startSeconds: max(0, intent.startTime.seconds),
|
||||
// Nur wenn der Spieler es ausdrücklich verlangt: das Profil
|
||||
// entscheidet der Server, und er entscheidet es besser.
|
||||
transcode: intent.transcode?.prefersRemux == false
|
||||
&& intent.transcode?.maximumBitrate != nil)),
|
||||
as: Wire.Stromauskunft.self),
|
||||
|
||||
// **Die Hauptliste, wo es sie gibt.** Nur dort stehen die
|
||||
// Textspuren; die Medienliste trägt bloss Bild und Ton.
|
||||
let adresse = client.url(path: strom.masterUrl ?? strom.url)
|
||||
{
|
||||
return PlaybackTarget(
|
||||
url: adresse,
|
||||
// Eine Direktwiedergabe legt nichts an, das aufzuräumen wäre.
|
||||
sessionID: strom.method == "direct" ? nil : strom.sessionId,
|
||||
versionID: intent.file.id,
|
||||
startOffset: .seconds(strom.startSeconds))
|
||||
}
|
||||
|
||||
return try await mitgenommeneDatei(intent)
|
||||
}
|
||||
|
||||
/// Der alte Weg: warten, bis die fertige Datei auf der Platte liegt.
|
||||
///
|
||||
/// **Bleibt als Rückfall.** Er ist langsam, aber er kommt ohne
|
||||
/// Stromsitzung aus — und wenn die nicht zustande kommt, ist eine späte
|
||||
/// Wiedergabe besser als keine.
|
||||
private func mitgenommeneDatei(
|
||||
_ intent: PlaybackIntent
|
||||
) async throws(MediaBackendError) -> PlaybackTarget {
|
||||
guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else {
|
||||
throw .transport(description: "Adresse nicht baubar")
|
||||
@@ -276,8 +447,6 @@ public actor FlaroBackend: MediaBackend {
|
||||
// Muss der Server umpacken, antwortet er auf den Download mit 202 und
|
||||
// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere
|
||||
// Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner.
|
||||
// Genau so sah es beim ersten Lauf im Simulator aus: „Stelle wird
|
||||
// wiederhergestellt", 0:00, für immer.
|
||||
//
|
||||
// Das Anstossen besorgt der Server beim ersten Abruf selbst; hier
|
||||
// wird nur nachgefragt, bis es so weit ist.
|
||||
@@ -305,16 +474,27 @@ public actor FlaroBackend: MediaBackend {
|
||||
}
|
||||
}
|
||||
|
||||
// **Keine Sitzung.** Flaro legt für eine Wiedergabe nichts an, das
|
||||
// hinterher aufzuräumen wäre: die Datei geht unverändert hinaus oder
|
||||
// liegt umgepackt im Zwischenspeicher. Es gibt deshalb nichts, was
|
||||
// weiterrechnet, wenn niemand mehr zusieht.
|
||||
return PlaybackTarget(url: adresse, sessionID: nil, versionID: intent.file.id)
|
||||
}
|
||||
|
||||
public func endPlayback(sessionID: String) async {}
|
||||
/// „1080p" wird zu 1080 — der Server kennt nur Zahlen.
|
||||
private static func hoehe(_ aufloesung: String) -> Int? {
|
||||
Int(aufloesung.filter(\.isNumber))
|
||||
}
|
||||
|
||||
public func reportProgress(_ report: PlaybackReport) async {
|
||||
/// Schliesst die Sitzung, damit nichts weiterrechnet.
|
||||
///
|
||||
/// **Das war eine leere Methode**, und sie passte zu einer Zeit, in der
|
||||
/// es gar keine Sitzung gab: eine fertige Datei auf der Platte rechnet
|
||||
/// nicht weiter. Mit dem Strom gibt es wieder einen Prozess, der ohne
|
||||
/// diesen Aufruf den ganzen Film zu Ende schreibt, während längst
|
||||
/// niemand mehr zusieht.
|
||||
public func endPlayback(sessionID: String) async {
|
||||
_ = try? await client.send(FlaroRequest(
|
||||
method: .delete, path: "/api/v1/stream/\(sessionID)"))
|
||||
}
|
||||
|
||||
public func reportProgress(_ report: PlaybackReport) async -> Bool {
|
||||
// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
||||
// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
||||
// wäre die schlechtere Hälfte des Tauschs.
|
||||
@@ -322,10 +502,15 @@ public actor FlaroBackend: MediaBackend {
|
||||
// Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen
|
||||
// Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden
|
||||
// hiesse, den ersten Bericht wegzuwerfen.
|
||||
_ = try? await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
|
||||
Wire.ProgressRequest(
|
||||
positionSeconds: report.time.seconds,
|
||||
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
|
||||
do {
|
||||
try await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
|
||||
Wire.ProgressRequest(
|
||||
positionSeconds: report.time.seconds,
|
||||
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Zustand melden
|
||||
@@ -401,17 +586,26 @@ public struct FlaroAccount: Sendable, Equatable {
|
||||
public let userName: String
|
||||
public let isAdministrator: Bool
|
||||
|
||||
/// Das eigene Bild, als Schlüssel in die Bildablage — `nil`: der Buchstabe.
|
||||
///
|
||||
/// **Mit Vorgabewert, damit Jellyfin unberührt bleibt.** Dort gibt es
|
||||
/// kein Profilbild, und ein Pflichtfeld hier zwänge jeden Aufrufer, sich
|
||||
/// dazu zu äussern.
|
||||
public let avatarImage: String?
|
||||
|
||||
public init(
|
||||
baseURL: URL,
|
||||
serverName: String,
|
||||
userID: String,
|
||||
userName: String,
|
||||
isAdministrator: Bool
|
||||
isAdministrator: Bool,
|
||||
avatarImage: String? = nil
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.serverName = serverName
|
||||
self.userID = userID
|
||||
self.userName = userName
|
||||
self.isAdministrator = isAdministrator
|
||||
self.avatarImage = avatarImage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ enum Wire {
|
||||
let id: String
|
||||
let userName: String
|
||||
let isAdministrator: Bool
|
||||
/// Das Profilbild, als Schlüssel in die Bildablage des Servers.
|
||||
let avatarImage: String?
|
||||
}
|
||||
|
||||
struct Library: Decodable, Sendable {
|
||||
@@ -78,6 +80,12 @@ enum Wire {
|
||||
let rating: Int?
|
||||
let dismissed: Bool
|
||||
let lastPlayedAt: Date?
|
||||
/// Ob der Server den Titel in „Weiterschauen" führt.
|
||||
///
|
||||
/// **Nicht dasselbe wie „Stelle über null".** Seit es eine
|
||||
/// einstellbare Untergrenze gibt, sind das zwei Fragen: eine Sekunde
|
||||
/// wird gemerkt, zählt aber nicht als angefangen.
|
||||
let inProgress: Bool?
|
||||
}
|
||||
|
||||
struct Movie: Decodable, Sendable {
|
||||
@@ -87,8 +95,14 @@ enum Wire {
|
||||
let overview: String?
|
||||
let posterImage: String?
|
||||
let backdropImage: String?
|
||||
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||
let heroImage: String?
|
||||
let files: [File]
|
||||
let state: ItemState?
|
||||
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
|
||||
let publishAt: Date?
|
||||
/// Der Abspann, sofern er im Bild gefunden wurde.
|
||||
let markers: [Marker]?
|
||||
}
|
||||
|
||||
struct Series: Decodable, Sendable {
|
||||
@@ -98,9 +112,12 @@ enum Wire {
|
||||
let overview: String?
|
||||
let posterImage: String?
|
||||
let backdropImage: String?
|
||||
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||
let heroImage: String?
|
||||
let episodeCount: Int
|
||||
let seasons: [Int]
|
||||
let unwatchedCount: Int
|
||||
let publishAt: Date?
|
||||
}
|
||||
|
||||
struct Episode: Decodable, Sendable {
|
||||
@@ -112,6 +129,24 @@ enum Wire {
|
||||
let stillImage: String?
|
||||
let files: [File]
|
||||
let state: ItemState?
|
||||
let publishAt: Date?
|
||||
/// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich
|
||||
/// nicht darstellbar: Titel und Bild der Serie fehlten.
|
||||
let seriesId: String?
|
||||
/// Vorspann und Abspann, sofern der Server sie gefunden hat.
|
||||
///
|
||||
/// Fehlt bei älteren Servern ganz — deshalb optional und nicht eine
|
||||
/// leere Liste: ein `[]` im Vertrag zwänge jeden Server, das Feld zu
|
||||
/// schicken.
|
||||
let markers: [Marker]?
|
||||
}
|
||||
|
||||
/// Ein überspringbarer Abschnitt.
|
||||
struct Marker: Decodable, Sendable {
|
||||
/// „intro" oder „credits" — dieselben Wörter wie bei Jellyfin.
|
||||
let kind: String
|
||||
let startSeconds: Double
|
||||
let endSeconds: Double
|
||||
}
|
||||
|
||||
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
|
||||
@@ -125,7 +160,15 @@ enum Wire {
|
||||
let year: Int?
|
||||
let posterImage: String?
|
||||
let backdropImage: String?
|
||||
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||
let heroImage: String?
|
||||
let state: ItemState
|
||||
/// Zu welcher Serie die Folge gehört.
|
||||
let seriesId: String?
|
||||
/// Das breite Bild der Serie — für den Aufmacher, nicht für die
|
||||
/// Kachel: die will das Standbild der Folge.
|
||||
let seriesBackdropImage: String?
|
||||
let publishAt: Date?
|
||||
}
|
||||
|
||||
struct CodeResponse: Decodable, Sendable {
|
||||
@@ -157,6 +200,51 @@ enum Wire {
|
||||
|
||||
// --- Was hinausgeht --------------------------------------------------
|
||||
|
||||
/// Was das Gerät kann und was gewünscht ist.
|
||||
///
|
||||
/// **Ohne Codec-Angaben nimmt der Server Apples Profil an**, und das ist
|
||||
/// hier genau richtig: dieses Gerät *ist* Apple. Eine eigene Liste wäre
|
||||
/// eine zweite Wahrheit neben der, die der Server ohnehin pflegt.
|
||||
struct StreamRequest: Encodable, Sendable {
|
||||
var height: Int?
|
||||
var audioStream: Int?
|
||||
var startSeconds: Double
|
||||
var transcode: Bool
|
||||
}
|
||||
|
||||
struct Stromspur: Decodable, Sendable {
|
||||
let index: Int
|
||||
let label: String
|
||||
let language: String?
|
||||
let isDefault: Bool
|
||||
}
|
||||
|
||||
struct Stromauskunft: Decodable, Sendable {
|
||||
let sessionId: String
|
||||
let url: String
|
||||
/// Dieselbe Wiedergabe mit Untertitelspuren — fehlt bei Direktwiedergabe.
|
||||
let masterUrl: String?
|
||||
/// „direct", „remux" oder „transcode".
|
||||
let method: String
|
||||
/// Ab welcher Sekunde des Films dieser Strom beginnt.
|
||||
let startSeconds: Double
|
||||
let height: Int?
|
||||
let audioStream: Int?
|
||||
let audioStreams: [Stromspur]
|
||||
let subtitleStreams: [Stromspur]
|
||||
}
|
||||
|
||||
struct Sprachangabe: Decodable, Sendable {
|
||||
let code: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct Sprachauswahl: Decodable, Sendable {
|
||||
let audio: [Sprachangabe]
|
||||
let subtitles: [Sprachangabe]
|
||||
let partial: Int
|
||||
}
|
||||
|
||||
struct LoginRequest: Encodable, Sendable {
|
||||
let userName: String
|
||||
let password: String
|
||||
@@ -173,3 +261,16 @@ enum Wire {
|
||||
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
|
||||
struct RatingRequest: Encodable, Sendable { let rating: Int? }
|
||||
}
|
||||
|
||||
extension Wire {
|
||||
/// Was der Server über die Vorschaubilder sagt.
|
||||
struct Vorschau: Decodable, Sendable {
|
||||
let fertig: Bool
|
||||
let intervall: Double
|
||||
let spalten: Int
|
||||
let zeilen: Int
|
||||
let kachelBreite: Int
|
||||
let kachelHoehe: Int
|
||||
let blaetter: Int
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,23 @@ extension Wire.Movie {
|
||||
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
||||
thumb: posterImage,
|
||||
art: backdropImage,
|
||||
heroArt: heroImage,
|
||||
playCount: state?.playCount,
|
||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||
lastPlayedAt: state?.lastPlayedAt,
|
||||
isFavourite: state?.favourite ?? false,
|
||||
versions: [Wire.fassung(id: id, files: files)]
|
||||
isInProgress: state?.inProgress,
|
||||
availableFrom: publishAt,
|
||||
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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -35,10 +47,12 @@ extension Wire.Series {
|
||||
year: year,
|
||||
thumb: posterImage,
|
||||
art: backdropImage,
|
||||
heroArt: heroImage,
|
||||
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
|
||||
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
||||
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
||||
// zu derselben Aussage zu haben.
|
||||
availableFrom: publishAt,
|
||||
episodeCount: episodeCount,
|
||||
playedEpisodeCount: episodeCount - unwatchedCount,
|
||||
childCount: seasons.count
|
||||
@@ -60,12 +74,25 @@ extension Wire.Episode {
|
||||
index: episode,
|
||||
parentIndex: season,
|
||||
seriesTitle: series?.title,
|
||||
seriesID: series?.id,
|
||||
// Die Serie aus dem Vertrag, wenn sie nicht mitgegeben wurde:
|
||||
// ohne sie führte der Weg zurück zur Serie ins Leere.
|
||||
seriesID: series?.id ?? seriesId,
|
||||
playCount: state?.playCount,
|
||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||
lastPlayedAt: state?.lastPlayedAt,
|
||||
isFavourite: state?.favourite ?? false,
|
||||
versions: [Wire.fassung(id: id, files: files)]
|
||||
isInProgress: state?.inProgress,
|
||||
availableFrom: publishAt,
|
||||
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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -85,15 +112,23 @@ extension Wire.Started {
|
||||
year: year,
|
||||
duration: state.runtimeSeconds.map { Int($0 * 1000) },
|
||||
thumb: folge ? backdropImage : posterImage,
|
||||
art: backdropImage,
|
||||
// **Für die Fläche über den halben Bildschirm das Bild der
|
||||
// Serie.** `backdropImage` ist bei einer Folge ihr Standbild —
|
||||
// richtig für die Kachel, falsch für den Aufmacher: wer der
|
||||
// Serie ein Bild ausgesucht hatte, fand dort ein anderes vor.
|
||||
art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage,
|
||||
seriesThumb: folge ? posterImage : nil,
|
||||
heroArt: heroImage,
|
||||
index: episode,
|
||||
parentIndex: season,
|
||||
seriesTitle: folge ? title : nil,
|
||||
seriesID: folge ? seriesId : nil,
|
||||
playCount: state.playCount,
|
||||
resumeMilliseconds: Int(state.positionSeconds * 1000),
|
||||
lastPlayedAt: state.lastPlayedAt,
|
||||
isFavourite: state.favourite
|
||||
isFavourite: state.favourite,
|
||||
isInProgress: state.inProgress,
|
||||
availableFrom: publishAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,8 @@ public enum FlaroSignIn {
|
||||
serverName: gruss.serverName,
|
||||
userID: antwort.user.id,
|
||||
userName: antwort.user.userName,
|
||||
isAdministrator: antwort.user.isAdministrator), client)
|
||||
isAdministrator: antwort.user.isAdministrator,
|
||||
avatarImage: antwort.user.avatarImage), client)
|
||||
}
|
||||
|
||||
/// Nimmt ein gespeichertes Token wieder auf.
|
||||
@@ -111,7 +112,8 @@ public enum FlaroSignIn {
|
||||
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||
userID: wer.id,
|
||||
userName: wer.userName,
|
||||
isAdministrator: wer.isAdministrator), client)
|
||||
isAdministrator: wer.isAdministrator,
|
||||
avatarImage: wer.avatarImage), client)
|
||||
}
|
||||
|
||||
// MARK: - Der Weg über einen Code
|
||||
@@ -179,7 +181,8 @@ public enum FlaroSignIn {
|
||||
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||
userID: anmeldung.user.id,
|
||||
userName: anmeldung.user.userName,
|
||||
isAdministrator: anmeldung.user.isAdministrator)), client)
|
||||
isAdministrator: anmeldung.user.isAdministrator,
|
||||
avatarImage: anmeldung.user.avatarImage)), client)
|
||||
case "expired":
|
||||
return (.expired, nil)
|
||||
default:
|
||||
|
||||
@@ -35,6 +35,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"flaro.row.latestIn %@": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Neu in %@"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "New in %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"flaro.row.movies": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
||||
@@ -106,7 +106,7 @@ extension JellyfinBackend {
|
||||
))
|
||||
}
|
||||
|
||||
public func reportProgress(_ report: PlaybackReport) async {
|
||||
public func reportProgress(_ report: PlaybackReport) async -> Bool {
|
||||
var fields: [String: Any] = [
|
||||
"ItemId": report.itemID,
|
||||
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
|
||||
@@ -126,13 +126,18 @@ extension JellyfinBackend {
|
||||
default: "/Sessions/Playing/Progress"
|
||||
}
|
||||
|
||||
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return }
|
||||
_ = try? await client.send(JellyfinRequest(
|
||||
method: .post,
|
||||
path: path,
|
||||
body: body,
|
||||
timeout: .seconds(10)
|
||||
))
|
||||
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return false }
|
||||
do {
|
||||
try await client.send(JellyfinRequest(
|
||||
method: .post,
|
||||
path: path,
|
||||
body: body,
|
||||
timeout: .seconds(10)
|
||||
))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hilfen
|
||||
|
||||
@@ -130,19 +130,34 @@ public func isInLocalNetwork() async -> Bool {
|
||||
// sind zwei verschiedene Fragen — in einer gemeinsamen Reihe steht am
|
||||
// Ende beides durcheinander, und wer nach einem Film sucht, blättert
|
||||
// an Folgen vorbei.
|
||||
for library in libraries {
|
||||
let items = try? await latestItems(limit: 20, inLibrary: library.id)
|
||||
rows.append(MediaRowGroup(
|
||||
id: "latest.\(library.id)",
|
||||
title: String(
|
||||
localized: "row.latestIn \(library.title)",
|
||||
bundle: .module
|
||||
),
|
||||
libraryID: library.id,
|
||||
items: items ?? []
|
||||
))
|
||||
//
|
||||
// **Nebeneinander, nicht nacheinander.** Hier stand ein `await` in
|
||||
// der Schleife: bei vier Mediatheken vier Rundreisen hintereinander.
|
||||
// Die Reihenfolge der Mediatheken bleibt trotzdem erhalten — sonst
|
||||
// stünden die Reihen nach jedem Start woanders.
|
||||
let latest = await withTaskGroup(of: (Int, MediaRowGroup).self) { group in
|
||||
for (place, library) in libraries.enumerated() {
|
||||
group.addTask {
|
||||
let items = try? await self.latestItems(limit: 20, inLibrary: library.id)
|
||||
return (place, MediaRowGroup(
|
||||
id: "latest.\(library.id)",
|
||||
title: String(
|
||||
localized: "row.latestIn \(library.title)",
|
||||
bundle: .module
|
||||
),
|
||||
libraryID: library.id,
|
||||
items: items ?? []
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
var collected: [(Int, MediaRowGroup)] = []
|
||||
for await result in group { collected.append(result) }
|
||||
return collected.sorted { $0.0 < $1.0 }.map(\.1)
|
||||
}
|
||||
|
||||
rows.append(contentsOf: latest)
|
||||
|
||||
rows.append(MediaRowGroup(
|
||||
id: "favourites",
|
||||
title: String(localized: "row.favourites", bundle: .module),
|
||||
@@ -333,6 +348,20 @@ public func isInLocalNetwork() async -> Bool {
|
||||
return next.first { $0.id != item.id }
|
||||
}
|
||||
|
||||
/// Die Folge davor.
|
||||
///
|
||||
/// **Über die Folgenliste der Serie, nicht über einen eigenen
|
||||
/// Endpunkt.** Jellyfin hat ein „NextUp", aber kein Gegenstück dazu —
|
||||
/// und die Liste gibt es ohnehin, weil die Serienseite sie zeigt. Eine
|
||||
/// Anfrage für einen Knopf, der selten gedrückt wird, ist der bessere
|
||||
/// Tausch gegenüber gar keinem Knopf.
|
||||
public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||
guard let seriesID = item.seriesID else { return nil }
|
||||
let folgen = try await episodes(inSeries: seriesID)
|
||||
guard let stelle = folgen.firstIndex(where: { $0.id == item.id }), stelle > 0 else { return nil }
|
||||
return folgen[stelle - 1]
|
||||
}
|
||||
|
||||
// MARK: - Bilder
|
||||
|
||||
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
/// Fortschritt, der den Server nicht erreicht hat.
|
||||
///
|
||||
/// **Was ohne sie verlorenging.** Eine Folge im Flugzeug zur Hälfte
|
||||
/// anzusehen, war für den Server ein Vorgang, der nie stattgefunden hat:
|
||||
/// jede Meldung lief in die tote Leitung, und beim nächsten Verbinden stand
|
||||
/// der Titel wieder bei null — auf dem Fernseher, im Web, überall. Genau
|
||||
/// dieser Fall ist der Grund, aus dem man etwas herunterlädt.
|
||||
///
|
||||
/// **Eine Meldung je Titel, die letzte gewinnt.** Wer eine Stunde schaut,
|
||||
/// erzeugt an die hundert Meldungen; nachzureichen ist davon genau eine
|
||||
/// sinnvoll — die, an der er aufgehört hat. Alles andere wäre ein Protokoll
|
||||
/// der Vergangenheit, das der Server erst wieder zusammenrechnen müsste.
|
||||
///
|
||||
/// Auf der Platte, nicht im Speicher: wer die App im Flugmodus beendet, hat
|
||||
/// sonst umsonst geschaut.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class Fortschrittspost {
|
||||
/// Der Briefkasten der App.
|
||||
///
|
||||
/// Einer, wie bei `LibraryCache`: zwei Instanzen auf dieselbe Datei
|
||||
/// würden einander überschreiben. Der Bauweg mit eigener Ablage bleibt
|
||||
/// für Prüfungen offen.
|
||||
public static let shared = Fortschrittspost()
|
||||
|
||||
/// Ein zurückgelegter Stand.
|
||||
public struct Eintrag: Codable, Sendable, Equatable {
|
||||
/// Zu welchem Server er gehört.
|
||||
///
|
||||
/// **Sonst ginge er an den falschen.** Wer sich abmeldet und an
|
||||
/// einem anderen Server anmeldet, bekäme sonst fremde Kennungen
|
||||
/// mit dem eigenen Sehfortschritt gemeldet.
|
||||
let server: String
|
||||
let titelID: String
|
||||
let sekunden: Double
|
||||
let laufzeitSekunden: Double?
|
||||
let zeitpunkt: Date
|
||||
}
|
||||
|
||||
private let ablage: URL
|
||||
private let logger = Logger(subsystem: "app.flaro", category: "Fortschrittspost")
|
||||
private var offen: [String: Eintrag]
|
||||
|
||||
/// Wie viele Stände noch auf einen Server warten.
|
||||
public var wartend: Int { offen.count }
|
||||
|
||||
public init(ablage: URL? = nil) {
|
||||
self.ablage = ablage ?? FileManager.default
|
||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appending(path: "flaro-fortschritt.json")
|
||||
self.offen = Self.lesen(from: self.ablage) ?? [:]
|
||||
}
|
||||
|
||||
/// Legt einen Stand zurück, weil er nicht ankam.
|
||||
public func zuruecklegen(_ report: PlaybackReport, server: String) {
|
||||
offen[report.itemID] = Eintrag(
|
||||
server: server,
|
||||
titelID: report.itemID,
|
||||
sekunden: report.time.seconds,
|
||||
laufzeitSekunden: report.duration > .zero ? report.duration.seconds : nil,
|
||||
zeitpunkt: Date()
|
||||
)
|
||||
schreiben()
|
||||
}
|
||||
|
||||
/// Versucht, alles Liegengebliebene loszuwerden.
|
||||
///
|
||||
/// **Was weiterhin scheitert, bleibt liegen.** Ein halb geleerter
|
||||
/// Briefkasten ist kein Fehler — beim nächsten Versuch geht der Rest.
|
||||
///
|
||||
/// Nacheinander und nicht nebeneinander: es sind selten mehr als eine
|
||||
/// Handvoll, und zwanzig gleichzeitige Anfragen an einen Server, der
|
||||
/// gerade erst wieder da ist, wären der falsche erste Gruß.
|
||||
public func zustellen(über backend: (any Fortschrittsempfaenger)?) async {
|
||||
guard let backend, !offen.isEmpty else { return }
|
||||
let kennung = backend.identity
|
||||
|
||||
// **Nicht ewig aufheben.** Ein Stand von vor einem halben Jahr sagt
|
||||
// nichts mehr darüber, wo jemand heute weiterschauen will — und ein
|
||||
// Server, der nie wiederkommt, hinterliesse sonst eine Datei, die
|
||||
// nur wächst.
|
||||
let frist = Date().addingTimeInterval(-30 * 24 * 60 * 60)
|
||||
offen = offen.filter { $0.value.zeitpunkt > frist }
|
||||
|
||||
for eintrag in offen.values.sorted(by: { $0.zeitpunkt < $1.zeitpunkt })
|
||||
where eintrag.server == kennung {
|
||||
let angekommen = await backend.reportProgress(PlaybackReport(
|
||||
itemID: eintrag.titelID,
|
||||
state: .stopped,
|
||||
time: .seconds(eintrag.sekunden),
|
||||
duration: .seconds(eintrag.laufzeitSekunden ?? 0)
|
||||
))
|
||||
guard angekommen else { break }
|
||||
// **Nur den, der tatsächlich ankam.** Währenddessen kann
|
||||
// derselbe Titel neu zurückgelegt worden sein; den zu
|
||||
// entfernen hiesse, den neueren Stand wegzuwerfen.
|
||||
if offen[eintrag.titelID] == eintrag { offen[eintrag.titelID] = nil }
|
||||
}
|
||||
schreiben()
|
||||
}
|
||||
|
||||
/// Alles vergessen — beim Abmelden.
|
||||
public func leeren() {
|
||||
offen = [:]
|
||||
try? FileManager.default.removeItem(at: ablage)
|
||||
}
|
||||
|
||||
// MARK: - Platte
|
||||
|
||||
private func schreiben() {
|
||||
do {
|
||||
try JSONEncoder().encode(offen).write(to: ablage, options: .atomic)
|
||||
} catch {
|
||||
logger.warning("Nicht gespeichert: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func lesen(from url: URL) -> [String: Eintrag]? {
|
||||
guard let daten = try? Data(contentsOf: url) else { return nil }
|
||||
return try? JSONDecoder().decode([String: Eintrag].self, from: daten)
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,41 @@ public actor LibraryCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Reihen der Startseite.
|
||||
///
|
||||
/// **Weil es sonst nichts gibt, womit sie sich füllen liesse.** Die
|
||||
/// Mediathekenliste und die Titel darin liegen schon auf der Platte; die
|
||||
/// Startseite dagegen entstand bei jedem Aufruf neu am Server, und ohne
|
||||
/// ihn blieb sie leer. Im Flugzeug ist das der Unterschied zwischen
|
||||
/// „sehen, was es gibt" und einem Fehlerbildschirm.
|
||||
public func homeRows(server: String) -> [MediaRowGroup]? {
|
||||
guard let daten = try? Data(contentsOf: homeRowsFile(server: server)) else { return nil }
|
||||
return try? JSONDecoder().decode([MediaRowGroup].self, from: daten)
|
||||
}
|
||||
|
||||
public func storeHomeRows(_ rows: [MediaRowGroup], server: String) {
|
||||
// Je Reihe eine Bildschirmbreite, mehr sieht ohnehin niemand, bevor
|
||||
// der Server wieder antwortet.
|
||||
let gekürzt = rows.map {
|
||||
MediaRowGroup(
|
||||
id: $0.id,
|
||||
title: $0.title,
|
||||
moreKey: $0.moreKey,
|
||||
libraryID: $0.libraryID,
|
||||
items: $0.items.prefix(20).map(\.listEssentials)
|
||||
)
|
||||
}
|
||||
let ziel = homeRowsFile(server: server)
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: ziel.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try JSONEncoder().encode(gekürzt).write(to: ziel, options: .atomic)
|
||||
} catch {
|
||||
logger.warning("Startseite nicht gespeichert: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Aufräumen
|
||||
|
||||
/// Alles zu einem Server vergessen — beim Abmelden.
|
||||
@@ -139,6 +174,10 @@ public actor LibraryCache {
|
||||
directory(server: server).appending(path: "libraries.json")
|
||||
}
|
||||
|
||||
private func homeRowsFile(server: String) -> URL {
|
||||
directory(server: server).appending(path: "home.json")
|
||||
}
|
||||
|
||||
/// Ein Name, der als Datei taugt.
|
||||
///
|
||||
/// Kennungen kommen vom Server: bei Jellyfin GUIDs, bei Plex Zahlen, und
|
||||
|
||||
@@ -6,9 +6,13 @@ import Foundation
|
||||
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
|
||||
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
|
||||
/// der alles umfasst, wäre kein Nenner mehr.
|
||||
public protocol MediaBackend: Sendable {
|
||||
/// Wie der Server heißt, so wie er sich selbst nennt.
|
||||
nonisolated var serverName: String { get }
|
||||
/// Wer Sehfortschritt entgegennimmt.
|
||||
///
|
||||
/// **Ein Ausschnitt aus `MediaBackend`, und zwar genau der, den die
|
||||
/// `Fortschrittspost` braucht.** Ohne ihn müsste jeder, der das Nachreichen
|
||||
/// prüfen will, einen vollständigen Server nachbauen — vierzig Methoden für
|
||||
/// die zwei, die zur Sache gehören.
|
||||
public protocol Fortschrittsempfaenger: Sendable {
|
||||
/// Wer dieser Server ist — für alles, was ihn über einen Start hinaus
|
||||
/// wiedererkennen muss.
|
||||
///
|
||||
@@ -17,6 +21,24 @@ public protocol MediaBackend: Sendable {
|
||||
/// beides überlebt und beim nächsten Start noch gleich lautet. Es ist
|
||||
/// eine Kennung und geht nirgends an die Oberfläche.
|
||||
nonisolated var identity: String { get }
|
||||
|
||||
/// Meldet, wo man gerade steht.
|
||||
///
|
||||
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
||||
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
||||
/// wäre die schlechtere Hälfte des Tauschs.
|
||||
///
|
||||
/// **Gibt aber zurück, ob sie ankam.** Im Flugzeug geht jede einzelne
|
||||
/// verloren, und am Ende stünde ein zur Hälfte gesehener Film auf dem
|
||||
/// Server weiter bei null. Der Aufrufer legt sie dann beiseite und
|
||||
/// meldet sie nach — siehe `Fortschrittspost`.
|
||||
@discardableResult
|
||||
func reportProgress(_ report: PlaybackReport) async -> Bool
|
||||
}
|
||||
|
||||
public protocol MediaBackend: Sendable, Fortschrittsempfaenger {
|
||||
/// Wie der Server heißt, so wie er sich selbst nennt.
|
||||
nonisolated var serverName: String { get }
|
||||
/// Was dieser Server darüber hinaus beherrscht.
|
||||
nonisolated var capabilities: Set<BackendCapability> { get }
|
||||
|
||||
@@ -60,9 +82,26 @@ public protocol MediaBackend: Sendable {
|
||||
/// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel.
|
||||
/// Ein Server, der es in einer Anfrage kann, soll das dürfen.
|
||||
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem]
|
||||
|
||||
/// Welche Sprachen eine Serie durchgehend führt — für die Vorwahl.
|
||||
func availableLanguages(inSeries id: String) async -> AvailableLanguages?
|
||||
/// Die nächste Folge nach dieser, sofern es eine gibt.
|
||||
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
||||
|
||||
/// Die Folge davor — für den Rückwärtsknopf im Spieler.
|
||||
///
|
||||
/// **Mit Vorgabe, anders als die nächste.** Die nächste Folge braucht
|
||||
/// jeder Server: ohne sie gibt es kein Weiterlaufen am Ende. Die vorige
|
||||
/// ist eine Bequemlichkeit, und ein Server, der sie nicht kennt, soll
|
||||
/// deshalb nicht unbaubar werden.
|
||||
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
||||
|
||||
/// Wie die Vorschaubilder einer Datei liegen — `nil`, wenn es keine gibt.
|
||||
func vorschauplan(fileId: String) async -> Vorschauplan?
|
||||
|
||||
/// Die Adresse eines Vorschaublattes.
|
||||
nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL?
|
||||
|
||||
// MARK: - Bilder
|
||||
|
||||
/// Ein Bild in der gewünschten Größe.
|
||||
@@ -125,13 +164,6 @@ public protocol MediaBackend: Sendable {
|
||||
/// niemand mehr zusieht.
|
||||
func endPlayback(sessionID: String) async
|
||||
|
||||
/// Meldet, wo man gerade steht.
|
||||
///
|
||||
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
||||
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
||||
/// wäre die schlechtere Hälfte des Tauschs.
|
||||
func reportProgress(_ report: PlaybackReport) async
|
||||
|
||||
// MARK: - Zustand melden
|
||||
|
||||
func markWatched(id: String) async throws(MediaBackendError)
|
||||
@@ -211,6 +243,19 @@ public struct MediaPage: Sendable, Equatable {
|
||||
}
|
||||
|
||||
public extension MediaBackend {
|
||||
/// Vorgabe: kein Weg zurück.
|
||||
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? { nil }
|
||||
|
||||
/// Wie die Vorschaubilder einer Datei liegen — `nil`, wenn es keine gibt.
|
||||
///
|
||||
/// **Mit Vorgabe, damit nicht jeder Server es können muss.** Jellyfin
|
||||
/// hat sein eigenes Trickplay mit anderem Zuschnitt; bis das angebunden
|
||||
/// ist, sagt es hier schlicht nichts.
|
||||
func vorschauplan(fileId: String) async -> Vorschauplan? { nil }
|
||||
|
||||
/// Die Adresse eines Vorschaublattes.
|
||||
nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL? { nil }
|
||||
|
||||
/// Vorgabe: der Server kann es nicht.
|
||||
///
|
||||
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
|
||||
@@ -227,6 +272,13 @@ public extension MediaBackend {
|
||||
subtitleStreamIndex: Int?
|
||||
) -> URL? { nil }
|
||||
|
||||
/// Welche Sprachen eine Serie durchgehend führt.
|
||||
///
|
||||
/// **`nil` heisst: der Server weiss es nicht.** Dann bleibt es bei der
|
||||
/// vollen Liste — eine leere Auswahl anzubieten wäre schlimmer als eine
|
||||
/// zu lange.
|
||||
func availableLanguages(inSeries id: String) async -> AvailableLanguages? { nil }
|
||||
|
||||
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
|
||||
///
|
||||
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
|
||||
@@ -261,3 +313,41 @@ public struct MediaFilterOption: Sendable, Equatable, Identifiable {
|
||||
self.title = title
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Eine Sprache, wie sie in einem Menü steht.
|
||||
public struct LanguageOption: Sendable, Equatable, Identifiable, Hashable {
|
||||
public let code: String
|
||||
public let name: String
|
||||
|
||||
public var id: String { code }
|
||||
|
||||
public init(code: String, name: String) {
|
||||
self.code = code
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
/// Welche Sprachen ein Titel durchgehend führt.
|
||||
///
|
||||
/// **Durchgehend, nicht irgendwo.** Bei einer Serie ist das die
|
||||
/// Schnittmenge über alle Folgen: eine Sprache anzubieten, die es nur in
|
||||
/// Staffel 1 gibt, hiesse eine Einstellung für die Serie, die ab Staffel 2
|
||||
/// stillschweigend etwas anderes tut.
|
||||
public struct AvailableLanguages: Sendable, Equatable {
|
||||
public let audio: [LanguageOption]
|
||||
public let subtitles: [LanguageOption]
|
||||
|
||||
/// Wie viele Sprachen es nur in einem Teil der Folgen gibt.
|
||||
///
|
||||
/// **Damit die Oberfläche es sagen kann.** Eine gekürzte Liste sieht
|
||||
/// sonst aus wie die ganze Wahrheit, und wer eine Sprache vermisst, die
|
||||
/// er in Staffel 1 gesehen hat, hält es für einen Fehler.
|
||||
public let partial: Int
|
||||
|
||||
public init(audio: [LanguageOption], subtitles: [LanguageOption], partial: Int) {
|
||||
self.audio = audio
|
||||
self.subtitles = subtitles
|
||||
self.partial = partial
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,13 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
||||
/// die Serie, und so heißt es jetzt auch.
|
||||
public let seriesThumb: String?
|
||||
public let seriesArt: String?
|
||||
/// Das Bild, das jetzt im Aufmacher gilt.
|
||||
///
|
||||
/// **Vom Server gewählt, nicht hier.** Wer zu einem Titel mehrere
|
||||
/// Aufnahmen ausgesucht hat, bekommt alle paar Stunden eine andere — und
|
||||
/// zwar überall dieselbe. `nil` heisst: keine ausgesucht, dann gilt das
|
||||
/// Hintergrundbild.
|
||||
public let heroArt: String?
|
||||
|
||||
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
|
||||
// Staffel.
|
||||
@@ -71,6 +78,26 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
||||
/// eine Fähigkeit, die es nur auf einem gibt.
|
||||
public let isFavourite: Bool
|
||||
|
||||
/// Ob der Server den Titel als angefangen führt.
|
||||
///
|
||||
/// **Die Entscheidung gehört dem Server, nicht der Beschriftung.** „Hat
|
||||
/// eine Stelle über null" und „ist angefangen" waren dasselbe, bis es eine
|
||||
/// einstellbare Untergrenze gab. Seitdem heisst ein Titel, in den jemand
|
||||
/// eine Sekunde hineingeschaut hat, nicht mehr „Weiterschauen" — er steht
|
||||
/// ja auch nicht mehr in der Reihe.
|
||||
///
|
||||
/// `nil` heisst: der Server sagt nichts dazu (Jellyfin, oder ein älterer
|
||||
/// Flaro). Dann gilt wie früher die blosse Stelle.
|
||||
public let isInProgress: Bool?
|
||||
|
||||
/// Ab wann es den Titel gibt — `nil` heisst: jetzt.
|
||||
///
|
||||
/// **Ein geplanter Titel steht in der Mediathek, läuft aber nicht.** Das
|
||||
/// ist der Sinn des Planens: man soll sehen, dass er kommt. Ohne dieses
|
||||
/// Feld bot die App „Abspielen" an und bekam vom Server eine Absage —
|
||||
/// ein Knopf, der ein Versprechen gibt und es zurücknimmt.
|
||||
public let availableFrom: Date?
|
||||
|
||||
// Zählwerte bei Serien und Staffeln.
|
||||
public let episodeCount: Int?
|
||||
public let playedEpisodeCount: Int?
|
||||
@@ -106,6 +133,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
||||
parentThumb: String? = nil,
|
||||
seriesThumb: String? = nil,
|
||||
seriesArt: String? = nil,
|
||||
heroArt: String? = nil,
|
||||
index: Int? = nil,
|
||||
parentIndex: Int? = nil,
|
||||
parentTitle: String? = nil,
|
||||
@@ -117,6 +145,8 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
||||
lastPlayedAt: Date? = nil,
|
||||
addedAt: Date? = nil,
|
||||
isFavourite: Bool = false,
|
||||
isInProgress: Bool? = nil,
|
||||
availableFrom: Date? = nil,
|
||||
episodeCount: Int? = nil,
|
||||
playedEpisodeCount: Int? = nil,
|
||||
childCount: Int? = nil,
|
||||
@@ -144,6 +174,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
||||
self.parentThumb = parentThumb
|
||||
self.seriesThumb = seriesThumb
|
||||
self.seriesArt = seriesArt
|
||||
self.heroArt = heroArt
|
||||
self.index = index
|
||||
self.parentIndex = parentIndex
|
||||
self.parentTitle = parentTitle
|
||||
@@ -155,6 +186,8 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
||||
self.lastPlayedAt = lastPlayedAt
|
||||
self.addedAt = addedAt
|
||||
self.isFavourite = isFavourite
|
||||
self.isInProgress = isInProgress
|
||||
self.availableFrom = availableFrom
|
||||
self.episodeCount = episodeCount
|
||||
self.playedEpisodeCount = playedEpisodeCount
|
||||
self.childCount = childCount
|
||||
@@ -225,6 +258,29 @@ extension MediaItem {
|
||||
|
||||
public var isWatched: Bool { (playCount ?? 0) > 0 }
|
||||
|
||||
/// Ob die Beschriftung „Weiterschauen" heissen soll.
|
||||
///
|
||||
/// Eine Stelle, die beide Fälle kennt: was der Server sagt, und was ohne
|
||||
/// seine Auskunft gilt. Stünde der Zweifel an jedem Knopf einzeln, hiesse
|
||||
/// derselbe Titel auf der Startseite anders als auf seiner Seite.
|
||||
public var isResumable: Bool { isInProgress ?? (watchProgress != nil) }
|
||||
|
||||
/// Der Balken unter der Kachel — nur, wenn der Titel wirklich angefangen ist.
|
||||
///
|
||||
/// **Sonst sagt die Kachel etwas anderes als der Knopf.** Ein Strich unter
|
||||
/// dem Plakat heisst „du bist mittendrin". Für eine Sekunde, in die jemand
|
||||
/// hineingeschaut hat, ist das keine Auskunft, sondern ein Fussel.
|
||||
public var resumeProgress: Double? { isResumable ? watchProgress : nil }
|
||||
|
||||
/// Ob der Titel erst noch kommt.
|
||||
///
|
||||
/// Gefragt wird an einer Stelle, beantwortet an einer: sonst steht an
|
||||
/// einem Bildschirm „Abspielen" und am nächsten der Termin.
|
||||
public var isUpcoming: Bool {
|
||||
guard let availableFrom else { return false }
|
||||
return availableFrom > Date()
|
||||
}
|
||||
|
||||
/// Ab wo fortgesetzt wird.
|
||||
public var resumeTime: Duration {
|
||||
.milliseconds(resumeMilliseconds ?? 0)
|
||||
|
||||
@@ -49,7 +49,7 @@ public struct MediaLibrary: Sendable, Equatable, Identifiable, Codable {
|
||||
}
|
||||
|
||||
/// Eine Reihe auf der Startseite oder in den Suchergebnissen.
|
||||
public struct MediaRowGroup: Sendable, Equatable, Identifiable {
|
||||
public struct MediaRowGroup: Sendable, Equatable, Identifiable, Codable {
|
||||
/// Eine stabile Kennung für diese Reihe.
|
||||
///
|
||||
/// Sie entscheidet, welche Reihe der Nutzer ausgeblendet oder verschoben
|
||||
|
||||
@@ -112,10 +112,29 @@ public struct PlaybackTarget: Sendable, Equatable {
|
||||
/// ordnet es die Meldung keiner Wiedergabe zu.
|
||||
public let versionID: String?
|
||||
|
||||
public init(url: URL, sessionID: String? = nil, versionID: String? = nil) {
|
||||
/// Ab welcher Stelle des Films dieser Strom beginnt.
|
||||
///
|
||||
/// **Null heisst: der Strom ist der ganze Film.** Dann springt der
|
||||
/// Spieler selbst, und das ist der Normalfall — bei Jellyfin immer.
|
||||
///
|
||||
/// **Ein Wert darüber heisst: der Server hat schon gesprungen.** Flaros
|
||||
/// Abspielliste wächst von der gewünschten Stelle an vorwärts; was davor
|
||||
/// liegt, gibt es in diesem Strom nicht. Der Spieler zählt trotzdem ab
|
||||
/// null — ohne diesen Wert ginge jede angezeigte Zeit und jede gemeldete
|
||||
/// Stelle um den Vorlauf daneben, und ein Sprung dorthin wäre ein Sprung
|
||||
/// ins Leere.
|
||||
public let startOffset: Duration
|
||||
|
||||
public init(
|
||||
url: URL,
|
||||
sessionID: String? = nil,
|
||||
versionID: String? = nil,
|
||||
startOffset: Duration = .zero
|
||||
) {
|
||||
self.url = url
|
||||
self.sessionID = sessionID
|
||||
self.versionID = versionID
|
||||
self.startOffset = startOffset
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
/// Wie die Vorschaubilder eines Titels zusammengesetzt sind.
|
||||
///
|
||||
/// **Dieselbe Rechnung wie im Server und im Web.** Dort heisst der Typ
|
||||
/// genauso; die Kachel zu einer Sekunde wird an drei Stellen ausgerechnet,
|
||||
/// und wenn eine davon abweicht, sieht man es nicht als Fehler — man hält
|
||||
/// die Vorschau für ein ungenaues Bild.
|
||||
///
|
||||
/// **Die Kachelmasse stehen nicht fest.** Skaliert wird auf eine feste
|
||||
/// Breite, die Höhe folgt dem Seitenverhältnis des Films. Deshalb misst
|
||||
/// der Server sie und teilt sie mit, statt dass sie hier stünden.
|
||||
public struct Vorschauplan: Sendable, Equatable, Decodable {
|
||||
public let intervall: Double
|
||||
public let spalten: Int
|
||||
public let zeilen: Int
|
||||
public let kachelBreite: Double
|
||||
public let kachelHoehe: Double
|
||||
public let blaetter: Int
|
||||
|
||||
public init(
|
||||
intervall: Double, spalten: Int, zeilen: Int,
|
||||
kachelBreite: Double, kachelHoehe: Double, blaetter: Int
|
||||
) {
|
||||
self.intervall = intervall
|
||||
self.spalten = spalten
|
||||
self.zeilen = zeilen
|
||||
self.kachelBreite = kachelBreite
|
||||
self.kachelHoehe = kachelHoehe
|
||||
self.blaetter = blaetter
|
||||
}
|
||||
|
||||
/// Wie eine Kachel adressiert wird: welches Blatt, und wo darin.
|
||||
public struct Stelle: Sendable, Equatable {
|
||||
public let blatt: Int
|
||||
public let spalte: Int
|
||||
public let zeile: Int
|
||||
}
|
||||
|
||||
public var proBlatt: Int { spalten * zeilen }
|
||||
|
||||
/// Welche Kachel die Stelle zeigt — oder nichts.
|
||||
///
|
||||
/// **Hinter dem letzten Bild bleibt das letzte stehen.** Das Raster
|
||||
/// endet dort, wo ffmpeg aufgehört hat; eine Sekunde darüber hinaus —
|
||||
/// bei ungenauer Laufzeit durchaus möglich — soll die Vorschau nicht
|
||||
/// verschwinden.
|
||||
public func stelle(bei sekunde: Double) -> Stelle? {
|
||||
guard blaetter > 0, spalten > 0, zeilen > 0, intervall > 0 else { return nil }
|
||||
|
||||
let nummer = min(Int(max(0, sekunde) / intervall), proBlatt * blaetter - 1)
|
||||
let rest = nummer % proBlatt
|
||||
return Stelle(blatt: nummer / proBlatt, spalte: rest % spalten, zeile: rest / spalten)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import FlaroDesign
|
||||
|
||||
/// Ein Bild von der Platte muss ankommen.
|
||||
///
|
||||
/// **Der Fehler, für den diese Prüfung steht.** Der Lader verlangte von jeder
|
||||
/// Antwort eine `HTTPURLResponse` — eine `file:`-Adresse antwortet aber mit
|
||||
/// einer schlichten `URLResponse`, und die Prüfung gab darauf `nil` zurück.
|
||||
/// Sichtbar wurde das erst, als die Downloads ihre Plakate mitbrachten: die
|
||||
/// Dateien lagen da, und die Kacheln blieben trotzdem grau.
|
||||
@Suite("Bilder von der Platte")
|
||||
struct ArtworkLoaderTests {
|
||||
/// Ein gültiges PNG, ein Bildpunkt gross.
|
||||
private static let winzigesPNG = Data(base64Encoded: """
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmM\
|
||||
IQAAAABJRU5ErkJggg==
|
||||
""")!
|
||||
|
||||
private func ablegen(_ daten: Data, als name: String) throws -> URL {
|
||||
let ziel = FileManager.default.temporaryDirectory
|
||||
.appending(path: "artwork-\(UUID().uuidString)-\(name)")
|
||||
try daten.write(to: ziel)
|
||||
return ziel
|
||||
}
|
||||
|
||||
@Test("Eine Datei wird gelesen, nicht abgefragt")
|
||||
func dateiWirdGelesen() async throws {
|
||||
let datei = try ablegen(Self.winzigesPNG, als: "plakat.bild")
|
||||
defer { try? FileManager.default.removeItem(at: datei) }
|
||||
|
||||
let bild = await ArtworkLoader.shared.image(for: datei)
|
||||
#expect(bild != nil)
|
||||
}
|
||||
|
||||
@Test("Eine Datei, die es nicht gibt, ergibt kein Bild")
|
||||
func fehlendeDatei() async {
|
||||
let nirgends = FileManager.default.temporaryDirectory
|
||||
.appending(path: "gibt-es-nicht-\(UUID().uuidString).bild")
|
||||
let bild = await ArtworkLoader.shared.image(for: nirgends)
|
||||
#expect(bild == nil)
|
||||
}
|
||||
|
||||
@Test("Was keine Bilddaten sind, ergibt kein Bild")
|
||||
func keinBild() async throws {
|
||||
let datei = try ablegen(Data("kein Bild, nur Text".utf8), als: "text.bild")
|
||||
defer { try? FileManager.default.removeItem(at: datei) }
|
||||
|
||||
let bild = await ArtworkLoader.shared.image(for: datei)
|
||||
#expect(bild == nil)
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import MediaKit
|
||||
/// Adressen und Zugang — alles, was ohne Netz entsteht.
|
||||
@Suite("Flaro: Adressen und Zugang")
|
||||
struct AdressenTests {
|
||||
private func backend(_ transport: Attrappe, keksglas: Keksglas? = nil) -> FlaroBackend {
|
||||
private func backend(_ transport: Attrappe, cookieglas: Cookieglas? = nil) -> FlaroBackend {
|
||||
FlaroBackend(
|
||||
client: FlaroClient(
|
||||
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
||||
transport: transport, cookieJar: keksglas),
|
||||
transport: transport, cookieJar: cookieglas),
|
||||
account: FlaroAccount(
|
||||
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
||||
userID: "u1", userName: "guido", isAdministrator: true))
|
||||
@@ -32,17 +32,17 @@ struct AdressenTests {
|
||||
}
|
||||
|
||||
@Test("Ein Zugang wird als Cookie abgelegt")
|
||||
func tokenWirdZuKeks() async {
|
||||
let glas = Keksglas()
|
||||
func tokenWirdZuCookie() async {
|
||||
let glas = Cookieglas()
|
||||
_ = FlaroClient(
|
||||
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
||||
transport: Attrappe(rumpf: "{}"), cookieJar: glas)
|
||||
|
||||
let keks = glas.kekse.first
|
||||
#expect(keks?.name == "flaro_session")
|
||||
#expect(keks?.value == "geheim-123")
|
||||
#expect(keks?.path == "/")
|
||||
#expect(keks?.domain.hasSuffix("nas") == true)
|
||||
let cookie = glas.cookies.first
|
||||
#expect(cookie?.name == "flaro_session")
|
||||
#expect(cookie?.value == "geheim-123")
|
||||
#expect(cookie?.path == "/")
|
||||
#expect(cookie?.domain.hasSuffix("nas") == true)
|
||||
}
|
||||
|
||||
/// **Nach einem Neustart meldet sich niemand neu an.**
|
||||
@@ -51,8 +51,8 @@ struct AdressenTests {
|
||||
/// allein aus der Antwort des Servers, wäre keines da — und jedes Plakat
|
||||
/// bliebe grau, obwohl der Zugang gilt.
|
||||
@Test("Auch ein wiederaufgenommenes Token legt das Cookie an")
|
||||
func keksAuchOhneAnmeldung() async throws {
|
||||
let glas = Keksglas()
|
||||
func cookieAuchOhneAnmeldung() async throws {
|
||||
let glas = Cookieglas()
|
||||
let transport = Attrappe([
|
||||
("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#),
|
||||
("/api/v1/system/info", 200, Proben.info),
|
||||
@@ -62,7 +62,7 @@ struct AdressenTests {
|
||||
baseURL: Proben.adresse, token: "geheim-123",
|
||||
identity: Proben.kennung, transport: transport, cookieJar: glas)
|
||||
|
||||
#expect(glas.kekse.contains { $0.name == "flaro_session" && $0.value == "geheim-123" })
|
||||
#expect(glas.cookies.contains { $0.name == "flaro_session" && $0.value == "geheim-123" })
|
||||
}
|
||||
|
||||
@Test("Die Download-Adresse zeigt auf die Datei, nicht auf den Titel")
|
||||
@@ -104,7 +104,7 @@ struct AdressenTests {
|
||||
|
||||
let (token, konto, _) = try await FlaroSignIn.signIn(
|
||||
baseURL: Proben.adresse, userName: "guido", password: "geheim",
|
||||
identity: Proben.kennung, transport: transport, cookieJar: Keksglas())
|
||||
identity: Proben.kennung, transport: transport, cookieJar: Cookieglas())
|
||||
|
||||
let rumpf = try #require(transport.rumpf(von: "/api/v1/auth/login"))
|
||||
|
||||
|
||||
@@ -46,15 +46,15 @@ final class Attrappe: FlaroTransport, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Keksglas, das nur mitschreibt.
|
||||
/// Ein Cookieglas, das nur mitschreibt.
|
||||
///
|
||||
/// Der Speicher des Systems anzufassen hiesse, dass eine Prüfung die nächste
|
||||
/// beeinflusst — und auf einem Entwicklerrechner auch den Browser.
|
||||
final class Keksglas: FlaroCookieJar, @unchecked Sendable {
|
||||
final class Cookieglas: FlaroCookieJar, @unchecked Sendable {
|
||||
private let sperre = NSLock()
|
||||
private var inhalt: [HTTPCookie] = []
|
||||
|
||||
var kekse: [HTTPCookie] { sperre.withLock { inhalt } }
|
||||
var cookies: [HTTPCookie] { sperre.withLock { inhalt } }
|
||||
|
||||
func store(_ cookie: HTTPCookie) { sperre.withLock { inhalt.append(cookie) } }
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import MediaKit
|
||||
@testable import FlaroServerKit
|
||||
|
||||
/// Vorspann und Abspann auf dem Weg vom Server in den Spieler.
|
||||
///
|
||||
/// **Der Anlass:** im Web erschien der Überspringen-Knopf, in der App nie.
|
||||
/// Dazwischen liegt genau diese Übersetzung — und ohne Prüfung liesse sich
|
||||
/// nicht sagen, ob sie der Grund ist oder etwas dahinter.
|
||||
@Suite("Flaro: Marken")
|
||||
struct MarkenTests {
|
||||
private static let folge = """
|
||||
{
|
||||
"id": "folge-1", "season": 1, "episode": 6,
|
||||
"title": "Bestimmung", "overview": null, "stillImage": null,
|
||||
"files": [{
|
||||
"id": "datei-1", "fileName": "F.mkv", "sizeBytes": 1,
|
||||
"container": "mkv", "durationSeconds": 2700, "streams": []
|
||||
}],
|
||||
"state": null, "publishAt": null, "seriesId": "serie-1",
|
||||
"markers": [
|
||||
{ "kind": "intro", "startSeconds": 30.5, "endSeconds": 65 },
|
||||
{ "kind": "credits", "startSeconds": 2500, "endSeconds": 2700 }
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
@Test("Eine Folge bringt Vorspann und Abspann mit")
|
||||
func folgeMitMarken() throws {
|
||||
let roh = try #require(
|
||||
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(Self.folge.utf8)))
|
||||
let titel = roh.neutral()
|
||||
|
||||
#expect(titel.markers.count == 2)
|
||||
|
||||
let intro = try #require(titel.markers.first { $0.kind == .intro })
|
||||
#expect(intro.startMilliseconds == 30_500)
|
||||
#expect(intro.endMilliseconds == 65_000)
|
||||
#expect(intro.contains(45_000))
|
||||
|
||||
let abspann = try #require(titel.markers.first { $0.kind == .credits })
|
||||
#expect(abspann.startMilliseconds == 2_500_000)
|
||||
}
|
||||
|
||||
/// Ein älterer Server schickt das Feld gar nicht — das ist kein Fehler.
|
||||
@Test("Ohne Marken bleibt die Liste leer")
|
||||
func ohneMarken() throws {
|
||||
let ohne = """
|
||||
{
|
||||
"id": "folge-1", "season": 1, "episode": 6,
|
||||
"title": "Bestimmung", "overview": null, "stillImage": null,
|
||||
"files": [], "state": null, "publishAt": null, "seriesId": "serie-1"
|
||||
}
|
||||
"""
|
||||
let roh = try #require(
|
||||
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(ohne.utf8)))
|
||||
|
||||
#expect(roh.neutral().markers.isEmpty)
|
||||
}
|
||||
|
||||
/// Ein unbekanntes Wort wirft die Marke nicht weg — sie zählt nur nicht.
|
||||
@Test("Eine unbekannte Art wird nicht zur falschen")
|
||||
func unbekannteArt() throws {
|
||||
let fremd = Self.folge.replacingOccurrences(of: "\"intro\"", with: "\"werbung\"")
|
||||
let roh = try #require(
|
||||
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(fremd.utf8)))
|
||||
let titel = roh.neutral()
|
||||
|
||||
#expect(titel.markers.count == 2)
|
||||
#expect(titel.markers.first?.kind == .unknown)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import MediaKit
|
||||
@testable import FlaroServerKit
|
||||
|
||||
/// Die Folge nach dieser — über die Staffelgrenze hinweg.
|
||||
///
|
||||
/// **Der Anlass.** Im Web stand am Abspann „Nächste Folge", in der App nur
|
||||
/// „Abspann überspringen". Der Server war es nicht: beide fragen denselben
|
||||
/// Endpunkt, und er ordnet nach Staffel und dann nach Nummer, findet also
|
||||
/// auch die erste Folge der nächsten Staffel.
|
||||
///
|
||||
/// **Es war der Dekoder.** `nachbar` baute sich einen frischen
|
||||
/// `JSONDecoder()`, und der kennt das Datumsformat des Servers nicht.
|
||||
/// Solange die Nachbarfolge weder `publishAt` noch `lastPlayedAt` hatte,
|
||||
/// ging es gut — mit eingeschalteter Prüfung hat sie ein `publishAt`. Das
|
||||
/// `try?` schluckte den Fehler, und von aussen sah es aus, als gäbe es
|
||||
/// keine nächste Folge.
|
||||
@Suite("Flaro: Nachbarfolge")
|
||||
struct NachbarfolgeTests {
|
||||
private func backend(_ transport: any FlaroTransport) -> FlaroBackend {
|
||||
FlaroBackend(
|
||||
client: FlaroClient(
|
||||
baseURL: Proben.adresse, identity: Proben.kennung, token: "t",
|
||||
transport: transport, cookieJar: nil),
|
||||
account: FlaroAccount(
|
||||
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
||||
userID: "u1", userName: "guido", isAdministrator: false))
|
||||
}
|
||||
|
||||
private static func folge(
|
||||
id: String, staffel: Int, nummer: Int,
|
||||
publishAt: String = "null", zuletzt: String = "null"
|
||||
) -> String {
|
||||
"""
|
||||
{
|
||||
"id": "\(id)", "season": \(staffel), "episode": \(nummer),
|
||||
"title": "Neuanfang", "overview": null, "stillImage": null,
|
||||
"files": [{
|
||||
"id": "d-\(id)", "fileName": "F.mkv", "sizeBytes": 1,
|
||||
"container": "mkv", "durationSeconds": 2700, "streams": []
|
||||
}],
|
||||
"state": { "positionSeconds": 0, "runtimeSeconds": 2700,
|
||||
"watched": false, "playCount": 0, "favourite": false,
|
||||
"rating": null, "dismissed": false,
|
||||
"lastPlayedAt": \(zuletzt), "inProgress": false },
|
||||
"publishAt": \(publishAt), "seriesId": "serie-1", "markers": []
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private static let folgeJetzt = MediaItem(
|
||||
id: "s1e10", kind: .episode, title: "Finale", index: 10, parentIndex: 1)
|
||||
|
||||
/// Das Staffelfinale: die nächste Folge ist die erste der nächsten Staffel.
|
||||
@Test("Über die Staffelgrenze hinweg")
|
||||
func ueberDieStaffelgrenze() async throws {
|
||||
let backend = backend(Attrappe(rumpf: Self.folge(id: "s2e1", staffel: 2, nummer: 1)))
|
||||
|
||||
let naechste = try #require(await backend.nextEpisode(after: Self.folgeJetzt))
|
||||
|
||||
#expect(naechste.id == "s2e1")
|
||||
#expect(naechste.parentIndex == 2)
|
||||
#expect(naechste.index == 1)
|
||||
}
|
||||
|
||||
/// **Der Fehler selbst.** Mit `publishAt` und `lastPlayedAt` scheiterte
|
||||
/// das Lesen stillschweigend, und der Knopf blieb weg. Mit dem frischen
|
||||
/// `JSONDecoder()` von vorher ist diese Prüfung rot.
|
||||
@Test("Auch mit Datumsangaben — daran scheiterte es")
|
||||
func mitDatumsangaben() async throws {
|
||||
let backend = backend(Attrappe(rumpf: Self.folge(
|
||||
id: "s2e1", staffel: 2, nummer: 1,
|
||||
publishAt: "\"2026-10-01T18:00:00Z\"",
|
||||
zuletzt: "\"2026-10-11T21:14:03.482Z\"")))
|
||||
|
||||
let naechste = try #require(await backend.nextEpisode(after: Self.folgeJetzt))
|
||||
|
||||
#expect(naechste.id == "s2e1")
|
||||
#expect(naechste.availableFrom != nil)
|
||||
}
|
||||
|
||||
/// Nach der letzten Folge kommt keine — 204, und das ist kein Fehler.
|
||||
@Test("Hinter der letzten Folge kommt nichts")
|
||||
func hinterDerLetzten() async throws {
|
||||
let backend = backend(Attrappe(rumpf: "", status: 204))
|
||||
|
||||
#expect(try await backend.nextEpisode(after: Self.folgeJetzt) == nil)
|
||||
}
|
||||
|
||||
/// Ein Film hat keine Nachbarn — und fragt gar nicht erst.
|
||||
@Test("Ein Film fragt nicht nach Nachbarn")
|
||||
func filmFragtNicht() async throws {
|
||||
let attrappe = Attrappe(rumpf: Self.folge(id: "x", staffel: 1, nummer: 1))
|
||||
let film = MediaItem(id: "f1", kind: .movie, title: "Arrival")
|
||||
|
||||
#expect(try await backend(attrappe).nextEpisode(after: film) == nil)
|
||||
#expect(attrappe.anfragen.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import MediaKit
|
||||
|
||||
/// Ein Server, der auf Ansage annimmt oder nicht.
|
||||
private final class Briefkasten: Fortschrittsempfaenger, @unchecked Sendable {
|
||||
nonisolated let identity: String
|
||||
/// Ob Meldungen angenommen werden.
|
||||
var nimmtAn: Bool
|
||||
private(set) var angekommen: [PlaybackReport] = []
|
||||
|
||||
init(identity: String = "server-a", nimmtAn: Bool = true) {
|
||||
self.identity = identity
|
||||
self.nimmtAn = nimmtAn
|
||||
}
|
||||
|
||||
func reportProgress(_ report: PlaybackReport) async -> Bool {
|
||||
guard nimmtAn else { return false }
|
||||
angekommen.append(report)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func bericht(_ titel: String, sekunden: Double) -> PlaybackReport {
|
||||
PlaybackReport(
|
||||
itemID: titel,
|
||||
state: .stopped,
|
||||
time: .seconds(sekunden),
|
||||
duration: .seconds(3600)
|
||||
)
|
||||
}
|
||||
|
||||
@Suite("Nachgereichter Fortschritt")
|
||||
@MainActor
|
||||
struct FortschrittspostTests {
|
||||
/// Eine eigene Datei je Prüfung — sonst sähe die zweite, was die erste
|
||||
/// liegen gelassen hat.
|
||||
private func ablage() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appending(path: "post-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
@Test("Was ankam, wird nicht aufgehoben")
|
||||
func nichtsLiegenLassen() async {
|
||||
let post = Fortschrittspost(ablage: ablage())
|
||||
let server = Briefkasten()
|
||||
|
||||
post.zuruecklegen(bericht("film", sekunden: 600), server: server.identity)
|
||||
#expect(post.wartend == 1)
|
||||
|
||||
await post.zustellen(über: server)
|
||||
#expect(post.wartend == 0)
|
||||
#expect(server.angekommen.count == 1)
|
||||
#expect(server.angekommen.first?.itemID == "film")
|
||||
}
|
||||
|
||||
@Test("Was nicht ankommt, bleibt liegen")
|
||||
func liegenbleiben() async {
|
||||
let post = Fortschrittspost(ablage: ablage())
|
||||
let server = Briefkasten(nimmtAn: false)
|
||||
|
||||
post.zuruecklegen(bericht("film", sekunden: 600), server: server.identity)
|
||||
await post.zustellen(über: server)
|
||||
#expect(post.wartend == 1)
|
||||
|
||||
server.nimmtAn = true
|
||||
await post.zustellen(über: server)
|
||||
#expect(post.wartend == 0)
|
||||
}
|
||||
|
||||
@Test("Je Titel nur der letzte Stand")
|
||||
func nurDerLetzteStand() async {
|
||||
let post = Fortschrittspost(ablage: ablage())
|
||||
let server = Briefkasten()
|
||||
|
||||
post.zuruecklegen(bericht("film", sekunden: 60), server: server.identity)
|
||||
post.zuruecklegen(bericht("film", sekunden: 1800), server: server.identity)
|
||||
#expect(post.wartend == 1)
|
||||
|
||||
await post.zustellen(über: server)
|
||||
#expect(server.angekommen.count == 1)
|
||||
#expect(server.angekommen.first?.time == .seconds(1800))
|
||||
}
|
||||
|
||||
@Test("Ein fremder Server bekommt nichts")
|
||||
func nichtAnDenFalschen() async {
|
||||
let post = Fortschrittspost(ablage: ablage())
|
||||
post.zuruecklegen(bericht("film", sekunden: 600), server: "server-a")
|
||||
|
||||
let anderer = Briefkasten(identity: "server-b")
|
||||
await post.zustellen(über: anderer)
|
||||
#expect(anderer.angekommen.isEmpty)
|
||||
#expect(post.wartend == 1)
|
||||
}
|
||||
|
||||
@Test("Der Briefkasten überlebt den Neustart")
|
||||
func aufDerPlatte() async {
|
||||
let datei = ablage()
|
||||
let erste = Fortschrittspost(ablage: datei)
|
||||
erste.zuruecklegen(bericht("folge", sekunden: 900), server: "server-a")
|
||||
|
||||
let zweite = Fortschrittspost(ablage: datei)
|
||||
#expect(zweite.wartend == 1)
|
||||
|
||||
let server = Briefkasten()
|
||||
await zweite.zustellen(über: server)
|
||||
#expect(server.angekommen.first?.time == .seconds(900))
|
||||
}
|
||||
|
||||
@Test("Leeren wirft alles weg")
|
||||
func leeren() async {
|
||||
let post = Fortschrittspost(ablage: ablage())
|
||||
post.zuruecklegen(bericht("film", sekunden: 600), server: "server-a")
|
||||
post.leeren()
|
||||
#expect(post.wartend == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import MediaKit
|
||||
|
||||
@Suite("Die gemerkte Startseite")
|
||||
struct LibraryCacheHomeTests {
|
||||
private func speicher() -> LibraryCache {
|
||||
LibraryCache(root: FileManager.default.temporaryDirectory
|
||||
.appending(path: "cache-\(UUID().uuidString)", directoryHint: .isDirectory))
|
||||
}
|
||||
|
||||
private func titel(_ nummer: Int) -> MediaItem {
|
||||
MediaItem(id: "t\(nummer)", kind: .movie, title: "Titel \(nummer)", thumb: "/bild/\(nummer)")
|
||||
}
|
||||
|
||||
@Test("Was gespeichert wurde, kommt zurück")
|
||||
func hinUndZurueck() async {
|
||||
let cache = speicher()
|
||||
let reihen = [
|
||||
MediaRowGroup(id: "continue", title: "Weiterschauen",
|
||||
libraryID: "mediathek", items: [titel(1), titel(2)]),
|
||||
MediaRowGroup(id: "latest.1", title: "Neu", moreKey: "/mehr", items: [titel(3)]),
|
||||
]
|
||||
|
||||
await cache.storeHomeRows(reihen, server: "server-a")
|
||||
let gemerkt = await cache.homeRows(server: "server-a")
|
||||
|
||||
#expect(gemerkt?.map(\.id) == ["continue", "latest.1"])
|
||||
#expect(gemerkt?.first?.title == "Weiterschauen")
|
||||
#expect(gemerkt?.first?.libraryID == "mediathek")
|
||||
#expect(gemerkt?.last?.moreKey == "/mehr")
|
||||
#expect(gemerkt?.first?.items.map(\.id) == ["t1", "t2"])
|
||||
// Ohne den Bildpfad wäre der Abzug wertlos: die Plakate liegen
|
||||
// bereits auf der Platte, nur findet sie dann niemand.
|
||||
#expect(gemerkt?.first?.items.first?.thumb == "/bild/1")
|
||||
}
|
||||
|
||||
@Test("Ein anderer Server hat seine eigene Startseite")
|
||||
func getrenntJeServer() async {
|
||||
let cache = speicher()
|
||||
await cache.storeHomeRows(
|
||||
[MediaRowGroup(id: "continue", title: "Weiterschauen", items: [titel(1)])],
|
||||
server: "server-a"
|
||||
)
|
||||
let fremd = await cache.homeRows(server: "server-b")
|
||||
#expect(fremd == nil)
|
||||
}
|
||||
|
||||
@Test("Ohne gespeicherten Stand kommt nichts")
|
||||
func nochNichtsDa() async {
|
||||
let gemerkt = await speicher().homeRows(server: "server-a")
|
||||
#expect(gemerkt == nil)
|
||||
}
|
||||
|
||||
@Test("Lange Reihen werden gekürzt")
|
||||
func nichtAlles() async {
|
||||
let cache = speicher()
|
||||
let lang = (1...80).map(titel)
|
||||
await cache.storeHomeRows(
|
||||
[MediaRowGroup(id: "latest", title: "Neu", items: lang)], server: "server-a"
|
||||
)
|
||||
let gemerkt = await cache.homeRows(server: "server-a")
|
||||
#expect(gemerkt?.first?.items.count == 20)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@
|
||||
# Tools/image-veroeffentlichen.sh → :latest
|
||||
# Tools/image-veroeffentlichen.sh 0.2.0 → :latest und :0.2.0
|
||||
#
|
||||
# Nur eine Architektur — deutlich schneller, weil die fremde sonst unter
|
||||
# Emulation läuft:
|
||||
#
|
||||
# FLARO_PLATTFORMEN=linux/amd64 Tools/image-veroeffentlichen.sh
|
||||
#
|
||||
# Vorher einmalig anmelden — das Passwort tippst du selbst ein:
|
||||
#
|
||||
# docker login git.scarriffle.com
|
||||
@@ -17,6 +22,7 @@ REGISTRY="${FLARO_REGISTRY:-git.scarriffle.com}"
|
||||
PFAD="${FLARO_IMAGE_PATH:-scarriffle/flaro}"
|
||||
BILD="$REGISTRY/$PFAD"
|
||||
FASSUNG="${1:-}"
|
||||
PLATTFORMEN="${FLARO_PLATTFORMEN:-linux/amd64,linux/arm64}"
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
@@ -37,22 +43,106 @@ if ! grep -q "$REGISTRY" ~/.docker/config.json 2>/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# **Vor dem Bauen, nicht danach.** Laufen App und Browser farblich
|
||||
# auseinander, soll das auffallen, bevor ein Image daraus wird.
|
||||
if command -v python3 >/dev/null && [ -f Tools/tokens-pruefen.py ]; then
|
||||
python3 Tools/tokens-pruefen.py || {
|
||||
echo
|
||||
echo "Abgebrochen. Mit FLARO_DRIFT_EGAL=1 trotzdem bauen."
|
||||
[ "${FLARO_DRIFT_EGAL:-}" = "1" ] || exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Fragt die Registry, welcher Stand dort unter `latest` liegt.
|
||||
#
|
||||
# Anonym — das Paket ist öffentlich lesbar. Schlägt es fehl, ist die Antwort
|
||||
# leer, und der Aufrufer entscheidet, was das bedeutet.
|
||||
registry_digest() {
|
||||
local token
|
||||
token=$(curl -fsS --max-time 20 \
|
||||
"https://$REGISTRY/v2/token?scope=repository:$PFAD:pull&service=container_registry" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("token",""))' 2>/dev/null) || return 1
|
||||
[ -n "$token" ] || return 1
|
||||
|
||||
curl -fsS --max-time 20 -D- -o /dev/null \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Accept: application/vnd.oci.image.index.v1+json" \
|
||||
"https://$REGISTRY/v2/$PFAD/manifests/latest" 2>/dev/null \
|
||||
| awk 'BEGIN{IGNORECASE=1} /^docker-content-digest:/ {print $2}' | tr -d '\r'
|
||||
}
|
||||
|
||||
MARKEN=(-t "$BILD:latest")
|
||||
[ -n "$FASSUNG" ] && MARKEN+=(-t "$BILD:$FASSUNG")
|
||||
|
||||
# **Eine Kennung je Bau.**
|
||||
#
|
||||
# Sechs Zeichen, jedes Mal neu — auch bei unverändertem Quelltext. Sie landet
|
||||
# im Abbild und steht danach in der Oberfläche unter „Einstellungen" sowie
|
||||
# unter `/health`. Damit ist die Frage „läuft auf dem NAS wirklich der Bau von
|
||||
# eben?" in zwei Sekunden beantwortet statt in einem halben Tag.
|
||||
#
|
||||
# Ohne I, O, 0 und 1: diese Kennung wird vorgelesen und abgetippt.
|
||||
#
|
||||
# **Nicht `tr < /dev/urandom | head -c 6`.** `head` schliesst die Pipe nach
|
||||
# sechs Zeichen, `tr` bekommt SIGPIPE, und mit `set -o pipefail` gilt das als
|
||||
# Fehlschlag — das Skript brach mit 141 ab, bevor es irgendetwas gebaut hatte.
|
||||
# Dieselbe Falle wie oben bei `grep -q`, und sie hat hier zweimal zugeschlagen.
|
||||
BUILD=$(python3 -c "import secrets; print(''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(6)))")
|
||||
echo "Build-Kennung dieses Laufs: $BUILD"
|
||||
|
||||
# **Ein eigener Builder.** Der Vorgabe-Builder von Docker Desktop kann kein
|
||||
# Manifest mit zwei Architekturen bauen; `docker buildx create` schon.
|
||||
docker buildx inspect flaro-bauer >/dev/null 2>&1 \
|
||||
|| docker buildx create --name flaro-bauer --use >/dev/null
|
||||
|
||||
echo "Baue $BILD für amd64 und arm64 — das dauert, arm64 läuft unter Emulation."
|
||||
docker buildx build \
|
||||
# **Den Digest festhalten, bevor gebaut wird.** Nur so lässt sich hinterher
|
||||
# sagen, ob wirklich etwas Neues angekommen ist.
|
||||
vorher=$(registry_digest || true)
|
||||
|
||||
echo "Baue $BILD für $PLATTFORMEN."
|
||||
if ! docker buildx build \
|
||||
--builder flaro-bauer \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--platform "$PLATTFORMEN" \
|
||||
-f server/Dockerfile \
|
||||
--build-arg "FLARO_BUILD=$BUILD" \
|
||||
"${MARKEN[@]}" \
|
||||
--push .
|
||||
--push . ; then
|
||||
echo
|
||||
echo "Der Bau oder das Hochladen ist gescheitert. In der Registry liegt weiter:"
|
||||
echo " ${vorher:-nichts}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# **Nachsehen, statt zu glauben.**
|
||||
#
|
||||
# `docker buildx build --push` hat bei einem abgebrochenen Upload schon mit
|
||||
# Rückgabewert 0 beendet, nachdem es „ERROR: failed to build" gedruckt hatte —
|
||||
# ein TLS-Zeitlimit mitten im Hochladen. Das Skript meldete Erfolg, in der
|
||||
# Registry lag das alte Image, und auf dem NAS kam eine Fassung an, die
|
||||
# niemand gebaut hatte. Seitdem wird gefragt, nicht vertraut.
|
||||
nachher=$(registry_digest || true)
|
||||
|
||||
if [ -z "$nachher" ]; then
|
||||
echo
|
||||
echo "Die Registry antwortet nicht auf $BILD:latest — hochgeladen wurde offenbar nichts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$vorher" ] && [ "$vorher" = "$nachher" ]; then
|
||||
echo
|
||||
echo "In der Registry liegt noch derselbe Stand wie vorher:"
|
||||
echo " $nachher"
|
||||
echo "Das Hochladen ist also nicht durchgekommen. Noch einmal versuchen."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Fertig. Auf dem NAS:"
|
||||
echo "Fertig. In der Registry liegt jetzt:"
|
||||
echo " $nachher"
|
||||
echo
|
||||
echo "Build-Kennung: $BUILD"
|
||||
echo " Dieselbe muss nach dem Ziehen unter Einstellungen stehen."
|
||||
echo " Steht dort eine andere, läuft auf dem NAS ein älteres Abbild."
|
||||
echo
|
||||
echo "Auf dem NAS:"
|
||||
echo " image: $BILD:${FASSUNG:-latest}"
|
||||
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prüft, ob App und Weboberfläche dieselben Farben und Formen benutzen.
|
||||
|
||||
**Warum es das braucht.** Die Werte stehen an mehreren Stellen von Hand
|
||||
abgeschrieben — in Swift für die Apps, in CSS für den Browser. Sie sind
|
||||
auseinandergelaufen, und zwar unsichtbar: `#1D1D21` gegen `#1D1D22`,
|
||||
`#A0A0A8` gegen `#B4B4BE`, Radius 8 gegen 6. Einzeln merkt das niemand.
|
||||
Zusammen sorgen sie dafür, dass App und Browser nicht wie dasselbe Programm
|
||||
aussehen.
|
||||
|
||||
**Warum kein Generator.** Einer müsste Swift erzeugen, und erzeugter Swift
|
||||
lässt sich schlechter lesen und kommentieren als geschriebener. Diese Prüfung
|
||||
kostet weniger und fängt dasselbe ab: sie sagt, dass etwas auseinanderläuft,
|
||||
und überlässt die Entscheidung dem Menschen.
|
||||
|
||||
Aufruf: python3 Tools/tokens-pruefen.py
|
||||
Rückgabe: 0, wenn alles übereinstimmt; sonst 1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
WURZEL = Path(__file__).resolve().parent.parent
|
||||
SWIFT_FARBEN = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/Color/FlaroColor.swift"
|
||||
SWIFT_AKZENT = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/Color/AccentOption.swift"
|
||||
SWIFT_FORMEN = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/FlaroFont.swift"
|
||||
CSS = WURZEL / "web/app/src/style.css"
|
||||
|
||||
# Was womit zusammengehört. Links der Swift-Name, rechts das CSS-Token.
|
||||
FARBEN = {
|
||||
"canvas": "--canvas",
|
||||
"surface": "--surface",
|
||||
"elevated": "--elevated",
|
||||
"placeholder": "--placeholder",
|
||||
"textPrimary": "--text",
|
||||
"textSecondary": "--text-dim",
|
||||
"textMuted": "--text-muted",
|
||||
"destructive": "--destructive",
|
||||
}
|
||||
|
||||
# Radien: Swift-Name → CSS-Token. `sheet` und `panel` haben im Browser kein
|
||||
# Gegenstück — dort gibt es keine Blätter und keine schwebende Seitenleiste.
|
||||
RADIEN = {
|
||||
"card": "--r-karte",
|
||||
"control": "--r-flaeche",
|
||||
}
|
||||
|
||||
|
||||
def hex_aus_swift(text: str, name: str) -> str | None:
|
||||
"""Liest `static let name = RGB(hex: 0xAABBCC)` oder `RGB(0xAABBCC)`."""
|
||||
m = re.search(rf"\b{name}\b[^\n=]*=\s*[^\n]*?0x([0-9A-Fa-f]{{6}})", text)
|
||||
return "#" + m.group(1).upper() if m else None
|
||||
|
||||
|
||||
def ohne_tvos(text: str) -> str:
|
||||
"""Entfernt die tvOS-Zweige.
|
||||
|
||||
**Sonst vergleicht man den falschen Wert.** `FlaroRadius.card` ist 8 —
|
||||
und auf tvOS 12, weil aus drei Metern eine stärkere Rundung nötig ist.
|
||||
Der Browser läuft nie auf einem Fernseher; massgeblich ist der andere Zweig.
|
||||
"""
|
||||
return re.sub(r"#if os\(tvOS\).*?(?:#else(.*?))?#endif", lambda m: m.group(1) or "",
|
||||
text, flags=re.S)
|
||||
|
||||
|
||||
def zahl_aus_swift(text: str, name: str) -> float | None:
|
||||
"""Liest den ersten Zahlenwert hinter `static let name`."""
|
||||
m = re.search(rf"\b{name}\b\s*:\s*CGFloat\s*(?:\{{[^}}]*?return\s*)?([0-9]+(?:\.[0-9]+)?)", text, re.S)
|
||||
if m:
|
||||
return float(m.group(1))
|
||||
m = re.search(rf"\b{name}\b[^\n=]*=\s*([0-9]+(?:\.[0-9]+)?)", text)
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def token_aus_css(text: str, name: str) -> str | None:
|
||||
m = re.search(rf"{re.escape(name)}\s*:\s*([^;]+);", text)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
fehlend = [p for p in (SWIFT_FARBEN, SWIFT_AKZENT, SWIFT_FORMEN, CSS) if not p.exists()]
|
||||
if fehlend:
|
||||
for p in fehlend:
|
||||
print(f"Nicht gefunden: {p.relative_to(WURZEL)}")
|
||||
return 1
|
||||
|
||||
farben_swift = SWIFT_FARBEN.read_text(encoding="utf-8")
|
||||
akzent_swift = SWIFT_AKZENT.read_text(encoding="utf-8")
|
||||
formen_swift = ohne_tvos(SWIFT_FORMEN.read_text(encoding="utf-8"))
|
||||
css = CSS.read_text(encoding="utf-8")
|
||||
|
||||
abweichungen: list[str] = []
|
||||
geprueft = 0
|
||||
|
||||
for swift_name, css_name in FARBEN.items():
|
||||
a = hex_aus_swift(farben_swift, swift_name)
|
||||
b = token_aus_css(css, css_name)
|
||||
if a is None or b is None:
|
||||
abweichungen.append(f" {swift_name} / {css_name}: nicht gefunden ({a} / {b})")
|
||||
continue
|
||||
geprueft += 1
|
||||
if a.upper() != b.upper():
|
||||
abweichungen.append(f" {swift_name}: Swift {a}, CSS {css_name} {b}")
|
||||
|
||||
# Die Vorgabefarbe der App gegen den Akzent im Browser.
|
||||
vorgabe = re.search(r'identifier\s*==\s*"([a-z]+)"', akzent_swift)
|
||||
if not vorgabe:
|
||||
vorgabe = re.search(r'\.first\s*\{\s*\$0\.id\s*==\s*"([a-z]+)"', akzent_swift)
|
||||
if vorgabe:
|
||||
hex_vorgabe = hex_aus_swift(akzent_swift, vorgabe.group(1))
|
||||
akzent_css = token_aus_css(css, "--accent")
|
||||
if hex_vorgabe and akzent_css:
|
||||
geprueft += 1
|
||||
if hex_vorgabe.upper() != akzent_css.upper():
|
||||
abweichungen.append(
|
||||
f" Vorgabe-Akzent: Swift {vorgabe.group(1)} {hex_vorgabe}, CSS {akzent_css}")
|
||||
|
||||
for swift_name, css_name in RADIEN.items():
|
||||
a = zahl_aus_swift(formen_swift, swift_name)
|
||||
b = token_aus_css(css, css_name)
|
||||
if a is None or b is None:
|
||||
abweichungen.append(f" Radius {swift_name} / {css_name}: nicht gefunden ({a} / {b})")
|
||||
continue
|
||||
geprueft += 1
|
||||
b_zahl = float(re.sub(r"[^0-9.]", "", b) or 0)
|
||||
if abs(a - b_zahl) > 0.01:
|
||||
abweichungen.append(f" Radius {swift_name}: Swift {a:g}, CSS {css_name} {b}")
|
||||
|
||||
if abweichungen:
|
||||
print(f"App und Weboberfläche laufen auseinander ({len(abweichungen)} Stelle(n)):")
|
||||
print("\n".join(abweichungen))
|
||||
print("\nFlaroDesign ist massgeblich — die CSS zieht nach.")
|
||||
return 1
|
||||
|
||||
print(f"App und Weboberfläche stimmen überein ({geprueft} Werte geprüft).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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)
|
||||
+59
-31
@@ -42,41 +42,36 @@ Ein Mac baut ohne Angabe **arm64** — also gehört die Angabe dazu.
|
||||
|
||||
## Schritt 2: Das Image auf das NAS bringen
|
||||
|
||||
Drei Wege. Der erste ist der bequemste, der zweite braucht keine Registry.
|
||||
**Das NAS baut nicht, es lädt nur.** Genau wie bei Jellyfin oder jedem
|
||||
anderen Container: dort baut jemand anderes und veröffentlicht, du ziehst
|
||||
das Ergebnis. Bei Flaro ist dieser Jemand dein Arbeitsrechner — er ist für
|
||||
die Apps ohnehin nötig, weil Xcode nirgendwo sonst läuft.
|
||||
|
||||
Drei Wege. Der erste ist der vorgesehene; der zweite braucht keine Registry.
|
||||
|
||||
### Weg A — über die Registry deines Gitea (empfohlen)
|
||||
|
||||
Dein Gitea unter `git.scarriffle.com` bringt eine Container-Registry mit.
|
||||
Einmal einrichten, danach ist jedes Update ein `docker compose pull`.
|
||||
|
||||
#### A1 · Gitea baut selbst
|
||||
#### A1 · Anmelden, einmalig
|
||||
|
||||
`.gitea/workflows/image.yml` liegt im Projekt. Bei jedem Push auf `main`
|
||||
baut Gitea das Image für **beide** Architekturen und legt es ab — ohne dass
|
||||
jemand daran denken muss, und ohne Anmeldung am Arbeitsrechner.
|
||||
|
||||
**Voraussetzung ist ein registrierter Act-Runner.** Hat dein Gitea keinen,
|
||||
passiert nichts; dann gilt A2. Nachsehen unter
|
||||
*Einstellungen → Actions → Runner*. Ein Runner lässt sich als Container
|
||||
neben Gitea betreiben:
|
||||
|
||||
```bash
|
||||
docker run -d --restart unless-stopped --name gitea-runner \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e GITEA_INSTANCE_URL=https://git.scarriffle.com \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<Token aus den Einstellungen> \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
|
||||
#### A2 · Von Hand vom Arbeitsrechner
|
||||
|
||||
Einmalig anmelden — das Passwort tippst du selbst ein:
|
||||
Nicht mit dem Kontopasswort, sondern mit einem Zugriffstoken: *Gitea →
|
||||
Einstellungen → Anwendungen → Zugriffstoken*, Berechtigungen
|
||||
`write:package` und `read:package`. Ein Token lässt sich einzeln widerrufen,
|
||||
und bei eingeschalteter Zwei-Faktor-Anmeldung funktioniert das Passwort hier
|
||||
gar nicht.
|
||||
|
||||
```bash
|
||||
docker login git.scarriffle.com
|
||||
```
|
||||
|
||||
Danach genügt ein Befehl; er baut amd64 **und** arm64 und lädt hoch:
|
||||
Benutzername ist dein Gitea-Name, als Passwort das Token.
|
||||
|
||||
#### A2 · Bauen und hochladen
|
||||
|
||||
Ein Befehl; er baut amd64 **und** arm64 und lädt beides als ein Manifest
|
||||
hoch:
|
||||
|
||||
```bash
|
||||
Tools/image-veroeffentlichen.sh
|
||||
@@ -88,6 +83,13 @@ Mit einer Fassungsnummer, wenn du sie festnageln willst:
|
||||
Tools/image-veroeffentlichen.sh 0.2.0
|
||||
```
|
||||
|
||||
Nur die Architektur des NAS — deutlich schneller, weil die fremde sonst
|
||||
unter Emulation gebaut wird:
|
||||
|
||||
```bash
|
||||
FLARO_PLATTFORMEN=linux/amd64 Tools/image-veroeffentlichen.sh
|
||||
```
|
||||
|
||||
#### A3 · Das Paket öffentlich stellen
|
||||
|
||||
Sonst muss sich auch das NAS anmelden. In Gitea unter
|
||||
@@ -108,9 +110,15 @@ kommt:
|
||||
|
||||
**Bestätigen** — danach findet die Suche in der Docker-App auch
|
||||
`scarriffle/flaro`. Ist das Paket privat, trägst du unter
|
||||
*Registierungseinstellungen* Benutzername und ein Gitea-Zugriffstoken ein
|
||||
(nicht dein Kontopasswort: ein Token lässt sich einzeln widerrufen, unter
|
||||
*Einstellungen → Anwendungen → Zugriffstoken*, Berechtigung `read:package`).
|
||||
*Registierungseinstellungen* Benutzername und dasselbe Zugriffstoken ein.
|
||||
|
||||
#### A5 · Das Image herunterladen, bevor du das Projekt anlegst
|
||||
|
||||
**Die Docker-App zieht Images nicht selbst.** Steht in einer Compose-Datei
|
||||
ein Image, das lokal fehlt, bricht sie das Anlegen mit „Bitte überprüfen Sie
|
||||
die Konfigurationsinformationen" ab — ohne zu sagen, woran es liegt. Also
|
||||
vorher **Image → Herunterladen**, Registry `gitea`, Quellname
|
||||
`scarriffle/flaro:latest`.
|
||||
|
||||
In der Compose-Datei steht dann:
|
||||
|
||||
@@ -141,11 +149,23 @@ docker load < /volume1/docker/flaro.tar.gz
|
||||
|
||||
In der Compose-Datei steht dann schlicht `image: flaro:latest`.
|
||||
|
||||
### Weg C — auf dem NAS bauen
|
||||
### Weg C — Gitea baut selbst
|
||||
|
||||
Geht, lohnt sich aber selten: der Bau braucht den Quelltext, Node, das
|
||||
.NET-SDK als Zwischenschritt und ordentlich Arbeitsspeicher. Die Docker-App
|
||||
von UGREEN kann `build:` in einer Compose-Datei ausserdem nicht immer.
|
||||
`.gitea/workflows/image.yml` liegt im Projekt und kann denselben Bau auf
|
||||
einem Act-Runner ausführen. **Gitea verteilt Arbeit nur, es baut nicht
|
||||
selbst** — ohne registrierten Runner passiert nichts.
|
||||
|
||||
Der Runner müsste auf dem NAS laufen, und daran scheitert es dort: er
|
||||
braucht Zugriff auf Docker, denn sein Zweck ist das Bauen von Images. Die
|
||||
Docker-App des UGREEN lehnt den Einhängepunkt `/var/run/docker.sock` ab —
|
||||
geprüft, indem die Compose-Datei Zeile für Zeile aufgebaut wurde: ohne diese
|
||||
eine Zeile wird das Projekt angelegt, mit ihr nicht. Übrig bleiben
|
||||
`privileged: true` mit einem Docker-in-Docker-Image oder der Weg über SSH an
|
||||
der Oberfläche vorbei. Beide geben dem Runner faktisch Root auf dem NAS.
|
||||
|
||||
Deshalb löst der Workflow **nicht** mehr bei jedem Push aus — sonst sammeln
|
||||
sich Läufe an, die nie jemand abarbeitet. Er steht auf `workflow_dispatch`
|
||||
und lässt sich in Gitea von Hand starten, sobald einmal ein Runner da ist.
|
||||
|
||||
---
|
||||
|
||||
@@ -253,7 +273,15 @@ keine Stelle, die das könnte.
|
||||
|
||||
## Aktualisieren
|
||||
|
||||
Mit Weg A:
|
||||
**Zwei Schritte, zwei Maschinen.** Erst baut der Arbeitsrechner die neue
|
||||
Fassung und legt sie in die Registry:
|
||||
|
||||
```bash
|
||||
Tools/image-veroeffentlichen.sh
|
||||
```
|
||||
|
||||
Dann holt das NAS sie. In der Docker-App genügt das Aktualisieren des
|
||||
Projekts; über die Kommandozeile:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<key id="15706be2-d1ec-4fa7-918b-920e87c4acce" version="1">
|
||||
<creationDate>2026-10-07T19:35:00.68989Z</creationDate>
|
||||
<activationDate>2026-10-07T19:35:00.68989Z</activationDate>
|
||||
<expirationDate>2027-01-05T19:35:00.68989Z</expirationDate>
|
||||
<descriptor deserializerType="Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel.AuthenticatedEncryptorDescriptorDeserializer, Microsoft.AspNetCore.DataProtection, Version=10.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
|
||||
<descriptor>
|
||||
<encryption algorithm="AES_256_CBC" />
|
||||
<validation algorithm="HMACSHA256" />
|
||||
<masterKey p4:requiresEncryption="true" xmlns:p4="http://schemas.asp.net/2015/03/dataProtection">
|
||||
<!-- Warning: the key below is in an unencrypted form. -->
|
||||
<value>oyFqIl0Q27Th3X+CX07TX95s7kMGEQ2OkjOUJEJVkgK7LeTccP4tp3FeJnMzLSuu7rsKVIJa4xINONp+0AOAWg==</value>
|
||||
</masterKey>
|
||||
</descriptor>
|
||||
</descriptor>
|
||||
</key>
|
||||
@@ -0,0 +1,5 @@
|
||||
21:35:00 INFO Server gestartet — 1.0.0 · DEV
|
||||
21:55:29 INFO Server fährt herunter.
|
||||
22:00:35 INFO Server gestartet — 1.0.0 · DEV
|
||||
22:11:06 INFO Server fährt herunter.
|
||||
22:14:53 INFO Server gestartet — 1.0.0 · DEV
|
||||
@@ -0,0 +1,17 @@
|
||||
06:38:47 INFO Server fährt herunter.
|
||||
06:44:02 INFO Server gestartet — 1.0.0 · DEV
|
||||
06:57:23 INFO Server fährt herunter.
|
||||
06:59:45 INFO Server gestartet — 1.0.0 · DEV
|
||||
17:38:34 INFO Server fährt herunter.
|
||||
17:57:11 INFO Server gestartet — 1.0.0 · DEV
|
||||
19:05:40 INFO Server fährt herunter.
|
||||
19:08:22 INFO Server gestartet — 1.0.0 · DEV
|
||||
21:03:32 INFO Server fährt herunter.
|
||||
21:08:04 INFO Server gestartet — 1.0.0 · DEV
|
||||
21:10:04 INFO Wartung — Sicherung: flaro-2026-10-08-1910.db, 0 MB
|
||||
21:10:04 INFO Wartung — Optimieren: nichts zusammenzupacken
|
||||
21:10:04 INFO Wartung — Bilder: kein Bildspeicher da
|
||||
21:45:40 INFO Server fährt herunter.
|
||||
21:45:44 INFO Server gestartet — 1.0.0 · DEV
|
||||
22:06:03 INFO Server fährt herunter.
|
||||
22:12:27 INFO Server gestartet — 1.0.0 · DEV
|
||||
@@ -0,0 +1 @@
|
||||
21:17:00 INFO Wartung — Sicherung: flaro-2026-10-11-1917.db, 0 MB
|
||||
@@ -0,0 +1 @@
|
||||
21:26:01 INFO Wartung — Sicherung: flaro-2026-10-14-1926.db, 0 MB
|
||||
@@ -0,0 +1,2 @@
|
||||
21:10:21 INFO Wartung — Optimieren: nichts zusammenzupacken
|
||||
21:10:21 INFO Wartung — Bilder: kein Bildspeicher da
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,23 @@
|
||||
<Project>
|
||||
|
||||
<!--
|
||||
Eine kaputte Protokollvorlage ist kein Schönheitsfehler.
|
||||
|
||||
`log.LogInformation("… {Folgen} … {Folgen}.", a, b, c, d, e)` wirft zur
|
||||
Laufzeit — jeder Platzhalter verbraucht ein Argument, auch ein
|
||||
wiederholter. Geschieht das in einem Hintergrundlauf, reisst die
|
||||
Ausnahme den ganzen Lauf mit, und alles, was er bis dahin gefunden
|
||||
hatte, ist verloren. Genau so ist es der Markenerkennung ergangen.
|
||||
|
||||
CA2017 hatte das längst gemeldet — als Warnung unter hundert anderen
|
||||
Zeilen, und damit übersehen. Als Fehler ist es nicht zu übersehen.
|
||||
|
||||
Nur diese eine Regel, nicht alle Warnungen: ein pauschales
|
||||
`TreatWarningsAsErrors` wäre eine andere Entscheidung und gehört nicht
|
||||
nebenbei getroffen.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);CA2017</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
+36
-2
@@ -48,7 +48,8 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
ARG TARGETARCH
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ffmpeg libva2 libva-drm2 mesa-va-drivers \
|
||||
ffmpeg libva2 libva-drm2 mesa-va-drivers curl \
|
||||
libchromaprint-tools \
|
||||
&& if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \
|
||||
fi \
|
||||
@@ -67,17 +68,50 @@ RUN mkdir -p /config /cache /transcode && chown -R 1000:1000 /config /cache /tra
|
||||
# braucht weder ein Startskript noch root im Container.
|
||||
USER 1000:1000
|
||||
|
||||
# **Kein eigenes ASPNETCORE_URLS.** Das Basisimage setzt seit .NET 8 selbst
|
||||
# ASPNETCORE_HTTP_PORTS=8080; beides zusammen ergab bei jedem Start die
|
||||
# Warnung „Overriding HTTP_PORTS '8080'". Zwei Wege zum selben Port sind
|
||||
# einer zu viel.
|
||||
ENV FLARO_DATA=/config \
|
||||
FLARO_CACHE=/cache \
|
||||
FLARO_TRANSCODE=/transcode \
|
||||
ASPNETCORE_URLS=http://+:8080 \
|
||||
DOTNET_gcServer=0
|
||||
|
||||
# **Welches Abbild das hier ist.**
|
||||
#
|
||||
# Sechs Zeichen, bei jedem Bau neu gewürfelt — auch dann, wenn am Quelltext
|
||||
# nichts anders ist. Die Version sagt, welcher Stand gemeint ist; diese
|
||||
# Kennung sagt, welches Abbild wirklich läuft.
|
||||
#
|
||||
# Das ist keine Spielerei: zwei Funktionen standen nachweislich im Quelltext
|
||||
# und waren auf dem Server trotzdem nicht zu sehen. Ob dort ein älteres
|
||||
# Abbild lief, war nicht feststellbar, und die Fehlersuche lief einen halben
|
||||
# Tag ins Leere. Seitdem beantwortet ein Blick die erste aller Fragen.
|
||||
#
|
||||
# Ganz unten, weil jede Änderung daran alle folgenden Schichten neu baut —
|
||||
# und das sind hier nur noch die beiden COPY-Zeilen.
|
||||
ARG FLARO_BUILD=DEV
|
||||
ENV FLARO_BUILD=${FLARO_BUILD}
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
COPY --from=web /web/dist ./wwwroot
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# **Eine Gesundheitsprüfung, die den Server fragt — nicht die Laufzeit.**
|
||||
#
|
||||
# Ohne sie weiss Docker nur, dass ein Prozess läuft. Die Oberfläche eines NAS
|
||||
# zeigt dann minutenlang „wird bereitgestellt", während der Server längst
|
||||
# antwortet, und niemand kann unterscheiden, ob er noch wandert oder hängt.
|
||||
#
|
||||
# `/health` und nicht `dotnet --info`: letzteres sagt nur, dass die Laufzeit
|
||||
# da ist, und das war sie auch, als der Server noch migrierte.
|
||||
#
|
||||
# 60 Sekunden Anlaufzeit, weil die Migrationen beim ersten Start einer
|
||||
# gefüllten Datenbank länger brauchen als ein Startvorgang sonst.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD curl -fsS http://localhost:8080/health || exit 1
|
||||
VOLUME ["/config", "/cache", "/transcode"]
|
||||
|
||||
ENTRYPOINT ["dotnet", "Flaro.Api.dll"]
|
||||
|
||||
@@ -31,6 +31,20 @@ services:
|
||||
# Datenbank oder ein zweiter Server finden Zuordnungen und Bilder wieder.
|
||||
- FLARO_WRITE_SIDECARS=true
|
||||
|
||||
# Womit das Bild gerechnet wird, wenn eine Wiedergabe umkodiert werden
|
||||
# muss — weil der Browser den Codec nicht kennt oder eine kleinere
|
||||
# Auflösung gewählt wurde.
|
||||
#
|
||||
# auto prüft, ob /dev/dri/renderD128 da ist, und nimmt sonst
|
||||
# den Hauptprozessor. Das ist die Voreinstellung.
|
||||
# vaapi erzwingt die Grafikeinheit. Fehlt sie, scheitert die
|
||||
# Wiedergabe — nützlich, um zu prüfen, ob sie wirklich greift.
|
||||
# none immer in Software. Langsam, aber überall gleich.
|
||||
#
|
||||
# **Zum blossen Umpacken wird nichts davon gebraucht.** Eine MKV mit
|
||||
# H.264 nach MP4 zu schachteln kostet den Server fast nichts.
|
||||
# - FLARO_HWACCEL=auto
|
||||
|
||||
# Wem die Dateien auf dem Wirt gehören. 1000:1000 ist die Voreinstellung
|
||||
# im Image; wer andere Kennungen hat, trägt sie hier ein — `id -u` und
|
||||
# `id -g` auf dem Wirt sagen, welche.
|
||||
@@ -67,9 +81,7 @@ services:
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "dotnet", "--info"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
# Die Gesundheitsprüfung steckt im Image und fragt /health ab — hier
|
||||
# braucht es sie nicht noch einmal. Die alte Fassung prüfte `dotnet
|
||||
# --info` und sagte damit nur, dass die Laufzeit da ist; das war sie auch,
|
||||
# während der Server noch migrierte.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Ein Gitea-Runner auf dem NAS.
|
||||
#
|
||||
# **Nicht in Betrieb — und auf einem UGREEN-NAS nicht über die Docker-App
|
||||
# anzulegen.** Deren Compose-Prüfung lehnt den Einhängepunkt
|
||||
# `/var/run/docker.sock` ab. Nachgewiesen durch Halbieren: dieselbe Datei
|
||||
# ohne diese eine Zeile wird angelegt, mit ihr kommt nur „Bitte überprüfen
|
||||
# Sie die Konfigurationsinformationen", ohne Angabe eines Grundes.
|
||||
#
|
||||
# Was bliebe: `gitea/act_runner:latest-dind-rootless` mit `privileged: true`
|
||||
# statt des Socket, oder die Datei über SSH ablegen und dort von Hand
|
||||
# starten. Beides gibt dem Runner faktisch Root auf dem NAS, und bei einem
|
||||
# öffentlichen Repo reicht dann unter Umständen ein fremder Pull Request,
|
||||
# um Code darauf auszuführen. Deshalb baut bis auf Weiteres der
|
||||
# Arbeitsrechner — siehe `Tools/image-veroeffentlichen.sh`.
|
||||
#
|
||||
# Die Datei bleibt liegen, damit der Weg beim nächsten Mal nicht noch
|
||||
# einmal von vorn untersucht werden muss.
|
||||
#
|
||||
# **Wozu.** Gitea verteilt Arbeit nur, es baut nicht selbst. Ohne einen
|
||||
# registrierten Runner legt es bei jedem Push einen Lauf an, der dann
|
||||
# liegenbleibt. Mit diesem hier baut das NAS das Flaro-Image selbst und legt
|
||||
# es in die Registry desselben Gitea — danach braucht es keinen Arbeits-
|
||||
# rechner mehr.
|
||||
#
|
||||
# **Das Token holst du dir vorher:**
|
||||
# Gitea → Repo → Einstellungen → Actions → Runner → Neuen Runner erstellen
|
||||
#
|
||||
# Ein Runner auf Organisations- oder Serverebene bedient alle Repos; das
|
||||
# Token dafür steht in der jeweiligen Verwaltung.
|
||||
|
||||
services:
|
||||
runner:
|
||||
image: gitea/act_runner:latest
|
||||
container_name: gitea-runner
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
- GITEA_INSTANCE_URL=https://git.scarriffle.com
|
||||
# Hier das Token aus der Gitea-Oberfläche eintragen.
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=
|
||||
- GITEA_RUNNER_NAME=nas
|
||||
# **Welche Marken dieser Runner bedient.** `ubuntu-latest` ist die, die
|
||||
# der Flaro-Workflow in `runs-on` nennt. Ohne diese Zeile meldet sich
|
||||
# der Runner mit den Vorgaben an, und ein Lauf findet ihn womöglich
|
||||
# nicht.
|
||||
- GITEA_RUNNER_LABELS=ubuntu-latest:docker://catthehacker/ubuntu:act-latest
|
||||
|
||||
volumes:
|
||||
# **Der Docker-Socket ist der Kern.** Der Runner baut nicht selbst,
|
||||
# sondern lässt den Docker des NAS bauen. Ohne ihn scheitert jeder
|
||||
# Schritt, der ein Image anfasst.
|
||||
#
|
||||
# Das ist zugleich die Abwägung: wer in diesen Container kommt, kann
|
||||
# Docker auf dem NAS steuern. Deshalb gehört er nicht ins Internet und
|
||||
# nimmt nur Arbeit von deinem eigenen Gitea an.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Anmeldung und Zwischenstände. Ohne dieses Volume meldet sich der
|
||||
# Runner nach jedem Neustart neu an, und in der Liste sammeln sich
|
||||
# Karteileichen.
|
||||
- /volume4/CRITICAL/Docker/gitea-runner:/data
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Wie hoch der Aufmacher auf der Startseite steht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Vier Stufen, keine Zahl.</b> Eine Pixelzahl einzustellen hiesse, sie
|
||||
/// für jeden Bildschirm neu einzustellen — der Aufmacher misst sich an der
|
||||
/// Fensterhöhe, nicht an einer festen Grösse. Vier Wörter sind das, was man
|
||||
/// dazu zu sagen hat.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Was die Stufen bedeuten, steht im Regelwerk</b>, nicht hier: der Server
|
||||
/// weiss nichts von Bildpunkten, er merkt sich nur die Wahl. Nachzulesen bei
|
||||
/// <c>.hero</c> in <c>style.css</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Serverweit.</b> Dieselbe Linie wie bei den Wiedergaberegeln: eine
|
||||
/// Entscheidung des Gastgebers, nicht des Gastes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Aufmacherhoehe
|
||||
{
|
||||
public const string Schlüssel = "darstellung.aufmacher.hoehe";
|
||||
|
||||
/// <summary>Die Mitte — das, was vorher fest verdrahtet war.</summary>
|
||||
public const string Vorgabe = "mittel";
|
||||
|
||||
/// <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 Stufen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Unbekanntes wird zur Vorgabe statt zu einem Fehler: in der Spalte
|
||||
/// steht, was irgendwann einmal hineingeschrieben wurde, und eine
|
||||
/// Startseite, die daran scheitert, wäre die schlechtere Antwort.
|
||||
/// </remarks>
|
||||
public static string Lesen(string? wert)
|
||||
{
|
||||
var blank = wert?.Trim().ToLowerInvariant() ?? "";
|
||||
return Stufen.Contains(blank) ? blank : Vorgabe;
|
||||
}
|
||||
|
||||
/// <summary>Ob das eine Stufe ist, die es gibt.</summary>
|
||||
public static bool Kennt(string? wert) =>
|
||||
wert is not null && Stufen.Contains(wert.Trim().ToLowerInvariant());
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace Flaro.Api.Auth;
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>Authorization: Bearer <token></c>. Der Server hasht das Vorgezeigte
|
||||
/// und sucht den Fingerabdruck — das Token selbst steht nirgends in der
|
||||
/// und sucht den Fingerfingerprint — das Token selbst steht nirgends in der
|
||||
/// Datenbank und ist aus ihr auch nicht zu gewinnen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -69,8 +69,8 @@ public sealed class TokenAuthenticationHandler(
|
||||
var token = AusKopfzeile() ?? Request.Cookies[CookieName];
|
||||
if (string.IsNullOrEmpty(token)) return AuthenticateResult.NoResult();
|
||||
|
||||
var fingerabdruck = Secrets.Fingerprint(token);
|
||||
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerabdruck);
|
||||
var fingerfingerprint = Secrets.Fingerprint(token);
|
||||
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerfingerprint);
|
||||
|
||||
var jetzt = clock.GetUtcNow();
|
||||
if (eintrag is null || !eintrag.IsValid(jetzt)) return AuthenticateResult.Fail("Token gilt nicht.");
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Welche Version läuft — und welcher Build genau.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum zwei Angaben.</b> Die Version sagt, welcher Stand gemeint ist;
|
||||
/// sie steht fest und ändert sich nur von Hand. Der Build sagt, welches
|
||||
/// Abbild tatsächlich läuft, und ändert sich bei <i>jedem</i> Bau — auch
|
||||
/// dann, wenn am Quelltext nichts anders ist.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Und warum das nötig wurde.</b> Es hat einen halben Tag gekostet: zwei
|
||||
/// Funktionen waren nachweislich im Quelltext und auf dem Server trotzdem
|
||||
/// nicht zu sehen. Ob dort ein älteres Abbild lief, liess sich nicht
|
||||
/// feststellen — der Server sagte es nirgends. Jede Fehlersuche lief damit
|
||||
/// ins Leere, weil die erste Frage unbeantwortbar war: reden wir vom selben
|
||||
/// Build?
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Sechs Zeichen, Grossbuchstaben und Ziffern.</b> Kurz genug, um sie
|
||||
/// vorzulesen und abzutippen, lang genug, um nicht zufällig zweimal
|
||||
/// vorzukommen. Ohne <c>I</c>, <c>O</c>, <c>0</c> und <c>1</c>, weil die
|
||||
/// beim Vorlesen verwechselt werden.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Buildkennung
|
||||
{
|
||||
/// <summary>Die feste Version. Steigt nur, wenn jemand sie hier ändert.</summary>
|
||||
public const string Version = "1.0.0";
|
||||
|
||||
/// <summary>Die Kennung dieses Abbilds — oder <c>DEV</c> ausserhalb eines Containers.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Gesetzt wird sie beim Bau des Abbilds über <c>FLARO_BUILD</c>. Fehlt
|
||||
/// sie, läuft der Server aus dem Quellbaum: dann ist <c>DEV</c> die
|
||||
/// ehrlichere Angabe als eine erfundene Kennung, die nichts bezeichnet.
|
||||
/// </remarks>
|
||||
public static string Build { get; } =
|
||||
Environment.GetEnvironmentVariable("FLARO_BUILD") is { Length: > 0 } wert
|
||||
? wert.Trim()
|
||||
: "DEV";
|
||||
|
||||
/// <summary>„1.0.0 · K7F2QX" — für Protokoll und Anzeige.</summary>
|
||||
public static string Beides => $"{Version} · {Build}";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Ein abgelehnter Versuch — mit einem Satz, den die Clients auch lesen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Es gab drei Formen, und zwei davon kamen nie an.</b> Gemessen: die
|
||||
/// Oberfläche las bei jedem abgelehnten Versuch „Die Anfrage war so nicht zu
|
||||
/// gebrauchen" — ihren eigenen Ersatzsatz für eine 400 ohne Begründung. Dabei
|
||||
/// hatte der Server sehr wohl einen deutschen Satz geschrieben, nur an eine
|
||||
/// Stelle, an der niemand nachsieht.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>Results.ValidationProblem</c> legt ihn unter <c>errors</c> ab,
|
||||
/// <c>Results.BadRequest(new { fehler = … })</c> unter <c>fehler</c>. Beide
|
||||
/// Clients lesen <c>detail</c> — der Browser in <c>api.ts</c>, die Apps in
|
||||
/// <c>FlaroClient.swift</c> —, und zwar aus gutem Grund: in <c>title</c> und in
|
||||
/// <c>errors</c> stehen sonst die englischen Prüftexte des Rahmenwerks, und
|
||||
/// „Not Found" ist keine Auskunft. Nur stand in <c>detail</c> an 28 Stellen
|
||||
/// eben gar nichts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Also eine Form.</b> <c>Results.Problem(detail, …)</c> füllt genau das
|
||||
/// Feld, das gelesen wird. Dass es hier durch drei benannte Türen geht statt
|
||||
/// durch einen Aufruf mit Zahl, hat einen Grund: so lässt sich nachsehen, wo
|
||||
/// ein Server etwas ablehnt, und man sieht am Wort, ob es eine Bitte um eine
|
||||
/// bessere Eingabe war, etwas Fehlendes oder ein Zusammenstoss.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nicht für Ausnahmen.</b> Was unerwartet schiefgeht, übersetzt
|
||||
/// <see cref="Fehlertext"/>. Hier geht es um Absichten, die der Server
|
||||
/// zurückweist — nicht um Unfälle.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Einwand
|
||||
{
|
||||
/// <summary>400 — so war die Eingabe nicht zu gebrauchen.</summary>
|
||||
public static IResult Bitte(string satz) =>
|
||||
Results.Problem(satz, statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
/// <summary>404 — das, worauf es sich bezieht, gibt es nicht.</summary>
|
||||
public static IResult Fehlt(string satz) =>
|
||||
Results.Problem(satz, statusCode: StatusCodes.Status404NotFound);
|
||||
|
||||
/// <summary>409 — es gibt schon etwas, das im Weg steht.</summary>
|
||||
public static IResult Belegt(string satz) =>
|
||||
Results.Problem(satz, statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Globalization;
|
||||
using Flaro.Core.Media;
|
||||
using Flaro.Core.Metadata;
|
||||
using Flaro.Data;
|
||||
using Flaro.Metadata;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
/// <summary>Plakat, Hintergrund und Schriftzug von Hand wählen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Wofür.</b> Es gab genau einen Bild-Endpunkt, und der lieferte nur aus.
|
||||
/// Wählte die Metadatenquelle ein schlechtes Plakat, blieb es — und die
|
||||
/// einzige Abhilfe war, den ganzen Titel einem anderen Eintrag zuzuordnen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Die Kandidaten kosten nichts.</b> TMDB liefert sie beim Einzelabruf
|
||||
/// ohnehin mit; bisher wurde alles ausser dem Schriftzug weggeworfen.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ArtworkEndpoints
|
||||
{
|
||||
/// <summary>Höchstens so gross darf ein eigenes Bild sein.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Zehn Megabyte reichen für jedes Plakat. Ohne Grenze lädt irgendwann
|
||||
/// jemand ein Foto hoch, und der Cache eines NAS ist nicht dafür da.
|
||||
/// </remarks>
|
||||
private const long HoechstensBytes = 10 * 1024 * 1024;
|
||||
|
||||
public static void MapArtwork(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var gruppe = app.MapGroup("/api/v1")
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
gruppe.MapGet("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||
string art, Guid id, FlaroContext db, TmdbClient tmdb, FanartClient fanart,
|
||||
ITmdbSettingsSource spracheVon, CancellationToken ct) =>
|
||||
{
|
||||
var serie = art == "series";
|
||||
var kennung = serie
|
||||
? await db.Series.Where(s => s.Id == id).Select(s => s.TmdbId).FirstOrDefaultAsync(ct)
|
||||
: await db.Movies.Where(m => m.Id == id).Select(m => m.TmdbId).FirstOrDefaultAsync(ct);
|
||||
|
||||
var roh = serie
|
||||
? await db.Series.Where(s => s.Id == id)
|
||||
.Select(s => new { s.PosterImage, s.BackdropImage, s.LogoImage,
|
||||
s.PosterPinned, s.BackdropPinned, s.LogoPinned, s.Aufmacherbilder })
|
||||
.FirstOrDefaultAsync(ct)
|
||||
: await db.Movies.Where(m => m.Id == id)
|
||||
.Select(m => new { m.PosterImage, m.BackdropImage, m.LogoImage,
|
||||
m.PosterPinned, m.BackdropPinned, m.LogoPinned, m.Aufmacherbilder })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
var aktuell = roh is null ? null : new Bildstand(
|
||||
roh.PosterImage, roh.BackdropImage, roh.LogoImage,
|
||||
roh.PosterPinned, roh.BackdropPinned, roh.LogoPinned,
|
||||
Aufmacherwahl.Lesen(roh.Aufmacherbilder));
|
||||
|
||||
if (aktuell is null) return Results.NotFound();
|
||||
|
||||
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die
|
||||
// Zuordnung das Problem, nicht das Bild.
|
||||
IReadOnlyList<Bildkandidat> kandidaten = [];
|
||||
if (kennung is { } k)
|
||||
{
|
||||
var ausTmdb = await tmdb.BilderAsync(k, serie, ct);
|
||||
|
||||
// **fanart.tv nur bei Filmen.** Deren Serienweg verlangt eine
|
||||
// TVDB-Kennung, und die speichert Flaro nicht. Lieber eine
|
||||
// Beschränkung, die man sieht, als eine, die stillschweigend
|
||||
// nichts tut.
|
||||
var ausFanart = serie
|
||||
? []
|
||||
: await fanart.BilderAsync(k.ToString(CultureInfo.InvariantCulture), ct);
|
||||
|
||||
var sprache = (await spracheVon.GetAsync(ct)).Language;
|
||||
var kuerzel = sprache.Split('-')[0];
|
||||
|
||||
// **Gemeinsam geordnet, nicht hintereinandergehängt.** Ein
|
||||
// deutsches Plakat von fanart.tv gehört vor ein sprachloses
|
||||
// von TMDB — das ist der Grund, warum es die zweite Quelle
|
||||
// überhaupt gibt.
|
||||
kandidaten = Bildrang.Geordnet(ausTmdb.Concat(ausFanart), kuerzel).ToList();
|
||||
}
|
||||
|
||||
return Results.Ok(new Bildauswahl(aktuell, kandidaten));
|
||||
});
|
||||
|
||||
gruppe.MapPut("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||
string art, Guid id, BildWahlRequest anfrage,
|
||||
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||
{
|
||||
if (anfrage.Kind is not ("poster" or "backdrop" or "logo"))
|
||||
return Einwand.Bitte("Erwartet: poster, backdrop oder logo.");
|
||||
|
||||
// Das Bild wandert in den Cache, bevor irgendetwas gesetzt wird —
|
||||
// sonst zeigt der Eintrag auf etwas, das es hier nicht gibt.
|
||||
var schluessel = string.IsNullOrWhiteSpace(anfrage.Url)
|
||||
? null
|
||||
: await ablage.StoreAsync(new RemoteImage(anfrage.Url), ct);
|
||||
|
||||
if (anfrage.Url is { Length: > 0 } && schluessel is null)
|
||||
return Results.Problem("Das Bild liess sich nicht laden.", statusCode: 502);
|
||||
|
||||
var gesetzt = art == "series"
|
||||
? await Setzen(db.Series.Where(s => s.Id == id), anfrage.Kind, schluessel, ct)
|
||||
: await Setzen(db.Movies.Where(m => m.Id == id), anfrage.Kind, schluessel, ct);
|
||||
|
||||
return gesetzt ? Results.NoContent() : Results.NotFound();
|
||||
});
|
||||
|
||||
// **Die Mehrfachauswahl fürs Karussell.**
|
||||
//
|
||||
// Poster, Hintergrund und Schriftzug bleiben einzeln: sie stehen
|
||||
// hinter Kacheln und Kopfzeilen, und dort soll nichts wechseln. Der
|
||||
// Aufmacher dagegen ist eine Bühne — wer fünf Aufnahmen ausgesucht
|
||||
// hat, soll sie auch zu sehen bekommen.
|
||||
gruppe.MapPut("/{art:regex(^movies|series$)}/{id:guid}/artwork/aufmacher", async (
|
||||
string art, Guid id, AufmacherWahlRequest anfrage,
|
||||
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||
{
|
||||
if (anfrage.Urls.Count > Aufmacherwahl.Hoechstens)
|
||||
return Einwand.Bitte($"Höchstens {Aufmacherwahl.Hoechstens} Bilder.");
|
||||
|
||||
// Erst alle in den Zwischenspeicher, dann setzen: sonst zeigte
|
||||
// die Auswahl auf etwas, das es hier nicht gibt. Was sich nicht
|
||||
// laden lässt, fällt heraus — ein einzelnes totes Bild soll
|
||||
// nicht die ganze Auswahl verhindern.
|
||||
var schluessel = new List<string>();
|
||||
foreach (var url in anfrage.Urls.Take(Aufmacherwahl.Hoechstens))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) continue;
|
||||
|
||||
// **Was schon hier liegt, bleibt liegen.** Der Client
|
||||
// schickt eine Mischung: die bereits gewählten Bilder als
|
||||
// Zwischenspeicherschlüssel und die neuen als Adresse. Einen
|
||||
// Schlüssel noch einmal herunterladen zu wollen hiesse, eine
|
||||
// Adresse daraus zu raten, die es nicht gibt.
|
||||
if (!url.Contains("://", StringComparison.Ordinal))
|
||||
{
|
||||
schluessel.Add(url);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await ablage.StoreAsync(new RemoteImage(url), ct) is { } k) schluessel.Add(k);
|
||||
}
|
||||
|
||||
var zeile = Aufmacherwahl.Schreiben(schluessel);
|
||||
|
||||
var betroffen = art == "series"
|
||||
? await db.Series.Where(s => s.Id == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Aufmacherbilder, zeile), ct)
|
||||
: await db.Movies.Where(m => m.Id == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Aufmacherbilder, zeile), ct);
|
||||
|
||||
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
|
||||
});
|
||||
|
||||
gruppe.MapPost("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||
string art, Guid id, string kind, HttpRequest anfrage,
|
||||
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||
{
|
||||
if (kind is not ("poster" or "backdrop" or "logo"))
|
||||
return Einwand.Bitte("Erwartet: poster, backdrop oder logo.");
|
||||
if (!anfrage.HasFormContentType) return Einwand.Bitte("Kein Formular.");
|
||||
|
||||
var form = await anfrage.ReadFormAsync(ct);
|
||||
var datei = form.Files.FirstOrDefault();
|
||||
if (datei is null || datei.Length == 0)
|
||||
return Einwand.Bitte("Keine Datei dabei.");
|
||||
if (datei.Length > HoechstensBytes)
|
||||
return Einwand.Bitte("Höchstens 10 MB.");
|
||||
|
||||
await using var strom = datei.OpenReadStream();
|
||||
var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct);
|
||||
if (schluessel is null) return Results.Problem("Das Bild liess sich nicht ablegen.", statusCode: 502);
|
||||
|
||||
var gesetzt = art == "series"
|
||||
? await Setzen(db.Series.Where(s => s.Id == id), kind, schluessel, ct)
|
||||
: await Setzen(db.Movies.Where(m => m.Id == id), kind, schluessel, ct);
|
||||
|
||||
return gesetzt ? Results.Ok(new { image = schluessel }) : Results.NotFound();
|
||||
}).DisableAntiforgery();
|
||||
}
|
||||
|
||||
/// <summary>Setzt ein Bild und merkt sich, dass es von Hand kam.</summary>
|
||||
private static async Task<bool> Setzen<T>(
|
||||
IQueryable<T> abfrage, string art, string? schluessel, CancellationToken ct)
|
||||
where T : class, IBebildert
|
||||
{
|
||||
var betroffen = art switch
|
||||
{
|
||||
"poster" => await abfrage.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.PosterImage, schluessel)
|
||||
.SetProperty(x => x.PosterPinned, schluessel != null), ct),
|
||||
"backdrop" => await abfrage.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.BackdropImage, schluessel)
|
||||
.SetProperty(x => x.BackdropPinned, schluessel != null), ct),
|
||||
_ => await abfrage.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.LogoImage, schluessel)
|
||||
.SetProperty(x => x.LogoPinned, schluessel != null), ct),
|
||||
};
|
||||
|
||||
return betroffen > 0;
|
||||
}
|
||||
}
|
||||
@@ -77,14 +77,22 @@ public static class AuthEndpoints
|
||||
return Results.NoContent();
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) =>
|
||||
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id)
|
||||
? Results.Ok(new AuthenticatedUser(
|
||||
id,
|
||||
wer.FindFirstValue(ClaimTypes.Name) ?? "",
|
||||
wer.IsInRole(FlaroRoles.Administrator)))
|
||||
: Results.Unauthorized())
|
||||
.RequireAuthorization();
|
||||
// **Aus der Datenbank, nicht nur aus dem Token.** Name und Rolle stehen
|
||||
// in den Ansprüchen; Sprache und Bild ändern sich, ohne dass ein neues
|
||||
// Token ausgestellt wird. Wer sein Bild tauscht und die Seite neu lädt,
|
||||
// sähe sonst das alte, bis er sich neu anmeldet.
|
||||
app.MapGet("/api/v1/auth/me", async (ClaimsPrincipal wer, FlaroContext db) =>
|
||||
{
|
||||
if (!Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id))
|
||||
return Results.Unauthorized();
|
||||
|
||||
var benutzer = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id);
|
||||
if (benutzer is null) return Results.Unauthorized();
|
||||
|
||||
return Results.Ok(new AuthenticatedUser(
|
||||
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
|
||||
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
|
||||
}).RequireAuthorization();
|
||||
|
||||
MapCodeFlow(app);
|
||||
}
|
||||
@@ -120,8 +128,8 @@ public static class AuthEndpoints
|
||||
string handle, FlaroContext db, TimeProvider clock) =>
|
||||
{
|
||||
var jetzt = clock.GetUtcNow();
|
||||
var fingerabdruck = Secrets.Fingerprint(handle);
|
||||
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerabdruck);
|
||||
var fingerfingerprint = Secrets.Fingerprint(handle);
|
||||
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerfingerprint);
|
||||
|
||||
if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null)
|
||||
return Results.Ok(new CodeState("expired", null));
|
||||
@@ -152,7 +160,7 @@ public static class AuthEndpoints
|
||||
c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt);
|
||||
|
||||
if (offen is null)
|
||||
return Results.Problem("Dieser Code gilt nicht mehr.", statusCode: StatusCodes.Status404NotFound);
|
||||
return Einwand.Fehlt("Dieser Code gilt nicht mehr.");
|
||||
|
||||
offen.ApprovedByUserId = benutzerId;
|
||||
await db.SaveChangesAsync();
|
||||
@@ -190,7 +198,9 @@ public static class AuthEndpoints
|
||||
LastSeenAt = jetzt,
|
||||
});
|
||||
|
||||
return new LoginResponse(token, new AuthenticatedUser(benutzer.Id, benutzer.UserName, benutzer.IsAdministrator));
|
||||
return new LoginResponse(token, new AuthenticatedUser(
|
||||
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
|
||||
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
|
||||
}
|
||||
|
||||
private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName;
|
||||
|
||||
@@ -5,8 +5,100 @@ namespace Flaro.Api.Endpoints;
|
||||
// und bricht Clients, die niemand mehr aktualisiert.
|
||||
|
||||
public sealed record SystemInfo(
|
||||
string Version, string ServerName, bool NeedsSetup,
|
||||
IReadOnlyList<AttributionResponse> Attributions);
|
||||
string Version, string Build, string ServerName, bool NeedsSetup,
|
||||
IReadOnlyList<AttributionResponse> Attributions,
|
||||
/// <summary>Wie hoch der Aufmacher steht: „niedrig", „mittel", „hoch", „sehrhoch".</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Hier und nicht in einer eigenen Abfrage.</b> Diese Auskunft holt
|
||||
/// der Client ohnehin als Erstes, noch vor der Anmeldung. Käme die Höhe
|
||||
/// später, stünde der Aufmacher einen Augenblick in der Vorgabe und
|
||||
/// spränge dann — genau das, was eine einstellbare Höhe vermeiden soll.
|
||||
/// </remarks>
|
||||
string HeroHeight = "mittel");
|
||||
|
||||
/// <summary>Ein Ablageort des Servers, mitsamt Belegung.</summary>
|
||||
///
|
||||
/// <param name="Variable">
|
||||
/// Die Umgebungsvariable, über die er gesetzt wird. <b>Sie wird mit
|
||||
/// angezeigt</b>, weil sie die Antwort auf die nächste Frage ist: „und wie
|
||||
/// ändere ich das?" Im Betrieb umbiegen lässt sich ein Pfad nicht — dann
|
||||
/// lägen die Dateien woanders als die Datenbank sie vermutet.
|
||||
/// </param>
|
||||
/// <param name="Bilder">Wie viel davon auf den Bildspeicher entfällt; sonst <c>null</c>.</param>
|
||||
/// <summary>Wie viel auf dem Transcode-Cache liegen bleiben darf.</summary>
|
||||
///
|
||||
/// <param name="CapGb">Obergrenze in Gigabyte; <c>0</c> heisst unbegrenzt.</param>
|
||||
/// <param name="UnusedDays">Ab wann Unbenutztes auch unterhalb der Grenze geht; <c>0</c> heisst nie.</param>
|
||||
/// <param name="GraceMinutes">Wie lange frisch Gebrauchtes in jedem Fall bleibt.</param>
|
||||
public sealed record MaintenanceSettingsRequest(
|
||||
double? CapGb, int? UnusedDays, int? GraceMinutes,
|
||||
int? BackupDays = null, int? BackupKeep = null, string? BackupFolder = null,
|
||||
int? OptimizeDays = null, int? ImageDays = null);
|
||||
|
||||
public sealed record MaintenanceSettingsResponse(
|
||||
double CapGb, int UnusedDays, int GraceMinutes,
|
||||
int BackupDays, int BackupKeep, string BackupFolder,
|
||||
int OptimizeDays, int ImageDays);
|
||||
|
||||
/// <param name="Teile">
|
||||
/// Die Unterordner, wo es welche gibt — heute nur beim Transcode-Cache.
|
||||
/// <b>Mit Dateizahl</b>, denn bei den Untertiteln ist die Anzahl das
|
||||
/// Problem und nicht die Grösse, und das sieht man an Bytes nicht.
|
||||
/// Additiv: <c>Bilder</c> bleibt stehen, damit kein Client bricht.
|
||||
/// </param>
|
||||
public sealed record Speicherort(
|
||||
string Name, string Variable, string Pfad, long Belegt, long? Frei, long? Bilder,
|
||||
IReadOnlyList<Teilbericht>? Teile = null);
|
||||
|
||||
/// <summary>Dateien, die zu keinem Titel gehören — mit Begründung.</summary>
|
||||
public sealed record Unzugeordnet(int Gesamt, IReadOnlyList<UnzugeordneteDatei> Dateien);
|
||||
|
||||
/// <summary>Ein Titel, zu dem die Metadatenquelle nichts hergab.</summary>
|
||||
///
|
||||
/// <param name="Art">„movie" oder „series" — damit der Client weiss, wohin.</param>
|
||||
/// <param name="Dateien">Wie viele Dateien daran hängen.</param>
|
||||
/// <param name="Beispiel">Ein Dateiname, damit man den Titel wiedererkennt.</param>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Weil die Zahl allein nichts erklärt.</b> In der Übersicht standen 448
|
||||
/// Filme, in Plex 451 — und nirgends stand, welche drei fehlen oder warum.
|
||||
/// Eine Mediathek, die etwas nicht zuordnen konnte, muss sagen was.
|
||||
/// </remarks>
|
||||
public sealed record OhneZuordnung(
|
||||
Guid Id, string Art, string Titel, int? Jahr, int Dateien, string? Beispiel);
|
||||
|
||||
/// <summary>Welche Mehrfachfassungen abgehakt oder wieder hervorgeholt werden.</summary>
|
||||
///
|
||||
/// <param name="Ignorieren">
|
||||
/// <c>true</c> hakt ab, <c>false</c> holt zurück. Ein Weg statt zweier
|
||||
/// Endpunkte: es ist dieselbe Spalte, nur einmal gesetzt und einmal geleert.
|
||||
/// </param>
|
||||
public sealed record FassungenRequest(IReadOnlyList<Guid> Ids, bool Ignorieren = true);
|
||||
|
||||
/// <param name="Titel">Titel mit mehr als einer Datei — dort versteckt sich Falsches.</param>
|
||||
public sealed record Mehrfachbericht(
|
||||
int Gesamt, IReadOnlyList<Mehrfachtitel> Titel);
|
||||
|
||||
/// <param name="Dateien">Die Dateien, die an diesem einen Titel hängen.</param>
|
||||
public sealed record Mehrfachtitel(
|
||||
Guid Id, string Art, string Titel, int? Jahr, IReadOnlyList<Fassungsdatei> Dateien);
|
||||
|
||||
/// <summary>Eine Datei an einem Titel — mit Kennung, damit sie sich herauslösen lässt.</summary>
|
||||
public sealed record Fassungsdatei(Guid Id, string Name);
|
||||
|
||||
public sealed record UnzugeordneteDatei(Guid Id, string Pfad, string Grund);
|
||||
|
||||
/// <summary>Wie viele Titel in welcher Sprache vorliegen.</summary>
|
||||
///
|
||||
/// <param name="Sprache"><c>null</c> heisst: vor der Einführung dieser Spalte geholt.</param>
|
||||
public sealed record Sprachanteil(string? Sprache, int Anzahl);
|
||||
|
||||
/// <param name="Abweichend">Wie viele nicht in der Serversprache vorliegen.</param>
|
||||
public sealed record Sprachverteilung(
|
||||
string Serversprache, IReadOnlyList<Sprachanteil> Anteile, int Abweichend);
|
||||
|
||||
public sealed record TitelRequest(string? Title);
|
||||
|
||||
public sealed record ServerNameRequest(string Name);
|
||||
|
||||
@@ -26,7 +118,28 @@ public sealed record LoginRequest(
|
||||
string UserName, string Password, string DeviceName, string DeviceId,
|
||||
bool StaySignedIn = true);
|
||||
|
||||
public sealed record AuthenticatedUser(Guid Id, string UserName, bool IsAdministrator);
|
||||
public sealed record AuthenticatedUser(
|
||||
Guid Id, string UserName, bool IsAdministrator,
|
||||
/// <summary>Die bevorzugte Sprache — <c>null</c> heisst: die des Servers.</summary>
|
||||
string? Language = null,
|
||||
|
||||
/// <summary>Das eigene Bild, als Schlüssel in die Bildablage.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Hier und nicht in einer eigenen Abfrage.</b> Die Kopfleiste zeigt es
|
||||
/// auf jeder Seite; es nach der Anmeldung noch einmal holen zu müssen
|
||||
/// hiesse, dass der Buchstabenkreis kurz aufblitzt und dann wechselt.
|
||||
/// </remarks>
|
||||
string? AvatarImage = null,
|
||||
|
||||
/// <summary>Der Besitzer — ihm nimmt niemand das Adminrecht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Hinten angehängt, wie jedes neue Feld in dieser Datei: in der Mitte
|
||||
/// eingeschoben bricht es jede Aufrufstelle, die die Werte der Reihe nach
|
||||
/// übergibt — und davon gibt es hier drei.
|
||||
/// </remarks>
|
||||
bool IsOwner = false);
|
||||
|
||||
public sealed record LoginResponse(string Token, AuthenticatedUser User);
|
||||
|
||||
@@ -44,6 +157,12 @@ public sealed record CodeState(string State, LoginResponse? Login);
|
||||
|
||||
public sealed record CreateUserRequest(string UserName, string Password, bool IsAdministrator);
|
||||
|
||||
/// <summary>Adminrecht geben oder nehmen.</summary>
|
||||
public sealed record RechtRequest(bool IsAdministrator);
|
||||
|
||||
/// <summary>Eine der vier Stufen für den Aufmacher.</summary>
|
||||
public sealed record HoeheRequest(string Height);
|
||||
|
||||
/// <summary>Eine Bibliothek, wie die Oberfläche sie zeigt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
@@ -52,11 +171,136 @@ public sealed record CreateUserRequest(string UserName, string Password, bool Is
|
||||
/// „nicht gezählt" sind verschiedene Auskünfte, und eine frisch angelegte
|
||||
/// Bibliothek als leer auszugeben wäre die falsche.
|
||||
/// </remarks>
|
||||
public sealed record LibraryResponse(Guid Id, string Name, string Kind, string Path, int? ItemCount);
|
||||
public sealed record LibraryResponse(
|
||||
Guid Id, string Name, string Kind, string Path, int? ItemCount,
|
||||
/// <summary>Ob neue Funde erst geprüft werden.</summary>
|
||||
bool ReviewNewItems = false,
|
||||
/// <summary>Wie viele Titel dieser Mediathek noch auf Freigabe warten.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Hier und nicht nur in der Prüfliste.</b> Wer eine Mediathek
|
||||
/// scannt, sieht das Ergebnis an ihr — dass zwanzig Titel irgendwo anders
|
||||
/// warten, erfährt er sonst nur, wenn er weiterscrollt.
|
||||
/// </remarks>
|
||||
int PendingCount = 0,
|
||||
|
||||
/// <summary>Wie viele Mediendateien in dieser Mediathek liegen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Weil „455 Filme" und „461 Dateien" beide stimmen können.</b> Plex
|
||||
/// zählte 451, Flaro 449 — und der Scan lief über 461 Dateien. Drei
|
||||
/// Zahlen für drei verschiedene Dinge, und keine davon falsch. Nebeneinander
|
||||
/// zu sehen, wie sie sich aufteilen, beendet die Frage; eine davon allein
|
||||
/// zu zeigen hält sie offen.
|
||||
/// </remarks>
|
||||
int FileCount = 0,
|
||||
|
||||
/// <summary>Wie viele Folgen — nur bei Serien, sonst <c>null</c>.</summary>
|
||||
int? EpisodeCount = null,
|
||||
|
||||
/// <summary>Dateien, die an keinem Titel hängen.</summary>
|
||||
int UnassignedCount = 0,
|
||||
|
||||
/// <summary>Dateien über die erste je Titel hinaus — zweite Fassungen.</summary>
|
||||
int ExtraVersionCount = 0);
|
||||
|
||||
public sealed record RenameLibraryRequest(string Name);
|
||||
|
||||
public sealed record CreateLibraryRequest(string Name, string Kind, string Path);
|
||||
public sealed record CreateLibraryRequest(
|
||||
string Name, string Kind, string Path,
|
||||
/// <summary>Ob neue Funde erst geprüft werden. Vorgabe: nein.</summary>
|
||||
bool ReviewNewItems = false);
|
||||
|
||||
public sealed record PruefungRequest(bool ReviewNewItems);
|
||||
|
||||
/// <summary>Die Reihenfolge der Mediatheken, von oben nach unten.</summary>
|
||||
public sealed record ReihenfolgeRequest(IReadOnlyList<Guid>? Ids);
|
||||
|
||||
public sealed record SprachRequest(string? Language);
|
||||
|
||||
/// <summary>Welche Sprache gilt — und wie weit sie abgedeckt ist.</summary>
|
||||
public sealed record SprachStand(string? Language, int Translated, int Total);
|
||||
|
||||
/// <summary>Welche Bilder gerade gesetzt sind — und welche von Hand.</summary>
|
||||
public sealed record Bildstand(
|
||||
string? Poster, string? Backdrop, string? Logo,
|
||||
bool PosterPinned, bool BackdropPinned, bool LogoPinned,
|
||||
/// <summary>Die für den Aufmacher gewählten Bilder, höchstens fünf.</summary>
|
||||
IReadOnlyList<string>? Heroes = null);
|
||||
|
||||
public sealed record Bildauswahl(
|
||||
Bildstand Current, IReadOnlyList<Flaro.Core.Metadata.Bildkandidat> Candidates);
|
||||
|
||||
public sealed record BildWahlRequest(string Kind, string? Url);
|
||||
|
||||
/// <summary>Die Mehrfachauswahl fürs Aufmacher-Karussell.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Eine Liste und kein einzelner Wert: hier wird nicht ein Bild gesetzt,
|
||||
/// sondern die ganze Auswahl ersetzt. Eine leere Liste nimmt sie zurück.
|
||||
/// </remarks>
|
||||
public sealed record AufmacherWahlRequest(IReadOnlyList<string> Urls);
|
||||
|
||||
/// <summary>Ein Titel, der noch wartet oder geplant ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine Zeile je Serie, nicht je Folge.</b> Bei zwanzig neuen Folgen
|
||||
/// derselben Serie wären zwanzig Zeilen zwanzig Entscheidungen für eine
|
||||
/// Sache. Die Folgen stecken in <see cref="Ids"/>; freigegeben werden sie
|
||||
/// gemeinsam.
|
||||
/// </remarks>
|
||||
public sealed record Pruefeintrag(
|
||||
/// <summary><c>movie</c> oder <c>series</c>.</summary>
|
||||
string Kind,
|
||||
Guid Id, string Title, int? Year, string? PosterImage,
|
||||
/// <summary><c>null</c> heisst: wartet. Ein Zeitpunkt in der Zukunft heisst: geplant.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Bei einer Serie nur gesetzt, wenn alle Folgen denselben Termin tragen —
|
||||
/// sonst wäre es eine Behauptung über zwanzig Folgen, von denen eine
|
||||
/// anders liegt.
|
||||
/// </remarks>
|
||||
DateTimeOffset? PublishAt,
|
||||
string? Path,
|
||||
/// <summary>Ob eine Zuordnung besteht — ohne sie fehlen Titel und Bilder.</summary>
|
||||
bool Matched,
|
||||
/// <summary>Wie viele Titel dahinterstecken — bei einer Serie die Zahl der Folgen.</summary>
|
||||
int Count,
|
||||
/// <summary>Worauf eine Freigabe wirkt.</summary>
|
||||
IReadOnlyList<Guid> Ids,
|
||||
|
||||
/// <summary>Warum der Eintrag hier steht: <c>neu</c> oder <c>geaendert</c>.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Zwei verschiedene Dinge in einer Liste, und das mit Absicht.</b> Ein
|
||||
/// neuer Fund wartet auf Freigabe und ist bis dahin unsichtbar. Ein
|
||||
/// geänderter Titel steht längst in der Mediathek — er soll nur nicht
|
||||
/// unbemerkt anders heissen oder ein anderes Plakat tragen. Beides gehört
|
||||
/// vor dieselben Augen, aber nicht unter denselben Knopf: das eine wird
|
||||
/// veröffentlicht, das andere nur zur Kenntnis genommen.
|
||||
/// </remarks>
|
||||
string Grund = "neu");
|
||||
|
||||
public sealed record FreigabeRequest(
|
||||
IReadOnlyList<Guid>? Ids,
|
||||
DateTimeOffset? PublishAt,
|
||||
/// <summary>Statt einzelner Kennungen: alles Wartende dieser Mediathek.</summary>
|
||||
Guid? LibraryId = null);
|
||||
|
||||
/// <summary>Ein Protokolltag.</summary>
|
||||
public sealed record Protokolltag(string Date, long Bytes);
|
||||
|
||||
/// <summary>Eine Datei, die der letzte Scan nicht mehr gefunden hat.</summary>
|
||||
public sealed record VermissteDatei(
|
||||
Guid FileId,
|
||||
string Path,
|
||||
string? VersionLabel,
|
||||
DateTimeOffset MissingSince,
|
||||
string LibraryName,
|
||||
/// <summary>Zu welchem Titel sie gehört — damit klar ist, was fehlen würde.</summary>
|
||||
string? Title,
|
||||
Guid? MovieId,
|
||||
Guid? EpisodeId);
|
||||
|
||||
// --- Stufe 2: Bibliothek -------------------------------------------------
|
||||
|
||||
@@ -74,7 +318,20 @@ public sealed record ScanStatus(
|
||||
int? Probed,
|
||||
IReadOnlyList<ScanProblem>? Problems,
|
||||
string? Error,
|
||||
MetadataProgress? Metadata);
|
||||
MetadataProgress? Metadata,
|
||||
/// <summary>Wie viele Dateien dieser Lauf als verschwunden gemeldet hat.</summary>
|
||||
int? Missing = null,
|
||||
/// <summary>Woran der Lauf gerade ist, in einem Satz.</summary>
|
||||
string? Step = null,
|
||||
/// <summary>Woran die Segmentsuche gerade ist — <c>null</c>, wenn keine läuft.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Ein eigenes Feld, nicht <see cref="Step"/>.</b> Die Segmentsuche
|
||||
/// hängt sich hinter den Scan und läuft weiter, wenn der längst
|
||||
/// „fertig" meldet. In dasselbe Feld geschrieben sähe der Scan wieder
|
||||
/// laufend aus, und die Verwaltung käme nie zur Ruhe.
|
||||
/// </remarks>
|
||||
string? Segmente = null);
|
||||
|
||||
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
|
||||
public sealed record MetadataProgress(
|
||||
@@ -83,7 +340,38 @@ public sealed record MetadataProgress(
|
||||
/// <summary>Eine Spur in einer Datei.</summary>
|
||||
public sealed record MediaStreamResponse(
|
||||
int Index, string Kind, string? Codec, string? Language, string? Title,
|
||||
bool IsDefault, bool IsForced, int? Channels, int? Width, int? Height);
|
||||
bool IsDefault, bool IsForced, int? Channels, int? Width, int? Height,
|
||||
/// <summary>Die Zeile, wie sie angezeigt werden soll.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Vom Server formuliert, nicht vom Client.</b> „DEU EAC3 6.0" wird
|
||||
/// „Deutsch · Dolby Digital Plus · 5.1" — und die Apps brauchen denselben
|
||||
/// Satz. Zweimal formuliert driftet er auseinander, und dann heisst
|
||||
/// dieselbe Tonspur am Fernseher anders als im Browser. Die Rohwerte
|
||||
/// stehen weiterhin daneben; wer sie braucht, hat sie.
|
||||
/// </remarks>
|
||||
string? Display = null,
|
||||
/// <summary>Die Auflösung als Wort — „4K", „Full HD", „HD", „SD".</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eigenes Feld, damit der Client nichts zerlegen muss.</b> Die
|
||||
/// Oberfläche las die Stufe bisher mit <c>display.split(' · ')[0]</c>
|
||||
/// heraus — richtig, solange vor der Auflösung nie etwas anderes steht,
|
||||
/// und falsch an dem Tag, an dem doch.
|
||||
/// </remarks>
|
||||
string? ResolutionLabel = null,
|
||||
/// <summary>Dieselbe Auflösung genau — „1080p". Gehört in den Tooltip.</summary>
|
||||
string? ResolutionExact = null,
|
||||
|
||||
/// <summary>Die Sprache als Wort — „Deutsch" statt „deu".</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit eine Liste von Sprachen ohne Rätselraten entsteht.</b> Die
|
||||
/// Detailansicht nennt, in welchen Sprachen ein Film vorliegt; sie dafür
|
||||
/// aus <see cref="Display"/> herauszuschneiden hiesse, sich auf dessen
|
||||
/// Satzbau zu verlassen.
|
||||
/// </remarks>
|
||||
string? LanguageName = null);
|
||||
|
||||
/// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary>
|
||||
///
|
||||
@@ -93,9 +381,32 @@ public sealed record MediaStreamResponse(
|
||||
/// App spricht Dateien über ihre Kennung an. Zum Unterscheiden zweier
|
||||
/// Fassungen genügt der Name.
|
||||
/// </remarks>
|
||||
/// <param name="VersionLabel">
|
||||
/// <b>Wie diese Fassung heisst</b> — „Mobil", „4K", „Director's Cut" — oder
|
||||
/// <c>null</c>, wenn es nur eine gibt. Ohne diese Angabe standen zwei Dateien
|
||||
/// derselben Folge als zwei gleich aussehende Knopfreihen untereinander, und
|
||||
/// die Folge wirkte doppelt.
|
||||
/// </param>
|
||||
/// <param name="Folder">
|
||||
/// <b>Der Ordner unterhalb der Mediathek</b> — <c>null</c> für alle, die nicht
|
||||
/// verwalten dürfen.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Weil man einer falschen Zuordnung am Ordner sofort ansieht, dass sie
|
||||
/// falsch ist.</b> Steht über der Beschreibung „Hanni & Nanni 3" und der
|
||||
/// Ordner heisst <c>Hanni und Nanni</c>, ist die Sache in einer Sekunde klar
|
||||
/// — ohne Umweg über den Zuordnungsdialog.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nur der Teil unterhalb der Mediathek, und nur für Administratoren.</b>
|
||||
/// Der volle Pfad verriete jedem Angemeldeten den Aufbau des Servers; der
|
||||
/// relative sagt genau das, was zum Beurteilen nötig ist.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public sealed record MediaFileResponse(
|
||||
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
|
||||
IReadOnlyList<MediaStreamResponse> Streams);
|
||||
IReadOnlyList<MediaStreamResponse> Streams, string? VersionLabel = null,
|
||||
string? Folder = null);
|
||||
|
||||
public sealed record MovieResponse(
|
||||
Guid Id, string Title, int? Year, string? Overview,
|
||||
@@ -116,7 +427,73 @@ public sealed record MovieResponse(
|
||||
/// von denen keiner angezeigt wird.
|
||||
/// </remarks>
|
||||
IReadOnlyList<string>? Genres = null,
|
||||
IReadOnlyList<CreditResponse>? Credits = null);
|
||||
IReadOnlyList<CreditResponse>? Credits = null,
|
||||
|
||||
/// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary>
|
||||
string? OriginalTitle = null,
|
||||
|
||||
/// <summary>Ab wann sichtbar — <c>null</c> heisst: wartet auf Freigabe.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit die Mediathek es zeigen kann.</b> Für einen Administrator
|
||||
/// stehen wartende Titel mitten zwischen den freigegebenen, ohne dass
|
||||
/// etwas sie unterscheidet — und wer einen davon sucht, findet ihn nur
|
||||
/// über die Prüfliste.
|
||||
/// </remarks>
|
||||
DateTimeOffset? PublishAt = null,
|
||||
|
||||
/// <summary>Ob der Titel von Hand gesetzt wurde.</summary>
|
||||
bool TitlePinned = false,
|
||||
|
||||
/// <summary>Wonach einsortiert wird, wenn nicht nach dem Titel.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit der Client weiss, wohin ein Titel gehört.</b> Nach einer
|
||||
/// Korrektur stand „WALL·E" weiter unter E, bis man die Seite neu lud —
|
||||
/// die Kachel wusste schlicht nicht, dass sie woandershin gehört.
|
||||
/// </remarks>
|
||||
string? SortTitle = null,
|
||||
|
||||
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
|
||||
bool SortTitlePinned = false,
|
||||
|
||||
/// <summary>Bewertungen von anderswo — leer, wenn keine Quelle eingerichtet ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Neben <c>Rating</c>, nicht statt dessen.</b> Das Feld ist die
|
||||
/// Wertung von TMDB und bleibt, wo es ist; ein Client, der nur das
|
||||
/// kennt, zeigt weiter dasselbe wie bisher.
|
||||
/// </remarks>
|
||||
IReadOnlyList<Fremdwertungsantwort>? ExternalRatings = null,
|
||||
|
||||
/// <summary>Zu welcher Mediathek der Titel gehört.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit die Detailseite einen einzelnen Titel auffrischen kann.</b>
|
||||
/// Das Auffrischen hängt an der Mediathek, nicht am Titel; ohne diese
|
||||
/// Kennung müsste der Client erst alle Mediatheken durchgehen, um zu
|
||||
/// erfahren, wohin dieser eine Film gehört.
|
||||
/// </remarks>
|
||||
Guid? LibraryId = null,
|
||||
/// <summary>Das Bild, das jetzt im Aufmacher gilt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Vom Server gewählt, nicht vom Client.</b> Wer mehrere Aufnahmen
|
||||
/// ausgesucht hat, bekommt alle fünf Stunden eine andere — und zwar
|
||||
/// überall dieselbe: im Web, in der App, nach einem Neustart. Siehe
|
||||
/// <c>Aufmacherwahl</c>. <c>null</c> heisst „keine ausgesucht"; dann
|
||||
/// gilt wie bisher der Hintergrund.
|
||||
/// </remarks>
|
||||
string? HeroImage = null,
|
||||
|
||||
/// <summary>Der Abspann, sofern er im Bild gefunden wurde.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Bei einem Film gibt es keinen Vorspann zu finden — erkannt wird er
|
||||
/// daran, dass er sich über die Folgen einer Staffel wiederholt, und
|
||||
/// ein Film hat nichts, womit er sich vergleichen liesse.
|
||||
/// </remarks>
|
||||
IReadOnlyList<MarkerResponse>? Markers = null);
|
||||
|
||||
public sealed record CreditResponse(
|
||||
Guid Id, string Name, string Role, string? Character, string? Image);
|
||||
@@ -139,17 +516,136 @@ public sealed record SeriesResponse(
|
||||
string? Tagline = null,
|
||||
string? ContentRating = null,
|
||||
IReadOnlyList<string>? Genres = null,
|
||||
IReadOnlyList<CreditResponse>? Credits = null);
|
||||
IReadOnlyList<CreditResponse>? Credits = null,
|
||||
|
||||
/// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary>
|
||||
string? OriginalTitle = null,
|
||||
|
||||
/// <summary>Ab wann sichtbar — <c>null</c> heisst: wartet auf Freigabe.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit die Mediathek es zeigen kann.</b> Für einen Administrator
|
||||
/// stehen wartende Titel mitten zwischen den freigegebenen, ohne dass
|
||||
/// etwas sie unterscheidet — und wer einen davon sucht, findet ihn nur
|
||||
/// über die Prüfliste.
|
||||
/// </remarks>
|
||||
DateTimeOffset? PublishAt = null,
|
||||
|
||||
/// <summary>Favorit, Bewertung, Häkchen — zur Serie als Ganzem.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Neben <see cref="UnwatchedCount"/>, nicht an dessen Stelle.</b> Die
|
||||
/// Zahl sagt, wie viel noch aussteht; dieser Zustand sagt, was der
|
||||
/// Benutzer über die Serie selbst festgehalten hat. Ohne ihn konnte eine
|
||||
/// Kachel nicht wissen, ob „Als gesehen markieren" oder „Als ungesehen
|
||||
/// markieren" darauf gehört — sie bot immer das erste an.
|
||||
/// </remarks>
|
||||
ItemStateResponse? State = null,
|
||||
|
||||
/// <summary>Ob der Titel von Hand gesetzt wurde.</summary>
|
||||
bool TitlePinned = false,
|
||||
|
||||
/// <summary>Wonach einsortiert wird, wenn nicht nach dem Titel.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit der Client weiss, wohin ein Titel gehört.</b> Nach einer
|
||||
/// Korrektur stand „WALL·E" weiter unter E, bis man die Seite neu lud —
|
||||
/// die Kachel wusste schlicht nicht, dass sie woandershin gehört.
|
||||
/// </remarks>
|
||||
string? SortTitle = null,
|
||||
|
||||
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
|
||||
bool SortTitlePinned = false,
|
||||
|
||||
/// <summary>Bewertungen von anderswo — leer, wenn keine Quelle eingerichtet ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Neben <c>Rating</c>, nicht statt dessen.</b> Das Feld ist die
|
||||
/// Wertung von TMDB und bleibt, wo es ist; ein Client, der nur das
|
||||
/// kennt, zeigt weiter dasselbe wie bisher.
|
||||
/// </remarks>
|
||||
IReadOnlyList<Fremdwertungsantwort>? ExternalRatings = null,
|
||||
|
||||
/// <summary>Zu welcher Mediathek der Titel gehört.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit die Detailseite einen einzelnen Titel auffrischen kann.</b>
|
||||
/// Das Auffrischen hängt an der Mediathek, nicht am Titel; ohne diese
|
||||
/// Kennung müsste der Client erst alle Mediatheken durchgehen, um zu
|
||||
/// erfahren, wohin dieser eine Film gehört.
|
||||
/// </remarks>
|
||||
Guid? LibraryId = null,
|
||||
/// <summary>Das Bild, das jetzt im Aufmacher gilt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Vom Server gewählt, nicht vom Client.</b> Wer mehrere Aufnahmen
|
||||
/// ausgesucht hat, bekommt alle fünf Stunden eine andere — und zwar
|
||||
/// überall dieselbe: im Web, in der App, nach einem Neustart. Siehe
|
||||
/// <c>Aufmacherwahl</c>. <c>null</c> heisst „keine ausgesucht"; dann
|
||||
/// gilt wie bisher der Hintergrund.
|
||||
/// </remarks>
|
||||
string? HeroImage = null);
|
||||
|
||||
/// <summary>Ein überspringbarer Abschnitt — Vorspann oder Abspann.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <c>Kind</c> ist <c>"intro"</c> oder <c>"credits"</c>: dieselben Wörter,
|
||||
/// die Jellyfin benutzt. Die Apps kennen sie bereits, und zwei Namen für
|
||||
/// dieselbe Sache wären zwei Übersetzungen an der Grenze jeder App.
|
||||
/// </remarks>
|
||||
public sealed record MarkerResponse(string Kind, double StartSeconds, double EndSeconds);
|
||||
|
||||
public sealed record EpisodeResponse(
|
||||
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
||||
IReadOnlyList<MediaFileResponse> Files,
|
||||
ItemStateResponse? State);
|
||||
ItemStateResponse? State,
|
||||
DateTimeOffset? PublishAt = null,
|
||||
|
||||
/// <summary>Zu welcher Serie sie gehört.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit eine Folge für sich geöffnet werden kann.</b> Die Apps
|
||||
/// reichen eine Kennung durch, ohne zu wissen, was dahintersteckt. War es
|
||||
/// eine Folge, fand sich weder ein Film noch eine Serie, und die
|
||||
/// Startseite meldete „Dieser Titel ist auf dem Server nicht mehr
|
||||
/// vorhanden" — über einen Titel, den sie selbst gerade angezeigt hatte.
|
||||
/// </remarks>
|
||||
Guid? SeriesId = null,
|
||||
|
||||
/// <summary>Vorspann und Abspann, sofern erkannt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Leer heisst „nicht erkannt", nicht „gibt es nicht". Wer sie sucht,
|
||||
/// stösst den Lauf an; bis er durch ist, bleibt die Liste leer und die
|
||||
/// Spieler zeigen schlicht keinen Knopf.
|
||||
/// </remarks>
|
||||
IReadOnlyList<MarkerResponse>? Markers = null);
|
||||
|
||||
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
|
||||
|
||||
/// <summary>Die saisonale Reihe — mit ihrem Namen, weil er wechselt.</summary>
|
||||
///
|
||||
/// <param name="Occasion">
|
||||
/// Was über der Reihe steht. <c>null</c> heisst: heute hat nichts Saison,
|
||||
/// oder der Anlass findet nichts — dann zeigt der Client gar keine Reihe.
|
||||
/// </param>
|
||||
public sealed record Saisonreihe(string? Occasion, Page<MovieResponse> Items);
|
||||
|
||||
/// <param name="HasFanartKey">
|
||||
/// Ob eine zweite Bildquelle eingerichtet ist. Auch hier kommt der Schlüssel
|
||||
/// selbst nicht zurück — zum Anzeigen genügt, dass einer da ist.
|
||||
/// </param>
|
||||
public sealed record MetadataSettingsResponse(
|
||||
bool HasApiKey, string Language, bool UsingProjectKey);
|
||||
bool HasApiKey, string Language, bool UsingProjectKey,
|
||||
bool HasFanartKey = false, bool HasOmdbKey = false,
|
||||
|
||||
/// <summary>Ob die Seite von Rotten Tomatoes gelesen werden darf.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Kein Schlüssel, sondern ein Schalter.</b> Dort gibt es keine
|
||||
/// Zugänge zu vergeben; eingeschaltet wird das Auslesen der Seite.
|
||||
/// </remarks>
|
||||
bool RottenTomatoes = false);
|
||||
|
||||
/// <summary>Was ein Client über der Mediathek anzeigen muss.</summary>
|
||||
///
|
||||
@@ -161,7 +657,33 @@ public sealed record MetadataSettingsResponse(
|
||||
/// </remarks>
|
||||
public sealed record AttributionResponse(string Text, string LogoUrl, string Url);
|
||||
|
||||
public sealed record MetadataSettingsRequest(string? ApiKey, string? Language);
|
||||
public sealed record MetadataSettingsRequest(
|
||||
string? ApiKey, string? Language, string? FanartApiKey = null, string? OmdbApiKey = null,
|
||||
bool? RottenTomatoes = null);
|
||||
|
||||
/// <summary>Was der Server über die Wiedergabe entscheidet.</summary>
|
||||
///
|
||||
/// <param name="ResumeAfterSeconds">
|
||||
/// Wie weit man gekommen sein muss, damit ein Titel in „Weiterschauen" steht.
|
||||
/// </param>
|
||||
/// <param name="WatchedFraction">Ab welchem Anteil er als gesehen gilt, 0 bis 1.</param>
|
||||
/// <param name="Encoder">„qualitaet", „ausgewogen" oder „tempo".</param>
|
||||
public sealed record PlaybackSettingsResponse(
|
||||
double ResumeAfterSeconds, double WatchedFraction, string Encoder);
|
||||
|
||||
/// <summary>Was geändert werden soll — <c>null</c> heisst: unverändert.</summary>
|
||||
public sealed record PlaybackSettingsRequest(
|
||||
double? ResumeAfterSeconds, double? WatchedFraction, string? Encoder);
|
||||
|
||||
/// <summary>Was danach gilt, und wie viele Zeilen das umgeworfen hat.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Zahl gehört in die Antwort.</b> Eine neue Schwelle wirkt rückwirkend
|
||||
/// auf alles, was schon in „Weiterschauen" steht; ohne diese Rückmeldung
|
||||
/// änderte sich die Startseite scheinbar von selbst.
|
||||
/// </remarks>
|
||||
public sealed record PlaybackSettingsApplied(
|
||||
double ResumeAfterSeconds, double WatchedFraction, string Encoder, int Reevaluated);
|
||||
|
||||
// --- Stufe 3: Ausliefern -------------------------------------------------
|
||||
|
||||
@@ -190,8 +712,26 @@ public sealed record MatchCandidate(
|
||||
public sealed record ApplyMatchRequest(int TmdbId);
|
||||
|
||||
/// <summary>Wie die Zuordnung jetzt aussieht.</summary>
|
||||
///
|
||||
/// <param name="Id">
|
||||
/// <b>Welcher Eintrag das jetzt ist.</b> Meist der, den man korrigiert hat —
|
||||
/// nach einem Zusammenlegen aber der überlebende, und der hat eine andere
|
||||
/// Kennung.
|
||||
/// </param>
|
||||
/// <param name="Merged">
|
||||
/// <b>Ob dabei zwei Einträge zu einem wurden.</b>
|
||||
///
|
||||
/// <para>
|
||||
/// Ohne diese Auskunft stand der korrigierte Titel weiter in der Liste,
|
||||
/// obwohl es ihn nicht mehr gab: wer ihn dann bearbeiten wollte, bekam eine
|
||||
/// 404 — und zwar als englisches „Not Found", weil auch das nicht stimmte.
|
||||
/// Der Client kann die Kachel jetzt wegnehmen, statt auf einen gelöschten
|
||||
/// Eintrag zu zeigen.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public sealed record MatchState(
|
||||
int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year);
|
||||
int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year,
|
||||
Guid Id = default, bool Merged = false);
|
||||
|
||||
// --- Stufe 4: Fortschritt und Zustand -------------------------------------
|
||||
|
||||
@@ -220,13 +760,127 @@ public sealed record ProgressRequest(
|
||||
|
||||
public sealed record WatchedRequest(bool Watched);
|
||||
public sealed record FavouriteRequest(bool Favourite);
|
||||
|
||||
/// <summary>Mehrere Titel auf einmal abhaken — oder das Häkchen wegnehmen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine Anfrage statt zweihundert.</b> „Alle als gesehen markieren" über
|
||||
/// eine Mediathek hinweg wäre sonst ein Anfragensturm, bei dem mittendrin
|
||||
/// etwas scheitert und niemand weiss, was durchging.
|
||||
/// </remarks>
|
||||
public sealed record BulkWatchedRequest(IReadOnlyList<Guid> Ids, bool Watched);
|
||||
|
||||
/// <param name="Changed">Wie viele Titel sich dadurch geändert haben.</param>
|
||||
/// <param name="Unknown">
|
||||
/// Kennungen, zu denen es nichts gibt — stillschweigend zu übergehen hiesse,
|
||||
/// einen Teilerfolg als vollen auszugeben.
|
||||
/// </param>
|
||||
public sealed record BulkWatchedResponse(int Changed, IReadOnlyList<Guid> Unknown);
|
||||
|
||||
/// <summary>Welche Titel neu geholt werden sollen — leer heisst: alle.</summary>
|
||||
public sealed record RefreshRequest(IReadOnlyList<Guid>? Ids);
|
||||
|
||||
/// <summary>Was ein Spieler kann und was er sehen will.</summary>
|
||||
///
|
||||
/// <param name="Containers">Schachteln, die das Gerät öffnet — „mp4", „webm".</param>
|
||||
/// <param name="Height">
|
||||
/// Die gewünschte Stufe: 1080, 720 oder 480. <c>null</c> heisst Originalauflösung.
|
||||
/// </param>
|
||||
/// <param name="AudioStream">
|
||||
/// Welche Tonspur — die Nummer aus der Datei. <c>null</c> nimmt die erste.
|
||||
/// </param>
|
||||
/// <param name="StartSeconds">Ab welcher Sekunde des Films.</param>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Das Gerät sagt, was es kann, statt dass der Server es rät.</b> Vorher
|
||||
/// bekam jeder Browser Apples Profil — und mit ihm HEVC, AC3 und EAC3, von
|
||||
/// denen er keines öffnen kann.
|
||||
/// </remarks>
|
||||
public sealed record StreamRequest(
|
||||
IReadOnlyList<string>? Containers,
|
||||
IReadOnlyList<string>? VideoCodecs,
|
||||
IReadOnlyList<string>? AudioCodecs,
|
||||
int? Height = null,
|
||||
int? AudioStream = null,
|
||||
double? StartSeconds = null,
|
||||
|
||||
/// <summary>Umkodieren, egal was das Profil sagt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Für den zweiten Versuch.</b> Der Spieler schickt sein Profil aus
|
||||
/// <c>canPlayType</c> — und das ist eine Behauptung: „kenne ich" heisst
|
||||
/// nicht „kann ich diese Datei". Scheitert die Wiedergabe trotzdem, weiss
|
||||
/// der Browser mehr als jede Abfrage vorher, und sein Urteil schlägt
|
||||
/// seine eigene Auskunft.
|
||||
/// </remarks>
|
||||
bool Transcode = false,
|
||||
|
||||
/// <summary>Um wie viele Millisekunden der Ton verschoben wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Für Dateien, bei denen es ab Werk nicht stimmt.</b> Positiv heisst
|
||||
/// später, negativ früher. Der Server schiebt dabei nicht am Bild, sondern
|
||||
/// liest die Datei ein zweites Mal mit <c>-itsoffset</c> und nimmt von dort
|
||||
/// nur den Ton — mit einer Eingabe verschöbe sich beides gemeinsam und
|
||||
/// nichts wäre gewonnen.
|
||||
/// </remarks>
|
||||
int AudioOffsetMs = 0);
|
||||
|
||||
/// <summary>Eine wählbare Spur, so wie sie im Menü steht.</summary>
|
||||
public sealed record SpurAuskunft(int Index, string Label, string? Language, bool IsDefault);
|
||||
|
||||
/// <param name="StartSeconds">
|
||||
/// Ab welcher Sekunde des Films dieser Strom beginnt. <b>Der Spieler zählt ab
|
||||
/// null</b> — ohne diesen Wert ginge jeder gemeldete Stand um den Vorlauf daneben.
|
||||
/// </param>
|
||||
public sealed record StreamResponse(
|
||||
Guid SessionId,
|
||||
string Url,
|
||||
|
||||
/// <summary>Dieselbe Wiedergabe als Hauptliste, mit Untertitelspuren.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Für Abspieler, die ihre Untertitel aus HLS beziehen.</b> Der
|
||||
/// Browser hängt sie als eigene Spur ans Videoelement und braucht nur
|
||||
/// <see cref="Url"/>; AVPlayer kennt diesen Weg nicht — dort gibt es
|
||||
/// keine Möglichkeit, einer laufenden Wiedergabe eine Textspur von
|
||||
/// aussen beizulegen. Bei einer Direktwiedergabe steht hier nichts: dort
|
||||
/// liegen die Spuren in der Datei selbst.
|
||||
/// </remarks>
|
||||
string? MasterUrl,
|
||||
string Method,
|
||||
double StartSeconds,
|
||||
int? Height,
|
||||
int? AudioStream,
|
||||
IReadOnlyList<SpurAuskunft> AudioStreams,
|
||||
IReadOnlyList<SpurAuskunft> SubtitleStreams);
|
||||
/// <summary>Eine Bewertung von anderswo, fertig zum Hinschreiben.</summary>
|
||||
///
|
||||
/// <param name="Source">„imdb", „rottenTomatoes", „metacritic".</param>
|
||||
/// <param name="Display">Was dastehen soll — in der Form, die man von der Quelle kennt.</param>
|
||||
public sealed record Fremdwertungsantwort(string Source, double Value, string Display);
|
||||
|
||||
public sealed record RatingRequest(int? Rating);
|
||||
|
||||
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
|
||||
public sealed record ItemStateResponse(
|
||||
double PositionSeconds, double? RuntimeSeconds,
|
||||
bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed,
|
||||
DateTimeOffset? LastPlayedAt);
|
||||
DateTimeOffset? LastPlayedAt,
|
||||
|
||||
/// <summary>Ob der Titel in „Weiterschauen" steht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit die Beschriftung dasselbe sagt wie die Reihe.</b> Web und Apps
|
||||
/// entschieden „Weiterschauen" gegen „Abspielen" selbst, allein daran, ob
|
||||
/// eine Stelle über null steht. Seit es eine einstellbare Untergrenze gibt,
|
||||
/// lief das auseinander: ein Titel, in den man eine Sekunde hineingeschaut
|
||||
/// hat, stand nicht mehr in der Reihe — hiess aber weiter „Weiterschauen".
|
||||
///
|
||||
/// Der Server hat diese Frage ohnehin beantwortet; sie noch einmal zu
|
||||
/// stellen hiesse, dieselbe Regel an vier Stellen zu pflegen.
|
||||
/// </remarks>
|
||||
bool InProgress = false);
|
||||
|
||||
/// <summary>Eine Zeile in „Weiterschauen".</summary>
|
||||
///
|
||||
@@ -235,11 +889,80 @@ public sealed record ItemStateResponse(
|
||||
/// steht in <c>Title</c> die Serie und in <c>SubTitle</c> die Folge — so liest
|
||||
/// sich die Kachel wie bei Plex: oben, worum es geht, darunter, wo man ist.
|
||||
/// </remarks>
|
||||
/// <param name="Id">
|
||||
/// Die Kennung des Titels selbst — bei einer Folge also die der Folge. Dorthin
|
||||
/// geht der Fortschritt.
|
||||
/// </param>
|
||||
/// <param name="SeriesId">
|
||||
/// <b>Zu welcher Serie die Folge gehört</b> — <c>null</c> bei einem Film.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Ohne diese Angabe führte die Kachel ins Leere.</b> Sie trug die
|
||||
/// Folgenkennung und sollte damit eine Serienseite öffnen; die gibt es unter
|
||||
/// dieser Kennung nie, und heraus kam „Diese Serie gibt es nicht mehr" —
|
||||
/// ausgerechnet unter „Nächste Folge", wo man gerade weiterschauen wollte.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public sealed record ContinueEntry(
|
||||
Guid Id, string Kind, string Title, string? SubTitle,
|
||||
int? Season, int? Episode, int? Year,
|
||||
string? PosterImage, string? BackdropImage,
|
||||
ItemStateResponse State);
|
||||
ItemStateResponse State,
|
||||
Guid? SeriesId = null,
|
||||
|
||||
/// <summary>Die Datei, die abgespielt würde.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit „Abspielen" abspielt.</b> Der Aufmacher der Startseite hatte
|
||||
/// keine Datei zur Hand und konnte deshalb nur die Detailseite öffnen —
|
||||
/// ein Knopf, auf dem „Abspielen" steht und der etwas anderes tut.
|
||||
/// </remarks>
|
||||
Guid? FileId = null,
|
||||
double? DurationSeconds = null,
|
||||
|
||||
/// <summary>Ab wann abspielbar. In der Zukunft heisst: noch nicht.</summary>
|
||||
DateTimeOffset? PublishAt = null,
|
||||
|
||||
/// <summary>Das breite Bild der Serie, zu der die Folge gehört.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Neben dem Folgenbild, nicht statt seiner.</b> Die Kachel will das
|
||||
/// Bild der Folge — es zeigt, wo man steht. Der Aufmacher füllt die halbe
|
||||
/// Seite und will das Bild der Serie; dort stand bisher das Folgenbild,
|
||||
/// und wer das Serienbild selbst ausgesucht hatte, fand ein anderes vor.
|
||||
/// </remarks>
|
||||
string? SeriesBackdropImage = null,
|
||||
|
||||
/// <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>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Zwei Arten Zahl, und der Unterschied gehört dazu.</b>
|
||||
/// <c>LoggedSeconds</c> kommt aus dem Sehprotokoll und fängt an dem Tag an,
|
||||
/// an dem es eingebaut wurde. <c>WatchedTitles</c> und
|
||||
/// <c>WatchedRuntimeSeconds</c> kommen aus den Sehständen und sind für die
|
||||
/// ganze Vergangenheit vollständig. Beides als eine Zahl auszugeben hiesse,
|
||||
/// eine davon zu behaupten.
|
||||
/// </remarks>
|
||||
public sealed record StatistikResponse(
|
||||
double LoggedSeconds,
|
||||
int WatchedTitles,
|
||||
double WatchedRuntimeSeconds,
|
||||
DateTimeOffset? Since,
|
||||
IReadOnlyList<Wochenwert> Weeks,
|
||||
IReadOnlyList<Zuletztgesehen> Recent,
|
||||
IReadOnlyList<Genrewert> Genres);
|
||||
|
||||
/// <param name="Start">Der Montag dieser Woche.</param>
|
||||
public sealed record Wochenwert(DateOnly Start, double Seconds);
|
||||
|
||||
public sealed record Zuletztgesehen(
|
||||
Guid Id, string Kind, string Title, string? SubTitle,
|
||||
string? PosterImage, DateTimeOffset At, bool Watched);
|
||||
|
||||
public sealed record Genrewert(string Name, double Seconds);
|
||||
|
||||
/// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary>
|
||||
public sealed record SessionResponse(
|
||||
@@ -250,3 +973,125 @@ public sealed record SessionResponse(
|
||||
/// <summary>Was der Server gerade tut.</summary>
|
||||
public sealed record ServerStatusResponse(
|
||||
int Playing, int Packaging, int Scanning, double Uptime);
|
||||
|
||||
/* ---------- Meldungen ---------- */
|
||||
|
||||
/// <summary>Was jemand an einem Titel gemeldet hat.</summary>
|
||||
public sealed record MeldungRequest(
|
||||
/// <summary><c>bild</c>, <c>ton</c>, <c>untertitel</c>, <c>daten</c> oder <c>sonstiges</c>.</summary>
|
||||
string Kind,
|
||||
string Comment,
|
||||
/// <summary>Eine Stelle wie <c>0:34:12</c> — oder nichts.</summary>
|
||||
string? Position = null,
|
||||
/// <summary>Nur bei einer Serie: eine einzelne Staffel statt der ganzen.</summary>
|
||||
int? SeasonNumber = null);
|
||||
|
||||
/// <summary>Eine Meldung, wie sie gelesen wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b><c>ItemId</c> ist die Kennung der Detailseite, nicht die des
|
||||
/// Gemeldeten.</b> Bei einer Folge und bei einer Staffel steht hier die Serie —
|
||||
/// die Detailseite gehört ihr, wie bei „Weiterschauen" auch. Was genau gemeint
|
||||
/// ist, steht in <c>Target</c> und <c>SubTitle</c>.
|
||||
///
|
||||
/// <b><c>Title</c> ist leer, wenn der Titel nicht mehr sichtbar ist.</b> Ein
|
||||
/// Administrator kann einen Titel zurückziehen, nachdem jemand ihn gemeldet
|
||||
/// hat; die Meldung bleibt dann in der eigenen Liste des Melders stehen, nur
|
||||
/// ohne Namen. Sie verschwinden zu lassen hiesse, jemandem seine eigene Eingabe
|
||||
/// wegen einer fremden Entscheidung wegzunehmen.
|
||||
/// </remarks>
|
||||
public sealed record Meldungseintrag(
|
||||
Guid Id,
|
||||
/// <summary><c>movie</c>, <c>series</c>, <c>season</c> oder <c>episode</c>.</summary>
|
||||
string Target,
|
||||
Guid ItemId,
|
||||
string Title,
|
||||
/// <summary>„Staffel 2" oder „S2 · F5 — Der Pakt" — sonst <c>null</c>.</summary>
|
||||
string? SubTitle,
|
||||
string? PosterImage,
|
||||
string Kind,
|
||||
double? PositionSeconds,
|
||||
string Comment,
|
||||
DateTimeOffset ReportedAt,
|
||||
string ReportedBy,
|
||||
/// <summary><c>null</c> heisst: offen.</summary>
|
||||
DateTimeOffset? ResolvedAt);
|
||||
|
||||
/* ---------- Einladungen ---------- */
|
||||
|
||||
/// <summary>Was beim Erstellen eines Einladungslinks gewählt wird.</summary>
|
||||
public sealed record EinladungRequest(
|
||||
/// <summary>Wie viele Tage er gilt.</summary>
|
||||
int Days = 7,
|
||||
/// <summary>Wie oft er sich einlösen lässt. <c>null</c> heisst: unbegrenzt.</summary>
|
||||
int? MaxUses = 1);
|
||||
|
||||
/// <summary>Der frisch erstellte Link — der Token steht hier zum einzigen Mal.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Adresse baut der Client.</b> Der Server weiss nicht, unter welchem
|
||||
/// Namen er von aussen erreichbar ist; hinter einem Reverse Proxy rät er
|
||||
/// falsch, und ein falscher Einladungslink ist schlimmer als keiner.
|
||||
/// </remarks>
|
||||
public sealed record EinladungErstellt(Guid Id, string Token, DateTimeOffset ExpiresAt, int? MaxUses);
|
||||
|
||||
/// <summary>Eine Einladung in der Verwaltungsliste — ohne Token.</summary>
|
||||
public sealed record Einladungseintrag(
|
||||
Guid Id,
|
||||
string CreatedBy,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
int? MaxUses,
|
||||
int UsedCount,
|
||||
DateTimeOffset? RevokedAt,
|
||||
/// <summary>Ob sich damit jetzt noch ein Konto anlegen lässt.</summary>
|
||||
bool Valid);
|
||||
|
||||
/// <summary>Was die Einladungsseite zeigt, bevor jemand etwas eintippt.</summary>
|
||||
public sealed record Einladungsschau(string ServerName, string InvitedBy, DateTimeOffset ExpiresAt);
|
||||
|
||||
/// <summary>Namen und Passwort, die sich der Eingeladene selbst gibt.</summary>
|
||||
public sealed record EinloesenRequest(string UserName, string Password);
|
||||
|
||||
/// <summary>Eine Sprache, wie sie in einem Menü steht.</summary>
|
||||
///
|
||||
/// <param name="Code">Das Kürzel, unter dem die Einstellung abgelegt wird.</param>
|
||||
/// <param name="Name">Wie sie dasteht — „Deutsch", „English".</param>
|
||||
public sealed record Sprachangabe(string Code, string Name);
|
||||
|
||||
/// <summary>Welche Sprachen eine Serie durchgehend führt.</summary>
|
||||
///
|
||||
/// <param name="Audio">Tonsprachen, die in jeder Folge vorkommen.</param>
|
||||
/// <param name="Subtitles">Textsprachen, die in jeder Folge vorkommen.</param>
|
||||
/// <param name="Partial">
|
||||
/// Wie viele Sprachen es nur in einem Teil der Folgen gibt.
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit die Oberfläche es sagen kann, statt es zu verschweigen.</b> Ohne
|
||||
/// diese Zahl sähe eine gekürzte Liste aus wie die ganze Wahrheit — und wer
|
||||
/// eine Sprache vermisst, die er in Staffel 1 gesehen hat, hielte es für
|
||||
/// einen Fehler.
|
||||
/// </remarks>
|
||||
/// </param>
|
||||
public sealed record Sprachauswahl(
|
||||
IReadOnlyList<Sprachangabe> Audio,
|
||||
IReadOnlyList<Sprachangabe> Subtitles,
|
||||
int Partial);
|
||||
|
||||
/// <summary>Wie die Vorschaubilder eines Titels zusammengesetzt sind.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Kachelmasse stehen nicht fest.</b> Skaliert wird auf eine feste
|
||||
/// Breite, die Höhe folgt dem Seitenverhältnis — ein Breitbildfilm ergibt
|
||||
/// flachere Kacheln. Deshalb werden sie gemessen und mitgeteilt; geraten
|
||||
/// wäre jede Kachel um ein paar Bildpunkte verschoben, und das sieht man
|
||||
/// nicht als Fehler, sondern hält es für ein unscharfes Bild.
|
||||
/// </remarks>
|
||||
public sealed record VorschauAuskunft(
|
||||
bool Fertig,
|
||||
double Intervall,
|
||||
int Spalten,
|
||||
int Zeilen,
|
||||
int KachelBreite,
|
||||
int KachelHoehe,
|
||||
int Blaetter);
|
||||
|
||||
@@ -25,11 +25,14 @@ public static class DeliveryEndpoints
|
||||
// durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
|
||||
// Genau das ist passiert: der Spieler bekam HTML statt Video.
|
||||
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async (
|
||||
Guid id, FlaroContext db, PackageCoordinator packer) =>
|
||||
Guid id, FlaroContext db, PackageCoordinator packer, TimeProvider uhr,
|
||||
HttpContext zusammenhang) =>
|
||||
{
|
||||
var gefunden = await Laden(db, id);
|
||||
if (gefunden is not var (datei, plan)) return Results.NotFound();
|
||||
|
||||
if (await FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht) return NochNicht(nochNicht);
|
||||
|
||||
// Passt schon alles, geht die Datei unverändert hinaus — mit
|
||||
// Längenangabe und Bereichsanfragen, also mit Fortschritt und
|
||||
// Fortsetzen.
|
||||
@@ -45,6 +48,18 @@ public static class DeliveryEndpoints
|
||||
var fertig = packer.PathFor(datei.Id, datei.ModifiedAt);
|
||||
if (File.Exists(fertig))
|
||||
{
|
||||
// **Festhalten, solange die Antwort läuft.** Ein Download
|
||||
// über Mobilfunk dauert Stunden, und zwischen zwei
|
||||
// Bereichsanfragen kommt lange nichts — ohne diesen Griff
|
||||
// räumte der Aufräumer die Datei unter dem Client weg, und
|
||||
// der finge von vorn an.
|
||||
var griff = packer.Benutzen(fertig);
|
||||
zusammenhang.Response.OnCompleted(() => { griff.Dispose(); return Task.CompletedTask; });
|
||||
|
||||
// Und festhalten, dass sie gebraucht wurde: die Änderungszeit
|
||||
// ist der Benutzungsstempel, nach dem der Aufräumer sortiert.
|
||||
packer.AlsBenutztStempeln(fertig);
|
||||
|
||||
return Results.File(fertig, "video/mp4",
|
||||
Path.ChangeExtension(Path.GetFileName(datei.Path), ".mp4"),
|
||||
enableRangeProcessing: true);
|
||||
@@ -70,6 +85,41 @@ public static class DeliveryEndpoints
|
||||
}).RequireAuthorization();
|
||||
}
|
||||
|
||||
/// <summary>Ab wann diese Datei herausgehen darf — <c>null</c> heisst: sofort.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Hier und nicht im Sichtbarkeitsfilter.</b> Ein geplanter Titel steht
|
||||
/// mit „Verfügbar ab …" in der Mediathek — das ist der Sinn des Planens.
|
||||
/// Sichtbar heisst aber nicht abspielbar: ohne diese Prüfung liesse sich
|
||||
/// die Datei trotzdem herunterladen, und die Angabe wäre eine Zierde.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Für alle, auch für Administratoren.</b> Wer die Freigabe vorziehen
|
||||
/// will, hat dafür den Knopf „Jetzt veröffentlichen" — eine Hintertür für
|
||||
/// eine Rolle wäre eine Regel, die manchmal gilt.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static async Task<DateTimeOffset?> FreiAb(
|
||||
FlaroContext db, MediaFile datei, DateTimeOffset jetzt)
|
||||
{
|
||||
DateTimeOffset? wann = datei.MovieId is { } film
|
||||
? await db.Movies.AsNoTracking().Where(m => m.Id == film)
|
||||
.Select(m => m.PublishAt).FirstOrDefaultAsync()
|
||||
: datei.EpisodeId is { } folge
|
||||
? await db.Episodes.AsNoTracking().Where(f => f.Id == folge)
|
||||
.Select(f => f.PublishAt).FirstOrDefaultAsync()
|
||||
: null;
|
||||
|
||||
return wann is { } termin && termin > jetzt ? termin : null;
|
||||
}
|
||||
|
||||
/// <summary>Der Satz, der statt der Datei kommt.</summary>
|
||||
internal static IResult NochNicht(DateTimeOffset wann) =>
|
||||
Results.Problem(
|
||||
$"Dieser Titel ist ab {wann.ToLocalTime():dd.MM.yyyy, HH:mm} Uhr verfügbar.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
|
||||
private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext db, Guid id)
|
||||
{
|
||||
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Security.Claims;
|
||||
using Flaro.Core.Auth;
|
||||
using Flaro.Core.Users;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
/// <summary>Jemanden über einen Link einladen, statt ein Konto für ihn zu erfinden.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum.</b> Ein Konto von Hand anzulegen heisst, sich ein Passwort für
|
||||
/// einen anderen auszudenken und es ihm durchzusagen — über denselben Kanal,
|
||||
/// über den er den Link bekommen hätte. Wer eingeladen wird, soll sich Namen
|
||||
/// und Passwort selbst geben.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Zwei Welten in einer Datei.</b> Erstellen, auflisten und zurückziehen
|
||||
/// ist Verwaltung. Ansehen und Einlösen muss jemand können, der noch gar kein
|
||||
/// Konto hat — also anonym, und damit gedrosselt: es gibt keine globale
|
||||
/// Drossel in diesem Server, ein anonymer Endpunkt ohne eigene wäre
|
||||
/// ungebremst.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class EinladungEndpoints
|
||||
{
|
||||
private const int HoechstensTage = 365;
|
||||
|
||||
public static void MapEinladungen(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var verwalten = app.MapGroup("/api/v1/invites")
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
verwalten.MapPost("", async (
|
||||
EinladungRequest anfrage, ClaimsPrincipal wer,
|
||||
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
{
|
||||
if (Kennung(wer) is not { } ich) return Results.Unauthorized();
|
||||
|
||||
var tage = Math.Clamp(anfrage.Days, 1, HoechstensTage);
|
||||
if (anfrage.MaxUses is { } anzahl && anzahl < 1)
|
||||
return Einwand.Bitte("Ein Link, der nullmal gilt, ist keiner.");
|
||||
|
||||
// **Das Geheimnis verlässt den Server genau hier.** Gespeichert
|
||||
// wird nur sein Fingerfingerprint; aus der Datenbank lässt sich kein
|
||||
// Link mehr bauen, und eine verlorene Einladung wird deshalb nicht
|
||||
// wiedergefunden, sondern neu gemacht.
|
||||
var token = Secrets.Create();
|
||||
var jetzt = uhr.GetUtcNow();
|
||||
var einladung = new Einladung
|
||||
{
|
||||
TokenFingerprint = Secrets.Fingerprint(token),
|
||||
CreatedByUserId = ich,
|
||||
CreatedAt = jetzt,
|
||||
ExpiresAt = jetzt.AddDays(tage),
|
||||
MaxUses = anfrage.MaxUses,
|
||||
};
|
||||
|
||||
db.Einladungen.Add(einladung);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Results.Ok(new EinladungErstellt(
|
||||
einladung.Id, token, einladung.ExpiresAt, einladung.MaxUses));
|
||||
});
|
||||
|
||||
verwalten.MapGet("", async (FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
{
|
||||
var jetzt = uhr.GetUtcNow();
|
||||
var roh = await db.Einladungen.AsNoTracking()
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Join(db.Users.AsNoTracking(), e => e.CreatedByUserId, u => u.Id,
|
||||
(e, u) => new { E = e, u.UserName })
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Results.Ok(roh.Select(z => new Einladungseintrag(
|
||||
z.E.Id, z.UserName, z.E.CreatedAt, z.E.ExpiresAt,
|
||||
z.E.MaxUses, z.E.UsedCount, z.E.RevokedAt, z.E.Gilt(jetzt))).ToList());
|
||||
});
|
||||
|
||||
// **Zurückziehen, nicht löschen.** Eine Zeile wegzuwerfen hiesse,
|
||||
// hinterher nicht mehr zu wissen, dass es den Link je gab — und genau
|
||||
// das will man wissen, wenn jemand Unerwartetes im Haushalt auftaucht.
|
||||
//
|
||||
// **Und mit `purge=true` doch.** Die Begründung oben bleibt richtig,
|
||||
// sie gilt nur nicht ewig: eine Liste, aus der nichts je verschwindet,
|
||||
// wird irgendwann unlesbar, und dann sieht man die eine Zeile nicht
|
||||
// mehr, um die es ging. Weggeworfen wird deshalb nur, was ohnehin
|
||||
// keine Tür mehr öffnet — zurückgezogen, abgelaufen oder
|
||||
// aufgebraucht. Ein gültiger Link lässt sich nicht wegwischen; erst
|
||||
// zurückziehen, dann entfernen, und das ist ein Handgriff mehr mit
|
||||
// Absicht.
|
||||
verwalten.MapDelete("/{id:guid}", async (
|
||||
Guid id, FlaroContext db, TimeProvider uhr, CancellationToken ct,
|
||||
bool purge = false) =>
|
||||
{
|
||||
var einladung = await db.Einladungen.FirstOrDefaultAsync(e => e.Id == id, ct);
|
||||
if (einladung is null) return Einwand.Fehlt("Diese Einladung gibt es nicht mehr.");
|
||||
|
||||
var jetzt = uhr.GetUtcNow();
|
||||
|
||||
if (purge)
|
||||
{
|
||||
if (einladung.Gilt(jetzt))
|
||||
{
|
||||
return Einwand.Bitte(
|
||||
"Dieser Einladungslink gilt noch. Zieh ihn erst zurück, dann lässt er sich entfernen.");
|
||||
}
|
||||
|
||||
db.Einladungen.Remove(einladung);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
einladung.RevokedAt ??= jetzt;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
/* ---------- Was ohne Konto erreichbar sein muss ---------- */
|
||||
|
||||
app.MapGet("/api/v1/invites/{token}", async (
|
||||
string token, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
{
|
||||
var gefunden = await Suchen(db, token, ct);
|
||||
if (Pruefen(gefunden, uhr.GetUtcNow()) is { } einwand) return einwand;
|
||||
|
||||
var name = await db.Users.AsNoTracking()
|
||||
.Where(u => u.Id == gefunden!.CreatedByUserId)
|
||||
.Select(u => u.UserName).FirstOrDefaultAsync(ct) ?? "";
|
||||
|
||||
var servername = await db.Settings.AsNoTracking()
|
||||
.Where(s => s.Key == SystemEndpoints.NameSchlüssel)
|
||||
.Select(s => s.Value).FirstOrDefaultAsync(ct);
|
||||
|
||||
return Results.Ok(new Einladungsschau(
|
||||
string.IsNullOrWhiteSpace(servername) ? "Flaro" : servername,
|
||||
name, gefunden!.ExpiresAt));
|
||||
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
|
||||
|
||||
app.MapPost("/api/v1/invites/{token}/redeem", async (
|
||||
string token, EinloesenRequest anfrage,
|
||||
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
{
|
||||
var name = anfrage.UserName?.Trim() ?? "";
|
||||
if (name.Length == 0 || (anfrage.Password?.Length ?? 0) < 8)
|
||||
{
|
||||
return Einwand.Bitte(
|
||||
"Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
|
||||
}
|
||||
|
||||
var jetzt = uhr.GetUtcNow();
|
||||
var gefunden = await Suchen(db, token, ct);
|
||||
if (Pruefen(gefunden, jetzt) is { } einwand) return einwand;
|
||||
|
||||
// **Verbrauchen und anlegen gehören zusammen.**
|
||||
//
|
||||
// Zwei Leute, die gleichzeitig denselben einmaligen Link öffnen,
|
||||
// läsen beide `UsedCount = 0` und schrieben beide `1` — und hätten
|
||||
// beide ein Konto. Der Zähler wird deshalb mit einer Bedingung
|
||||
// hochgesetzt, die die Gültigkeit noch einmal prüft; wer dabei
|
||||
// keine Zeile trifft, war der Zweite.
|
||||
//
|
||||
// Und das Ganze in einer Transaktion: scheitert das Anlegen am
|
||||
// Namen, rollt der Verbrauch mit zurück. Sonst wäre ein einmaliger
|
||||
// Link an einem Tippfehler verbrannt.
|
||||
await using var geschaeft = await db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
var verbraucht = await db.Einladungen
|
||||
.Where(e => e.Id == gefunden!.Id
|
||||
&& e.RevokedAt == null
|
||||
&& e.ExpiresAt > jetzt
|
||||
&& (e.MaxUses == null || e.UsedCount < e.MaxUses))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(e => e.UsedCount, e => e.UsedCount + 1), ct);
|
||||
|
||||
if (verbraucht != 1)
|
||||
return Einwand.Bitte("Dieser Einladungslink wurde schon benutzt.");
|
||||
|
||||
if (await db.Users.AnyAsync(u => u.UserName == name, ct))
|
||||
return Einwand.Belegt($"„{name}“ gibt es schon. Nimm einen anderen.");
|
||||
|
||||
db.Users.Add(new User
|
||||
{
|
||||
UserName = name,
|
||||
PasswordHash = PasswordHasher.Hash(anfrage.Password!),
|
||||
IsAdministrator = false,
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await geschaeft.CommitAsync(ct);
|
||||
return Results.NoContent();
|
||||
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
|
||||
}
|
||||
|
||||
private static Task<Einladung?> Suchen(FlaroContext db, string token, CancellationToken ct) =>
|
||||
db.Einladungen.FirstOrDefaultAsync(
|
||||
e => e.TokenFingerprint == Secrets.Fingerprint(token ?? ""), ct);
|
||||
|
||||
/// <summary>Der Grund, warum ein Link nicht gilt — oder <c>null</c>.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Unterschiedliche Sätze, mit Absicht.</b> Alles in ein „gilt nicht"
|
||||
/// zusammenzufassen wäre verschwiegener, aber hier ist Verschwiegenheit
|
||||
/// am falschen Platz: wer einen Link bekommen hat und abgewiesen wird,
|
||||
/// muss wissen, ob er zu spät ist oder ob jemand ihn zurückgezogen hat —
|
||||
/// sonst fragt er nach, und der Gastgeber rät mit.
|
||||
/// </remarks>
|
||||
private static IResult? Pruefen(Einladung? einladung, DateTimeOffset jetzt) => einladung switch
|
||||
{
|
||||
null => Einwand.Fehlt("Diesen Einladungslink gibt es nicht."),
|
||||
{ RevokedAt: not null } => Einwand.Bitte("Dieser Einladungslink wurde zurückgezogen."),
|
||||
var e when jetzt >= e.ExpiresAt => Einwand.Bitte("Dieser Einladungslink ist abgelaufen."),
|
||||
var e when e.MaxUses is { } h && e.UsedCount >= h =>
|
||||
Einwand.Bitte("Dieser Einladungslink wurde schon benutzt."),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static Guid? Kennung(ClaimsPrincipal wer) =>
|
||||
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
/// <summary>Neue Funde prüfen, veröffentlichen oder auf einen Zeitpunkt legen.</summary>
|
||||
public static class FreigabeEndpoints
|
||||
{
|
||||
public static void MapFreigabe(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var gruppe = app.MapGroup("/api/v1/review")
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Was noch wartet, und was schon geplant ist.**
|
||||
//
|
||||
// Beides in einer Liste: ein geplanter Titel ist nicht erledigt, er
|
||||
// hat nur schon einen Termin. Ihn verschwinden zu lassen hiesse, dass
|
||||
// man ihn nur findet, wenn man sich den Termin gemerkt hat.
|
||||
gruppe.MapGet("/", async (FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
{
|
||||
var jetzt = uhr.GetUtcNow();
|
||||
|
||||
var filme = await db.Movies.AsNoTracking()
|
||||
.Where(m => m.PublishAt == null || m.PublishAt > jetzt || m.ChangedSince != null)
|
||||
.OrderBy(m => m.SortTitleValue)
|
||||
.Select(m => new Pruefeintrag(
|
||||
"movie", m.Id, m.Title, m.Year, m.PosterImage, m.PublishAt,
|
||||
db.MediaFiles.Where(f => f.MovieId == m.Id).Select(f => f.Path).FirstOrDefault(),
|
||||
m.TmdbId != null, 1, new List<Guid> { m.Id },
|
||||
// **Neu sticht geändert.** Ein Titel, der noch gar nicht
|
||||
// sichtbar ist, braucht eine Freigabe — dass sich dabei
|
||||
// auch etwas geändert hat, ist die kleinere Nachricht.
|
||||
m.PublishAt == null || m.PublishAt > jetzt ? "neu" : "geaendert"))
|
||||
.ToListAsync(ct);
|
||||
|
||||
// **Folgen werden nach Serie gebündelt.** Zwanzig neue Folgen
|
||||
// derselben Serie sind eine Entscheidung, nicht zwanzig.
|
||||
var offeneFolgen = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.PublishAt == null || f.PublishAt > jetzt)
|
||||
.Select(f => new
|
||||
{
|
||||
f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.PublishAt,
|
||||
Pfad = db.MediaFiles.Where(d => d.EpisodeId == f.Id)
|
||||
.Select(d => d.Path).FirstOrDefault(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var serienIds = offeneFolgen.Select(f => f.SeriesId).Distinct().ToList();
|
||||
var serien = await db.Series.AsNoTracking()
|
||||
.Where(s => serienIds.Contains(s.Id))
|
||||
.Select(s => new { s.Id, s.Title, s.Year, s.PosterImage, Zugeordnet = s.TmdbId != null })
|
||||
.ToDictionaryAsync(s => s.Id, ct);
|
||||
|
||||
var gebuendelt = offeneFolgen
|
||||
.GroupBy(f => f.SeriesId)
|
||||
.Select(g =>
|
||||
{
|
||||
var serie = serien.GetValueOrDefault(g.Key);
|
||||
var termine = g.Select(f => f.PublishAt).Distinct().ToList();
|
||||
|
||||
return new Pruefeintrag(
|
||||
"series", g.Key,
|
||||
serie?.Title ?? "Unbekannte Serie",
|
||||
serie?.Year,
|
||||
serie?.PosterImage,
|
||||
// Nur wenn alle denselben Termin tragen — sonst wäre es
|
||||
// eine Behauptung über Folgen, die anders liegen.
|
||||
termine.Count == 1 ? termine[0] : null,
|
||||
Pfad(g.Select(f => f.Pfad).FirstOrDefault()),
|
||||
serie?.Zugeordnet ?? false,
|
||||
g.Count(),
|
||||
g.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
||||
.Select(f => f.Id).ToList());
|
||||
})
|
||||
.OrderBy(e => e.Title)
|
||||
.ToList();
|
||||
|
||||
// **Geänderte Serien stehen für sich.** Die Bündelung oben geht
|
||||
// über wartende Folgen; eine Serie, deren Titel oder Plakat sich
|
||||
// geändert hat, hat damit nichts zu tun — ihre Folgen sind längst
|
||||
// sichtbar.
|
||||
var schonDa = gebuendelt.Select(e => e.Id).ToHashSet();
|
||||
var geaenderteSerien = await db.Series.AsNoTracking()
|
||||
.Where(x => x.ChangedSince != null && !schonDa.Contains(x.Id))
|
||||
.OrderBy(x => x.SortTitleValue)
|
||||
.Select(x => new Pruefeintrag(
|
||||
"series", x.Id, x.Title, x.Year, x.PosterImage, null,
|
||||
null, x.TmdbId != null,
|
||||
db.Episodes.Count(f => f.SeriesId == x.Id),
|
||||
new List<Guid> { x.Id },
|
||||
"geaendert"))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Results.Ok(filme.Concat(gebuendelt).Concat(geaenderteSerien).ToList());
|
||||
|
||||
// Bei einer Serie sagt der Ordner mehr als die erste Folgendatei.
|
||||
static string? Pfad(string? datei) =>
|
||||
datei is null ? null : System.IO.Path.GetDirectoryName(datei);
|
||||
});
|
||||
|
||||
// **Zur Kenntnis genommen.**
|
||||
//
|
||||
// Für Titel, die längst sichtbar sind und sich nur geändert haben.
|
||||
// Veröffentlichen wäre dort das falsche Wort — es gibt nichts
|
||||
// freizugeben, nur etwas abzuhaken.
|
||||
gruppe.MapPost("/acknowledge", async (
|
||||
FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
var ids = anfrage.Ids ?? [];
|
||||
|
||||
var filme = ids.Count == 0
|
||||
? await db.Movies.Where(m => m.ChangedSince != null)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(m => m.ChangedSince, (DateTimeOffset?)null), ct)
|
||||
: await db.Movies.Where(m => ids.Contains(m.Id) && m.ChangedSince != null)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(m => m.ChangedSince, (DateTimeOffset?)null), ct);
|
||||
|
||||
var serien = ids.Count == 0
|
||||
? await db.Series.Where(x => x.ChangedSince != null)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ChangedSince, (DateTimeOffset?)null), ct)
|
||||
: await db.Series.Where(x => ids.Contains(x.Id) && x.ChangedSince != null)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ChangedSince, (DateTimeOffset?)null), ct);
|
||||
|
||||
return Results.Ok(new { Acknowledged = filme + serien });
|
||||
});
|
||||
|
||||
// Veröffentlichen oder planen. Ohne Zeitpunkt heisst: jetzt.
|
||||
gruppe.MapPost("/publish", async (
|
||||
FreigabeRequest anfrage, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
{
|
||||
var wann = anfrage.PublishAt ?? uhr.GetUtcNow();
|
||||
|
||||
// Alles Wartende einer einzelnen Mediathek — der Knopf, der dort
|
||||
// steht, wo man gerade gescannt hat.
|
||||
if (anfrage.LibraryId is { } mediathek)
|
||||
{
|
||||
var jetztDort = uhr.GetUtcNow();
|
||||
|
||||
var dortFilme = await db.Movies
|
||||
.Where(m => m.LibraryId == mediathek
|
||||
&& (m.PublishAt == null || m.PublishAt > jetztDort))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct);
|
||||
|
||||
var dortFolgen = await db.Episodes
|
||||
.Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek)
|
||||
&& (f.PublishAt == null || f.PublishAt > jetztDort))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct);
|
||||
|
||||
return Results.Ok(new { published = dortFilme + dortFolgen });
|
||||
}
|
||||
|
||||
// **Alles auf einmal, wenn keine Kennungen dabei sind.** Bei
|
||||
// vierhundert neuen Titeln wäre einzeln Klicken keine Bedienung.
|
||||
if (anfrage.Ids is null or { Count: 0 })
|
||||
{
|
||||
// **Alles, was noch nicht sichtbar ist** — Wartendes und
|
||||
// Geplantes. Nur das Wartende zu fassen hiesse: ein auf
|
||||
// nächste Woche gelegter Titel bliebe liegen, obwohl er in
|
||||
// derselben Liste stand und „Alle veröffentlichen" gedrückt
|
||||
// wurde.
|
||||
var jetzt = uhr.GetUtcNow();
|
||||
|
||||
var alleFilme = await db.Movies
|
||||
.Where(m => m.PublishAt == null || m.PublishAt > jetzt)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct);
|
||||
var alleFolgen = await db.Episodes
|
||||
.Where(f => f.PublishAt == null || f.PublishAt > jetzt)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct);
|
||||
|
||||
return Results.Ok(new { published = alleFilme + alleFolgen });
|
||||
}
|
||||
|
||||
var ids = anfrage.Ids;
|
||||
var filme = await db.Movies.Where(m => ids.Contains(m.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct);
|
||||
var folgen = await db.Episodes.Where(f => ids.Contains(f.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct);
|
||||
|
||||
return Results.Ok(new { published = filme + folgen });
|
||||
});
|
||||
|
||||
// Zurückziehen — wieder in die Prüfung.
|
||||
gruppe.MapPost("/withhold", async (
|
||||
FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
if (anfrage.Ids is null or { Count: 0 })
|
||||
return Einwand.Bitte("Ohne Kennungen gibt es nichts zurückzuziehen.");
|
||||
|
||||
var ids = anfrage.Ids;
|
||||
var filme = await db.Movies.Where(m => ids.Contains(m.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, (DateTimeOffset?)null), ct);
|
||||
var folgen = await db.Episodes.Where(f => ids.Contains(f.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, (DateTimeOffset?)null), ct);
|
||||
|
||||
return Results.Ok(new { withheld = filme + folgen });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public static class ImageEndpoints
|
||||
// Der Schlüssel ist der Hash des Inhalts, also ändert sich das Bild
|
||||
// unter dieser Adresse nie. Ein Jahr zwischenspeichern spart bei
|
||||
// einer Mediathek mit tausend Plakaten das meiste an Verkehr.
|
||||
return Results.File(pfad, "image/jpeg", enableRangeProcessing: true);
|
||||
return Results.File(pfad, ImageCache.MedientypVon(pfad), enableRangeProcessing: true);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.AddEndpointFilter(async (zusammenhang, weiter) =>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Flaro.Api.Auth;
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Media;
|
||||
using Flaro.Core.Media;
|
||||
using System.Security.Claims;
|
||||
using Flaro.Core.Playback;
|
||||
using Flaro.Data;
|
||||
@@ -16,6 +22,34 @@ public static class LibraryContentEndpoints
|
||||
/// </remarks>
|
||||
private const int HöchstensJeSeite = 200;
|
||||
|
||||
/// <summary>Wie viele Titel eine Auswahl höchstens umfasst.</summary>
|
||||
private const int HöchstensJeZug = 1000;
|
||||
|
||||
/// <summary>Welche Titel im Rumpf stehen — oder <c>null</c>, wenn keiner da ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Ein fehlender Rumpf ist kein Fehler, sondern die Voreinstellung.</b>
|
||||
/// „Alles auffrischen" schickt nichts mit, und das muss so bleiben: der
|
||||
/// Knopf dafür gibt es länger als die Auswahl.
|
||||
/// </remarks>
|
||||
private static async Task<IReadOnlyList<Guid>?> Auswahl(HttpRequest anfrage)
|
||||
{
|
||||
if (anfrage.ContentLength is null or 0) return null;
|
||||
if (!anfrage.HasJsonContentType()) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var gelesen = await anfrage.ReadFromJsonAsync<RefreshRequest>();
|
||||
return gelesen?.Ids;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
// Unlesbarer Rumpf heisst: keine Auswahl. Alles aufzufrischen ist
|
||||
// hier die harmlosere Auslegung — und sie kostet nur Zeit.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wonach eine Liste geordnet wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
@@ -46,6 +80,119 @@ public static class LibraryContentEndpoints
|
||||
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
|
||||
/// und SQLite führt die nacheinander aus.
|
||||
/// </remarks>
|
||||
/// <summary>Die Folge vor oder nach dieser.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine Rechnung, zwei Wege.</b> Vorwärts und rückwärts unterscheiden
|
||||
/// sich in zwei Zeichen — als zwei Endpunkte abgeschrieben liefen sie
|
||||
/// auseinander, sobald einer von beiden etwas dazubekommt.
|
||||
/// </remarks>
|
||||
private static async Task<IResult> Nachbarfolge(
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db, bool vorwaerts)
|
||||
{
|
||||
var diese = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.Id == id)
|
||||
.Select(f => new { f.SeriesId, f.SeasonNumber, f.EpisodeNumber })
|
||||
.FirstOrDefaultAsync();
|
||||
if (diese is null) return Results.NotFound();
|
||||
|
||||
var geschwister = db.Episodes.AsNoTracking().Where(f => f.SeriesId == diese.SeriesId);
|
||||
|
||||
var gefiltert = vorwaerts
|
||||
? geschwister
|
||||
.Where(f => f.SeasonNumber > diese.SeasonNumber
|
||||
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
|
||||
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
||||
: geschwister
|
||||
.Where(f => f.SeasonNumber < diese.SeasonNumber
|
||||
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber < diese.EpisodeNumber))
|
||||
.OrderByDescending(f => f.SeasonNumber).ThenByDescending(f => f.EpisodeNumber);
|
||||
|
||||
var nachbar = await gefiltert
|
||||
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
// Keine weitere Folge ist kein Fehler — es war die erste oder die letzte.
|
||||
if (nachbar is null) return Results.NoContent();
|
||||
|
||||
var dateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(d => d.EpisodeId == nachbar.Id)
|
||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel })
|
||||
.ToListAsync();
|
||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||
var stände = await ZuständeZu(db, wer, folgen: [nachbar.Id]);
|
||||
var segmente = await SegmenteZu(db, [nachbar.Id]);
|
||||
|
||||
// **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
|
||||
// nichts; das Plakat der Serie sagt wenigstens, worum es geht.
|
||||
// TMDB liefert Folgenbilder nur für einen Teil der Folgen, und bei
|
||||
// kleineren Serien für gar keine.
|
||||
var serienBild = await db.Series.AsNoTracking()
|
||||
.Where(x => x.Id == diese.SeriesId)
|
||||
.Select(x => x.PosterImage).FirstOrDefaultAsync();
|
||||
|
||||
return Results.Ok(new EpisodeResponse(
|
||||
nachbar.Id, nachbar.SeasonNumber, nachbar.EpisodeNumber,
|
||||
nachbar.Title, nachbar.Overview, nachbar.StillImage ?? serienBild,
|
||||
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
|
||||
stände.GetValueOrDefault(nachbar.Id),
|
||||
nachbar.PublishAt,
|
||||
SeriesId: diese.SeriesId,
|
||||
Markers: segmente[nachbar.Id].ToList()));
|
||||
}
|
||||
|
||||
/// <summary>Die Segmente zu einer Reihe von Folgen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Als Nachschlagewerk und nicht je Folge einzeln: eine Staffel mit
|
||||
/// zwanzig Folgen wären sonst zwanzig Abfragen für zwanzig kurze Zeilen.
|
||||
/// </remarks>
|
||||
private static async Task<ILookup<Guid, MarkerResponse>> SegmenteZu(
|
||||
FlaroContext db, IReadOnlyCollection<Guid> folgen)
|
||||
{
|
||||
if (folgen.Count == 0)
|
||||
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
|
||||
|
||||
var roh = await db.Segmente.AsNoTracking()
|
||||
.Where(m => m.EpisodeId != null && folgen.Contains(m.EpisodeId!.Value))
|
||||
// In Zeitfolge: ein geteilter Abspann kommt als zwei Segmente, und
|
||||
// die Spieler nehmen die erste passende.
|
||||
.OrderBy(m => m.VonSekunden)
|
||||
.Select(m => new { Id = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
|
||||
.ToListAsync();
|
||||
|
||||
return roh.ToLookup(
|
||||
m => m.Id,
|
||||
m => new MarkerResponse(
|
||||
m.Art == Segmentart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
|
||||
}
|
||||
|
||||
/// <summary>Dasselbe für Filme.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Getrennt und nicht über eine gemeinsame Kennung: ein Segment gehört
|
||||
/// entweder zu einer Folge oder zu einem Film, und eine Abfrage, die
|
||||
/// beides zugleich sucht, fände zu einer Filmkennung auch eine Folge
|
||||
/// mit derselben — unmöglich heute, aber nichts hält es fest.
|
||||
/// </remarks>
|
||||
private static async Task<ILookup<Guid, MarkerResponse>> FilmsegmenteZu(
|
||||
FlaroContext db, IReadOnlyCollection<Guid> filme)
|
||||
{
|
||||
if (filme.Count == 0)
|
||||
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
|
||||
|
||||
var roh = await db.Segmente.AsNoTracking()
|
||||
.Where(m => m.MovieId != null && filme.Contains(m.MovieId!.Value))
|
||||
.OrderBy(m => m.VonSekunden)
|
||||
.Select(m => new { Id = m.MovieId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
|
||||
.ToListAsync();
|
||||
|
||||
return roh.ToLookup(
|
||||
m => m.Id,
|
||||
m => new MarkerResponse(
|
||||
m.Art == Segmentart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
|
||||
}
|
||||
|
||||
private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien)
|
||||
{
|
||||
if (dateien.Count == 0) return Array.Empty<(Guid, MediaStreamResponse)>().ToLookup(x => x.Item1, x => x.Item2);
|
||||
@@ -57,15 +204,101 @@ public static class LibraryContentEndpoints
|
||||
|
||||
return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse(
|
||||
s.Index, s.Kind.ToString(), s.Codec, s.Language, s.Title,
|
||||
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height));
|
||||
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height,
|
||||
Display: s.Kind switch
|
||||
{
|
||||
MediaStreamKind.Video => Benennung.Bildspur(s.Codec, s.Width, s.Height),
|
||||
MediaStreamKind.Audio => Benennung.Tonspur(s.Language, s.Codec, s.Channels, s.Title),
|
||||
_ => Benennung.Untertitelspur(s.Language, s.IsForced, fuerHoergeschaedigte: false),
|
||||
},
|
||||
LanguageName: Benennung.Sprache(s.Language),
|
||||
ResolutionLabel: s.Kind == MediaStreamKind.Video
|
||||
? Benennung.Aufloesungswort(s.Width, s.Height) : null,
|
||||
ResolutionExact: s.Kind == MediaStreamKind.Video
|
||||
? Benennung.Aufloesung(s.Width, s.Height) : null));
|
||||
}
|
||||
|
||||
private static MediaFileResponse Datei(
|
||||
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
|
||||
ILookup<Guid, MediaStreamResponse> spuren) =>
|
||||
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList());
|
||||
ILookup<Guid, MediaStreamResponse> spuren, string? fassung = null,
|
||||
string? mediathekspfad = null) =>
|
||||
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds,
|
||||
spuren[id].ToList(),
|
||||
// **Drei Quellen, in dieser Reihenfolge.** Was in der Datenbank
|
||||
// steht, kam aus dem Dateinamen und ist das Genaueste. Sonst wird
|
||||
// der Pfad gelesen — ein Bestand von vor dieser Änderung soll
|
||||
// nicht erst neu gescannt werden müssen, um „Mobil" zu zeigen.
|
||||
// Und sonst die Messung: hier gerechnet und nicht gespeichert,
|
||||
// damit ein neues Wort nicht erst einen Scan braucht.
|
||||
fassung ?? FileNames.Fassung(pfad) ?? AusDenSpuren(spuren[id]),
|
||||
Ordner(pfad, mediathekspfad));
|
||||
|
||||
/// <summary>Die Fassung aus der gemessenen Bildgrösse — „Full HD", „SD".</summary>
|
||||
private static string? AusDenSpuren(IEnumerable<MediaStreamResponse> spuren) =>
|
||||
spuren.FirstOrDefault(s => s.Kind == nameof(MediaStreamKind.Video))?.ResolutionLabel;
|
||||
|
||||
/// <summary>Der Pfad der Mediathek — nur für Administratoren.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Hier entscheidet sich, wer den Ordner sieht.</b> Ohne Wurzel bleibt
|
||||
/// <see cref="Ordner"/> bei <c>null</c>, und das Feld kommt erst gar nicht
|
||||
/// in der Antwort an — statt es im Client zu verstecken, wo es jeder
|
||||
/// auslesen könnte.
|
||||
/// </remarks>
|
||||
private static async Task<string?> MediathekspfadFuer(
|
||||
FlaroContext db, ClaimsPrincipal wer, Guid bibliothek)
|
||||
{
|
||||
if (!wer.IsInRole(FlaroRoles.Administrator)) return null;
|
||||
|
||||
return await db.Libraries.AsNoTracking()
|
||||
.Where(l => l.Id == bibliothek)
|
||||
.Select(l => l.Path)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>Der Ordner unterhalb der Mediathek — oder <c>null</c>.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Nur der Teil unterhalb, nie der volle Pfad.</b> Der verriete jedem
|
||||
/// Angemeldeten den Aufbau des Servers; was zum Beurteilen einer Zuordnung
|
||||
/// nötig ist, ist der Ordnername — „Hanni und Nanni" neben dem Titel
|
||||
/// „Hanni & Nanni 3" sagt in einer Sekunde, dass etwas nicht stimmt.
|
||||
///
|
||||
/// <c>null</c> für alle, die nicht verwalten dürfen: dort wird der
|
||||
/// Mediathekspfad gar nicht erst hereingereicht.
|
||||
/// </remarks>
|
||||
private static string? Ordner(string pfad, string? mediathekspfad)
|
||||
{
|
||||
if (string.IsNullOrEmpty(mediathekspfad)) return null;
|
||||
|
||||
var ordner = System.IO.Path.GetDirectoryName(pfad);
|
||||
if (string.IsNullOrEmpty(ordner)) return null;
|
||||
|
||||
var wurzel = mediathekspfad.TrimEnd('/', '\\');
|
||||
if (!ordner.StartsWith(wurzel, StringComparison.Ordinal)) return null;
|
||||
|
||||
var darunter = ordner[wurzel.Length..].Trim('/', '\\');
|
||||
// Liegt die Datei unmittelbar in der Mediathek, gibt es keinen Ordner
|
||||
// — und „" wäre eine Zeile, die nichts sagt.
|
||||
return darunter.Length > 0 ? darunter : null;
|
||||
}
|
||||
|
||||
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
|
||||
/// <summary>Die Bewertungen von anderswo zu einem Titel.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Nur beim Einzelabruf.</b> In einer Liste mit fünfzig Kacheln
|
||||
/// stünden sie nirgends, kosteten aber fünfzig Abfragen — dieselbe
|
||||
/// Überlegung wie bei Genres und Besetzung.
|
||||
/// </remarks>
|
||||
private static async Task<IReadOnlyList<Fremdwertungsantwort>> FremdwertungenZu(
|
||||
FlaroContext db, Guid? film, Guid? serie) =>
|
||||
await db.Fremdbewertungen.AsNoTracking()
|
||||
.Where(b => film != null ? b.MovieId == film : b.SeriesId == serie)
|
||||
.OrderByDescending(b => b.Wert)
|
||||
.Select(b => new Fremdwertungsantwort(b.Quelle, b.Wert, b.Anzeige))
|
||||
.ToListAsync();
|
||||
|
||||
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
|
||||
await db.GenreLinks.AsNoTracking()
|
||||
.Where(l => film != null ? l.MovieId == film : l.SeriesId == serie)
|
||||
@@ -90,23 +323,33 @@ public static class LibraryContentEndpoints
|
||||
/// Stand — das ist kein Fehler, sondern der Normalfall für alles, was
|
||||
/// noch niemand angefasst hat, und die Antwort trägt dort <c>null</c>.
|
||||
/// </remarks>
|
||||
private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
|
||||
FlaroContext db, ClaimsPrincipal wer, List<Guid>? filme = null, List<Guid>? folgen = null)
|
||||
/// <remarks>
|
||||
/// <b>Nicht mehr privat.</b> Drei Listen ausserhalb dieser Klasse gaben
|
||||
/// `State: null` zurück, obwohl die Stände in derselben Datenbank
|
||||
/// liegen: Vorschläge, saisonale Reihe und ähnliche Titel. Ein halb
|
||||
/// gesehener Film stand dort ohne Balken, während dieselbe Kachel in der
|
||||
/// Mediathek einen hatte.
|
||||
/// </remarks>
|
||||
internal static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
|
||||
FlaroContext db, ClaimsPrincipal wer,
|
||||
List<Guid>? filme = null, List<Guid>? folgen = null, List<Guid>? serien = null)
|
||||
{
|
||||
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return [];
|
||||
|
||||
var gesucht = (filme ?? folgen)!;
|
||||
var gesucht = (filme ?? folgen ?? serien)!;
|
||||
if (gesucht.Count == 0) return [];
|
||||
|
||||
var zeilen = await db.UserItemStates.AsNoTracking()
|
||||
.Where(z => z.UserId == benutzer)
|
||||
.Where(z => filme != null
|
||||
? z.MovieId != null && gesucht.Contains(z.MovieId!.Value)
|
||||
: z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value))
|
||||
: folgen != null
|
||||
? z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value)
|
||||
: z.SeriesId != null && gesucht.Contains(z.SeriesId!.Value))
|
||||
.ToListAsync();
|
||||
|
||||
return zeilen.ToDictionary(
|
||||
z => (filme != null ? z.MovieId : z.EpisodeId)!.Value,
|
||||
z => (filme != null ? z.MovieId : folgen != null ? z.EpisodeId : z.SeriesId)!.Value,
|
||||
PlaybackEndpoints.Abbild);
|
||||
}
|
||||
|
||||
@@ -147,23 +390,54 @@ public static class LibraryContentEndpoints
|
||||
// erst beim nächsten neuen Film. Geholt wird über die bekannte
|
||||
// Kennung, nicht über eine neue Suche; eine Zuordnung von Hand
|
||||
// bleibt damit stehen.
|
||||
//
|
||||
// **Mit einer Auswahl im Rumpf nur diese Titel.** Zehn ausgewählte
|
||||
// Filme kosten zehn Anfragen bei TMDB; der ganze Bestand viertausend
|
||||
// und eine Viertelstunde. Ohne Rumpf bleibt es beim ganzen Bestand.
|
||||
//
|
||||
// **Der Rumpf wird von Hand gelesen, und das hat einen Grund.** Als
|
||||
// `RefreshRequest?` im Kopf der Methode lehnte der Server jede Anfrage
|
||||
// ohne Rumpf mit 404 ab — also genau den Aufruf „die ganze Mediathek
|
||||
// auffrischen", den es vorher schon gab. Gemessen, nicht vermutet: ohne
|
||||
// Rumpf 404, mit `{}` 202.
|
||||
app.MapPost("/api/v1/libraries/{id:guid}/refresh", async (
|
||||
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
|
||||
Guid id, HttpRequest anfrage, FlaroContext db, ScanCoordinator koordinator) =>
|
||||
{
|
||||
if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound();
|
||||
|
||||
return koordinator.TryStart(id, erneut: true)
|
||||
IReadOnlySet<Guid>? nurDiese = null;
|
||||
if (await Auswahl(anfrage) is { Count: > 0 } gewaehlt)
|
||||
{
|
||||
if (gewaehlt.Count > HöchstensJeZug)
|
||||
return Results.Problem(
|
||||
$"Mehr als {HöchstensJeZug} Titel auf einmal gehen nicht. "
|
||||
+ "Nimm weniger und wiederhole es.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
nurDiese = gewaehlt.ToHashSet();
|
||||
}
|
||||
|
||||
return koordinator.TryStart(id, erneut: true, nurDiese: nurDiese)
|
||||
? Results.AcceptedAtRoute("Scanstand", new { id })
|
||||
: Results.Problem(
|
||||
"Für diese Bibliothek läuft bereits ein Lauf.",
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapGet("/api/v1/libraries/{id:guid}/scan", (Guid id, ScanCoordinator koordinator) =>
|
||||
app.MapGet("/api/v1/libraries/{id:guid}/scan",
|
||||
(Guid id, ScanCoordinator koordinator, Segmenterkennung erkennung) =>
|
||||
{
|
||||
// **Auch ohne Scan kann etwas laufen.** Die Segmentsuche lässt
|
||||
// sich von Hand anstossen; ohne diese Zeile stünde die
|
||||
// Verwaltung dann auf „idle", während der Server stundenlang
|
||||
// arbeitet.
|
||||
var segmente = erkennung.Stand(id)?.Text();
|
||||
|
||||
var zustand = koordinator.State(id);
|
||||
if (zustand is null)
|
||||
return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null, null, null));
|
||||
return Results.Ok(new ScanStatus(
|
||||
segmente is null ? "idle" : "marken",
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
Segmente: segmente));
|
||||
|
||||
var bericht = zustand.Report;
|
||||
var metadaten = zustand.Enrichment;
|
||||
@@ -175,20 +449,91 @@ public static class LibraryContentEndpoints
|
||||
zustand.Error,
|
||||
metadaten is null ? null : new MetadataProgress(
|
||||
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
|
||||
metadaten.Skipped, metadaten.Error)));
|
||||
metadaten.Skipped, metadaten.Error),
|
||||
bericht?.Missing,
|
||||
zustand.Schritt?.Text(),
|
||||
segmente));
|
||||
}).WithName("Scanstand").RequireAuthorization();
|
||||
}
|
||||
|
||||
/// <summary>Ähnliche Titel — aus dem eigenen Bestand, nicht aus dem Netz.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Nur, was man besitzt.</b> Ein Vorschlag, den man nicht abspielen
|
||||
/// kann, ist Werbung. TMDB hätte eine Empfehlungsliste, aber die besteht
|
||||
/// zur Hälfte aus Filmen, die hier nicht liegen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Aus Genres und Menschen, ohne eine einzige fremde Anfrage.</b> Der
|
||||
/// Katalog steht ohnehin in der Datenbank. Ein gemeinsamer Mensch wiegt
|
||||
/// schwerer als ein gemeinsames Genre: dass zwei Filme „Drama" sind, sagt
|
||||
/// wenig; dass beide von derselben Regie stammen, sagt viel.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task<List<Guid>> AehnlicheAsync(
|
||||
FlaroContext db, Guid filmId, int wieViele)
|
||||
{
|
||||
var genres = await db.GenreLinks.Where(g => g.MovieId == filmId)
|
||||
.Select(g => g.GenreId).ToListAsync();
|
||||
var leute = await db.Credits.Where(c => c.MovieId == filmId)
|
||||
.Select(c => c.PersonId).ToListAsync();
|
||||
|
||||
if (genres.Count == 0 && leute.Count == 0) return [];
|
||||
|
||||
var bibliothek = await db.Movies.Where(m => m.Id == filmId)
|
||||
.Select(m => m.LibraryId).FirstOrDefaultAsync();
|
||||
|
||||
var punkte = await db.Movies
|
||||
.Where(m => m.Id != filmId && m.LibraryId == bibliothek)
|
||||
.Select(m => new
|
||||
{
|
||||
m.Id,
|
||||
Punkte =
|
||||
db.GenreLinks.Count(g => g.MovieId == m.Id && genres.Contains(g.GenreId))
|
||||
+ db.Credits.Count(c => c.MovieId == m.Id && leute.Contains(c.PersonId)) * 3,
|
||||
})
|
||||
.Where(x => x.Punkte > 0)
|
||||
.OrderByDescending(x => x.Punkte)
|
||||
.Take(wieViele)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync();
|
||||
|
||||
return punkte;
|
||||
}
|
||||
|
||||
/// <summary>Die Sprache dieses Benutzers — oder <c>null</c> für die des Servers.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Einmal je Anfrage, nicht je Zeile.</b> Ein Abruf für fünfzig Titel
|
||||
/// wäre fünfzig Abfragen für eine Angabe, die sich dabei nicht ändert.
|
||||
/// </remarks>
|
||||
private static async Task<string?> SpracheVon(ClaimsPrincipal wer, FlaroContext db)
|
||||
{
|
||||
if (PlaybackEndpoints.Benutzer(wer) is not { } kennung) return null;
|
||||
return await db.Users.Where(u => u.Id == kennung).Select(u => u.Language).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private static void MapBrowse(IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/movies", async (
|
||||
ClaimsPrincipal wer, FlaroContext db,
|
||||
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
|
||||
{
|
||||
var sprache = await SpracheVon(wer, db);
|
||||
var abfrage = db.Movies.AsNoTracking();
|
||||
if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung);
|
||||
// **Über beide Titel.** Wer „Despicable Me" tippt, soll „Ich einfach
|
||||
// unverbesserlich" finden — und umgekehrt. Nur über den
|
||||
// angezeigten zu suchen hiesse, die halbe Mediathek unauffindbar
|
||||
// zu machen, sobald jemand den anderen Namen im Kopf hat.
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
abfrage = abfrage.Where(m => EF.Functions.Like(m.Title, $"%{search.Trim()}%"));
|
||||
{
|
||||
var muster = $"%{search.Trim()}%";
|
||||
abfrage = abfrage.Where(m =>
|
||||
EF.Functions.Like(m.Title, muster)
|
||||
|| (m.OriginalTitle != null && EF.Functions.Like(m.OriginalTitle, muster)));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(genre))
|
||||
{
|
||||
var gesucht = genre.Trim();
|
||||
@@ -211,8 +556,29 @@ public static class LibraryContentEndpoints
|
||||
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||
.Select(m => new
|
||||
{
|
||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||
m.Id,
|
||||
// **Die Übersetzung sticht, wenn es eine gibt.** Fehlt sie,
|
||||
// steht der Titel in der Sprache des Servers da — nicht
|
||||
// leer. Ein Film ohne englische Übersetzung heisst dann
|
||||
// eben weiter „Das Boot", und das ist richtiger als eine
|
||||
// Lücke.
|
||||
Title = sprache == null ? m.Title
|
||||
: db.Translations
|
||||
.Where(t => t.MovieId == m.Id && t.Language == sprache)
|
||||
.Select(t => t.Title).FirstOrDefault() ?? m.Title,
|
||||
m.OriginalTitle, m.Year,
|
||||
Overview = sprache == null ? m.Overview
|
||||
: db.Translations
|
||||
.Where(t => t.MovieId == m.Id && t.Language == sprache)
|
||||
.Select(t => t.Overview).FirstOrDefault() ?? m.Overview,
|
||||
m.PosterImage, m.BackdropImage,
|
||||
m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating,
|
||||
m.PublishAt,
|
||||
// **Damit die Kachel weiss, wohin sie gehört.** Nach einer
|
||||
// Korrektur stand „WALL·E" weiter unter E, bis man die
|
||||
// Seite neu lud.
|
||||
m.SortTitleValue, m.TitlePinned, m.SortTitlePinned,
|
||||
m.Aufmacherbilder,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
@@ -222,17 +588,26 @@ public static class LibraryContentEndpoints
|
||||
var ids = kennungen.Select(m => m.Id).ToList();
|
||||
var dateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value))
|
||||
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId })
|
||||
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId, f.VersionLabel })
|
||||
.ToListAsync();
|
||||
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
||||
var stände = await ZuständeZu(db, wer, filme: ids);
|
||||
var segmente = await FilmsegmenteZu(db, ids);
|
||||
|
||||
var items = kennungen.Select(m => new MovieResponse(
|
||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||
dateien.Where(f => f.MovieId == m.Id)
|
||||
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(),
|
||||
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel)).ToList(),
|
||||
stände.GetValueOrDefault(m.Id),
|
||||
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).ToList();
|
||||
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
|
||||
OriginalTitle: m.OriginalTitle,
|
||||
PublishAt: m.PublishAt,
|
||||
TitlePinned: m.TitlePinned,
|
||||
SortTitle: m.SortTitleValue,
|
||||
SortTitlePinned: m.SortTitlePinned,
|
||||
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow),
|
||||
Markers: segmente[m.Id].ToList()))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new Page<MovieResponse>(items, gesamt));
|
||||
}).RequireAuthorization();
|
||||
@@ -241,10 +616,16 @@ public static class LibraryContentEndpoints
|
||||
ClaimsPrincipal wer, FlaroContext db,
|
||||
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
|
||||
{
|
||||
var sprache = await SpracheVon(wer, db);
|
||||
var abfrage = db.Series.AsNoTracking();
|
||||
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
abfrage = abfrage.Where(s => EF.Functions.Like(s.Title, $"%{search.Trim()}%"));
|
||||
{
|
||||
var muster = $"%{search.Trim()}%";
|
||||
abfrage = abfrage.Where(x =>
|
||||
EF.Functions.Like(x.Title, muster)
|
||||
|| (x.OriginalTitle != null && EF.Functions.Like(x.OriginalTitle, muster)));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(genre))
|
||||
{
|
||||
var gesucht = genre.Trim();
|
||||
@@ -264,8 +645,25 @@ public static class LibraryContentEndpoints
|
||||
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage,
|
||||
s.Id,
|
||||
Title = sprache == null ? s.Title
|
||||
: db.Translations
|
||||
.Where(t => t.SeriesId == s.Id && t.Language == sprache)
|
||||
.Select(t => t.Title).FirstOrDefault() ?? s.Title,
|
||||
s.OriginalTitle, s.Year,
|
||||
Overview = sprache == null ? s.Overview
|
||||
: db.Translations
|
||||
.Where(t => t.SeriesId == s.Id && t.Language == sprache)
|
||||
.Select(t => t.Overview).FirstOrDefault() ?? s.Overview,
|
||||
s.PosterImage, s.BackdropImage,
|
||||
s.LogoImage, s.RuntimeMinutes, s.Rating, s.ContentRating,
|
||||
// **Eine Serie wartet, solange keine Folge sichtbar ist.**
|
||||
// Der früheste Zeitpunkt sagt, ab wann überhaupt etwas da
|
||||
// ist; steht dort nichts, wartet alles.
|
||||
PublishAt = db.Episodes.Where(f => f.SeriesId == s.Id)
|
||||
.Min(f => f.PublishAt),
|
||||
s.SortTitleValue, s.TitlePinned, s.SortTitlePinned,
|
||||
s.Aufmacherbilder,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
@@ -279,6 +677,7 @@ public static class LibraryContentEndpoints
|
||||
// zusammen. Je Serie zu fragen wäre bei fünfzig Serien
|
||||
// einundfünfzig Abfragen.
|
||||
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
|
||||
var stände = await ZuständeZu(db, wer, serien: ids);
|
||||
|
||||
var items = serien.Select(s =>
|
||||
{
|
||||
@@ -287,7 +686,14 @@ public static class LibraryContentEndpoints
|
||||
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count,
|
||||
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
|
||||
meine.Count(f => !gesehen.Contains(f.Id)),
|
||||
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating);
|
||||
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating,
|
||||
OriginalTitle: s.OriginalTitle,
|
||||
PublishAt: s.PublishAt,
|
||||
State: stände.GetValueOrDefault(s.Id),
|
||||
TitlePinned: s.TitlePinned,
|
||||
SortTitle: s.SortTitleValue,
|
||||
SortTitlePinned: s.SortTitlePinned,
|
||||
HeroImage: Aufmacherwahl.Jetziges(s.Aufmacherbilder, s.Id, DateTimeOffset.UtcNow));
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
||||
@@ -321,6 +727,44 @@ public static class LibraryContentEndpoints
|
||||
// **Einzelabruf, weil ein Client nicht immer aus einer Liste kommt.**
|
||||
// Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus
|
||||
// „Weiterschauen" — in all diesen Fällen hat er nur die Kennung.
|
||||
// Ähnliche Titel als eigener Abruf: die Detailseite soll stehen,
|
||||
// bevor diese Rechnung durch ist.
|
||||
app.MapGet("/api/v1/movies/{id:guid}/similar", async (
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db, int take = 12) =>
|
||||
{
|
||||
var kennungen = await AehnlicheAsync(db, id, Math.Clamp(take, 1, 24));
|
||||
if (kennungen.Count == 0) return Results.Ok(new Page<MovieResponse>([], 0));
|
||||
|
||||
var sprache = await SpracheVon(wer, db);
|
||||
var filme = await db.Movies.AsNoTracking()
|
||||
.Where(m => kennungen.Contains(m.Id))
|
||||
.Select(m => new
|
||||
{
|
||||
m.Id,
|
||||
Title = sprache == null ? m.Title
|
||||
: db.Translations.Where(t => t.MovieId == m.Id && t.Language == sprache)
|
||||
.Select(t => t.Title).FirstOrDefault() ?? m.Title,
|
||||
m.OriginalTitle, m.Year, m.PosterImage, m.BackdropImage, m.LogoImage,
|
||||
m.RuntimeMinutes, m.Rating, m.ContentRating,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var ständeÄhnliche = await ZuständeZu(db, wer, filme: kennungen.ToList());
|
||||
|
||||
// In der Reihenfolge der Punkte, nicht in der der Datenbank.
|
||||
var items = kennungen
|
||||
.Select(k => filme.FirstOrDefault(f => f.Id == k))
|
||||
.Where(f => f != null)
|
||||
.Select(f => new MovieResponse(
|
||||
f!.Id, f.Title, f.Year, null, f.PosterImage, f.BackdropImage,
|
||||
[], ständeÄhnliche.GetValueOrDefault(f.Id),
|
||||
f.LogoImage, f.RuntimeMinutes, f.Rating, null, f.ContentRating,
|
||||
OriginalTitle: f.OriginalTitle))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new Page<MovieResponse>(items, items.Count));
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/movies/{id:guid}", async (
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||
{
|
||||
@@ -331,18 +775,34 @@ public static class LibraryContentEndpoints
|
||||
|
||||
var dateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(f => f.MovieId == id)
|
||||
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration })
|
||||
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.VersionLabel })
|
||||
.ToListAsync();
|
||||
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
||||
var stände = await ZuständeZu(db, wer, filme: [id]);
|
||||
var wurzel = await MediathekspfadFuer(db, wer, film.LibraryId);
|
||||
|
||||
var sprache = await SpracheVon(wer, db);
|
||||
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.MovieId == id && t.Language == sprache);
|
||||
|
||||
return Results.Ok(new MovieResponse(
|
||||
film.Id, film.Title, film.Year, film.Overview, film.PosterImage, film.BackdropImage,
|
||||
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(),
|
||||
film.Id, uebersetzt?.Title ?? film.Title, film.Year,
|
||||
uebersetzt?.Overview ?? film.Overview, film.PosterImage, film.BackdropImage,
|
||||
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel, wurzel)).ToList(),
|
||||
stände.GetValueOrDefault(id),
|
||||
film.LogoImage, film.RuntimeMinutes, film.Rating, film.Tagline, film.ContentRating,
|
||||
film.LogoImage, film.RuntimeMinutes, film.Rating,
|
||||
uebersetzt?.Tagline ?? film.Tagline, film.ContentRating,
|
||||
await GenresZu(db, film: id, serie: null),
|
||||
await MitwirkendeZu(db, film: id, serie: null)));
|
||||
await MitwirkendeZu(db, film: id, serie: null),
|
||||
OriginalTitle: film.OriginalTitle,
|
||||
TitlePinned: film.TitlePinned,
|
||||
SortTitle: film.SortTitleValue,
|
||||
SortTitlePinned: film.SortTitlePinned,
|
||||
PublishAt: film.PublishAt,
|
||||
ExternalRatings: await FremdwertungenZu(db, film: id, serie: null),
|
||||
LibraryId: film.LibraryId,
|
||||
HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow),
|
||||
Markers: (await FilmsegmenteZu(db, [id]))[id].ToList()));
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/series/{id:guid}", async (
|
||||
@@ -355,54 +815,242 @@ public static class LibraryContentEndpoints
|
||||
|
||||
var folgen = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.SeriesId == id)
|
||||
.Select(f => new { f.Id, f.SeasonNumber })
|
||||
.Select(f => new { f.Id, f.SeasonNumber, f.PublishAt })
|
||||
.ToListAsync();
|
||||
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
|
||||
|
||||
var sprache = await SpracheVon(wer, db);
|
||||
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.SeriesId == id && t.Language == sprache);
|
||||
|
||||
return Results.Ok(new SeriesResponse(
|
||||
serie.Id, serie.Title, serie.Year, serie.Overview, serie.PosterImage, serie.BackdropImage,
|
||||
serie.Id, uebersetzt?.Title ?? serie.Title, serie.Year,
|
||||
uebersetzt?.Overview ?? serie.Overview, serie.PosterImage, serie.BackdropImage,
|
||||
folgen.Count, folgen.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
|
||||
folgen.Count(f => !gesehen.Contains(f.Id)),
|
||||
serie.LogoImage, serie.RuntimeMinutes, serie.Rating, serie.Tagline, serie.ContentRating,
|
||||
serie.LogoImage, serie.RuntimeMinutes, serie.Rating,
|
||||
uebersetzt?.Tagline ?? serie.Tagline, serie.ContentRating,
|
||||
await GenresZu(db, film: null, serie: id),
|
||||
await MitwirkendeZu(db, film: null, serie: id)));
|
||||
await MitwirkendeZu(db, film: null, serie: id),
|
||||
OriginalTitle: serie.OriginalTitle,
|
||||
State: (await ZuständeZu(db, wer, serien: [id])).GetValueOrDefault(id),
|
||||
TitlePinned: serie.TitlePinned,
|
||||
SortTitle: serie.SortTitleValue,
|
||||
SortTitlePinned: serie.SortTitlePinned,
|
||||
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();
|
||||
|
||||
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
|
||||
// Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann
|
||||
// der Scan sie fand, nicht wann sie läuft.
|
||||
app.MapGet("/api/v1/episodes/{id:guid}/next", async (
|
||||
// **Eine Folge für sich.**
|
||||
//
|
||||
// Es gab sie nur im Rudel — als Liste einer Serie. Die Apps reichen
|
||||
// aber eine blanke Kennung durch: sie haben sie aus „Weiterschauen"
|
||||
// und wissen nicht, ob ein Film, eine Serie oder eine Folge
|
||||
// dahintersteckt. Sie fragten der Reihe nach Film und Serie, fanden
|
||||
// beides nicht und meldeten „Dieser Titel ist auf dem Server nicht
|
||||
// mehr vorhanden" — über eine Folge, die sie selbst gerade zeigten.
|
||||
app.MapGet("/api/v1/episodes/{id:guid}", async (
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||
{
|
||||
var diese = await db.Episodes.AsNoTracking()
|
||||
var folge = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.Id == id)
|
||||
.Select(f => new { f.SeriesId, f.SeasonNumber, f.EpisodeNumber })
|
||||
.Select(f => new
|
||||
{
|
||||
f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber,
|
||||
f.Title, f.Overview, f.StillImage, f.PublishAt,
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
if (diese is null) return Results.NotFound();
|
||||
|
||||
var naechste = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.SeriesId == diese.SeriesId)
|
||||
.Where(f => f.SeasonNumber > diese.SeasonNumber
|
||||
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
|
||||
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
||||
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
// Keine weitere Folge ist kein Fehler — es war die letzte.
|
||||
if (naechste is null) return Results.NoContent();
|
||||
if (folge is null) return Results.NotFound();
|
||||
|
||||
var dateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(d => d.EpisodeId == naechste.Id)
|
||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration })
|
||||
.Where(d => d.EpisodeId == 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: [naechste.Id]);
|
||||
var stände = await ZuständeZu(db, wer, folgen: [id]);
|
||||
var segmente = await SegmenteZu(db, [id]);
|
||||
|
||||
var wurzel = await MediathekspfadFuer(db, wer,
|
||||
await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId)
|
||||
.Select(x => x.LibraryId).FirstAsync());
|
||||
|
||||
return Results.Ok(new EpisodeResponse(
|
||||
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
|
||||
naechste.Title, naechste.Overview, naechste.StillImage,
|
||||
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
|
||||
stände.GetValueOrDefault(naechste.Id)));
|
||||
folge.Id, folge.SeasonNumber, folge.EpisodeNumber,
|
||||
folge.Title, folge.Overview, folge.StillImage,
|
||||
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||
stände.GetValueOrDefault(id),
|
||||
folge.PublishAt,
|
||||
SeriesId: folge.SeriesId,
|
||||
Markers: segmente[id].ToList()));
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Die Nachbarfolge — vorwärts wie rückwärts.** Reihenfolge ist
|
||||
// Staffel, dann Folge; nicht die Kennung: die ist zeitlich geordnet
|
||||
// und sagt, wann der Scan sie fand, nicht wann sie läuft.
|
||||
//
|
||||
// **Beide Richtungen, seit es im Spieler Knöpfe dafür gibt.** Wer
|
||||
// eine Folge zu weit gesprungen ist, soll zurück können, ohne über
|
||||
// die Serienseite zu gehen.
|
||||
// **Vorspann und Abspann suchen — auf Zuruf.**
|
||||
//
|
||||
// Der Lauf dauert Minuten: er liest von jeder Folge einer Staffel
|
||||
// ein Stück Ton und vergleicht die Abdrücke. Deshalb 202 und ein
|
||||
// Hintergrundlauf statt einer Antwort, auf die jemand wartet.
|
||||
// Dasselbe für einen Film — dort sucht der Server im Bild.
|
||||
app.MapPost("/api/v1/movies/{id:guid}/marken", async (
|
||||
Guid id, FlaroContext db, Segmenterkennung erkennung,
|
||||
Segmentumfang umfang = Segmentumfang.Alles) =>
|
||||
{
|
||||
if (!await db.Movies.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
|
||||
|
||||
_ = erkennung.FilmAsync(id, umfang);
|
||||
return Results.Accepted();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Die Messkurve eines Films — zum Nachsehen, nicht zum Erkennen.**
|
||||
//
|
||||
// Die Erkennung sagt „kein Abspann" und nennt die Gründe; was sie
|
||||
// nicht sagen kann, ist, wie die Kurve aussieht. Genau die braucht
|
||||
// es, bevor eine neue Schwelle gesetzt wird — zum Beispiel für die
|
||||
// Frage, ob bei Outtakes im Abspann wenigstens die Ränder dunkel
|
||||
// bleiben.
|
||||
app.MapGet("/api/v1/movies/{id:guid}/markenkurve", async (
|
||||
Guid id, FlaroContext db, Frameabtaster abtaster, CancellationToken ct) =>
|
||||
{
|
||||
var film = await db.Movies.AsNoTracking()
|
||||
.Where(m => m.Id == id)
|
||||
.Join(db.MediaFiles.AsNoTracking(), m => m.Id, d => d.MovieId,
|
||||
(m, d) => new { m.Title, d.Path, d.Duration })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (film?.Duration is not { TotalSeconds: > 0 } laufzeit)
|
||||
return Einwand.Bitte("Zu diesem Film gibt es keine Datei mit Laufzeit.");
|
||||
|
||||
var ab = Math.Max(0, laufzeit.TotalSeconds - Segmenterkennung.Suchbereich);
|
||||
var kurve = await abtaster.KurveAsync(film.Path, ab, laufzeit.TotalSeconds - ab, ct);
|
||||
|
||||
var text = new StringBuilder("sekunde;gesamt;links;rechts\n");
|
||||
foreach (var punkt in kurve)
|
||||
{
|
||||
text.Append(CultureInfo.InvariantCulture,
|
||||
$"{punkt.Sekunde:0.###};{punkt.Gesamt:0.#};{punkt.Links:0.#};{punkt.Rechts:0.#}\n");
|
||||
}
|
||||
|
||||
var name = string.Concat((film.Title ?? "film")
|
||||
.Select(c => char.IsLetterOrDigit(c) ? c : '-'));
|
||||
|
||||
return Results.File(Encoding.UTF8.GetBytes(text.ToString()),
|
||||
"text/csv", $"markenkurve-{name}.csv");
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Ein Segment wieder wegnehmen.**
|
||||
//
|
||||
// Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal
|
||||
// recht hat: ein Fehlgriff bliebe stehen, und niemand könnte etwas
|
||||
// dagegen tun ausser die Datenbank anzufassen.
|
||||
app.MapDelete("/api/v1/movies/{id:guid}/marken", async (Guid id, FlaroContext db) =>
|
||||
{
|
||||
var weg = await db.Segmente.Where(m => m.MovieId == id).ExecuteDeleteAsync();
|
||||
// Der Vermerk bleibt: „gesucht" stimmt weiterhin, und ohne ihn
|
||||
// legte der nächste Scan dieselbe falsches Segment wieder an.
|
||||
return weg > 0 ? Results.NoContent() : Results.NotFound();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Bei einem einzelnen Titel ist „alles" die Vorgabe.** Wer den
|
||||
// Knopf bei genau dieser Serie drückt, ist mit genau dieser Serie
|
||||
// unzufrieden; ihn dann nach Lücken suchen zu lassen hiesse, ihm
|
||||
// die Antwort zu verweigern, die er haben will.
|
||||
app.MapPost("/api/v1/series/{id:guid}/marken", async (
|
||||
Guid id, FlaroContext db, Segmenterkennung erkennung,
|
||||
Segmentumfang umfang = Segmentumfang.Alles) =>
|
||||
{
|
||||
if (!await db.Series.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
|
||||
|
||||
return erkennung.Starten(id, umfang)
|
||||
? Results.Accepted()
|
||||
: Einwand.Belegt("Für diese Serie werden gerade Segmente gesucht.");
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Eine ganze Mediathek auf einmal.** Je Serie einen Knopf zu
|
||||
// drücken ist bei fünfundneunzig Serien kein Angebot — und nach
|
||||
// jeder Änderung an der Erkennung will man ohnehin alles noch
|
||||
// einmal. Der Lauf kann Stunden dauern und läuft deshalb im
|
||||
// Hintergrund weiter; die Antwort kommt sofort.
|
||||
app.MapPost("/api/v1/libraries/{id:guid}/marken", async (
|
||||
Guid id, FlaroContext db, Segmenterkennung erkennung,
|
||||
Segmentumfang umfang = Segmentumfang.Neue) =>
|
||||
{
|
||||
if (!await db.Libraries.AsNoTracking().AnyAsync(x => x.Id == id))
|
||||
return Results.NotFound();
|
||||
|
||||
erkennung.MediathekStarten(id, umfang);
|
||||
return Results.Accepted();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapGet("/api/v1/episodes/{id:guid}/next", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||
Nachbarfolge(id, wer, db, vorwaerts: true)).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/episodes/{id:guid}/previous", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||
Nachbarfolge(id, wer, db, vorwaerts: false)).RequireAuthorization();
|
||||
|
||||
// **Welche Sprachen eine Serie durchgehend hat.**
|
||||
//
|
||||
// Für die Vorwahl auf der Serienseite. Die Folgen einzeln abzufragen
|
||||
// wären bei zwanzig Folgen zwanzig Rundreisen, und am Ende müsste
|
||||
// der Client dieselbe Rechnung anstellen.
|
||||
//
|
||||
// **Die Schnittmenge, nicht die Vereinigung.** Eine Sprache
|
||||
// anzubieten, die es nur in Staffel 1 gibt, hiesse: eine Einstellung
|
||||
// für die Serie, die ab Staffel 2 stillschweigend etwas anderes tut.
|
||||
// Was dabei wegfällt, wird gezählt, damit die Oberfläche es sagen
|
||||
// kann statt es zu verschweigen.
|
||||
app.MapGet("/api/v1/series/{id:guid}/languages", async (Guid id, FlaroContext db) =>
|
||||
{
|
||||
if (!await db.Series.AnyAsync(x => x.Id == id)) return Results.NotFound();
|
||||
|
||||
var folgen = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.SeriesId == id).Select(f => f.Id).ToListAsync();
|
||||
|
||||
var dateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(f => f.EpisodeId != null && folgen.Contains(f.EpisodeId!.Value))
|
||||
.Select(f => new { f.Id, Folge = f.EpisodeId!.Value })
|
||||
.ToListAsync();
|
||||
|
||||
// **Nur Folgen, die überhaupt eine Datei haben.** Eine Folge, die
|
||||
// noch nicht da ist, kann keine Sprache beisteuern — sie als
|
||||
// fehlend zu zählen liesse die Schnittmenge leer werden.
|
||||
var mitDatei = dateien.Select(d => d.Folge).Distinct().Count();
|
||||
if (mitDatei == 0) return Results.Ok(new Sprachauswahl([], [], 0));
|
||||
|
||||
var zuFolge = dateien.ToDictionary(d => d.Id, d => d.Folge);
|
||||
var kennungen = dateien.Select(d => d.Id).ToList();
|
||||
|
||||
var spuren = await db.MediaStreams.AsNoTracking()
|
||||
.Where(x => kennungen.Contains(x.MediaFileId))
|
||||
.Where(x => x.Kind == MediaStreamKind.Audio || x.Kind == MediaStreamKind.Subtitle)
|
||||
.Select(x => new { x.MediaFileId, x.Kind, x.Language, x.Codec })
|
||||
.ToListAsync();
|
||||
|
||||
var (ton, tonFehlt) = Sprachschnitt.Von(
|
||||
spuren.Where(x => x.Kind == MediaStreamKind.Audio)
|
||||
.Select(x => (zuFolge[x.MediaFileId], x.Language)),
|
||||
mitDatei);
|
||||
|
||||
var (text, textFehlt) = Sprachschnitt.Von(
|
||||
spuren.Where(x => x.Kind == MediaStreamKind.Subtitle)
|
||||
// Bildbasierte Untertitel lassen sich nicht nach WebVTT
|
||||
// wandeln; sie anzubieten wäre eine Zusage, die niemand
|
||||
// einlösen kann.
|
||||
.Where(x => x.Codec is null || !Untertitelvorrat.IstBild(x.Codec))
|
||||
.Select(x => (zuFolge[x.MediaFileId], x.Language)),
|
||||
mitDatei);
|
||||
|
||||
return Results.Ok(new Sprachauswahl(
|
||||
[.. ton.Select(x => new Sprachangabe(x.Code, x.Name))],
|
||||
[.. text.Select(x => new Sprachangabe(x.Code, x.Name))],
|
||||
tonFehlt + textFehlt));
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
|
||||
@@ -415,22 +1063,35 @@ public static class LibraryContentEndpoints
|
||||
|
||||
var folgen = await abfrage
|
||||
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
||||
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
|
||||
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
|
||||
.ToListAsync();
|
||||
|
||||
var wurzel = await MediathekspfadFuer(db, wer,
|
||||
await db.Series.AsNoTracking().Where(x => x.Id == id)
|
||||
.Select(x => x.LibraryId).FirstAsync());
|
||||
|
||||
var ids = folgen.Select(f => f.Id).ToList();
|
||||
var dateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value))
|
||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId })
|
||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId, d.VersionLabel })
|
||||
.ToListAsync();
|
||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||
var stände = await ZuständeZu(db, wer, folgen: ids);
|
||||
var segmente = await SegmenteZu(db, ids);
|
||||
|
||||
var serienBild = await db.Series.AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => x.PosterImage).FirstOrDefaultAsync();
|
||||
|
||||
var items = folgen.Select(f => new EpisodeResponse(
|
||||
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage,
|
||||
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview,
|
||||
f.StillImage ?? serienBild,
|
||||
dateien.Where(d => d.EpisodeId == f.Id)
|
||||
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
|
||||
stände.GetValueOrDefault(f.Id))).ToList();
|
||||
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||
stände.GetValueOrDefault(f.Id),
|
||||
f.PublishAt,
|
||||
SeriesId: id,
|
||||
Markers: segmente[f.Id].ToList())).ToList();
|
||||
|
||||
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
||||
}).RequireAuthorization();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using Flaro.Api.Auth;
|
||||
using Flaro.Core.Libraries;
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Core.Users;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -8,6 +10,9 @@ namespace Flaro.Api.Endpoints;
|
||||
|
||||
public static class ManagementEndpoints
|
||||
{
|
||||
private static Guid? Kennung(ClaimsPrincipal wer) =>
|
||||
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
||||
|
||||
public static void MapManagement(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator);
|
||||
@@ -15,20 +20,87 @@ public static class ManagementEndpoints
|
||||
benutzer.MapGet("/", async (FlaroContext db) =>
|
||||
Results.Ok(await db.Users
|
||||
.OrderBy(u => u.UserName)
|
||||
.Select(u => new AuthenticatedUser(u.Id, u.UserName, u.IsAdministrator))
|
||||
.Select(u => new AuthenticatedUser(
|
||||
u.Id, u.UserName, u.IsAdministrator, u.Language, u.AvatarImage, u.IsOwner))
|
||||
.ToListAsync()));
|
||||
|
||||
/* ---------- Rechte, Besitz, Löschen ---------- */
|
||||
|
||||
// **Warum es einen Besitzer gibt.** Sobald Administratoren einander
|
||||
// das Recht entziehen können, gibt es einen Zustand ohne jeden
|
||||
// Administrator — und aus dem führt kein Weg zurück ausser dem an der
|
||||
// Datenbank. Ein Konto, das ausgenommen ist, schliesst ihn aus.
|
||||
benutzer.MapPut("/{id:guid}/administrator", async (
|
||||
Guid id, RechtRequest anfrage, ClaimsPrincipal wer,
|
||||
FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||
if (ziel is null) return Einwand.Fehlt("Dieses Konto gibt es nicht mehr.");
|
||||
|
||||
if (ziel.IsOwner && !anfrage.IsAdministrator)
|
||||
return Einwand.Bitte("Dem Besitzer lässt sich das Adminrecht nicht nehmen.");
|
||||
|
||||
// **Nicht an sich selbst.** Wer sich im Eifer das eigene Recht
|
||||
// nimmt, steht vor einer Verwaltung, die er nicht mehr öffnen
|
||||
// kann — und das Zurücknehmen braucht genau die.
|
||||
if (Kennung(wer) == id && !anfrage.IsAdministrator)
|
||||
return Einwand.Bitte("Das eigene Adminrecht kannst du dir nicht selbst nehmen.");
|
||||
|
||||
ziel.IsAdministrator = anfrage.IsAdministrator;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// **Nur der Besitzer gibt den Besitz weiter**, und nur an jemanden,
|
||||
// der schon Administrator ist: sonst wäre der Server einen Klick von
|
||||
// einem Besitzer entfernt, der die Verwaltung gar nicht sieht.
|
||||
benutzer.MapPost("/{id:guid}/owner", async (
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
if (Kennung(wer) is not { } ich) return Results.Unauthorized();
|
||||
|
||||
var bisher = await db.Users.FirstOrDefaultAsync(u => u.Id == ich, ct);
|
||||
if (bisher is not { IsOwner: true })
|
||||
return Einwand.Bitte("Nur der Besitzer kann den Besitz weitergeben.");
|
||||
|
||||
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||
if (ziel is null) return Einwand.Fehlt("Dieses Konto gibt es nicht mehr.");
|
||||
if (!ziel.IsAdministrator)
|
||||
return Einwand.Bitte("Der Besitz geht nur an einen Administrator.");
|
||||
|
||||
bisher.IsOwner = false;
|
||||
ziel.IsOwner = true;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
benutzer.MapDelete("/{id:guid}", async (
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||
if (ziel is null) return Results.NoContent();
|
||||
|
||||
if (ziel.IsOwner) return Einwand.Bitte("Den Besitzer kann man nicht löschen.");
|
||||
if (Kennung(wer) == id)
|
||||
return Einwand.Bitte("Das eigene Konto kannst du hier nicht löschen.");
|
||||
|
||||
// Geräte, Sehstände, Sehprotokoll, Meldungen und offene
|
||||
// Einladungen hängen mit `Cascade` daran — siehe `FlaroContext`.
|
||||
// Ein Konto zu löschen und seine Spuren zu lassen hiesse, Zahlen
|
||||
// über jemanden zu führen, den es nicht mehr gibt.
|
||||
db.Users.Remove(ziel);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
benutzer.MapPost("/", async (CreateUserRequest anfrage, FlaroContext db) =>
|
||||
{
|
||||
var name = anfrage.UserName.Trim();
|
||||
if (name.Length == 0 || anfrage.Password.Length < 8)
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
|
||||
});
|
||||
return Einwand.Bitte("Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
|
||||
|
||||
if (await db.Users.AnyAsync(u => u.UserName == name))
|
||||
return Results.Problem($"„{name}“ gibt es schon.", statusCode: StatusCodes.Status409Conflict);
|
||||
return Einwand.Belegt($"„{name}“ gibt es schon.");
|
||||
|
||||
var neu = new User
|
||||
{
|
||||
@@ -51,9 +123,44 @@ public static class ManagementEndpoints
|
||||
// dem sich nach dem Klick nichts findet — da ist der Unterschied
|
||||
// zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens
|
||||
// sichtbar. Zwei Abfragen für alle zusammen, nicht eine je Zeile.
|
||||
bibliotheken.MapGet("/", async (FlaroContext db) =>
|
||||
bibliotheken.MapGet("/", async (FlaroContext db, TimeProvider uhr) =>
|
||||
{
|
||||
var liste = await db.Libraries.AsNoTracking().OrderBy(l => l.Name).ToListAsync();
|
||||
// **Ohne Rücksicht auf Gross- und Kleinschreibung.**
|
||||
//
|
||||
// SQLite vergleicht Text sonst zeichengenau, und dort stehen alle
|
||||
// Grossbuchstaben vor allen kleinen: „Serien" landete über „film",
|
||||
// obwohl f vor s kommt. Wer eine Mediathek umbenennt, sieht sie
|
||||
// dann scheinbar grundlos springen — es sah aus, als stünde die
|
||||
// zuletzt bearbeitete oben.
|
||||
// **Erst die eigene Reihenfolge, dann der Name.** Wer nie
|
||||
// umsortiert hat, bekommt das Alphabet; wer es getan hat, seine
|
||||
// Reihenfolge. Ohne den zweiten Schlüssel stünden alle
|
||||
// Nie-Sortierten in zufälliger Folge.
|
||||
var liste = await db.Libraries.AsNoTracking()
|
||||
.OrderBy(l => l.SortOrder)
|
||||
.ThenBy(l => EF.Functions.Collate(l.Name, "NOCASE"))
|
||||
.ToListAsync();
|
||||
var jetzt = uhr.GetUtcNow();
|
||||
|
||||
// **Was noch wartet, je Mediathek.** Wer scannt, sieht das Ergebnis
|
||||
// an der Mediathek — dass zwanzig Titel irgendwo weiter unten
|
||||
// warten, erführe er sonst nur durch Weiterscrollen.
|
||||
//
|
||||
// Für einen gewöhnlichen Benutzer bleibt die Zahl null: der
|
||||
// Freigabefilter lässt ihn Unveröffentlichtes gar nicht erst
|
||||
// zählen, und das ist richtig so.
|
||||
var wartendeFilme = await db.Movies.AsNoTracking()
|
||||
.Where(m => m.PublishAt == null || m.PublishAt > jetzt)
|
||||
.GroupBy(m => m.LibraryId)
|
||||
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
|
||||
|
||||
var wartendeFolgen = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.PublishAt == null || f.PublishAt > jetzt)
|
||||
.GroupBy(f => db.Series.Where(s => s.Id == f.SeriesId)
|
||||
.Select(s => s.LibraryId).First())
|
||||
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
|
||||
|
||||
var filme = await db.Movies.AsNoTracking()
|
||||
.GroupBy(m => m.LibraryId)
|
||||
@@ -65,36 +172,323 @@ public static class ManagementEndpoints
|
||||
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
|
||||
|
||||
return Results.Ok(liste.Select(l => new LibraryResponse(
|
||||
l.Id, l.Name, l.Kind.ToString(), l.Path,
|
||||
l.Kind == LibraryKind.Shows
|
||||
? serien.GetValueOrDefault(l.Id)
|
||||
: filme.GetValueOrDefault(l.Id))).ToList());
|
||||
// **Drei Zahlen, die alle stimmen und Verschiedenes meinen.**
|
||||
//
|
||||
// Plex zählte 451 Filme, Flaro 449, und der Scan lief über 461
|
||||
// Dateien. Keine davon ist falsch — es sind Titel, Dateien und
|
||||
// zweite Fassungen. Nebeneinander beantwortet sich die Frage von
|
||||
// selbst; einzeln bleibt sie offen.
|
||||
var folgen = await db.Episodes.AsNoTracking()
|
||||
.GroupBy(f => db.Series.Where(x => x.Id == f.SeriesId)
|
||||
.Select(x => x.LibraryId).First())
|
||||
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
|
||||
|
||||
// Vermisste zählen nicht mit: sie liegen nicht mehr auf der Platte,
|
||||
// und der Scan hat sie deshalb auch nicht gesehen.
|
||||
//
|
||||
// **`MitTitel` zählt Titel, nicht Dateien** — und zwar nur solche,
|
||||
// zu denen wirklich noch eine Datei daliegt. Ohne das ginge die
|
||||
// Rechnung schief, sobald etwas vermisst wird: ein erster Versuch
|
||||
// verglich gegen alle Folgen und kam auf 1223 Titel bei 324
|
||||
// Dateien.
|
||||
var dateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(f => f.MissingSince == null)
|
||||
.GroupBy(f => f.LibraryId)
|
||||
.Select(g => new
|
||||
{
|
||||
Id = g.Key,
|
||||
Alle = g.Count(),
|
||||
Ohne = g.Count(f => f.MovieId == null && f.EpisodeId == null),
|
||||
MitTitel = g.Where(f => f.MovieId != null).Select(f => f.MovieId).Distinct().Count()
|
||||
+ g.Where(f => f.EpisodeId != null).Select(f => f.EpisodeId).Distinct().Count(),
|
||||
})
|
||||
.ToDictionaryAsync(x => x.Id, x => x);
|
||||
|
||||
return Results.Ok(liste.Select(l =>
|
||||
{
|
||||
var zaehler = dateien.GetValueOrDefault(l.Id);
|
||||
var alleDateien = zaehler?.Alle ?? 0;
|
||||
var ohneTitel = zaehler?.Ohne ?? 0;
|
||||
|
||||
// Jeder Titel, zu dem noch eine Datei daliegt, hat mindestens
|
||||
// eine. Was darüber liegt, sind zweite Fassungen — Plex'
|
||||
// „Optimized for Mobile" etwa.
|
||||
var mitTitel = zaehler?.MitTitel ?? 0;
|
||||
|
||||
return new LibraryResponse(
|
||||
l.Id, l.Name, l.Kind.ToString(), l.Path,
|
||||
l.Kind == LibraryKind.Shows
|
||||
? serien.GetValueOrDefault(l.Id)
|
||||
: filme.GetValueOrDefault(l.Id),
|
||||
l.ReviewNewItems,
|
||||
l.Kind == LibraryKind.Shows
|
||||
? wartendeFolgen.GetValueOrDefault(l.Id)
|
||||
: wartendeFilme.GetValueOrDefault(l.Id),
|
||||
FileCount: alleDateien,
|
||||
EpisodeCount: l.Kind == LibraryKind.Shows ? folgen.GetValueOrDefault(l.Id) : null,
|
||||
UnassignedCount: ohneTitel,
|
||||
ExtraVersionCount: Math.Max(0, alleDateien - ohneTitel - mitTitel));
|
||||
}).ToList());
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Was nicht mehr da ist, aber noch in der Mediathek steht.**
|
||||
//
|
||||
// Der Scan löscht nichts mehr von selbst — eine kurz nicht eingehängte
|
||||
// Platte hätte sonst Titel samt Sehfortschritt gekostet. Stattdessen
|
||||
// steht es hier, und das Löschen ist eine Entscheidung.
|
||||
// **Die Reihenfolge der Seitenleiste.**
|
||||
//
|
||||
// Als ganze Liste und nicht als „eins hoch": zwei Benutzer, die
|
||||
// gleichzeitig schieben, kämen sonst in einen Zustand, den keiner von
|
||||
// beiden wollte. Die Liste beschreibt, wie es danach aussehen soll.
|
||||
bibliotheken.MapPut("/order", async (
|
||||
ReihenfolgeRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
if (anfrage.Ids is null or { Count: 0 })
|
||||
return Einwand.Bitte("Ohne Reihenfolge gibt es nichts zu ordnen.");
|
||||
|
||||
// Ab 1 zählen: die 0 bleibt denen, die nie umsortiert wurden.
|
||||
for (var i = 0; i < anfrage.Ids.Count; i++)
|
||||
{
|
||||
var platz = i + 1;
|
||||
var kennung = anfrage.Ids[i];
|
||||
await db.Libraries.Where(l => l.Id == kennung)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(l => l.SortOrder, platz), ct);
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// Die Prüfung je Mediathek umstellen.
|
||||
bibliotheken.MapPut("/{id:guid}/review", async (
|
||||
Guid id, PruefungRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
var betroffen = await db.Libraries.Where(l => l.Id == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(l => l.ReviewNewItems, anfrage.ReviewNewItems), ct);
|
||||
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Was zu keinem Titel gehört.**
|
||||
//
|
||||
// Der Scan erfindet keine Serie mehr, wenn weder Ordner noch Dateiname
|
||||
// eine nennen — nur verschwanden die Dateien damit lautlos. Bei 3559
|
||||
// von 5884 war das die halbe Mediathek, und in der Übersicht stand
|
||||
// eine Zahl, die niemand erklären konnte. Hier stehen sie, mit dem
|
||||
// Grund dazu.
|
||||
bibliotheken.MapGet("/{id:guid}/unassigned", async (
|
||||
Guid id, FlaroContext db, int take = 300) =>
|
||||
{
|
||||
var bibliothek = await db.Libraries.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id);
|
||||
if (bibliothek is null) return Results.NotFound();
|
||||
|
||||
var offen = db.MediaFiles.AsNoTracking()
|
||||
.Where(f => f.LibraryId == id && f.MovieId == null && f.EpisodeId == null
|
||||
&& f.MissingSince == null);
|
||||
|
||||
var gesamt = await offen.CountAsync();
|
||||
var pfade = await offen.OrderBy(f => f.Path).Take(Math.Clamp(take, 1, 2000)).ToListAsync();
|
||||
|
||||
// Der Grund wird hier gerechnet, nicht gespeichert: er hängt allein
|
||||
// am Pfad und an der Benennungsregel. Eine Spalte dafür wäre beim
|
||||
// nächsten Regelwechsel veraltet, ohne dass es jemand merkt.
|
||||
return Results.Ok(new Unzugeordnet(
|
||||
gesamt,
|
||||
pfade.Select(f => new UnzugeordneteDatei(
|
||||
f.Id, f.Path, FileNames.WarumNicht(f.Path, bibliothek.Path))).ToList()));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Was die Metadatenquelle nicht hergab.**
|
||||
//
|
||||
// `/unassigned` listet Dateien ganz ohne Titel — bei einer
|
||||
// Filmbibliothek ist das immer leer, weil der Scan für jede Datei
|
||||
// einen Eintrag anlegt. Sichtbar war damit nur die Hälfte: dass ein
|
||||
// Film zwar dasteht, aber unter seinem Dateinamen und ohne Plakat,
|
||||
// erfuhr niemand. In der Übersicht standen 448 Filme, in Plex 451 —
|
||||
// und nirgends, welche drei fehlen.
|
||||
bibliotheken.MapGet("/{id:guid}/unmatched", async (
|
||||
Guid id, FlaroContext db, int take = 300) =>
|
||||
{
|
||||
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound();
|
||||
|
||||
var grenze = Math.Clamp(take, 1, 2000);
|
||||
|
||||
var filme = await db.Movies.AsNoTracking()
|
||||
.Where(m => m.LibraryId == id && m.TmdbId == null)
|
||||
.OrderBy(m => m.SortTitleValue)
|
||||
.Take(grenze)
|
||||
.Select(m => new
|
||||
{
|
||||
m.Id, m.Title, Jahr = m.ParsedYear ?? m.Year,
|
||||
Dateien = db.MediaFiles.Count(f => f.MovieId == m.Id),
|
||||
Beispiel = db.MediaFiles.Where(f => f.MovieId == m.Id)
|
||||
.Select(f => f.Path).FirstOrDefault(),
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var serien = await db.Series.AsNoTracking()
|
||||
.Where(x => x.LibraryId == id && x.TmdbId == null)
|
||||
.OrderBy(x => x.SortTitleValue)
|
||||
.Take(grenze)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Title, Jahr = x.ParsedYear ?? x.Year,
|
||||
Dateien = db.Episodes.Count(f => f.SeriesId == x.Id),
|
||||
Beispiel = db.Episodes.Where(f => f.SeriesId == x.Id)
|
||||
.Join(db.MediaFiles, f => f.Id, d => d.EpisodeId, (_, d) => d.Path)
|
||||
.FirstOrDefault(),
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var liste = filme
|
||||
.Select(m => new OhneZuordnung(
|
||||
m.Id, "movie", m.Title, m.Jahr, m.Dateien, Dateiname(m.Beispiel)))
|
||||
.Concat(serien.Select(x => new OhneZuordnung(
|
||||
x.Id, "series", x.Title, x.Jahr, x.Dateien, Dateiname(x.Beispiel))))
|
||||
.OrderBy(x => x.Titel, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(liste);
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Titel mit mehr als einer Datei — dort versteckt sich Falsches.**
|
||||
//
|
||||
// Zwei Dateien an einem Film sind meist zwei Fassungen und völlig in
|
||||
// Ordnung. Manchmal sind es aber zwei verschiedene Filme, die bei der
|
||||
// Metadatenquelle auf denselben Treffer liefen — und dann ist einer
|
||||
// davon lautlos verschwunden. Wer die Liste durchsieht, erkennt das
|
||||
// an den Dateinamen in zwei Sekunden.
|
||||
//
|
||||
// **`ignorierte` zeigt, was abgehakt wurde.** Ohne diesen Schalter
|
||||
// wäre das Abhaken unumkehrbar, und niemand wüsste hinterher, was er
|
||||
// weggeklickt hat — dieselbe Überlegung wie bei den Meldungen.
|
||||
bibliotheken.MapGet("/{id:guid}/versions", async (
|
||||
Guid id, FlaroContext db, int take = 300, bool ignorierte = false) =>
|
||||
{
|
||||
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound();
|
||||
|
||||
var mehrfach = await db.Movies.AsNoTracking()
|
||||
.Where(m => m.LibraryId == id && db.MediaFiles.Count(f => f.MovieId == m.Id) > 1)
|
||||
// Abgehakt ist, wer bei genau dieser Dateizahl abgehakt
|
||||
// wurde. Kommt eine Fassung dazu, stimmt die Zahl nicht mehr
|
||||
// und die Gruppe meldet sich von selbst zurück.
|
||||
.Where(m => ignorierte
|
||||
? m.FassungenGeprueftBei == db.MediaFiles.Count(f => f.MovieId == m.Id)
|
||||
: m.FassungenGeprueftBei != db.MediaFiles.Count(f => f.MovieId == m.Id))
|
||||
.OrderBy(m => m.SortTitleValue)
|
||||
.Take(Math.Clamp(take, 1, 2000))
|
||||
.Select(m => new
|
||||
{
|
||||
m.Id, m.Title, Jahr = m.ParsedYear ?? m.Year,
|
||||
Dateien = db.MediaFiles.Where(f => f.MovieId == m.Id)
|
||||
.Select(f => new { f.Id, f.Path }).ToList(),
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var liste = mehrfach
|
||||
.Select(m => new Mehrfachtitel(
|
||||
m.Id, "movie", m.Title, m.Jahr,
|
||||
m.Dateien.Select(f => new Fassungsdatei(f.Id, Dateiname(f.Path) ?? f.Path)).ToList()))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new Mehrfachbericht(liste.Count, liste));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
/// Abhaken und wieder hervorholen — beides über denselben Weg.
|
||||
bibliotheken.MapPost("/{id:guid}/versions/acknowledge", async (
|
||||
Guid id, FassungenRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id, ct)) return Results.NotFound();
|
||||
if (anfrage.Ids is not { Count: > 0 }) return Einwand.Bitte("Es war kein Titel ausgewählt.");
|
||||
|
||||
var gewaehlt = anfrage.Ids.ToHashSet();
|
||||
|
||||
// **Die Zahl wird hier gerechnet, nicht vom Client geschickt.**
|
||||
// Ein Client, der eine veraltete Liste anzeigt, hakte sonst eine
|
||||
// Gruppe mit zwei Dateien ab, die inzwischen drei hat — und die
|
||||
// dritte verschwände ungesehen.
|
||||
var betroffen = await db.Movies
|
||||
.Where(m => m.LibraryId == id && gewaehlt.Contains(m.Id))
|
||||
.Select(m => new { Film = m, Anzahl = db.MediaFiles.Count(f => f.MovieId == m.Id) })
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var z in betroffen)
|
||||
{
|
||||
z.Film.FassungenGeprueftBei = anfrage.Ignorieren ? z.Anzahl : null;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { geaendert = betroffen.Count });
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
bibliotheken.MapGet("/missing", async (FlaroContext db) =>
|
||||
{
|
||||
var liste = await db.MediaFiles.AsNoTracking()
|
||||
.Where(f => f.MissingSince != null)
|
||||
.OrderBy(f => f.Path)
|
||||
.Select(f => new VermissteDatei(
|
||||
f.Id, f.Path, f.VersionLabel, f.MissingSince!.Value,
|
||||
db.Libraries.Where(l => l.Id == f.LibraryId).Select(l => l.Name).First(),
|
||||
f.MovieId != null
|
||||
? db.Movies.Where(m => m.Id == f.MovieId).Select(m => m.Title).FirstOrDefault()
|
||||
: db.Episodes.Where(e => e.Id == f.EpisodeId)
|
||||
.Select(e => e.Title ?? ("Folge " + e.EpisodeNumber)).FirstOrDefault(),
|
||||
f.MovieId, f.EpisodeId))
|
||||
.ToListAsync();
|
||||
|
||||
return Results.Ok(liste);
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// Endgültig weg — mitsamt dem Titel, wenn es seine letzte Datei war.
|
||||
bibliotheken.MapDelete("/missing", async (Guid? fileId, FlaroContext db) =>
|
||||
{
|
||||
var betroffen = db.MediaFiles.Where(f => f.MissingSince != null);
|
||||
if (fileId is { } eine) betroffen = betroffen.Where(f => f.Id == eine);
|
||||
|
||||
// Die Titel merken, bevor die Dateien weg sind — danach lässt sich
|
||||
// nicht mehr feststellen, welche verwaist zurückbleiben.
|
||||
var filme = await betroffen.Where(f => f.MovieId != null)
|
||||
.Select(f => f.MovieId!.Value).Distinct().ToListAsync();
|
||||
var folgen = await betroffen.Where(f => f.EpisodeId != null)
|
||||
.Select(f => f.EpisodeId!.Value).Distinct().ToListAsync();
|
||||
|
||||
var weg = await betroffen.ExecuteDeleteAsync();
|
||||
|
||||
await db.Movies
|
||||
.Where(m => filme.Contains(m.Id) && !db.MediaFiles.Any(f => f.MovieId == m.Id))
|
||||
.ExecuteDeleteAsync();
|
||||
await db.Episodes
|
||||
.Where(e => folgen.Contains(e.Id) && !db.MediaFiles.Any(f => f.EpisodeId == e.Id))
|
||||
.ExecuteDeleteAsync();
|
||||
await db.Series
|
||||
.Where(x => !db.Episodes.Any(e => e.SeriesId == x.Id))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
return Results.Ok(new { removed = weg });
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
|
||||
{
|
||||
if (!Enum.TryParse<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["kind"] = [$"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows."],
|
||||
});
|
||||
return Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows, Videos.");
|
||||
|
||||
// **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad
|
||||
// führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und
|
||||
// die Ursache stünde allenfalls im Protokoll.
|
||||
if (!Directory.Exists(anfrage.Path))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["path"] = [$"„{anfrage.Path}“ gibt es im Container nicht. " +
|
||||
"Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt."],
|
||||
});
|
||||
return Einwand.Bitte($"„{anfrage.Path}“ gibt es im Container nicht. " +
|
||||
"Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt.");
|
||||
|
||||
var neu = new Library { Name = anfrage.Name.Trim(), Kind = art, Path = anfrage.Path };
|
||||
var neu = new Library
|
||||
{
|
||||
Name = anfrage.Name.Trim(), Kind = art, Path = anfrage.Path,
|
||||
ReviewNewItems = anfrage.ReviewNewItems,
|
||||
};
|
||||
db.Libraries.Add(neu);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Created($"/api/v1/libraries/{neu.Id}",
|
||||
new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path, 0));
|
||||
new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path, 0,
|
||||
neu.ReviewNewItems));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in
|
||||
@@ -107,10 +501,7 @@ public static class ManagementEndpoints
|
||||
{
|
||||
var name = anfrage.Name.Trim();
|
||||
if (name.Length == 0)
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["name"] = ["Ein Name darf nicht leer sein."],
|
||||
});
|
||||
return Einwand.Bitte("Ein Name darf nicht leer sein.");
|
||||
|
||||
var vorhanden = await db.Libraries.FindAsync(id);
|
||||
if (vorhanden is null) return Results.NotFound();
|
||||
@@ -125,16 +516,74 @@ public static class ManagementEndpoints
|
||||
// sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim
|
||||
// Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server,
|
||||
// den man nicht noch einmal benutzt.
|
||||
bibliotheken.MapDelete("/{id:guid}", async (Guid id, FlaroContext db) =>
|
||||
bibliotheken.MapDelete("/{id:guid}", async (
|
||||
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
|
||||
{
|
||||
var vorhanden = await db.Libraries.FindAsync(id);
|
||||
if (vorhanden is null) return Results.NotFound();
|
||||
|
||||
// Aus demselben Grund wie beim Zurücksetzen — nur dass hier die
|
||||
// ganze Mediathek unter dem laufenden Scan verschwände.
|
||||
if (koordinator.IsRunning(id))
|
||||
{
|
||||
return Results.Problem(
|
||||
"Für diese Mediathek läuft gerade ein Scan. "
|
||||
+ "Entfernen geht erst, wenn er durch ist.",
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
db.Libraries.Remove(vorhanden);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.NoContent();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Den Inhalt wegwerfen, die Mediathek behalten.**
|
||||
//
|
||||
// Wenn eine Zuordnungsregel falsch war, steht hinterher ein Bestand da,
|
||||
// den kein Scan mehr geradezieht: die falschen Einträge haben ihre
|
||||
// Dateien, gelten also nicht als verwaist, und bleiben. Die Mediathek
|
||||
// zu löschen und neu anzulegen tut dasselbe — nur verliert man dabei
|
||||
// Namen, Pfad und Einstellungen und tippt sie neu.
|
||||
//
|
||||
// Nicht gelöscht wird eine einzige Mediendatei. Entfernt wird allein,
|
||||
// was Flaro über sie weiss.
|
||||
bibliotheken.MapPost("/{id:guid}/reset", async (
|
||||
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
|
||||
{
|
||||
var vorhanden = await db.Libraries.FindAsync(id);
|
||||
if (vorhanden is null) return Results.NotFound();
|
||||
|
||||
// **Nicht während eines Laufs.**
|
||||
//
|
||||
// Die Oberfläche sperrt den Knopf, aber darauf darf sich der
|
||||
// Server nicht verlassen: ein zweiter Administrator oder ein
|
||||
// Aufruf von Hand genügt. Wer Zeilen löscht, während der Scan
|
||||
// welche schreibt, bekommt einen Lauf, der mitten im Schreiben
|
||||
// abbricht — und einen Bestand, den niemand mehr erklären kann.
|
||||
if (koordinator.IsRunning(id))
|
||||
{
|
||||
return Results.Problem(
|
||||
"Für diese Mediathek läuft gerade ein Scan. "
|
||||
+ "Zurücksetzen geht erst, wenn er durch ist.",
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
// In dieser Reihenfolge: erst die Dateien, dann die Titel. Die
|
||||
// Fremdschlüssel zeigen von den Dateien auf Film und Folge.
|
||||
await db.MediaFiles.Where(f => f.LibraryId == id).ExecuteDeleteAsync();
|
||||
await db.Movies.Where(m => m.LibraryId == id).ExecuteDeleteAsync();
|
||||
await db.Episodes
|
||||
.Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == id))
|
||||
.ExecuteDeleteAsync();
|
||||
await db.Series.Where(s => s.LibraryId == id).ExecuteDeleteAsync();
|
||||
|
||||
return Results.NoContent();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
}
|
||||
|
||||
/// <summary>Nur der Dateiname — der volle Pfad geht niemanden etwas an.</summary>
|
||||
private static string? Dateiname(string? pfad) =>
|
||||
pfad is null ? null : Path.GetFileName(pfad);
|
||||
}
|
||||
|
||||
public static class FlaroPolicies
|
||||
|
||||
@@ -25,6 +25,224 @@ public static class MatchEndpoints
|
||||
{
|
||||
public static void MapMatching(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// **Den Titel von Hand setzen — und festschreiben.**
|
||||
//
|
||||
// TMDB kennt nicht jeden Titel so, wie er im Haushalt heisst, und bei
|
||||
// Mitschnitten gibt es gar keinen. Ohne diesen Weg bleibt nur der
|
||||
// Dateiname oder ein falscher Treffer.
|
||||
//
|
||||
// Festgeschrieben wie Plakat und Hintergrund: sonst überschreibt das
|
||||
// nächste Auffrischen die Korrektur, und man tippt sie nach jedem Lauf
|
||||
// neu.
|
||||
app.MapPut("/api/v1/{art:regex(^movies|series$)}/{id:guid}/title", async (
|
||||
string art, Guid id, TitelRequest anfrage, FlaroContext db) =>
|
||||
{
|
||||
var titel = anfrage.Title?.Trim();
|
||||
if (string.IsNullOrEmpty(titel))
|
||||
{
|
||||
return Einwand.Bitte("Ein Titel darf nicht leer sein.");
|
||||
}
|
||||
if (titel.Length > 512)
|
||||
return Einwand.Bitte("Höchstens 512 Zeichen.");
|
||||
|
||||
// **Der Sortiertitel bleibt, wenn er von Hand gesetzt wurde.**
|
||||
// Sonst wäre „Harry Potter 3" nach jeder Titeländerung wieder weg.
|
||||
var betroffen = art == "movies"
|
||||
? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s
|
||||
.SetProperty(m => m.Title, titel)
|
||||
.SetProperty(m => m.SortTitleValue,
|
||||
m => m.SortTitlePinned ? m.SortTitleValue : SortTitle.For(titel))
|
||||
.SetProperty(m => m.TitlePinned, true))
|
||||
: await db.Series.Where(x => x.Id == id).ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.Title, titel)
|
||||
.SetProperty(x => x.SortTitleValue,
|
||||
x => x.SortTitlePinned ? x.SortTitleValue : SortTitle.For(titel))
|
||||
.SetProperty(x => x.TitlePinned, true));
|
||||
|
||||
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Der Sortiertitel — wonach einsortiert wird, wenn nicht nach dem Titel.**
|
||||
//
|
||||
// Eine Reihe steht alphabetisch durcheinander: „…Orden des Phönix" vor
|
||||
// „…Stein der Weisen", weil O vor S liegt. Mit „Harry Potter 1",
|
||||
// „Harry Potter 2" stehen sie so, wie man sie sieht.
|
||||
app.MapPut("/api/v1/{art:regex(^movies|series$)}/{id:guid}/sorttitle", async (
|
||||
string art, Guid id, TitelRequest anfrage, FlaroContext db) =>
|
||||
{
|
||||
var sortiert = anfrage.Title?.Trim();
|
||||
if (string.IsNullOrEmpty(sortiert))
|
||||
{
|
||||
return Results.Problem(
|
||||
"Ein Sortiertitel darf nicht leer sein. Zum Zurücksetzen gibt es „Wieder automatisch“.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
if (sortiert.Length > 512)
|
||||
return Einwand.Bitte("Höchstens 512 Zeichen.");
|
||||
|
||||
var betroffen = art == "movies"
|
||||
? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s
|
||||
.SetProperty(m => m.SortTitleValue, sortiert)
|
||||
.SetProperty(m => m.SortTitlePinned, true))
|
||||
: await db.Series.Where(x => x.Id == id).ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.SortTitleValue, sortiert)
|
||||
.SetProperty(x => x.SortTitlePinned, true));
|
||||
|
||||
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Zurück zum errechneten Sortiertitel.**
|
||||
//
|
||||
// Anders als beim Titel wird er sofort neu gebildet: er steht nirgends
|
||||
// sichtbar, und ihn bis zum nächsten Auffrischen falsch stehen zu
|
||||
// lassen hiesse, dass die Liste solange in der falschen Reihenfolge
|
||||
// steht.
|
||||
//
|
||||
// **Geladen und zurückgeschrieben, nicht in einem Rutsch.** Die Regel
|
||||
// steht in `SortTitle.For` — einer C#-Methode, die EF Core nicht nach
|
||||
// SQL übersetzen kann. Der Versuch endete in einer 500; dieselbe Regel
|
||||
// ein zweites Mal als SQL zu schreiben wäre die Variante, bei der
|
||||
// beide irgendwann auseinanderlaufen. Es geht um eine Zeile.
|
||||
app.MapDelete("/api/v1/{art:regex(^movies|series$)}/{id:guid}/sorttitle", async (
|
||||
string art, Guid id, FlaroContext db) =>
|
||||
{
|
||||
if (art == "movies")
|
||||
{
|
||||
var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
|
||||
if (film is null) return Results.NotFound();
|
||||
|
||||
film.SortTitleValue = SortTitle.For(film.Title);
|
||||
film.SortTitlePinned = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
var serie = await db.Series.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (serie is null) return Results.NotFound();
|
||||
|
||||
serie.SortTitleValue = SortTitle.For(serie.Title);
|
||||
serie.SortTitlePinned = false;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.NoContent();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Zurück zum automatischen Titel.**
|
||||
//
|
||||
// Ohne diesen Weg gäbe es keinen zurück, ausser den alten Namen zu
|
||||
// erraten. Der Titel selbst bleibt stehen, bis das nächste Auffrischen
|
||||
// ihn holt — sonst stünde dort bis dahin gar nichts.
|
||||
app.MapDelete("/api/v1/{art:regex(^movies|series$)}/{id:guid}/title", async (
|
||||
string art, Guid id, FlaroContext db) =>
|
||||
{
|
||||
var betroffen = art == "movies"
|
||||
? await db.Movies.Where(m => m.Id == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(m => m.TitlePinned, false))
|
||||
: await db.Series.Where(x => x.Id == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.TitlePinned, false));
|
||||
|
||||
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Eine Datei aus ihrem Titel herauslösen.**
|
||||
//
|
||||
// Zwei Dateien hängen an einem Eintrag, gehören aber zu zwei Filmen:
|
||||
// „Hanni und Nanni" und „Hanni und Nanni 3" liefen bei der
|
||||
// Metadatenquelle auf denselben Treffer, und der zweite Film war
|
||||
// damit nicht mehr zu sehen. Die Riegel dagegen greifen erst beim
|
||||
// nächsten Lauf — was schon zusammengelegt ist, bleibt es.
|
||||
//
|
||||
// Der neue Eintrag bekommt den Titel aus dem Dateinamen und keine
|
||||
// Kennung: er steht danach unter „Ohne Zuordnung" und lässt sich dort
|
||||
// in zwei Klicks richtigstellen. Eine Kennung zu raten hiesse, denselben
|
||||
// Fehler noch einmal zu machen.
|
||||
app.MapPost("/api/v1/files/{id:guid}/split", async (
|
||||
Guid id, FlaroContext db, TimeProvider clock) =>
|
||||
{
|
||||
var datei = await db.MediaFiles.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (datei is null) return Results.NotFound();
|
||||
|
||||
if (datei.MovieId is not { } alt)
|
||||
{
|
||||
return Results.Problem(
|
||||
"Nur eine Filmdatei lässt sich herauslösen. Bei Serien entscheidet "
|
||||
+ "die Folgennummer, zu welcher Folge eine Datei gehört.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var geschwister = await db.MediaFiles.CountAsync(f => f.MovieId == alt);
|
||||
if (geschwister < 2)
|
||||
{
|
||||
return Results.Problem(
|
||||
"An diesem Titel hängt nur diese eine Datei — da gibt es nichts zu trennen.",
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
var name = FileNames.ParseMovie(datei.Path);
|
||||
var bisher = await db.Movies.AsNoTracking().FirstAsync(m => m.Id == alt);
|
||||
|
||||
var neu = new Movie
|
||||
{
|
||||
LibraryId = datei.LibraryId,
|
||||
ParsedTitle = name.Title,
|
||||
ParsedYear = name.Year,
|
||||
Title = name.Title,
|
||||
SortTitleValue = SortTitle.For(name.Title),
|
||||
Year = name.Year,
|
||||
// **Sofort sichtbar.** Wer etwas herauslöst, will es sehen —
|
||||
// nicht erst freigeben müssen.
|
||||
PublishAt = bisher.PublishAt ?? clock.GetUtcNow(),
|
||||
};
|
||||
db.Movies.Add(neu);
|
||||
|
||||
datei.MovieId = neu.Id;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new MatchState(null, null, false, neu.Title, neu.Year, Id: neu.Id));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Woher dieser Titel kommt.**
|
||||
//
|
||||
// Steht im Dialog über der Suche. Wenn eine Zuordnung völlig daneben
|
||||
// liegt — „Dark Shadows" von 1966 statt „Dark" —, sagt der angezeigte
|
||||
// Titel nichts mehr darüber, welche Datei da eigentlich gemeint ist.
|
||||
// Der Ordner sagt es, und zwar sofort.
|
||||
app.MapGet("/api/v1/{art:regex(^movies|series$)}/{id:guid}/herkunft", async (
|
||||
string art, Guid id, FlaroContext db) =>
|
||||
{
|
||||
var dateien = art == "movies"
|
||||
? db.MediaFiles.AsNoTracking().Where(f => f.MovieId == id)
|
||||
: db.MediaFiles.AsNoTracking().Where(f => f.EpisodeId != null
|
||||
&& db.Episodes.Any(e => e.Id == f.EpisodeId && e.SeriesId == id));
|
||||
|
||||
// **Gezählt wird vollständig, gezeigt nur eine Handvoll.** Hier
|
||||
// stand erst ein `Take(50)` vor dem Zählen — bei 1200 Folgen
|
||||
// behauptete der Dialog dann „50 Dateien". Eine Zahl, die bei
|
||||
// genau dem Titel falsch wird, bei dem man hinsieht.
|
||||
//
|
||||
// **Ein Zug, dann im Speicher.** Der Ordner aus einem Pfad zu
|
||||
// schneiden lässt sich nicht nach SQL übersetzen — der Versuch
|
||||
// endete in einem 500er. Die Pfade eines einzelnen Titels sind
|
||||
// auch bei 1200 Folgen ein paar hundert Kilobyte; das ist
|
||||
// billiger als eine Abfrage, die nicht läuft.
|
||||
var pfade = await dateien.Select(f => f.Path).ToListAsync();
|
||||
if (pfade.Count == 0) return Results.Ok(new Herkunft(null, 0, []));
|
||||
|
||||
var ordner = pfade
|
||||
.Select(p => Path.GetDirectoryName(p) ?? "")
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new Herkunft(
|
||||
ordner.Count == 1 ? ordner[0] : GemeinsamerAnfang(ordner),
|
||||
pfade.Count,
|
||||
pfade.Order(StringComparer.Ordinal)
|
||||
.Take(6)
|
||||
.Select(Path.GetFileName)
|
||||
.Where(n => n is not null)
|
||||
.ToList()!));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapGet("/api/v1/movies/{id:guid}/matches", async (
|
||||
Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) =>
|
||||
{
|
||||
@@ -34,7 +252,22 @@ public static class MatchEndpoints
|
||||
// Ohne eigene Suchworte das, was aus dem Dateinamen gelesen wurde
|
||||
// — nicht der angezeigte Titel, der ja gerade der falsche ist.
|
||||
var vorschläge = await anbieter.SearchMoviesAsync(
|
||||
string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(), year ?? film.Year);
|
||||
string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(),
|
||||
// **Kein Jahr heisst: ohne Jahr suchen.**
|
||||
//
|
||||
// Die Maske trägt das Jahr aus dem Dateinamen schon ein; wer
|
||||
// es herausnimmt, sagt damit, dass er es nicht weiss. Hier
|
||||
// stand ein Rückfall auf eben dieses Jahr — das leere Feld
|
||||
// hatte also keine Wirkung, und ein falsch benannter Film
|
||||
// liess sich nicht finden. „Plan B (2010)" heisst bei TMDB
|
||||
// „DivertiMento – Plan B" und ist von 2011; mit dem Jahr aus
|
||||
// dem Dateinamen kam nie ein Treffer.
|
||||
//
|
||||
// Davor stand hier `film.Year`, also das Jahr aus der
|
||||
// Zuordnung, die gerade korrigiert werden soll — ein falscher
|
||||
// Treffer verhinderte so seine eigene Korrektur. Auch das ist
|
||||
// damit erledigt.
|
||||
year);
|
||||
|
||||
return Results.Ok(vorschläge.Select(v =>
|
||||
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
||||
@@ -42,18 +275,46 @@ public static class MatchEndpoints
|
||||
|
||||
app.MapPut("/api/v1/movies/{id:guid}/match", async (
|
||||
Guid id, ApplyMatchRequest anfrage, FlaroContext db,
|
||||
IMetadataProvider anbieter, MatchApplier anwender) =>
|
||||
IMetadataProvider anbieter, MatchApplier anwender, Zusammenlegung zusammen) =>
|
||||
{
|
||||
var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
|
||||
if (film is null) return Results.NotFound();
|
||||
|
||||
var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId);
|
||||
if (gefunden is null)
|
||||
return Results.Problem($"TMDB kennt den Film {anfrage.TmdbId} nicht.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
return Einwand.Fehlt($"TMDB kennt den Film {anfrage.TmdbId} nicht.");
|
||||
|
||||
// **Hat die Kennung schon jemand, ist es derselbe Film.**
|
||||
//
|
||||
// Ein Film kommt je Mediathek genau einmal vor — das erzwingt ein
|
||||
// Eindeutigkeitsindex. Die Kennung blind zu setzen liess ihn
|
||||
// zuschlagen, und der Benutzer bekam „An error occurred while
|
||||
// processing your request." zu sehen: eine englische
|
||||
// Allgemeinmeldung für einen Fall, der gar kein Fehler ist.
|
||||
//
|
||||
// Zwei Dateien, die denselben Film meinen, gehören zusammengelegt
|
||||
// — genau das tut der Scan am Ende jedes Laufs auch. Der
|
||||
// Sehfortschritt wird dabei gefaltet, nichts geht verloren.
|
||||
var schonDa = await db.Movies
|
||||
.Where(m => m.LibraryId == film.LibraryId && m.TmdbId == anfrage.TmdbId && m.Id != id)
|
||||
.Select(m => m.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (schonDa != Guid.Empty)
|
||||
{
|
||||
await anwender.ApplyAsync(
|
||||
(await db.Movies.FirstAsync(m => m.Id == schonDa)), gefunden);
|
||||
await zusammen.FilmVerschmelzenAsync(sieger: schonDa, verlierer: id);
|
||||
|
||||
var sieger = await db.Movies.AsNoTracking().FirstAsync(m => m.Id == schonDa);
|
||||
return Results.Ok(new MatchState(
|
||||
sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year,
|
||||
Id: sieger.Id, Merged: true));
|
||||
}
|
||||
|
||||
await anwender.ApplyAsync(film, gefunden);
|
||||
return Results.Ok(new MatchState(film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year));
|
||||
return Results.Ok(new MatchState(
|
||||
film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year, Id: film.Id));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapGet("/api/v1/series/{id:guid}/matches", async (
|
||||
@@ -63,7 +324,9 @@ public static class MatchEndpoints
|
||||
if (serie is null) return Results.NotFound();
|
||||
|
||||
var vorschläge = await anbieter.SearchSeriesAsync(
|
||||
string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(), year ?? serie.Year);
|
||||
string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(),
|
||||
// Siehe oben: ein leeres Jahr ist eine Angabe, kein Fehlen.
|
||||
year);
|
||||
|
||||
return Results.Ok(vorschläge.Select(v =>
|
||||
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
||||
@@ -71,18 +334,68 @@ public static class MatchEndpoints
|
||||
|
||||
app.MapPut("/api/v1/series/{id:guid}/match", async (
|
||||
Guid id, ApplyMatchRequest anfrage, FlaroContext db,
|
||||
IMetadataProvider anbieter, MatchApplier anwender) =>
|
||||
IMetadataProvider anbieter, MatchApplier anwender, Zusammenlegung zusammen) =>
|
||||
{
|
||||
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id);
|
||||
if (serie is null) return Results.NotFound();
|
||||
|
||||
var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId);
|
||||
if (gefunden is null)
|
||||
return Results.Problem($"TMDB kennt die Serie {anfrage.TmdbId} nicht.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
return Einwand.Fehlt($"TMDB kennt die Serie {anfrage.TmdbId} nicht.");
|
||||
|
||||
// Siehe bei den Filmen: dieselbe Kennung heisst dieselbe Serie,
|
||||
// und zwei Ordner derselben Serie gehören zusammengelegt.
|
||||
var schonDa = await db.Series
|
||||
.Where(x => x.LibraryId == serie.LibraryId && x.TmdbId == anfrage.TmdbId && x.Id != id)
|
||||
.Select(x => x.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (schonDa != Guid.Empty)
|
||||
{
|
||||
await anwender.ApplyAsync(await db.Series.FirstAsync(x => x.Id == schonDa), gefunden);
|
||||
await zusammen.SerieVerschmelzenAsync(sieger: schonDa, verlierer: id);
|
||||
|
||||
var sieger = await db.Series.AsNoTracking().FirstAsync(x => x.Id == schonDa);
|
||||
return Results.Ok(new MatchState(
|
||||
sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year,
|
||||
Id: sieger.Id, Merged: true));
|
||||
}
|
||||
|
||||
await anwender.ApplyAsync(serie, gefunden);
|
||||
return Results.Ok(new MatchState(serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year));
|
||||
return Results.Ok(new MatchState(
|
||||
serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year, Id: serie.Id));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
}
|
||||
|
||||
/// <summary>Der längste Pfad, mit dem alle anfangen — oder <c>null</c>.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Verglichen wird nach Ordnerbestandteilen, nicht nach Zeichen: sonst
|
||||
/// ergäben <c>/media/Serien/Dark</c> und <c>/media/Serien/Darkwing</c> den
|
||||
/// gemeinsamen Anfang <c>/media/Serien/Dark</c>, und der bezeichnet einen
|
||||
/// Ordner, in dem nur eines von beidem liegt.
|
||||
/// </remarks>
|
||||
private static string? GemeinsamerAnfang(IReadOnlyList<string> pfade)
|
||||
{
|
||||
if (pfade.Count == 0) return null;
|
||||
|
||||
var teile = pfade.Select(p => p.Split('/', StringSplitOptions.RemoveEmptyEntries)).ToList();
|
||||
var gemeinsam = new List<string>();
|
||||
|
||||
for (var i = 0; i < teile[0].Length; i++)
|
||||
{
|
||||
var stueck = teile[0][i];
|
||||
if (teile.Any(t => i >= t.Length || t[i] != stueck)) break;
|
||||
gemeinsam.Add(stueck);
|
||||
}
|
||||
|
||||
return gemeinsam.Count == 0 ? null : "/" + string.Join('/', gemeinsam);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Woher ein Titel im Dateisystem stammt.</summary>
|
||||
///
|
||||
/// <param name="Ordner">Der gemeinsame Ordner aller Dateien; <c>null</c>, wenn es keinen gibt.</param>
|
||||
/// <param name="Dateien">Wie viele Dateien an diesem Titel hängen.</param>
|
||||
/// <param name="Beispiele">Ein paar Dateinamen — mehr braucht niemand zum Wiedererkennen.</param>
|
||||
public sealed record Herkunft(string? Ordner, int Dateien, IReadOnlyList<string> Beispiele);
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
using System.Security.Claims;
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
/// <summary>Melden, was an einem Titel nicht stimmt — und es abarbeiten.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum es das gibt.</b> Wer einen Server für andere betreibt, erfährt von
|
||||
/// Fehlern zuletzt: dass die deutsche Tonspur nach einer halben Stunde
|
||||
/// aussetzt, merkt der, der den Film sieht, und nicht der, der ihn eingeräumt
|
||||
/// hat. Bisher gab es dafür keinen Weg ausser dem Zuruf im Wohnzimmer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Melden darf jeder, lesen nur die Verwaltung.</b> Die eine Ausnahme sind
|
||||
/// die eigenen Meldungen: wer etwas gemeldet hat, soll nachsehen können, ob
|
||||
/// etwas daraus geworden ist, ohne zu fragen. Das ist dieselbe Linie wie beim
|
||||
/// Sehprotokoll — eigene Daten ja, fremde nein, auch als Administrator nicht
|
||||
/// über diesen Weg.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MeldungEndpoints
|
||||
{
|
||||
private const int HoechstensZeichen = 2000;
|
||||
|
||||
/// <summary>Wie viele Meldungen eine Liste höchstens zurückgibt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Die offene Liste ist eine Warteschlange. Wer fünfhundert offene
|
||||
/// Meldungen hat, hat ein anderes Problem als das, das dieser Bildschirm
|
||||
/// löst.
|
||||
/// </remarks>
|
||||
private const int HoechstensZeilen = 500;
|
||||
|
||||
public static void MapMeldungen(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// **Am selben Präfix wie alles, was eine undurchsichtige Titelkennung
|
||||
// annimmt.** Gesehen, Favorit, Weglegen und Fortschritt liegen auch
|
||||
// dort; der Client muss dadurch nicht wissen, ob er einen Film, eine
|
||||
// Serie oder eine Folge in der Hand hält.
|
||||
var melden = app.MapGroup("/api/v1/items").RequireAuthorization();
|
||||
|
||||
melden.MapPost("/{id:guid}/reports", async (
|
||||
Guid id, MeldungRequest anfrage, ClaimsPrincipal wer,
|
||||
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
{
|
||||
if (Kennung(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
|
||||
var kommentar = anfrage.Comment?.Trim() ?? "";
|
||||
if (kommentar.Length == 0)
|
||||
return Einwand.Bitte("Ohne Beschreibung lässt sich nichts nachsehen.");
|
||||
if (kommentar.Length > HoechstensZeichen)
|
||||
return Einwand.Bitte($"Höchstens {HoechstensZeichen} Zeichen.");
|
||||
|
||||
if (AlsArt(anfrage.Kind) is not { } art)
|
||||
{
|
||||
return Einwand.Bitte("Unbekannte Art. Möglich sind: bild, ton, untertitel, daten, sonstiges.");
|
||||
}
|
||||
|
||||
if (!AlsStelle(anfrage.Position, out var stelle))
|
||||
return Einwand.Bitte("Die Stelle muss wie 0:34:12 aussehen.");
|
||||
|
||||
// **Der Freigabefilter erledigt die Rechteprüfung.** Wer einen
|
||||
// Titel nicht sehen darf, findet ihn hier nicht — ohne ein
|
||||
// einziges `Where` und ohne eine Stelle, die man zu pflegen
|
||||
// vergessen kann.
|
||||
Guid? film = null, serie = null, folge = null;
|
||||
if (await db.Movies.AnyAsync(m => m.Id == id, ct)) film = id;
|
||||
else if (await db.Episodes.AnyAsync(f => f.Id == id, ct)) folge = id;
|
||||
else if (await db.Series.AnyAsync(s => s.Id == id, ct)) serie = id;
|
||||
else return Einwand.Bitte("Diesen Titel gibt es nicht.");
|
||||
|
||||
int? staffel = null;
|
||||
if (anfrage.SeasonNumber is { } nummer)
|
||||
{
|
||||
if (serie is null)
|
||||
return Einwand.Bitte("Eine Staffel gibt es nur zu einer Serie.");
|
||||
if (!await db.Episodes.AnyAsync(f => f.SeriesId == serie && f.SeasonNumber == nummer, ct))
|
||||
return Einwand.Bitte("Diese Staffel gibt es in der Serie nicht.");
|
||||
staffel = nummer;
|
||||
}
|
||||
|
||||
var meldung = new Meldung
|
||||
{
|
||||
UserId = benutzer,
|
||||
MovieId = film,
|
||||
SeriesId = serie,
|
||||
EpisodeId = folge,
|
||||
SeasonNumber = staffel,
|
||||
Art = art,
|
||||
Stelle = stelle,
|
||||
Kommentar = kommentar,
|
||||
GemeldetAm = uhr.GetUtcNow(),
|
||||
};
|
||||
|
||||
db.Meldungen.Add(meldung);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { id = meldung.Id });
|
||||
});
|
||||
|
||||
// **Die eigenen — jeder nur sich selbst, auch ein Administrator.**
|
||||
// Steht vor der Verwaltungsgruppe, damit `mine` nicht als Kennung
|
||||
// gelesen wird; die Verwaltungsrouten tragen zusätzlich `:guid`.
|
||||
app.MapGroup("/api/v1/reports/mine").RequireAuthorization()
|
||||
.MapGet("", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
if (Kennung(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
|
||||
var roh = await db.Meldungen.AsNoTracking()
|
||||
.Where(m => m.UserId == benutzer)
|
||||
.OrderByDescending(m => m.GemeldetAm)
|
||||
.Take(HoechstensZeilen)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Results.Ok(await Anreichern(db, roh, ct));
|
||||
});
|
||||
|
||||
var verwalten = app.MapGroup("/api/v1/reports")
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
verwalten.MapGet("", async (
|
||||
FlaroContext db, CancellationToken ct, bool erledigt = false) =>
|
||||
{
|
||||
// **Offene aufsteigend, erledigte absteigend.** Das eine ist eine
|
||||
// Warteschlange — dort gehört das Älteste nach oben. Das andere
|
||||
// ist ein Archiv, und darin sucht man das Letzte.
|
||||
var abfrage = db.Meldungen.AsNoTracking();
|
||||
var roh = erledigt
|
||||
? await abfrage.Where(m => m.ErledigtAm != null)
|
||||
.OrderByDescending(m => m.ErledigtAm).Take(HoechstensZeilen).ToListAsync(ct)
|
||||
: await abfrage.Where(m => m.ErledigtAm == null)
|
||||
.OrderBy(m => m.GemeldetAm).Take(HoechstensZeilen).ToListAsync(ct);
|
||||
|
||||
return Results.Ok(await Anreichern(db, roh, ct));
|
||||
});
|
||||
|
||||
// **Abhaken, nicht löschen.** Wiederholt sich ein Problem, ist die
|
||||
// Vorgeschichte die halbe Antwort. Zweimal abhaken ist kein Fehler,
|
||||
// sondern zweimal dasselbe Ergebnis.
|
||||
verwalten.MapPost("/{id:guid}/resolve", (Guid id, ClaimsPrincipal wer,
|
||||
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
Umschalten(id, wer, db, ct, uhr.GetUtcNow()));
|
||||
|
||||
verwalten.MapPost("/{id:guid}/reopen", (Guid id, ClaimsPrincipal wer,
|
||||
FlaroContext db, CancellationToken ct) =>
|
||||
Umschalten(id, wer, db, ct, null));
|
||||
}
|
||||
|
||||
private static async Task<IResult> Umschalten(
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct, DateTimeOffset? wann)
|
||||
{
|
||||
var meldung = await db.Meldungen.FirstOrDefaultAsync(m => m.Id == id, ct);
|
||||
if (meldung is null)
|
||||
return Einwand.Fehlt("Diese Meldung gibt es nicht mehr.");
|
||||
|
||||
meldung.ErledigtAm = wann;
|
||||
meldung.ErledigtVon = wann is null ? null : Kennung(wer);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Hängt Titel, Plakat und Melder an die nackten Zeilen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Nachgeschlagen, nicht verbunden.</b> Ein Join würde eine Meldung
|
||||
/// verschwinden lassen, sobald ihr Titel für den Fragenden unsichtbar wird
|
||||
/// — und das trifft ausgerechnet die eigene Liste des Melders, wenn ein
|
||||
/// Administrator den Titel zurückzieht. Hier bleibt die Zeile stehen und
|
||||
/// der Name fehlt.
|
||||
///
|
||||
/// <b>Und ohne <c>IgnoreQueryFilters</c>.</b> Das wäre die bequeme Lösung
|
||||
/// und gäbe den Namen eines unveröffentlichten Titels über einen Weg
|
||||
/// heraus, an den beim nächsten Mal niemand denkt.
|
||||
/// </remarks>
|
||||
private static async Task<List<Meldungseintrag>> Anreichern(
|
||||
FlaroContext db, List<Meldung> roh, CancellationToken ct)
|
||||
{
|
||||
var filmIds = roh.Where(m => m.MovieId != null).Select(m => m.MovieId!.Value).Distinct().ToList();
|
||||
var serienIds = roh.Where(m => m.SeriesId != null).Select(m => m.SeriesId!.Value).Distinct().ToList();
|
||||
var folgenIds = roh.Where(m => m.EpisodeId != null).Select(m => m.EpisodeId!.Value).Distinct().ToList();
|
||||
var leuteIds = roh.Select(m => m.UserId).Distinct().ToList();
|
||||
|
||||
var filme = await db.Movies.AsNoTracking().Where(m => filmIds.Contains(m.Id))
|
||||
.Select(m => new { m.Id, m.Title, m.PosterImage }).ToDictionaryAsync(m => m.Id, ct);
|
||||
|
||||
var serien = await db.Series.AsNoTracking().Where(s => serienIds.Contains(s.Id))
|
||||
.Select(s => new { s.Id, s.Title, s.PosterImage }).ToDictionaryAsync(s => s.Id, ct);
|
||||
|
||||
var folgen = await db.Episodes.AsNoTracking().Where(f => folgenIds.Contains(f.Id))
|
||||
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
|
||||
{
|
||||
f.Id, Folgentitel = f.Title, f.SeasonNumber, f.EpisodeNumber,
|
||||
Serientitel = s.Title, s.PosterImage, SerienId = s.Id,
|
||||
})
|
||||
.ToDictionaryAsync(f => f.Id, ct);
|
||||
|
||||
var leute = await db.Users.AsNoTracking().Where(u => leuteIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.UserName }).ToDictionaryAsync(u => u.Id, ct);
|
||||
|
||||
var fertig = new List<Meldungseintrag>(roh.Count);
|
||||
foreach (var m in roh)
|
||||
{
|
||||
var melder = leute.TryGetValue(m.UserId, out var u) ? u.UserName : "";
|
||||
var gemeinsam = (
|
||||
Kind: AlsWort(m.Art),
|
||||
Stelle: m.Stelle?.TotalSeconds,
|
||||
m.Kommentar, m.GemeldetAm, m.ErledigtAm);
|
||||
|
||||
if (m.MovieId is { } film)
|
||||
{
|
||||
filme.TryGetValue(film, out var f);
|
||||
fertig.Add(new Meldungseintrag(
|
||||
m.Id, "movie", film, f?.Title ?? "", null, f?.PosterImage,
|
||||
gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar,
|
||||
gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm));
|
||||
}
|
||||
else if (m.EpisodeId is { } folge)
|
||||
{
|
||||
folgen.TryGetValue(folge, out var f);
|
||||
fertig.Add(new Meldungseintrag(
|
||||
m.Id, "episode", f?.SerienId ?? folge, f?.Serientitel ?? "",
|
||||
f is null ? null
|
||||
: $"S{f.SeasonNumber} · F{f.EpisodeNumber}"
|
||||
+ (f.Folgentitel is { Length: > 0 } t ? $" — {t}" : ""),
|
||||
f?.PosterImage,
|
||||
gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar,
|
||||
gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm));
|
||||
}
|
||||
else if (m.SeriesId is { } serie)
|
||||
{
|
||||
serien.TryGetValue(serie, out var s);
|
||||
fertig.Add(new Meldungseintrag(
|
||||
m.Id, m.SeasonNumber is null ? "series" : "season", serie,
|
||||
s?.Title ?? "",
|
||||
m.SeasonNumber is { } nummer ? $"Staffel {nummer}" : null,
|
||||
s?.PosterImage,
|
||||
gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar,
|
||||
gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm));
|
||||
}
|
||||
}
|
||||
return fertig;
|
||||
}
|
||||
|
||||
private static Guid? Kennung(ClaimsPrincipal wer) =>
|
||||
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
||||
|
||||
private static Meldungsart? AlsArt(string? wert) => wert?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"bild" => Meldungsart.Bild,
|
||||
"ton" => Meldungsart.Ton,
|
||||
"untertitel" => Meldungsart.Untertitel,
|
||||
"daten" => Meldungsart.Daten,
|
||||
"sonstiges" => Meldungsart.Sonstiges,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Das Wort, das über die Leitung geht — nicht der Name im Code.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Von Hand und nicht über <c>ToString()</c>: sonst entschiede ein
|
||||
/// Umbenennen im C# über das Format, das der Client liest.
|
||||
/// </remarks>
|
||||
private static string AlsWort(Meldungsart art) => art switch
|
||||
{
|
||||
Meldungsart.Bild => "bild",
|
||||
Meldungsart.Ton => "ton",
|
||||
Meldungsart.Untertitel => "untertitel",
|
||||
Meldungsart.Daten => "daten",
|
||||
_ => "sonstiges",
|
||||
};
|
||||
|
||||
/// <summary>„1:02:03", „2:03" oder „123" — genau die Formen, die der Client schreibt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Nicht <c>TimeSpan.Parse</c>: der liest „2:03" als zwei Stunden und drei
|
||||
/// Minuten. Wer in ein Feld neben einem Film „2:03" schreibt, meint zwei
|
||||
/// Minuten — so steht es auch unter jedem Fortschrittsbalken.
|
||||
/// </remarks>
|
||||
private static bool AlsStelle(string? text, out TimeSpan? wert)
|
||||
{
|
||||
wert = null;
|
||||
var roh = text?.Trim() ?? "";
|
||||
if (roh.Length == 0) return true;
|
||||
|
||||
var teile = roh.Split(':');
|
||||
if (teile.Length > 3) return false;
|
||||
|
||||
var zahlen = new List<int>();
|
||||
foreach (var teil in teile)
|
||||
{
|
||||
if (!int.TryParse(teil, out var zahl) || zahl < 0) return false;
|
||||
zahlen.Add(zahl);
|
||||
}
|
||||
|
||||
// Alles ausser dem vordersten Glied zählt in Sechzigern.
|
||||
if (zahlen.Skip(1).Any(z => z > 59)) return false;
|
||||
|
||||
var sekunden = zahlen.Aggregate(0L, (summe, zahl) => summe * 60 + zahl);
|
||||
wert = TimeSpan.FromSeconds(sekunden);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user