Compare commits
177
Commits
aa1e14cdab
...
main
+19
-10
@@ -1,20 +1,29 @@
|
|||||||
# Baut das Flaro-Image und legt es in die Registry dieses Gitea.
|
# Baut das Flaro-Image und legt es in die Registry dieses Gitea.
|
||||||
#
|
#
|
||||||
# **Warum hier und nicht auf dem Arbeitsrechner.** Ein Bau von Hand heisst:
|
# **Zur Zeit schläft diese Datei.** Gebaut wird auf dem Arbeitsrechner mit
|
||||||
# jemand muss daran denken, muss die richtige Architektur angeben und muss
|
# `Tools/image-veroeffentlichen.sh` — der Rechner ist für die Apps ohnehin
|
||||||
# angemeldet sein. Hier passiert es bei jedem Push von selbst, immer für
|
# nötig, weil Xcode nirgendwo sonst läuft.
|
||||||
# beide Architekturen, und die Anmeldung besorgt Gitea.
|
|
||||||
#
|
#
|
||||||
# **Voraussetzung ist ein Runner.** Ohne einen registrierten Act-Runner
|
# **Weil Gitea nicht selbst baut.** Es verteilt Arbeit an einen Act-Runner,
|
||||||
# läuft diese Datei nie an — dann bleibt der Weg von Hand aus
|
# und der müsste auf dem NAS laufen. Dort scheitert er: sein Zweck ist das
|
||||||
# `Tools/image-veroeffentlichen.sh`.
|
# 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
|
name: Image bauen und veröffentlichen
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
# Wieder automatisch, sobald ein Runner existiert:
|
||||||
branches: [main]
|
# push:
|
||||||
tags: ['v*']
|
# branches: [main]
|
||||||
|
# tags: ['v*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
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" : [
|
"fill-specializations" : [
|
||||||
{
|
{
|
||||||
"value" : {
|
"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" : [
|
"layers" : [
|
||||||
{
|
{
|
||||||
"fill" : {
|
"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",
|
"image-name" : "flare (1).svg",
|
||||||
"name" : "play",
|
"name" : "flare (1)",
|
||||||
"position" : {
|
"position" : {
|
||||||
"scale" : 1.18,
|
"scale" : 100,
|
||||||
"translation-in-points" : [
|
"translation-in-points" : [
|
||||||
49.7265625,
|
0,
|
||||||
1.468530916781674e-05
|
0
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ struct FlaroApp: App {
|
|||||||
@State private var network = NetworkConditions()
|
@State private var network = NetworkConditions()
|
||||||
@State private var homeLayout = HomeLayoutSettings()
|
@State private var homeLayout = HomeLayoutSettings()
|
||||||
@State private var diagnostics = PlaybackDiagnostics()
|
@State private var diagnostics = PlaybackDiagnostics()
|
||||||
|
/// Fortschritt, der den Server nicht erreicht hat — siehe
|
||||||
|
/// `Fortschrittspost`. Hier, weil er die Wiedergabe überleben muss.
|
||||||
|
@State private var post = Fortschrittspost.shared
|
||||||
@State private var downloads: DownloadCoordinator
|
@State private var downloads: DownloadCoordinator
|
||||||
|
|
||||||
private let modelContainer: ModelContainer
|
private let modelContainer: ModelContainer
|
||||||
@@ -92,6 +95,7 @@ struct FlaroApp: App {
|
|||||||
.environment(downloads)
|
.environment(downloads)
|
||||||
.environment(homeLayout)
|
.environment(homeLayout)
|
||||||
.environment(diagnostics)
|
.environment(diagnostics)
|
||||||
|
.environment(post)
|
||||||
.environment(theme)
|
.environment(theme)
|
||||||
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
|
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
|
||||||
//
|
//
|
||||||
@@ -117,6 +121,8 @@ struct FlaroApp: App {
|
|||||||
backend: session.backend,
|
backend: session.backend,
|
||||||
coordinator: downloads
|
coordinator: downloads
|
||||||
).refill()
|
).refill()
|
||||||
|
// Was im Flugzeug liegengeblieben ist, geht jetzt raus.
|
||||||
|
await post.zustellen(über: session.backend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modelContainer(modelContainer)
|
.modelContainer(modelContainer)
|
||||||
|
|||||||
@@ -25,6 +25,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%@: %@": {
|
||||||
|
"comment": "A label that shows the audio or subtitle language. The argument is the language name.",
|
||||||
|
"isCommentAutoGenerated": true,
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "new",
|
||||||
|
"value": "%1$@: %2$@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"4K": {
|
"4K": {
|
||||||
"comment": "A label for a 4K video.",
|
"comment": "A label for a 4K video.",
|
||||||
"isCommentAutoGenerated": true
|
"isCommentAutoGenerated": true
|
||||||
@@ -1241,6 +1253,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"downloads.episodeCount %lld": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld Folgen geladen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld episodes downloaded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloads.failed": {
|
"downloads.failed": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -1722,7 +1750,7 @@
|
|||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Staffel %lld · Episode %lld"
|
"value": "Staffel %lld · Folge %lld"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
@@ -1886,6 +1914,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filter.downloadedOnly": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nur Heruntergeladene"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Downloaded only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"filter.filters": {
|
"filter.filters": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -2122,6 +2167,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"home.downloaded": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Heruntergeladen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Downloaded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"home.empty.description": {
|
"home.empty.description": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -2339,6 +2400,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"home.offline": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Kein Server erreichbar. Was hier steht, stammt vom letzten Mal — ansehen lässt sich nur, was auf dem Gerät liegt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "No server reachable. What you see is from last time — only what is on this device can be played."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"home.rowsHeader": {
|
"home.rowsHeader": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -2468,6 +2545,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"languages.audioShort": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Ton"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Audio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"languages.avoidAudioDescription": {
|
"languages.avoidAudioDescription": {
|
||||||
"extractionState": "manual",
|
"extractionState": "manual",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -2621,6 +2715,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"languages.forThisTitle": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Tonsprache für diesen Titel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Audio language for this title"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"languages.headerExplanation": {
|
"languages.headerExplanation": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -2655,6 +2766,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"languages.none": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Keine"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "None"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"languages.partial %@": {
|
||||||
|
"comment": "Eine kleine Schriftart, die",
|
||||||
|
"isCommentAutoGenerated": true
|
||||||
|
},
|
||||||
|
"languages.partial %lld": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nur Sprachen, die in jeder Folge vorkommen. %lld weitere gibt es nur in einem Teil."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Only languages present in every episode. %lld more exist in some episodes only."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"languages.serverDefault": {
|
"languages.serverDefault": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -2704,6 +2853,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"languages.subtitlesForThisSeries": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Untertitel für diese Serie"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Subtitles for this series"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"languages.subtitlesForThisTitle": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Untertitel für diesen Titel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Subtitles for this title"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"languages.subtitlesHeader": {
|
"languages.subtitlesHeader": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -2736,6 +2919,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"languages.subtitlesShort": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Untertitel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Subtitles"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"languages.title": {
|
"languages.title": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -3905,7 +4105,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player.nextEpisode": {
|
"player.nextEpisode": {
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -4041,6 +4240,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"player.previousEpisode": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Vorige Folge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Previous episode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"player.quality": {
|
"player.quality": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -5039,6 +5254,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"season.short %@": {
|
||||||
|
"comment": "A label that shows the number of the selected season.",
|
||||||
|
"isCommentAutoGenerated": true
|
||||||
|
},
|
||||||
"season.short %lld": {
|
"season.short %lld": {
|
||||||
"comment": "Platzhalter: Staffelnummer",
|
"comment": "Platzhalter: Staffelnummer",
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
@@ -5912,6 +6131,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.skipExtraScenes": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Zusätzliche Szenen mit überspringen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Skip extra scenes too"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.storageLimit": {
|
"settings.storageLimit": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -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"
|
"version": "1.1"
|
||||||
|
|||||||
@@ -196,9 +196,65 @@ struct DownloadService {
|
|||||||
quality: DownloadQuality = .original,
|
quality: DownloadQuality = .original,
|
||||||
audioStream: MediaStream? = nil
|
audioStream: MediaStream? = nil
|
||||||
) throws(DownloadRefusal) -> DownloadItem {
|
) throws(DownloadRefusal) -> DownloadItem {
|
||||||
try coordinator.enqueue(
|
let eintrag = try coordinator.enqueue(
|
||||||
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
|
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
|
||||||
)
|
)
|
||||||
|
plakatHolen(for: item)
|
||||||
|
return eintrag
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holt das Plakat mit herunter.
|
||||||
|
///
|
||||||
|
/// **Ohne Verbindung gibt es keine Adresse mehr, von der eines käme.**
|
||||||
|
/// Ein Download ohne Bild ist offline eine graue Fläche mit einem Titel
|
||||||
|
/// darunter — und genau dann, wenn man etwas zum Ansehen sucht, ist das
|
||||||
|
/// Bild die halbe Auskunft. Ein paar Kilobyte je Eintrag.
|
||||||
|
///
|
||||||
|
/// Nebenher und ohne Fehlerweg: scheitert es, fehlt ein Bild. Den
|
||||||
|
/// Download deshalb scheitern zu lassen wäre die schlechtere Hälfte.
|
||||||
|
func plakatHolen(for item: MediaItem) {
|
||||||
|
if let adresse = backend?.rawImageURL(path: item.thumb) {
|
||||||
|
Task { await coordinator.fetchArtwork(for: item.id, from: adresse) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Und das Plakat der Serie, unter deren Schlüssel.**
|
||||||
|
//
|
||||||
|
// `thumb` ist bei einer Folge ihr Szenenbild — quer, und hochkant
|
||||||
|
// beschnitten zeigt es einen zufälligen Ausschnitt. Die Downloads
|
||||||
|
// stehen aber als Serie in der Reihe, und eine Serienkachel ist ein
|
||||||
|
// Plakat. Ohne diesen zweiten Griff gäbe es für die Serie gar kein
|
||||||
|
// Bild: heruntergeladen wird sie nie, nur ihre Folgen.
|
||||||
|
if let serie = item.seriesID,
|
||||||
|
let pfad = item.seriesThumb ?? item.parentThumb,
|
||||||
|
let adresse = backend?.rawImageURL(path: pfad) {
|
||||||
|
Task { await coordinator.fetchArtwork(for: serie, from: adresse) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holt Plakate nach, die früheren Downloads fehlen.
|
||||||
|
///
|
||||||
|
/// **Weil bis vor Kurzem gar keines mitkam.** Der Dateiname dafür stand
|
||||||
|
/// im Modell, gesetzt hat ihn nie jemand — alles, was vorher
|
||||||
|
/// heruntergeladen wurde, hat kein Bild und stünde in der neuen Reihe
|
||||||
|
/// als graue Fläche.
|
||||||
|
///
|
||||||
|
/// Einmal je Serverwechsel, nur für die Einträge ohne Bild; danach
|
||||||
|
/// findet die Schleife nichts mehr.
|
||||||
|
func plakateNachholen() async {
|
||||||
|
guard let backend else { return }
|
||||||
|
for eintrag in coordinator.items where fehltEinBild(eintrag) {
|
||||||
|
guard let titel = try? await backend.item(id: eintrag.ratingKey) else { continue }
|
||||||
|
plakatHolen(for: titel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob zu diesem Eintrag noch ein Bild fehlt — seines oder das seiner
|
||||||
|
/// Serie.
|
||||||
|
private func fehltEinBild(_ eintrag: DownloadItem) -> Bool {
|
||||||
|
if coordinator.artworkURL(forRatingKey: eintrag.ratingKey) == nil { return true }
|
||||||
|
if let serie = eintrag.seriesKey,
|
||||||
|
coordinator.artworkURL(forRatingKey: serie) == nil { return true }
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
|
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
|
||||||
@@ -294,6 +350,7 @@ struct DownloadService {
|
|||||||
_ = try coordinator.enqueue(
|
_ = try coordinator.enqueue(
|
||||||
try plan(for: detailed, quality: quality, audioStream: ton)
|
try plan(for: detailed, quality: quality, audioStream: ton)
|
||||||
)
|
)
|
||||||
|
plakatHolen(for: detailed)
|
||||||
accepted += 1
|
accepted += 1
|
||||||
} catch DownloadRefusal.noDownloadableFile {
|
} catch DownloadRefusal.noDownloadableFile {
|
||||||
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
|
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
|
||||||
|
|||||||
@@ -130,6 +130,16 @@ final class FlaroSession {
|
|||||||
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
||||||
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
|
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
|
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
|
||||||
/// erscheinen gar nicht erst.
|
/// erscheinen gar nicht erst.
|
||||||
private(set) var libraries: [MediaLibrary] = []
|
private(set) var libraries: [MediaLibrary] = []
|
||||||
@@ -273,6 +283,9 @@ final class FlaroSession {
|
|||||||
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
|
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
|
||||||
// verloren.
|
// verloren.
|
||||||
Task { await LibraryCache.shared.clearAll() }
|
Task { await LibraryCache.shared.clearAll() }
|
||||||
|
// Dasselbe gilt für den Fortschritt, der noch auf den Server
|
||||||
|
// wartet: er gehört dem Konto, das gerade gegangen ist.
|
||||||
|
Fortschrittspost.shared.leeren()
|
||||||
phase = .signedOut
|
phase = .signedOut
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,14 +475,45 @@ final class FlaroSession {
|
|||||||
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
|
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
|
||||||
// Server das, und es ist kein Grund, jemanden abzumelden.
|
// Server das, und es ist kein Grund, jemanden abzumelden.
|
||||||
//
|
//
|
||||||
// **Trotzdem mit Server:** sonst stünde beim Erneut-Versuchen
|
// **Und auch kein Grund, vor der Tür stehen zu bleiben.** Vorher
|
||||||
// kein Konto da, und das Heruntergeladene wäre unerreichbar.
|
// endete es hier bei einem Fehlerbildschirm mit zwei Knöpfen:
|
||||||
|
// „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))
|
phase = .failed(message: ErrorText.describe(error))
|
||||||
activeKind = .flaro
|
activeKind = .flaro
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Das Konto aus den Voreinstellungen, ohne den Server zu fragen.
|
||||||
|
///
|
||||||
|
/// Alles darin wurde beim letzten erfolgreichen Anmelden geschrieben.
|
||||||
|
/// Es ist vielleicht veraltet — ein umbenannter Server, ein geänderter
|
||||||
|
/// Anzeigename —, aber es ist richtiger als gar keins.
|
||||||
|
private func gemerktesFlaroKonto(baseURL: URL) -> FlaroAccount {
|
||||||
|
FlaroAccount(
|
||||||
|
baseURL: baseURL,
|
||||||
|
serverName: defaults.string(forKey: Key.flaroServerName)
|
||||||
|
?? baseURL.host() ?? "Flaro",
|
||||||
|
userID: defaults.string(forKey: Key.flaroUserID) ?? "",
|
||||||
|
userName: defaults.string(forKey: Key.flaroUserName) ?? "",
|
||||||
|
isAdministrator: defaults.bool(forKey: Key.flaroIsAdministrator)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func rememberFlaro(account: FlaroAccount, token: String?) {
|
private func rememberFlaro(account: FlaroAccount, token: String?) {
|
||||||
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
|
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
|
||||||
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
|
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ enum HeroArtwork {
|
|||||||
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
|
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
|
||||||
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
|
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
|
||||||
// das der Serie — und erst danach das Vorschaubild der Folge.
|
// das der Serie — und erst danach das Vorschaubild der Folge.
|
||||||
: [item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
|
// **Das Aufmacherbild zuerst.** Es ist das, was für genau diese
|
||||||
|
// Fläche ausgesucht wurde; der Hintergrund ist die Vorgabe
|
||||||
|
// dahinter.
|
||||||
|
: [item.heroArt, item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
|
||||||
|
|
||||||
var gesehen = Set<String>()
|
var gesehen = Set<String>()
|
||||||
return pfade
|
return pfade
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import FlaroDownloads
|
||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Was auf dem Gerät liegt, als gewöhnliche Titel.
|
||||||
|
///
|
||||||
|
/// **Damit ein Download aussieht wie alles andere.** Bisher waren Downloads
|
||||||
|
/// eine eigene Welt: eine eigene Liste, eine eigene Zeile, ein eigener
|
||||||
|
/// Bereich. Wer etwas ansehen wollte, musste erst wissen, dass er dorthin
|
||||||
|
/// wechseln muss. Als `MediaItem` fügt sich ein Download in jede Reihe und
|
||||||
|
/// jedes Raster ein, das es ohnehin gibt — Kachel, Fortschrittsbalken,
|
||||||
|
/// Kontextmenü inbegriffen.
|
||||||
|
///
|
||||||
|
/// **Und es ist die Grundlage fürs Offline-Arbeiten.** Ohne Server gibt es
|
||||||
|
/// keine andere Quelle für einen Titel; alles, was dann noch dasteht, kommt
|
||||||
|
/// von hier.
|
||||||
|
@MainActor
|
||||||
|
enum Heruntergeladenes {
|
||||||
|
/// Die fertigen Downloads, neueste zuerst.
|
||||||
|
///
|
||||||
|
/// **Nur fertige.** Ein halb geladener Film ist nichts zum Ansehen; er
|
||||||
|
/// steht im Downloads-Bereich mit seinem Balken, und dort gehört er hin.
|
||||||
|
static func fertige(in coordinator: DownloadCoordinator) -> [DownloadItem] {
|
||||||
|
coordinator.items
|
||||||
|
.filter(\.isComplete)
|
||||||
|
.sorted { ($0.completedAt ?? $0.addedAt) > ($1.completedAt ?? $1.addedAt) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was auf dem Gerät liegt — Filme einzeln, Serien als **eine** Kachel.
|
||||||
|
///
|
||||||
|
/// **Zwanzig Folgen sind keine zwanzig Titel.** Vorher stand jede
|
||||||
|
/// einzeln in der Reihe, und eine heruntergeladene Staffel schob alles
|
||||||
|
/// andere aus dem Bild. Was man im Kopf hat, ist die Serie; welche
|
||||||
|
/// Folgen davon da sind, ist die Frage danach — und die beantwortet die
|
||||||
|
/// Seite der Serie.
|
||||||
|
static func reihe(in coordinator: DownloadCoordinator) -> [MediaItem] {
|
||||||
|
var ergebnis: [MediaItem] = []
|
||||||
|
var gesehen = Set<String>()
|
||||||
|
|
||||||
|
for eintrag in fertige(in: coordinator) {
|
||||||
|
guard let serie = eintrag.seriesKey else {
|
||||||
|
ergebnis.append(titel(eintrag, in: coordinator))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Die Reihenfolge der Folgen ist die der Serie: die zuletzt
|
||||||
|
// fertige Folge bestimmt, wo die Serie steht.
|
||||||
|
guard gesehen.insert(serie).inserted else { continue }
|
||||||
|
ergebnis.append(self.serie(serie, in: coordinator))
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Serie, von der Folgen auf dem Gerät liegen.
|
||||||
|
static func serie(_ schluessel: String, in coordinator: DownloadCoordinator) -> MediaItem {
|
||||||
|
let folgen = folgenEintraege(derSerie: schluessel, in: coordinator)
|
||||||
|
return MediaItem(
|
||||||
|
id: schluessel,
|
||||||
|
kind: .show,
|
||||||
|
title: folgen.first?.seriesTitle ?? folgen.first?.title ?? "",
|
||||||
|
// **Erst das Plakat der Serie, dann irgendein Folgenbild.** Ein
|
||||||
|
// Szenenbild hochkant ist ein zufälliger Ausschnitt — aber immer
|
||||||
|
// noch besser als eine graue Fläche.
|
||||||
|
thumb: coordinator.artworkURL(forRatingKey: schluessel)?.absoluteString
|
||||||
|
?? folgen.lazy
|
||||||
|
.compactMap { coordinator.artworkURL(forRatingKey: $0.ratingKey)?.absoluteString }
|
||||||
|
.first
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie viele Folgen dieser Serie hier liegen — als Zeile unter der
|
||||||
|
/// Kachel.
|
||||||
|
///
|
||||||
|
/// `nil` für alles, was keine Serie mit Downloads ist: dann gilt, was
|
||||||
|
/// sonst unter einer Kachel steht.
|
||||||
|
static func untertitel(für titel: MediaItem, in coordinator: DownloadCoordinator) -> String? {
|
||||||
|
guard titel.kind == .show else { return nil }
|
||||||
|
let anzahl = folgenEintraege(derSerie: titel.id, in: coordinator).count
|
||||||
|
guard anzahl > 0 else { return nil }
|
||||||
|
return String(localized: "downloads.episodeCount \(anzahl)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die heruntergeladenen Folgen einer Serie, aufsteigend sortiert.
|
||||||
|
///
|
||||||
|
/// Für die Seite der Serie, wenn kein Server antwortet: dann ist das
|
||||||
|
/// hier die ganze Liste, die es gibt.
|
||||||
|
static func folgen(derSerie schluessel: String, in coordinator: DownloadCoordinator) -> [MediaItem] {
|
||||||
|
folgenEintraege(derSerie: schluessel, in: coordinator)
|
||||||
|
.sorted {
|
||||||
|
($0.seasonIndex ?? 0, $0.episodeIndex ?? 0) < ($1.seasonIndex ?? 0, $1.episodeIndex ?? 0)
|
||||||
|
}
|
||||||
|
.map { titel($0, in: coordinator) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func folgenEintraege(
|
||||||
|
derSerie schluessel: String,
|
||||||
|
in coordinator: DownloadCoordinator
|
||||||
|
) -> [DownloadItem] {
|
||||||
|
fertige(in: coordinator).filter { $0.seriesKey == schluessel }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Download als Titel.
|
||||||
|
///
|
||||||
|
/// **Das Bild kommt von der Platte, nicht vom Server.** `thumb` ist sonst
|
||||||
|
/// ein Pfad, den erst ein Server zu einer Adresse macht; hier steht
|
||||||
|
/// stattdessen die fertige lokale Adresse. Ansichten, die `rawImageURL`
|
||||||
|
/// darauf anwenden, müssen das wissen — siehe `bildAdresse`.
|
||||||
|
static func titel(_ eintrag: DownloadItem, in coordinator: DownloadCoordinator) -> MediaItem {
|
||||||
|
MediaItem(
|
||||||
|
id: eintrag.ratingKey,
|
||||||
|
kind: eintrag.seriesKey == nil ? .movie : .episode,
|
||||||
|
title: eintrag.title,
|
||||||
|
duration: eintrag.durationMilliseconds,
|
||||||
|
thumb: coordinator.artworkURL(forRatingKey: eintrag.ratingKey)?.absoluteString,
|
||||||
|
index: eintrag.episodeIndex,
|
||||||
|
parentIndex: eintrag.seasonIndex,
|
||||||
|
parentTitle: eintrag.seriesTitle,
|
||||||
|
seriesTitle: eintrag.seriesTitle,
|
||||||
|
seriesID: eintrag.seriesKey,
|
||||||
|
versions: [fassung(eintrag)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Datei auf dem Gerät als Fassung.
|
||||||
|
///
|
||||||
|
/// **Ohne sie bleibt der Abspielknopf grau.** Die Detailseite schaltet
|
||||||
|
/// ihn an `currentMedia?.files.first` fest, und das ist richtig: ein
|
||||||
|
/// Titel ohne Datei lässt sich nicht abspielen. Offline *gibt* es die
|
||||||
|
/// Datei aber — sie liegt nur woanders. Was der Server beim Herunterladen
|
||||||
|
/// über sie gesagt hat, steht in `offlineMediaData`.
|
||||||
|
private static func fassung(_ eintrag: DownloadItem) -> MediaVersion {
|
||||||
|
let angaben = eintrag.offlineMediaData.flatMap {
|
||||||
|
try? JSONDecoder().decode(OfflineMedia.self, from: $0)
|
||||||
|
}
|
||||||
|
return MediaVersion(
|
||||||
|
id: eintrag.ratingKey,
|
||||||
|
container: angaben?.container,
|
||||||
|
videoCodec: angaben?.videoCodec,
|
||||||
|
audioCodec: angaben?.audioCodec,
|
||||||
|
videoResolution: angaben?.videoResolution,
|
||||||
|
bitrate: angaben?.bitrate,
|
||||||
|
duration: eintrag.durationMilliseconds,
|
||||||
|
files: [MediaFile(
|
||||||
|
id: eintrag.ratingKey,
|
||||||
|
itemID: eintrag.ratingKey,
|
||||||
|
container: angaben?.container,
|
||||||
|
size: eintrag.totalBytes > 0 ? eintrag.totalBytes : nil,
|
||||||
|
duration: eintrag.durationMilliseconds
|
||||||
|
)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Adresse eines Bildes, das schon eine ist.
|
||||||
|
///
|
||||||
|
/// **Ein Umweg, der nötig ist.** Die Kacheln bauen ihre Bildadresse über
|
||||||
|
/// den Server (`rawImageURL`), weil `thumb` dort ein Pfad ist. Bei einem
|
||||||
|
/// Download steht darin bereits eine `file:`-Adresse — durch denselben
|
||||||
|
/// Bau geschickt käme Unsinn heraus.
|
||||||
|
static func bildAdresse(_ titel: MediaItem) -> URL? {
|
||||||
|
guard let roh = titel.thumb, roh.hasPrefix("file:") else { return nil }
|
||||||
|
return URL(string: roh)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob dieser Titel auf dem Gerät liegt.
|
||||||
|
///
|
||||||
|
/// **Eine Serie gilt als da, wenn Folgen von ihr da sind.** Die Serie
|
||||||
|
/// selbst wird nie heruntergeladen — ihre Folgen schon, und wer die
|
||||||
|
/// Kachel sieht, will wissen, ob er etwas davon ansehen kann.
|
||||||
|
static func liegtBereit(_ titelID: String, in coordinator: DownloadCoordinator) -> Bool {
|
||||||
|
coordinator.items.contains {
|
||||||
|
$0.isComplete && ($0.ratingKey == titelID || $0.seriesKey == titelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Foundation
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
@@ -19,6 +20,14 @@ import Observation
|
|||||||
final class HomeFeed {
|
final class HomeFeed {
|
||||||
private(set) var state: LoadState<[MediaRowGroup]> = .loading
|
private(set) var state: LoadState<[MediaRowGroup]> = .loading
|
||||||
|
|
||||||
|
/// Ob das Gezeigte vom letzten Mal stammt, weil der Server nicht
|
||||||
|
/// antwortet.
|
||||||
|
///
|
||||||
|
/// Die Startseite ist dann **nicht** falsch, nur alt — und das muss
|
||||||
|
/// dranstehen. Ein Stand von gestern, der sich als heute ausgibt, ist
|
||||||
|
/// schlimmer als gar keiner.
|
||||||
|
private(set) var ausDemSpeicher = false
|
||||||
|
|
||||||
/// Holt die Reihen vom Server.
|
/// Holt die Reihen vom Server.
|
||||||
///
|
///
|
||||||
/// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe
|
/// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe
|
||||||
@@ -29,6 +38,24 @@ final class HomeFeed {
|
|||||||
state = await LoadState.load(keepingPrevious: state) {
|
state = await LoadState.load(keepingPrevious: state) {
|
||||||
try await backend.homeRows()
|
try await backend.homeRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case .loaded(let reihen):
|
||||||
|
ausDemSpeicher = false
|
||||||
|
await LibraryCache.shared.storeHomeRows(reihen, server: backend.identity)
|
||||||
|
case .failed:
|
||||||
|
// **Der Abzug erst hier, nicht vorweg.** Ihn sofort zu zeigen
|
||||||
|
// hiesse, bei jedem Start eine alte Startseite aufzublitzen, die
|
||||||
|
// eine Sekunde später ersetzt wird. Er ist ein Rettungsanker,
|
||||||
|
// kein Beschleuniger.
|
||||||
|
if let gemerkt = await LibraryCache.shared.homeRows(server: backend.identity),
|
||||||
|
!gemerkt.isEmpty {
|
||||||
|
state = .loaded(gemerkt)
|
||||||
|
ausDemSpeicher = true
|
||||||
|
}
|
||||||
|
case .loading:
|
||||||
|
break
|
||||||
|
}
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// Das Regal über der App im Menü. Es wird gezeichnet, wenn die App
|
// Das Regal über der App im Menü. Es wird gezeichnet, wenn die App
|
||||||
// **nicht** läuft — also legen wir hier ab, was dort stehen soll.
|
// **nicht** läuft — also legen wir hier ab, was dort stehen soll.
|
||||||
@@ -38,10 +65,33 @@ final class HomeFeed {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Kennung der Downloadreihe.
|
||||||
|
///
|
||||||
|
/// Fest und nicht vom Server: nur so lässt sie sich wie jede andere
|
||||||
|
/// Reihe verschieben oder ausblenden.
|
||||||
|
static let downloadsRow = "flaro.downloads"
|
||||||
|
|
||||||
/// Die Reihen in der eingestellten Form.
|
/// Die Reihen in der eingestellten Form.
|
||||||
func rows(for layout: HomeLayoutSettings) -> [MediaRowGroup] {
|
///
|
||||||
guard case .loaded(let alle) = state else { return [] }
|
/// `downloaded` kommt von aussen, weil `HomeFeed` nichts von Downloads
|
||||||
return layout.apply(to: prepared(alle, layout: layout))
|
/// weiss und auch nichts davon wissen muss — es ist eine Reihe wie jede
|
||||||
|
/// andere, nur mit einer anderen Quelle.
|
||||||
|
/// **Auch ohne geladene Reihen**: scheitert der Server und liegt nichts
|
||||||
|
/// im Speicher, bleibt immer noch, was auf dem Gerät liegt.
|
||||||
|
func rows(for layout: HomeLayoutSettings, downloaded: [MediaItem] = []) -> [MediaRowGroup] {
|
||||||
|
var reihen: [MediaRowGroup] = []
|
||||||
|
if case .loaded(let alle) = state { reihen = prepared(alle, layout: layout) }
|
||||||
|
if !downloaded.isEmpty {
|
||||||
|
reihen.insert(
|
||||||
|
MediaRowGroup(
|
||||||
|
id: Self.downloadsRow,
|
||||||
|
title: String(localized: "home.downloaded"),
|
||||||
|
items: downloaded
|
||||||
|
),
|
||||||
|
at: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return layout.apply(to: reihen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Ordnen
|
// MARK: - Ordnen
|
||||||
@@ -155,8 +205,8 @@ final class HomeFeed {
|
|||||||
case .continueFirst:
|
case .continueFirst:
|
||||||
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
|
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
|
||||||
// Angefangenes bleibt vorn, wechselt aber untereinander.
|
// Angefangenes bleibt vorn, wechselt aber untereinander.
|
||||||
let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil })
|
let ordered = HeroSelection.ordered(unique.filter(\.isResumable))
|
||||||
+ HeroSelection.ordered(unique.filter { $0.watchProgress == nil })
|
+ HeroSelection.ordered(unique.filter { !$0.isResumable })
|
||||||
return Array(ordered.prefix(layout.heroCount))
|
return Array(ordered.prefix(layout.heroCount))
|
||||||
case .latestOnly:
|
case .latestOnly:
|
||||||
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
|
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ final class LanguagePreferences {
|
|||||||
static let avoidsAudioDescription = "flaro.languages.avoidsAudioDescription"
|
static let avoidsAudioDescription = "flaro.languages.avoidsAudioDescription"
|
||||||
static let perLibrary = "flaro.languages.library"
|
static let perLibrary = "flaro.languages.library"
|
||||||
static let perSeries = "flaro.languages.series"
|
static let perSeries = "flaro.languages.series"
|
||||||
|
static let perSeriesSubtitles = "flaro.languages.seriesSubtitles"
|
||||||
static let rememberedTracks = "flaro.languages.seriesTrack"
|
static let rememberedTracks = "flaro.languages.seriesTrack"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +99,14 @@ final class LanguagePreferences {
|
|||||||
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
|
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
|
||||||
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
|
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
|
||||||
/// abzulegen hiesse dort, den Fehler festzuschreiben.
|
/// abzulegen hiesse dort, den Fehler festzuschreiben.
|
||||||
|
/// Die Untertitelsprache je Titel — Serie oder Film.
|
||||||
|
///
|
||||||
|
/// **Ein Topf für beide.** Die Kennung eines Films kollidiert mit keiner
|
||||||
|
/// Serie; zwei Wörterbücher für dieselbe Frage liefen auseinander.
|
||||||
|
private var perSeriesSubtitles: [String: String] {
|
||||||
|
didSet { defaults.set(perSeriesSubtitles, forKey: Key.perSeriesSubtitles) }
|
||||||
|
}
|
||||||
|
|
||||||
private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] {
|
private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] {
|
||||||
didSet {
|
didSet {
|
||||||
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
|
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
|
||||||
@@ -117,6 +126,8 @@ final class LanguagePreferences {
|
|||||||
(defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true
|
(defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true
|
||||||
self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:]
|
self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:]
|
||||||
self.perSeries = defaults.dictionary(forKey: Key.perSeries) as? [String: String] ?? [:]
|
self.perSeries = defaults.dictionary(forKey: Key.perSeries) as? [String: String] ?? [:]
|
||||||
|
self.perSeriesSubtitles =
|
||||||
|
defaults.dictionary(forKey: Key.perSeriesSubtitles) as? [String: String] ?? [:]
|
||||||
self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap {
|
self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap {
|
||||||
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
|
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
|
||||||
} ?? [:]
|
} ?? [:]
|
||||||
@@ -136,6 +147,34 @@ final class LanguagePreferences {
|
|||||||
perSeries[id] = code
|
perSeries[id] = code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was „keine Untertitel" im Speicher heisst.
|
||||||
|
///
|
||||||
|
/// **Abgewählt ist eine Aussage, kein Fehlen einer.** Wer sie für diese
|
||||||
|
/// Serie abgeschaltet hat, will sie auch in Folge 7 nicht; ein fehlender
|
||||||
|
/// Eintrag hiesse dagegen „nie etwas gesagt", und dann darf eine
|
||||||
|
/// erzwungene Spur durchaus erscheinen.
|
||||||
|
static let noSubtitles = "keine"
|
||||||
|
|
||||||
|
func subtitleLanguage(forSeries id: String) -> String? { perSeriesSubtitles[id] }
|
||||||
|
|
||||||
|
func setSubtitleLanguage(_ code: String?, forSeries id: String) {
|
||||||
|
perSeriesSubtitles[id] = code
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welche Untertitelsprache für diesen Titel gilt — `nil` heisst: keine.
|
||||||
|
///
|
||||||
|
/// **Die Ebene schlägt das Allgemeine.** Dieselbe Rangfolge wie beim Ton,
|
||||||
|
/// nur ohne Mediathek: eine Untertitelsprache je Mediathek hat noch
|
||||||
|
/// niemand verlangt, und eine Einstellung, die niemand sucht, ist eine
|
||||||
|
/// Stelle mehr, an der etwas auseinanderlaufen kann.
|
||||||
|
func subtitleWish(titleID: String?) -> String? {
|
||||||
|
if let eigen = titleID.flatMap({ perSeriesSubtitles[$0] }) {
|
||||||
|
return eigen == Self.noSubtitles ? nil : eigen
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtitleLanguage.isEmpty ? nil : subtitleLanguage
|
||||||
|
}
|
||||||
|
|
||||||
func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? {
|
func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? {
|
||||||
rememberedTracks[id]
|
rememberedTracks[id]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ final class PlaybackSettings {
|
|||||||
static let cellularQuality = "flaro.playback.quality.cellular"
|
static let cellularQuality = "flaro.playback.quality.cellular"
|
||||||
static let autoSkipIntro = "flaro.playback.autoSkipIntro"
|
static let autoSkipIntro = "flaro.playback.autoSkipIntro"
|
||||||
static let autoSkipCredits = "flaro.playback.autoSkipCredits"
|
static let autoSkipCredits = "flaro.playback.autoSkipCredits"
|
||||||
|
static let skipExtraScenes = "flaro.playback.skipExtraScenes"
|
||||||
static let autoPlayNext = "flaro.playback.autoPlayNext"
|
static let autoPlayNext = "flaro.playback.autoPlayNext"
|
||||||
static let locksLandscape = "flaro.playback.locksLandscape"
|
static let locksLandscape = "flaro.playback.locksLandscape"
|
||||||
static let rewindSeconds = "flaro.playback.rewindSeconds"
|
static let rewindSeconds = "flaro.playback.rewindSeconds"
|
||||||
@@ -58,6 +59,14 @@ final class PlaybackSettings {
|
|||||||
var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
|
var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
|
||||||
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
|
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
|
||||||
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
|
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
|
||||||
|
|
||||||
|
/// Ob „Abspann überspringen" auch über Post-Credit-Szenen hinweggeht.
|
||||||
|
///
|
||||||
|
/// **Eine Gerätesache, keine Servereinstellung.** Ob jemand die Szene
|
||||||
|
/// sehen will, entscheidet er am Gerät, auf dem er sitzt — wie bei
|
||||||
|
/// `autoSkipIntro`. Vorgabe aus: wer den Knopf drückt, landet bei der
|
||||||
|
/// Szene und kann sie wegdrücken, wenn er mag.
|
||||||
|
var skipExtraScenes: Bool { didSet { defaults.set(skipExtraScenes, forKey: Key.skipExtraScenes) } }
|
||||||
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
|
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
|
||||||
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
|
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
|
||||||
|
|
||||||
@@ -117,6 +126,7 @@ final class PlaybackSettings {
|
|||||||
|
|
||||||
self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
|
self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
|
||||||
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false
|
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false
|
||||||
|
self.skipExtraScenes = defaults.object(forKey: Key.skipExtraScenes) as? Bool ?? false
|
||||||
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
|
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
|
||||||
|
|
||||||
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true
|
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ enum TopShelfWriter {
|
|||||||
width: posterWidth,
|
width: posterWidth,
|
||||||
height: posterHeight
|
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
|
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
|
||||||
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
|
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
|
||||||
// Fehler bei sich.
|
// Fehler bei sich.
|
||||||
if item.watchProgress != nil {
|
if item.isResumable {
|
||||||
Button {
|
Button {
|
||||||
run { try await $0.removeFromContinueWatching(item) }
|
run { try await $0.removeFromContinueWatching(item) }
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -17,14 +17,18 @@ import MediaKit
|
|||||||
/// und wäre für VoiceOver gar nicht mehr da.
|
/// und wäre für VoiceOver gar nicht mehr da.
|
||||||
struct QuickPlayTile<Card: View>: View {
|
struct QuickPlayTile<Card: View>: View {
|
||||||
let item: MediaItem
|
let item: MediaItem
|
||||||
/// Ob ein Tipp auf die Detailseite führt, statt abzuspielen.
|
/// Ob ein Tipp sofort abspielt, statt auf die Detailseite zu führen.
|
||||||
///
|
///
|
||||||
/// **Für die Suche.** Dort weiß man gerade *nicht*, was man sehen will —
|
/// **Nur beim Weiterschauen.** Dort ist die Absicht eindeutig: der Titel
|
||||||
/// sonst hätte man nicht gesucht. Ein Tipp, der sofort abspielt, ist auf
|
/// steht in dieser Reihe, *weil* man ihn angefangen hat.
|
||||||
/// der Startseite richtig (die Kachel stand schon da, man kennt sie) und
|
///
|
||||||
/// in einer Trefferliste falsch: der erste Treffer ist eine Vermutung des
|
/// **Überall sonst führt der Tipp auf die Seite.** Vorher war es
|
||||||
/// Servers, keine Entscheidung des Nutzers.
|
/// umgekehrt, mit dem Argument „wer eine Kachel antippt, weiss meistens
|
||||||
var opensDetails: Bool = false
|
/// schon, was er sehen will". Im Gebrauch stimmte das nicht: wer nur
|
||||||
|
/// nachsehen will, wie viele Staffeln eine Serie hat, bekam den Film.
|
||||||
|
/// Ein Umweg von einem Schritt ist ärgerlich; eine Wiedergabe, die man
|
||||||
|
/// nicht wollte, ist schlimmer.
|
||||||
|
var startsPlayback: Bool = false
|
||||||
@ViewBuilder let card: () -> Card
|
@ViewBuilder let card: () -> Card
|
||||||
|
|
||||||
@Environment(FlaroSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@@ -57,7 +61,11 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
/// Ob es überhaupt etwas zu starten gibt.
|
/// Ob es überhaupt etwas zu starten gibt.
|
||||||
///
|
///
|
||||||
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
|
/// 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) }
|
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
|
/// Drei Gründe, derselbe Ausgang: am Mac ist der Klick seit jeher der Weg
|
||||||
/// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine
|
/// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine
|
||||||
/// Sammlung hat nichts abzuspielen, und in der Suche ist es gewollt.
|
/// Sammlung hat nichts abzuspielen, und überall ausser beim
|
||||||
private var tapOpensDetails: Bool { isMac || opensDetails || !isPlayable }
|
/// Weiterschauen ist die Seite das Ziel.
|
||||||
|
private var tapOpensDetails: Bool { isMac || !startsPlayback || !isPlayable }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
kachel
|
kachel
|
||||||
@@ -80,6 +89,11 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
.overlay(alignment: .topTrailing) {
|
.overlay(alignment: .topTrailing) {
|
||||||
if isPlayable, isMac { playButton }
|
if isPlayable, isMac { playButton }
|
||||||
}
|
}
|
||||||
|
// **Links, weil rechts schon etwas sitzt** — am Mac der Abspielknopf
|
||||||
|
// beim Überfahren, unten der Fortschrittsbalken.
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
if Heruntergeladenes.liegtBereit(item.id, in: downloads) { downloadMarke }
|
||||||
|
}
|
||||||
.onContinuousHover { phase in
|
.onContinuousHover { phase in
|
||||||
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
|
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
|
||||||
// der iOS-Bau und läuft trotzdem auf dem Mac.
|
// der iOS-Bau und läuft trotzdem auf dem Mac.
|
||||||
@@ -95,11 +109,9 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
// Kachel steht.
|
// Kachel steht.
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if isPlayable {
|
if isPlayable {
|
||||||
// **Am Telefon und am iPad führt der Weg zur Detailseite
|
// **Nur dort, wo der Tipp abspielt.** Sonst führt er
|
||||||
// hierher.** Antippen spielt dort ab; wer stattdessen
|
// schon auf die Seite, und ein zweiter Weg dorthin im Menü
|
||||||
// nachsehen will, was das überhaupt ist, drückt lang. Am Mac
|
// wäre ein Eintrag, der nichts Neues anbietet.
|
||||||
// ist es umgekehrt: dort öffnet der Klick die Seite, und zum
|
|
||||||
// Starten gibt es den Knopf beim Überfahren.
|
|
||||||
if !tapOpensDetails {
|
if !tapOpensDetails {
|
||||||
Button("hero.details", systemImage: "info.circle") { push(route) }
|
Button("hero.details", systemImage: "info.circle") { push(route) }
|
||||||
Divider()
|
Divider()
|
||||||
@@ -195,14 +207,11 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
|
|
||||||
/// Die Kachel selbst — und was ein Antippen bedeutet.
|
/// Die Kachel selbst — und was ein Antippen bedeutet.
|
||||||
///
|
///
|
||||||
/// **Am Telefon und am iPad spielt es ab.** Wer eine Kachel antippt, weiss
|
/// **Der Tipp führt auf die Detailseite**, am Telefon wie am iPad wie am
|
||||||
/// meistens schon, was er sehen will; der Umweg über die Detailseite
|
/// Mac. Nur in „Weiterschauen" spielt er ab: dort steht der Titel,
|
||||||
/// kostete zwei Schritte für eine Absicht. Die Seite ist weiter da — über
|
/// *weil* man ihn angefangen hat. Abspielen ist sonst einen Schritt
|
||||||
/// langes Drücken, wo auch „gesehen" und „entfernen" stehen.
|
/// weiter — über den Knopf auf der Seite, über langes Drücken, oder am
|
||||||
///
|
/// Mac über den Knopf beim Überfahren.
|
||||||
/// **Am Mac bleibt der Klick die Detailseite.** Dort gibt es den
|
|
||||||
/// Abspielknopf beim Überfahren, und langes Drücken ist keine übliche
|
|
||||||
/// Geste — ein Rechtsklick ist der Weg ins Menü, nicht in den Film.
|
|
||||||
///
|
///
|
||||||
/// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg
|
/// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg
|
||||||
/// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im
|
/// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im
|
||||||
@@ -243,6 +252,20 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Das Zeichen für „liegt auf diesem Gerät".
|
||||||
|
///
|
||||||
|
/// **Damit man es sieht, ohne danach zu suchen.** Bisher stand ein
|
||||||
|
/// Download nur im Downloads-Bereich; in der Mediathek sah eine
|
||||||
|
/// heruntergeladene Folge aus wie jede andere, und wer wissen wollte,
|
||||||
|
/// was im Flugzeug läuft, musste zwei Listen vergleichen.
|
||||||
|
private var downloadMarke: some View {
|
||||||
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.white, Color.black.opacity(0.55))
|
||||||
|
.padding(FlaroSpacing.snug)
|
||||||
|
.accessibilityLabel(Text("downloads.available"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist.
|
/// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist.
|
||||||
///
|
///
|
||||||
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
|
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
|
||||||
|
|||||||
@@ -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 SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die Detailseite eines Titels.
|
/// Die Detailseite eines Titels.
|
||||||
@@ -11,6 +12,7 @@ struct MediaDetailView: View {
|
|||||||
let route: MediaRoute
|
let route: MediaRoute
|
||||||
|
|
||||||
@Environment(FlaroSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@State private var state: LoadState<MediaItem> = .loading
|
@State private var state: LoadState<MediaItem> = .loading
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -50,10 +52,44 @@ struct MediaDetailView: View {
|
|||||||
.task(id: route.ratingKey) { await load() }
|
.task(id: route.ratingKey) { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holt den Titel — vom Server, und sonst vom Gerät.
|
||||||
|
///
|
||||||
|
/// **Sonst führte ein Download ins Leere.** Ausserhalb von
|
||||||
|
/// „Fortsetzen" öffnet ein Tipp auf eine Kachel diese Seite; ohne
|
||||||
|
/// Server endete sie bei einem Fehlerbild, und die Datei, die daneben
|
||||||
|
/// auf der Platte lag, war von hier aus nicht mehr zu erreichen.
|
||||||
|
///
|
||||||
|
/// Der Server bleibt massgeblich: nur wenn er nicht antwortet, tritt
|
||||||
|
/// das Wenige ein, was ein Download über sich selbst weiss.
|
||||||
private func load() async {
|
private func load() async {
|
||||||
guard let server = session.backend else { return }
|
guard let server = session.backend else {
|
||||||
|
state = vomGeraet.map { .loaded($0) }
|
||||||
|
?? .failed(message: String(localized: "error.serverUnreachable"))
|
||||||
|
return
|
||||||
|
}
|
||||||
state = await LoadState.load {
|
state = await LoadState.load {
|
||||||
try await server.item(id: route.ratingKey)
|
try await server.item(id: route.ratingKey)
|
||||||
}
|
}
|
||||||
|
if case .failed(let message) = state {
|
||||||
|
state = vomGeraet.map { .loaded($0) } ?? .failed(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was über diesen Titel auf dem Gerät liegt.
|
||||||
|
///
|
||||||
|
/// **Auch eine Serie**, obwohl keine Serie je heruntergeladen wird: in
|
||||||
|
/// der Downloadreihe steht sie als eine Kachel, und ein Tipp darauf
|
||||||
|
/// führt hierher. Ohne diesen Zweig endete er ohne Verbindung im
|
||||||
|
/// Nichts.
|
||||||
|
private var vomGeraet: MediaItem? {
|
||||||
|
if let eintrag = downloads.items.first(
|
||||||
|
where: { $0.isComplete && $0.ratingKey == route.ratingKey }
|
||||||
|
) {
|
||||||
|
return Heruntergeladenes.titel(eintrag, in: downloads)
|
||||||
|
}
|
||||||
|
guard downloads.items.contains(
|
||||||
|
where: { $0.isComplete && $0.seriesKey == route.ratingKey }
|
||||||
|
) else { return nil }
|
||||||
|
return Heruntergeladenes.serie(route.ratingKey, in: downloads)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
||||||
/// einzelne Folge.
|
/// einzelne Folge.
|
||||||
@@ -67,6 +68,41 @@ struct PlayableDetailView: View {
|
|||||||
return item.versions[selectedVersion]
|
return item.versions[selectedVersion]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Welche Sprachen in dieser Datei liegen.
|
||||||
|
///
|
||||||
|
/// **Kein Serverweg nötig.** Bei einem Film ist die Datei der Titel; ihre
|
||||||
|
/// Spuren stehen in der Antwort, die diese Seite ohnehin geholt hat. Nur
|
||||||
|
/// bei einer Serie muss gerechnet werden, und dafür gibt es den
|
||||||
|
/// Endpunkt.
|
||||||
|
private var sprachen: AvailableLanguages? {
|
||||||
|
guard let part = currentMedia?.files.first else { return nil }
|
||||||
|
|
||||||
|
return AvailableLanguages(
|
||||||
|
audio: optionen(part.audioStreams),
|
||||||
|
subtitles: optionen(part.subtitleStreams),
|
||||||
|
partial: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aus Spuren werden Sprachen — jede einmal, auch bei zwei Tonspuren.
|
||||||
|
private func optionen(_ spuren: [MediaStream]) -> [LanguageOption] {
|
||||||
|
var gesehen = Set<String>()
|
||||||
|
var liste: [LanguageOption] = []
|
||||||
|
|
||||||
|
for spur in spuren {
|
||||||
|
guard let roh = spur.languageCode ?? spur.language,
|
||||||
|
let code = LanguageCode.normalized(roh),
|
||||||
|
gesehen.insert(code).inserted
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
let name = Locale.current.localizedString(forLanguageCode: code)?.capitalized
|
||||||
|
?? spur.language
|
||||||
|
?? code.uppercased()
|
||||||
|
liste.append(LanguageOption(code: code, name: name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return liste
|
||||||
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HeroBanner(
|
HeroBanner(
|
||||||
title: item.rowTitle,
|
title: item.rowTitle,
|
||||||
@@ -97,11 +133,16 @@ struct PlayableDetailView: View {
|
|||||||
// Karussell auf dem Telefon quer ist.
|
// Karussell auf dem Telefon quer ist.
|
||||||
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
|
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
|
||||||
) {
|
) {
|
||||||
|
// 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 {
|
Button {
|
||||||
playback.play(item, mediaIndex: selectedVersion)
|
playback.play(item, mediaIndex: selectedVersion)
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
item.watchProgress == nil ? "hero.play" : "hero.resume",
|
item.isResumable ? "hero.resume" : "hero.play",
|
||||||
systemImage: "play.fill"
|
systemImage: "play.fill"
|
||||||
)
|
)
|
||||||
.font(.flaroCardTitle)
|
.font(.flaroCardTitle)
|
||||||
@@ -112,7 +153,9 @@ struct PlayableDetailView: View {
|
|||||||
.tint(accent)
|
.tint(accent)
|
||||||
.disabled(currentMedia?.files.first == nil)
|
.disabled(currentMedia?.files.first == nil)
|
||||||
|
|
||||||
if let progress = item.watchProgress, progress > 0 {
|
// „Von vorn" nur, wenn es ein „weiter" gibt — dieselbe
|
||||||
|
// Entscheidung wie beim Knopf daneben.
|
||||||
|
if item.isResumable {
|
||||||
Button {
|
Button {
|
||||||
playback.play(item, mediaIndex: selectedVersion, resuming: false)
|
playback.play(item, mediaIndex: selectedVersion, resuming: false)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -126,6 +169,40 @@ struct PlayableDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DownloadButton(item: item, mediaIndex: selectedVersion)
|
DownloadButton(item: item, mediaIndex: selectedVersion)
|
||||||
|
|
||||||
|
// **Die Sprache vor dem Start, nicht erst mitten im Film.**
|
||||||
|
//
|
||||||
|
// Im Spieler umzustellen heisst, den Strom neu aufzubauen —
|
||||||
|
// und bei einem Film, den man immer im Original sehen will,
|
||||||
|
// jedes Mal von Neuem. Welche Spuren es gibt, steht hier
|
||||||
|
// ohnehin schon, also gehört die Wahl neben den Knopf, der
|
||||||
|
// sie anwendet.
|
||||||
|
//
|
||||||
|
// **Nur bei einem Film.** Bei einer Folge gilt, was für die
|
||||||
|
// Serie eingestellt ist; dieselbe Frage zwanzigmal zu
|
||||||
|
// stellen wäre keine Wahl, sondern eine Prüfung der Geduld.
|
||||||
|
if item.kind != .episode, let angebot = sprachen {
|
||||||
|
if !angebot.audio.isEmpty {
|
||||||
|
AudioLanguagePicker(
|
||||||
|
scope: .series(item.id),
|
||||||
|
title: "languages.audioShort",
|
||||||
|
kind: .audio,
|
||||||
|
form: .knopf,
|
||||||
|
available: angebot.audio
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !angebot.subtitles.isEmpty {
|
||||||
|
AudioLanguagePicker(
|
||||||
|
scope: .series(item.id),
|
||||||
|
title: "languages.subtitlesShort",
|
||||||
|
kind: .subtitles,
|
||||||
|
form: .knopf,
|
||||||
|
available: angebot.subtitles
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ struct SeriesDetailView: View {
|
|||||||
@State private var isWorking = false
|
@State private var isWorking = false
|
||||||
@State private var summary: String?
|
@State private var summary: String?
|
||||||
|
|
||||||
|
/// Welche Staffel die Liste zeigt — `nil` heisst: die erste.
|
||||||
|
///
|
||||||
|
/// **Weil der Server die Folgen am Stück liefert.** Eine Serie mit zwei
|
||||||
|
/// Staffeln ergab eine Liste, in der nach Folge 8 wieder eine Folge 1
|
||||||
|
/// kam, ohne dass irgendwo stünde, dass dazwischen eine Staffel endet.
|
||||||
|
@State private var gewaehlteStaffel: Int?
|
||||||
|
|
||||||
|
/// Welche Sprachen diese Serie durchgehend führt — `nil`, solange es
|
||||||
|
/// niemand weiss.
|
||||||
|
@State private var sprachen: AvailableLanguages?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
@@ -54,11 +65,35 @@ struct SeriesDetailView: View {
|
|||||||
// dieselbe Einstellung zu haben, von denen einer nie gelesen
|
// dieselbe Einstellung zu haben, von denen einer nie gelesen
|
||||||
// wird.
|
// wird.
|
||||||
if item.kind == .show {
|
if item.kind == .show {
|
||||||
|
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||||
AudioLanguagePicker(
|
AudioLanguagePicker(
|
||||||
scope: .series(item.id),
|
scope: .series(item.id),
|
||||||
title: "languages.forThisSeries"
|
title: "languages.forThisSeries",
|
||||||
|
kind: .audio,
|
||||||
|
available: sprachen?.audio
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AudioLanguagePicker(
|
||||||
|
scope: .series(item.id),
|
||||||
|
title: "languages.subtitlesForThisSeries",
|
||||||
|
kind: .subtitles,
|
||||||
|
available: sprachen?.subtitles
|
||||||
|
)
|
||||||
|
|
||||||
|
// **Was fehlt, wird gesagt.** Eine gekürzte Liste
|
||||||
|
// sieht sonst aus wie die ganze Wahrheit, und wer
|
||||||
|
// eine Sprache vermisst, die er in Staffel 1 gesehen
|
||||||
|
// hat, hält es für einen Fehler.
|
||||||
|
if let partial = sprachen?.partial, partial > 0 {
|
||||||
|
Text("languages.partial \(partial)")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
.task(id: item.id) {
|
||||||
|
sprachen = await session.backend?.availableLanguages(inSeries: item.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
childList
|
childList
|
||||||
@@ -172,23 +207,33 @@ struct SeriesDetailView: View {
|
|||||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
|
||||||
case .loaded(let entries):
|
case .loaded(let entries):
|
||||||
|
let nummern = staffelnummern(in: entries)
|
||||||
|
let sichtbar = sichtbareFolgen(in: entries, staffeln: nummern)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
|
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
|
||||||
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
|
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
|
||||||
// als von allem anderen auf dieser Seite.
|
// als von allem anderen auf dieser Seite.
|
||||||
HStack {
|
HStack {
|
||||||
|
if nummern.count > 1 {
|
||||||
|
staffelwahl(nummern)
|
||||||
|
} else {
|
||||||
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
||||||
.font(.flaroRowHeader)
|
.font(.flaroRowHeader)
|
||||||
.foregroundStyle(Color.flaroTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if entries.contains(where: { $0.kind == .episode }) {
|
if sichtbar.contains(where: { $0.kind == .episode }) {
|
||||||
selectionControls(for: entries)
|
// **Nur die gezeigte Staffel.** „Alle auswählen" soll
|
||||||
|
// das meinen, was dasteht — sonst lädt ein Griff
|
||||||
|
// sechzehn Folgen herunter statt acht.
|
||||||
|
selectionControls(for: sichtbar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(entries) { entry in
|
ForEach(sichtbar) { entry in
|
||||||
row(for: entry)
|
row(for: entry)
|
||||||
Divider().overlay(Color.flaroSeparator)
|
Divider().overlay(Color.flaroSeparator)
|
||||||
}
|
}
|
||||||
@@ -197,6 +242,55 @@ struct SeriesDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Welche Staffeln in dieser Liste vorkommen.
|
||||||
|
///
|
||||||
|
/// Leer, wenn die Einträge selbst Staffeln sind — dann gibt es nichts zu
|
||||||
|
/// wählen, die Liste **ist** die Wahl.
|
||||||
|
private func staffelnummern(in entries: [MediaItem]) -> [Int] {
|
||||||
|
Set(entries.compactMap { $0.kind == .episode ? $0.parentIndex : nil }).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sichtbareFolgen(in entries: [MediaItem], staffeln: [Int]) -> [MediaItem] {
|
||||||
|
guard staffeln.count > 1 else { return entries }
|
||||||
|
let gewaehlt = gewaehlteStaffel ?? staffeln[0]
|
||||||
|
return entries.filter { $0.kind != .episode || $0.parentIndex == gewaehlt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Staffel als Menü — an der Stelle, an der sonst „Folgen" steht.
|
||||||
|
///
|
||||||
|
/// **Kein `Picker`.** Diese Seite ist eine `ScrollView`, kein `Form`;
|
||||||
|
/// dort hat ein `Picker` kein Aussehen, auf das man sich verlassen kann.
|
||||||
|
/// Dieselbe Begründung wie bei `AudioLanguagePicker`.
|
||||||
|
private func staffelwahl(_ nummern: [Int]) -> some View {
|
||||||
|
let gewaehlt = gewaehlteStaffel ?? nummern[0]
|
||||||
|
|
||||||
|
return Menu {
|
||||||
|
ForEach(nummern, id: \.self) { nummer in
|
||||||
|
Button {
|
||||||
|
gewaehlteStaffel = nummer
|
||||||
|
// Eine Auswahl aus der vorigen Staffel meint hier nichts
|
||||||
|
// mehr — stehen zu lassen, was man nicht mehr sieht,
|
||||||
|
// lädt unsichtbare Folgen herunter.
|
||||||
|
selection.removeAll()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
String(localized: "season.short \(nummer)"),
|
||||||
|
systemImage: nummer == gewaehlt ? "checkmark" : ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: FlaroSpacing.tight) {
|
||||||
|
Text("season.short \(gewaehlt)")
|
||||||
|
.font(.flaroRowHeader)
|
||||||
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen.
|
/// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func row(for entry: MediaItem) -> some View {
|
private func row(for entry: MediaItem) -> some View {
|
||||||
@@ -376,11 +470,31 @@ struct SeriesDetailView: View {
|
|||||||
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holt die Staffeln oder Folgen — und sonst, was auf dem Gerät liegt.
|
||||||
|
///
|
||||||
|
/// **Sonst wäre die Seite einer heruntergeladenen Serie leer.** Sie ist
|
||||||
|
/// das Ziel, auf das die Downloadreihe zeigt; dort anzukommen und einen
|
||||||
|
/// Fehler zu lesen, während die Folgen daneben auf der Platte liegen,
|
||||||
|
/// wäre die Umkehrung dessen, wofür man sie geladen hat.
|
||||||
private func loadChildren() async {
|
private func loadChildren() async {
|
||||||
guard let server = session.backend else { return }
|
guard let server = session.backend else {
|
||||||
|
children = vomGeraet.isEmpty
|
||||||
|
? .failed(message: String(localized: "error.serverUnreachable"))
|
||||||
|
: .loaded(vomGeraet)
|
||||||
|
return
|
||||||
|
}
|
||||||
children = await LoadState.load {
|
children = await LoadState.load {
|
||||||
try await server.children(of: item.id)
|
try await server.children(of: item.id)
|
||||||
}
|
}
|
||||||
|
if case .failed = children, !vomGeraet.isEmpty {
|
||||||
|
children = .loaded(vomGeraet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Folgen dieser Serie, die auf dem Gerät liegen.
|
||||||
|
private var vomGeraet: [MediaItem] {
|
||||||
|
guard item.kind == .show else { return [] }
|
||||||
|
return Heruntergeladenes.folgen(derSerie: item.id, in: downloads)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +517,7 @@ private struct ChildRow: View {
|
|||||||
.frame(width: item.kind == .season ? 48 : 96)
|
.frame(width: item.kind == .season ? 48 : 96)
|
||||||
.clipShape(.rect(cornerRadius: 4))
|
.clipShape(.rect(cornerRadius: 4))
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
if let progress = item.watchProgress {
|
if let progress = item.resumeProgress {
|
||||||
WatchProgressBar(fraction: progress)
|
WatchProgressBar(fraction: progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,12 +47,18 @@ struct HeroSection: View {
|
|||||||
// Bildschirm leer.
|
// Bildschirm leer.
|
||||||
fillsAvailableSpace: sizeClass != .compact
|
fillsAvailableSpace: sizeClass != .compact
|
||||||
) {
|
) {
|
||||||
|
// Geplant heisst sichtbar, nicht abspielbar — statt des Knopfes
|
||||||
|
// der Termin.
|
||||||
|
if item.isUpcoming, let ab = item.availableFrom {
|
||||||
|
UpcomingNotice(date: ab)
|
||||||
|
} else {
|
||||||
Button {
|
Button {
|
||||||
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher
|
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
||||||
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert
|
// Aufmacher eine Serie — und Jellyfins „Zuletzt
|
||||||
// neue Folgen als ihre Serie —, schickte der direkte Weg
|
// hinzugefügt" liefert neue Folgen als ihre Serie —,
|
||||||
// deren eigene Kennung an den Player, und der scheiterte mit
|
// schickte der direkte Weg deren eigene Kennung an den
|
||||||
// „keine abspielbare Datei". Eine Serie hat keine Fassungen.
|
// Player, und der scheiterte mit „keine abspielbare
|
||||||
|
// Datei". Eine Serie hat keine Fassungen.
|
||||||
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
||||||
} label: {
|
} label: {
|
||||||
Label(playLabel, systemImage: "play.fill")
|
Label(playLabel, systemImage: "play.fill")
|
||||||
@@ -62,6 +68,7 @@ struct HeroSection: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
|
}
|
||||||
|
|
||||||
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
||||||
Label("hero.details", systemImage: "info.circle")
|
Label("hero.details", systemImage: "info.circle")
|
||||||
@@ -77,7 +84,7 @@ struct HeroSection: View {
|
|||||||
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
||||||
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
||||||
private var playLabel: LocalizedStringKey {
|
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) }
|
private var metadata: String? { Self.metadata(for: item) }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
||||||
@@ -11,15 +12,26 @@ import MediaKit
|
|||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@Environment(FlaroSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(HomeLayoutSettings.self) private var layout
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@Environment(Fortschrittspost.self) private var post
|
||||||
@State private var feed = HomeFeed()
|
@State private var feed = HomeFeed()
|
||||||
|
|
||||||
|
/// Was auf dem Gerät liegt, als Reihe ganz oben.
|
||||||
|
///
|
||||||
|
/// Wird bei jeder Änderung an den Downloads neu berechnet, weil
|
||||||
|
/// `DownloadCoordinator` beobachtet wird — eine fertige Folge steht
|
||||||
|
/// ohne Neuladen der Startseite da.
|
||||||
|
private var heruntergeladen: [MediaItem] {
|
||||||
|
Heruntergeladenes.reihe(in: downloads)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
switch feed.state {
|
switch feed.state {
|
||||||
case .loading:
|
case .loading:
|
||||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
case .failed(let message):
|
case .failed(let message) where heruntergeladen.isEmpty:
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||||
} description: {
|
} description: {
|
||||||
@@ -28,8 +40,12 @@ struct HomeView: View {
|
|||||||
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
case .loaded:
|
// **Mit Downloads ist „kein Server" kein Fehlerbildschirm.**
|
||||||
let hubs = feed.rows(for: layout)
|
// Was auf dem Gerät liegt, lässt sich auch ohne ihn ansehen —
|
||||||
|
// genau dafür wurde es heruntergeladen. Ein Fehlerbild davor zu
|
||||||
|
// stellen hiesse, dem Nutzer etwas wegzunehmen, das er hat.
|
||||||
|
case .failed, .loaded:
|
||||||
|
let hubs = feed.rows(for: layout, downloaded: heruntergeladen)
|
||||||
if hubs.isEmpty {
|
if hubs.isEmpty {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("home.empty.title", systemImage: "film.stack")
|
Label("home.empty.title", systemImage: "film.stack")
|
||||||
@@ -59,7 +75,18 @@ struct HomeView: View {
|
|||||||
// daneben, und genau diese Kante sah man. Auf den geschobenen
|
// daneben, und genau diese Kante sah man. Auf den geschobenen
|
||||||
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
|
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
.task(id: session.serverName) { await feed.load(from: session.backend) }
|
.task(id: session.serverName) {
|
||||||
|
await feed.load(from: session.backend)
|
||||||
|
// Siehe `DownloadService.plakateNachholen`: ältere Downloads
|
||||||
|
// haben kein Bild, und hier ist die Stelle, an der man es merken
|
||||||
|
// würde.
|
||||||
|
await DownloadService(backend: session.backend, coordinator: downloads)
|
||||||
|
.plakateNachholen()
|
||||||
|
// **Der Server hat geantwortet — dann nimmt er auch Post an.**
|
||||||
|
// Der Start der App ist dafür der falsche Zeitpunkt: da ist oft
|
||||||
|
// noch gar keine Verbindung.
|
||||||
|
if !feed.ausDemSpeicher { await post.zustellen(über: session.backend) }
|
||||||
|
}
|
||||||
.refreshable { await feed.load(from: session.backend) }
|
.refreshable { await feed.load(from: session.backend) }
|
||||||
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
|
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
|
||||||
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
|
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
|
||||||
@@ -69,13 +96,22 @@ struct HomeView: View {
|
|||||||
private func content(hubs: [MediaRowGroup]) -> some View {
|
private func content(hubs: [MediaRowGroup]) -> some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
|
if ohneServer {
|
||||||
|
offlinehinweis
|
||||||
|
} else {
|
||||||
let featured = feed.heroItems(for: layout)
|
let featured = feed.heroItems(for: layout)
|
||||||
if !featured.isEmpty {
|
if !featured.isEmpty {
|
||||||
HeroCarousel(items: featured)
|
HeroCarousel(items: featured)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ForEach(hubs) { hub in
|
ForEach(hubs) { hub in
|
||||||
MediaRow(row: hub)
|
MediaRow(row: hub)
|
||||||
|
// **Ausgegraut, aber nicht gesperrt.** Was hier steht,
|
||||||
|
// stammt vom letzten Mal; antippen darf man es
|
||||||
|
// trotzdem — dann kommt ein Satz, der sagt warum, und
|
||||||
|
// das ist ehrlicher als ein Knopf, der nichts tut.
|
||||||
|
.opacity(ohneServer && hub.id != HomeFeed.downloadsRow ? 0.4 : 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, FlaroSpacing.section)
|
.padding(.bottom, FlaroSpacing.section)
|
||||||
@@ -83,4 +119,36 @@ struct HomeView: View {
|
|||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
.flaroRevealsTopChrome()
|
.flaroRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ob gerade kein Server antwortet.
|
||||||
|
///
|
||||||
|
/// Zwei Wege führen hierher: gar nichts geladen, oder ein Abzug vom
|
||||||
|
/// letzten Mal. Für den Nutzer ist es dieselbe Lage.
|
||||||
|
private var ohneServer: Bool {
|
||||||
|
if case .failed = feed.state { return true }
|
||||||
|
return feed.ausDemSpeicher
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statt des Aufmachers, wenn der Server fehlt.
|
||||||
|
///
|
||||||
|
/// **An seiner Stelle, nicht darüber.** Ein grosses Bild vom letzten Mal
|
||||||
|
/// über einer Startseite, die nicht stimmt, wäre die falsche Betonung —
|
||||||
|
/// und die Downloads sollen oben stehen.
|
||||||
|
private var offlinehinweis: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: FlaroSpacing.regular) {
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
Text("home.offline")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(FlaroSpacing.regular)
|
||||||
|
.background(Color.flaroElevated, in: .rect(cornerRadius: 12))
|
||||||
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Eine waagerechte Reihe von Titeln.
|
/// Eine waagerechte Reihe von Titeln.
|
||||||
struct MediaRow: View {
|
struct MediaRow: View {
|
||||||
let row: MediaRowGroup
|
let row: MediaRowGroup
|
||||||
/// Wird durchgereicht — siehe `QuickPlayTile`.
|
/// Ob ein Tipp sofort abspielt — siehe `QuickPlayTile`.
|
||||||
var opensDetails: Bool = false
|
///
|
||||||
|
/// **Die Reihe weiss es, nicht die Kachel.** „Weiterschauen" ist eine
|
||||||
|
/// Eigenschaft der Reihe; eine Kachel kennt nur ihren Titel.
|
||||||
|
private var startsPlayback: Bool { row.isContinueWatching }
|
||||||
|
|
||||||
@Environment(FlaroSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
|
|
||||||
@Environment(HomeLayoutSettings.self) private var layout
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
|
||||||
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
|
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
|
||||||
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
|
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
|
||||||
@@ -32,7 +37,7 @@ struct MediaRow: View {
|
|||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
LazyHStack(spacing: FlaroSpacing.regular) {
|
LazyHStack(spacing: FlaroSpacing.regular) {
|
||||||
ForEach(row.items) { item in
|
ForEach(row.items) { item in
|
||||||
QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) }
|
QuickPlayTile(item: item, startsPlayback: startsPlayback) { card(for: item) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
@@ -55,27 +60,41 @@ struct MediaRow: View {
|
|||||||
if row.isContinueWatching {
|
if row.isContinueWatching {
|
||||||
LandscapeCard(
|
LandscapeCard(
|
||||||
title: item.rowTitle,
|
title: item.rowTitle,
|
||||||
subtitle: item.cardSubtitle,
|
subtitle: untertitel(for: item),
|
||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
|
||||||
path: item.rowArtworkPath,
|
path: item.rowArtworkPath,
|
||||||
width: 440,
|
width: 440,
|
||||||
height: 248
|
height: 248
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
.frame(width: landscapeWidth)
|
.frame(width: landscapeWidth)
|
||||||
} else {
|
} else {
|
||||||
PosterCard(
|
PosterCard(
|
||||||
title: item.rowTitle,
|
title: item.rowTitle,
|
||||||
subtitle: item.cardSubtitle,
|
subtitle: untertitel(for: item),
|
||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
|
||||||
path: item.posterArtworkPath,
|
path: item.posterArtworkPath,
|
||||||
width: 300,
|
width: 300,
|
||||||
height: 450
|
height: 450
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
.frame(width: posterWidth)
|
.frame(width: posterWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was unter der Kachel steht.
|
||||||
|
///
|
||||||
|
/// **In der Downloadreihe zählt, wie viel da ist.** Eine Serie steht
|
||||||
|
/// dort als eine Kachel; „2019" darunter beantwortet nicht die Frage,
|
||||||
|
/// die man an dieser Stelle hat — nämlich wie viele Folgen man dabei
|
||||||
|
/// hat.
|
||||||
|
private func untertitel(for item: MediaItem) -> String? {
|
||||||
|
if row.id == HomeFeed.downloadsRow,
|
||||||
|
let zahl = Heruntergeladenes.untertitel(für: item, in: downloads) {
|
||||||
|
return zahl
|
||||||
|
}
|
||||||
|
return item.cardSubtitle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import MediaKit
|
|||||||
/// Filter und Sortierung einer Mediathek.
|
/// Filter und Sortierung einer Mediathek.
|
||||||
struct LibraryFilterSheet: View {
|
struct LibraryFilterSheet: View {
|
||||||
@Binding var query: MediaQuery
|
@Binding var query: MediaQuery
|
||||||
|
/// Steht getrennt von `query`, weil der Server ihn nicht auswerten kann —
|
||||||
|
/// siehe `LibraryView.nurHeruntergeladene`.
|
||||||
|
@Binding var nurHeruntergeladene: Bool
|
||||||
/// Die Mediathek selbst, nicht nur ihre Art.
|
/// Die Mediathek selbst, nicht nur ihre Art.
|
||||||
///
|
///
|
||||||
/// Vorher stand hier die Art, und die Genres wurden über *irgendeine*
|
/// Vorher stand hier die Art, und die Genres wurden über *irgendeine*
|
||||||
@@ -23,11 +26,18 @@ struct LibraryFilterSheet: View {
|
|||||||
/// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus —
|
/// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus —
|
||||||
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
|
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
|
||||||
@State private var draft: MediaQuery
|
@State private var draft: MediaQuery
|
||||||
|
@State private var entwurfHeruntergeladene: Bool
|
||||||
|
|
||||||
init(query: Binding<MediaQuery>, library: MediaLibrary) {
|
init(
|
||||||
|
query: Binding<MediaQuery>,
|
||||||
|
nurHeruntergeladene: Binding<Bool>,
|
||||||
|
library: MediaLibrary
|
||||||
|
) {
|
||||||
self._query = query
|
self._query = query
|
||||||
|
self._nurHeruntergeladene = nurHeruntergeladene
|
||||||
self.library = library
|
self.library = library
|
||||||
self._draft = State(initialValue: query.wrappedValue)
|
self._draft = State(initialValue: query.wrappedValue)
|
||||||
|
self._entwurfHeruntergeladene = State(initialValue: nurHeruntergeladene.wrappedValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -45,6 +55,7 @@ struct LibraryFilterSheet: View {
|
|||||||
|
|
||||||
Section("filter.filters") {
|
Section("filter.filters") {
|
||||||
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
|
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
|
||||||
|
Toggle("filter.downloadedOnly", isOn: $entwurfHeruntergeladene)
|
||||||
|
|
||||||
Picker("filter.genre", selection: $draft.genre) {
|
Picker("filter.genre", selection: $draft.genre) {
|
||||||
Text("filter.any").tag(String?.none)
|
Text("filter.any").tag(String?.none)
|
||||||
@@ -80,8 +91,9 @@ struct LibraryFilterSheet: View {
|
|||||||
Section {
|
Section {
|
||||||
Button("filter.reset", role: .destructive) {
|
Button("filter.reset", role: .destructive) {
|
||||||
draft = MediaQuery(sort: draft.sort)
|
draft = MediaQuery(sort: draft.sort)
|
||||||
|
entwurfHeruntergeladene = false
|
||||||
}
|
}
|
||||||
.disabled(!draft.isFiltering)
|
.disabled(!draft.isFiltering && !entwurfHeruntergeladene)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ausdrücklich, nicht nur geerbt.
|
// Ausdrücklich, nicht nur geerbt.
|
||||||
@@ -102,6 +114,7 @@ struct LibraryFilterSheet: View {
|
|||||||
}
|
}
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("action.apply") {
|
Button("action.apply") {
|
||||||
|
nurHeruntergeladene = entwurfHeruntergeladene
|
||||||
query = draft
|
query = draft
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Eine Mediathek zum Durchblättern.
|
/// Eine Mediathek zum Durchblättern.
|
||||||
@@ -10,6 +11,7 @@ struct LibraryView: View {
|
|||||||
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
|
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
|
||||||
/// zwischen Raster und Liste.
|
/// zwischen Raster und Liste.
|
||||||
@Environment(HomeLayoutSettings.self) private var sizing
|
@Environment(HomeLayoutSettings.self) private var sizing
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@AppStorage("flaro.library.layout") private var layout: LibraryLayout = .grid
|
@AppStorage("flaro.library.layout") private var layout: LibraryLayout = .grid
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
@@ -25,6 +27,14 @@ struct LibraryView: View {
|
|||||||
@State private var loadMoreFailed = false
|
@State private var loadMoreFailed = false
|
||||||
@State private var isLoadingMore = false
|
@State private var isLoadingMore = false
|
||||||
@State private var showsFilters = false
|
@State private var showsFilters = false
|
||||||
|
/// Ob nur gezeigt wird, was auf diesem Gerät liegt.
|
||||||
|
///
|
||||||
|
/// **Kein Teil von `MediaQuery`**, und das ist der Kern der Sache: die
|
||||||
|
/// Abfrage wertet der Server aus, und der weiss nicht, was auf diesem
|
||||||
|
/// Gerät liegt. Steht der Filter an, wird gar nicht erst gefragt — die
|
||||||
|
/// Liste kommt aus den Downloads. Das ist genau, vollständig, und
|
||||||
|
/// funktioniert ohne Verbindung.
|
||||||
|
@State private var nurHeruntergeladene = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -48,8 +58,9 @@ struct LibraryView: View {
|
|||||||
} description: {
|
} description: {
|
||||||
Text("library.empty.description")
|
Text("library.empty.description")
|
||||||
} actions: {
|
} actions: {
|
||||||
if query.isFiltering {
|
if query.isFiltering || nurHeruntergeladene {
|
||||||
Button("library.clearFilters") {
|
Button("library.clearFilters") {
|
||||||
|
nurHeruntergeladene = false
|
||||||
query = MediaQuery(sort: query.sort)
|
query = MediaQuery(sort: query.sort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,7 +75,11 @@ struct LibraryView: View {
|
|||||||
.flaroInlineTitle()
|
.flaroInlineTitle()
|
||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
.sheet(isPresented: $showsFilters) {
|
.sheet(isPresented: $showsFilters) {
|
||||||
LibraryFilterSheet(query: $query, library: library)
|
LibraryFilterSheet(
|
||||||
|
query: $query,
|
||||||
|
nurHeruntergeladene: $nurHeruntergeladene,
|
||||||
|
library: library
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.task(id: library.id) {
|
.task(id: library.id) {
|
||||||
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
|
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
|
||||||
@@ -78,6 +93,11 @@ struct LibraryView: View {
|
|||||||
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
|
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
|
||||||
// verschiedenen Abfragen vermischen.
|
// verschiedenen Abfragen vermischen.
|
||||||
.onChange(of: query) { _, _ in Task { await reload() } }
|
.onChange(of: query) { _, _ in Task { await reload() } }
|
||||||
|
.onChange(of: nurHeruntergeladene) { _, _ in
|
||||||
|
items = []
|
||||||
|
page = nil
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
.refreshable { await reload() }
|
.refreshable { await reload() }
|
||||||
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
|
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
|
||||||
.environment(\.flaroItemChanged, { await reload() })
|
.environment(\.flaroItemChanged, { await reload() })
|
||||||
@@ -104,9 +124,13 @@ struct LibraryView: View {
|
|||||||
QuickPlayTile(item: item) {
|
QuickPlayTile(item: item) {
|
||||||
PosterCard(
|
PosterCard(
|
||||||
title: item.title,
|
title: item.title,
|
||||||
subtitle: item.cardSubtitle,
|
subtitle: nurHeruntergeladene
|
||||||
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
? Heruntergeladenes.untertitel(für: item, in: downloads)
|
||||||
progress: item.watchProgress
|
?? 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) }
|
.task { await loadMoreIfNeeded(reaching: item) }
|
||||||
@@ -232,7 +256,7 @@ struct LibraryView: View {
|
|||||||
Button {
|
Button {
|
||||||
showsFilters = true
|
showsFilters = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: query.isFiltering
|
Image(systemName: query.isFiltering || nurHeruntergeladene
|
||||||
? "line.3.horizontal.decrease.circle.fill"
|
? "line.3.horizontal.decrease.circle.fill"
|
||||||
: "line.3.horizontal.decrease.circle")
|
: "line.3.horizontal.decrease.circle")
|
||||||
}
|
}
|
||||||
@@ -276,6 +300,16 @@ struct LibraryView: View {
|
|||||||
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
|
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
|
||||||
/// Fehlerbildschirm.
|
/// Fehlerbildschirm.
|
||||||
private func reload() async {
|
private func reload() async {
|
||||||
|
// **Vor dem Server, nicht danach.** Was auf dem Gerät liegt, weiss
|
||||||
|
// nur das Gerät — und es weiss es auch im Flugmodus.
|
||||||
|
if nurHeruntergeladene {
|
||||||
|
items = geraeteliste
|
||||||
|
page = MediaPage(items: items, totalCount: items.count)
|
||||||
|
loadMoreFailed = false
|
||||||
|
state = .loaded(())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
guard let server = session.backend else { return }
|
guard let server = session.backend else { return }
|
||||||
let schlüssel = query.cacheKey
|
let schlüssel = query.cacheKey
|
||||||
let kennung = server.identity
|
let kennung = server.identity
|
||||||
@@ -319,6 +353,25 @@ struct LibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Downloads, die in diese Mediathek gehören.
|
||||||
|
///
|
||||||
|
/// **Zugeordnet über die Art der Mediathek**, weil ein Download seine
|
||||||
|
/// Mediathek nicht kennt: er führt einen Serienschlüssel oder keinen,
|
||||||
|
/// und mehr braucht es nicht. Eine Folge gehört in eine Serien-,
|
||||||
|
/// ein Film in eine Filmmediathek.
|
||||||
|
///
|
||||||
|
/// shortcut: zwei Filmmediatheken bekämen beide dieselbe Liste — erst
|
||||||
|
/// wenn `DownloadItem` die Mediathek mitführt, lässt sich das trennen.
|
||||||
|
private var geraeteliste: [MediaItem] {
|
||||||
|
Heruntergeladenes.reihe(in: downloads).filter { titel in
|
||||||
|
switch library.kind {
|
||||||
|
case .movie: titel.kind != .show
|
||||||
|
case .show: titel.kind == .show
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
|
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
|
||||||
private func loadMoreIfNeeded(reaching item: MediaItem) async {
|
private func loadMoreIfNeeded(reaching item: MediaItem) async {
|
||||||
guard
|
guard
|
||||||
@@ -385,7 +438,8 @@ private struct LibraryListRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: FlaroSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
FlaroArtwork(
|
FlaroArtwork(
|
||||||
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
|
url: Heruntergeladenes.bildAdresse(item)
|
||||||
|
?? session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
|
||||||
aspectRatio: PosterCard.aspectRatio
|
aspectRatio: PosterCard.aspectRatio
|
||||||
)
|
)
|
||||||
.frame(width: 44)
|
.frame(width: 44)
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
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
|
/// **Der Buchstabe ist keine Notlösung.** Jellyfin gibt kein Profilbild
|
||||||
/// eines hinterlegt ist, und am Konto führt die App heute keines mit. Ein
|
/// heraus, und auch an einem Flaro-Konto muss keines hängen. Ein Buchstabe
|
||||||
/// Buchstabe ist ehrlicher als eine graue Silhouette, die bei jedem gleich
|
/// ist ehrlicher als eine graue Silhouette, die bei jedem gleich aussieht —
|
||||||
/// aussieht — und man erkennt an ihm wenigstens, mit welchem Konto man
|
/// und man erkennt an ihm wenigstens, mit welchem Konto man angemeldet ist.
|
||||||
/// angemeldet ist.
|
|
||||||
///
|
///
|
||||||
/// Stand bis eben in `AccountBar` und wird jetzt zweimal gebraucht: dort in
|
/// Steht ein Bild im Konto, tritt es an seine Stelle. Lädt es nicht, kommt
|
||||||
/// der Seitenleiste und auf dem iPhone oben rechts, wo seit dem Wegfall des
|
/// der Buchstabe zurück: eine leere Scheibe sagt weniger als ein P.
|
||||||
/// Einstellungen-Tabs der Weg dorthin liegt.
|
///
|
||||||
|
/// 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 {
|
struct AccountAvatar: View {
|
||||||
let name: String
|
let name: String
|
||||||
var diameter: CGFloat = 26
|
var diameter: CGFloat = 26
|
||||||
|
/// Das Bild des Kontos, falls eines hinterlegt ist.
|
||||||
|
var image: URL?
|
||||||
|
|
||||||
@Environment(\.flaroAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
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())
|
Text(name.prefix(1).uppercased())
|
||||||
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
|
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
|
||||||
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
|
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
|
||||||
@@ -26,8 +48,6 @@ struct AccountAvatar: View {
|
|||||||
// Fehler.
|
// Fehler.
|
||||||
.font(.system(size: diameter * 0.46, weight: .semibold))
|
.font(.system(size: diameter * 0.46, weight: .semibold))
|
||||||
.foregroundStyle(Color.flaroCanvas)
|
.foregroundStyle(Color.flaroCanvas)
|
||||||
.frame(width: diameter, height: diameter)
|
|
||||||
.background(accent, in: .circle)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +73,8 @@ struct AccountToolbarButton: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: openSettings) {
|
Button(action: openSettings) {
|
||||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
|
||||||
|
image: session.accountAvatarURL)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(Text("sidebar.settings"))
|
.accessibilityLabel(Text("sidebar.settings"))
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ struct AccountBar: View {
|
|||||||
isExpanded.toggle()
|
isExpanded.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: FlaroSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
AccountAvatar(name: title)
|
AccountAvatar(name: title, image: session.accountAvatarURL)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text(title)
|
Text(title)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct RootView: View {
|
|||||||
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
||||||
@Environment(NetworkConditions.self) private var network
|
@Environment(NetworkConditions.self) private var network
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@Environment(Fortschrittspost.self) private var post
|
||||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||||
|
|
||||||
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
||||||
@@ -229,6 +230,7 @@ struct RootView: View {
|
|||||||
backend: session.backend,
|
backend: session.backend,
|
||||||
liveTV: session.liveTV,
|
liveTV: session.liveTV,
|
||||||
downloads: downloads,
|
downloads: downloads,
|
||||||
|
post: post,
|
||||||
diagnostics: diagnostics,
|
diagnostics: diagnostics,
|
||||||
policy: settings.policy(for: network),
|
policy: settings.policy(for: network),
|
||||||
settings: settings,
|
settings: settings,
|
||||||
@@ -514,7 +516,8 @@ struct RootView: View {
|
|||||||
Button {
|
Button {
|
||||||
showsSettings = true
|
showsSettings = true
|
||||||
} label: {
|
} label: {
|
||||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
|
||||||
|
image: session.accountAvatarURL)
|
||||||
.padding(FlaroSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
// Damit er auch über einem hellen Aufmacher steht.
|
// Damit er auch über einem hellen Aufmacher steht.
|
||||||
.background(.black.opacity(0.28), in: .circle)
|
.background(.black.opacity(0.28), in: .circle)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
|
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
|
||||||
///
|
///
|
||||||
@@ -34,6 +35,11 @@ struct PlaybackScrubber: View {
|
|||||||
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
|
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
|
||||||
let onActivity: () -> Void
|
let onActivity: () -> Void
|
||||||
|
|
||||||
|
/// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
|
||||||
|
var vorschau: Vorschauplan?
|
||||||
|
/// Woher ein Blatt kommt.
|
||||||
|
var blatt: (Int) -> URL? = { _ in nil }
|
||||||
|
|
||||||
@Environment(\.flaroAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
private static let trackHeight: CGFloat = 5
|
private static let trackHeight: CGFloat = 5
|
||||||
@@ -71,6 +77,22 @@ struct PlaybackScrubber: View {
|
|||||||
.offset(x: gespielt - Self.thumbSize / 2)
|
.offset(x: gespielt - Self.thumbSize / 2)
|
||||||
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
|
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
|
||||||
}
|
}
|
||||||
|
// **Die Vorschau steht über dem Griff, nicht darunter.** Unter
|
||||||
|
// dem Finger verdeckte sie genau die Stelle, die man sucht.
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
if let plan = vorschau,
|
||||||
|
let gezogen = scrubbing,
|
||||||
|
let stelle = plan.stelle(bei: gezogen) {
|
||||||
|
Vorschaukachel(plan: plan, stelle: stelle, blatt: blatt)
|
||||||
|
.offset(
|
||||||
|
x: min(
|
||||||
|
breite - plan.kachelBreite,
|
||||||
|
max(0, gespielt - plan.kachelBreite / 2)),
|
||||||
|
y: -Self.hitHeight / 2 - 8)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
.frame(height: Self.hitHeight)
|
.frame(height: Self.hitHeight)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.gesture(
|
.gesture(
|
||||||
@@ -117,3 +139,75 @@ struct PlaybackScrubber: View {
|
|||||||
return min(max(Double(x / width), 0), 1) * duration.seconds
|
return min(max(Double(x / width), 0), 1) * duration.seconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ein Standbild aus dem Film, ausgeschnitten aus einem Blatt.
|
||||||
|
///
|
||||||
|
/// **Ausgeschnitten, nicht einzeln geladen.** Ein Blatt trägt hundert
|
||||||
|
/// Bilder; beim Ziehen über die Leiste wird nur der Ausschnitt verschoben,
|
||||||
|
/// und nach dem ersten Blatt kommt kein Abruf mehr. Einzelne Bilder zu
|
||||||
|
/// holen hiesse, bei jeder Fingerbewegung ins Netz zu greifen.
|
||||||
|
///
|
||||||
|
/// **Geladen wird von Hand, nicht über `FlaroArtwork`.** Jenes rechnet mit
|
||||||
|
/// Seitenverhältnissen und schneidet selbst zu — hier wird ein Blatt
|
||||||
|
/// gebraucht, das genau so gross dasteht, wie es ist, damit der Versatz
|
||||||
|
/// auf den Bildpunkt stimmt.
|
||||||
|
struct Vorschaukachel: View {
|
||||||
|
let plan: Vorschauplan
|
||||||
|
let stelle: Vorschauplan.Stelle
|
||||||
|
/// Woher das Blatt kommt — der Spieler weiss, welche Datei läuft.
|
||||||
|
let blatt: (Int) -> URL?
|
||||||
|
|
||||||
|
@State private var bild: PlatformImage?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Rechteck
|
||||||
|
.frame(width: plan.kachelBreite, height: plan.kachelHoehe)
|
||||||
|
.background(.black)
|
||||||
|
.clipShape(.rect(cornerRadius: FlaroRadius.card))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: FlaroRadius.card)
|
||||||
|
.stroke(.white.opacity(0.18), lineWidth: 1))
|
||||||
|
.shadow(color: .black.opacity(0.6), radius: 10, y: 4)
|
||||||
|
.task(id: stelle.blatt) { await laden() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var Rechteck: some View {
|
||||||
|
if let bild {
|
||||||
|
// `Image(platform:)` liegt in FlaroDesign und ist paketintern;
|
||||||
|
// dieselbe Fallunterscheidung, zwei Zeilen.
|
||||||
|
bildAnsicht(bild)
|
||||||
|
.resizable()
|
||||||
|
.frame(
|
||||||
|
width: plan.kachelBreite * Double(plan.spalten),
|
||||||
|
height: plan.kachelHoehe * Double(plan.zeilen))
|
||||||
|
// Der Ausschnitt entsteht durch Verschieben hinter einem
|
||||||
|
// Rahmen in Kachelgrösse — dieselbe Rechnung wie
|
||||||
|
// `background-position` im Web.
|
||||||
|
.offset(
|
||||||
|
x: -plan.kachelBreite * Double(stelle.spalte),
|
||||||
|
y: -plan.kachelHoehe * Double(stelle.zeile))
|
||||||
|
.frame(
|
||||||
|
width: plan.kachelBreite, height: plan.kachelHoehe,
|
||||||
|
alignment: .topLeading)
|
||||||
|
.clipped()
|
||||||
|
} else {
|
||||||
|
Color.black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bildAnsicht(_ bild: PlatformImage) -> Image {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
Image(uiImage: bild)
|
||||||
|
#else
|
||||||
|
Image(nsImage: bild)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func laden() async {
|
||||||
|
guard let adresse = blatt(stelle.blatt) else { return }
|
||||||
|
// Der Lader hält Blätter im Speicher: beim Ziehen über die Leiste
|
||||||
|
// wird dasselbe Blatt hundertmal gebraucht.
|
||||||
|
bild = await ArtworkLoader.shared.image(for: adresse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,9 +61,20 @@ final class PlayerModel {
|
|||||||
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
||||||
/// Überspringen-Knopf.
|
/// Überspringen-Knopf.
|
||||||
private(set) var activeMarker: MediaMarker?
|
private(set) var activeMarker: MediaMarker?
|
||||||
|
|
||||||
|
/// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
|
||||||
|
///
|
||||||
|
/// **Einmal gefragt, bei „noch nicht" einmal nachgefasst.** Die Blätter
|
||||||
|
/// entstehen beim Abspielen im Hintergrund; nach zehn Sekunden sind sie
|
||||||
|
/// bei den meisten Dateien da. Weiter zu fragen hiesse, für eine
|
||||||
|
/// Annehmlichkeit dauernd am Server zu ziehen.
|
||||||
|
private(set) var vorschau: Vorschauplan?
|
||||||
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
|
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
|
||||||
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
||||||
private(set) var nextEpisode: MediaItem?
|
private(set) var nextEpisode: MediaItem?
|
||||||
|
/// Die Folge davor — für den Rückwärtsknopf. `nil` heisst: es ist die
|
||||||
|
/// erste, oder es ist gar keine Folge.
|
||||||
|
private(set) var previousEpisode: MediaItem?
|
||||||
private(set) var hasReachedEnd = false
|
private(set) var hasReachedEnd = false
|
||||||
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
|
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
|
||||||
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
|
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
|
||||||
@@ -105,6 +116,26 @@ final class PlayerModel {
|
|||||||
/// und ausgerechnet da will man zusehen, wie der Vorrat wächst.
|
/// und ausgerechnet da will man zusehen, wie der Vorrat wächst.
|
||||||
private(set) var bufferedUntil: Duration?
|
private(set) var bufferedUntil: Duration?
|
||||||
|
|
||||||
|
/// Ab welcher Stelle des Films der laufende Strom beginnt.
|
||||||
|
///
|
||||||
|
/// **Null ist der Normalfall.** Dann ist der Strom der ganze Film, und
|
||||||
|
/// die Uhr des Spielers ist die Uhr des Films.
|
||||||
|
///
|
||||||
|
/// **Darüber heisst: der Server hat schon gesprungen.** Flaros
|
||||||
|
/// Abspielliste wächst von der gewünschten Stelle an vorwärts; was davor
|
||||||
|
/// liegt, gibt es in diesem Strom nicht. Der Spieler zählt trotzdem ab
|
||||||
|
/// null — ohne diesen Wert stünde bei „Fortsetzen ab 1:32" eine Null auf
|
||||||
|
/// der Uhr, und genau die würde auch gemeldet.
|
||||||
|
private var streamOffset: Duration = .zero
|
||||||
|
|
||||||
|
/// Bis wohin der laufende Strom reicht.
|
||||||
|
///
|
||||||
|
/// **Nicht dasselbe wie <see cref="duration"/>.** Das ist die Länge des
|
||||||
|
/// Films; dies ist, was davon in diesem Strom liegt. Bei einer
|
||||||
|
/// wachsenden Abspielliste wächst es mit. Ein Sprung darüber hinaus
|
||||||
|
/// braucht einen neuen Strom, keinen Sprung.
|
||||||
|
private var streamEnd: Duration?
|
||||||
|
|
||||||
/// Was der eigene Vorratsspeicher gerade tut — in einem Satz.
|
/// Was der eigene Vorratsspeicher gerade tut — in einem Satz.
|
||||||
///
|
///
|
||||||
/// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher
|
/// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher
|
||||||
@@ -211,6 +242,9 @@ final class PlayerModel {
|
|||||||
/// Protokoll mit Null-Objekt wäre die dritte Möglichkeit und die
|
/// Protokoll mit Null-Objekt wäre die dritte Möglichkeit und die
|
||||||
/// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden.
|
/// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden.
|
||||||
private let downloads: DownloadCoordinator?
|
private let downloads: DownloadCoordinator?
|
||||||
|
/// Siehe `Fortschrittspost`: was der Server nicht annimmt, wird
|
||||||
|
/// zurückgelegt und nachgereicht.
|
||||||
|
private let post: Fortschrittspost?
|
||||||
private let diagnostics: PlaybackDiagnostics
|
private let diagnostics: PlaybackDiagnostics
|
||||||
private let policy: PlaybackPolicy
|
private let policy: PlaybackPolicy
|
||||||
private let settings: PlaybackSettings
|
private let settings: PlaybackSettings
|
||||||
@@ -461,6 +495,8 @@ final class PlayerModel {
|
|||||||
backend: (any MediaBackend)?,
|
backend: (any MediaBackend)?,
|
||||||
liveTV: (any LiveTVBackend)?,
|
liveTV: (any LiveTVBackend)?,
|
||||||
downloads: DownloadCoordinator? = nil,
|
downloads: DownloadCoordinator? = nil,
|
||||||
|
/// Wohin ein Fortschritt kommt, den der Server nicht annimmt.
|
||||||
|
post: Fortschrittspost? = nil,
|
||||||
diagnostics: PlaybackDiagnostics,
|
diagnostics: PlaybackDiagnostics,
|
||||||
policy: PlaybackPolicy,
|
policy: PlaybackPolicy,
|
||||||
settings: PlaybackSettings,
|
settings: PlaybackSettings,
|
||||||
@@ -474,6 +510,7 @@ final class PlayerModel {
|
|||||||
self.backend = backend
|
self.backend = backend
|
||||||
self.liveTV = liveTV
|
self.liveTV = liveTV
|
||||||
self.downloads = downloads
|
self.downloads = downloads
|
||||||
|
self.post = post
|
||||||
self.diagnostics = diagnostics
|
self.diagnostics = diagnostics
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -499,6 +536,18 @@ final class PlayerModel {
|
|||||||
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
|
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
|
||||||
if let downloads, let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
|
if let downloads, let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
|
||||||
await startLocal(url: localURL)
|
await startLocal(url: localURL)
|
||||||
|
// **Die Angaben trotzdem holen, nur eben hinterher.**
|
||||||
|
//
|
||||||
|
// Hier wurde früher schlicht zurückgekehrt — und damit blieb
|
||||||
|
// `item` leer. Daran hing mehr, als es aussieht: keine Marken,
|
||||||
|
// also kein „Intro überspringen"; keine nächste Folge, also
|
||||||
|
// keine Knöpfe zum Blättern. Bei allem, was heruntergeladen
|
||||||
|
// war, fehlte das, und nur dort — weshalb es so lange aussah
|
||||||
|
// wie ein Fehler in der Erkennung.
|
||||||
|
//
|
||||||
|
// Nebenher und nicht davor: das Bild soll sofort laufen, und
|
||||||
|
// ohne Verbindung gibt es ohnehin nichts zu holen.
|
||||||
|
metadatenNachladen()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,6 +606,7 @@ final class PlayerModel {
|
|||||||
|
|
||||||
await load(source, decision: decision)
|
await load(source, decision: decision)
|
||||||
preloadNextEpisode()
|
preloadNextEpisode()
|
||||||
|
vorschauHolen()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stellt auf eine andere Qualitätsstufe um.
|
/// Stellt auf eine andere Qualitätsstufe um.
|
||||||
@@ -718,7 +768,58 @@ final class PlayerModel {
|
|||||||
await load(PlaybackSource(url: url, origin: .remote), decision: decision)
|
await load(PlaybackSource(url: url, origin: .remote), decision: decision)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holt Titelangaben nach, während die lokale Datei schon läuft.
|
||||||
|
///
|
||||||
|
/// Scheitert es — kein Server, kein Netz —, bleibt es, wie es war: die
|
||||||
|
/// Datei läuft, es gibt bloss keine Marken und keine Nachbarfolgen.
|
||||||
|
/// Welche Datei läuft — die Vorschau hängt an ihr, nicht am Titel.
|
||||||
|
///
|
||||||
|
/// `MediaVersion.id` ist die Kennung der Mediendatei; eine Fassung
|
||||||
|
/// besteht aus genau einer.
|
||||||
|
private var vorschaudatei: String?
|
||||||
|
|
||||||
|
/// Holt den Vorschauplan — einmal sofort, einmal nach zehn Sekunden.
|
||||||
|
private func vorschauHolen() {
|
||||||
|
guard vorschau == nil, let backend, let item else { return }
|
||||||
|
guard let datei = version(of: item)?.id else { return }
|
||||||
|
vorschaudatei = datei
|
||||||
|
|
||||||
|
Task { [weak self] in
|
||||||
|
for warten in [Duration.zero, .seconds(10)] {
|
||||||
|
if warten > .zero { try? await Task.sleep(for: warten) }
|
||||||
|
guard let self, self.vorschau == nil else { return }
|
||||||
|
if let plan = await backend.vorschauplan(fileId: datei) {
|
||||||
|
self.vorschau = plan
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Woher ein Vorschaublatt kommt.
|
||||||
|
func vorschaublatt(_ nummer: Int) -> URL? {
|
||||||
|
guard let backend, let datei = vorschaudatei else { return nil }
|
||||||
|
return backend.vorschaublatt(fileId: datei, blatt: nummer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metadatenNachladen() {
|
||||||
|
guard item == nil, let backend else { return }
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let geholt = try? await backend.item(id: self?.request.ratingKey ?? "") else { return }
|
||||||
|
guard let self, self.item == nil else { return }
|
||||||
|
self.item = geholt
|
||||||
|
self.updateActiveMarker()
|
||||||
|
self.preloadNextEpisode()
|
||||||
|
self.vorschauHolen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func startLocal(url: URL) async {
|
private func startLocal(url: URL) async {
|
||||||
|
// Eine Datei auf dem Gerät ist der ganze Film; es gibt keinen
|
||||||
|
// Vorlauf, den der Spieler aufrechnen müsste.
|
||||||
|
streamOffset = .zero
|
||||||
|
streamEnd = nil
|
||||||
|
|
||||||
let offline = downloads?.offlineMedia(forRatingKey: request.ratingKey) ?? nil
|
let offline = downloads?.offlineMedia(forRatingKey: request.ratingKey) ?? nil
|
||||||
|
|
||||||
let decision = PlaybackDecider().decide(
|
let decision = PlaybackDecider().decide(
|
||||||
@@ -839,6 +940,26 @@ final class PlayerModel {
|
|||||||
playbackSession = target.sessionID
|
playbackSession = target.sessionID
|
||||||
playbackVersionID = target.versionID
|
playbackVersionID = target.versionID
|
||||||
|
|
||||||
|
// **Wer schon gesprungen ist, springt nicht noch einmal.**
|
||||||
|
//
|
||||||
|
// Flaro legt den Strom an der gewünschten Stelle an; Jellyfin
|
||||||
|
// liefert den ganzen Film und lässt den Spieler springen. Beides
|
||||||
|
// zugleich hiesse bei einundzwanzig Minuten Fortschritt, bei
|
||||||
|
// zweiundvierzig zu landen.
|
||||||
|
streamOffset = target.startOffset
|
||||||
|
streamEnd = nil
|
||||||
|
|
||||||
|
// **Die Länge kommt aus der Datei, nicht vom Player.**
|
||||||
|
//
|
||||||
|
// Eine wachsende Abspielliste hat für AVPlayer keine Länge — sie
|
||||||
|
// trägt kein `EXT-X-ENDLIST`, also bleibt die Dauer unbestimmt
|
||||||
|
// und `durationResolved` kommt nie. Ohne diese Zeile blieb
|
||||||
|
// `duration` auf nichts, und die Ansicht rechnet dann mit einer
|
||||||
|
// Sekunde: der Regler stand bei 1:50:07 am Anschlag.
|
||||||
|
if let bekannt = part.duration ?? item.duration {
|
||||||
|
duration = .milliseconds(bekannt)
|
||||||
|
}
|
||||||
|
|
||||||
// **Der Vorrat läuft voraus, nicht der Player.**
|
// **Der Vorrat läuft voraus, nicht der Player.**
|
||||||
//
|
//
|
||||||
// AVPlayer entscheidet selbst, wie weit er puffert, und bei einer
|
// AVPlayer entscheidet selbst, wie weit er puffert, und bei einer
|
||||||
@@ -856,7 +977,7 @@ final class PlayerModel {
|
|||||||
return PlaybackSource(
|
return PlaybackSource(
|
||||||
url: adresse,
|
url: adresse,
|
||||||
origin: .remote,
|
origin: .remote,
|
||||||
startTime: start,
|
startTime: target.startOffset > .zero ? .zero : start,
|
||||||
// **Mit eigenem Vorrat ein kleiner Puffer.**
|
// **Mit eigenem Vorrat ein kleiner Puffer.**
|
||||||
//
|
//
|
||||||
// Er bestimmt, wie viel AVPlayer beisammenhaben will, bevor
|
// Er bestimmt, wie viel AVPlayer beisammenhaben will, bevor
|
||||||
@@ -1504,6 +1625,10 @@ final class PlayerModel {
|
|||||||
// Der Sprung **ist** angekommen, in dem Moment, in dem man ihn
|
// Der Sprung **ist** angekommen, in dem Moment, in dem man ihn
|
||||||
// auslöst. Alles Weitere ist Warten auf Daten, und dafür gibt es den
|
// auslöst. Alles Weitere ist Warten auf Daten, und dafür gibt es den
|
||||||
// Kreisel.
|
// Kreisel.
|
||||||
|
// **Vor dem Aufräumen entscheiden.** Die Antwort hängt daran, wie
|
||||||
|
// weit der Vorrat reicht — und der wird gleich verworfen.
|
||||||
|
let neuerStrom = ausserhalbDesStroms(time)
|
||||||
|
|
||||||
currentTime = time
|
currentTime = time
|
||||||
seekTarget = time
|
seekTarget = time
|
||||||
// Der Vorrat von vorhin gilt für die alte Stelle. Ihn stehen zu lassen
|
// Der Vorrat von vorhin gilt für die alte Stelle. Ihn stehen zu lassen
|
||||||
@@ -1512,7 +1637,55 @@ final class PlayerModel {
|
|||||||
NowPlaying.shared.update(from: self)
|
NowPlaying.shared.update(from: self)
|
||||||
updateActiveMarker()
|
updateActiveMarker()
|
||||||
|
|
||||||
Task { await engine.seek(to: time) }
|
// **Ausserhalb dieses Stroms springt nicht der Spieler, sondern der
|
||||||
|
// Server.**
|
||||||
|
//
|
||||||
|
// Flaros Abspielliste beginnt an der gewünschten Stelle und wächst
|
||||||
|
// von dort vorwärts. Was davor liegt und was noch nicht gerechnet
|
||||||
|
// ist, gibt es darin nicht; ein Sprung dorthin ginge ins Leere und
|
||||||
|
// der Spieler bliebe mit einem Kreisel stehen. Also wird der Strom
|
||||||
|
// neu aufgebaut — `restart` steigt bei `currentTime` wieder ein, und
|
||||||
|
// das ist genau die Stelle, die eben gesetzt wurde.
|
||||||
|
if neuerStrom {
|
||||||
|
Task { await restart() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { await engine.seek(to: time - streamOffset) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob diese Stelle in dem Strom liegt, der gerade läuft.
|
||||||
|
///
|
||||||
|
/// **Innerhalb des Erzeugten springt der Spieler selbst.** Dafür eine
|
||||||
|
/// neue Sitzung zu starten hiesse, Sekunden zu warten, um fünf Sekunden
|
||||||
|
/// zurückzugehen.
|
||||||
|
private func ausserhalbDesStroms(_ time: Duration) -> Bool {
|
||||||
|
// **Eine Datei mit bekannter Länge: überallhin.** `streamEnd` steht
|
||||||
|
// nur, wenn der Player eine Dauer ermitteln konnte, und das kann er
|
||||||
|
// genau dann, wenn das Ganze schon dasteht — eine Mediendatei oder
|
||||||
|
// eine fertige Abspielliste.
|
||||||
|
if streamEnd != nil, streamOffset == .zero { return false }
|
||||||
|
|
||||||
|
// Vor dem Anfang des Stroms gibt es in ihm nichts.
|
||||||
|
if time < streamOffset { return true }
|
||||||
|
|
||||||
|
// **Eine wachsende Liste reicht so weit, wie Daten da sind.**
|
||||||
|
//
|
||||||
|
// Hier stand ein Vergleich mit `streamEnd`, und der ging nie auf:
|
||||||
|
// bei einer wachsenden Liste bleibt die Dauer unbestimmt, also ist
|
||||||
|
// `streamEnd` nichts, und jeder Sprung galt als innerhalb. AVPlayer
|
||||||
|
// bekam ein Ziel, zu dem es keine Schnipsel gibt, verwarf es, und
|
||||||
|
// der Regler sprang zurück — beim Ziehen sichtbar, im Pausieren
|
||||||
|
// nicht, weil die Liste dort weiterwuchs und ihn irgendwann einholte.
|
||||||
|
//
|
||||||
|
// **Weiss niemand, wie weit es reicht, wird neu gebaut.** Ein neuer
|
||||||
|
// Strom dauert ein paar Sekunden; ein Sprung, der nichts tut, ist
|
||||||
|
// kaputt.
|
||||||
|
guard let bis = bufferedUntil else { return true }
|
||||||
|
|
||||||
|
// Eine halbe Sekunde Nachsicht an der Kante: dort einen neuen Strom
|
||||||
|
// zu bauen wäre Aufwand für nichts.
|
||||||
|
return time > bis + .milliseconds(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
|
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
|
||||||
@@ -1523,9 +1696,20 @@ final class PlayerModel {
|
|||||||
|
|
||||||
/// Springt ans Ende der laufenden Marke.
|
/// Springt ans Ende der laufenden Marke.
|
||||||
func skipActiveMarker() {
|
func skipActiveMarker() {
|
||||||
guard let marker = activeMarker else { return }
|
guard let marker = activeMarker, let item else { return }
|
||||||
skippedMarkers.insert(marker.id)
|
skippedMarkers.insert(marker.id)
|
||||||
seek(to: .milliseconds(marker.endMilliseconds))
|
|
||||||
|
// **Mit dem Schalter in einem Zug bis ans Ende.** Sonst endet der
|
||||||
|
// Sprung an dieser Marke, also bei der Post-Credit-Szene — wer sie
|
||||||
|
// nicht sehen will, stellt es einmal um und drückt einmal.
|
||||||
|
let ziel = settings.skipExtraScenes
|
||||||
|
? item.markers
|
||||||
|
.filter { $0.kind == marker.kind && $0.startMilliseconds >= marker.startMilliseconds }
|
||||||
|
.map(\.endMilliseconds)
|
||||||
|
.max() ?? marker.endMilliseconds
|
||||||
|
: marker.endMilliseconds
|
||||||
|
|
||||||
|
seek(to: .milliseconds(ziel))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ob es bei der Tonspur überhaupt etwas zu wählen gibt.
|
/// Ob es bei der Tonspur überhaupt etwas zu wählen gibt.
|
||||||
@@ -1660,26 +1844,43 @@ final class PlayerModel {
|
|||||||
private func report(state: PlaybackReportState) async {
|
private func report(state: PlaybackReportState) async {
|
||||||
guard let backend, let item else { return }
|
guard let backend, let item else { return }
|
||||||
|
|
||||||
// Die Laufzeit der Engine ist die genauere, aber sie steht erst ein
|
// **Die Laufzeit des Titels schlägt die der Engine.**
|
||||||
// paar Sekunden nach dem Öffnen fest. Für die erste Meldung tut es die
|
//
|
||||||
// des Servers — sonst fiele ausgerechnet der Bericht aus, der die
|
// Vorher war es umgekehrt, mit dem Argument, die Engine messe
|
||||||
// Sitzung anlegt, und der Titel erschiene nirgends als „läuft gerade".
|
// genauer. Sie misst aber am Strom und nicht am Film: bei einem
|
||||||
guard let total = duration ?? item.duration.map({ Duration.milliseconds($0) })
|
// 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 }
|
else { return }
|
||||||
|
|
||||||
// Beim Beginn steht die Zeit noch auf null — das ist keine fehlende
|
// **Keine Null melden, die der Spieler gar nicht meint.**
|
||||||
// Angabe, sondern der Anfang. Alle anderen Meldungen ohne Zeit wären
|
//
|
||||||
// dagegen wertlos und würden einen gemerkten Fortschritt überschreiben.
|
// Beim Öffnen steht die Zeit der Engine auf null, obwohl der Spieler
|
||||||
guard currentTime > .zero || state == .started else { return }
|
// 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,
|
itemID: item.id,
|
||||||
sessionID: playbackSession,
|
sessionID: playbackSession,
|
||||||
versionID: playbackVersionID,
|
versionID: playbackVersionID,
|
||||||
state: state,
|
state: state,
|
||||||
time: currentTime,
|
time: stelle,
|
||||||
duration: total
|
duration: total
|
||||||
))
|
)
|
||||||
|
// **Was nicht ankommt, wird zurückgelegt.** Ohne das war eine Folge,
|
||||||
|
// die man offline zu Ende gesehen hat, für den Server nie gelaufen.
|
||||||
|
if await backend.reportProgress(bericht) == false {
|
||||||
|
post?.zuruecklegen(bericht, server: backend.identity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Marken und nächste Folge
|
// MARK: - Marken und nächste Folge
|
||||||
@@ -1688,14 +1889,14 @@ final class PlayerModel {
|
|||||||
guard let item else { return }
|
guard let item else { return }
|
||||||
let milliseconds = currentTime.milliseconds
|
let milliseconds = currentTime.milliseconds
|
||||||
|
|
||||||
activeMarker = item.markers.first { marker in
|
// **Was der Server schickt, gilt.** Hier stand eine eigene Regel:
|
||||||
guard marker.contains(milliseconds) else { return false }
|
// bei einem Film wurde die Abspannmarke verworfen, weil
|
||||||
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
|
// „überspringen" dort gleichbedeutend mit „beenden" war. Das ist
|
||||||
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
|
// eine Entscheidung über Marken, und die trifft der Server — er
|
||||||
// „Film beenden".
|
// schickt sie nur, wenn er einen Abspann gefunden hat, und seit
|
||||||
if marker.isCredits && item.kind != .episode { return false }
|
// ein Abspann geteilt sein darf, führt der Knopf zur
|
||||||
return true
|
// Post-Credit-Szene statt ins Nichts.
|
||||||
}
|
activeMarker = item.markers.first { $0.contains(milliseconds) }
|
||||||
|
|
||||||
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
|
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
|
||||||
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
|
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
|
||||||
@@ -1703,21 +1904,43 @@ final class PlayerModel {
|
|||||||
if shouldSkip { skipActiveMarker() }
|
if shouldSkip { skipActiveMarker() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Beide Nachbarn, nicht nur der nächste.**
|
||||||
|
///
|
||||||
|
/// Die nächste Folge wurde schon immer vorgeladen — für den Knopf am
|
||||||
|
/// Ende. Seit es im Spieler Knöpfe zum Blättern gibt, gilt dasselbe für
|
||||||
|
/// die vorige: wer eine zu weit gesprungen ist, soll zurück können, ohne
|
||||||
|
/// den Spieler zu verlassen.
|
||||||
private func preloadNextEpisode() {
|
private func preloadNextEpisode() {
|
||||||
guard let item, item.kind == .episode, let backend else { return }
|
guard let item, item.kind == .episode, let backend else { return }
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
let next = try? await backend.nextEpisode(after: item)
|
let next = try? await backend.nextEpisode(after: item)
|
||||||
self?.nextEpisode = next
|
self?.nextEpisode = next
|
||||||
}
|
}
|
||||||
|
Task { [weak self] in
|
||||||
|
let vorige = try? await backend.previousEpisode(before: item)
|
||||||
|
self?.previousEpisode = vorige
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
|
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
|
||||||
/// Koordinator weiter.
|
/// Koordinator weiter.
|
||||||
func nextEpisodeRequest() -> PlaybackRequest? {
|
func nextEpisodeRequest() -> PlaybackRequest? { wunsch(für: nextEpisode) }
|
||||||
guard let nextEpisode else { return nil }
|
|
||||||
|
/// Dasselbe rückwärts.
|
||||||
|
func previousEpisodeRequest() -> PlaybackRequest? { wunsch(für: previousEpisode) }
|
||||||
|
|
||||||
|
/// **Von vorn, nicht von der gemerkten Stelle.**
|
||||||
|
///
|
||||||
|
/// Wer blättert, will die Folge sehen. Beim Vorwärtsblättern ist sie
|
||||||
|
/// ohnehin meist ungesehen; beim Zurückblättern stünde man sonst im
|
||||||
|
/// Abspann der Folge, die man gerade noch einmal anfangen wollte. Wer
|
||||||
|
/// fortsetzen will, nimmt den Weg über die Serienseite — dort steht die
|
||||||
|
/// Stelle dabei.
|
||||||
|
private func wunsch(für folge: MediaItem?) -> PlaybackRequest? {
|
||||||
|
guard let folge else { return nil }
|
||||||
return PlaybackRequest(
|
return PlaybackRequest(
|
||||||
ratingKey: nextEpisode.id,
|
ratingKey: folge.id,
|
||||||
title: nextEpisode.playbackTitle,
|
title: folge.playbackTitle,
|
||||||
mediaIndex: 0,
|
mediaIndex: 0,
|
||||||
startTime: .zero
|
startTime: .zero
|
||||||
)
|
)
|
||||||
@@ -1859,7 +2082,14 @@ final class PlayerModel {
|
|||||||
String(describing: failure),
|
String(describing: failure),
|
||||||
].joined(separator: "\n\n"))
|
].joined(separator: "\n\n"))
|
||||||
}
|
}
|
||||||
case .timeChanged(let time):
|
case .timeChanged(let stromzeit):
|
||||||
|
// **Die Uhr des Spielers ist nicht die Uhr des Films.**
|
||||||
|
//
|
||||||
|
// Beginnt der Strom bei 1:32, zählt der Spieler trotzdem
|
||||||
|
// ab null. Ab hier wird in Filmzeit gerechnet — alles
|
||||||
|
// darunter, von der Anzeige bis zur Fortschrittsmeldung,
|
||||||
|
// meint denselben Punkt im Film.
|
||||||
|
let time = streamOffset + stromzeit
|
||||||
// **Nachzügler verwerfen.** Nach einem Sprung meldet der
|
// **Nachzügler verwerfen.** Nach einem Sprung meldet der
|
||||||
// Player noch ein paar Mal die alte Stelle, bis er dort
|
// Player noch ein paar Mal die alte Stelle, bis er dort
|
||||||
// ankommt. Ungeprüft übernommen zöge das den Regler wieder
|
// ankommt. Ungeprüft übernommen zöge das den Regler wieder
|
||||||
@@ -1889,7 +2119,22 @@ final class PlayerModel {
|
|||||||
}
|
}
|
||||||
updateActiveMarker()
|
updateActiveMarker()
|
||||||
case .durationResolved(let value):
|
case .durationResolved(let value):
|
||||||
duration = value
|
streamEnd = streamOffset + value
|
||||||
|
// **Die Länge des Films, nicht die des Stroms.**
|
||||||
|
//
|
||||||
|
// Eine wachsende Abspielliste meldet anfangs ein paar
|
||||||
|
// Sekunden. Als Filmlänge genommen stünde der Regler
|
||||||
|
// sofort am Ende, und ein Sprung nach vorn wäre
|
||||||
|
// unmöglich.
|
||||||
|
// **Die Datei weiss es genau, der Titel oft gar nicht.**
|
||||||
|
// `item.duration` kommt aus der Liste, und dort stehen
|
||||||
|
// keine Dateien — bei einem Titel, der aus einer Reihe
|
||||||
|
// geöffnet wurde, ist es nichts. Dann blieb nur die Länge
|
||||||
|
// des Stroms, und die ist am Anfang ein paar Sekunden:
|
||||||
|
// Regler am Ende, obwohl noch zwanzig Minuten kommen.
|
||||||
|
duration = currentPart?.duration.map { Duration.milliseconds($0) }
|
||||||
|
?? item?.duration.map { Duration.milliseconds($0) }
|
||||||
|
?? streamEnd
|
||||||
NowPlaying.shared.update(from: self)
|
NowPlaying.shared.update(from: self)
|
||||||
case .reachedEnd:
|
case .reachedEnd:
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
@@ -1931,14 +2176,52 @@ final class PlayerModel {
|
|||||||
/// Einstellungen eine Untertitelsprache steht. Wer keine eingestellt
|
/// Einstellungen eine Untertitelsprache steht. Wer keine eingestellt
|
||||||
/// hatte, schaltete die Untertitel ein und sah nichts.
|
/// hatte, schaltete die Untertitel ein und sah nichts.
|
||||||
///
|
///
|
||||||
/// **Die erste ist die richtige.** Rechnet der Server um, liegt genau
|
/// **Die erste ist nicht mehr die richtige.**
|
||||||
/// eine im Stream — nämlich die bestellte. Es gibt nichts zu verwechseln.
|
///
|
||||||
|
/// Das galt, solange der Server genau eine Spur auslieferte — die
|
||||||
|
/// bestellte. Flaro legt seit dem Strom **alle** Textspuren als eigene
|
||||||
|
/// Spuren in die Abspielliste, und dann ist die erste schlicht die
|
||||||
|
/// erste: wer Deutsch wählte, sah Englisch.
|
||||||
|
///
|
||||||
|
/// Gesucht wird deshalb die Spur, die zur Bestellung passt — erst über
|
||||||
|
/// das Sprachkürzel, dann über den Namen. Findet sich keine, bleibt es
|
||||||
|
/// bei der ersten: bei einem umgerechneten Strom gibt es nur sie, und
|
||||||
|
/// gar keinen Untertitel zu zeigen wäre schlechter als den einen.
|
||||||
private func showOrderedSubtitle() {
|
private func showOrderedSubtitle() {
|
||||||
guard selectedSubtitleStream != nil, selectedSubtitleTrack == nil else { return }
|
guard let bestellt = selectedSubtitleStream, selectedSubtitleTrack == nil else { return }
|
||||||
guard let erste = subtitleTracks.first else { return }
|
guard let treffer = passendeSpur(zu: bestellt) ?? subtitleTracks.first else { return }
|
||||||
|
|
||||||
diagnostics.note("Untertitel eingeschaltet: \(erste.displayName)")
|
diagnostics.note("Untertitel eingeschaltet: \(treffer.displayName)")
|
||||||
selectSubtitleTrack(erste)
|
selectSubtitleTrack(treffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Spur im Strom, die zu der bestellten aus den Metadaten gehört.
|
||||||
|
private func passendeSpur(zu bestellt: MediaStream) -> MediaTrack? {
|
||||||
|
// **Die Sprache zuerst.** Sie steht in der Abspielliste als
|
||||||
|
// `LANGUAGE` und ist das, was ein Spieler verlässlich durchreicht.
|
||||||
|
if let code = bestellt.languageCode ?? bestellt.language,
|
||||||
|
let treffer = subtitleTracks.first(where: {
|
||||||
|
LanguageCode.matches(code, $0.languageCode)
|
||||||
|
&& $0.isForced == bestellt.isForced
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return treffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Dann der Name.** Zwei Spuren in derselben Sprache — Dialoge und
|
||||||
|
// Schilder etwa — unterscheiden sich nur darüber.
|
||||||
|
if let name = bestellt.displayTitle,
|
||||||
|
let treffer = subtitleTracks.first(where: { $0.displayName == name })
|
||||||
|
{
|
||||||
|
return treffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Sprachkürzel bleibt die Sprache allein.
|
||||||
|
if let code = bestellt.languageCode ?? bestellt.language {
|
||||||
|
return subtitleTracks.first { LanguageCode.matches(code, $0.languageCode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wählt die Untertitelspur, sofern eine gewünscht ist.
|
/// Wählt die Untertitelspur, sofern eine gewünscht ist.
|
||||||
@@ -1952,9 +2235,15 @@ final class PlayerModel {
|
|||||||
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
|
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
|
||||||
/// eine später gemeldete Spurliste das nicht zurücksetzen.
|
/// eine später gemeldete Spurliste das nicht zurücksetzen.
|
||||||
private func applyPreferredLanguages() {
|
private func applyPreferredLanguages() {
|
||||||
guard selectedSubtitleTrack == nil, !languages.subtitleLanguage.isEmpty,
|
// **Die Ebene schlägt das Allgemeine.** Hier stand nur die
|
||||||
|
// allgemeine Sprache; eine Serie, die man im Original mit deutschen
|
||||||
|
// Untertiteln sieht, liess sich damit nicht einstellen, ohne sie für
|
||||||
|
// alles andere auch einzuschalten. Bei einer Folge gilt die Serie,
|
||||||
|
// bei einem Film der Titel selbst.
|
||||||
|
guard selectedSubtitleTrack == nil,
|
||||||
|
let wunsch = languages.subtitleWish(titleID: titelbereich),
|
||||||
let treffer = subtitleTracks.first(where: {
|
let treffer = subtitleTracks.first(where: {
|
||||||
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
|
LanguageCode.matches(wunsch, $0.languageCode)
|
||||||
})
|
})
|
||||||
else { return }
|
else { return }
|
||||||
selectSubtitleTrack(treffer)
|
selectSubtitleTrack(treffer)
|
||||||
@@ -1972,6 +2261,14 @@ final class PlayerModel {
|
|||||||
/// Netz und ohne Einstellungen, damit sich der ZDF-Fall wörtlich
|
/// Netz und ohne Einstellungen, damit sich der ZDF-Fall wörtlich
|
||||||
/// hinschreiben und durchspielen lässt. Hier steht nur, woher der Wunsch
|
/// hinschreiben und durchspielen lässt. Hier steht nur, woher der Wunsch
|
||||||
/// kommt.
|
/// kommt.
|
||||||
|
/// Unter welcher Kennung die Vorwahl dieses Titels liegt.
|
||||||
|
///
|
||||||
|
/// **Bei einer Folge die Serie, bei einem Film er selbst.** „Diese Serie
|
||||||
|
/// auf Japanisch" gilt für alle Folgen; „dieser Film auf Englisch" gilt
|
||||||
|
/// für ihn. Beides ist dieselbe Frage auf der Ebene, die der Benutzer
|
||||||
|
/// vor sich hatte, als er sie beantwortet hat.
|
||||||
|
private var titelbereich: String? { item?.seriesID ?? item?.id }
|
||||||
|
|
||||||
private func chooseAudioStream(
|
private func chooseAudioStream(
|
||||||
in part: MediaFile,
|
in part: MediaFile,
|
||||||
of item: MediaItem,
|
of item: MediaItem,
|
||||||
@@ -1981,9 +2278,8 @@ final class PlayerModel {
|
|||||||
subtitleStreams = part.subtitleStreams
|
subtitleStreams = part.subtitleStreams
|
||||||
// Untertitel sind eine Zutat, keine Voraussetzung: der Wunsch gilt,
|
// Untertitel sind eine Zutat, keine Voraussetzung: der Wunsch gilt,
|
||||||
// wenn es einen passenden gibt, und sonst bleibt es ohne.
|
// wenn es einen passenden gibt, und sonst bleibt es ohne.
|
||||||
selectedSubtitleStream = languages.subtitleLanguage.isEmpty ? nil
|
selectedSubtitleStream = languages.subtitleWish(titleID: titelbereich).flatMap { wunsch in
|
||||||
: subtitleStreams.first {
|
subtitleStreams.first { LanguageCode.matches(wunsch, $0.languageCode) }
|
||||||
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !audioStreams.isEmpty else {
|
guard !audioStreams.isEmpty else {
|
||||||
@@ -2000,7 +2296,12 @@ final class PlayerModel {
|
|||||||
|
|
||||||
selectedAudioStream = AudioTrackChooser.choose(
|
selectedAudioStream = AudioTrackChooser.choose(
|
||||||
from: part.streams,
|
from: part.streams,
|
||||||
wish: languages.wish(seriesID: item.seriesID, libraryID: libraryID)
|
// **Bei einem Film seine eigene Kennung.** Hier stand nur
|
||||||
|
// `item.seriesID`, und die ist bei einem Film nichts: die
|
||||||
|
// Vorwahl auf der Filmseite wurde abgelegt und nie wieder
|
||||||
|
// gelesen. Untertitel gingen durch, Ton nicht — weil die beiden
|
||||||
|
// an verschiedenen Stellen nachsehen.
|
||||||
|
wish: languages.wish(seriesID: titelbereich, libraryID: libraryID)
|
||||||
)
|
)
|
||||||
|
|
||||||
if let gewählt = selectedAudioStream {
|
if let gewählt = selectedAudioStream {
|
||||||
@@ -2020,8 +2321,8 @@ final class PlayerModel {
|
|||||||
guard stream != selectedAudioStream else { return }
|
guard stream != selectedAudioStream else { return }
|
||||||
selectedAudioStream = stream
|
selectedAudioStream = stream
|
||||||
|
|
||||||
if let seriesID = item?.seriesID {
|
if let bereich = titelbereich {
|
||||||
languages.remember(stream, forSeries: seriesID)
|
languages.remember(stream, forSeries: bereich)
|
||||||
}
|
}
|
||||||
|
|
||||||
// **Der billige Weg, wenn er offensteht.** Wird die Datei unverändert
|
// **Der billige Weg, wenn er offensteht.** Wird die Datei unverändert
|
||||||
@@ -2069,16 +2370,16 @@ final class PlayerModel {
|
|||||||
|
|
||||||
/// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt.
|
/// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt.
|
||||||
var rememberedTrackName: String? {
|
var rememberedTrackName: String? {
|
||||||
guard let seriesID = item?.seriesID,
|
guard let bereich = titelbereich,
|
||||||
let signatur = languages.rememberedTrack(forSeries: seriesID)
|
let signatur = languages.rememberedTrack(forSeries: bereich)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return signatur.title ?? signatur.languageCode
|
return signatur.title ?? signatur.languageCode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vergisst die gemerkte Spur dieser Serie.
|
/// Vergisst die gemerkte Spur dieser Serie.
|
||||||
func forgetRememberedTrack() {
|
func forgetRememberedTrack() {
|
||||||
guard let seriesID = item?.seriesID else { return }
|
guard let bereich = titelbereich else { return }
|
||||||
languages.forgetTrack(forSeries: seriesID)
|
languages.forgetTrack(forSeries: bereich)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playerSubtitle(for item: MediaItem) -> String? {
|
private func playerSubtitle(for item: MediaItem) -> String? {
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ struct PlayerView: View {
|
|||||||
@State private var trackPicker: TrackPickerSheet.Kind?
|
@State private var trackPicker: TrackPickerSheet.Kind?
|
||||||
@State private var showsQualityChoice = false
|
@State private var showsQualityChoice = false
|
||||||
@State private var hideTask: Task<Void, Never>?
|
@State private var hideTask: Task<Void, Never>?
|
||||||
|
/// Ob die Marken-Knöpfe inzwischen an der Bedienung hängen.
|
||||||
|
///
|
||||||
|
/// **Erst für sich, dann mit der Bedienung.** Eine neue Marke zeigt
|
||||||
|
/// ihre Knöpfe von selbst — sonst müsste man erst das Bild antippen,
|
||||||
|
/// um das Intro überspringen zu können, und dann hätte man die
|
||||||
|
/// Bedienung ohnehin. Sobald man sie aber einmal eingeblendet hat,
|
||||||
|
/// gehören die Knöpfe dazu und gehen mit ihr. Keine Frist, kein
|
||||||
|
/// Zeitgeber: der Zuschauer sagt selbst, wann er genug gesehen hat.
|
||||||
|
@State private var markenGebunden = false
|
||||||
@State private var scrubbing: Double?
|
@State private var scrubbing: Double?
|
||||||
/// Welche Richtung gerade als Hinweis eingeblendet ist.
|
/// Welche Richtung gerade als Hinweis eingeblendet ist.
|
||||||
@State private var seekHint: Double?
|
@State private var seekHint: Double?
|
||||||
@@ -106,8 +115,29 @@ struct PlayerView: View {
|
|||||||
// Jetzt rechnet das Layout. Der schwarze Rand ist schlicht
|
// Jetzt rechnet das Layout. Der schwarze Rand ist schlicht
|
||||||
// der Hintergrund dahinter, und wer ihn anklickt, klickt
|
// der Hintergrund dahinter, und wer ihn anklickt, klickt
|
||||||
// nicht aufs Bild — das muss niemand mehr feststellen.
|
// nicht aufs Bild — das muss niemand mehr feststellen.
|
||||||
|
// **Im Vollbild über alles, sonst nicht unter die
|
||||||
|
// Insel.**
|
||||||
|
//
|
||||||
|
// Bei 21∶9 auf einem Telefon reicht das Bild über die
|
||||||
|
// ganze Breite — und damit hinter die Dynamic Island, die
|
||||||
|
// links einen Streifen davon verdeckt. Wer Vollbild
|
||||||
|
// wählt, hat sich fürs Beschneiden entschieden; dort ist
|
||||||
|
// Rand an Rand folgerichtig. Im normalen Modus ist der
|
||||||
|
// Anspruch gerade, dass nichts fehlt — dann darf auch
|
||||||
|
// nichts verdeckt sein. Plex macht es genauso.
|
||||||
Color.clear
|
Color.clear
|
||||||
.ignoresSafeArea()
|
//
|
||||||
|
// **Ein Modifikator mit leeren Kanten, kein `if`.**
|
||||||
|
// Hier stand ein eigener `ViewModifier`, der
|
||||||
|
// zwischen `ignoresSafeArea()` und gar nichts
|
||||||
|
// umschaltete — und genau das ist in SwiftUI ein
|
||||||
|
// Zweig: beim Umschalten wurde der Baum darunter
|
||||||
|
// neu aufgebaut, die Bildebene mit ihm, und der
|
||||||
|
// Spieler stand ohne sie da. Schwarz, sobald
|
||||||
|
// jemand auf „Bildfüllend" wechselte. `edges: []`
|
||||||
|
// ignoriert nichts und ist doch derselbe
|
||||||
|
// Modifikator — die Ansicht bleibt dieselbe.
|
||||||
|
.ignoresSafeArea(.container, edges: model.videoFit == .fill ? .all : [])
|
||||||
.overlay {
|
.overlay {
|
||||||
surface(for: model)
|
surface(for: model)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
@@ -341,6 +371,17 @@ struct PlayerView: View {
|
|||||||
.environment(settings)
|
.environment(settings)
|
||||||
.environment(\.flaroAccent, accent)
|
.environment(\.flaroAccent, accent)
|
||||||
}
|
}
|
||||||
|
// Eine neue Marke ist ein neues Angebot: sie steht wieder für sich,
|
||||||
|
// bis die Bedienung das nächste Mal auftaucht.
|
||||||
|
.onChange(of: model.activeMarker?.id, initial: true) { _, _ in
|
||||||
|
markenGebunden = false
|
||||||
|
}
|
||||||
|
// **Der Übergang zählt, nicht der Zustand.** Beim Start liegt die
|
||||||
|
// Bedienung ohnehin da; das als „einmal gezeigt" zu werten hiesse,
|
||||||
|
// dem Intro seinen Knopf zu nehmen, bevor es ihn je hatte.
|
||||||
|
.onChange(of: showsControls) { vorher, jetzt in
|
||||||
|
if !vorher && jetzt { markenGebunden = true }
|
||||||
|
}
|
||||||
.onChange(of: showsQualityChoice) { _, isShown in
|
.onChange(of: showsQualityChoice) { _, isShown in
|
||||||
// Nach dem Schließen die Bedienelemente stehen lassen und die
|
// Nach dem Schließen die Bedienelemente stehen lassen und die
|
||||||
// Ausblendfrist neu starten. Ohne das verschwänden sie sofort,
|
// Ausblendfrist neu starten. Ohne das verschwänden sie sofort,
|
||||||
@@ -594,7 +635,25 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func transport(for model: PlayerModel) -> some View {
|
private func transport(for model: PlayerModel) -> some View {
|
||||||
HStack(spacing: FlaroSpacing.section * 2) {
|
// **Enger, seit zwei Knöpfe dazukommen.** Bei fünf Knöpfen in einer
|
||||||
|
// Reihe wird der alte Abstand auf einem Telefon im Hochformat zu
|
||||||
|
// breit; bei drei bleibt er, wie er war.
|
||||||
|
let folgen = model.previousEpisode != nil || model.nextEpisode != nil
|
||||||
|
|
||||||
|
return HStack(spacing: folgen ? FlaroSpacing.section : FlaroSpacing.section * 2) {
|
||||||
|
// **Eine Folge zurück — nur bei Serien, nur wenn es eine gibt.**
|
||||||
|
//
|
||||||
|
// Bei einem Film bleibt die Reihe, wie sie war: ein Knopf, der
|
||||||
|
// nie etwas tut, ist schlechter als keiner.
|
||||||
|
if model.previousEpisode != nil {
|
||||||
|
Button {
|
||||||
|
if let wunsch = model.previousEpisodeRequest() { playback.play(wunsch) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "backward.end.fill").font(.title2)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(Text("player.previousEpisode"))
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
model.skip(by: .seconds(-settings.rewindSeconds))
|
model.skip(by: .seconds(-settings.rewindSeconds))
|
||||||
scheduleControlsHide()
|
scheduleControlsHide()
|
||||||
@@ -638,6 +697,15 @@ struct PlayerView: View {
|
|||||||
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
|
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("player.forward10"))
|
.accessibilityLabel(Text("player.forward10"))
|
||||||
|
|
||||||
|
if model.nextEpisode != nil {
|
||||||
|
Button {
|
||||||
|
if let wunsch = model.nextEpisodeRequest() { playback.play(wunsch) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "forward.end.fill").font(.title2)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(Text("player.nextEpisode"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
@@ -662,7 +730,9 @@ struct PlayerView: View {
|
|||||||
// springen lässt das Bild flackern und hält bei großen
|
// springen lässt das Bild flackern und hält bei großen
|
||||||
// Dateien den Puffer dauerhaft leer.
|
// Dateien den Puffer dauerhaft leer.
|
||||||
onCommit: { model.seek(to: $0) },
|
onCommit: { model.seek(to: $0) },
|
||||||
onActivity: { scheduleControlsHide() }
|
onActivity: { scheduleControlsHide() },
|
||||||
|
vorschau: model.vorschau,
|
||||||
|
blatt: { model.vorschaublatt($0) }
|
||||||
)
|
)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
@@ -683,12 +753,31 @@ struct PlayerView: View {
|
|||||||
|
|
||||||
/// Überspringen-Knopf und „Nächste Folge".
|
/// Überspringen-Knopf und „Nächste Folge".
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
/// Ob jetzt „Nächste Folge" dransteht.
|
||||||
|
///
|
||||||
|
/// Am Ende immer, im Abspann auch — dort ist es die Antwort auf die
|
||||||
|
/// Frage, die der Abspann stellt. Gibt es keine nächste Folge, bleibt
|
||||||
|
/// es beim Überspringen.
|
||||||
|
private func naechsteAnbieten(_ model: PlayerModel) -> Bool {
|
||||||
|
guard model.nextEpisodeRequest() != nil else { return false }
|
||||||
|
return model.hasReachedEnd || model.activeMarker?.isCredits == true
|
||||||
|
}
|
||||||
|
|
||||||
private func overlayActions(for model: PlayerModel) -> some View {
|
private func overlayActions(for model: PlayerModel) -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
if model.hasReachedEnd, model.nextEpisodeRequest() != nil {
|
// **Im Abspann ist „überspringen" die falsche Frage — aber
|
||||||
|
// nicht die einzige.**
|
||||||
|
//
|
||||||
|
// Was man dort meistens will, ist die nächste Folge; ans
|
||||||
|
// Ende dieser zu springen bringt niemanden weiter. Wer den
|
||||||
|
// Abspann aber sehen wollte und es sich anders überlegt,
|
||||||
|
// soll ihn trotzdem überspringen können. Zwei Antworten,
|
||||||
|
// zwei Knöpfe — welche gemeint ist, weiss nur der
|
||||||
|
// Zuschauer.
|
||||||
|
if naechsteAnbieten(model) {
|
||||||
Button {
|
Button {
|
||||||
if let next = model.nextEpisodeRequest() {
|
if let next = model.nextEpisodeRequest() {
|
||||||
playback.play(next)
|
playback.play(next)
|
||||||
@@ -701,7 +790,9 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
} else if let marker = model.activeMarker {
|
}
|
||||||
|
|
||||||
|
if let marker = model.activeMarker {
|
||||||
Button {
|
Button {
|
||||||
model.skipActiveMarker()
|
model.skipActiveMarker()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -721,9 +812,28 @@ struct PlayerView: View {
|
|||||||
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
||||||
// die Bedienelemente eingeblendet sind.
|
// die Bedienelemente eingeblendet sind.
|
||||||
.padding(.bottom, (showsControls ? 110 : FlaroSpacing.section) + safeArea.bottom)
|
.padding(.bottom, (showsControls ? 110 : FlaroSpacing.section) + safeArea.bottom)
|
||||||
|
// **Am Ende bleibt es stehen, sonst nicht.**
|
||||||
|
//
|
||||||
|
// Ist der Film aus, ist der Knopf die einzige Frage, die noch
|
||||||
|
// offen ist — der darf warten. Mitten in einer Marke ist er ein
|
||||||
|
// Angebot, und ein Angebot, das nicht angenommen wird, nimmt
|
||||||
|
// man zurück.
|
||||||
|
.opacity(markenSichtbar(model) ? 1 : 0)
|
||||||
|
.allowsHitTesting(markenSichtbar(model))
|
||||||
}
|
}
|
||||||
.animation(.snappy, value: model.activeMarker?.id)
|
.animation(.snappy, value: model.activeMarker?.id)
|
||||||
.animation(.snappy, value: model.hasReachedEnd)
|
.animation(.snappy, value: model.hasReachedEnd)
|
||||||
|
.animation(.easeIn(duration: 0.28), value: markenGebunden)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Dieselbe Regel für Vorspann und Abspann, Web wie App.**
|
||||||
|
///
|
||||||
|
/// Sichtbar, solange die Knöpfe noch für sich stehen; danach genau
|
||||||
|
/// dann, wenn die Bedienung da ist. Am Ende des Films bleiben sie
|
||||||
|
/// trotzdem: dort ist „Nächste Folge" die einzige noch offene Frage,
|
||||||
|
/// und es gibt kein Bild mehr, das sie verdecken könnten.
|
||||||
|
private func markenSichtbar(_ model: PlayerModel) -> Bool {
|
||||||
|
model.hasReachedEnd || showsControls || !markenGebunden
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Abdeckung während des Umstellens.
|
/// Die Abdeckung während des Umstellens.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ struct SearchView: View {
|
|||||||
// weiß aber noch nicht, was er sehen will; der erste
|
// weiß aber noch nicht, was er sehen will; der erste
|
||||||
// Treffer ist eine Vermutung des Servers. Starten geht
|
// Treffer ist eine Vermutung des Servers. Starten geht
|
||||||
// weiter, über langes Drücken.
|
// weiter, über langes Drücken.
|
||||||
MediaRow(row: hub, opensDetails: true)
|
MediaRow(row: hub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, FlaroSpacing.loose)
|
.padding(.vertical, FlaroSpacing.loose)
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
/// „Welche Tonsprache gilt hier?" — an genau zwei Orten dieselbe Frage.
|
/// „Welche Sprache gilt hier?" — an mehreren Orten dieselbe Frage.
|
||||||
///
|
///
|
||||||
/// **Warum ein gemeinsamer Typ.** Die Frage steht einmal auf der Serienseite
|
/// **Warum ein gemeinsamer Typ.** Die Frage steht auf der Serienseite, auf
|
||||||
/// und einmal bei der Mediathek, und die Antwort bedeutet beide Male dasselbe:
|
/// der Filmseite und bei der Mediathek, und die Antwort bedeutet jedes Mal
|
||||||
/// diese Ebene setzt sich gegen die darüber durch, oder sie tut es nicht. Zwei
|
/// dasselbe: diese Ebene setzt sich gegen die darüber durch, oder sie tut es
|
||||||
/// Auswahlknöpfe mit demselben Inhalt liefen unweigerlich auseinander — einer
|
/// nicht. Mehrere Auswahlknöpfe mit demselben Inhalt liefen unweigerlich
|
||||||
/// bekäme eine Sprache dazu, der andere nicht.
|
/// auseinander — einer bekäme eine Sprache dazu, der andere nicht.
|
||||||
|
///
|
||||||
|
/// **Ton und Untertitel im selben Typ.** Sie unterscheiden sich in genau
|
||||||
|
/// zwei Punkten: woher der Wert kommt, und ob es „keine" gibt. Ein zweiter
|
||||||
|
/// Typ dafür wäre dieselbe Datei mit zwei geänderten Zeilen.
|
||||||
///
|
///
|
||||||
/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein
|
/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein
|
||||||
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
|
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
|
||||||
@@ -15,17 +20,44 @@ import FlaroDesign
|
|||||||
struct AudioLanguagePicker: View {
|
struct AudioLanguagePicker: View {
|
||||||
/// Für welche Ebene gewählt wird.
|
/// Für welche Ebene gewählt wird.
|
||||||
enum Scope {
|
enum Scope {
|
||||||
|
/// Eine Serie oder ein Film — beides eine Kennung, beides ein Titel.
|
||||||
case series(String)
|
case series(String)
|
||||||
case library(String)
|
case library(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ton oder Text.
|
||||||
|
enum Kind {
|
||||||
|
case audio
|
||||||
|
case subtitles
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie der Knopf aussieht.
|
||||||
|
enum Form {
|
||||||
|
/// Eine Zeile: Beschriftung links, Wahl rechts. Für Listen.
|
||||||
|
case zeile
|
||||||
|
/// Ein Knopf wie „Ton: Deutsch". Für die Reihe neben „Abspielen".
|
||||||
|
case knopf
|
||||||
|
}
|
||||||
|
|
||||||
let scope: Scope
|
let scope: Scope
|
||||||
let title: LocalizedStringKey
|
let title: LocalizedStringKey
|
||||||
|
var kind: Kind = .audio
|
||||||
|
var form: Form = .zeile
|
||||||
|
|
||||||
|
/// Welche Sprachen es wirklich gibt — `nil` heisst: alle anbieten.
|
||||||
|
///
|
||||||
|
/// **Eine Liste mit zwanzig Sprachen für eine Serie mit zweien** ist
|
||||||
|
/// keine Auswahl, sondern eine Suche. Weiss niemand, welche es gibt,
|
||||||
|
/// bleibt es bei der vollen Liste: eine zu lange ist besser als eine
|
||||||
|
/// leere.
|
||||||
|
var available: [LanguageOption]?
|
||||||
|
|
||||||
@Environment(LanguagePreferences.self) private var languages
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
@Environment(\.flaroAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
switch form {
|
||||||
|
case .zeile:
|
||||||
HStack(spacing: FlaroSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.flaroBody)
|
.font(.flaroBody)
|
||||||
@@ -33,7 +65,53 @@ struct AudioLanguagePicker: View {
|
|||||||
|
|
||||||
Spacer(minLength: FlaroSpacing.regular)
|
Spacer(minLength: FlaroSpacing.regular)
|
||||||
|
|
||||||
Menu {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das Menü selbst — zweimal dieselben Einträge, zwei Aufhänger.
|
||||||
|
private func menü(@ViewBuilder label: () -> some View) -> some View {
|
||||||
|
Menu { auswahlEintraege } label: { label() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var auswahl: some View {
|
||||||
|
HStack(spacing: FlaroSpacing.tight) {
|
||||||
|
Text(currentLabel)
|
||||||
|
.font(.flaroBody)
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
}
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var auswahlEintraege: some View {
|
||||||
|
Group {
|
||||||
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
|
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
|
||||||
// sollen nichts Eigenes sagen, sondern die Frage nach oben
|
// sollen nichts Eigenes sagen, sondern die Frage nach oben
|
||||||
// durchreichen.
|
// durchreichen.
|
||||||
@@ -43,42 +121,65 @@ struct AudioLanguagePicker: View {
|
|||||||
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
|
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **„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(LanguagePreferences.noSubtitles)
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
"languages.none",
|
||||||
|
systemImage: current == LanguagePreferences.noSubtitles
|
||||||
|
? "checkmark" : ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
ForEach(LanguagePreferences.offeredCodes, id: \.self) { code in
|
ForEach(options, id: \.code) { option in
|
||||||
Button {
|
Button {
|
||||||
set(code)
|
set(option.code)
|
||||||
} label: {
|
} label: {
|
||||||
Label(name(of: code), systemImage: current == code ? "checkmark" : "")
|
Label(
|
||||||
|
option.name,
|
||||||
|
systemImage: current == option.code ? "checkmark" : ""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
HStack(spacing: FlaroSpacing.tight) {
|
|
||||||
Text(currentLabel)
|
|
||||||
.font(.flaroBody)
|
|
||||||
Image(systemName: "chevron.up.chevron.down")
|
|
||||||
.font(.flaroCaption)
|
|
||||||
}
|
}
|
||||||
.foregroundStyle(accent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was zur Wahl steht — das Vorhandene, sonst die allgemeine Liste.
|
||||||
|
private var options: [LanguageOption] {
|
||||||
|
available ?? LanguagePreferences.offeredCodes.map {
|
||||||
|
LanguageOption(code: $0, name: name(of: $0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var current: String? {
|
private var current: String? {
|
||||||
switch scope {
|
switch (scope, kind) {
|
||||||
case .series(let id): languages.audioLanguage(forSeries: id)
|
case (.series(let id), .audio): languages.audioLanguage(forSeries: id)
|
||||||
case .library(let id): languages.audioLanguage(forLibrary: id)
|
case (.series(let id), .subtitles): languages.subtitleLanguage(forSeries: id)
|
||||||
|
case (.library(let id), .audio): languages.audioLanguage(forLibrary: id)
|
||||||
|
// Je Mediathek gibt es keine Untertitelsprache — siehe `subtitleWish`.
|
||||||
|
case (.library, .subtitles): nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentLabel: String {
|
private var currentLabel: String {
|
||||||
current.map(name(of:)) ?? String(localized: "languages.inherit")
|
guard let current else { return String(localized: "languages.inherit") }
|
||||||
|
if current == LanguagePreferences.noSubtitles { return String(localized: "languages.none") }
|
||||||
|
return options.first { $0.code == current }?.name ?? name(of: current)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func set(_ code: String?) {
|
private func set(_ code: String?) {
|
||||||
switch scope {
|
switch (scope, kind) {
|
||||||
case .series(let id): languages.setAudioLanguage(code, forSeries: id)
|
case (.series(let id), .audio): languages.setAudioLanguage(code, forSeries: id)
|
||||||
case .library(let id): languages.setAudioLanguage(code, forLibrary: id)
|
case (.series(let id), .subtitles): languages.setSubtitleLanguage(code, forSeries: id)
|
||||||
|
case (.library(let id), .audio): languages.setAudioLanguage(code, forLibrary: id)
|
||||||
|
case (.library, .subtitles): break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ struct PlaybackSettingsView: View {
|
|||||||
Section {
|
Section {
|
||||||
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
|
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
|
||||||
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
|
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
|
||||||
|
Toggle("settings.skipExtraScenes", isOn: $settings.skipExtraScenes)
|
||||||
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
|
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
|
||||||
} header: {
|
} header: {
|
||||||
Text("settings.episodes")
|
Text("settings.episodes")
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct TVHeroSection: View {
|
|||||||
|
|
||||||
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde.
|
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde.
|
||||||
private var abspielTitel: LocalizedStringKey {
|
private var abspielTitel: LocalizedStringKey {
|
||||||
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
item.isResumable ? "hero.resume" : "hero.play"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var metadaten: String? {
|
private var metadaten: String? {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ struct TVMediaRow: View {
|
|||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: session.backend?.artworkURL(
|
||||||
path: item.rowArtworkPath, width: 880, height: 496
|
path: item.rowArtworkPath, width: 880, height: 496
|
||||||
),
|
),
|
||||||
progress: item.watchProgress,
|
progress: item.resumeProgress,
|
||||||
onFocusChange: { if $0 { onFocus(item) } }
|
onFocusChange: { if $0 { onFocus(item) } }
|
||||||
) { onSelect(item) }
|
) { onSelect(item) }
|
||||||
} else {
|
} else {
|
||||||
@@ -65,7 +65,7 @@ struct TVMediaRow: View {
|
|||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: session.backend?.artworkURL(
|
||||||
path: item.thumb, width: 560, height: 840
|
path: item.thumb, width: 560, height: 840
|
||||||
),
|
),
|
||||||
progress: item.watchProgress,
|
progress: item.resumeProgress,
|
||||||
onFocusChange: { if $0 { onFocus(item) } }
|
onFocusChange: { if $0 { onFocus(item) } }
|
||||||
) { onSelect(item) }
|
) { onSelect(item) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ struct TVLibraryView: View {
|
|||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: session.backend?.artworkURL(
|
||||||
path: item.posterArtworkPath, width: 560, height: 840
|
path: item.posterArtworkPath, width: 560, height: 840
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
) {
|
) {
|
||||||
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
||||||
// Raster eine Serie, hat die keine Fassungen; der
|
// Raster eine Serie, hat die keine Fassungen; der
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ public actor ArtworkLoader {
|
|||||||
using session: URLSession,
|
using session: URLSession,
|
||||||
attempts: Int = 3
|
attempts: Int = 3
|
||||||
) async -> PlatformImage? {
|
) async -> PlatformImage? {
|
||||||
|
// **Eine Datei auf der Platte ist kein Server.**
|
||||||
|
//
|
||||||
|
// Hier stand nur der Weg über HTTP, und die Antwort einer
|
||||||
|
// `file:`-Adresse ist keine `HTTPURLResponse` — die Prüfung darunter
|
||||||
|
// gab also `nil` zurück, immer. Sichtbar wurde das erst, als die
|
||||||
|
// Downloads ihre Plakate mitbrachten: sie lagen auf der Platte, und
|
||||||
|
// die Kacheln blieben trotzdem grau. Kein Wiederholen, kein
|
||||||
|
// Zeitablauf: eine Datei ist entweder da oder nicht.
|
||||||
|
if url.isFileURL {
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
return PlatformImage(data: data)
|
||||||
|
}
|
||||||
|
|
||||||
for attempt in 1...attempts {
|
for attempt in 1...attempts {
|
||||||
if Task.isCancelled { return nil }
|
if Task.isCancelled { return nil }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Eine Reihe, die umbricht statt zu quetschen.
|
||||||
|
///
|
||||||
|
/// **Der Anlass.** Im Kopf einer Detailseite stehen Abspielen, Von vorn,
|
||||||
|
/// Herunterladen und zwei Sprachknöpfe nebeneinander. In einem `HStack`
|
||||||
|
/// teilen sie sich die Breite — auf einem Telefon wurde daraus eine Reihe
|
||||||
|
/// von Säulen aus einzelnen Buchstaben, untereinander gesetzt. Lesbar war
|
||||||
|
/// nichts mehr, und angetippt hätte man trotzdem das Richtige.
|
||||||
|
///
|
||||||
|
/// **Umbrechen ist die Antwort, nicht Weglassen.** Ein Knopf, der auf einem
|
||||||
|
/// schmalen Gerät verschwindet, ist eine zweite Oberfläche, die gepflegt
|
||||||
|
/// werden will. Zwei Zeilen sind kein Mangel; sie sind, was dort hinpasst.
|
||||||
|
///
|
||||||
|
/// **Warum eigener `Layout` und kein `ViewThatFits`.** Das wählt zwischen
|
||||||
|
/// fertigen Entwürfen — man müsste jede Aufteilung von Hand hinschreiben,
|
||||||
|
/// und bei fünf Knöpfen sind das fünf. Hier wird gerechnet.
|
||||||
|
public struct Flusszeile: Layout {
|
||||||
|
private let abstand: CGFloat
|
||||||
|
private let zeilenabstand: CGFloat
|
||||||
|
|
||||||
|
public init(abstand: CGFloat = FlaroSpacing.regular, zeilenabstand: CGFloat? = nil) {
|
||||||
|
self.abstand = abstand
|
||||||
|
self.zeilenabstand = zeilenabstand ?? abstand
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sizeThatFits(
|
||||||
|
proposal: ProposedViewSize,
|
||||||
|
subviews: Subviews,
|
||||||
|
cache: inout Void
|
||||||
|
) -> CGSize {
|
||||||
|
let breite = proposal.width ?? .infinity
|
||||||
|
let zeilen = umbrechen(subviews, in: breite)
|
||||||
|
|
||||||
|
let hoehe = zeilen.reduce(CGFloat.zero) { $0 + $1.hoehe }
|
||||||
|
+ CGFloat(max(0, zeilen.count - 1)) * zeilenabstand
|
||||||
|
|
||||||
|
// **Die breiteste Zeile, nicht die angebotene Breite.** Sonst nimmt
|
||||||
|
// die Reihe das ganze Fenster ein, auch wenn zwei Knöpfe darin
|
||||||
|
// stehen — und alles daneben rutscht weg.
|
||||||
|
let genutzt = zeilen.map(\.breite).max() ?? 0
|
||||||
|
return CGSize(width: min(genutzt, breite), height: hoehe)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func placeSubviews(
|
||||||
|
in bounds: CGRect,
|
||||||
|
proposal: ProposedViewSize,
|
||||||
|
subviews: Subviews,
|
||||||
|
cache: inout Void
|
||||||
|
) {
|
||||||
|
var y = bounds.minY
|
||||||
|
|
||||||
|
for zeile in umbrechen(subviews, in: bounds.width) {
|
||||||
|
var x = bounds.minX
|
||||||
|
|
||||||
|
for index in zeile.teile {
|
||||||
|
let groesse = subviews[index].sizeThatFits(.unspecified)
|
||||||
|
subviews[index].place(
|
||||||
|
at: CGPoint(x: x, y: y + (zeile.hoehe - groesse.height) / 2),
|
||||||
|
proposal: ProposedViewSize(groesse)
|
||||||
|
)
|
||||||
|
x += groesse.width + abstand
|
||||||
|
}
|
||||||
|
|
||||||
|
y += zeile.hoehe + zeilenabstand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Zeile {
|
||||||
|
var teile: [Int] = []
|
||||||
|
var breite: CGFloat = 0
|
||||||
|
var hoehe: CGFloat = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func umbrechen(_ subviews: Subviews, in breite: CGFloat) -> [Zeile] {
|
||||||
|
var zeilen: [Zeile] = []
|
||||||
|
var jetzt = Zeile()
|
||||||
|
|
||||||
|
for index in subviews.indices {
|
||||||
|
let groesse = subviews[index].sizeThatFits(.unspecified)
|
||||||
|
let dazu = jetzt.teile.isEmpty ? groesse.width : jetzt.breite + abstand + groesse.width
|
||||||
|
|
||||||
|
// **Das erste Element bleibt immer stehen.** Ein Knopf, der
|
||||||
|
// allein schon breiter ist als die Zeile, käme sonst in eine
|
||||||
|
// leere Zeile und von dort in die nächste, endlos.
|
||||||
|
if !jetzt.teile.isEmpty, dazu > breite {
|
||||||
|
zeilen.append(jetzt)
|
||||||
|
jetzt = Zeile()
|
||||||
|
jetzt.teile = [index]
|
||||||
|
jetzt.breite = groesse.width
|
||||||
|
jetzt.hoehe = groesse.height
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
jetzt.teile.append(index)
|
||||||
|
jetzt.breite = dazu
|
||||||
|
jetzt.hoehe = max(jetzt.hoehe, groesse.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !jetzt.teile.isEmpty { zeilen.append(jetzt) }
|
||||||
|
return zeilen
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -291,7 +291,10 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
.frame(maxWidth: 640 * scale, alignment: .leading)
|
.frame(maxWidth: 640 * scale, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: FlaroSpacing.regular) {
|
// **Umbrechen statt quetschen.** In einem `HStack` teilen sich
|
||||||
|
// fünf Knöpfe die Breite; auf einem Telefon wurde daraus eine
|
||||||
|
// Reihe von Säulen aus einzelnen Buchstaben.
|
||||||
|
Flusszeile {
|
||||||
actions
|
actions
|
||||||
}
|
}
|
||||||
.padding(.top, FlaroSpacing.tight)
|
.padding(.top, FlaroSpacing.tight)
|
||||||
|
|||||||
@@ -395,9 +395,16 @@ public final class DownloadCoordinator {
|
|||||||
forgetTransfer(item.ratingKey)
|
forgetTransfer(item.ratingKey)
|
||||||
fileStore.remove(fileName: item.localFileName)
|
fileStore.remove(fileName: item.localFileName)
|
||||||
fileStore.remove(fileName: item.artworkFileName)
|
fileStore.remove(fileName: item.artworkFileName)
|
||||||
|
let serie = item.seriesKey
|
||||||
context.delete(item)
|
context.delete(item)
|
||||||
save()
|
save()
|
||||||
reloadItems()
|
reloadItems()
|
||||||
|
|
||||||
|
// **Das Serienplakat geht mit der letzten Folge.** Es hängt an
|
||||||
|
// keinem Eintrag — ohne diese Zeile bliebe es für immer liegen.
|
||||||
|
if let serie, !items.contains(where: { $0.seriesKey == serie }) {
|
||||||
|
fileStore.remove(fileName: fileStore.artworkFileName(forRatingKey: serie))
|
||||||
|
}
|
||||||
refreshUsage()
|
refreshUsage()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +429,58 @@ public final class DownloadCoordinator {
|
|||||||
return fileStore.url(forFileName: fileName)
|
return fileStore.url(forFileName: fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Das lokale Plakat eines Downloads — `nil`, wenn keines geholt wurde.
|
||||||
|
///
|
||||||
|
/// **Weil eine Reihe ohne Bilder keine Reihe ist.** Ohne Verbindung gibt
|
||||||
|
/// es keine Adresse, von der ein Plakat nachzuladen wäre; was nicht beim
|
||||||
|
/// Herunterladen mitkam, bleibt eine graue Fläche.
|
||||||
|
/// **Nach der Datei gefragt, nicht nach dem Eintrag.** Hier wurde erst
|
||||||
|
/// der Download gesucht und dann sein Dateiname gelesen — damit gab es
|
||||||
|
/// nur Bilder zu Dingen, die selbst heruntergeladen wurden. Das Plakat
|
||||||
|
/// einer **Serie** gehört aber keiner einzelnen Folge; es liegt unter
|
||||||
|
/// dem Serienschlüssel, zu dem es keinen Eintrag gibt und nie geben
|
||||||
|
/// wird.
|
||||||
|
public func artworkURL(forRatingKey key: String) -> URL? {
|
||||||
|
let name = fileStore.artworkFileName(forRatingKey: key)
|
||||||
|
guard fileStore.exists(fileName: name) else { return nil }
|
||||||
|
return fileStore.url(forFileName: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holt das Plakat zu einem Eintrag nach und legt es ab.
|
||||||
|
///
|
||||||
|
/// **Beim Anlegen, nicht erst beim Fertigwerden.** Die Liste zeigt den
|
||||||
|
/// Eintrag, sobald er in der Warteschlange steht; bis dahin wäre sonst
|
||||||
|
/// nichts zu sehen. Es sind ein paar Kilobyte.
|
||||||
|
/// Der Schlüssel muss zu keinem Download gehören: unter dem Schlüssel
|
||||||
|
/// einer Serie liegt ihr Plakat, obwohl die Serie selbst nie
|
||||||
|
/// heruntergeladen wird.
|
||||||
|
public func fetchArtwork(for ratingKey: String, from url: URL) async {
|
||||||
|
let name = fileStore.artworkFileName(forRatingKey: ratingKey)
|
||||||
|
guard !fileStore.exists(fileName: name) else {
|
||||||
|
vermerken(name, bei: ratingKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let (daten, antwort) = try? await URLSession.shared.data(from: url),
|
||||||
|
(antwort as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? true,
|
||||||
|
!daten.isEmpty
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
guard fileStore.store(daten, as: name) != nil else { return }
|
||||||
|
vermerken(name, bei: ratingKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hält am Eintrag fest, dass es ein Bild gibt — damit es beim Entfernen
|
||||||
|
/// mitgeht. Zum Serienschlüssel gibt es keinen Eintrag; dann bleibt die
|
||||||
|
/// Datei stehen, bis die letzte Folge der Serie geht.
|
||||||
|
private func vermerken(_ name: String, bei ratingKey: String) {
|
||||||
|
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
||||||
|
item.artworkFileName != name
|
||||||
|
else { return }
|
||||||
|
item.artworkFileName = name
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
public func offlineMedia(forRatingKey key: String) -> OfflineMedia? {
|
public func offlineMedia(forRatingKey key: String) -> OfflineMedia? {
|
||||||
guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil }
|
guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil }
|
||||||
return try? JSONDecoder().decode(OfflineMedia.self, from: data)
|
return try? JSONDecoder().decode(OfflineMedia.self, from: data)
|
||||||
@@ -458,6 +517,13 @@ public final class DownloadCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wie oft je Eintrag schon auf ein Umpacken gewartet wurde.
|
||||||
|
///
|
||||||
|
/// **Im Arbeitsspeicher und nicht im Eintrag.** Nach einem Neustart
|
||||||
|
/// beginnt das Zählen von vorn — und das ist richtig: der Server hat
|
||||||
|
/// inzwischen vermutlich fertig gepackt.
|
||||||
|
private var wartend: [String: Int] = [:]
|
||||||
|
|
||||||
private func start(_ item: DownloadItem) {
|
private func start(_ item: DownloadItem) {
|
||||||
guard let url = URL(string: item.sourceURLString) else {
|
guard let url = URL(string: item.sourceURLString) else {
|
||||||
fail(item, message: "Ungültige Adresse")
|
fail(item, message: "Ungültige Adresse")
|
||||||
@@ -526,6 +592,7 @@ public final class DownloadCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleCompletion(ratingKey: String, temporaryFile: URL) {
|
func handleCompletion(ratingKey: String, temporaryFile: URL) {
|
||||||
|
wartend[ratingKey] = nil
|
||||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
||||||
let sourceURL = URL(string: item.sourceURLString)
|
let sourceURL = URL(string: item.sourceURLString)
|
||||||
else { return }
|
else { return }
|
||||||
@@ -608,6 +675,49 @@ public final class DownloadCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Der Server hat die Anfrage abgelehnt.
|
/// Der Server hat die Anfrage abgelehnt.
|
||||||
|
/// Wie oft auf ein Umpacken gewartet wird, bevor aufgegeben wird.
|
||||||
|
///
|
||||||
|
/// **Grosszügig, und das mit Absicht.** Ein Dreistünder in 4K braucht auf
|
||||||
|
/// einem NAS Minuten; abzubrechen, während die Arbeit läuft, wäre die
|
||||||
|
/// eine Antwort, die niemandem hilft. Zwanzig Versuche im Abstand von
|
||||||
|
/// fünfzehn Sekunden sind fünf Minuten.
|
||||||
|
private static let hoechstensWarten = 20
|
||||||
|
private static let wartezeit = Duration.seconds(15)
|
||||||
|
|
||||||
|
/// Der Server packt noch um — später noch einmal fragen.
|
||||||
|
///
|
||||||
|
/// **Nicht sofort.** Ein Umpacken dauert Minuten, und im Sekundentakt
|
||||||
|
/// nachzufragen hiesse, dieselbe Datei hundertmal anzufangen und wieder
|
||||||
|
/// wegzuwerfen.
|
||||||
|
func handleNotReady(ratingKey: String) {
|
||||||
|
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||||
|
|
||||||
|
let versuche = (wartend[ratingKey] ?? 0) + 1
|
||||||
|
wartend[ratingKey] = versuche
|
||||||
|
|
||||||
|
guard versuche <= Self.hoechstensWarten else {
|
||||||
|
wartend[ratingKey] = nil
|
||||||
|
fail(item, message: String(localized: "downloads.failed.notReady", bundle: .module))
|
||||||
|
Task { await startNextIfPossible() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Zurück in die Warteschlange, nicht in den Fehler.** Es ist
|
||||||
|
// nichts schiefgegangen; es ist nur noch nicht fertig.
|
||||||
|
item.state = .waiting
|
||||||
|
save()
|
||||||
|
|
||||||
|
// **Nicht `resume`.** Das nimmt nur auf, was angehalten oder
|
||||||
|
// gescheitert ist — ein wartender Eintrag lief dort in die
|
||||||
|
// Schutzklausel und blieb für immer auf „wartet" stehen, während der
|
||||||
|
// Server längst fertig gepackt hatte. Die Warteschlange abzuarbeiten
|
||||||
|
// ist das, was hier gebraucht wird: `.waiting` steht darin.
|
||||||
|
Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: Self.wartezeit)
|
||||||
|
await self?.startNextIfPossible()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleServerRejection(ratingKey: String, status: Int) {
|
func handleServerRejection(ratingKey: String, status: Int) {
|
||||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||||
fail(item, message: String(localized: "downloads.failed.status \(status)", bundle: .module))
|
fail(item, message: String(localized: "downloads.failed.status \(status)", bundle: .module))
|
||||||
@@ -740,6 +850,21 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch
|
|||||||
) {
|
) {
|
||||||
guard let key = downloadTask.taskDescription else { return }
|
guard let key = downloadTask.taskDescription else { return }
|
||||||
|
|
||||||
|
// **202 heisst: noch nicht, komm wieder.**
|
||||||
|
//
|
||||||
|
// Der Server packt eine Datei um, die so nicht ausgeliefert werden
|
||||||
|
// kann, und antwortet währenddessen mit 202 und einem kurzen Text.
|
||||||
|
// Das ist ein Erfolgscode, also ging er durch die Prüfung darunter
|
||||||
|
// glatt hindurch: 163 Byte Antworttext lagen danach als Film auf dem
|
||||||
|
// Gerät, „in einer Sekunde heruntergeladen".
|
||||||
|
if let response = downloadTask.response as? HTTPURLResponse,
|
||||||
|
response.statusCode == 202 {
|
||||||
|
Task { @MainActor [weak coordinator] in
|
||||||
|
coordinator?.handleNotReady(ratingKey: key)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// `URLSessionDownloadTask` gilt auch bei 404 oder 500 als erfolgreich
|
// `URLSessionDownloadTask` gilt auch bei 404 oder 500 als erfolgreich
|
||||||
// und legt die Fehlerseite als Datei ab. Ohne diese Prüfung wurde eine
|
// und legt die Fehlerseite als Datei ab. Ohne diese Prüfung wurde eine
|
||||||
// zwei Kilobyte große HTML-Seite als fertiger Film verbucht — in einer
|
// zwei Kilobyte große HTML-Seite als fertiger Film verbucht — in einer
|
||||||
|
|||||||
@@ -58,6 +58,29 @@ public struct DownloadFileStore: Sendable {
|
|||||||
return "\(key).\(ext)"
|
return "\(key).\(ext)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Der Name des Plakats zu einem Titel.
|
||||||
|
///
|
||||||
|
/// **Eine eigene Datei, kein Anhängsel am Film.** Sie ist ein paar
|
||||||
|
/// Kilobyte gross und wird gebraucht, solange der Eintrag steht — auch
|
||||||
|
/// während der Film noch lädt und noch gar keine Datei hat.
|
||||||
|
public func artworkFileName(forRatingKey key: String) -> String { "\(key).bild" }
|
||||||
|
|
||||||
|
/// Legt ein geholtes Plakat ab. Schlägt es fehl, gibt es eben keines.
|
||||||
|
///
|
||||||
|
/// **Wirft nicht.** Ein Download ohne Bild ist ein Download; ein
|
||||||
|
/// Download, der am Bild scheitert, wäre keiner.
|
||||||
|
@discardableResult
|
||||||
|
public func store(_ data: Data, as name: String) -> URL? {
|
||||||
|
let ziel = url(forFileName: name)
|
||||||
|
do {
|
||||||
|
try createRootIfNeeded()
|
||||||
|
try data.write(to: ziel, options: .atomic)
|
||||||
|
return ziel
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz.
|
/// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz.
|
||||||
///
|
///
|
||||||
/// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der
|
/// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der
|
||||||
|
|||||||
@@ -1,6 +1,40 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage": "en",
|
"sourceLanguage": "en",
|
||||||
"strings": {
|
"strings": {
|
||||||
|
"downloads.failed.notReady": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Server packt diese Datei noch um. Versuch es in ein paar Minuten noch einmal."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The server is still repackaging this file. Try again in a few minutes."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloads.failed.status %lld": {
|
||||||
|
"comment": "Der Server hat die Download-Anfrage abgelehnt",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The server refused the download with error %lld."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloads.failed.tooSmall %@": {
|
"downloads.failed.tooSmall %@": {
|
||||||
"comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein",
|
"comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -34,23 +68,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"downloads.failed.status %lld": {
|
|
||||||
"comment": "Der Server hat die Download-Anfrage abgelehnt",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "The server refused the download with error %lld."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "1.0"
|
"version": "1.0"
|
||||||
|
|||||||
@@ -135,14 +135,14 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
|||||||
//
|
//
|
||||||
// Serverneutral: wo kein Cookie liegt, ist die Liste leer und es
|
// Serverneutral: wo kein Cookie liegt, ist die Liste leer und es
|
||||||
// ändert sich nichts.
|
// ändert sich nichts.
|
||||||
let kekse = HTTPCookieStorage.shared.cookies(for: source.url) ?? []
|
let cookies = HTTPCookieStorage.shared.cookies(for: source.url) ?? []
|
||||||
|
|
||||||
let asset = AVURLAsset(url: source.url, options: [
|
let asset = AVURLAsset(url: source.url, options: [
|
||||||
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
|
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
|
||||||
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
|
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
|
||||||
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
|
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
|
||||||
AVURLAssetPreferPreciseDurationAndTimingKey: false,
|
AVURLAssetPreferPreciseDurationAndTimingKey: false,
|
||||||
AVURLAssetHTTPCookiesKey: kekse,
|
AVURLAssetHTTPCookiesKey: cookies,
|
||||||
])
|
])
|
||||||
let item = AVPlayerItem(asset: asset)
|
let item = AVPlayerItem(asset: asset)
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ public actor StreamCache {
|
|||||||
configuration.urlCache = nil
|
configuration.urlCache = nil
|
||||||
configuration.httpMaximumConnectionsPerHost = 4
|
configuration.httpMaximumConnectionsPerHost = 4
|
||||||
configuration.timeoutIntervalForRequest = 30
|
configuration.timeoutIntervalForRequest = 30
|
||||||
|
|
||||||
|
// **Die Anmeldung muss mit.**
|
||||||
|
//
|
||||||
|
// Eine flüchtige Sitzung bringt ihren eigenen, leeren Cookie-Speicher
|
||||||
|
// mit — jede Anfrage von hier ging also unangemeldet hinaus. Bei
|
||||||
|
// Jellyfin fiel das nie auf: dort steht der Schlüssel in der Adresse.
|
||||||
|
// Flaro meldet über ein Cookie an, und im Protokoll stand genau das:
|
||||||
|
// `FlaroToken was challenged`, einmal je Wiedergabe, gefolgt von
|
||||||
|
// zwölf Sekunden Stille und einer zweiten Bestellung.
|
||||||
|
configuration.httpCookieStorage = HTTPCookieStorage.shared
|
||||||
|
configuration.httpShouldSetCookies = true
|
||||||
self.session = URLSession(configuration: configuration)
|
self.session = URLSession(configuration: configuration)
|
||||||
|
|
||||||
self.directory = FileManager.default.temporaryDirectory
|
self.directory = FileManager.default.temporaryDirectory
|
||||||
|
|||||||
@@ -54,13 +54,12 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
|
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
|
||||||
// **Vier Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der eine
|
// **Mehrere Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der
|
||||||
// fertige Startseite liefert, und das ist Absicht: welche Reihen eine
|
// eine fertige Startseite liefert, und das ist Absicht: welche Reihen
|
||||||
// App zeigt, entscheidet die App. Der Server liefert die Zutaten.
|
// eine App zeigt, entscheidet die App. Der Server liefert die Zutaten.
|
||||||
async let weiter = reihe(pfad: "/api/v1/home/continue")
|
async let weiter = reihe(pfad: "/api/v1/home/continue")
|
||||||
async let naechste = reihe(pfad: "/api/v1/home/next")
|
async let naechste = reihe(pfad: "/api/v1/home/next")
|
||||||
async let filme = seite(pfad: "/api/v1/movies", take: 24, as: Wire.Movie.self)
|
let mediatheken = (try? await libraries()) ?? []
|
||||||
async let serien = seite(pfad: "/api/v1/series", take: 24, as: Wire.Series.self)
|
|
||||||
|
|
||||||
var reihen: [MediaRowGroup] = []
|
var reihen: [MediaRowGroup] = []
|
||||||
|
|
||||||
@@ -76,21 +75,90 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
title: String(localized: "flaro.row.nextUp", bundle: .module),
|
title: String(localized: "flaro.row.nextUp", bundle: .module),
|
||||||
items: eintraege))
|
items: eintraege))
|
||||||
}
|
}
|
||||||
if let gefunden = try? await filme, !gefunden.items.isEmpty {
|
|
||||||
reihen.append(MediaRowGroup(
|
// **Je Mediathek eine Reihe mit dem zuletzt Hinzugefügten.**
|
||||||
id: "flaro.movies",
|
//
|
||||||
title: String(localized: "flaro.row.movies", bundle: .module),
|
// Vorher standen hier zwei Reihen, „Filme" und „Serien", mit dem
|
||||||
items: gefunden.items.map { $0.neutral() }))
|
// 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)) }
|
||||||
}
|
}
|
||||||
if let gefunden = try? await serien, !gefunden.items.isEmpty {
|
|
||||||
reihen.append(MediaRowGroup(
|
// **Die Reihenfolge der Mediatheken, nicht die der Antworten.**
|
||||||
id: "flaro.series",
|
// Sonst stünden die Reihen nach jedem Start woanders — je
|
||||||
title: String(localized: "flaro.row.series", bundle: .module),
|
// nachdem, welche Abfrage zuerst zurückkam.
|
||||||
items: gefunden.items.map { $0.neutral() }))
|
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
|
return reihen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Reihe „Zuletzt hinzugefügt" einer Mediathek — oder nichts.
|
||||||
|
///
|
||||||
|
/// **Ein Fehler bei einer Mediathek nimmt nicht die anderen mit.** Die
|
||||||
|
/// Startseite mit drei von vier Reihen ist besser als gar keine.
|
||||||
|
private func neuzugang(in mediathek: MediaLibrary) async -> MediaRowGroup? {
|
||||||
|
let pfad = mediathek.kind == .show ? "/api/v1/series" : "/api/v1/movies"
|
||||||
|
let abfrage = [
|
||||||
|
URLQueryItem(name: "library", value: mediathek.id),
|
||||||
|
URLQueryItem(name: "sort", value: "added"),
|
||||||
|
URLQueryItem(name: "take", value: "24"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let titel: [MediaItem]
|
||||||
|
if mediathek.kind == .show {
|
||||||
|
guard let seite = try? await client.send(
|
||||||
|
FlaroRequest(path: pfad, query: abfrage),
|
||||||
|
as: Wire.Page<Wire.Series>.self) else { return nil }
|
||||||
|
titel = seite.items.map { $0.neutral() }
|
||||||
|
} else {
|
||||||
|
guard let seite = try? await client.send(
|
||||||
|
FlaroRequest(path: pfad, query: abfrage),
|
||||||
|
as: Wire.Page<Wire.Movie>.self) else { return nil }
|
||||||
|
titel = seite.items.map { $0.neutral() }
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !titel.isEmpty else { return nil }
|
||||||
|
|
||||||
|
return MediaRowGroup(
|
||||||
|
id: "latest.\(mediathek.id)",
|
||||||
|
title: String(localized: "flaro.row.latestIn \(mediathek.title)", bundle: .module),
|
||||||
|
libraryID: mediathek.id,
|
||||||
|
items: titel)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func availableLanguages(inSeries id: String) async -> AvailableLanguages? {
|
||||||
|
guard let roh = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series/\(id)/languages"),
|
||||||
|
as: Wire.Sprachauswahl.self)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
return AvailableLanguages(
|
||||||
|
audio: roh.audio.map { LanguageOption(code: $0.code, name: $0.name) },
|
||||||
|
subtitles: roh.subtitles.map { LanguageOption(code: $0.code, name: $0.name) },
|
||||||
|
partial: roh.partial)
|
||||||
|
}
|
||||||
|
|
||||||
public func items(
|
public func items(
|
||||||
inLibrary library: MediaLibrary,
|
inLibrary library: MediaLibrary,
|
||||||
query: MediaQuery,
|
query: MediaQuery,
|
||||||
@@ -135,6 +203,21 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
|
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
|
||||||
return serie.neutral()
|
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
|
throw .notFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +239,29 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||||
|
try await nachbar(von: item, weg: "next")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||||
|
try await nachbar(von: item, weg: "previous")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Folge vor oder nach dieser.
|
||||||
|
///
|
||||||
|
/// 204 heisst: es gibt keine. Kein Fehler, nur das Ende der Serie —
|
||||||
|
/// oder ihr Anfang.
|
||||||
|
private func nachbar(von item: MediaItem, weg: String) async throws(MediaBackendError) -> MediaItem? {
|
||||||
guard item.kind == .episode else { return nil }
|
guard item.kind == .episode else { return nil }
|
||||||
// 204 heisst: es war die letzte. Kein Fehler, nur kein Nachfolger.
|
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/\(weg)"))
|
||||||
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/next"))
|
|
||||||
guard !daten.isEmpty else { return nil }
|
guard !daten.isEmpty else { return nil }
|
||||||
guard let folge = try? JSONDecoder().decode(Wire.Episode.self, from: daten) else { return nil }
|
// **Der eingerichtete Dekoder, nicht ein frischer.** Ein frischer
|
||||||
|
// kennt das Datumsformat des Servers nicht; sobald die Nachbarfolge
|
||||||
|
// ein `publishAt` oder ein `lastPlayedAt` mitbringt — und mit
|
||||||
|
// eingeschalteter Prüfung bringt sie es —, scheiterte das Lesen.
|
||||||
|
// Das `try?` schluckte den Fehler, und von aussen sah es aus, als
|
||||||
|
// gäbe es keine nächste Folge: der Knopf blieb weg.
|
||||||
|
guard let folge = try? FlaroClient.decoder.decode(
|
||||||
|
Wire.Episode.self, from: daten) else { return nil }
|
||||||
return folge.neutral()
|
return folge.neutral()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +308,32 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
/// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein
|
/// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein
|
||||||
/// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet
|
/// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet
|
||||||
/// Rechenzeit, die auf einem NAS knapper ist.
|
/// Rechenzeit, die auf einem NAS knapper ist.
|
||||||
|
/// Wie die Vorschaubilder einer Datei liegen.
|
||||||
|
///
|
||||||
|
/// **Der Server erzeugt sie beim Abspielen.** Steht noch nichts da,
|
||||||
|
/// antwortet er mit `fertig: false` — dann gibt es hier nichts, und
|
||||||
|
/// der Spieler fragt später noch einmal.
|
||||||
|
public func vorschauplan(fileId: String) async -> Vorschauplan? {
|
||||||
|
guard let auskunft = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/files/\(fileId)/vorschau"),
|
||||||
|
as: Wire.Vorschau.self), auskunft.fertig
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
return Vorschauplan(
|
||||||
|
intervall: auskunft.intervall,
|
||||||
|
spalten: auskunft.spalten,
|
||||||
|
zeilen: auskunft.zeilen,
|
||||||
|
kachelBreite: Double(auskunft.kachelBreite),
|
||||||
|
kachelHoehe: Double(auskunft.kachelHoehe),
|
||||||
|
blaetter: auskunft.blaetter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Ohne Kopfzeilen, wie bei den Plakaten.** Die Anmeldung hängt am
|
||||||
|
/// Cookie; der Bildlader holt die Adresse roh und kommt damit durch.
|
||||||
|
public nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL? {
|
||||||
|
client.url(path: "/api/v1/files/\(fileId)/vorschau/\(blatt).jpg")
|
||||||
|
}
|
||||||
|
|
||||||
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
||||||
rawImageURL(path: path)
|
rawImageURL(path: path)
|
||||||
}
|
}
|
||||||
@@ -260,6 +387,50 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
|
|
||||||
public func preparePlayback(
|
public func preparePlayback(
|
||||||
_ intent: PlaybackIntent
|
_ intent: PlaybackIntent
|
||||||
|
) async throws(MediaBackendError) -> PlaybackTarget {
|
||||||
|
// **Erst der Strom, und nur bei dessen Scheitern die fertige Datei.**
|
||||||
|
//
|
||||||
|
// Hier stand ausschliesslich der zweite Weg: `/download`, und dann
|
||||||
|
// im Sekundentakt nachfragen, bis die ganze MP4 auf der Platte liegt.
|
||||||
|
// Gemessen an einem Spielfilm waren das dreissig Sekunden Schwarz,
|
||||||
|
// während die Weboberfläche nach einer Sekunde lief — die holt sich
|
||||||
|
// eine HLS-Sitzung, und deren erstes Stück ist beim Umpacken in
|
||||||
|
// deutlich unter einer Sekunde da.
|
||||||
|
if let strom = try? await client.send(
|
||||||
|
.json(.post, "/api/v1/files/\(intent.file.id)/stream", Wire.StreamRequest(
|
||||||
|
height: intent.transcode?.resolution.flatMap(Self.hoehe),
|
||||||
|
// Ohne diese Angabe wählt der Server, und bei einem
|
||||||
|
// umgerechneten Strom kommt danach nur noch eine Spur an.
|
||||||
|
audioStream: intent.audioStreamIndex,
|
||||||
|
startSeconds: max(0, intent.startTime.seconds),
|
||||||
|
// Nur wenn der Spieler es ausdrücklich verlangt: das Profil
|
||||||
|
// entscheidet der Server, und er entscheidet es besser.
|
||||||
|
transcode: intent.transcode?.prefersRemux == false
|
||||||
|
&& intent.transcode?.maximumBitrate != nil)),
|
||||||
|
as: Wire.Stromauskunft.self),
|
||||||
|
|
||||||
|
// **Die Hauptliste, wo es sie gibt.** Nur dort stehen die
|
||||||
|
// Textspuren; die Medienliste trägt bloss Bild und Ton.
|
||||||
|
let adresse = client.url(path: strom.masterUrl ?? strom.url)
|
||||||
|
{
|
||||||
|
return PlaybackTarget(
|
||||||
|
url: adresse,
|
||||||
|
// Eine Direktwiedergabe legt nichts an, das aufzuräumen wäre.
|
||||||
|
sessionID: strom.method == "direct" ? nil : strom.sessionId,
|
||||||
|
versionID: intent.file.id,
|
||||||
|
startOffset: .seconds(strom.startSeconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await mitgenommeneDatei(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der alte Weg: warten, bis die fertige Datei auf der Platte liegt.
|
||||||
|
///
|
||||||
|
/// **Bleibt als Rückfall.** Er ist langsam, aber er kommt ohne
|
||||||
|
/// Stromsitzung aus — und wenn die nicht zustande kommt, ist eine späte
|
||||||
|
/// Wiedergabe besser als keine.
|
||||||
|
private func mitgenommeneDatei(
|
||||||
|
_ intent: PlaybackIntent
|
||||||
) async throws(MediaBackendError) -> PlaybackTarget {
|
) async throws(MediaBackendError) -> PlaybackTarget {
|
||||||
guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else {
|
guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else {
|
||||||
throw .transport(description: "Adresse nicht baubar")
|
throw .transport(description: "Adresse nicht baubar")
|
||||||
@@ -276,8 +447,6 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
// Muss der Server umpacken, antwortet er auf den Download mit 202 und
|
// Muss der Server umpacken, antwortet er auf den Download mit 202 und
|
||||||
// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere
|
// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere
|
||||||
// Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner.
|
// Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner.
|
||||||
// Genau so sah es beim ersten Lauf im Simulator aus: „Stelle wird
|
|
||||||
// wiederhergestellt", 0:00, für immer.
|
|
||||||
//
|
//
|
||||||
// Das Anstossen besorgt der Server beim ersten Abruf selbst; hier
|
// Das Anstossen besorgt der Server beim ersten Abruf selbst; hier
|
||||||
// wird nur nachgefragt, bis es so weit ist.
|
// wird nur nachgefragt, bis es so weit ist.
|
||||||
@@ -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)
|
return PlaybackTarget(url: adresse, sessionID: nil, versionID: intent.file.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func endPlayback(sessionID: String) async {}
|
/// „1080p" wird zu 1080 — der Server kennt nur Zahlen.
|
||||||
|
private static func hoehe(_ aufloesung: String) -> Int? {
|
||||||
|
Int(aufloesung.filter(\.isNumber))
|
||||||
|
}
|
||||||
|
|
||||||
public func reportProgress(_ report: PlaybackReport) async {
|
/// Schliesst die Sitzung, damit nichts weiterrechnet.
|
||||||
|
///
|
||||||
|
/// **Das war eine leere Methode**, und sie passte zu einer Zeit, in der
|
||||||
|
/// es gar keine Sitzung gab: eine fertige Datei auf der Platte rechnet
|
||||||
|
/// nicht weiter. Mit dem Strom gibt es wieder einen Prozess, der ohne
|
||||||
|
/// diesen Aufruf den ganzen Film zu Ende schreibt, während längst
|
||||||
|
/// niemand mehr zusieht.
|
||||||
|
public func endPlayback(sessionID: String) async {
|
||||||
|
_ = try? await client.send(FlaroRequest(
|
||||||
|
method: .delete, path: "/api/v1/stream/\(sessionID)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func reportProgress(_ report: PlaybackReport) async -> Bool {
|
||||||
// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
||||||
// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
||||||
// wäre die schlechtere Hälfte des Tauschs.
|
// wäre die schlechtere Hälfte des Tauschs.
|
||||||
@@ -322,10 +502,15 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
// Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen
|
// Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen
|
||||||
// Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden
|
// Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden
|
||||||
// hiesse, den ersten Bericht wegzuwerfen.
|
// hiesse, den ersten Bericht wegzuwerfen.
|
||||||
_ = try? await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
|
do {
|
||||||
|
try await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
|
||||||
Wire.ProgressRequest(
|
Wire.ProgressRequest(
|
||||||
positionSeconds: report.time.seconds,
|
positionSeconds: report.time.seconds,
|
||||||
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
|
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Zustand melden
|
// MARK: - Zustand melden
|
||||||
@@ -401,17 +586,26 @@ public struct FlaroAccount: Sendable, Equatable {
|
|||||||
public let userName: String
|
public let userName: String
|
||||||
public let isAdministrator: Bool
|
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(
|
public init(
|
||||||
baseURL: URL,
|
baseURL: URL,
|
||||||
serverName: String,
|
serverName: String,
|
||||||
userID: String,
|
userID: String,
|
||||||
userName: String,
|
userName: String,
|
||||||
isAdministrator: Bool
|
isAdministrator: Bool,
|
||||||
|
avatarImage: String? = nil
|
||||||
) {
|
) {
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
self.serverName = serverName
|
self.serverName = serverName
|
||||||
self.userID = userID
|
self.userID = userID
|
||||||
self.userName = userName
|
self.userName = userName
|
||||||
self.isAdministrator = isAdministrator
|
self.isAdministrator = isAdministrator
|
||||||
|
self.avatarImage = avatarImage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ enum Wire {
|
|||||||
let id: String
|
let id: String
|
||||||
let userName: String
|
let userName: String
|
||||||
let isAdministrator: Bool
|
let isAdministrator: Bool
|
||||||
|
/// Das Profilbild, als Schlüssel in die Bildablage des Servers.
|
||||||
|
let avatarImage: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Library: Decodable, Sendable {
|
struct Library: Decodable, Sendable {
|
||||||
@@ -78,6 +80,12 @@ enum Wire {
|
|||||||
let rating: Int?
|
let rating: Int?
|
||||||
let dismissed: Bool
|
let dismissed: Bool
|
||||||
let lastPlayedAt: Date?
|
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 {
|
struct Movie: Decodable, Sendable {
|
||||||
@@ -87,8 +95,14 @@ enum Wire {
|
|||||||
let overview: String?
|
let overview: String?
|
||||||
let posterImage: String?
|
let posterImage: String?
|
||||||
let backdropImage: String?
|
let backdropImage: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||||
|
let heroImage: String?
|
||||||
let files: [File]
|
let files: [File]
|
||||||
let state: ItemState?
|
let state: ItemState?
|
||||||
|
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
|
||||||
|
let publishAt: Date?
|
||||||
|
/// Der Abspann, sofern er im Bild gefunden wurde.
|
||||||
|
let markers: [Marker]?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Series: Decodable, Sendable {
|
struct Series: Decodable, Sendable {
|
||||||
@@ -98,9 +112,12 @@ enum Wire {
|
|||||||
let overview: String?
|
let overview: String?
|
||||||
let posterImage: String?
|
let posterImage: String?
|
||||||
let backdropImage: String?
|
let backdropImage: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||||
|
let heroImage: String?
|
||||||
let episodeCount: Int
|
let episodeCount: Int
|
||||||
let seasons: [Int]
|
let seasons: [Int]
|
||||||
let unwatchedCount: Int
|
let unwatchedCount: Int
|
||||||
|
let publishAt: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Episode: Decodable, Sendable {
|
struct Episode: Decodable, Sendable {
|
||||||
@@ -112,6 +129,24 @@ enum Wire {
|
|||||||
let stillImage: String?
|
let stillImage: String?
|
||||||
let files: [File]
|
let files: [File]
|
||||||
let state: ItemState?
|
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".
|
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
|
||||||
@@ -125,7 +160,15 @@ enum Wire {
|
|||||||
let year: Int?
|
let year: Int?
|
||||||
let posterImage: String?
|
let posterImage: String?
|
||||||
let backdropImage: String?
|
let backdropImage: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||||
|
let heroImage: String?
|
||||||
let state: ItemState
|
let state: ItemState
|
||||||
|
/// Zu welcher Serie die Folge gehört.
|
||||||
|
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 {
|
struct CodeResponse: Decodable, Sendable {
|
||||||
@@ -157,6 +200,51 @@ enum Wire {
|
|||||||
|
|
||||||
// --- Was hinausgeht --------------------------------------------------
|
// --- Was hinausgeht --------------------------------------------------
|
||||||
|
|
||||||
|
/// Was das Gerät kann und was gewünscht ist.
|
||||||
|
///
|
||||||
|
/// **Ohne Codec-Angaben nimmt der Server Apples Profil an**, und das ist
|
||||||
|
/// hier genau richtig: dieses Gerät *ist* Apple. Eine eigene Liste wäre
|
||||||
|
/// eine zweite Wahrheit neben der, die der Server ohnehin pflegt.
|
||||||
|
struct StreamRequest: Encodable, Sendable {
|
||||||
|
var height: Int?
|
||||||
|
var audioStream: Int?
|
||||||
|
var startSeconds: Double
|
||||||
|
var transcode: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Stromspur: Decodable, Sendable {
|
||||||
|
let index: Int
|
||||||
|
let label: String
|
||||||
|
let language: String?
|
||||||
|
let isDefault: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Stromauskunft: Decodable, Sendable {
|
||||||
|
let sessionId: String
|
||||||
|
let url: String
|
||||||
|
/// Dieselbe Wiedergabe mit Untertitelspuren — fehlt bei Direktwiedergabe.
|
||||||
|
let masterUrl: String?
|
||||||
|
/// „direct", „remux" oder „transcode".
|
||||||
|
let method: String
|
||||||
|
/// Ab welcher Sekunde des Films dieser Strom beginnt.
|
||||||
|
let startSeconds: Double
|
||||||
|
let height: Int?
|
||||||
|
let audioStream: Int?
|
||||||
|
let audioStreams: [Stromspur]
|
||||||
|
let subtitleStreams: [Stromspur]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Sprachangabe: Decodable, Sendable {
|
||||||
|
let code: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Sprachauswahl: Decodable, Sendable {
|
||||||
|
let audio: [Sprachangabe]
|
||||||
|
let subtitles: [Sprachangabe]
|
||||||
|
let partial: Int
|
||||||
|
}
|
||||||
|
|
||||||
struct LoginRequest: Encodable, Sendable {
|
struct LoginRequest: Encodable, Sendable {
|
||||||
let userName: String
|
let userName: String
|
||||||
let password: String
|
let password: String
|
||||||
@@ -173,3 +261,16 @@ enum Wire {
|
|||||||
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
|
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
|
||||||
struct RatingRequest: Encodable, Sendable { let rating: Int? }
|
struct RatingRequest: Encodable, Sendable { let rating: Int? }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Wire {
|
||||||
|
/// Was der Server über die Vorschaubilder sagt.
|
||||||
|
struct Vorschau: Decodable, Sendable {
|
||||||
|
let fertig: Bool
|
||||||
|
let intervall: Double
|
||||||
|
let spalten: Int
|
||||||
|
let zeilen: Int
|
||||||
|
let kachelBreite: Int
|
||||||
|
let kachelHoehe: Int
|
||||||
|
let blaetter: Int
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,11 +16,23 @@ extension Wire.Movie {
|
|||||||
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
||||||
thumb: posterImage,
|
thumb: posterImage,
|
||||||
art: backdropImage,
|
art: backdropImage,
|
||||||
|
heroArt: heroImage,
|
||||||
playCount: state?.playCount,
|
playCount: state?.playCount,
|
||||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
lastPlayedAt: state?.lastPlayedAt,
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
isFavourite: state?.favourite ?? false,
|
isFavourite: state?.favourite ?? false,
|
||||||
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,
|
year: year,
|
||||||
thumb: posterImage,
|
thumb: posterImage,
|
||||||
art: backdropImage,
|
art: backdropImage,
|
||||||
|
heroArt: heroImage,
|
||||||
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
|
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
|
||||||
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
||||||
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
||||||
// zu derselben Aussage zu haben.
|
// zu derselben Aussage zu haben.
|
||||||
|
availableFrom: publishAt,
|
||||||
episodeCount: episodeCount,
|
episodeCount: episodeCount,
|
||||||
playedEpisodeCount: episodeCount - unwatchedCount,
|
playedEpisodeCount: episodeCount - unwatchedCount,
|
||||||
childCount: seasons.count
|
childCount: seasons.count
|
||||||
@@ -60,12 +74,25 @@ extension Wire.Episode {
|
|||||||
index: episode,
|
index: episode,
|
||||||
parentIndex: season,
|
parentIndex: season,
|
||||||
seriesTitle: series?.title,
|
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,
|
playCount: state?.playCount,
|
||||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
lastPlayedAt: state?.lastPlayedAt,
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
isFavourite: state?.favourite ?? false,
|
isFavourite: state?.favourite ?? false,
|
||||||
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,
|
year: year,
|
||||||
duration: state.runtimeSeconds.map { Int($0 * 1000) },
|
duration: state.runtimeSeconds.map { Int($0 * 1000) },
|
||||||
thumb: folge ? backdropImage : posterImage,
|
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,
|
seriesThumb: folge ? posterImage : nil,
|
||||||
|
heroArt: heroImage,
|
||||||
index: episode,
|
index: episode,
|
||||||
parentIndex: season,
|
parentIndex: season,
|
||||||
seriesTitle: folge ? title : nil,
|
seriesTitle: folge ? title : nil,
|
||||||
|
seriesID: folge ? seriesId : nil,
|
||||||
playCount: state.playCount,
|
playCount: state.playCount,
|
||||||
resumeMilliseconds: Int(state.positionSeconds * 1000),
|
resumeMilliseconds: Int(state.positionSeconds * 1000),
|
||||||
lastPlayedAt: state.lastPlayedAt,
|
lastPlayedAt: state.lastPlayedAt,
|
||||||
isFavourite: state.favourite
|
isFavourite: state.favourite,
|
||||||
|
isInProgress: state.inProgress,
|
||||||
|
availableFrom: publishAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ public enum FlaroSignIn {
|
|||||||
serverName: gruss.serverName,
|
serverName: gruss.serverName,
|
||||||
userID: antwort.user.id,
|
userID: antwort.user.id,
|
||||||
userName: antwort.user.userName,
|
userName: antwort.user.userName,
|
||||||
isAdministrator: antwort.user.isAdministrator), client)
|
isAdministrator: antwort.user.isAdministrator,
|
||||||
|
avatarImage: antwort.user.avatarImage), client)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nimmt ein gespeichertes Token wieder auf.
|
/// Nimmt ein gespeichertes Token wieder auf.
|
||||||
@@ -111,7 +112,8 @@ public enum FlaroSignIn {
|
|||||||
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||||
userID: wer.id,
|
userID: wer.id,
|
||||||
userName: wer.userName,
|
userName: wer.userName,
|
||||||
isAdministrator: wer.isAdministrator), client)
|
isAdministrator: wer.isAdministrator,
|
||||||
|
avatarImage: wer.avatarImage), client)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Der Weg über einen Code
|
// MARK: - Der Weg über einen Code
|
||||||
@@ -179,7 +181,8 @@ public enum FlaroSignIn {
|
|||||||
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||||
userID: anmeldung.user.id,
|
userID: anmeldung.user.id,
|
||||||
userName: anmeldung.user.userName,
|
userName: anmeldung.user.userName,
|
||||||
isAdministrator: anmeldung.user.isAdministrator)), client)
|
isAdministrator: anmeldung.user.isAdministrator,
|
||||||
|
avatarImage: anmeldung.user.avatarImage)), client)
|
||||||
case "expired":
|
case "expired":
|
||||||
return (.expired, nil)
|
return (.expired, nil)
|
||||||
default:
|
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": {
|
"flaro.row.movies": {
|
||||||
"extractionState": "manual",
|
"extractionState": "manual",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ extension JellyfinBackend {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reportProgress(_ report: PlaybackReport) async {
|
public func reportProgress(_ report: PlaybackReport) async -> Bool {
|
||||||
var fields: [String: Any] = [
|
var fields: [String: Any] = [
|
||||||
"ItemId": report.itemID,
|
"ItemId": report.itemID,
|
||||||
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
|
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
|
||||||
@@ -126,13 +126,18 @@ extension JellyfinBackend {
|
|||||||
default: "/Sessions/Playing/Progress"
|
default: "/Sessions/Playing/Progress"
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return }
|
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return false }
|
||||||
_ = try? await client.send(JellyfinRequest(
|
do {
|
||||||
|
try await client.send(JellyfinRequest(
|
||||||
method: .post,
|
method: .post,
|
||||||
path: path,
|
path: path,
|
||||||
body: body,
|
body: body,
|
||||||
timeout: .seconds(10)
|
timeout: .seconds(10)
|
||||||
))
|
))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hilfen
|
// MARK: - Hilfen
|
||||||
|
|||||||
@@ -130,9 +130,16 @@ public func isInLocalNetwork() async -> Bool {
|
|||||||
// sind zwei verschiedene Fragen — in einer gemeinsamen Reihe steht am
|
// sind zwei verschiedene Fragen — in einer gemeinsamen Reihe steht am
|
||||||
// Ende beides durcheinander, und wer nach einem Film sucht, blättert
|
// Ende beides durcheinander, und wer nach einem Film sucht, blättert
|
||||||
// an Folgen vorbei.
|
// an Folgen vorbei.
|
||||||
for library in libraries {
|
//
|
||||||
let items = try? await latestItems(limit: 20, inLibrary: library.id)
|
// **Nebeneinander, nicht nacheinander.** Hier stand ein `await` in
|
||||||
rows.append(MediaRowGroup(
|
// der Schleife: bei vier Mediatheken vier Rundreisen hintereinander.
|
||||||
|
// 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)",
|
id: "latest.\(library.id)",
|
||||||
title: String(
|
title: String(
|
||||||
localized: "row.latestIn \(library.title)",
|
localized: "row.latestIn \(library.title)",
|
||||||
@@ -142,6 +149,14 @@ public func isInLocalNetwork() async -> Bool {
|
|||||||
items: items ?? []
|
items: items ?? []
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var collected: [(Int, MediaRowGroup)] = []
|
||||||
|
for await result in group { collected.append(result) }
|
||||||
|
return collected.sorted { $0.0 < $1.0 }.map(\.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.append(contentsOf: latest)
|
||||||
|
|
||||||
rows.append(MediaRowGroup(
|
rows.append(MediaRowGroup(
|
||||||
id: "favourites",
|
id: "favourites",
|
||||||
@@ -333,6 +348,20 @@ public func isInLocalNetwork() async -> Bool {
|
|||||||
return next.first { $0.id != item.id }
|
return next.first { $0.id != item.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Folge davor.
|
||||||
|
///
|
||||||
|
/// **Über die Folgenliste der Serie, nicht über einen eigenen
|
||||||
|
/// Endpunkt.** Jellyfin hat ein „NextUp", aber kein Gegenstück dazu —
|
||||||
|
/// und die Liste gibt es ohnehin, weil die Serienseite sie zeigt. Eine
|
||||||
|
/// Anfrage für einen Knopf, der selten gedrückt wird, ist der bessere
|
||||||
|
/// Tausch gegenüber gar keinem Knopf.
|
||||||
|
public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||||
|
guard let seriesID = item.seriesID else { return nil }
|
||||||
|
let folgen = try await episodes(inSeries: seriesID)
|
||||||
|
guard let stelle = folgen.firstIndex(where: { $0.id == item.id }), stelle > 0 else { return nil }
|
||||||
|
return folgen[stelle - 1]
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Bilder
|
// MARK: - Bilder
|
||||||
|
|
||||||
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
/// Fortschritt, der den Server nicht erreicht hat.
|
||||||
|
///
|
||||||
|
/// **Was ohne sie verlorenging.** Eine Folge im Flugzeug zur Hälfte
|
||||||
|
/// anzusehen, war für den Server ein Vorgang, der nie stattgefunden hat:
|
||||||
|
/// jede Meldung lief in die tote Leitung, und beim nächsten Verbinden stand
|
||||||
|
/// der Titel wieder bei null — auf dem Fernseher, im Web, überall. Genau
|
||||||
|
/// dieser Fall ist der Grund, aus dem man etwas herunterlädt.
|
||||||
|
///
|
||||||
|
/// **Eine Meldung je Titel, die letzte gewinnt.** Wer eine Stunde schaut,
|
||||||
|
/// erzeugt an die hundert Meldungen; nachzureichen ist davon genau eine
|
||||||
|
/// sinnvoll — die, an der er aufgehört hat. Alles andere wäre ein Protokoll
|
||||||
|
/// der Vergangenheit, das der Server erst wieder zusammenrechnen müsste.
|
||||||
|
///
|
||||||
|
/// Auf der Platte, nicht im Speicher: wer die App im Flugmodus beendet, hat
|
||||||
|
/// sonst umsonst geschaut.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class Fortschrittspost {
|
||||||
|
/// Der Briefkasten der App.
|
||||||
|
///
|
||||||
|
/// Einer, wie bei `LibraryCache`: zwei Instanzen auf dieselbe Datei
|
||||||
|
/// würden einander überschreiben. Der Bauweg mit eigener Ablage bleibt
|
||||||
|
/// für Prüfungen offen.
|
||||||
|
public static let shared = Fortschrittspost()
|
||||||
|
|
||||||
|
/// Ein zurückgelegter Stand.
|
||||||
|
public struct Eintrag: Codable, Sendable, Equatable {
|
||||||
|
/// Zu welchem Server er gehört.
|
||||||
|
///
|
||||||
|
/// **Sonst ginge er an den falschen.** Wer sich abmeldet und an
|
||||||
|
/// einem anderen Server anmeldet, bekäme sonst fremde Kennungen
|
||||||
|
/// mit dem eigenen Sehfortschritt gemeldet.
|
||||||
|
let server: String
|
||||||
|
let titelID: String
|
||||||
|
let sekunden: Double
|
||||||
|
let laufzeitSekunden: Double?
|
||||||
|
let zeitpunkt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private let ablage: URL
|
||||||
|
private let logger = Logger(subsystem: "app.flaro", category: "Fortschrittspost")
|
||||||
|
private var offen: [String: Eintrag]
|
||||||
|
|
||||||
|
/// Wie viele Stände noch auf einen Server warten.
|
||||||
|
public var wartend: Int { offen.count }
|
||||||
|
|
||||||
|
public init(ablage: URL? = nil) {
|
||||||
|
self.ablage = ablage ?? FileManager.default
|
||||||
|
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
.appending(path: "flaro-fortschritt.json")
|
||||||
|
self.offen = Self.lesen(from: self.ablage) ?? [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt einen Stand zurück, weil er nicht ankam.
|
||||||
|
public func zuruecklegen(_ report: PlaybackReport, server: String) {
|
||||||
|
offen[report.itemID] = Eintrag(
|
||||||
|
server: server,
|
||||||
|
titelID: report.itemID,
|
||||||
|
sekunden: report.time.seconds,
|
||||||
|
laufzeitSekunden: report.duration > .zero ? report.duration.seconds : nil,
|
||||||
|
zeitpunkt: Date()
|
||||||
|
)
|
||||||
|
schreiben()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Versucht, alles Liegengebliebene loszuwerden.
|
||||||
|
///
|
||||||
|
/// **Was weiterhin scheitert, bleibt liegen.** Ein halb geleerter
|
||||||
|
/// Briefkasten ist kein Fehler — beim nächsten Versuch geht der Rest.
|
||||||
|
///
|
||||||
|
/// Nacheinander und nicht nebeneinander: es sind selten mehr als eine
|
||||||
|
/// Handvoll, und zwanzig gleichzeitige Anfragen an einen Server, der
|
||||||
|
/// gerade erst wieder da ist, wären der falsche erste Gruß.
|
||||||
|
public func zustellen(über backend: (any Fortschrittsempfaenger)?) async {
|
||||||
|
guard let backend, !offen.isEmpty else { return }
|
||||||
|
let kennung = backend.identity
|
||||||
|
|
||||||
|
// **Nicht ewig aufheben.** Ein Stand von vor einem halben Jahr sagt
|
||||||
|
// nichts mehr darüber, wo jemand heute weiterschauen will — und ein
|
||||||
|
// Server, der nie wiederkommt, hinterliesse sonst eine Datei, die
|
||||||
|
// nur wächst.
|
||||||
|
let frist = Date().addingTimeInterval(-30 * 24 * 60 * 60)
|
||||||
|
offen = offen.filter { $0.value.zeitpunkt > frist }
|
||||||
|
|
||||||
|
for eintrag in offen.values.sorted(by: { $0.zeitpunkt < $1.zeitpunkt })
|
||||||
|
where eintrag.server == kennung {
|
||||||
|
let angekommen = await backend.reportProgress(PlaybackReport(
|
||||||
|
itemID: eintrag.titelID,
|
||||||
|
state: .stopped,
|
||||||
|
time: .seconds(eintrag.sekunden),
|
||||||
|
duration: .seconds(eintrag.laufzeitSekunden ?? 0)
|
||||||
|
))
|
||||||
|
guard angekommen else { break }
|
||||||
|
// **Nur den, der tatsächlich ankam.** Währenddessen kann
|
||||||
|
// derselbe Titel neu zurückgelegt worden sein; den zu
|
||||||
|
// entfernen hiesse, den neueren Stand wegzuwerfen.
|
||||||
|
if offen[eintrag.titelID] == eintrag { offen[eintrag.titelID] = nil }
|
||||||
|
}
|
||||||
|
schreiben()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alles vergessen — beim Abmelden.
|
||||||
|
public func leeren() {
|
||||||
|
offen = [:]
|
||||||
|
try? FileManager.default.removeItem(at: ablage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Platte
|
||||||
|
|
||||||
|
private func schreiben() {
|
||||||
|
do {
|
||||||
|
try JSONEncoder().encode(offen).write(to: ablage, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
logger.warning("Nicht gespeichert: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lesen(from url: URL) -> [String: Eintrag]? {
|
||||||
|
guard let daten = try? Data(contentsOf: url) else { return nil }
|
||||||
|
return try? JSONDecoder().decode([String: Eintrag].self, from: daten)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,41 @@ public actor LibraryCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Reihen der Startseite.
|
||||||
|
///
|
||||||
|
/// **Weil es sonst nichts gibt, womit sie sich füllen liesse.** Die
|
||||||
|
/// Mediathekenliste und die Titel darin liegen schon auf der Platte; die
|
||||||
|
/// Startseite dagegen entstand bei jedem Aufruf neu am Server, und ohne
|
||||||
|
/// ihn blieb sie leer. Im Flugzeug ist das der Unterschied zwischen
|
||||||
|
/// „sehen, was es gibt" und einem Fehlerbildschirm.
|
||||||
|
public func homeRows(server: String) -> [MediaRowGroup]? {
|
||||||
|
guard let daten = try? Data(contentsOf: homeRowsFile(server: server)) else { return nil }
|
||||||
|
return try? JSONDecoder().decode([MediaRowGroup].self, from: daten)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func storeHomeRows(_ rows: [MediaRowGroup], server: String) {
|
||||||
|
// Je Reihe eine Bildschirmbreite, mehr sieht ohnehin niemand, bevor
|
||||||
|
// der Server wieder antwortet.
|
||||||
|
let gekürzt = rows.map {
|
||||||
|
MediaRowGroup(
|
||||||
|
id: $0.id,
|
||||||
|
title: $0.title,
|
||||||
|
moreKey: $0.moreKey,
|
||||||
|
libraryID: $0.libraryID,
|
||||||
|
items: $0.items.prefix(20).map(\.listEssentials)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let ziel = homeRowsFile(server: server)
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: ziel.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try JSONEncoder().encode(gekürzt).write(to: ziel, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
logger.warning("Startseite nicht gespeichert: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Aufräumen
|
// MARK: - Aufräumen
|
||||||
|
|
||||||
/// Alles zu einem Server vergessen — beim Abmelden.
|
/// Alles zu einem Server vergessen — beim Abmelden.
|
||||||
@@ -139,6 +174,10 @@ public actor LibraryCache {
|
|||||||
directory(server: server).appending(path: "libraries.json")
|
directory(server: server).appending(path: "libraries.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func homeRowsFile(server: String) -> URL {
|
||||||
|
directory(server: server).appending(path: "home.json")
|
||||||
|
}
|
||||||
|
|
||||||
/// Ein Name, der als Datei taugt.
|
/// Ein Name, der als Datei taugt.
|
||||||
///
|
///
|
||||||
/// Kennungen kommen vom Server: bei Jellyfin GUIDs, bei Plex Zahlen, und
|
/// Kennungen kommen vom Server: bei Jellyfin GUIDs, bei Plex Zahlen, und
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import Foundation
|
|||||||
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
|
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
|
||||||
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
|
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
|
||||||
/// der alles umfasst, wäre kein Nenner mehr.
|
/// der alles umfasst, wäre kein Nenner mehr.
|
||||||
public protocol MediaBackend: Sendable {
|
/// Wer Sehfortschritt entgegennimmt.
|
||||||
/// Wie der Server heißt, so wie er sich selbst nennt.
|
///
|
||||||
nonisolated var serverName: String { get }
|
/// **Ein Ausschnitt aus `MediaBackend`, und zwar genau der, den die
|
||||||
|
/// `Fortschrittspost` braucht.** Ohne ihn müsste jeder, der das Nachreichen
|
||||||
|
/// prüfen will, einen vollständigen Server nachbauen — vierzig Methoden für
|
||||||
|
/// die zwei, die zur Sache gehören.
|
||||||
|
public protocol Fortschrittsempfaenger: Sendable {
|
||||||
/// Wer dieser Server ist — für alles, was ihn über einen Start hinaus
|
/// Wer dieser Server ist — für alles, was ihn über einen Start hinaus
|
||||||
/// wiedererkennen muss.
|
/// wiedererkennen muss.
|
||||||
///
|
///
|
||||||
@@ -17,6 +21,24 @@ public protocol MediaBackend: Sendable {
|
|||||||
/// beides überlebt und beim nächsten Start noch gleich lautet. Es ist
|
/// beides überlebt und beim nächsten Start noch gleich lautet. Es ist
|
||||||
/// eine Kennung und geht nirgends an die Oberfläche.
|
/// eine Kennung und geht nirgends an die Oberfläche.
|
||||||
nonisolated var identity: String { get }
|
nonisolated var identity: String { get }
|
||||||
|
|
||||||
|
/// Meldet, wo man gerade steht.
|
||||||
|
///
|
||||||
|
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
||||||
|
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
||||||
|
/// wäre die schlechtere Hälfte des Tauschs.
|
||||||
|
///
|
||||||
|
/// **Gibt aber zurück, ob sie ankam.** Im Flugzeug geht jede einzelne
|
||||||
|
/// verloren, und am Ende stünde ein zur Hälfte gesehener Film auf dem
|
||||||
|
/// Server weiter bei null. Der Aufrufer legt sie dann beiseite und
|
||||||
|
/// meldet sie nach — siehe `Fortschrittspost`.
|
||||||
|
@discardableResult
|
||||||
|
func reportProgress(_ report: PlaybackReport) async -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol MediaBackend: Sendable, Fortschrittsempfaenger {
|
||||||
|
/// Wie der Server heißt, so wie er sich selbst nennt.
|
||||||
|
nonisolated var serverName: String { get }
|
||||||
/// Was dieser Server darüber hinaus beherrscht.
|
/// Was dieser Server darüber hinaus beherrscht.
|
||||||
nonisolated var capabilities: Set<BackendCapability> { get }
|
nonisolated var capabilities: Set<BackendCapability> { get }
|
||||||
|
|
||||||
@@ -60,9 +82,26 @@ public protocol MediaBackend: Sendable {
|
|||||||
/// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel.
|
/// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel.
|
||||||
/// Ein Server, der es in einer Anfrage kann, soll das dürfen.
|
/// Ein Server, der es in einer Anfrage kann, soll das dürfen.
|
||||||
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem]
|
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem]
|
||||||
|
|
||||||
|
/// Welche Sprachen eine Serie durchgehend führt — für die Vorwahl.
|
||||||
|
func availableLanguages(inSeries id: String) async -> AvailableLanguages?
|
||||||
/// Die nächste Folge nach dieser, sofern es eine gibt.
|
/// Die nächste Folge nach dieser, sofern es eine gibt.
|
||||||
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
||||||
|
|
||||||
|
/// Die Folge davor — für den Rückwärtsknopf im Spieler.
|
||||||
|
///
|
||||||
|
/// **Mit Vorgabe, anders als die nächste.** Die nächste Folge braucht
|
||||||
|
/// jeder Server: ohne sie gibt es kein Weiterlaufen am Ende. Die vorige
|
||||||
|
/// ist eine Bequemlichkeit, und ein Server, der sie nicht kennt, soll
|
||||||
|
/// deshalb nicht unbaubar werden.
|
||||||
|
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
||||||
|
|
||||||
|
/// Wie die Vorschaubilder einer Datei liegen — `nil`, wenn es keine gibt.
|
||||||
|
func vorschauplan(fileId: String) async -> Vorschauplan?
|
||||||
|
|
||||||
|
/// Die Adresse eines Vorschaublattes.
|
||||||
|
nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL?
|
||||||
|
|
||||||
// MARK: - Bilder
|
// MARK: - Bilder
|
||||||
|
|
||||||
/// Ein Bild in der gewünschten Größe.
|
/// Ein Bild in der gewünschten Größe.
|
||||||
@@ -125,13 +164,6 @@ public protocol MediaBackend: Sendable {
|
|||||||
/// niemand mehr zusieht.
|
/// niemand mehr zusieht.
|
||||||
func endPlayback(sessionID: String) async
|
func endPlayback(sessionID: String) async
|
||||||
|
|
||||||
/// Meldet, wo man gerade steht.
|
|
||||||
///
|
|
||||||
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
|
||||||
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
|
||||||
/// wäre die schlechtere Hälfte des Tauschs.
|
|
||||||
func reportProgress(_ report: PlaybackReport) async
|
|
||||||
|
|
||||||
// MARK: - Zustand melden
|
// MARK: - Zustand melden
|
||||||
|
|
||||||
func markWatched(id: String) async throws(MediaBackendError)
|
func markWatched(id: String) async throws(MediaBackendError)
|
||||||
@@ -211,6 +243,19 @@ public struct MediaPage: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public extension MediaBackend {
|
public extension MediaBackend {
|
||||||
|
/// Vorgabe: kein Weg zurück.
|
||||||
|
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? { nil }
|
||||||
|
|
||||||
|
/// Wie die Vorschaubilder einer Datei liegen — `nil`, wenn es keine gibt.
|
||||||
|
///
|
||||||
|
/// **Mit Vorgabe, damit nicht jeder Server es können muss.** Jellyfin
|
||||||
|
/// hat sein eigenes Trickplay mit anderem Zuschnitt; bis das angebunden
|
||||||
|
/// ist, sagt es hier schlicht nichts.
|
||||||
|
func vorschauplan(fileId: String) async -> Vorschauplan? { nil }
|
||||||
|
|
||||||
|
/// Die Adresse eines Vorschaublattes.
|
||||||
|
nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL? { nil }
|
||||||
|
|
||||||
/// Vorgabe: der Server kann es nicht.
|
/// Vorgabe: der Server kann es nicht.
|
||||||
///
|
///
|
||||||
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
|
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
|
||||||
@@ -227,6 +272,13 @@ public extension MediaBackend {
|
|||||||
subtitleStreamIndex: Int?
|
subtitleStreamIndex: Int?
|
||||||
) -> URL? { nil }
|
) -> URL? { nil }
|
||||||
|
|
||||||
|
/// Welche Sprachen eine Serie durchgehend führt.
|
||||||
|
///
|
||||||
|
/// **`nil` heisst: der Server weiss es nicht.** Dann bleibt es bei der
|
||||||
|
/// vollen Liste — eine leere Auswahl anzubieten wäre schlimmer als eine
|
||||||
|
/// zu lange.
|
||||||
|
func availableLanguages(inSeries id: String) async -> AvailableLanguages? { nil }
|
||||||
|
|
||||||
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
|
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
|
||||||
///
|
///
|
||||||
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
|
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
|
||||||
@@ -261,3 +313,41 @@ public struct MediaFilterOption: Sendable, Equatable, Identifiable {
|
|||||||
self.title = title
|
self.title = title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Eine Sprache, wie sie in einem Menü steht.
|
||||||
|
public struct LanguageOption: Sendable, Equatable, Identifiable, Hashable {
|
||||||
|
public let code: String
|
||||||
|
public let name: String
|
||||||
|
|
||||||
|
public var id: String { code }
|
||||||
|
|
||||||
|
public init(code: String, name: String) {
|
||||||
|
self.code = code
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welche Sprachen ein Titel durchgehend führt.
|
||||||
|
///
|
||||||
|
/// **Durchgehend, nicht irgendwo.** Bei einer Serie ist das die
|
||||||
|
/// Schnittmenge über alle Folgen: eine Sprache anzubieten, die es nur in
|
||||||
|
/// Staffel 1 gibt, hiesse eine Einstellung für die Serie, die ab Staffel 2
|
||||||
|
/// stillschweigend etwas anderes tut.
|
||||||
|
public struct AvailableLanguages: Sendable, Equatable {
|
||||||
|
public let audio: [LanguageOption]
|
||||||
|
public let subtitles: [LanguageOption]
|
||||||
|
|
||||||
|
/// Wie viele Sprachen es nur in einem Teil der Folgen gibt.
|
||||||
|
///
|
||||||
|
/// **Damit die Oberfläche es sagen kann.** Eine gekürzte Liste sieht
|
||||||
|
/// sonst aus wie die ganze Wahrheit, und wer eine Sprache vermisst, die
|
||||||
|
/// er in Staffel 1 gesehen hat, hält es für einen Fehler.
|
||||||
|
public let partial: Int
|
||||||
|
|
||||||
|
public init(audio: [LanguageOption], subtitles: [LanguageOption], partial: Int) {
|
||||||
|
self.audio = audio
|
||||||
|
self.subtitles = subtitles
|
||||||
|
self.partial = partial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
/// die Serie, und so heißt es jetzt auch.
|
/// die Serie, und so heißt es jetzt auch.
|
||||||
public let seriesThumb: String?
|
public let seriesThumb: String?
|
||||||
public let seriesArt: String?
|
public let seriesArt: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt.
|
||||||
|
///
|
||||||
|
/// **Vom Server gewählt, nicht hier.** Wer zu einem Titel mehrere
|
||||||
|
/// Aufnahmen ausgesucht hat, bekommt alle paar Stunden eine andere — und
|
||||||
|
/// zwar überall dieselbe. `nil` heisst: keine ausgesucht, dann gilt das
|
||||||
|
/// Hintergrundbild.
|
||||||
|
public let heroArt: String?
|
||||||
|
|
||||||
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
|
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
|
||||||
// Staffel.
|
// Staffel.
|
||||||
@@ -71,6 +78,26 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
/// eine Fähigkeit, die es nur auf einem gibt.
|
/// eine Fähigkeit, die es nur auf einem gibt.
|
||||||
public let isFavourite: Bool
|
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.
|
// Zählwerte bei Serien und Staffeln.
|
||||||
public let episodeCount: Int?
|
public let episodeCount: Int?
|
||||||
public let playedEpisodeCount: Int?
|
public let playedEpisodeCount: Int?
|
||||||
@@ -106,6 +133,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
parentThumb: String? = nil,
|
parentThumb: String? = nil,
|
||||||
seriesThumb: String? = nil,
|
seriesThumb: String? = nil,
|
||||||
seriesArt: String? = nil,
|
seriesArt: String? = nil,
|
||||||
|
heroArt: String? = nil,
|
||||||
index: Int? = nil,
|
index: Int? = nil,
|
||||||
parentIndex: Int? = nil,
|
parentIndex: Int? = nil,
|
||||||
parentTitle: String? = nil,
|
parentTitle: String? = nil,
|
||||||
@@ -117,6 +145,8 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
lastPlayedAt: Date? = nil,
|
lastPlayedAt: Date? = nil,
|
||||||
addedAt: Date? = nil,
|
addedAt: Date? = nil,
|
||||||
isFavourite: Bool = false,
|
isFavourite: Bool = false,
|
||||||
|
isInProgress: Bool? = nil,
|
||||||
|
availableFrom: Date? = nil,
|
||||||
episodeCount: Int? = nil,
|
episodeCount: Int? = nil,
|
||||||
playedEpisodeCount: Int? = nil,
|
playedEpisodeCount: Int? = nil,
|
||||||
childCount: Int? = nil,
|
childCount: Int? = nil,
|
||||||
@@ -144,6 +174,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
self.parentThumb = parentThumb
|
self.parentThumb = parentThumb
|
||||||
self.seriesThumb = seriesThumb
|
self.seriesThumb = seriesThumb
|
||||||
self.seriesArt = seriesArt
|
self.seriesArt = seriesArt
|
||||||
|
self.heroArt = heroArt
|
||||||
self.index = index
|
self.index = index
|
||||||
self.parentIndex = parentIndex
|
self.parentIndex = parentIndex
|
||||||
self.parentTitle = parentTitle
|
self.parentTitle = parentTitle
|
||||||
@@ -155,6 +186,8 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
self.lastPlayedAt = lastPlayedAt
|
self.lastPlayedAt = lastPlayedAt
|
||||||
self.addedAt = addedAt
|
self.addedAt = addedAt
|
||||||
self.isFavourite = isFavourite
|
self.isFavourite = isFavourite
|
||||||
|
self.isInProgress = isInProgress
|
||||||
|
self.availableFrom = availableFrom
|
||||||
self.episodeCount = episodeCount
|
self.episodeCount = episodeCount
|
||||||
self.playedEpisodeCount = playedEpisodeCount
|
self.playedEpisodeCount = playedEpisodeCount
|
||||||
self.childCount = childCount
|
self.childCount = childCount
|
||||||
@@ -225,6 +258,29 @@ extension MediaItem {
|
|||||||
|
|
||||||
public var isWatched: Bool { (playCount ?? 0) > 0 }
|
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.
|
/// Ab wo fortgesetzt wird.
|
||||||
public var resumeTime: Duration {
|
public var resumeTime: Duration {
|
||||||
.milliseconds(resumeMilliseconds ?? 0)
|
.milliseconds(resumeMilliseconds ?? 0)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public struct MediaLibrary: Sendable, Equatable, Identifiable, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Eine Reihe auf der Startseite oder in den Suchergebnissen.
|
/// Eine Reihe auf der Startseite oder in den Suchergebnissen.
|
||||||
public struct MediaRowGroup: Sendable, Equatable, Identifiable {
|
public struct MediaRowGroup: Sendable, Equatable, Identifiable, Codable {
|
||||||
/// Eine stabile Kennung für diese Reihe.
|
/// Eine stabile Kennung für diese Reihe.
|
||||||
///
|
///
|
||||||
/// Sie entscheidet, welche Reihe der Nutzer ausgeblendet oder verschoben
|
/// Sie entscheidet, welche Reihe der Nutzer ausgeblendet oder verschoben
|
||||||
|
|||||||
@@ -112,10 +112,29 @@ public struct PlaybackTarget: Sendable, Equatable {
|
|||||||
/// ordnet es die Meldung keiner Wiedergabe zu.
|
/// ordnet es die Meldung keiner Wiedergabe zu.
|
||||||
public let versionID: String?
|
public let versionID: String?
|
||||||
|
|
||||||
public init(url: URL, sessionID: String? = nil, versionID: String? = nil) {
|
/// Ab welcher Stelle des Films dieser Strom beginnt.
|
||||||
|
///
|
||||||
|
/// **Null heisst: der Strom ist der ganze Film.** Dann springt der
|
||||||
|
/// Spieler selbst, und das ist der Normalfall — bei Jellyfin immer.
|
||||||
|
///
|
||||||
|
/// **Ein Wert darüber heisst: der Server hat schon gesprungen.** Flaros
|
||||||
|
/// Abspielliste wächst von der gewünschten Stelle an vorwärts; was davor
|
||||||
|
/// liegt, gibt es in diesem Strom nicht. Der Spieler zählt trotzdem ab
|
||||||
|
/// null — ohne diesen Wert ginge jede angezeigte Zeit und jede gemeldete
|
||||||
|
/// Stelle um den Vorlauf daneben, und ein Sprung dorthin wäre ein Sprung
|
||||||
|
/// ins Leere.
|
||||||
|
public let startOffset: Duration
|
||||||
|
|
||||||
|
public init(
|
||||||
|
url: URL,
|
||||||
|
sessionID: String? = nil,
|
||||||
|
versionID: String? = nil,
|
||||||
|
startOffset: Duration = .zero
|
||||||
|
) {
|
||||||
self.url = url
|
self.url = url
|
||||||
self.sessionID = sessionID
|
self.sessionID = sessionID
|
||||||
self.versionID = versionID
|
self.versionID = versionID
|
||||||
|
self.startOffset = startOffset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Wie die Vorschaubilder eines Titels zusammengesetzt sind.
|
||||||
|
///
|
||||||
|
/// **Dieselbe Rechnung wie im Server und im Web.** Dort heisst der Typ
|
||||||
|
/// genauso; die Kachel zu einer Sekunde wird an drei Stellen ausgerechnet,
|
||||||
|
/// und wenn eine davon abweicht, sieht man es nicht als Fehler — man hält
|
||||||
|
/// die Vorschau für ein ungenaues Bild.
|
||||||
|
///
|
||||||
|
/// **Die Kachelmasse stehen nicht fest.** Skaliert wird auf eine feste
|
||||||
|
/// Breite, die Höhe folgt dem Seitenverhältnis des Films. Deshalb misst
|
||||||
|
/// der Server sie und teilt sie mit, statt dass sie hier stünden.
|
||||||
|
public struct Vorschauplan: Sendable, Equatable, Decodable {
|
||||||
|
public let intervall: Double
|
||||||
|
public let spalten: Int
|
||||||
|
public let zeilen: Int
|
||||||
|
public let kachelBreite: Double
|
||||||
|
public let kachelHoehe: Double
|
||||||
|
public let blaetter: Int
|
||||||
|
|
||||||
|
public init(
|
||||||
|
intervall: Double, spalten: Int, zeilen: Int,
|
||||||
|
kachelBreite: Double, kachelHoehe: Double, blaetter: Int
|
||||||
|
) {
|
||||||
|
self.intervall = intervall
|
||||||
|
self.spalten = spalten
|
||||||
|
self.zeilen = zeilen
|
||||||
|
self.kachelBreite = kachelBreite
|
||||||
|
self.kachelHoehe = kachelHoehe
|
||||||
|
self.blaetter = blaetter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie eine Kachel adressiert wird: welches Blatt, und wo darin.
|
||||||
|
public struct Stelle: Sendable, Equatable {
|
||||||
|
public let blatt: Int
|
||||||
|
public let spalte: Int
|
||||||
|
public let zeile: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
public var proBlatt: Int { spalten * zeilen }
|
||||||
|
|
||||||
|
/// Welche Kachel die Stelle zeigt — oder nichts.
|
||||||
|
///
|
||||||
|
/// **Hinter dem letzten Bild bleibt das letzte stehen.** Das Raster
|
||||||
|
/// endet dort, wo ffmpeg aufgehört hat; eine Sekunde darüber hinaus —
|
||||||
|
/// bei ungenauer Laufzeit durchaus möglich — soll die Vorschau nicht
|
||||||
|
/// verschwinden.
|
||||||
|
public func stelle(bei sekunde: Double) -> Stelle? {
|
||||||
|
guard blaetter > 0, spalten > 0, zeilen > 0, intervall > 0 else { return nil }
|
||||||
|
|
||||||
|
let nummer = min(Int(max(0, sekunde) / intervall), proBlatt * blaetter - 1)
|
||||||
|
let rest = nummer % proBlatt
|
||||||
|
return Stelle(blatt: nummer / proBlatt, spalte: rest % spalten, zeile: rest / spalten)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import FlaroDesign
|
||||||
|
|
||||||
|
/// Ein Bild von der Platte muss ankommen.
|
||||||
|
///
|
||||||
|
/// **Der Fehler, für den diese Prüfung steht.** Der Lader verlangte von jeder
|
||||||
|
/// Antwort eine `HTTPURLResponse` — eine `file:`-Adresse antwortet aber mit
|
||||||
|
/// einer schlichten `URLResponse`, und die Prüfung gab darauf `nil` zurück.
|
||||||
|
/// Sichtbar wurde das erst, als die Downloads ihre Plakate mitbrachten: die
|
||||||
|
/// Dateien lagen da, und die Kacheln blieben trotzdem grau.
|
||||||
|
@Suite("Bilder von der Platte")
|
||||||
|
struct ArtworkLoaderTests {
|
||||||
|
/// Ein gültiges PNG, ein Bildpunkt gross.
|
||||||
|
private static let winzigesPNG = Data(base64Encoded: """
|
||||||
|
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmM\
|
||||||
|
IQAAAABJRU5ErkJggg==
|
||||||
|
""")!
|
||||||
|
|
||||||
|
private func ablegen(_ daten: Data, als name: String) throws -> URL {
|
||||||
|
let ziel = FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "artwork-\(UUID().uuidString)-\(name)")
|
||||||
|
try daten.write(to: ziel)
|
||||||
|
return ziel
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine Datei wird gelesen, nicht abgefragt")
|
||||||
|
func dateiWirdGelesen() async throws {
|
||||||
|
let datei = try ablegen(Self.winzigesPNG, als: "plakat.bild")
|
||||||
|
defer { try? FileManager.default.removeItem(at: datei) }
|
||||||
|
|
||||||
|
let bild = await ArtworkLoader.shared.image(for: datei)
|
||||||
|
#expect(bild != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine Datei, die es nicht gibt, ergibt kein Bild")
|
||||||
|
func fehlendeDatei() async {
|
||||||
|
let nirgends = FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "gibt-es-nicht-\(UUID().uuidString).bild")
|
||||||
|
let bild = await ArtworkLoader.shared.image(for: nirgends)
|
||||||
|
#expect(bild == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Was keine Bilddaten sind, ergibt kein Bild")
|
||||||
|
func keinBild() async throws {
|
||||||
|
let datei = try ablegen(Data("kein Bild, nur Text".utf8), als: "text.bild")
|
||||||
|
defer { try? FileManager.default.removeItem(at: datei) }
|
||||||
|
|
||||||
|
let bild = await ArtworkLoader.shared.image(for: datei)
|
||||||
|
#expect(bild == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,11 @@ import MediaKit
|
|||||||
/// Adressen und Zugang — alles, was ohne Netz entsteht.
|
/// Adressen und Zugang — alles, was ohne Netz entsteht.
|
||||||
@Suite("Flaro: Adressen und Zugang")
|
@Suite("Flaro: Adressen und Zugang")
|
||||||
struct AdressenTests {
|
struct AdressenTests {
|
||||||
private func backend(_ transport: Attrappe, keksglas: Keksglas? = nil) -> FlaroBackend {
|
private func backend(_ transport: Attrappe, cookieglas: Cookieglas? = nil) -> FlaroBackend {
|
||||||
FlaroBackend(
|
FlaroBackend(
|
||||||
client: FlaroClient(
|
client: FlaroClient(
|
||||||
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
||||||
transport: transport, cookieJar: keksglas),
|
transport: transport, cookieJar: cookieglas),
|
||||||
account: FlaroAccount(
|
account: FlaroAccount(
|
||||||
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
||||||
userID: "u1", userName: "guido", isAdministrator: true))
|
userID: "u1", userName: "guido", isAdministrator: true))
|
||||||
@@ -32,17 +32,17 @@ struct AdressenTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test("Ein Zugang wird als Cookie abgelegt")
|
@Test("Ein Zugang wird als Cookie abgelegt")
|
||||||
func tokenWirdZuKeks() async {
|
func tokenWirdZuCookie() async {
|
||||||
let glas = Keksglas()
|
let glas = Cookieglas()
|
||||||
_ = FlaroClient(
|
_ = FlaroClient(
|
||||||
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
||||||
transport: Attrappe(rumpf: "{}"), cookieJar: glas)
|
transport: Attrappe(rumpf: "{}"), cookieJar: glas)
|
||||||
|
|
||||||
let keks = glas.kekse.first
|
let cookie = glas.cookies.first
|
||||||
#expect(keks?.name == "flaro_session")
|
#expect(cookie?.name == "flaro_session")
|
||||||
#expect(keks?.value == "geheim-123")
|
#expect(cookie?.value == "geheim-123")
|
||||||
#expect(keks?.path == "/")
|
#expect(cookie?.path == "/")
|
||||||
#expect(keks?.domain.hasSuffix("nas") == true)
|
#expect(cookie?.domain.hasSuffix("nas") == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **Nach einem Neustart meldet sich niemand neu an.**
|
/// **Nach einem Neustart meldet sich niemand neu an.**
|
||||||
@@ -51,8 +51,8 @@ struct AdressenTests {
|
|||||||
/// allein aus der Antwort des Servers, wäre keines da — und jedes Plakat
|
/// allein aus der Antwort des Servers, wäre keines da — und jedes Plakat
|
||||||
/// bliebe grau, obwohl der Zugang gilt.
|
/// bliebe grau, obwohl der Zugang gilt.
|
||||||
@Test("Auch ein wiederaufgenommenes Token legt das Cookie an")
|
@Test("Auch ein wiederaufgenommenes Token legt das Cookie an")
|
||||||
func keksAuchOhneAnmeldung() async throws {
|
func cookieAuchOhneAnmeldung() async throws {
|
||||||
let glas = Keksglas()
|
let glas = Cookieglas()
|
||||||
let transport = Attrappe([
|
let transport = Attrappe([
|
||||||
("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#),
|
("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#),
|
||||||
("/api/v1/system/info", 200, Proben.info),
|
("/api/v1/system/info", 200, Proben.info),
|
||||||
@@ -62,7 +62,7 @@ struct AdressenTests {
|
|||||||
baseURL: Proben.adresse, token: "geheim-123",
|
baseURL: Proben.adresse, token: "geheim-123",
|
||||||
identity: Proben.kennung, transport: transport, cookieJar: glas)
|
identity: Proben.kennung, transport: transport, cookieJar: glas)
|
||||||
|
|
||||||
#expect(glas.kekse.contains { $0.name == "flaro_session" && $0.value == "geheim-123" })
|
#expect(glas.cookies.contains { $0.name == "flaro_session" && $0.value == "geheim-123" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Die Download-Adresse zeigt auf die Datei, nicht auf den Titel")
|
@Test("Die Download-Adresse zeigt auf die Datei, nicht auf den Titel")
|
||||||
@@ -104,7 +104,7 @@ struct AdressenTests {
|
|||||||
|
|
||||||
let (token, konto, _) = try await FlaroSignIn.signIn(
|
let (token, konto, _) = try await FlaroSignIn.signIn(
|
||||||
baseURL: Proben.adresse, userName: "guido", password: "geheim",
|
baseURL: Proben.adresse, userName: "guido", password: "geheim",
|
||||||
identity: Proben.kennung, transport: transport, cookieJar: Keksglas())
|
identity: Proben.kennung, transport: transport, cookieJar: Cookieglas())
|
||||||
|
|
||||||
let rumpf = try #require(transport.rumpf(von: "/api/v1/auth/login"))
|
let rumpf = try #require(transport.rumpf(von: "/api/v1/auth/login"))
|
||||||
|
|
||||||
|
|||||||
@@ -46,15 +46,15 @@ final class Attrappe: FlaroTransport, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein Keksglas, das nur mitschreibt.
|
/// Ein Cookieglas, das nur mitschreibt.
|
||||||
///
|
///
|
||||||
/// Der Speicher des Systems anzufassen hiesse, dass eine Prüfung die nächste
|
/// Der Speicher des Systems anzufassen hiesse, dass eine Prüfung die nächste
|
||||||
/// beeinflusst — und auf einem Entwicklerrechner auch den Browser.
|
/// beeinflusst — und auf einem Entwicklerrechner auch den Browser.
|
||||||
final class Keksglas: FlaroCookieJar, @unchecked Sendable {
|
final class Cookieglas: FlaroCookieJar, @unchecked Sendable {
|
||||||
private let sperre = NSLock()
|
private let sperre = NSLock()
|
||||||
private var inhalt: [HTTPCookie] = []
|
private var inhalt: [HTTPCookie] = []
|
||||||
|
|
||||||
var kekse: [HTTPCookie] { sperre.withLock { inhalt } }
|
var cookies: [HTTPCookie] { sperre.withLock { inhalt } }
|
||||||
|
|
||||||
func store(_ cookie: HTTPCookie) { sperre.withLock { inhalt.append(cookie) } }
|
func store(_ cookie: HTTPCookie) { sperre.withLock { inhalt.append(cookie) } }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import MediaKit
|
||||||
|
@testable import FlaroServerKit
|
||||||
|
|
||||||
|
/// Vorspann und Abspann auf dem Weg vom Server in den Spieler.
|
||||||
|
///
|
||||||
|
/// **Der Anlass:** im Web erschien der Überspringen-Knopf, in der App nie.
|
||||||
|
/// Dazwischen liegt genau diese Übersetzung — und ohne Prüfung liesse sich
|
||||||
|
/// nicht sagen, ob sie der Grund ist oder etwas dahinter.
|
||||||
|
@Suite("Flaro: Marken")
|
||||||
|
struct MarkenTests {
|
||||||
|
private static let folge = """
|
||||||
|
{
|
||||||
|
"id": "folge-1", "season": 1, "episode": 6,
|
||||||
|
"title": "Bestimmung", "overview": null, "stillImage": null,
|
||||||
|
"files": [{
|
||||||
|
"id": "datei-1", "fileName": "F.mkv", "sizeBytes": 1,
|
||||||
|
"container": "mkv", "durationSeconds": 2700, "streams": []
|
||||||
|
}],
|
||||||
|
"state": null, "publishAt": null, "seriesId": "serie-1",
|
||||||
|
"markers": [
|
||||||
|
{ "kind": "intro", "startSeconds": 30.5, "endSeconds": 65 },
|
||||||
|
{ "kind": "credits", "startSeconds": 2500, "endSeconds": 2700 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
@Test("Eine Folge bringt Vorspann und Abspann mit")
|
||||||
|
func folgeMitMarken() throws {
|
||||||
|
let roh = try #require(
|
||||||
|
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(Self.folge.utf8)))
|
||||||
|
let titel = roh.neutral()
|
||||||
|
|
||||||
|
#expect(titel.markers.count == 2)
|
||||||
|
|
||||||
|
let intro = try #require(titel.markers.first { $0.kind == .intro })
|
||||||
|
#expect(intro.startMilliseconds == 30_500)
|
||||||
|
#expect(intro.endMilliseconds == 65_000)
|
||||||
|
#expect(intro.contains(45_000))
|
||||||
|
|
||||||
|
let abspann = try #require(titel.markers.first { $0.kind == .credits })
|
||||||
|
#expect(abspann.startMilliseconds == 2_500_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein älterer Server schickt das Feld gar nicht — das ist kein Fehler.
|
||||||
|
@Test("Ohne Marken bleibt die Liste leer")
|
||||||
|
func ohneMarken() throws {
|
||||||
|
let ohne = """
|
||||||
|
{
|
||||||
|
"id": "folge-1", "season": 1, "episode": 6,
|
||||||
|
"title": "Bestimmung", "overview": null, "stillImage": null,
|
||||||
|
"files": [], "state": null, "publishAt": null, "seriesId": "serie-1"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let roh = try #require(
|
||||||
|
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(ohne.utf8)))
|
||||||
|
|
||||||
|
#expect(roh.neutral().markers.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein unbekanntes Wort wirft die Marke nicht weg — sie zählt nur nicht.
|
||||||
|
@Test("Eine unbekannte Art wird nicht zur falschen")
|
||||||
|
func unbekannteArt() throws {
|
||||||
|
let fremd = Self.folge.replacingOccurrences(of: "\"intro\"", with: "\"werbung\"")
|
||||||
|
let roh = try #require(
|
||||||
|
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(fremd.utf8)))
|
||||||
|
let titel = roh.neutral()
|
||||||
|
|
||||||
|
#expect(titel.markers.count == 2)
|
||||||
|
#expect(titel.markers.first?.kind == .unknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import MediaKit
|
||||||
|
@testable import FlaroServerKit
|
||||||
|
|
||||||
|
/// Die Folge nach dieser — über die Staffelgrenze hinweg.
|
||||||
|
///
|
||||||
|
/// **Der Anlass.** Im Web stand am Abspann „Nächste Folge", in der App nur
|
||||||
|
/// „Abspann überspringen". Der Server war es nicht: beide fragen denselben
|
||||||
|
/// Endpunkt, und er ordnet nach Staffel und dann nach Nummer, findet also
|
||||||
|
/// auch die erste Folge der nächsten Staffel.
|
||||||
|
///
|
||||||
|
/// **Es war der Dekoder.** `nachbar` baute sich einen frischen
|
||||||
|
/// `JSONDecoder()`, und der kennt das Datumsformat des Servers nicht.
|
||||||
|
/// Solange die Nachbarfolge weder `publishAt` noch `lastPlayedAt` hatte,
|
||||||
|
/// ging es gut — mit eingeschalteter Prüfung hat sie ein `publishAt`. Das
|
||||||
|
/// `try?` schluckte den Fehler, und von aussen sah es aus, als gäbe es
|
||||||
|
/// keine nächste Folge.
|
||||||
|
@Suite("Flaro: Nachbarfolge")
|
||||||
|
struct NachbarfolgeTests {
|
||||||
|
private func backend(_ transport: any FlaroTransport) -> FlaroBackend {
|
||||||
|
FlaroBackend(
|
||||||
|
client: FlaroClient(
|
||||||
|
baseURL: Proben.adresse, identity: Proben.kennung, token: "t",
|
||||||
|
transport: transport, cookieJar: nil),
|
||||||
|
account: FlaroAccount(
|
||||||
|
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
||||||
|
userID: "u1", userName: "guido", isAdministrator: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func folge(
|
||||||
|
id: String, staffel: Int, nummer: Int,
|
||||||
|
publishAt: String = "null", zuletzt: String = "null"
|
||||||
|
) -> String {
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id": "\(id)", "season": \(staffel), "episode": \(nummer),
|
||||||
|
"title": "Neuanfang", "overview": null, "stillImage": null,
|
||||||
|
"files": [{
|
||||||
|
"id": "d-\(id)", "fileName": "F.mkv", "sizeBytes": 1,
|
||||||
|
"container": "mkv", "durationSeconds": 2700, "streams": []
|
||||||
|
}],
|
||||||
|
"state": { "positionSeconds": 0, "runtimeSeconds": 2700,
|
||||||
|
"watched": false, "playCount": 0, "favourite": false,
|
||||||
|
"rating": null, "dismissed": false,
|
||||||
|
"lastPlayedAt": \(zuletzt), "inProgress": false },
|
||||||
|
"publishAt": \(publishAt), "seriesId": "serie-1", "markers": []
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let folgeJetzt = MediaItem(
|
||||||
|
id: "s1e10", kind: .episode, title: "Finale", index: 10, parentIndex: 1)
|
||||||
|
|
||||||
|
/// Das Staffelfinale: die nächste Folge ist die erste der nächsten Staffel.
|
||||||
|
@Test("Über die Staffelgrenze hinweg")
|
||||||
|
func ueberDieStaffelgrenze() async throws {
|
||||||
|
let backend = backend(Attrappe(rumpf: Self.folge(id: "s2e1", staffel: 2, nummer: 1)))
|
||||||
|
|
||||||
|
let naechste = try #require(await backend.nextEpisode(after: Self.folgeJetzt))
|
||||||
|
|
||||||
|
#expect(naechste.id == "s2e1")
|
||||||
|
#expect(naechste.parentIndex == 2)
|
||||||
|
#expect(naechste.index == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Der Fehler selbst.** Mit `publishAt` und `lastPlayedAt` scheiterte
|
||||||
|
/// das Lesen stillschweigend, und der Knopf blieb weg. Mit dem frischen
|
||||||
|
/// `JSONDecoder()` von vorher ist diese Prüfung rot.
|
||||||
|
@Test("Auch mit Datumsangaben — daran scheiterte es")
|
||||||
|
func mitDatumsangaben() async throws {
|
||||||
|
let backend = backend(Attrappe(rumpf: Self.folge(
|
||||||
|
id: "s2e1", staffel: 2, nummer: 1,
|
||||||
|
publishAt: "\"2026-10-01T18:00:00Z\"",
|
||||||
|
zuletzt: "\"2026-10-11T21:14:03.482Z\"")))
|
||||||
|
|
||||||
|
let naechste = try #require(await backend.nextEpisode(after: Self.folgeJetzt))
|
||||||
|
|
||||||
|
#expect(naechste.id == "s2e1")
|
||||||
|
#expect(naechste.availableFrom != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nach der letzten Folge kommt keine — 204, und das ist kein Fehler.
|
||||||
|
@Test("Hinter der letzten Folge kommt nichts")
|
||||||
|
func hinterDerLetzten() async throws {
|
||||||
|
let backend = backend(Attrappe(rumpf: "", status: 204))
|
||||||
|
|
||||||
|
#expect(try await backend.nextEpisode(after: Self.folgeJetzt) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Film hat keine Nachbarn — und fragt gar nicht erst.
|
||||||
|
@Test("Ein Film fragt nicht nach Nachbarn")
|
||||||
|
func filmFragtNicht() async throws {
|
||||||
|
let attrappe = Attrappe(rumpf: Self.folge(id: "x", staffel: 1, nummer: 1))
|
||||||
|
let film = MediaItem(id: "f1", kind: .movie, title: "Arrival")
|
||||||
|
|
||||||
|
#expect(try await backend(attrappe).nextEpisode(after: film) == nil)
|
||||||
|
#expect(attrappe.anfragen.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import MediaKit
|
||||||
|
|
||||||
|
/// Ein Server, der auf Ansage annimmt oder nicht.
|
||||||
|
private final class Briefkasten: Fortschrittsempfaenger, @unchecked Sendable {
|
||||||
|
nonisolated let identity: String
|
||||||
|
/// Ob Meldungen angenommen werden.
|
||||||
|
var nimmtAn: Bool
|
||||||
|
private(set) var angekommen: [PlaybackReport] = []
|
||||||
|
|
||||||
|
init(identity: String = "server-a", nimmtAn: Bool = true) {
|
||||||
|
self.identity = identity
|
||||||
|
self.nimmtAn = nimmtAn
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportProgress(_ report: PlaybackReport) async -> Bool {
|
||||||
|
guard nimmtAn else { return false }
|
||||||
|
angekommen.append(report)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bericht(_ titel: String, sekunden: Double) -> PlaybackReport {
|
||||||
|
PlaybackReport(
|
||||||
|
itemID: titel,
|
||||||
|
state: .stopped,
|
||||||
|
time: .seconds(sekunden),
|
||||||
|
duration: .seconds(3600)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Nachgereichter Fortschritt")
|
||||||
|
@MainActor
|
||||||
|
struct FortschrittspostTests {
|
||||||
|
/// Eine eigene Datei je Prüfung — sonst sähe die zweite, was die erste
|
||||||
|
/// liegen gelassen hat.
|
||||||
|
private func ablage() -> URL {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "post-\(UUID().uuidString).json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Was ankam, wird nicht aufgehoben")
|
||||||
|
func nichtsLiegenLassen() async {
|
||||||
|
let post = Fortschrittspost(ablage: ablage())
|
||||||
|
let server = Briefkasten()
|
||||||
|
|
||||||
|
post.zuruecklegen(bericht("film", sekunden: 600), server: server.identity)
|
||||||
|
#expect(post.wartend == 1)
|
||||||
|
|
||||||
|
await post.zustellen(über: server)
|
||||||
|
#expect(post.wartend == 0)
|
||||||
|
#expect(server.angekommen.count == 1)
|
||||||
|
#expect(server.angekommen.first?.itemID == "film")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Was nicht ankommt, bleibt liegen")
|
||||||
|
func liegenbleiben() async {
|
||||||
|
let post = Fortschrittspost(ablage: ablage())
|
||||||
|
let server = Briefkasten(nimmtAn: false)
|
||||||
|
|
||||||
|
post.zuruecklegen(bericht("film", sekunden: 600), server: server.identity)
|
||||||
|
await post.zustellen(über: server)
|
||||||
|
#expect(post.wartend == 1)
|
||||||
|
|
||||||
|
server.nimmtAn = true
|
||||||
|
await post.zustellen(über: server)
|
||||||
|
#expect(post.wartend == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Je Titel nur der letzte Stand")
|
||||||
|
func nurDerLetzteStand() async {
|
||||||
|
let post = Fortschrittspost(ablage: ablage())
|
||||||
|
let server = Briefkasten()
|
||||||
|
|
||||||
|
post.zuruecklegen(bericht("film", sekunden: 60), server: server.identity)
|
||||||
|
post.zuruecklegen(bericht("film", sekunden: 1800), server: server.identity)
|
||||||
|
#expect(post.wartend == 1)
|
||||||
|
|
||||||
|
await post.zustellen(über: server)
|
||||||
|
#expect(server.angekommen.count == 1)
|
||||||
|
#expect(server.angekommen.first?.time == .seconds(1800))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein fremder Server bekommt nichts")
|
||||||
|
func nichtAnDenFalschen() async {
|
||||||
|
let post = Fortschrittspost(ablage: ablage())
|
||||||
|
post.zuruecklegen(bericht("film", sekunden: 600), server: "server-a")
|
||||||
|
|
||||||
|
let anderer = Briefkasten(identity: "server-b")
|
||||||
|
await post.zustellen(über: anderer)
|
||||||
|
#expect(anderer.angekommen.isEmpty)
|
||||||
|
#expect(post.wartend == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Der Briefkasten überlebt den Neustart")
|
||||||
|
func aufDerPlatte() async {
|
||||||
|
let datei = ablage()
|
||||||
|
let erste = Fortschrittspost(ablage: datei)
|
||||||
|
erste.zuruecklegen(bericht("folge", sekunden: 900), server: "server-a")
|
||||||
|
|
||||||
|
let zweite = Fortschrittspost(ablage: datei)
|
||||||
|
#expect(zweite.wartend == 1)
|
||||||
|
|
||||||
|
let server = Briefkasten()
|
||||||
|
await zweite.zustellen(über: server)
|
||||||
|
#expect(server.angekommen.first?.time == .seconds(900))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leeren wirft alles weg")
|
||||||
|
func leeren() async {
|
||||||
|
let post = Fortschrittspost(ablage: ablage())
|
||||||
|
post.zuruecklegen(bericht("film", sekunden: 600), server: "server-a")
|
||||||
|
post.leeren()
|
||||||
|
#expect(post.wartend == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import MediaKit
|
||||||
|
|
||||||
|
@Suite("Die gemerkte Startseite")
|
||||||
|
struct LibraryCacheHomeTests {
|
||||||
|
private func speicher() -> LibraryCache {
|
||||||
|
LibraryCache(root: FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "cache-\(UUID().uuidString)", directoryHint: .isDirectory))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func titel(_ nummer: Int) -> MediaItem {
|
||||||
|
MediaItem(id: "t\(nummer)", kind: .movie, title: "Titel \(nummer)", thumb: "/bild/\(nummer)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Was gespeichert wurde, kommt zurück")
|
||||||
|
func hinUndZurueck() async {
|
||||||
|
let cache = speicher()
|
||||||
|
let reihen = [
|
||||||
|
MediaRowGroup(id: "continue", title: "Weiterschauen",
|
||||||
|
libraryID: "mediathek", items: [titel(1), titel(2)]),
|
||||||
|
MediaRowGroup(id: "latest.1", title: "Neu", moreKey: "/mehr", items: [titel(3)]),
|
||||||
|
]
|
||||||
|
|
||||||
|
await cache.storeHomeRows(reihen, server: "server-a")
|
||||||
|
let gemerkt = await cache.homeRows(server: "server-a")
|
||||||
|
|
||||||
|
#expect(gemerkt?.map(\.id) == ["continue", "latest.1"])
|
||||||
|
#expect(gemerkt?.first?.title == "Weiterschauen")
|
||||||
|
#expect(gemerkt?.first?.libraryID == "mediathek")
|
||||||
|
#expect(gemerkt?.last?.moreKey == "/mehr")
|
||||||
|
#expect(gemerkt?.first?.items.map(\.id) == ["t1", "t2"])
|
||||||
|
// Ohne den Bildpfad wäre der Abzug wertlos: die Plakate liegen
|
||||||
|
// bereits auf der Platte, nur findet sie dann niemand.
|
||||||
|
#expect(gemerkt?.first?.items.first?.thumb == "/bild/1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein anderer Server hat seine eigene Startseite")
|
||||||
|
func getrenntJeServer() async {
|
||||||
|
let cache = speicher()
|
||||||
|
await cache.storeHomeRows(
|
||||||
|
[MediaRowGroup(id: "continue", title: "Weiterschauen", items: [titel(1)])],
|
||||||
|
server: "server-a"
|
||||||
|
)
|
||||||
|
let fremd = await cache.homeRows(server: "server-b")
|
||||||
|
#expect(fremd == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne gespeicherten Stand kommt nichts")
|
||||||
|
func nochNichtsDa() async {
|
||||||
|
let gemerkt = await speicher().homeRows(server: "server-a")
|
||||||
|
#expect(gemerkt == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Lange Reihen werden gekürzt")
|
||||||
|
func nichtAlles() async {
|
||||||
|
let cache = speicher()
|
||||||
|
let lang = (1...80).map(titel)
|
||||||
|
await cache.storeHomeRows(
|
||||||
|
[MediaRowGroup(id: "latest", title: "Neu", items: lang)], server: "server-a"
|
||||||
|
)
|
||||||
|
let gemerkt = await cache.homeRows(server: "server-a")
|
||||||
|
#expect(gemerkt?.first?.items.count == 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,22 +43,106 @@ if ! grep -q "$REGISTRY" ~/.docker/config.json 2>/dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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")
|
MARKEN=(-t "$BILD:latest")
|
||||||
[ -n "$FASSUNG" ] && MARKEN+=(-t "$BILD:$FASSUNG")
|
[ -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
|
# **Ein eigener Builder.** Der Vorgabe-Builder von Docker Desktop kann kein
|
||||||
# Manifest mit zwei Architekturen bauen; `docker buildx create` schon.
|
# Manifest mit zwei Architekturen bauen; `docker buildx create` schon.
|
||||||
docker buildx inspect flaro-bauer >/dev/null 2>&1 \
|
docker buildx inspect flaro-bauer >/dev/null 2>&1 \
|
||||||
|| docker buildx create --name flaro-bauer --use >/dev/null
|
|| docker buildx create --name flaro-bauer --use >/dev/null
|
||||||
|
|
||||||
|
# **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."
|
echo "Baue $BILD für $PLATTFORMEN."
|
||||||
docker buildx build \
|
if ! docker buildx build \
|
||||||
--builder flaro-bauer \
|
--builder flaro-bauer \
|
||||||
--platform "$PLATTFORMEN" \
|
--platform "$PLATTFORMEN" \
|
||||||
-f server/Dockerfile \
|
-f server/Dockerfile \
|
||||||
|
--build-arg "FLARO_BUILD=$BUILD" \
|
||||||
"${MARKEN[@]}" \
|
"${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
|
||||||
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}"
|
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
|
## 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)
|
### Weg A — über die Registry deines Gitea (empfohlen)
|
||||||
|
|
||||||
Dein Gitea unter `git.scarriffle.com` bringt eine Container-Registry mit.
|
Dein Gitea unter `git.scarriffle.com` bringt eine Container-Registry mit.
|
||||||
Einmal einrichten, danach ist jedes Update ein `docker compose pull`.
|
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`
|
Nicht mit dem Kontopasswort, sondern mit einem Zugriffstoken: *Gitea →
|
||||||
baut Gitea das Image für **beide** Architekturen und legt es ab — ohne dass
|
Einstellungen → Anwendungen → Zugriffstoken*, Berechtigungen
|
||||||
jemand daran denken muss, und ohne Anmeldung am Arbeitsrechner.
|
`write:package` und `read:package`. Ein Token lässt sich einzeln widerrufen,
|
||||||
|
und bei eingeschalteter Zwei-Faktor-Anmeldung funktioniert das Passwort hier
|
||||||
**Voraussetzung ist ein registrierter Act-Runner.** Gitea verteilt Arbeit
|
gar nicht.
|
||||||
nur, es baut nicht selbst: ohne Runner legt es bei jedem Push einen Lauf an,
|
|
||||||
der dann liegenbleibt. Nachsehen unter
|
|
||||||
*Repo → Einstellungen → Actions → Runner*.
|
|
||||||
|
|
||||||
Der Runner gehört **auf das NAS** — dort läuft Docker ohnehin, und damit
|
|
||||||
braucht es für ein neues Image nie wieder einen Arbeitsrechner. Fertig
|
|
||||||
vorbereitet liegt er in `server/docker-compose.runner.yml`; hineingehört nur
|
|
||||||
das Registrierungstoken aus derselben Gitea-Seite.
|
|
||||||
|
|
||||||
Danach erscheint der Runner in der Liste, und der wartende Lauf startet —
|
|
||||||
oder spätestens der nächste Push.
|
|
||||||
|
|
||||||
#### A2 · Von Hand vom Arbeitsrechner
|
|
||||||
|
|
||||||
Einmalig anmelden — das Passwort tippst du selbst ein:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker login git.scarriffle.com
|
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
|
```bash
|
||||||
Tools/image-veroeffentlichen.sh
|
Tools/image-veroeffentlichen.sh
|
||||||
@@ -88,6 +83,13 @@ Mit einer Fassungsnummer, wenn du sie festnageln willst:
|
|||||||
Tools/image-veroeffentlichen.sh 0.2.0
|
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
|
#### A3 · Das Paket öffentlich stellen
|
||||||
|
|
||||||
Sonst muss sich auch das NAS anmelden. In Gitea unter
|
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
|
**Bestätigen** — danach findet die Suche in der Docker-App auch
|
||||||
`scarriffle/flaro`. Ist das Paket privat, trägst du unter
|
`scarriffle/flaro`. Ist das Paket privat, trägst du unter
|
||||||
*Registierungseinstellungen* Benutzername und ein Gitea-Zugriffstoken ein
|
*Registierungseinstellungen* Benutzername und dasselbe Zugriffstoken ein.
|
||||||
(nicht dein Kontopasswort: ein Token lässt sich einzeln widerrufen, unter
|
|
||||||
*Einstellungen → Anwendungen → Zugriffstoken*, Berechtigung `read:package`).
|
#### 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:
|
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`.
|
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
|
`.gitea/workflows/image.yml` liegt im Projekt und kann denselben Bau auf
|
||||||
.NET-SDK als Zwischenschritt und ordentlich Arbeitsspeicher. Die Docker-App
|
einem Act-Runner ausführen. **Gitea verteilt Arbeit nur, es baut nicht
|
||||||
von UGREEN kann `build:` in einer Compose-Datei ausserdem nicht immer.
|
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
|
## 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
|
```bash
|
||||||
docker compose pull && docker compose up -d
|
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
|
ARG TARGETARCH
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
ffmpeg libva2 libva-drm2 mesa-va-drivers \
|
ffmpeg libva2 libva-drm2 mesa-va-drivers curl \
|
||||||
|
libchromaprint-tools \
|
||||||
&& if [ "$TARGETARCH" = "amd64" ]; then \
|
&& if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \
|
apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \
|
||||||
fi \
|
fi \
|
||||||
@@ -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.
|
# braucht weder ein Startskript noch root im Container.
|
||||||
USER 1000:1000
|
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 \
|
ENV FLARO_DATA=/config \
|
||||||
FLARO_CACHE=/cache \
|
FLARO_CACHE=/cache \
|
||||||
FLARO_TRANSCODE=/transcode \
|
FLARO_TRANSCODE=/transcode \
|
||||||
ASPNETCORE_URLS=http://+:8080 \
|
|
||||||
DOTNET_gcServer=0
|
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
|
WORKDIR /app
|
||||||
COPY --from=build /app .
|
COPY --from=build /app .
|
||||||
COPY --from=web /web/dist ./wwwroot
|
COPY --from=web /web/dist ./wwwroot
|
||||||
|
|
||||||
EXPOSE 8080
|
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"]
|
VOLUME ["/config", "/cache", "/transcode"]
|
||||||
|
|
||||||
ENTRYPOINT ["dotnet", "Flaro.Api.dll"]
|
ENTRYPOINT ["dotnet", "Flaro.Api.dll"]
|
||||||
|
|||||||
@@ -31,6 +31,20 @@ services:
|
|||||||
# Datenbank oder ein zweiter Server finden Zuordnungen und Bilder wieder.
|
# Datenbank oder ein zweiter Server finden Zuordnungen und Bilder wieder.
|
||||||
- FLARO_WRITE_SIDECARS=true
|
- 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
|
# 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
|
# im Image; wer andere Kennungen hat, trägt sie hier ein — `id -u` und
|
||||||
# `id -g` auf dem Wirt sagen, welche.
|
# `id -g` auf dem Wirt sagen, welche.
|
||||||
@@ -67,9 +81,7 @@ services:
|
|||||||
devices:
|
devices:
|
||||||
- /dev/dri:/dev/dri
|
- /dev/dri:/dev/dri
|
||||||
|
|
||||||
healthcheck:
|
# Die Gesundheitsprüfung steckt im Image und fragt /health ab — hier
|
||||||
test: ["CMD", "dotnet", "--info"]
|
# braucht es sie nicht noch einmal. Die alte Fassung prüfte `dotnet
|
||||||
interval: 30s
|
# --info` und sagte damit nur, dass die Laufzeit da ist; das war sie auch,
|
||||||
timeout: 5s
|
# während der Server noch migrierte.
|
||||||
retries: 3
|
|
||||||
start_period: 20s
|
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Ein Gitea-Runner auf dem NAS.
|
# 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
|
# **Wozu.** Gitea verteilt Arbeit nur, es baut nicht selbst. Ohne einen
|
||||||
# registrierten Runner legt es bei jedem Push einen Lauf an, der dann
|
# 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
|
# liegenbleibt. Mit diesem hier baut das NAS das Flaro-Image selbst und legt
|
||||||
|
|||||||
@@ -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>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <c>Authorization: Bearer <token></c>. Der Server hasht das Vorgezeigte
|
/// <c>Authorization: Bearer <token></c>. Der Server hasht das Vorgezeigte
|
||||||
/// und sucht den Fingerabdruck — das Token selbst steht nirgends in der
|
/// und sucht den Fingerfingerprint — das Token selbst steht nirgends in der
|
||||||
/// Datenbank und ist aus ihr auch nicht zu gewinnen.
|
/// Datenbank und ist aus ihr auch nicht zu gewinnen.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -69,8 +69,8 @@ public sealed class TokenAuthenticationHandler(
|
|||||||
var token = AusKopfzeile() ?? Request.Cookies[CookieName];
|
var token = AusKopfzeile() ?? Request.Cookies[CookieName];
|
||||||
if (string.IsNullOrEmpty(token)) return AuthenticateResult.NoResult();
|
if (string.IsNullOrEmpty(token)) return AuthenticateResult.NoResult();
|
||||||
|
|
||||||
var fingerabdruck = Secrets.Fingerprint(token);
|
var fingerfingerprint = Secrets.Fingerprint(token);
|
||||||
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerabdruck);
|
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerfingerprint);
|
||||||
|
|
||||||
var jetzt = clock.GetUtcNow();
|
var jetzt = clock.GetUtcNow();
|
||||||
if (eintrag is null || !eintrag.IsValid(jetzt)) return AuthenticateResult.Fail("Token gilt nicht.");
|
if (eintrag is null || !eintrag.IsValid(jetzt)) return AuthenticateResult.Fail("Token gilt nicht.");
|
||||||
|
|||||||
@@ -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();
|
return Results.NoContent();
|
||||||
}).AllowAnonymous();
|
}).AllowAnonymous();
|
||||||
|
|
||||||
app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) =>
|
// **Aus der Datenbank, nicht nur aus dem Token.** Name und Rolle stehen
|
||||||
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id)
|
// in den Ansprüchen; Sprache und Bild ändern sich, ohne dass ein neues
|
||||||
? Results.Ok(new AuthenticatedUser(
|
// Token ausgestellt wird. Wer sein Bild tauscht und die Seite neu lädt,
|
||||||
id,
|
// sähe sonst das alte, bis er sich neu anmeldet.
|
||||||
wer.FindFirstValue(ClaimTypes.Name) ?? "",
|
app.MapGet("/api/v1/auth/me", async (ClaimsPrincipal wer, FlaroContext db) =>
|
||||||
wer.IsInRole(FlaroRoles.Administrator)))
|
{
|
||||||
: Results.Unauthorized())
|
if (!Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id))
|
||||||
.RequireAuthorization();
|
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);
|
MapCodeFlow(app);
|
||||||
}
|
}
|
||||||
@@ -120,8 +128,8 @@ public static class AuthEndpoints
|
|||||||
string handle, FlaroContext db, TimeProvider clock) =>
|
string handle, FlaroContext db, TimeProvider clock) =>
|
||||||
{
|
{
|
||||||
var jetzt = clock.GetUtcNow();
|
var jetzt = clock.GetUtcNow();
|
||||||
var fingerabdruck = Secrets.Fingerprint(handle);
|
var fingerfingerprint = Secrets.Fingerprint(handle);
|
||||||
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerabdruck);
|
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerfingerprint);
|
||||||
|
|
||||||
if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null)
|
if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null)
|
||||||
return Results.Ok(new CodeState("expired", null));
|
return Results.Ok(new CodeState("expired", null));
|
||||||
@@ -152,7 +160,7 @@ public static class AuthEndpoints
|
|||||||
c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt);
|
c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt);
|
||||||
|
|
||||||
if (offen is null)
|
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;
|
offen.ApprovedByUserId = benutzerId;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -190,7 +198,9 @@ public static class AuthEndpoints
|
|||||||
LastSeenAt = jetzt,
|
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;
|
private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName;
|
||||||
|
|||||||
@@ -5,8 +5,100 @@ namespace Flaro.Api.Endpoints;
|
|||||||
// und bricht Clients, die niemand mehr aktualisiert.
|
// und bricht Clients, die niemand mehr aktualisiert.
|
||||||
|
|
||||||
public sealed record SystemInfo(
|
public sealed record SystemInfo(
|
||||||
string Version, string ServerName, bool NeedsSetup,
|
string Version, string Build, string ServerName, bool NeedsSetup,
|
||||||
IReadOnlyList<AttributionResponse> Attributions);
|
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);
|
public sealed record ServerNameRequest(string Name);
|
||||||
|
|
||||||
@@ -26,7 +118,28 @@ public sealed record LoginRequest(
|
|||||||
string UserName, string Password, string DeviceName, string DeviceId,
|
string UserName, string Password, string DeviceName, string DeviceId,
|
||||||
bool StaySignedIn = true);
|
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);
|
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);
|
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>
|
/// <summary>Eine Bibliothek, wie die Oberfläche sie zeigt.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <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
|
/// „nicht gezählt" sind verschiedene Auskünfte, und eine frisch angelegte
|
||||||
/// Bibliothek als leer auszugeben wäre die falsche.
|
/// Bibliothek als leer auszugeben wäre die falsche.
|
||||||
/// </remarks>
|
/// </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 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 -------------------------------------------------
|
// --- Stufe 2: Bibliothek -------------------------------------------------
|
||||||
|
|
||||||
@@ -74,7 +318,20 @@ public sealed record ScanStatus(
|
|||||||
int? Probed,
|
int? Probed,
|
||||||
IReadOnlyList<ScanProblem>? Problems,
|
IReadOnlyList<ScanProblem>? Problems,
|
||||||
string? Error,
|
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>
|
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
|
||||||
public sealed record MetadataProgress(
|
public sealed record MetadataProgress(
|
||||||
@@ -83,7 +340,38 @@ public sealed record MetadataProgress(
|
|||||||
/// <summary>Eine Spur in einer Datei.</summary>
|
/// <summary>Eine Spur in einer Datei.</summary>
|
||||||
public sealed record MediaStreamResponse(
|
public sealed record MediaStreamResponse(
|
||||||
int Index, string Kind, string? Codec, string? Language, string? Title,
|
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>
|
/// <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
|
/// App spricht Dateien über ihre Kennung an. Zum Unterscheiden zweier
|
||||||
/// Fassungen genügt der Name.
|
/// Fassungen genügt der Name.
|
||||||
/// </remarks>
|
/// </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(
|
public sealed record MediaFileResponse(
|
||||||
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
|
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(
|
public sealed record MovieResponse(
|
||||||
Guid Id, string Title, int? Year, string? Overview,
|
Guid Id, string Title, int? Year, string? Overview,
|
||||||
@@ -116,7 +427,73 @@ public sealed record MovieResponse(
|
|||||||
/// von denen keiner angezeigt wird.
|
/// von denen keiner angezeigt wird.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
IReadOnlyList<string>? Genres = 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>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(
|
public sealed record CreditResponse(
|
||||||
Guid Id, string Name, string Role, string? Character, string? Image);
|
Guid Id, string Name, string Role, string? Character, string? Image);
|
||||||
@@ -139,17 +516,136 @@ public sealed record SeriesResponse(
|
|||||||
string? Tagline = null,
|
string? Tagline = null,
|
||||||
string? ContentRating = null,
|
string? ContentRating = null,
|
||||||
IReadOnlyList<string>? Genres = 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(
|
public sealed record EpisodeResponse(
|
||||||
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
||||||
IReadOnlyList<MediaFileResponse> Files,
|
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);
|
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
|
||||||
|
|
||||||
|
/// <summary>Die saisonale Reihe — mit ihrem Namen, weil er wechselt.</summary>
|
||||||
|
///
|
||||||
|
/// <param name="Occasion">
|
||||||
|
/// Was über der Reihe steht. <c>null</c> heisst: heute hat nichts Saison,
|
||||||
|
/// oder der Anlass findet nichts — dann zeigt der Client gar keine Reihe.
|
||||||
|
/// </param>
|
||||||
|
public sealed record Saisonreihe(string? Occasion, Page<MovieResponse> Items);
|
||||||
|
|
||||||
|
/// <param name="HasFanartKey">
|
||||||
|
/// Ob eine zweite Bildquelle eingerichtet ist. Auch hier kommt der Schlüssel
|
||||||
|
/// selbst nicht zurück — zum Anzeigen genügt, dass einer da ist.
|
||||||
|
/// </param>
|
||||||
public sealed record MetadataSettingsResponse(
|
public sealed record MetadataSettingsResponse(
|
||||||
bool HasApiKey, string Language, bool UsingProjectKey);
|
bool HasApiKey, string Language, bool UsingProjectKey,
|
||||||
|
bool HasFanartKey = false, bool HasOmdbKey = false,
|
||||||
|
|
||||||
|
/// <summary>Ob die Seite von Rotten Tomatoes gelesen werden darf.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Kein Schlüssel, sondern ein Schalter.</b> Dort gibt es keine
|
||||||
|
/// Zugänge zu vergeben; eingeschaltet wird das Auslesen der Seite.
|
||||||
|
/// </remarks>
|
||||||
|
bool RottenTomatoes = false);
|
||||||
|
|
||||||
/// <summary>Was ein Client über der Mediathek anzeigen muss.</summary>
|
/// <summary>Was ein Client über der Mediathek anzeigen muss.</summary>
|
||||||
///
|
///
|
||||||
@@ -161,7 +657,33 @@ public sealed record MetadataSettingsResponse(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record AttributionResponse(string Text, string LogoUrl, string Url);
|
public sealed record AttributionResponse(string Text, string LogoUrl, string Url);
|
||||||
|
|
||||||
public sealed record MetadataSettingsRequest(string? ApiKey, string? Language);
|
public sealed record MetadataSettingsRequest(
|
||||||
|
string? ApiKey, string? Language, string? FanartApiKey = null, string? OmdbApiKey = null,
|
||||||
|
bool? RottenTomatoes = null);
|
||||||
|
|
||||||
|
/// <summary>Was der Server über die Wiedergabe entscheidet.</summary>
|
||||||
|
///
|
||||||
|
/// <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 -------------------------------------------------
|
// --- Stufe 3: Ausliefern -------------------------------------------------
|
||||||
|
|
||||||
@@ -190,8 +712,26 @@ public sealed record MatchCandidate(
|
|||||||
public sealed record ApplyMatchRequest(int TmdbId);
|
public sealed record ApplyMatchRequest(int TmdbId);
|
||||||
|
|
||||||
/// <summary>Wie die Zuordnung jetzt aussieht.</summary>
|
/// <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(
|
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 -------------------------------------
|
// --- Stufe 4: Fortschritt und Zustand -------------------------------------
|
||||||
|
|
||||||
@@ -220,13 +760,127 @@ public sealed record ProgressRequest(
|
|||||||
|
|
||||||
public sealed record WatchedRequest(bool Watched);
|
public sealed record WatchedRequest(bool Watched);
|
||||||
public sealed record FavouriteRequest(bool Favourite);
|
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);
|
public sealed record RatingRequest(int? Rating);
|
||||||
|
|
||||||
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
|
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
|
||||||
public sealed record ItemStateResponse(
|
public sealed record ItemStateResponse(
|
||||||
double PositionSeconds, double? RuntimeSeconds,
|
double PositionSeconds, double? RuntimeSeconds,
|
||||||
bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed,
|
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>
|
/// <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
|
/// 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.
|
/// sich die Kachel wie bei Plex: oben, worum es geht, darunter, wo man ist.
|
||||||
/// </remarks>
|
/// </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(
|
public sealed record ContinueEntry(
|
||||||
Guid Id, string Kind, string Title, string? SubTitle,
|
Guid Id, string Kind, string Title, string? SubTitle,
|
||||||
int? Season, int? Episode, int? Year,
|
int? Season, int? Episode, int? Year,
|
||||||
string? PosterImage, string? BackdropImage,
|
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>
|
/// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary>
|
||||||
public sealed record SessionResponse(
|
public sealed record SessionResponse(
|
||||||
@@ -250,3 +973,125 @@ public sealed record SessionResponse(
|
|||||||
/// <summary>Was der Server gerade tut.</summary>
|
/// <summary>Was der Server gerade tut.</summary>
|
||||||
public sealed record ServerStatusResponse(
|
public sealed record ServerStatusResponse(
|
||||||
int Playing, int Packaging, int Scanning, double Uptime);
|
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.
|
// durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
|
||||||
// Genau das ist passiert: der Spieler bekam HTML statt Video.
|
// Genau das ist passiert: der Spieler bekam HTML statt Video.
|
||||||
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async (
|
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);
|
var gefunden = await Laden(db, id);
|
||||||
if (gefunden is not var (datei, plan)) return Results.NotFound();
|
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
|
// Passt schon alles, geht die Datei unverändert hinaus — mit
|
||||||
// Längenangabe und Bereichsanfragen, also mit Fortschritt und
|
// Längenangabe und Bereichsanfragen, also mit Fortschritt und
|
||||||
// Fortsetzen.
|
// Fortsetzen.
|
||||||
@@ -45,6 +48,18 @@ public static class DeliveryEndpoints
|
|||||||
var fertig = packer.PathFor(datei.Id, datei.ModifiedAt);
|
var fertig = packer.PathFor(datei.Id, datei.ModifiedAt);
|
||||||
if (File.Exists(fertig))
|
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",
|
return Results.File(fertig, "video/mp4",
|
||||||
Path.ChangeExtension(Path.GetFileName(datei.Path), ".mp4"),
|
Path.ChangeExtension(Path.GetFileName(datei.Path), ".mp4"),
|
||||||
enableRangeProcessing: true);
|
enableRangeProcessing: true);
|
||||||
@@ -70,6 +85,41 @@ public static class DeliveryEndpoints
|
|||||||
}).RequireAuthorization();
|
}).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)
|
private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext db, Guid id)
|
||||||
{
|
{
|
||||||
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == 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
|
// Der Schlüssel ist der Hash des Inhalts, also ändert sich das Bild
|
||||||
// unter dieser Adresse nie. Ein Jahr zwischenspeichern spart bei
|
// unter dieser Adresse nie. Ein Jahr zwischenspeichern spart bei
|
||||||
// einer Mediathek mit tausend Plakaten das meiste an Verkehr.
|
// 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()
|
.RequireAuthorization()
|
||||||
.AddEndpointFilter(async (zusammenhang, weiter) =>
|
.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 System.Security.Claims;
|
||||||
using Flaro.Core.Playback;
|
using Flaro.Core.Playback;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
@@ -16,6 +22,34 @@ public static class LibraryContentEndpoints
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const int HöchstensJeSeite = 200;
|
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>
|
/// <summary>Wonach eine Liste geordnet wird.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -46,6 +80,119 @@ public static class LibraryContentEndpoints
|
|||||||
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
|
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
|
||||||
/// und SQLite führt die nacheinander aus.
|
/// und SQLite führt die nacheinander aus.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <summary>Die Folge vor oder nach dieser.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Eine Rechnung, zwei Wege.</b> Vorwärts und rückwärts unterscheiden
|
||||||
|
/// sich in zwei Zeichen — als zwei Endpunkte abgeschrieben liefen sie
|
||||||
|
/// auseinander, sobald einer von beiden etwas dazubekommt.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<IResult> Nachbarfolge(
|
||||||
|
Guid id, ClaimsPrincipal wer, FlaroContext db, bool vorwaerts)
|
||||||
|
{
|
||||||
|
var diese = await db.Episodes.AsNoTracking()
|
||||||
|
.Where(f => f.Id == id)
|
||||||
|
.Select(f => new { f.SeriesId, f.SeasonNumber, f.EpisodeNumber })
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (diese is null) return Results.NotFound();
|
||||||
|
|
||||||
|
var geschwister = db.Episodes.AsNoTracking().Where(f => f.SeriesId == diese.SeriesId);
|
||||||
|
|
||||||
|
var gefiltert = vorwaerts
|
||||||
|
? geschwister
|
||||||
|
.Where(f => f.SeasonNumber > diese.SeasonNumber
|
||||||
|
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
|
||||||
|
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
||||||
|
: geschwister
|
||||||
|
.Where(f => f.SeasonNumber < diese.SeasonNumber
|
||||||
|
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber < diese.EpisodeNumber))
|
||||||
|
.OrderByDescending(f => f.SeasonNumber).ThenByDescending(f => f.EpisodeNumber);
|
||||||
|
|
||||||
|
var nachbar = await gefiltert
|
||||||
|
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
// Keine weitere Folge ist kein Fehler — es war die erste oder die letzte.
|
||||||
|
if (nachbar is null) return Results.NoContent();
|
||||||
|
|
||||||
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
|
.Where(d => d.EpisodeId == nachbar.Id)
|
||||||
|
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel })
|
||||||
|
.ToListAsync();
|
||||||
|
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
|
var stände = await ZuständeZu(db, wer, folgen: [nachbar.Id]);
|
||||||
|
var segmente = await SegmenteZu(db, [nachbar.Id]);
|
||||||
|
|
||||||
|
// **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
|
||||||
|
// nichts; das Plakat der Serie sagt wenigstens, worum es geht.
|
||||||
|
// TMDB liefert Folgenbilder nur für einen Teil der Folgen, und bei
|
||||||
|
// kleineren Serien für gar keine.
|
||||||
|
var serienBild = await db.Series.AsNoTracking()
|
||||||
|
.Where(x => x.Id == diese.SeriesId)
|
||||||
|
.Select(x => x.PosterImage).FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new EpisodeResponse(
|
||||||
|
nachbar.Id, nachbar.SeasonNumber, nachbar.EpisodeNumber,
|
||||||
|
nachbar.Title, nachbar.Overview, nachbar.StillImage ?? serienBild,
|
||||||
|
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
|
||||||
|
stände.GetValueOrDefault(nachbar.Id),
|
||||||
|
nachbar.PublishAt,
|
||||||
|
SeriesId: diese.SeriesId,
|
||||||
|
Markers: segmente[nachbar.Id].ToList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Segmente zu einer Reihe von Folgen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Als Nachschlagewerk und nicht je Folge einzeln: eine Staffel mit
|
||||||
|
/// zwanzig Folgen wären sonst zwanzig Abfragen für zwanzig kurze Zeilen.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<ILookup<Guid, MarkerResponse>> SegmenteZu(
|
||||||
|
FlaroContext db, IReadOnlyCollection<Guid> folgen)
|
||||||
|
{
|
||||||
|
if (folgen.Count == 0)
|
||||||
|
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
|
||||||
|
|
||||||
|
var roh = await db.Segmente.AsNoTracking()
|
||||||
|
.Where(m => m.EpisodeId != null && folgen.Contains(m.EpisodeId!.Value))
|
||||||
|
// In Zeitfolge: ein geteilter Abspann kommt als zwei Segmente, und
|
||||||
|
// die Spieler nehmen die erste passende.
|
||||||
|
.OrderBy(m => m.VonSekunden)
|
||||||
|
.Select(m => new { Id = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return roh.ToLookup(
|
||||||
|
m => m.Id,
|
||||||
|
m => new MarkerResponse(
|
||||||
|
m.Art == Segmentart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dasselbe für Filme.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Getrennt und nicht über eine gemeinsame Kennung: ein Segment gehört
|
||||||
|
/// entweder zu einer Folge oder zu einem Film, und eine Abfrage, die
|
||||||
|
/// beides zugleich sucht, fände zu einer Filmkennung auch eine Folge
|
||||||
|
/// mit derselben — unmöglich heute, aber nichts hält es fest.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<ILookup<Guid, MarkerResponse>> FilmsegmenteZu(
|
||||||
|
FlaroContext db, IReadOnlyCollection<Guid> filme)
|
||||||
|
{
|
||||||
|
if (filme.Count == 0)
|
||||||
|
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
|
||||||
|
|
||||||
|
var roh = await db.Segmente.AsNoTracking()
|
||||||
|
.Where(m => m.MovieId != null && filme.Contains(m.MovieId!.Value))
|
||||||
|
.OrderBy(m => m.VonSekunden)
|
||||||
|
.Select(m => new { Id = m.MovieId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return roh.ToLookup(
|
||||||
|
m => m.Id,
|
||||||
|
m => new MarkerResponse(
|
||||||
|
m.Art == Segmentart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien)
|
private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien)
|
||||||
{
|
{
|
||||||
if (dateien.Count == 0) return Array.Empty<(Guid, MediaStreamResponse)>().ToLookup(x => x.Item1, x => x.Item2);
|
if (dateien.Count == 0) return Array.Empty<(Guid, MediaStreamResponse)>().ToLookup(x => x.Item1, x => x.Item2);
|
||||||
@@ -57,15 +204,101 @@ public static class LibraryContentEndpoints
|
|||||||
|
|
||||||
return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse(
|
return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse(
|
||||||
s.Index, s.Kind.ToString(), s.Codec, s.Language, s.Title,
|
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(
|
private static MediaFileResponse Datei(
|
||||||
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
|
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
|
||||||
ILookup<Guid, MediaStreamResponse> spuren) =>
|
ILookup<Guid, MediaStreamResponse> spuren, string? fassung = null,
|
||||||
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList());
|
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 Genres eines Titels, in der Reihenfolge der Quelle.</summary>
|
||||||
|
/// <summary>Die Bewertungen von anderswo zu einem Titel.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Nur beim Einzelabruf.</b> In einer Liste mit fünfzig Kacheln
|
||||||
|
/// stünden sie nirgends, kosteten aber fünfzig Abfragen — dieselbe
|
||||||
|
/// Überlegung wie bei Genres und Besetzung.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<IReadOnlyList<Fremdwertungsantwort>> FremdwertungenZu(
|
||||||
|
FlaroContext db, Guid? film, Guid? serie) =>
|
||||||
|
await db.Fremdbewertungen.AsNoTracking()
|
||||||
|
.Where(b => film != null ? b.MovieId == film : b.SeriesId == serie)
|
||||||
|
.OrderByDescending(b => b.Wert)
|
||||||
|
.Select(b => new Fremdwertungsantwort(b.Quelle, b.Wert, b.Anzeige))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
|
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
|
||||||
await db.GenreLinks.AsNoTracking()
|
await db.GenreLinks.AsNoTracking()
|
||||||
.Where(l => film != null ? l.MovieId == film : l.SeriesId == serie)
|
.Where(l => film != null ? l.MovieId == film : l.SeriesId == serie)
|
||||||
@@ -90,23 +323,33 @@ public static class LibraryContentEndpoints
|
|||||||
/// Stand — das ist kein Fehler, sondern der Normalfall für alles, was
|
/// Stand — das ist kein Fehler, sondern der Normalfall für alles, was
|
||||||
/// noch niemand angefasst hat, und die Antwort trägt dort <c>null</c>.
|
/// noch niemand angefasst hat, und die Antwort trägt dort <c>null</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
|
/// <remarks>
|
||||||
FlaroContext db, ClaimsPrincipal wer, List<Guid>? filme = null, List<Guid>? folgen = null)
|
/// <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 [];
|
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return [];
|
||||||
|
|
||||||
var gesucht = (filme ?? folgen)!;
|
var gesucht = (filme ?? folgen ?? serien)!;
|
||||||
if (gesucht.Count == 0) return [];
|
if (gesucht.Count == 0) return [];
|
||||||
|
|
||||||
var zeilen = await db.UserItemStates.AsNoTracking()
|
var zeilen = await db.UserItemStates.AsNoTracking()
|
||||||
.Where(z => z.UserId == benutzer)
|
.Where(z => z.UserId == benutzer)
|
||||||
.Where(z => filme != null
|
.Where(z => filme != null
|
||||||
? z.MovieId != null && gesucht.Contains(z.MovieId!.Value)
|
? 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();
|
.ToListAsync();
|
||||||
|
|
||||||
return zeilen.ToDictionary(
|
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);
|
PlaybackEndpoints.Abbild);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,23 +390,54 @@ public static class LibraryContentEndpoints
|
|||||||
// erst beim nächsten neuen Film. Geholt wird über die bekannte
|
// erst beim nächsten neuen Film. Geholt wird über die bekannte
|
||||||
// Kennung, nicht über eine neue Suche; eine Zuordnung von Hand
|
// Kennung, nicht über eine neue Suche; eine Zuordnung von Hand
|
||||||
// bleibt damit stehen.
|
// 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 (
|
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();
|
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.AcceptedAtRoute("Scanstand", new { id })
|
||||||
: Results.Problem(
|
: Results.Problem(
|
||||||
"Für diese Bibliothek läuft bereits ein Lauf.",
|
"Für diese Bibliothek läuft bereits ein Lauf.",
|
||||||
statusCode: StatusCodes.Status409Conflict);
|
statusCode: StatusCodes.Status409Conflict);
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
app.MapGet("/api/v1/libraries/{id:guid}/scan", (Guid id, ScanCoordinator koordinator) =>
|
app.MapGet("/api/v1/libraries/{id:guid}/scan",
|
||||||
|
(Guid id, ScanCoordinator koordinator, Segmenterkennung erkennung) =>
|
||||||
{
|
{
|
||||||
|
// **Auch ohne Scan kann etwas laufen.** Die Segmentsuche lässt
|
||||||
|
// sich von Hand anstossen; ohne diese Zeile stünde die
|
||||||
|
// Verwaltung dann auf „idle", während der Server stundenlang
|
||||||
|
// arbeitet.
|
||||||
|
var segmente = erkennung.Stand(id)?.Text();
|
||||||
|
|
||||||
var zustand = koordinator.State(id);
|
var zustand = koordinator.State(id);
|
||||||
if (zustand is null)
|
if (zustand is null)
|
||||||
return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null, null, null));
|
return Results.Ok(new ScanStatus(
|
||||||
|
segmente is null ? "idle" : "marken",
|
||||||
|
null, null, null, null, null, null, null, null, null, null, null,
|
||||||
|
Segmente: segmente));
|
||||||
|
|
||||||
var bericht = zustand.Report;
|
var bericht = zustand.Report;
|
||||||
var metadaten = zustand.Enrichment;
|
var metadaten = zustand.Enrichment;
|
||||||
@@ -175,20 +449,91 @@ public static class LibraryContentEndpoints
|
|||||||
zustand.Error,
|
zustand.Error,
|
||||||
metadaten is null ? null : new MetadataProgress(
|
metadaten is null ? null : new MetadataProgress(
|
||||||
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
|
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
|
||||||
metadaten.Skipped, metadaten.Error)));
|
metadaten.Skipped, metadaten.Error),
|
||||||
|
bericht?.Missing,
|
||||||
|
zustand.Schritt?.Text(),
|
||||||
|
segmente));
|
||||||
}).WithName("Scanstand").RequireAuthorization();
|
}).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)
|
private static void MapBrowse(IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
app.MapGet("/api/v1/movies", async (
|
app.MapGet("/api/v1/movies", async (
|
||||||
ClaimsPrincipal wer, FlaroContext db,
|
ClaimsPrincipal wer, FlaroContext db,
|
||||||
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
|
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();
|
var abfrage = db.Movies.AsNoTracking();
|
||||||
if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung);
|
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))
|
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))
|
if (!string.IsNullOrWhiteSpace(genre))
|
||||||
{
|
{
|
||||||
var gesucht = genre.Trim();
|
var gesucht = genre.Trim();
|
||||||
@@ -211,8 +556,29 @@ public static class LibraryContentEndpoints
|
|||||||
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||||
.Select(m => new
|
.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.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();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -222,17 +588,26 @@ public static class LibraryContentEndpoints
|
|||||||
var ids = kennungen.Select(m => m.Id).ToList();
|
var ids = kennungen.Select(m => m.Id).ToList();
|
||||||
var dateien = await db.MediaFiles.AsNoTracking()
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
.Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value))
|
.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();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, filme: ids);
|
var stände = await ZuständeZu(db, wer, filme: ids);
|
||||||
|
var segmente = await FilmsegmenteZu(db, ids);
|
||||||
|
|
||||||
var items = kennungen.Select(m => new MovieResponse(
|
var items = kennungen.Select(m => new MovieResponse(
|
||||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||||
dateien.Where(f => f.MovieId == m.Id)
|
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),
|
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));
|
return Results.Ok(new Page<MovieResponse>(items, gesamt));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
@@ -241,10 +616,16 @@ public static class LibraryContentEndpoints
|
|||||||
ClaimsPrincipal wer, FlaroContext db,
|
ClaimsPrincipal wer, FlaroContext db,
|
||||||
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
|
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();
|
var abfrage = db.Series.AsNoTracking();
|
||||||
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung);
|
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung);
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
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))
|
if (!string.IsNullOrWhiteSpace(genre))
|
||||||
{
|
{
|
||||||
var gesucht = genre.Trim();
|
var gesucht = genre.Trim();
|
||||||
@@ -264,8 +645,25 @@ public static class LibraryContentEndpoints
|
|||||||
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||||
.Select(s => new
|
.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,
|
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();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -279,6 +677,7 @@ public static class LibraryContentEndpoints
|
|||||||
// zusammen. Je Serie zu fragen wäre bei fünfzig Serien
|
// zusammen. Je Serie zu fragen wäre bei fünfzig Serien
|
||||||
// einundfünfzig Abfragen.
|
// einundfünfzig Abfragen.
|
||||||
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
|
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 =>
|
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,
|
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count,
|
||||||
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
|
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
|
||||||
meine.Count(f => !gesehen.Contains(f.Id)),
|
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();
|
}).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
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.**
|
// **Einzelabruf, weil ein Client nicht immer aus einer Liste kommt.**
|
||||||
// Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus
|
// Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus
|
||||||
// „Weiterschauen" — in all diesen Fällen hat er nur die Kennung.
|
// „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 (
|
app.MapGet("/api/v1/movies/{id:guid}", async (
|
||||||
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
@@ -331,18 +775,34 @@ public static class LibraryContentEndpoints
|
|||||||
|
|
||||||
var dateien = await db.MediaFiles.AsNoTracking()
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
.Where(f => f.MovieId == id)
|
.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();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, filme: [id]);
|
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(
|
return Results.Ok(new MovieResponse(
|
||||||
film.Id, film.Title, film.Year, film.Overview, film.PosterImage, film.BackdropImage,
|
film.Id, uebersetzt?.Title ?? film.Title, film.Year,
|
||||||
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(),
|
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),
|
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 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();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/v1/series/{id:guid}", async (
|
app.MapGet("/api/v1/series/{id:guid}", async (
|
||||||
@@ -355,54 +815,242 @@ public static class LibraryContentEndpoints
|
|||||||
|
|
||||||
var folgen = await db.Episodes.AsNoTracking()
|
var folgen = await db.Episodes.AsNoTracking()
|
||||||
.Where(f => f.SeriesId == id)
|
.Where(f => f.SeriesId == id)
|
||||||
.Select(f => new { f.Id, f.SeasonNumber })
|
.Select(f => new { f.Id, f.SeasonNumber, f.PublishAt })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
|
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(
|
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, folgen.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
|
||||||
folgen.Count(f => !gesehen.Contains(f.Id)),
|
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 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();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
|
// **Eine Folge für sich.**
|
||||||
// Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann
|
//
|
||||||
// der Scan sie fand, nicht wann sie läuft.
|
// Es gab sie nur im Rudel — als Liste einer Serie. Die Apps reichen
|
||||||
app.MapGet("/api/v1/episodes/{id:guid}/next", async (
|
// 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) =>
|
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
var diese = await db.Episodes.AsNoTracking()
|
var folge = await db.Episodes.AsNoTracking()
|
||||||
.Where(f => f.Id == id)
|
.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();
|
.FirstOrDefaultAsync();
|
||||||
if (diese is null) return Results.NotFound();
|
if (folge 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();
|
|
||||||
|
|
||||||
var dateien = await db.MediaFiles.AsNoTracking()
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
.Where(d => d.EpisodeId == naechste.Id)
|
.Where(d => d.EpisodeId == id)
|
||||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration })
|
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, folgen: [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(
|
return Results.Ok(new EpisodeResponse(
|
||||||
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
|
folge.Id, folge.SeasonNumber, folge.EpisodeNumber,
|
||||||
naechste.Title, naechste.Overview, naechste.StillImage,
|
folge.Title, folge.Overview, folge.StillImage,
|
||||||
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
|
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||||
stände.GetValueOrDefault(naechste.Id)));
|
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();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
|
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
|
||||||
@@ -415,22 +1063,35 @@ public static class LibraryContentEndpoints
|
|||||||
|
|
||||||
var folgen = await abfrage
|
var folgen = await abfrage
|
||||||
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
.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();
|
.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 ids = folgen.Select(f => f.Id).ToList();
|
||||||
var dateien = await db.MediaFiles.AsNoTracking()
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value))
|
.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();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, folgen: ids);
|
var stände = await ZuständeZu(db, wer, folgen: ids);
|
||||||
|
var segmente = await SegmenteZu(db, ids);
|
||||||
|
|
||||||
|
var serienBild = await db.Series.AsNoTracking()
|
||||||
|
.Where(x => x.Id == id)
|
||||||
|
.Select(x => x.PosterImage).FirstOrDefaultAsync();
|
||||||
|
|
||||||
var items = folgen.Select(f => new EpisodeResponse(
|
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)
|
dateien.Where(d => d.EpisodeId == f.Id)
|
||||||
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
|
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||||
stände.GetValueOrDefault(f.Id))).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));
|
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using Flaro.Api.Auth;
|
using Flaro.Api.Auth;
|
||||||
using Flaro.Core.Libraries;
|
using Flaro.Core.Libraries;
|
||||||
|
using Flaro.Core.Library;
|
||||||
using Flaro.Core.Users;
|
using Flaro.Core.Users;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -8,6 +10,9 @@ namespace Flaro.Api.Endpoints;
|
|||||||
|
|
||||||
public static class ManagementEndpoints
|
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)
|
public static void MapManagement(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator);
|
var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
@@ -15,20 +20,87 @@ public static class ManagementEndpoints
|
|||||||
benutzer.MapGet("/", async (FlaroContext db) =>
|
benutzer.MapGet("/", async (FlaroContext db) =>
|
||||||
Results.Ok(await db.Users
|
Results.Ok(await db.Users
|
||||||
.OrderBy(u => u.UserName)
|
.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()));
|
.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) =>
|
benutzer.MapPost("/", async (CreateUserRequest anfrage, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
var name = anfrage.UserName.Trim();
|
var name = anfrage.UserName.Trim();
|
||||||
if (name.Length == 0 || anfrage.Password.Length < 8)
|
if (name.Length == 0 || anfrage.Password.Length < 8)
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
return Einwand.Bitte("Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
|
||||||
{
|
|
||||||
["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (await db.Users.AnyAsync(u => u.UserName == name))
|
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
|
var neu = new User
|
||||||
{
|
{
|
||||||
@@ -51,9 +123,44 @@ public static class ManagementEndpoints
|
|||||||
// dem sich nach dem Klick nichts findet — da ist der Unterschied
|
// dem sich nach dem Klick nichts findet — da ist der Unterschied
|
||||||
// zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens
|
// zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens
|
||||||
// sichtbar. Zwei Abfragen für alle zusammen, nicht eine je Zeile.
|
// 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()
|
var filme = await db.Movies.AsNoTracking()
|
||||||
.GroupBy(m => m.LibraryId)
|
.GroupBy(m => m.LibraryId)
|
||||||
@@ -65,36 +172,323 @@ public static class ManagementEndpoints
|
|||||||
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
|
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
|
||||||
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
|
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
|
||||||
|
|
||||||
return Results.Ok(liste.Select(l => new LibraryResponse(
|
// **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.Id, l.Name, l.Kind.ToString(), l.Path,
|
||||||
l.Kind == LibraryKind.Shows
|
l.Kind == LibraryKind.Shows
|
||||||
? serien.GetValueOrDefault(l.Id)
|
? serien.GetValueOrDefault(l.Id)
|
||||||
: filme.GetValueOrDefault(l.Id))).ToList());
|
: 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();
|
}).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) =>
|
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
if (!Enum.TryParse<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
|
if (!Enum.TryParse<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
return Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows, Videos.");
|
||||||
{
|
|
||||||
["kind"] = [$"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows."],
|
|
||||||
});
|
|
||||||
|
|
||||||
// **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad
|
// **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad
|
||||||
// führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und
|
// führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und
|
||||||
// die Ursache stünde allenfalls im Protokoll.
|
// die Ursache stünde allenfalls im Protokoll.
|
||||||
if (!Directory.Exists(anfrage.Path))
|
if (!Directory.Exists(anfrage.Path))
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
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.");
|
||||||
["path"] = [$"„{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);
|
db.Libraries.Add(neu);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Created($"/api/v1/libraries/{neu.Id}",
|
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);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
// **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in
|
// **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in
|
||||||
@@ -107,10 +501,7 @@ public static class ManagementEndpoints
|
|||||||
{
|
{
|
||||||
var name = anfrage.Name.Trim();
|
var name = anfrage.Name.Trim();
|
||||||
if (name.Length == 0)
|
if (name.Length == 0)
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
return Einwand.Bitte("Ein Name darf nicht leer sein.");
|
||||||
{
|
|
||||||
["name"] = ["Ein Name darf nicht leer sein."],
|
|
||||||
});
|
|
||||||
|
|
||||||
var vorhanden = await db.Libraries.FindAsync(id);
|
var vorhanden = await db.Libraries.FindAsync(id);
|
||||||
if (vorhanden is null) return Results.NotFound();
|
if (vorhanden is null) return Results.NotFound();
|
||||||
@@ -125,16 +516,74 @@ public static class ManagementEndpoints
|
|||||||
// sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim
|
// sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim
|
||||||
// Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server,
|
// Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server,
|
||||||
// den man nicht noch einmal benutzt.
|
// 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);
|
var vorhanden = await db.Libraries.FindAsync(id);
|
||||||
if (vorhanden is null) return Results.NotFound();
|
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);
|
db.Libraries.Remove(vorhanden);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).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
|
public static class FlaroPolicies
|
||||||
|
|||||||
@@ -25,6 +25,224 @@ public static class MatchEndpoints
|
|||||||
{
|
{
|
||||||
public static void MapMatching(this IEndpointRouteBuilder app)
|
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 (
|
app.MapGet("/api/v1/movies/{id:guid}/matches", async (
|
||||||
Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) =>
|
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
|
// Ohne eigene Suchworte das, was aus dem Dateinamen gelesen wurde
|
||||||
// — nicht der angezeigte Titel, der ja gerade der falsche ist.
|
// — nicht der angezeigte Titel, der ja gerade der falsche ist.
|
||||||
var vorschläge = await anbieter.SearchMoviesAsync(
|
var vorschläge = await anbieter.SearchMoviesAsync(
|
||||||
string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(), 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 =>
|
return Results.Ok(vorschläge.Select(v =>
|
||||||
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
||||||
@@ -42,18 +275,46 @@ public static class MatchEndpoints
|
|||||||
|
|
||||||
app.MapPut("/api/v1/movies/{id:guid}/match", async (
|
app.MapPut("/api/v1/movies/{id:guid}/match", async (
|
||||||
Guid id, ApplyMatchRequest anfrage, FlaroContext db,
|
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);
|
var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
|
||||||
if (film is null) return Results.NotFound();
|
if (film is null) return Results.NotFound();
|
||||||
|
|
||||||
var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId);
|
var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId);
|
||||||
if (gefunden is null)
|
if (gefunden is null)
|
||||||
return Results.Problem($"TMDB kennt den Film {anfrage.TmdbId} nicht.",
|
return Einwand.Fehlt($"TMDB kennt den Film {anfrage.TmdbId} nicht.");
|
||||||
statusCode: StatusCodes.Status404NotFound);
|
|
||||||
|
// **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);
|
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);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
app.MapGet("/api/v1/series/{id:guid}/matches", async (
|
app.MapGet("/api/v1/series/{id:guid}/matches", async (
|
||||||
@@ -63,7 +324,9 @@ public static class MatchEndpoints
|
|||||||
if (serie is null) return Results.NotFound();
|
if (serie is null) return Results.NotFound();
|
||||||
|
|
||||||
var vorschläge = await anbieter.SearchSeriesAsync(
|
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 =>
|
return Results.Ok(vorschläge.Select(v =>
|
||||||
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
||||||
@@ -71,18 +334,68 @@ public static class MatchEndpoints
|
|||||||
|
|
||||||
app.MapPut("/api/v1/series/{id:guid}/match", async (
|
app.MapPut("/api/v1/series/{id:guid}/match", async (
|
||||||
Guid id, ApplyMatchRequest anfrage, FlaroContext db,
|
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);
|
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id);
|
||||||
if (serie is null) return Results.NotFound();
|
if (serie is null) return Results.NotFound();
|
||||||
|
|
||||||
var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId);
|
var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId);
|
||||||
if (gefunden is null)
|
if (gefunden is null)
|
||||||
return Results.Problem($"TMDB kennt die Serie {anfrage.TmdbId} nicht.",
|
return Einwand.Fehlt($"TMDB kennt die Serie {anfrage.TmdbId} nicht.");
|
||||||
statusCode: StatusCodes.Status404NotFound);
|
|
||||||
|
// 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);
|
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);
|
}).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