Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbf4a29461 | ||
|
|
c12748e262 | ||
|
|
f2b5ee633b | ||
|
|
a7ec3a73ba | ||
|
|
8c395ac051 | ||
|
|
ede4069cd4 | ||
|
|
230beb9664 | ||
|
|
172b38da06 | ||
|
|
6d5824b889 | ||
|
|
67cf0fd1d9 | ||
|
|
c4081ff251 | ||
|
|
705ec87416 | ||
|
|
91eb934048 |
@@ -0,0 +1,26 @@
|
|||||||
|
# Der Bau-Kontext ist die Wurzel des Projekts, seit die Oberfläche
|
||||||
|
# mitgebaut wird. Diese Datei gehört deshalb hierher und nicht nach server/ —
|
||||||
|
# Docker sucht sie neben dem Kontext, nicht neben dem Dockerfile.
|
||||||
|
|
||||||
|
# **Der Grund für diese Datei.** Ohne sie überschreibt `COPY server/ ./server/`
|
||||||
|
# die obj/-Verzeichnisse, die `dotnet restore` im Container gerade angelegt
|
||||||
|
# hat, mit denen vom Arbeitsrechner. Die zeigen auf osx-arm64, der Bau will
|
||||||
|
# linux-arm64, und `dotnet publish` bricht mit NETSDK1047 ab.
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/*.user
|
||||||
|
|
||||||
|
# Gebaute Oberfläche und ihre Abhängigkeiten: der web-Abschnitt baut beides neu.
|
||||||
|
**/node_modules/
|
||||||
|
web/app/dist/
|
||||||
|
|
||||||
|
# Laufzeitdaten eines lokalen Starts.
|
||||||
|
server/data/
|
||||||
|
server/cache/
|
||||||
|
server/transcode/
|
||||||
|
|
||||||
|
# Was im Server-Image nichts verloren hat.
|
||||||
|
.git/
|
||||||
|
.build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcodeproj/
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Baut das Flaro-Image und legt es in die Registry dieses Gitea.
|
||||||
|
#
|
||||||
|
# **Warum hier und nicht auf dem Arbeitsrechner.** Ein Bau von Hand heisst:
|
||||||
|
# jemand muss daran denken, muss die richtige Architektur angeben und muss
|
||||||
|
# angemeldet sein. Hier passiert es bei jedem Push von selbst, immer für
|
||||||
|
# beide Architekturen, und die Anmeldung besorgt Gitea.
|
||||||
|
#
|
||||||
|
# **Voraussetzung ist ein Runner.** Ohne einen registrierten Act-Runner
|
||||||
|
# läuft diese Datei nie an — dann bleibt der Weg von Hand aus
|
||||||
|
# `Tools/image-veroeffentlichen.sh`.
|
||||||
|
|
||||||
|
name: Image bauen und veröffentlichen
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Quelltext holen
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# **buildx, nicht das eingebaute `docker build`.** Nur damit lassen
|
||||||
|
# sich zwei Architekturen in einem Manifest ablegen; ohne das bekommt
|
||||||
|
# ein Intel-NAS ein arm64-Image und meldet „exec format error".
|
||||||
|
- name: buildx vorbereiten
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: QEMU für die fremde Architektur
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: An der Registry anmelden
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY || 'git.scarriffle.com' }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
- name: Marken bestimmen
|
||||||
|
id: marken
|
||||||
|
run: |
|
||||||
|
REGISTRY="${{ vars.REGISTRY || 'git.scarriffle.com' }}"
|
||||||
|
# **Der Name des Pakets, nicht der des Repos.** Gitea legt Pakete
|
||||||
|
# am Besitzer ab, nicht am Repository — sie dürfen deshalb anders
|
||||||
|
# heissen. Hier ist das nötig: das Repo heisst noch „halo", das
|
||||||
|
# Programm aber Flaro, und ein Image namens `halo` wäre die eine
|
||||||
|
# Stelle, an der der alte Name überlebt.
|
||||||
|
#
|
||||||
|
# Kleinbuchstaben, weil die Registry nichts anderes annimmt.
|
||||||
|
BESITZER=$(echo "${{ gitea.repository_owner }}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
BILD="$REGISTRY/$BESITZER/${{ vars.IMAGE_NAME || 'flaro' }}"
|
||||||
|
|
||||||
|
MARKEN="$BILD:latest,$BILD:${GITHUB_SHA::7}"
|
||||||
|
# Auf einem Git-Tag zusätzlich dessen Namen — damit sich eine
|
||||||
|
# Fassung festnageln lässt, statt immer „latest" zu ziehen.
|
||||||
|
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||||
|
MARKEN="$MARKEN,$BILD:${GITHUB_REF_NAME#v}"
|
||||||
|
fi
|
||||||
|
echo "marken=$MARKEN" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Gebaut wird: $MARKEN"
|
||||||
|
|
||||||
|
- name: Bauen und hochladen
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: server/Dockerfile
|
||||||
|
# **Beide Architekturen.** amd64 für die Intel-NAS, arm64 für
|
||||||
|
# ARM-Geräte und für den Apple-Rechner.
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.marken.outputs.marken }}
|
||||||
|
# Zwischenergebnisse behalten: ohne das lädt jeder Lauf alle
|
||||||
|
# Pakete neu, und ein Bau dauert statt zwei Minuten zwölf.
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -6,6 +6,7 @@ import UIKit
|
|||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
import FlaroDownloads
|
import FlaroDownloads
|
||||||
import FlaroPlayback
|
import FlaroPlayback
|
||||||
|
import FlaroServerKit
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ struct FlaroApp: App {
|
|||||||
client: Branding.appName,
|
client: Branding.appName,
|
||||||
version: Branding.version
|
version: Branding.version
|
||||||
),
|
),
|
||||||
|
flaroIdentity: FlaroIdentity.current(),
|
||||||
tokenStore: KeychainTokenStore(service: Branding.keychainService)
|
tokenStore: KeychainTokenStore(service: Branding.keychainService)
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"action.back": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Zurück"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"action.cancel": {
|
"action.cancel": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -3854,23 +3871,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player.jellyfinPending": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Die Wiedergabe von Jellyfin ist noch nicht fertig — sie ist der nächste Schritt."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Playback from Jellyfin isn't finished yet — it's the next step."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"player.loadFailed": {
|
"player.loadFailed": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -6254,6 +6254,312 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"signIn.flaro.addressHint": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Schema und Port darfst du weglassen — Flaro probiert die üblichen durch."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "You can leave out the scheme and port — Flaro tries the usual ones."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.addressMissing": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Trag eine Adresse ein."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Enter an address."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.addressPlaceholder": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "z. B. 192.168.1.50 oder medien.example.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "e.g. 192.168.1.50 or media.example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.addressPrompt": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Wo steht dein Server?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Where is your server?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.changeAddress": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Andere Adresse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Change address"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.codeExpired": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Code ist abgelaufen. Fordere einen neuen an."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The code has expired. Request a new one."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.codeHint": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Öffne Flaro auf deinem Telefon und gib diesen Code ein."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Open Flaro on your phone and enter this code."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.credentialsMissing": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Benutzername und Passwort fehlen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Username and password are missing."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.findServer": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Server suchen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Find server"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.needsSetup": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Dieser Server ist noch nicht eingerichtet. Öffne ihn im Browser und lege ein Konto an."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "This server isn't set up yet. Open it in a browser and create an account."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.notFound": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Unter dieser Adresse antwortet kein Flaro-Server."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "No Flaro server answers at this address."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.searching": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Server wird gesucht …"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Looking for the server…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.signIn": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Anmelden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sign in"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.signingIn": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Wird angemeldet …"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Signing in…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.title": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Flaro-Server"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Flaro Server"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.useCode": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Stattdessen Code"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Use a code instead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.usePassword": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Stattdessen Passwort"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Use a password instead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.waitingForApproval": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Warte auf Bestätigung"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Waiting for approval"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"signIn.jellyfin.addressHint": {
|
"signIn.jellyfin.addressHint": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -6403,23 +6709,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"signIn.jellyfin.password": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Passwort"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Password"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"signIn.jellyfin.passwordNote": {
|
"signIn.jellyfin.passwordNote": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -6568,23 +6857,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"signIn.jellyfin.userName": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Benutzername"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Username"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"signIn.jellyfin.waitingForApproval": {
|
"signIn.jellyfin.waitingForApproval": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -6601,6 +6873,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"signIn.password": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Passwort"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"signIn.privacy": {
|
"signIn.privacy": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -6634,6 +6923,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"signIn.userName": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Benutzername"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Username"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.withFlaro": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Mit Flaro verbinden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Connect to Flaro"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"signIn.withJellyfin": {
|
"signIn.withJellyfin": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ struct DownloadService {
|
|||||||
// hinterher niemanden mehr, der das nachholen könnte — und seit es
|
// hinterher niemanden mehr, der das nachholen könnte — und seit es
|
||||||
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
|
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
|
||||||
// schwarz. Also wird schon beim Herunterladen umgepackt.
|
// schwarz. Also wird schon beim Herunterladen umgepackt.
|
||||||
let umpacken = quality == .original && !systemPlayerOpens(media: media, part: part)
|
let umpacken = quality == .original && !kannAlleSpuren
|
||||||
|
&& !systemPlayerOpens(media: media, part: part)
|
||||||
|
|
||||||
let url: URL?
|
let url: URL?
|
||||||
switch quality {
|
switch quality {
|
||||||
@@ -200,6 +201,17 @@ struct DownloadService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
|
||||||
|
///
|
||||||
|
/// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Flaros
|
||||||
|
/// Download liefert eine MP4 mit jeder Ton- und Textspur, auch wenn dafür
|
||||||
|
/// umgepackt werden muss. Dann entfällt die Spurwahl, und das Umpacken
|
||||||
|
/// mit genau einer Spur entfällt gleich mit — es gäbe nichts zu
|
||||||
|
/// verbessern, nur etwas zu verlieren.
|
||||||
|
private var kannAlleSpuren: Bool {
|
||||||
|
backend?.capabilities.contains(.allTracksDownload) == true
|
||||||
|
}
|
||||||
|
|
||||||
/// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt
|
/// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt
|
||||||
/// werden muss.
|
/// werden muss.
|
||||||
///
|
///
|
||||||
@@ -211,6 +223,12 @@ struct DownloadService {
|
|||||||
/// falsche Weg.
|
/// falsche Weg.
|
||||||
func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool {
|
func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool {
|
||||||
guard quality == .original else { return false }
|
guard quality == .original else { return false }
|
||||||
|
// **Kann der Server alles mitgeben, gibt es nichts zu entscheiden.**
|
||||||
|
// Die Frage ist nur deshalb da, weil Jellyfins Transcoder eine
|
||||||
|
// Tonspur je Sitzung abbildet. Wo das nicht gilt, wäre sie eine
|
||||||
|
// Entscheidung, die der Nutzer treffen soll, obwohl beide Antworten
|
||||||
|
// zum selben Ergebnis führen.
|
||||||
|
guard !kannAlleSpuren else { return false }
|
||||||
guard let media = mediaIndex < item.versions.count
|
guard let media = mediaIndex < item.versions.count
|
||||||
? item.versions[mediaIndex] : item.preferredVersion,
|
? item.versions[mediaIndex] : item.preferredVersion,
|
||||||
let part = media.files.first
|
let part = media.files.first
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import FlaroServerKit
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import Observation
|
import Observation
|
||||||
@@ -31,12 +32,41 @@ final class FlaroSession {
|
|||||||
// stillschweigend als „ungleich" behandelt.
|
// stillschweigend als „ungleich" behandelt.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mit welcher Art Server gesprochen wird.
|
||||||
|
///
|
||||||
|
/// **Diese Unterscheidung gab es schon einmal** — damals für Plex und
|
||||||
|
/// Jellyfin — und sie wurde beim Entfernen von Plex gestrichen, weil ein
|
||||||
|
/// Aufzählungstyp mit einem Fall ein umständliches Bool ist. Jetzt gibt
|
||||||
|
/// es wieder zwei, und es ist wieder eine Unterscheidung.
|
||||||
|
///
|
||||||
|
/// **Einer zur Zeit, nicht beide.** Zwei gleichzeitig hiessen: zwei
|
||||||
|
/// Startseiten, zwei Mediatheken, zwei Suchergebnisse — und bei jedem
|
||||||
|
/// Titel die Frage, woher er stammt.
|
||||||
|
enum ServerKind: String, CaseIterable, Sendable, Identifiable {
|
||||||
|
case jellyfin, flaro
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
private enum Key {
|
private enum Key {
|
||||||
|
/// Welche Art zuletzt angemeldet war.
|
||||||
|
///
|
||||||
|
/// Nicht `flaro.backend.kind`: so hiess der Schlüssel zu Plex-Zeiten,
|
||||||
|
/// und der steht in der Aufräumliste weiter unten. Denselben Namen
|
||||||
|
/// mit anderer Bedeutung zu benutzen wäre eine Falle für den
|
||||||
|
/// Nächsten, der die Liste liest.
|
||||||
|
static let activeKind = "flaro.backend.active"
|
||||||
|
|
||||||
static let jellyfinAddress = "flaro.jellyfin.address"
|
static let jellyfinAddress = "flaro.jellyfin.address"
|
||||||
static let jellyfinUserID = "flaro.jellyfin.userID"
|
static let jellyfinUserID = "flaro.jellyfin.userID"
|
||||||
static let jellyfinUserName = "flaro.jellyfin.userName"
|
static let jellyfinUserName = "flaro.jellyfin.userName"
|
||||||
static let jellyfinServerName = "flaro.jellyfin.serverName"
|
static let jellyfinServerName = "flaro.jellyfin.serverName"
|
||||||
static let jellyfinIsAdministrator = "flaro.jellyfin.isAdministrator"
|
static let jellyfinIsAdministrator = "flaro.jellyfin.isAdministrator"
|
||||||
|
|
||||||
|
static let flaroAddress = "flaro.flaroServer.address"
|
||||||
|
static let flaroUserID = "flaro.flaroServer.userID"
|
||||||
|
static let flaroUserName = "flaro.flaroServer.userName"
|
||||||
|
static let flaroServerName = "flaro.flaroServer.serverName"
|
||||||
|
static let flaroIsAdministrator = "flaro.flaroServer.isAdministrator"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
|
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
|
||||||
@@ -45,6 +75,7 @@ final class FlaroSession {
|
|||||||
/// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
|
/// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
|
||||||
/// später daneben passt, ohne diesen zu verdrängen.
|
/// später daneben passt, ohne diesen zu verdrängen.
|
||||||
private static let jellyfinTokenAccount = "jellyfin.server"
|
private static let jellyfinTokenAccount = "jellyfin.server"
|
||||||
|
private static let flaroTokenAccount = "flaro.server"
|
||||||
|
|
||||||
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
|
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
|
||||||
///
|
///
|
||||||
@@ -90,8 +121,14 @@ final class FlaroSession {
|
|||||||
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
|
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
|
||||||
private(set) var jellyfinAccount: JellyfinAccount?
|
private(set) var jellyfinAccount: JellyfinAccount?
|
||||||
|
|
||||||
|
/// Das angemeldete Flaro-Konto, solange Flaro aktiv ist.
|
||||||
|
private(set) var flaroAccount: FlaroAccount?
|
||||||
|
|
||||||
|
/// Mit welcher Art Server gerade gesprochen wird.
|
||||||
|
private(set) var activeKind: ServerKind?
|
||||||
|
|
||||||
/// 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 }
|
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -101,7 +138,12 @@ final class FlaroSession {
|
|||||||
var serverName: String { backend?.serverName ?? "" }
|
var serverName: String { backend?.serverName ?? "" }
|
||||||
|
|
||||||
/// Unter welcher Adresse er erreicht wird.
|
/// Unter welcher Adresse er erreicht wird.
|
||||||
var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) }
|
var serverAddress: String? {
|
||||||
|
switch activeKind {
|
||||||
|
case .flaro: defaults.string(forKey: Key.flaroAddress)
|
||||||
|
case .jellyfin, nil: defaults.string(forKey: Key.jellyfinAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Wie die Verbindung zum Server zu beschreiben ist.
|
/// Wie die Verbindung zum Server zu beschreiben ist.
|
||||||
///
|
///
|
||||||
@@ -136,30 +178,28 @@ final class FlaroSession {
|
|||||||
|
|
||||||
/// Warum gerade nicht abgespielt werden kann.
|
/// Warum gerade nicht abgespielt werden kann.
|
||||||
///
|
///
|
||||||
/// Zwei verschiedene Lagen, und sie dürfen nicht verwechselt werden: ist
|
/// **Nur noch ein Fall, und das ist eine Bereinigung.** Hier stand eine
|
||||||
/// ein Server da, ist die Anmeldung fertig und nur die Wiedergabe noch
|
/// Fallunterscheidung aus der Plex-Zeit: war Jellyfin aktiv, hiess es
|
||||||
/// nicht — „nicht erreichbar" wäre dann gelogen. Ist **keiner** da, ist
|
/// „die Wiedergabe von Jellyfin ist noch nicht fertig". Sie ist längst
|
||||||
/// niemand angemeldet, und „Wiedergabe steht aus" wäre es.
|
/// fertig — und der Satz war ohnehin unerreichbar, denn der Player zeigt
|
||||||
///
|
/// diese Meldung ausschliesslich dann, wenn gar kein Server da ist.
|
||||||
/// Die Unterscheidung hing früher an der Serverart, und deren `nil`-Fall
|
|
||||||
/// lief stillschweigend im selben Zweig mit. Jetzt steht sie da, wo sie
|
|
||||||
/// hingehört: an der Frage, ob überhaupt ein Server da ist.
|
|
||||||
var playbackUnavailableMessage: String {
|
var playbackUnavailableMessage: String {
|
||||||
backend != nil
|
String(localized: "error.serverUnreachable")
|
||||||
? String(localized: "player.jellyfinPending")
|
|
||||||
: String(localized: "error.serverUnreachable")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let jellyfinIdentity: JellyfinIdentity
|
private let jellyfinIdentity: JellyfinIdentity
|
||||||
|
private let flaroIdentity: FlaroIdentity
|
||||||
private let tokenStore: any TokenStoring
|
private let tokenStore: any TokenStoring
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
init(
|
init(
|
||||||
jellyfinIdentity: JellyfinIdentity,
|
jellyfinIdentity: JellyfinIdentity,
|
||||||
|
flaroIdentity: FlaroIdentity,
|
||||||
tokenStore: any TokenStoring,
|
tokenStore: any TokenStoring,
|
||||||
defaults: UserDefaults = .standard
|
defaults: UserDefaults = .standard
|
||||||
) {
|
) {
|
||||||
self.jellyfinIdentity = jellyfinIdentity
|
self.jellyfinIdentity = jellyfinIdentity
|
||||||
|
self.flaroIdentity = flaroIdentity
|
||||||
self.tokenStore = tokenStore
|
self.tokenStore = tokenStore
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
}
|
}
|
||||||
@@ -174,7 +214,13 @@ final class FlaroSession {
|
|||||||
if await adoptStubServerIfRequested() { return }
|
if await adoptStubServerIfRequested() { return }
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if await restoreJellyfin() { return }
|
// **Die gespeicherte Art zuerst.** Wer ohne sie startet, war vor
|
||||||
|
// dieser Fassung angemeldet — damals gab es nur Jellyfin, und genau
|
||||||
|
// das ist dann gemeint.
|
||||||
|
switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
|
||||||
|
case .flaro: if await restoreFlaro() { return }
|
||||||
|
case .jellyfin, nil: if await restoreJellyfin() { return }
|
||||||
|
}
|
||||||
phase = .signedOut
|
phase = .signedOut
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +258,16 @@ final class FlaroSession {
|
|||||||
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
|
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
|
||||||
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
|
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
|
||||||
func signOut() {
|
func signOut() {
|
||||||
|
// **Dem Flaro-Server sagen, dass dieses Gerät geht.** Jellyfin
|
||||||
|
// erfährt es über das Verfallen des Tokens; Flaro zieht es auf
|
||||||
|
// Zuruf zurück, und ein nicht zurückgezogenes Token stünde sonst
|
||||||
|
// bis zum Ablauf in der Geräteliste.
|
||||||
|
if let client = flaroClient {
|
||||||
|
Task { await FlaroSignIn.signOut(client: client) }
|
||||||
|
}
|
||||||
forgetJellyfin()
|
forgetJellyfin()
|
||||||
|
forgetFlaro()
|
||||||
|
defaults.removeObject(forKey: Key.activeKind)
|
||||||
clearActiveServer()
|
clearActiveServer()
|
||||||
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
|
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
|
||||||
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
|
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
|
||||||
@@ -276,14 +331,14 @@ final class FlaroSession {
|
|||||||
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
|
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
|
||||||
clearActiveServer()
|
clearActiveServer()
|
||||||
jellyfinAccount = account
|
jellyfinAccount = account
|
||||||
|
activeKind = .jellyfin
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
isLocalConnection = await backend.isInLocalNetwork()
|
isLocalConnection = await backend.isInLocalNetwork()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
libraries = try await loadLibraries(from: backend)
|
libraries = try await loadLibraries(from: backend)
|
||||||
} catch {
|
} catch {
|
||||||
self.backend = nil
|
clearActiveServer()
|
||||||
jellyfinAccount = nil
|
|
||||||
phase = .failed(message: ErrorText.describe(error))
|
phase = .failed(message: ErrorText.describe(error))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -320,6 +375,7 @@ final class FlaroSession {
|
|||||||
|
|
||||||
clearActiveServer()
|
clearActiveServer()
|
||||||
jellyfinAccount = account
|
jellyfinAccount = account
|
||||||
|
activeKind = .jellyfin
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
|
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
|
||||||
// Zug wiederhergestellt werden, und die Antwort gilt je Sitzung.
|
// Zug wiederhergestellt werden, und die Antwort gilt je Sitzung.
|
||||||
@@ -341,7 +397,99 @@ final class FlaroSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Verbinden mit Flaro
|
||||||
|
|
||||||
|
/// Übernimmt eine fertige Flaro-Anmeldung.
|
||||||
|
///
|
||||||
|
/// Dieselbe Form wie bei Jellyfin, und das ist der Beweis für die Naht:
|
||||||
|
/// was danach kommt — Mediatheken laden, Phase setzen, merken — steht
|
||||||
|
/// zweimal genau gleich da, weil `MediaBackend` dazwischen liegt.
|
||||||
|
func adoptFlaro(client: FlaroClient, account: FlaroAccount) async {
|
||||||
|
phase = .connecting(serverName: account.serverName)
|
||||||
|
|
||||||
|
let backend = FlaroBackend(client: client, account: account)
|
||||||
|
clearActiveServer()
|
||||||
|
flaroAccount = account
|
||||||
|
flaroClient = client
|
||||||
|
activeKind = .flaro
|
||||||
|
self.backend = backend
|
||||||
|
// **Flaro fragt danach nicht.** Jellyfin meldet, ob die Verbindung
|
||||||
|
// im selben Netz liegt; Flaro hat dafür keinen Endpunkt und rechnet
|
||||||
|
// auch nichts daran fest. Im Zweifel vorsichtig: lieber einmal zu
|
||||||
|
// stark gedeckelt als eine Wiedergabe, die unterwegs nicht anläuft.
|
||||||
|
isLocalConnection = false
|
||||||
|
|
||||||
|
do {
|
||||||
|
libraries = try await loadLibraries(from: backend)
|
||||||
|
} catch {
|
||||||
|
clearActiveServer()
|
||||||
|
phase = .failed(message: ErrorText.describe(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberFlaro(account: account, token: nil)
|
||||||
|
phase = .ready
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dasselbe mit frischem Token — nach einer Anmeldung mit Passwort.
|
||||||
|
func adoptFlaro(client: FlaroClient, account: FlaroAccount, token: String) async {
|
||||||
|
await adoptFlaro(client: client, account: account)
|
||||||
|
guard phase == .ready else { return }
|
||||||
|
rememberFlaro(account: account, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nimmt eine gespeicherte Flaro-Anmeldung wieder auf.
|
||||||
|
private func restoreFlaro() async -> Bool {
|
||||||
|
guard let address = defaults.string(forKey: Key.flaroAddress),
|
||||||
|
let baseURL = URL(string: address),
|
||||||
|
let token = tokenStore.token(for: Self.flaroTokenAccount)
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
phase = .connecting(serverName: defaults.string(forKey: Key.flaroServerName) ?? "")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (account, client) = try await FlaroSignIn.restore(
|
||||||
|
baseURL: baseURL, token: token, identity: flaroIdentity)
|
||||||
|
await adoptFlaro(client: client, account: account)
|
||||||
|
return true
|
||||||
|
} catch MediaBackendError.unauthorized {
|
||||||
|
// Am Server zurückgezogen. Ihn zu behalten erzeugte bei jedem
|
||||||
|
// Start denselben Fehler.
|
||||||
|
forgetFlaro()
|
||||||
|
clearActiveServer()
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
|
||||||
|
// Server das, und es ist kein Grund, jemanden abzumelden.
|
||||||
|
//
|
||||||
|
// **Trotzdem mit Server:** sonst stünde beim Erneut-Versuchen
|
||||||
|
// kein Konto da, und das Heruntergeladene wäre unerreichbar.
|
||||||
|
phase = .failed(message: ErrorText.describe(error))
|
||||||
|
activeKind = .flaro
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rememberFlaro(account: FlaroAccount, token: String?) {
|
||||||
|
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
|
||||||
|
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
|
||||||
|
defaults.set(account.userID, forKey: Key.flaroUserID)
|
||||||
|
defaults.set(account.userName, forKey: Key.flaroUserName)
|
||||||
|
defaults.set(account.serverName, forKey: Key.flaroServerName)
|
||||||
|
defaults.set(account.isAdministrator, forKey: Key.flaroIsAdministrator)
|
||||||
|
if let token { try? tokenStore.store(token, for: Self.flaroTokenAccount) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func forgetFlaro() {
|
||||||
|
for key in [Key.flaroAddress, Key.flaroUserID, Key.flaroUserName,
|
||||||
|
Key.flaroServerName, Key.flaroIsAdministrator] {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
tokenStore.remove(for: Self.flaroTokenAccount)
|
||||||
|
}
|
||||||
|
|
||||||
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
|
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
|
||||||
|
defaults.set(ServerKind.jellyfin.rawValue, forKey: Key.activeKind)
|
||||||
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
|
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
|
||||||
defaults.set(account.userID, forKey: Key.jellyfinUserID)
|
defaults.set(account.userID, forKey: Key.jellyfinUserID)
|
||||||
defaults.set(account.userName, forKey: Key.jellyfinUserName)
|
defaults.set(account.userName, forKey: Key.jellyfinUserName)
|
||||||
@@ -385,12 +533,18 @@ final class FlaroSession {
|
|||||||
private func clearActiveServer() {
|
private func clearActiveServer() {
|
||||||
backend = nil
|
backend = nil
|
||||||
jellyfinAccount = nil
|
jellyfinAccount = nil
|
||||||
|
flaroAccount = nil
|
||||||
|
flaroClient = nil
|
||||||
|
activeKind = nil
|
||||||
libraries = []
|
libraries = []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
|
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
|
||||||
func retry() async {
|
func retry() async {
|
||||||
if await restoreJellyfin() { return }
|
switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
|
||||||
|
case .flaro: if await restoreFlaro() { return }
|
||||||
|
case .jellyfin, nil: if await restoreJellyfin() { return }
|
||||||
|
}
|
||||||
phase = .signedOut
|
phase = .signedOut
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,4 +554,13 @@ final class FlaroSession {
|
|||||||
/// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App
|
/// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App
|
||||||
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
|
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
|
||||||
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
|
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
|
||||||
|
|
||||||
|
/// Dasselbe für Flaro.
|
||||||
|
var flaroDeviceIdentity: FlaroIdentity { flaroIdentity }
|
||||||
|
|
||||||
|
/// Der Client des aktiven Flaro-Servers — zum Abmelden.
|
||||||
|
///
|
||||||
|
/// Nicht `@Observable`-sichtbar: keine Ansicht liest ihn, und eine
|
||||||
|
/// Änderung daran soll kein Neuzeichnen auslösen.
|
||||||
|
@ObservationIgnored private var flaroClient: FlaroClient?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import Foundation
|
||||||
|
import FlaroServerKit
|
||||||
|
import MediaKit
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Führt durch die Anmeldung an einem Flaro-Server.
|
||||||
|
///
|
||||||
|
/// Zwei Fragen am Telefon: wo steht der Server, und wer bist du.
|
||||||
|
///
|
||||||
|
/// **Am Fernseher andersherum.** Dort steht der sechsstellige Code vorn:
|
||||||
|
/// es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und
|
||||||
|
/// das Gerät, auf dem man bestätigt, liegt sowieso auf dem Sofa. Am Telefon
|
||||||
|
/// führt derselbe Weg nirgendwohin — der Code braucht ein zweites, bereits
|
||||||
|
/// angemeldetes Gerät, und das erste ist gerade dieses hier.
|
||||||
|
///
|
||||||
|
/// Welcher Fall gilt, weiss die Oberfläche; dieses Modell bekommt es im
|
||||||
|
/// Initialisierer gesagt.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class FlaroSignInModel {
|
||||||
|
enum Step: Equatable {
|
||||||
|
case address
|
||||||
|
case checking
|
||||||
|
/// Der Code steht auf dem Bildschirm, jetzt wird gewartet.
|
||||||
|
case code(String)
|
||||||
|
case credentials
|
||||||
|
case signingIn
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PreferredMethod { case credentials, code }
|
||||||
|
|
||||||
|
var address = ""
|
||||||
|
var userName = ""
|
||||||
|
var password = ""
|
||||||
|
|
||||||
|
private(set) var step: Step = .address
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
/// Wie der gefundene Server heißt — die Bestätigung, dass die Adresse
|
||||||
|
/// stimmt, noch bevor jemand Anmeldedaten eintippt.
|
||||||
|
private(set) var serverName: String?
|
||||||
|
/// Der Pflichthinweis auf die Metadatenquelle, schon vor der Anmeldung.
|
||||||
|
private(set) var attributions: [FlaroSignIn.MetadataAttribution] = []
|
||||||
|
|
||||||
|
var onAuthenticated: ((FlaroClient, FlaroAccount, String) -> Void)?
|
||||||
|
|
||||||
|
private let identity: FlaroIdentity
|
||||||
|
private let preferred: PreferredMethod
|
||||||
|
private var baseURL: URL?
|
||||||
|
private var pollTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Der Vorgabewert hält iOS unverändert.
|
||||||
|
init(identity: FlaroIdentity, preferring: PreferredMethod = .credentials) {
|
||||||
|
self.identity = identity
|
||||||
|
self.preferred = preferring
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kein `deinit`, der abbricht: `pollTask` gehört dem Hauptakteur, und
|
||||||
|
// ein `deinit` läuft nicht dort. Abgebrochen wird über `cancel()` beim
|
||||||
|
// Verschwinden der Ansicht — wie beim Jellyfin-Modell auch.
|
||||||
|
|
||||||
|
/// Hält die Abfrageschleife an.
|
||||||
|
///
|
||||||
|
/// Ohne das läuft sie nach dem Wegwischen des Sheets noch minutenlang
|
||||||
|
/// weiter — unsichtbar und für nichts.
|
||||||
|
func cancel() {
|
||||||
|
pollTask?.cancel()
|
||||||
|
pollTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schritt 1: den Server finden
|
||||||
|
|
||||||
|
func findServer() async {
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
let kandidaten = FlaroAddress.candidates(for: address)
|
||||||
|
guard !kandidaten.isEmpty else {
|
||||||
|
errorMessage = String(localized: "signIn.flaro.addressMissing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
step = .checking
|
||||||
|
|
||||||
|
for kandidat in kandidaten {
|
||||||
|
guard let gruss = try? await FlaroSignIn.greet(
|
||||||
|
baseURL: kandidat, identity: identity) else { continue }
|
||||||
|
|
||||||
|
// **Ein frischer Server ist kein falsches Passwort.** Wer hier
|
||||||
|
// weitermachte, bekäme „Benutzername oder Passwort stimmt nicht"
|
||||||
|
// für ein Konto, das es noch gar nicht gibt.
|
||||||
|
guard !gruss.needsSetup else {
|
||||||
|
baseURL = kandidat
|
||||||
|
serverName = gruss.serverName
|
||||||
|
step = .address
|
||||||
|
errorMessage = String(localized: "signIn.flaro.needsSetup")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL = kandidat
|
||||||
|
address = kandidat.absoluteString
|
||||||
|
serverName = gruss.serverName
|
||||||
|
attributions = gruss.attributions
|
||||||
|
|
||||||
|
if preferred == .code {
|
||||||
|
await beginCode()
|
||||||
|
} else {
|
||||||
|
step = .credentials
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
step = .address
|
||||||
|
errorMessage = String(localized: "signIn.flaro.notFound")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zurück zur Adresse — etwa, weil der gefundene Server der falsche ist.
|
||||||
|
func changeAddress() {
|
||||||
|
cancel()
|
||||||
|
baseURL = nil
|
||||||
|
serverName = nil
|
||||||
|
errorMessage = nil
|
||||||
|
step = .address
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schritt 2a: der Code
|
||||||
|
|
||||||
|
/// Fordert einen Code an und wartet auf die Bestätigung.
|
||||||
|
func beginCode() async {
|
||||||
|
guard let baseURL else { return }
|
||||||
|
cancel()
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
let offen: FlaroSignIn.PendingCode
|
||||||
|
do {
|
||||||
|
offen = try await FlaroSignIn.requestCode(baseURL: baseURL, identity: identity)
|
||||||
|
} catch {
|
||||||
|
step = .credentials
|
||||||
|
errorMessage = ErrorText.describe(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
step = .code(offen.code)
|
||||||
|
|
||||||
|
// **Nachfragen, nicht offen halten.** Eine Anfrage, die fünf Minuten
|
||||||
|
// auf eine Bestätigung wartet, läuft hinter jedem Reverse Proxy ins
|
||||||
|
// Zeitlimit.
|
||||||
|
pollTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
guard let self else { return }
|
||||||
|
guard await self.checkCode(baseURL: baseURL, pending: offen) else { return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Runde der Abfrage. `false` heisst: es gibt nichts mehr zu warten.
|
||||||
|
private func checkCode(baseURL: URL, pending: FlaroSignIn.PendingCode) async -> Bool {
|
||||||
|
guard let antwort = try? await FlaroSignIn.pollCode(
|
||||||
|
baseURL: baseURL, pending: pending, identity: identity)
|
||||||
|
else { return true } // Ein Aussetzer ist kein Abbruch.
|
||||||
|
|
||||||
|
switch antwort.state {
|
||||||
|
case .pending:
|
||||||
|
return true
|
||||||
|
case .expired:
|
||||||
|
errorMessage = String(localized: "signIn.flaro.codeExpired")
|
||||||
|
step = .credentials
|
||||||
|
return false
|
||||||
|
case .approved(let token, let konto):
|
||||||
|
guard let client = antwort.client else { return false }
|
||||||
|
onAuthenticated?(client, konto, token)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zurück zum Passwort — für Server, auf denen niemand bestätigen kann.
|
||||||
|
func useCredentials() {
|
||||||
|
cancel()
|
||||||
|
errorMessage = nil
|
||||||
|
step = .credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schritt 2b: anmelden
|
||||||
|
|
||||||
|
func signIn() async {
|
||||||
|
guard let baseURL else { return }
|
||||||
|
cancel()
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
let name = userName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !name.isEmpty, !password.isEmpty else {
|
||||||
|
errorMessage = String(localized: "signIn.flaro.credentialsMissing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
step = .signingIn
|
||||||
|
do {
|
||||||
|
let (token, konto, client) = try await FlaroSignIn.signIn(
|
||||||
|
baseURL: baseURL, userName: name, password: password, identity: identity)
|
||||||
|
// **Das Passwort sofort wegwerfen.** Es wird für die Dauer einer
|
||||||
|
// Anfrage gehalten und danach nicht mehr gebraucht; es stehen zu
|
||||||
|
// lassen hiesse, es in einem Speicherabbild wiederzufinden.
|
||||||
|
password = ""
|
||||||
|
onAuthenticated?(client, konto, token)
|
||||||
|
} catch {
|
||||||
|
password = ""
|
||||||
|
step = .credentials
|
||||||
|
errorMessage = ErrorText.describe(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlaroDesign
|
||||||
|
import FlaroServerKit
|
||||||
|
|
||||||
|
/// Die Anmeldung an einem Flaro-Server.
|
||||||
|
///
|
||||||
|
/// Zwei Schritte: wo steht er, und wer bist du. Mehr Fragen gibt es nicht —
|
||||||
|
/// und eine davon beantwortet der Server selbst, indem er seinen Namen nennt.
|
||||||
|
struct FlaroSignInView: View {
|
||||||
|
@Environment(FlaroSession.self) private var session
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
|
@State private var model: FlaroSignInModel?
|
||||||
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
|
private enum Field { case address, userName, password }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
if let model {
|
||||||
|
content(for: model)
|
||||||
|
.padding(.horizontal, FlaroSpacing.section)
|
||||||
|
.padding(.vertical, FlaroSpacing.section)
|
||||||
|
.frame(maxWidth: 420)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.background(Color.flaroCanvas)
|
||||||
|
.navigationTitle(Text("signIn.flaro.title"))
|
||||||
|
.flaroInlineTitle()
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("action.cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(accent)
|
||||||
|
.task {
|
||||||
|
guard model == nil else { return }
|
||||||
|
let fresh = FlaroSignInModel(identity: session.flaroDeviceIdentity)
|
||||||
|
fresh.onAuthenticated = { client, konto, token in
|
||||||
|
dismiss()
|
||||||
|
Task { await session.adoptFlaro(client: client, account: konto, token: token) }
|
||||||
|
}
|
||||||
|
model = fresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func content(for model: FlaroSignInModel) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
|
switch model.step {
|
||||||
|
case .address, .checking:
|
||||||
|
addressStep(model: model)
|
||||||
|
case .credentials, .signingIn:
|
||||||
|
credentialsStep(model: model)
|
||||||
|
case .code:
|
||||||
|
// **Am Telefon führt der Code nirgendwohin.** Er braucht ein
|
||||||
|
// zweites, bereits angemeldetes Gerät — und das erste ist
|
||||||
|
// gerade dieses hier. Das Modell bietet ihn deshalb nur an,
|
||||||
|
// wenn es mit `preferring: .code` gebaut wurde; am Fernseher
|
||||||
|
// ist das so, hier nicht. Der Fall steht trotzdem da, weil
|
||||||
|
// ein `default` die nächste neue Lage verschlucken würde.
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let message = model.errorMessage {
|
||||||
|
Text(message)
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(Color.flaroDestructive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func addressStep(model: FlaroSignInModel) -> some View {
|
||||||
|
@Bindable var model = model
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
|
Text("signIn.flaro.addressPrompt")
|
||||||
|
.font(.flaroRowHeader)
|
||||||
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
|
||||||
|
TextField("signIn.flaro.addressPlaceholder", text: $model.address)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.textContentType(.URL)
|
||||||
|
#if os(iOS)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
#endif
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($focusedField, equals: .address)
|
||||||
|
.onSubmit { Task { await model.findServer() } }
|
||||||
|
|
||||||
|
Text("signIn.flaro.addressHint")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await model.findServer() }
|
||||||
|
} label: {
|
||||||
|
if model.step == .checking {
|
||||||
|
ProgressView().frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
Text("signIn.flaro.findServer").frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(accent)
|
||||||
|
.disabled(model.step == .checking)
|
||||||
|
}
|
||||||
|
.onAppear { focusedField = .address }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func credentialsStep(model: FlaroSignInModel) -> some View {
|
||||||
|
@Bindable var model = model
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
|
if let name = model.serverName {
|
||||||
|
// Der Name des Servers steht hier, damit sichtbar ist,
|
||||||
|
// **wo** man sich gleich anmeldet — eine Adresse allein sagt
|
||||||
|
// das nicht.
|
||||||
|
HStack {
|
||||||
|
Text(name)
|
||||||
|
.font(.flaroRowHeader)
|
||||||
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
Spacer()
|
||||||
|
Button("signIn.flaro.changeAddress") { model.changeAddress() }
|
||||||
|
.font(.flaroCaption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("signIn.userName", text: $model.userName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.textContentType(.username)
|
||||||
|
#if os(iOS)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
#endif
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($focusedField, equals: .userName)
|
||||||
|
.onSubmit { focusedField = .password }
|
||||||
|
|
||||||
|
SecureField("signIn.password", text: $model.password)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.textContentType(.password)
|
||||||
|
.focused($focusedField, equals: .password)
|
||||||
|
.onSubmit { Task { await model.signIn() } }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await model.signIn() }
|
||||||
|
} label: {
|
||||||
|
if model.step == .signingIn {
|
||||||
|
ProgressView().frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
Text("signIn.flaro.signIn").frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(accent)
|
||||||
|
.disabled(model.step == .signingIn)
|
||||||
|
|
||||||
|
// Der Pflichthinweis auf TMDB — Bedingung ihrer Lizenz, und der
|
||||||
|
// Server liefert ihn ohne Anmeldung genau dafür.
|
||||||
|
ForEach(model.attributions, id: \.text) { hinweis in
|
||||||
|
Text(hinweis.text)
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { focusedField = .userName }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,7 +184,7 @@ struct JellyfinSignInView: View {
|
|||||||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
foundServerHeader(model: model)
|
foundServerHeader(model: model)
|
||||||
|
|
||||||
TextField("signIn.jellyfin.userName", text: $model.userName)
|
TextField("signIn.userName", text: $model.userName)
|
||||||
.textContentType(.username)
|
.textContentType(.username)
|
||||||
.flaroPlainEntry()
|
.flaroPlainEntry()
|
||||||
.submitLabel(.next)
|
.submitLabel(.next)
|
||||||
@@ -203,7 +203,7 @@ struct JellyfinSignInView: View {
|
|||||||
.padding(FlaroSpacing.regular)
|
.padding(FlaroSpacing.regular)
|
||||||
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
||||||
|
|
||||||
SecureField("signIn.jellyfin.password", text: $model.password)
|
SecureField("signIn.password", text: $model.password)
|
||||||
.textContentType(.password)
|
.textContentType(.password)
|
||||||
.submitLabel(.go)
|
.submitLabel(.go)
|
||||||
.focused($focusedField, equals: .password)
|
.focused($focusedField, equals: .password)
|
||||||
|
|||||||
@@ -3,16 +3,23 @@ import FlaroDesign
|
|||||||
|
|
||||||
/// Der Anmeldebildschirm.
|
/// Der Anmeldebildschirm.
|
||||||
///
|
///
|
||||||
/// **Ein Weg, seit es einen Server gibt.** Hier standen zwei Knöpfe
|
/// **Wieder zwei Knöpfe, und diesmal zu zwei Servern.** Hier standen schon
|
||||||
/// nebeneinander, und der obere war der auffälligere — er führte zu Plex.
|
/// einmal zwei — Plex oben, Jellyfin darunter —, und nach dem Entfernen von
|
||||||
/// Bleibt einer übrig, erbt er die Hervorhebung: ein Knopf, der aussieht wie
|
/// Plex blieb einer übrig. Jetzt gibt es Flaro, und die Frage stellt sich
|
||||||
/// die zweite Wahl, lässt den Benutzer nach der ersten suchen.
|
/// neu: welcher steht oben?
|
||||||
///
|
///
|
||||||
/// Jellyfin hat kein Verzeichnis, an dem die eigenen Server hängen. Deshalb
|
/// **Flaro.** Nicht aus Eitelkeit, sondern weil die App dort mehr kann: der
|
||||||
/// wird die Adresse eingetippt und, wann immer der Server es anbietet, ein
|
/// Download bringt alle Tonspuren mit, der Fortschritt wird ab der ersten
|
||||||
/// Code bestätigt statt eines Passworts — das führt `JellyfinSignInView`.
|
/// Sekunde gemerkt. Wer Jellyfin benutzt, sucht gezielt danach — wer neu
|
||||||
|
/// anfängt, soll nicht in der schwächeren Hälfte landen, weil ihr Knopf
|
||||||
|
/// auffälliger war.
|
||||||
|
///
|
||||||
|
/// Beide Server haben kein Verzeichnis, an dem die eigenen Instanzen hängen.
|
||||||
|
/// Die Adresse wird deshalb eingetippt; den Rest führen die beiden
|
||||||
|
/// Anmeldeansichten.
|
||||||
struct SignInView: View {
|
struct SignInView: View {
|
||||||
@Environment(\.flaroAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
@State private var showsFlaroSignIn = false
|
||||||
@State private var showsJellyfinSignIn = false
|
@State private var showsJellyfinSignIn = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -37,6 +44,17 @@ struct SignInView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
VStack(spacing: FlaroSpacing.regular) {
|
VStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Button {
|
||||||
|
showsFlaroSignIn = true
|
||||||
|
} label: {
|
||||||
|
Text("signIn.withFlaro")
|
||||||
|
.font(.flaroRowHeader)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, FlaroSpacing.regular)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(accent)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showsJellyfinSignIn = true
|
showsJellyfinSignIn = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -45,8 +63,7 @@ struct SignInView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, FlaroSpacing.regular)
|
.padding(.vertical, FlaroSpacing.regular)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.bordered)
|
||||||
.tint(accent)
|
|
||||||
|
|
||||||
Text("signIn.privacy")
|
Text("signIn.privacy")
|
||||||
.font(.flaroCaption)
|
.font(.flaroCaption)
|
||||||
@@ -60,6 +77,9 @@ struct SignInView: View {
|
|||||||
.frame(maxWidth: 420)
|
.frame(maxWidth: 420)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.flaroCanvas)
|
.background(Color.flaroCanvas)
|
||||||
|
.sheet(isPresented: $showsFlaroSignIn) {
|
||||||
|
FlaroSignInView()
|
||||||
|
}
|
||||||
.sheet(isPresented: $showsJellyfinSignIn) {
|
.sheet(isPresented: $showsJellyfinSignIn) {
|
||||||
JellyfinSignInView()
|
JellyfinSignInView()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
import FlaroPlayback
|
import FlaroPlayback
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
import FlaroServerKit
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
|
|
||||||
/// Die App auf dem Fernseher.
|
/// Die App auf dem Fernseher.
|
||||||
@@ -45,6 +46,7 @@ struct FlaroTVApp: App {
|
|||||||
client: Branding.appName,
|
client: Branding.appName,
|
||||||
version: Branding.version
|
version: Branding.version
|
||||||
),
|
),
|
||||||
|
flaroIdentity: FlaroIdentity.current(),
|
||||||
tokenStore: KeychainTokenStore(service: Branding.keychainService)
|
tokenStore: KeychainTokenStore(service: Branding.keychainService)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlaroDesign
|
||||||
|
import FlaroServerKit
|
||||||
|
|
||||||
|
/// Die Anmeldung an einem Flaro-Server, am Fernseher.
|
||||||
|
///
|
||||||
|
/// Dasselbe Modell wie am Telefon, nur mit `preferring: .code`: sechs Ziffern
|
||||||
|
/// auf dem Bildschirm, bestätigt am Telefon. Das Passwort bleibt der Rückfall
|
||||||
|
/// — für den Fall, dass gerade kein angemeldetes Gerät zur Hand ist.
|
||||||
|
struct TVFlaroSignInView: View {
|
||||||
|
@Environment(FlaroSession.self) private var session
|
||||||
|
|
||||||
|
let zurueck: () -> Void
|
||||||
|
|
||||||
|
@State private var model: FlaroSignInModel?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let model {
|
||||||
|
inhalt(for: model)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
guard model == nil else { return }
|
||||||
|
let frisch = FlaroSignInModel(
|
||||||
|
identity: session.flaroDeviceIdentity, preferring: .code)
|
||||||
|
frisch.onAuthenticated = { client, konto, token in
|
||||||
|
Task { await session.adoptFlaro(client: client, account: konto, token: token) }
|
||||||
|
}
|
||||||
|
model = frisch
|
||||||
|
}
|
||||||
|
.onDisappear { model?.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func inhalt(for model: FlaroSignInModel) -> some View {
|
||||||
|
@Bindable var model = model
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
|
Text(Branding.appName).font(.flaroHero)
|
||||||
|
Text("signIn.flaro.title").font(.flaroBody).foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
|
switch model.step {
|
||||||
|
case .address:
|
||||||
|
adresse(model: model)
|
||||||
|
case .checking:
|
||||||
|
warten("signIn.flaro.searching")
|
||||||
|
case .code(let code):
|
||||||
|
codeAnzeigen(model: model, code: code)
|
||||||
|
case .credentials:
|
||||||
|
zugangsdaten(model: model)
|
||||||
|
case .signingIn:
|
||||||
|
warten("signIn.flaro.signingIn")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fehler = model.errorMessage {
|
||||||
|
Text(fehler).font(.flaroBody).foregroundStyle(Color.flaroDestructive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(FlaroSpacing.section)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func adresse(model: FlaroSignInModel) -> some View {
|
||||||
|
@Bindable var model = model
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
|
Text("signIn.flaro.addressPrompt").font(.flaroRowHeader)
|
||||||
|
TextField("signIn.flaro.addressPlaceholder", text: $model.address)
|
||||||
|
.flaroPlainEntry(isAddress: true)
|
||||||
|
Text("signIn.flaro.addressHint")
|
||||||
|
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Button("signIn.flaro.findServer") { Task { await model.findServer() } }
|
||||||
|
Button("action.back", action: zurueck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func codeAnzeigen(model: FlaroSignInModel, code: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
|
if let name = model.serverName {
|
||||||
|
Text(name).font(.flaroRowHeader)
|
||||||
|
}
|
||||||
|
// **Der Code ist hier der Inhalt, nicht eine Beschriftung.**
|
||||||
|
// Er wird aus drei Metern abgelesen und in ein Telefon getippt;
|
||||||
|
// in Fliesstextgrösse ist das eine Zumutung.
|
||||||
|
Text(code)
|
||||||
|
.font(.system(size: 96, weight: .bold, design: .monospaced))
|
||||||
|
.kerning(12)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text("signIn.flaro.codeHint").font(.flaroBody)
|
||||||
|
Label("signIn.flaro.waitingForApproval", systemImage: "hourglass")
|
||||||
|
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Button("signIn.flaro.usePassword") { model.useCredentials() }
|
||||||
|
Button("signIn.flaro.changeAddress") { model.changeAddress() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func zugangsdaten(model: FlaroSignInModel) -> some View {
|
||||||
|
@Bindable var model = model
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
|
if let name = model.serverName {
|
||||||
|
Text(name).font(.flaroRowHeader)
|
||||||
|
}
|
||||||
|
TextField("signIn.userName", text: $model.userName)
|
||||||
|
.flaroPlainEntry()
|
||||||
|
SecureField("signIn.password", text: $model.password)
|
||||||
|
|
||||||
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Button("signIn.flaro.signIn") { Task { await model.signIn() } }
|
||||||
|
Button("signIn.flaro.useCode") { Task { await model.beginCode() } }
|
||||||
|
Button("signIn.flaro.changeAddress") { model.changeAddress() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func warten(_ text: LocalizedStringKey) -> some View {
|
||||||
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
|
ProgressView()
|
||||||
|
Text(text).font(.flaroBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,51 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroServerKit
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
|
|
||||||
/// Die Anmeldung am Fernseher.
|
/// Die Anmeldung am Fernseher.
|
||||||
///
|
///
|
||||||
/// **Dieselben vier Schritte wie am Telefon, andere Reihenfolge.** Das Modell
|
/// **Erst die Frage, welcher Server.** Seit es zwei gibt, steht sie vorn —
|
||||||
/// `JellyfinSignInModel` ist unverändert dasselbe — nur bekommt es hier
|
/// und Flaro oben, weil die App dort mehr kann.
|
||||||
/// `preferring: .quickConnect`. Vor einem Fernseher ist der Code der bequemere
|
|
||||||
/// Weg: es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und
|
|
||||||
/// das Gerät, auf dem man den Code bestätigt, liegt sowieso auf dem Sofa.
|
|
||||||
///
|
///
|
||||||
/// Der Weg über Benutzername und Passwort bleibt erreichbar — für Server, auf
|
/// Danach dieselben Schritte wie am Telefon, nur andere Reihenfolge: beide
|
||||||
/// denen Quick Connect abgeschaltet ist.
|
/// Modelle bekommen hier den Code-Weg voran. Vor einem Fernseher ist er der
|
||||||
|
/// bequemere: es gibt keinen Passwortspeicher, getippt wird mit dem
|
||||||
|
/// Steuerkreuz, und das Gerät, auf dem man bestätigt, liegt sowieso auf dem
|
||||||
|
/// Sofa. Benutzername und Passwort bleiben erreichbar.
|
||||||
struct TVSignInView: View {
|
struct TVSignInView: View {
|
||||||
@Environment(FlaroSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
|
|
||||||
|
@State private var art: FlaroSession.ServerKind?
|
||||||
@State private var model: JellyfinSignInModel?
|
@State private var model: JellyfinSignInModel?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.flaroCanvas.ignoresSafeArea()
|
Color.flaroCanvas.ignoresSafeArea()
|
||||||
|
|
||||||
if let model {
|
Group {
|
||||||
inhalt(for: model)
|
switch art {
|
||||||
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
|
case nil:
|
||||||
// liest sich aus drei Metern nicht mehr als Zeile.
|
auswahl()
|
||||||
.frame(maxWidth: 900)
|
case .flaro:
|
||||||
} else {
|
TVFlaroSignInView(zurueck: { art = nil })
|
||||||
ProgressView()
|
case .jellyfin:
|
||||||
|
if let model {
|
||||||
|
inhalt(for: model)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
|
||||||
|
// liest sich aus drei Metern nicht mehr als Zeile.
|
||||||
|
.frame(maxWidth: 900)
|
||||||
}
|
}
|
||||||
.task {
|
.onChange(of: art) { _, neu in
|
||||||
|
guard neu == .jellyfin, model == nil else { return }
|
||||||
// Erst hier, nicht im Eigenschaftswert: das Modell braucht die
|
// Erst hier, nicht im Eigenschaftswert: das Modell braucht die
|
||||||
// Gerätekennung der Sitzung. Baut es sich eine eigene, steht die
|
// Gerätekennung der Sitzung. Baut es sich eine eigene, steht die
|
||||||
// App nach dem Anmelden ein zweites Mal in der Geräteliste.
|
// App nach dem Anmelden ein zweites Mal in der Geräteliste.
|
||||||
guard model == nil else { return }
|
|
||||||
let frisch = JellyfinSignInModel(
|
let frisch = JellyfinSignInModel(
|
||||||
identity: session.jellyfinDeviceIdentity,
|
identity: session.jellyfinDeviceIdentity,
|
||||||
preferring: .quickConnect
|
preferring: .quickConnect
|
||||||
@@ -51,6 +62,19 @@ struct TVSignInView: View {
|
|||||||
.onDisappear { model?.cancel() }
|
.onDisappear { model?.cancel() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func auswahl() -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
|
Text(Branding.appName).font(.flaroHero)
|
||||||
|
Text("signIn.tagline").font(.flaroBody).foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
|
VStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Button("signIn.withFlaro") { art = .flaro }
|
||||||
|
Button("signIn.withJellyfin") { art = .jellyfin }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(FlaroSpacing.section)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func inhalt(for model: JellyfinSignInModel) -> some View {
|
private func inhalt(for model: JellyfinSignInModel) -> some View {
|
||||||
@Bindable var model = model
|
@Bindable var model = model
|
||||||
@@ -129,9 +153,9 @@ struct TVSignInView: View {
|
|||||||
if let name = model.serverName {
|
if let name = model.serverName {
|
||||||
Text(name).font(.flaroRowHeader)
|
Text(name).font(.flaroRowHeader)
|
||||||
}
|
}
|
||||||
TextField("signIn.jellyfin.userName", text: $model.userName)
|
TextField("signIn.userName", text: $model.userName)
|
||||||
.flaroPlainEntry()
|
.flaroPlainEntry()
|
||||||
SecureField("signIn.jellyfin.password", text: $model.password)
|
SecureField("signIn.password", text: $model.password)
|
||||||
Text("signIn.jellyfin.passwordNote")
|
Text("signIn.jellyfin.passwordNote")
|
||||||
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
|
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ let package = Package(
|
|||||||
products: [
|
products: [
|
||||||
.library(name: "MediaKit", targets: ["MediaKit"]),
|
.library(name: "MediaKit", targets: ["MediaKit"]),
|
||||||
.library(name: "JellyfinKit", targets: ["JellyfinKit"]),
|
.library(name: "JellyfinKit", targets: ["JellyfinKit"]),
|
||||||
|
.library(name: "FlaroServerKit", targets: ["FlaroServerKit"]),
|
||||||
.library(name: "FlaroDesign", targets: ["FlaroDesign"]),
|
.library(name: "FlaroDesign", targets: ["FlaroDesign"]),
|
||||||
.library(name: "FlaroPlayback", targets: ["FlaroPlayback"]),
|
.library(name: "FlaroPlayback", targets: ["FlaroPlayback"]),
|
||||||
.library(name: "FlaroDownloads", targets: ["FlaroDownloads"]),
|
.library(name: "FlaroDownloads", targets: ["FlaroDownloads"]),
|
||||||
@@ -39,6 +40,12 @@ let package = Package(
|
|||||||
resources: [.copy("Fixtures")]
|
resources: [.copy("Fixtures")]
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Flaros eigener Server. Daneben, nicht darin — genau der Steckplatz,
|
||||||
|
// den Plex geräumt hat. Dass die Naht zwei Server zugleich trägt, war
|
||||||
|
// schon einmal bewiesen; hier wird es wieder gebraucht.
|
||||||
|
.target(name: "FlaroServerKit", dependencies: ["MediaKit"], resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.testTarget(name: "FlaroServerKitTests", dependencies: ["FlaroServerKit"]),
|
||||||
|
|
||||||
// Ein Werkzeug für die Fragen, die reines HTTP sind — ohne Oberfläche,
|
// Ein Werkzeug für die Fragen, die reines HTTP sind — ohne Oberfläche,
|
||||||
// ohne Gerät, ohne Upload. Eine Runde dauert damit Sekunden statt
|
// ohne Gerät, ohne Upload. Eine Runde dauert damit Sekunden statt
|
||||||
// eines halben Tages, und das Upload-Kontingent bleibt für Builds.
|
// eines halben Tages, und das Upload-Kontingent bleibt für Builds.
|
||||||
|
|||||||
@@ -125,11 +125,24 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
|||||||
|
|
||||||
pendingStartTime = source.startTime
|
pendingStartTime = source.startTime
|
||||||
|
|
||||||
|
// **Cookies muss man AVFoundation ausdrücklich mitgeben.**
|
||||||
|
//
|
||||||
|
// `URLSession` liest den gemeinsamen Speicher von allein, `AVURLAsset`
|
||||||
|
// nicht. Für Jellyfin ist das belanglos — dort steht der Zugang in
|
||||||
|
// der Adresse. Flaro schreibt ihn bewusst nicht dorthin: er stünde
|
||||||
|
// sonst in jedem Zugriffsprotokoll und in jedem weitergegebenen Link.
|
||||||
|
// Ohne diese Zeile bekäme der Spieler dort 401 und zeigte Schwarz.
|
||||||
|
//
|
||||||
|
// Serverneutral: wo kein Cookie liegt, ist die Liste leer und es
|
||||||
|
// ändert sich nichts.
|
||||||
|
let kekse = 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,
|
||||||
])
|
])
|
||||||
let item = AVPlayerItem(asset: asset)
|
let item = AVPlayerItem(asset: asset)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Die Adressraterei für Flaro.
|
||||||
|
///
|
||||||
|
/// Der Inhalt steht in `ServerAddress`. Hier steht nur, was Flaro von
|
||||||
|
/// Jellyfin unterscheidet: der Port.
|
||||||
|
public enum FlaroAddress {
|
||||||
|
/// Der Standardport eines Flaro-Servers.
|
||||||
|
///
|
||||||
|
/// 8080, nicht 8096. Die Beispiel-Compose-Datei bildet ihn so ab, und
|
||||||
|
/// einen fremden Port zu erben hiesse, dass jede Anmeldung einmal ins
|
||||||
|
/// Leere läuft, bevor sie den richtigen findet.
|
||||||
|
public static let defaultPort = 8080
|
||||||
|
|
||||||
|
public static func candidates(for input: String) -> [URL] {
|
||||||
|
ServerAddress.candidates(for: input, defaultPort: defaultPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Ein Flaro-Server, gesehen durch `MediaBackend`.
|
||||||
|
///
|
||||||
|
/// Der zweite Server an derselben Naht, an der vorher Plex und Jellyfin
|
||||||
|
/// nebeneinander hingen. Was hier steht, ist die Übersetzung von Flaros
|
||||||
|
/// Schnittstelle in den Vertrag — nichts weiter: keine Oberfläche, keine
|
||||||
|
/// Entscheidung über Wiedergabe, keine Zwischenspeicher.
|
||||||
|
public actor FlaroBackend: MediaBackend {
|
||||||
|
private let client: FlaroClient
|
||||||
|
private let account: FlaroAccount
|
||||||
|
/// Die Mediatheken, einmal geholt.
|
||||||
|
///
|
||||||
|
/// Sie ändern sich nur, wenn jemand in der Verwaltung eine anlegt — und
|
||||||
|
/// jede Seite der Mediathek bräuchte sie sonst erneut, nur um zu wissen,
|
||||||
|
/// ob sie nach Filmen oder nach Serien fragt.
|
||||||
|
private var libraryMemo: [MediaLibrary]?
|
||||||
|
|
||||||
|
public init(client: FlaroClient, account: FlaroAccount) {
|
||||||
|
self.client = client
|
||||||
|
self.account = account
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wer dieser Server ist
|
||||||
|
|
||||||
|
public nonisolated var serverName: String { account.serverName }
|
||||||
|
|
||||||
|
/// **Die Adresse, nicht der Name.** Zwei Server dürfen „Medien" heissen,
|
||||||
|
/// und derselbe darf umbenannt werden. Was hier steht, überlebt beides.
|
||||||
|
public nonisolated var identity: String { "flaro:" + account.baseURL.absoluteString }
|
||||||
|
|
||||||
|
public nonisolated var capabilities: Set<BackendCapability> {
|
||||||
|
[
|
||||||
|
.favourites,
|
||||||
|
.ratings,
|
||||||
|
.libraryScan,
|
||||||
|
.transcoding,
|
||||||
|
// **Der Grund, aus dem es diesen Server gibt.** Flaros Download
|
||||||
|
// bringt jede Ton- und Textspur mit; die App muss vorher nichts
|
||||||
|
// wählen und kann offline umstellen.
|
||||||
|
.allTracksDownload,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Blättern
|
||||||
|
|
||||||
|
public func libraries() async throws(MediaBackendError) -> [MediaLibrary] {
|
||||||
|
if let libraryMemo { return libraryMemo }
|
||||||
|
let roh = try await client.send(FlaroRequest(path: "/api/v1/libraries"), as: [Wire.Library].self)
|
||||||
|
let liste = roh.map { $0.neutral() }
|
||||||
|
libraryMemo = liste
|
||||||
|
return liste
|
||||||
|
}
|
||||||
|
|
||||||
|
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
|
||||||
|
// **Vier Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der eine
|
||||||
|
// fertige Startseite liefert, und das ist Absicht: welche Reihen eine
|
||||||
|
// App zeigt, entscheidet die App. Der Server liefert die Zutaten.
|
||||||
|
async let weiter = reihe(pfad: "/api/v1/home/continue")
|
||||||
|
async let naechste = reihe(pfad: "/api/v1/home/next")
|
||||||
|
async let filme = seite(pfad: "/api/v1/movies", take: 24, as: Wire.Movie.self)
|
||||||
|
async let serien = seite(pfad: "/api/v1/series", take: 24, as: Wire.Series.self)
|
||||||
|
|
||||||
|
var reihen: [MediaRowGroup] = []
|
||||||
|
|
||||||
|
if let eintraege = try? await weiter, !eintraege.isEmpty {
|
||||||
|
reihen.append(MediaRowGroup(
|
||||||
|
id: "flaro.continue",
|
||||||
|
title: String(localized: "flaro.row.continue", bundle: .module),
|
||||||
|
items: eintraege))
|
||||||
|
}
|
||||||
|
if let eintraege = try? await naechste, !eintraege.isEmpty {
|
||||||
|
reihen.append(MediaRowGroup(
|
||||||
|
id: "flaro.nextup",
|
||||||
|
title: String(localized: "flaro.row.nextUp", bundle: .module),
|
||||||
|
items: eintraege))
|
||||||
|
}
|
||||||
|
if let gefunden = try? await filme, !gefunden.items.isEmpty {
|
||||||
|
reihen.append(MediaRowGroup(
|
||||||
|
id: "flaro.movies",
|
||||||
|
title: String(localized: "flaro.row.movies", bundle: .module),
|
||||||
|
items: gefunden.items.map { $0.neutral() }))
|
||||||
|
}
|
||||||
|
if let gefunden = try? await serien, !gefunden.items.isEmpty {
|
||||||
|
reihen.append(MediaRowGroup(
|
||||||
|
id: "flaro.series",
|
||||||
|
title: String(localized: "flaro.row.series", bundle: .module),
|
||||||
|
items: gefunden.items.map { $0.neutral() }))
|
||||||
|
}
|
||||||
|
return reihen
|
||||||
|
}
|
||||||
|
|
||||||
|
public func items(
|
||||||
|
inLibrary library: MediaLibrary,
|
||||||
|
query: MediaQuery,
|
||||||
|
offset: Int,
|
||||||
|
limit: Int
|
||||||
|
) async throws(MediaBackendError) -> MediaPage {
|
||||||
|
var abfrage = [
|
||||||
|
URLQueryItem(name: "library", value: library.id),
|
||||||
|
URLQueryItem(name: "skip", value: String(offset)),
|
||||||
|
URLQueryItem(name: "take", value: String(limit)),
|
||||||
|
]
|
||||||
|
if let text = query.searchText, !text.isEmpty {
|
||||||
|
abfrage.append(URLQueryItem(name: "search", value: text))
|
||||||
|
}
|
||||||
|
|
||||||
|
if library.kind == .show {
|
||||||
|
let seite = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series", query: abfrage),
|
||||||
|
as: Wire.Page<Wire.Series>.self)
|
||||||
|
return MediaPage(
|
||||||
|
items: gesiebt(seite.items.map { $0.neutral() }, query),
|
||||||
|
offset: offset, totalCount: seite.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
let seite = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/movies", query: abfrage),
|
||||||
|
as: Wire.Page<Wire.Movie>.self)
|
||||||
|
return MediaPage(
|
||||||
|
items: gesiebt(seite.items.map { $0.neutral() }, query),
|
||||||
|
offset: offset, totalCount: seite.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func item(id: String) async throws(MediaBackendError) -> MediaItem {
|
||||||
|
// **Erst Film, dann Serie, dann Folge.** Die App reicht eine Kennung
|
||||||
|
// durch und weiss nicht, was dahintersteckt — sie hat sie aus einer
|
||||||
|
// Liste. Drei Anfragen im schlechtesten Fall; der Normalfall ist eine.
|
||||||
|
if let film = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/movies/\(id)"), as: Wire.Movie.self) {
|
||||||
|
return film.neutral()
|
||||||
|
}
|
||||||
|
if let serie = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
|
||||||
|
return serie.neutral()
|
||||||
|
}
|
||||||
|
throw .notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] {
|
||||||
|
try await episodes(inSeries: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Folgen einer Serie — in einer Anfrage.
|
||||||
|
///
|
||||||
|
/// Der allgemeine Weg über Staffeln kostete bei zwölf Staffeln dreizehn
|
||||||
|
/// Anfragen. Flaro kann es in einer, also tut es das.
|
||||||
|
public func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] {
|
||||||
|
let serie = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self)
|
||||||
|
let folgen = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series/\(id)/episodes"),
|
||||||
|
as: Wire.Page<Wire.Episode>.self)
|
||||||
|
return folgen.items.map { $0.neutral(series: serie) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||||
|
guard item.kind == .episode else { return nil }
|
||||||
|
// 204 heisst: es war die letzte. Kein Fehler, nur kein Nachfolger.
|
||||||
|
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/next"))
|
||||||
|
guard !daten.isEmpty else { return nil }
|
||||||
|
guard let folge = try? JSONDecoder().decode(Wire.Episode.self, from: daten) else { return nil }
|
||||||
|
return folge.neutral()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] {
|
||||||
|
let abfrage = [
|
||||||
|
URLQueryItem(name: "search", value: text),
|
||||||
|
URLQueryItem(name: "take", value: String(limit)),
|
||||||
|
]
|
||||||
|
async let filme = client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/movies", query: abfrage), as: Wire.Page<Wire.Movie>.self)
|
||||||
|
async let serien = client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series", query: abfrage), as: Wire.Page<Wire.Series>.self)
|
||||||
|
|
||||||
|
var reihen: [MediaRowGroup] = []
|
||||||
|
if let gefunden = try? await filme, !gefunden.items.isEmpty {
|
||||||
|
reihen.append(MediaRowGroup(
|
||||||
|
id: "flaro.search.movies",
|
||||||
|
title: String(localized: "flaro.row.movies", bundle: .module),
|
||||||
|
items: gefunden.items.map { $0.neutral() }))
|
||||||
|
}
|
||||||
|
if let gefunden = try? await serien, !gefunden.items.isEmpty {
|
||||||
|
reihen.append(MediaRowGroup(
|
||||||
|
id: "flaro.search.series",
|
||||||
|
title: String(localized: "flaro.row.series", bundle: .module),
|
||||||
|
items: gefunden.items.map { $0.neutral() }))
|
||||||
|
}
|
||||||
|
return reihen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flaro kennt noch keine Schlagwörter.
|
||||||
|
///
|
||||||
|
/// Eine leere Liste, kein Fehler: die Oberfläche blendet den Filter dann
|
||||||
|
/// aus, statt einen Knopf zu zeigen, hinter dem nichts ist.
|
||||||
|
public func filterOptions(
|
||||||
|
inLibrary library: MediaLibrary,
|
||||||
|
field: MediaQuery.Field
|
||||||
|
) async throws(MediaBackendError) -> [MediaFilterOption] { [] }
|
||||||
|
|
||||||
|
public func libraryID(for item: MediaItem) async -> String? { nil }
|
||||||
|
|
||||||
|
// MARK: - Bilder
|
||||||
|
|
||||||
|
/// **Ohne Grössenangabe.** Flaro legt die Bilder so ab, wie TMDB sie
|
||||||
|
/// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein
|
||||||
|
/// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet
|
||||||
|
/// Rechenzeit, die auf einem NAS knapper ist.
|
||||||
|
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
||||||
|
rawImageURL(path: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated func rawImageURL(path: String?) -> URL? {
|
||||||
|
guard let path, !path.isEmpty else { return nil }
|
||||||
|
return client.url(path: "/api/v1/images/\(path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Herunterladen
|
||||||
|
|
||||||
|
/// **Eine Adresse für alles.** Flaro entscheidet selbst, ob die Datei
|
||||||
|
/// unverändert hinausgeht oder erst umgepackt wird — und packt dabei
|
||||||
|
/// *alle* Ton- und Textspuren mit ein.
|
||||||
|
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
|
||||||
|
client.url(path: "/api/v1/files/\(file.id)/download")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Dieselbe Adresse, und das ist der Punkt.**
|
||||||
|
///
|
||||||
|
/// Der Vertrag nimmt hier je eine Spurnummer entgegen, weil Jellyfins
|
||||||
|
/// Transcoder genau eine Tonspur je Sitzung abbildet. Flaro braucht die
|
||||||
|
/// Wahl nicht: was herauskommt, hat alles. Die Nummern werden deshalb
|
||||||
|
/// nicht ignoriert, sondern sind gegenstandslos — und die Oberfläche
|
||||||
|
/// fragt bei `.allTracksDownload` gar nicht erst danach.
|
||||||
|
public nonisolated func remuxDownloadURL(
|
||||||
|
itemID: String,
|
||||||
|
versionIndex: Int,
|
||||||
|
audioStreamIndex: Int?,
|
||||||
|
subtitleStreamIndex: Int?
|
||||||
|
) -> URL? { nil }
|
||||||
|
|
||||||
|
/// Noch nicht da: das Herunterrechnen auf eine Bitrate kommt mit Stufe 3.
|
||||||
|
public nonisolated func compressedDownloadURL(
|
||||||
|
itemID: String,
|
||||||
|
versionIndex: Int,
|
||||||
|
maximumBitrate: Int,
|
||||||
|
resolution: String?
|
||||||
|
) -> URL? { nil }
|
||||||
|
|
||||||
|
// MARK: - Abspielen
|
||||||
|
|
||||||
|
/// Wie lange auf ein Umpacken gewartet wird, bevor aufgegeben wird.
|
||||||
|
///
|
||||||
|
/// Grosszügig: ein Dreistünder in 4K braucht auf einem NAS Minuten, und
|
||||||
|
/// abzubrechen, während die Arbeit läuft, wäre die eine Antwort, die
|
||||||
|
/// niemandem hilft. Irgendwann muss es trotzdem eine geben — ein Spieler,
|
||||||
|
/// der unbegrenzt auf einen hängengebliebenen Prozess wartet, zeigt
|
||||||
|
/// Schwarz ohne Begründung.
|
||||||
|
private static let packWartezeit = Duration.seconds(600)
|
||||||
|
|
||||||
|
public func preparePlayback(
|
||||||
|
_ intent: PlaybackIntent
|
||||||
|
) async throws(MediaBackendError) -> PlaybackTarget {
|
||||||
|
guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else {
|
||||||
|
throw .transport(description: "Adresse nicht baubar")
|
||||||
|
}
|
||||||
|
|
||||||
|
var auskunft = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/files/\(intent.file.id)/delivery"),
|
||||||
|
as: Wire.Delivery.self)
|
||||||
|
|
||||||
|
if let fehler = auskunft.error { throw .refused(reason: fehler) }
|
||||||
|
|
||||||
|
// **Erst warten, dann die Adresse herausgeben.**
|
||||||
|
//
|
||||||
|
// Muss der Server umpacken, antwortet er auf den Download mit 202 und
|
||||||
|
// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere
|
||||||
|
// Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner.
|
||||||
|
// Genau so sah es beim ersten Lauf im Simulator aus: „Stelle wird
|
||||||
|
// wiederhergestellt", 0:00, für immer.
|
||||||
|
//
|
||||||
|
// Das Anstossen besorgt der Server beim ersten Abruf selbst; hier
|
||||||
|
// wird nur nachgefragt, bis es so weit ist.
|
||||||
|
if auskunft.method != "directFile", auskunft.packageState != "ready" {
|
||||||
|
let bis = ContinuousClock.now + Self.packWartezeit
|
||||||
|
while ContinuousClock.now < bis {
|
||||||
|
// Ein HEAD stösst an und fragt zugleich nach. Die Datei dafür
|
||||||
|
// herunterzuladen wäre absurd.
|
||||||
|
_ = try? await client.send(FlaroRequest(
|
||||||
|
method: .head, path: "/api/v1/files/\(intent.file.id)/download"))
|
||||||
|
|
||||||
|
auskunft = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/files/\(intent.file.id)/delivery"),
|
||||||
|
as: Wire.Delivery.self)
|
||||||
|
|
||||||
|
if let fehler = auskunft.error { throw .refused(reason: fehler) }
|
||||||
|
if auskunft.packageState == "ready" { break }
|
||||||
|
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard auskunft.packageState == "ready" else {
|
||||||
|
throw .refused(reason: String(
|
||||||
|
localized: "flaro.remux.tooLong", bundle: .module))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Keine Sitzung.** Flaro legt für eine Wiedergabe nichts an, das
|
||||||
|
// hinterher aufzuräumen wäre: die Datei geht unverändert hinaus oder
|
||||||
|
// liegt umgepackt im Zwischenspeicher. Es gibt deshalb nichts, was
|
||||||
|
// weiterrechnet, wenn niemand mehr zusieht.
|
||||||
|
return PlaybackTarget(url: adresse, sessionID: nil, versionID: intent.file.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func endPlayback(sessionID: String) async {}
|
||||||
|
|
||||||
|
public func reportProgress(_ report: PlaybackReport) async {
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen
|
||||||
|
// Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden
|
||||||
|
// hiesse, den ersten Bericht wegzuwerfen.
|
||||||
|
_ = try? await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
|
||||||
|
Wire.ProgressRequest(
|
||||||
|
positionSeconds: report.time.seconds,
|
||||||
|
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Zustand melden
|
||||||
|
|
||||||
|
public func markWatched(id: String) async throws(MediaBackendError) {
|
||||||
|
try await client.send(.json(.put, "/api/v1/items/\(id)/watched", Wire.WatchedRequest(watched: true)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func markUnwatched(id: String) async throws(MediaBackendError) {
|
||||||
|
try await client.send(.json(.put, "/api/v1/items/\(id)/watched", Wire.WatchedRequest(watched: false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aus „Weiterschauen" nehmen, ohne als gesehen zu gelten.
|
||||||
|
///
|
||||||
|
/// **Flaro hat dafür einen eigenen Weg**, und nur deshalb bleibt die
|
||||||
|
/// Stelle erhalten. Bei Jellyfin wird hier das Benutzerdatenblatt
|
||||||
|
/// geschrieben, und darin steht auch „gesehen" — wer es nicht mitschickt,
|
||||||
|
/// löscht es.
|
||||||
|
public func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError) {
|
||||||
|
try await client.send(FlaroRequest(method: .post, path: "/api/v1/items/\(item.id)/dismiss"))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) {
|
||||||
|
try await client.send(.json(.put, "/api/v1/items/\(id)/favourite",
|
||||||
|
Wire.FavouriteRequest(favourite: isFavourite)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func rate(id: String, rating: Int) async throws(MediaBackendError) {
|
||||||
|
try await client.send(.json(.put, "/api/v1/items/\(id)/rating",
|
||||||
|
Wire.RatingRequest(rating: rating > 0 ? rating : nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) {
|
||||||
|
try await client.send(FlaroRequest(method: .post, path: "/api/v1/libraries/\(library.id)/scan"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gemeinsames
|
||||||
|
|
||||||
|
private func seite<T: Decodable & Sendable>(
|
||||||
|
pfad: String, take: Int, as type: T.Type
|
||||||
|
) async throws(MediaBackendError) -> Wire.Page<T> {
|
||||||
|
try await client.send(
|
||||||
|
FlaroRequest(path: pfad, query: [URLQueryItem(name: "take", value: String(take))]),
|
||||||
|
as: Wire.Page<T>.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reihe(pfad: String) async throws(MediaBackendError) -> [MediaItem] {
|
||||||
|
let seite = try await client.send(FlaroRequest(path: pfad), as: Wire.Page<Wire.Started>.self)
|
||||||
|
return seite.items.map { $0.neutral() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was der Server noch nicht kann, siebt die App — aber nur das.
|
||||||
|
///
|
||||||
|
/// **Nicht alles.** Gefiltert wird hier über eine *Seite*, nicht über die
|
||||||
|
/// Mediathek: wer bei fünftausend Titeln „nur ungesehene" wählt, bekommt
|
||||||
|
/// die ungesehenen der ersten fünfzig. Das ist ein Zwischenstand, kein
|
||||||
|
/// Entwurf — sobald Flaro die Einschränkung selbst kann, fällt das hier
|
||||||
|
/// weg. Es jetzt wegzulassen hiesse, einen Schalter anzubieten, der
|
||||||
|
/// nichts tut.
|
||||||
|
private func gesiebt(_ titel: [MediaItem], _ query: MediaQuery) -> [MediaItem] {
|
||||||
|
var liste = titel
|
||||||
|
if query.unwatchedOnly { liste = liste.filter { !$0.isWatched } }
|
||||||
|
if query.favouritesOnly { liste = liste.filter(\.isFavourite) }
|
||||||
|
return liste
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mit wem die App spricht und als wer.
|
||||||
|
public struct FlaroAccount: Sendable, Equatable {
|
||||||
|
public let baseURL: URL
|
||||||
|
public let serverName: String
|
||||||
|
public let userID: String
|
||||||
|
public let userName: String
|
||||||
|
public let isAdministrator: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
baseURL: URL,
|
||||||
|
serverName: String,
|
||||||
|
userID: String,
|
||||||
|
userName: String,
|
||||||
|
isAdministrator: Bool
|
||||||
|
) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.serverName = serverName
|
||||||
|
self.userID = userID
|
||||||
|
self.userName = userName
|
||||||
|
self.isAdministrator = isAdministrator
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Wer wir für einen Flaro-Server sind.
|
||||||
|
///
|
||||||
|
/// Anders als bei Jellyfin steht davon nichts in einer Kopfzeile: Flaro nimmt
|
||||||
|
/// den Gerätenamen einmal bei der Anmeldung entgegen und führt das Gerät
|
||||||
|
/// danach über das Token. Die Kennung muss trotzdem über Starts hinweg
|
||||||
|
/// gleich bleiben — wechselt sie, meldet sich dasselbe Gerät als neues an,
|
||||||
|
/// und in der Geräteliste sammelt sich Müll.
|
||||||
|
public struct FlaroIdentity: Sendable, Equatable {
|
||||||
|
public let deviceName: String
|
||||||
|
public let deviceID: String
|
||||||
|
|
||||||
|
public init(deviceName: String, deviceID: String) {
|
||||||
|
self.deviceName = deviceName
|
||||||
|
self.deviceID = deviceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spricht mit einem Flaro-Server.
|
||||||
|
public actor FlaroClient {
|
||||||
|
public nonisolated let baseURL: URL
|
||||||
|
private let identity: FlaroIdentity
|
||||||
|
private let transport: any FlaroTransport
|
||||||
|
private let cookieJar: (any FlaroCookieJar)?
|
||||||
|
private var token: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
baseURL: URL,
|
||||||
|
identity: FlaroIdentity,
|
||||||
|
token: String? = nil,
|
||||||
|
transport: any FlaroTransport = URLSession.shared,
|
||||||
|
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
|
||||||
|
) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.identity = identity
|
||||||
|
self.token = token
|
||||||
|
self.transport = transport
|
||||||
|
self.cookieJar = cookieJar
|
||||||
|
if let token { plant(token) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setToken(_ token: String?) {
|
||||||
|
self.token = token
|
||||||
|
if let token { plant(token) } else { clearCookie() }
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated var deviceID: String { identity.deviceID }
|
||||||
|
public nonisolated var deviceName: String { identity.deviceName }
|
||||||
|
|
||||||
|
// MARK: - Das Cookie
|
||||||
|
|
||||||
|
/// Legt den Zugang als Cookie ab, damit auch fremde Lader ihn mitschicken.
|
||||||
|
///
|
||||||
|
/// **Warum überhaupt.** Flaro nimmt kein Token in der Adresse — es stünde
|
||||||
|
/// sonst in jedem Zugriffsprotokoll, in jedem Verlauf und in jedem
|
||||||
|
/// weitergegebenen Link. Jellyfin macht das anders, und `artworkURL`
|
||||||
|
/// heisst deshalb im Vertrag ausdrücklich „der Token steckt in der
|
||||||
|
/// Adresse selbst".
|
||||||
|
///
|
||||||
|
/// Bleibt der Weg, den der Browser längst geht: ein Cookie. Der
|
||||||
|
/// Bilderlader und die Hintergrundübertragung benutzen `URLSession` mit
|
||||||
|
/// der Standardkonfiguration und schicken es von sich aus mit; dem
|
||||||
|
/// Systemplayer wird es ausdrücklich mitgegeben (siehe
|
||||||
|
/// `playbackCookies`), weil AVFoundation den gemeinsamen Speicher nicht
|
||||||
|
/// von allein liest.
|
||||||
|
///
|
||||||
|
/// **Gesetzt, nicht abgewartet.** Der Server schickt es bei der Anmeldung
|
||||||
|
/// auch selbst — aber nach einem Neustart hat die App nur das Token aus
|
||||||
|
/// dem Schlüsselbund und meldet sich gar nicht erst neu an. Dann wäre
|
||||||
|
/// kein Cookie da, und jedes Plakat bliebe grau.
|
||||||
|
private nonisolated func plant(_ token: String) {
|
||||||
|
guard let cookie = Self.cookie(for: baseURL, token: token) else { return }
|
||||||
|
cookieJar?.store(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated func clearCookie() {
|
||||||
|
guard let host = baseURL.host() else { return }
|
||||||
|
cookieJar?.removeCookie(named: Self.cookieName, host: host)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let cookieName = "flaro_session"
|
||||||
|
|
||||||
|
nonisolated static func cookie(for baseURL: URL, token: String) -> HTTPCookie? {
|
||||||
|
guard let host = baseURL.host() else { return nil }
|
||||||
|
return HTTPCookie(properties: [
|
||||||
|
.name: cookieName,
|
||||||
|
.value: token,
|
||||||
|
.domain: host,
|
||||||
|
.path: "/",
|
||||||
|
// Ein Jahr, wie der Server es auch setzt. Kürzer hiesse: das
|
||||||
|
// Plakat lädt nicht mehr, obwohl das Token noch gilt.
|
||||||
|
.expires: Date(timeIntervalSinceNow: 365 * 24 * 3600),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Cookies, die der Systemplayer für diese Adresse mitschicken soll.
|
||||||
|
public nonisolated func cookies(for url: URL, token: String?) -> [HTTPCookie] {
|
||||||
|
guard let token, let cookie = Self.cookie(for: url, token: token) else { return [] }
|
||||||
|
return [cookie]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Adressen
|
||||||
|
|
||||||
|
/// Die Adresse eines Endpunkts.
|
||||||
|
///
|
||||||
|
/// **Ohne Token.** Das ist der Unterschied zu Jellyfin und kein Versehen —
|
||||||
|
/// wer die Adresse abruft, bringt das Cookie mit.
|
||||||
|
public nonisolated func url(path: String, query: [URLQueryItem] = []) -> URL? {
|
||||||
|
guard var components = URLComponents(
|
||||||
|
url: baseURL.appending(path: path),
|
||||||
|
resolvingAgainstBaseURL: false
|
||||||
|
) else { return nil }
|
||||||
|
components.queryItems = query.isEmpty ? nil : query
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Anfragen
|
||||||
|
|
||||||
|
public func send<Value: Decodable & Sendable>(
|
||||||
|
_ request: FlaroRequest,
|
||||||
|
as type: Value.Type
|
||||||
|
) async throws(MediaBackendError) -> Value {
|
||||||
|
let data = try await send(request)
|
||||||
|
do {
|
||||||
|
return try Self.decoder.decode(Value.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw .decoding(description: String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func send(_ request: FlaroRequest) async throws(MediaBackendError) -> Data {
|
||||||
|
guard var components = URLComponents(
|
||||||
|
url: baseURL.appending(path: request.path),
|
||||||
|
resolvingAgainstBaseURL: false
|
||||||
|
) else {
|
||||||
|
throw .transport(description: "Adresse nicht baubar")
|
||||||
|
}
|
||||||
|
components.queryItems = request.query.isEmpty ? nil : request.query
|
||||||
|
|
||||||
|
guard let url = components.url else {
|
||||||
|
throw .transport(description: "Adresse nicht baubar")
|
||||||
|
}
|
||||||
|
|
||||||
|
var urlRequest = URLRequest(url: url)
|
||||||
|
urlRequest.httpMethod = request.method.rawValue
|
||||||
|
urlRequest.timeoutInterval = request.timeout.seconds
|
||||||
|
// Die App schickt die Kopfzeile. Das Cookie ist der zweite Weg zum
|
||||||
|
// selben Token, für Lader, die keine Kopfzeilen können.
|
||||||
|
if let token { urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||||||
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
if let body = request.body {
|
||||||
|
urlRequest.httpBody = body
|
||||||
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
let response: URLResponse
|
||||||
|
do {
|
||||||
|
(data, response) = try await transport.data(for: urlRequest)
|
||||||
|
} catch {
|
||||||
|
throw .transport(description: error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw .transport(description: "Keine HTTP-Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch http.statusCode {
|
||||||
|
case 200..<300: return data
|
||||||
|
case 401, 403: throw .unauthorized
|
||||||
|
case 404: throw .notFound
|
||||||
|
default:
|
||||||
|
// **Flaro begründet seine Absagen.** Ein Problem-Dokument nach
|
||||||
|
// RFC 9457 trägt einen Satz, den der Server selbst formuliert
|
||||||
|
// hat — und der ist mehr wert als die Zahl davor. Genau daran
|
||||||
|
// hing beim Live-TV tagelang alles, weil die Antwort weggeworfen
|
||||||
|
// wurde.
|
||||||
|
if let grund = Self.begruendung(in: data) { throw .refused(reason: grund) }
|
||||||
|
throw .server(status: http.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Satz aus einem Problem-Dokument, falls einer dasteht.
|
||||||
|
private static func begruendung(in data: Data) -> String? {
|
||||||
|
struct Problem: Decodable { let detail: String?; let title: String? }
|
||||||
|
guard let problem = try? JSONDecoder().decode(Problem.self, from: data) else { return nil }
|
||||||
|
let text = problem.detail ?? problem.title
|
||||||
|
return text?.isEmpty == false ? text : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flaro schreibt klein und rechnet in Sekunden.
|
||||||
|
///
|
||||||
|
/// Kein `convertFromSnakeCase`: die Felder heissen schon so, wie sie
|
||||||
|
/// heissen sollen. ASP.NET Core schreibt sie in `camelCase` hinaus, und
|
||||||
|
/// Swift liest sie genauso.
|
||||||
|
///
|
||||||
|
/// Nicht `private`, damit die Prüfungen denselben Dekoder benutzen. Mit
|
||||||
|
/// einem frisch gebauten `JSONDecoder()` scheitern sie — und zwar an
|
||||||
|
/// genau dem Datumsformat, für das die Strategie unten da ist.
|
||||||
|
static let decoder: JSONDecoder = {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601WithFraction
|
||||||
|
return decoder
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Anfrage an Flaro.
|
||||||
|
public struct FlaroRequest: Sendable {
|
||||||
|
public enum Method: String, Sendable { case get = "GET", head = "HEAD", post = "POST", put = "PUT", delete = "DELETE" }
|
||||||
|
|
||||||
|
public let method: Method
|
||||||
|
public let path: String
|
||||||
|
public let query: [URLQueryItem]
|
||||||
|
public let body: Data?
|
||||||
|
public let timeout: Duration
|
||||||
|
|
||||||
|
public init(
|
||||||
|
method: Method = .get,
|
||||||
|
path: String,
|
||||||
|
query: [URLQueryItem] = [],
|
||||||
|
body: Data? = nil,
|
||||||
|
timeout: Duration = .seconds(30)
|
||||||
|
) {
|
||||||
|
self.method = method
|
||||||
|
self.path = path
|
||||||
|
self.query = query
|
||||||
|
self.body = body
|
||||||
|
self.timeout = timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Anfrage mit einem Rumpf aus einem beliebigen Wert.
|
||||||
|
public static func json(
|
||||||
|
_ method: Method, _ path: String, _ value: some Encodable
|
||||||
|
) -> FlaroRequest {
|
||||||
|
FlaroRequest(method: method, path: path, body: try? JSONEncoder().encode(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie Anfragen tatsächlich verschickt werden.
|
||||||
|
///
|
||||||
|
/// Dieselbe Trennung wie bei Jellyfin: Tests brauchen keinen Server, sondern
|
||||||
|
/// vorbereitete Antworten.
|
||||||
|
public protocol FlaroTransport: Sendable {
|
||||||
|
func data(for request: URLRequest) async throws -> (Data, URLResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension URLSession: FlaroTransport {}
|
||||||
|
|
||||||
|
/// Wo Cookies landen.
|
||||||
|
///
|
||||||
|
/// Eigenes Protokoll, damit ein Test nachsehen kann, ob eines abgelegt wurde,
|
||||||
|
/// ohne den Cookie-Speicher des ganzen Systems anzufassen.
|
||||||
|
public protocol FlaroCookieJar: Sendable {
|
||||||
|
func store(_ cookie: HTTPCookie)
|
||||||
|
func removeCookie(named name: String, host: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HTTPCookieStorage: FlaroCookieJar {
|
||||||
|
public func store(_ cookie: HTTPCookie) { setCookie(cookie) }
|
||||||
|
|
||||||
|
public func removeCookie(named name: String, host: String) {
|
||||||
|
cookies?
|
||||||
|
.filter { $0.name == name && $0.domain.hasSuffix(host) }
|
||||||
|
.forEach(deleteCookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension JSONDecoder.DateDecodingStrategy {
|
||||||
|
/// ASP.NET Core schreibt Bruchteile von Sekunden mit.
|
||||||
|
///
|
||||||
|
/// `.iso8601` kommt damit nicht zurecht und wirft — ein Datum, das
|
||||||
|
/// „2026-10-03T19:25:04.5209555+00:00" lautet, ist für den
|
||||||
|
/// Standarddekoder kein Datum. Der Fehler fällt erst dort auf, wo ein
|
||||||
|
/// Feld gelesen wird, das die meisten Antworten leer lassen.
|
||||||
|
static var iso8601WithFraction: Self {
|
||||||
|
.custom { decoder in
|
||||||
|
let text = try decoder.singleValueContainer().decode(String.self)
|
||||||
|
let mitBruch = ISO8601DateFormatter()
|
||||||
|
mitBruch.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let datum = mitBruch.date(from: text) { return datum }
|
||||||
|
|
||||||
|
let ohne = ISO8601DateFormatter()
|
||||||
|
ohne.formatOptions = [.withInternetDateTime]
|
||||||
|
if let datum = ohne.date(from: text) { return datum }
|
||||||
|
|
||||||
|
throw DecodingError.dataCorrupted(.init(
|
||||||
|
codingPath: decoder.codingPath,
|
||||||
|
debugDescription: "Kein ISO-8601-Datum: \(text)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Was Flaro über die Leitung schickt.
|
||||||
|
///
|
||||||
|
/// Ein Spiegel der `Contracts.cs` auf der Serverseite, und nur das: hier wird
|
||||||
|
/// nichts gerechnet und nichts gedeutet. Die Übersetzung in die neutralen
|
||||||
|
/// Typen steht in `FlaroBackend+Mapping`.
|
||||||
|
///
|
||||||
|
/// **Die Felder heissen wie dort.** Keine `CodingKeys`, keine Umbenennung:
|
||||||
|
/// ASP.NET Core schreibt `camelCase`, Swift liest `camelCase`, und jede
|
||||||
|
/// Abweichung wäre eine Stelle, an der beim nächsten Serverfeld jemand
|
||||||
|
/// nachziehen müsste.
|
||||||
|
enum Wire {
|
||||||
|
struct Page<T: Decodable & Sendable>: Decodable, Sendable {
|
||||||
|
let items: [T]
|
||||||
|
let total: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SystemInfo: Decodable, Sendable {
|
||||||
|
let version: String?
|
||||||
|
let serverName: String?
|
||||||
|
let needsSetup: Bool
|
||||||
|
let attributions: [Attribution]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Attribution: Decodable, Sendable {
|
||||||
|
let text: String
|
||||||
|
let logoUrl: String
|
||||||
|
let url: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoginResponse: Decodable, Sendable {
|
||||||
|
let token: String
|
||||||
|
let user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
struct User: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let userName: String
|
||||||
|
let isAdministrator: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Library: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let kind: String
|
||||||
|
let path: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Stream: Decodable, Sendable {
|
||||||
|
let index: Int
|
||||||
|
let kind: String
|
||||||
|
let codec: String?
|
||||||
|
let language: String?
|
||||||
|
let title: String?
|
||||||
|
let isDefault: Bool
|
||||||
|
let isForced: Bool
|
||||||
|
let channels: Int?
|
||||||
|
let width: Int?
|
||||||
|
let height: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct File: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let fileName: String
|
||||||
|
let sizeBytes: Int64
|
||||||
|
let container: String?
|
||||||
|
let durationSeconds: Double?
|
||||||
|
let streams: [Stream]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ItemState: Decodable, Sendable {
|
||||||
|
let positionSeconds: Double
|
||||||
|
let runtimeSeconds: Double?
|
||||||
|
let watched: Bool
|
||||||
|
let playCount: Int
|
||||||
|
let favourite: Bool
|
||||||
|
let rating: Int?
|
||||||
|
let dismissed: Bool
|
||||||
|
let lastPlayedAt: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Movie: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let year: Int?
|
||||||
|
let overview: String?
|
||||||
|
let posterImage: String?
|
||||||
|
let backdropImage: String?
|
||||||
|
let files: [File]
|
||||||
|
let state: ItemState?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Series: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let year: Int?
|
||||||
|
let overview: String?
|
||||||
|
let posterImage: String?
|
||||||
|
let backdropImage: String?
|
||||||
|
let episodeCount: Int
|
||||||
|
let seasons: [Int]
|
||||||
|
let unwatchedCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Episode: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let season: Int
|
||||||
|
let episode: Int
|
||||||
|
let title: String?
|
||||||
|
let overview: String?
|
||||||
|
let stillImage: String?
|
||||||
|
let files: [File]
|
||||||
|
let state: ItemState?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
|
||||||
|
struct Started: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let kind: String
|
||||||
|
let title: String
|
||||||
|
let subTitle: String?
|
||||||
|
let season: Int?
|
||||||
|
let episode: Int?
|
||||||
|
let year: Int?
|
||||||
|
let posterImage: String?
|
||||||
|
let backdropImage: String?
|
||||||
|
let state: ItemState
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CodeResponse: Decodable, Sendable {
|
||||||
|
let code: String
|
||||||
|
let handle: String
|
||||||
|
let expiresAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `state` ist „pending", „approved" oder „expired".
|
||||||
|
struct CodeState: Decodable, Sendable {
|
||||||
|
let state: String
|
||||||
|
let login: LoginResponse?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CodeRequest: Encodable, Sendable {
|
||||||
|
let deviceName: String
|
||||||
|
let deviceId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Delivery: Decodable, Sendable {
|
||||||
|
let method: String
|
||||||
|
let reasons: [String]
|
||||||
|
let audioToEncode: [Int]
|
||||||
|
let subtitlesDropped: [Int]
|
||||||
|
let packageState: String
|
||||||
|
let progress: Double
|
||||||
|
let error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Was hinausgeht --------------------------------------------------
|
||||||
|
|
||||||
|
struct LoginRequest: Encodable, Sendable {
|
||||||
|
let userName: String
|
||||||
|
let password: String
|
||||||
|
let deviceName: String
|
||||||
|
let deviceId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProgressRequest: Encodable, Sendable {
|
||||||
|
let positionSeconds: Double
|
||||||
|
let runtimeSeconds: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchedRequest: Encodable, Sendable { let watched: Bool }
|
||||||
|
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
|
||||||
|
struct RatingRequest: Encodable, Sendable { let rating: Int? }
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extension FlaroIdentity {
|
||||||
|
private static let storageKey = "flaro.flaroServer.deviceID"
|
||||||
|
|
||||||
|
/// Baut die Kennung für das laufende Gerät und merkt sie sich.
|
||||||
|
///
|
||||||
|
/// **Eine eigene, nicht die von Jellyfin.** Beide Server führen eigene
|
||||||
|
/// Geräteverzeichnisse; dieselbe Kennung doppelt zu vergeben hiesse, dass
|
||||||
|
/// ein Widerruf auf dem einen Server im anderen verwirrt — und wer von
|
||||||
|
/// Jellyfin zu Flaro wechselt, hinterliesse auf dem alten Server ein
|
||||||
|
/// Gerät, das es dort gar nicht mehr gibt.
|
||||||
|
///
|
||||||
|
/// Gleichbleiben muss sie trotzdem: wechselt sie, gilt die App als neues
|
||||||
|
/// Gerät, und in der Liste steht bei jedem Start ein Eintrag mehr.
|
||||||
|
///
|
||||||
|
/// `@MainActor`, weil `UIDevice.current` dort beheimatet ist.
|
||||||
|
@MainActor
|
||||||
|
public static func current(defaults: UserDefaults = .standard) -> FlaroIdentity {
|
||||||
|
let deviceID: String
|
||||||
|
if let stored = defaults.string(forKey: storageKey) {
|
||||||
|
deviceID = stored
|
||||||
|
} else {
|
||||||
|
deviceID = UUID().uuidString
|
||||||
|
defaults.set(deviceID, forKey: storageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
let name = UIDevice.current.name
|
||||||
|
#else
|
||||||
|
let name = ProcessInfo.processInfo.hostName
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return FlaroIdentity(deviceName: name, deviceID: deviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Aus Flaros Antworten werden neutrale Typen.
|
||||||
|
///
|
||||||
|
/// Die einzige Stelle, an der Flaros Schreibweise in die der App übersetzt
|
||||||
|
/// wird. Alles darüber kennt nur noch `MediaItem`.
|
||||||
|
extension Wire.Movie {
|
||||||
|
func neutral() -> MediaItem {
|
||||||
|
MediaItem(
|
||||||
|
id: id,
|
||||||
|
kind: .movie,
|
||||||
|
title: title,
|
||||||
|
summary: overview,
|
||||||
|
year: year,
|
||||||
|
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
||||||
|
thumb: posterImage,
|
||||||
|
art: backdropImage,
|
||||||
|
playCount: state?.playCount,
|
||||||
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
|
isFavourite: state?.favourite ?? false,
|
||||||
|
versions: [Wire.fassung(id: id, files: files)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire.Series {
|
||||||
|
func neutral() -> MediaItem {
|
||||||
|
MediaItem(
|
||||||
|
id: id,
|
||||||
|
kind: .show,
|
||||||
|
title: title,
|
||||||
|
summary: overview,
|
||||||
|
year: year,
|
||||||
|
thumb: posterImage,
|
||||||
|
art: backdropImage,
|
||||||
|
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
|
||||||
|
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
||||||
|
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
||||||
|
// zu derselben Aussage zu haben.
|
||||||
|
episodeCount: episodeCount,
|
||||||
|
playedEpisodeCount: episodeCount - unwatchedCount,
|
||||||
|
childCount: seasons.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire.Episode {
|
||||||
|
func neutral(series: Wire.Series? = nil) -> MediaItem {
|
||||||
|
MediaItem(
|
||||||
|
id: id,
|
||||||
|
kind: .episode,
|
||||||
|
title: title ?? "Folge \(episode)",
|
||||||
|
summary: overview,
|
||||||
|
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
||||||
|
thumb: stillImage,
|
||||||
|
seriesThumb: series?.posterImage,
|
||||||
|
seriesArt: series?.backdropImage,
|
||||||
|
index: episode,
|
||||||
|
parentIndex: season,
|
||||||
|
seriesTitle: series?.title,
|
||||||
|
seriesID: series?.id,
|
||||||
|
playCount: state?.playCount,
|
||||||
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
|
isFavourite: state?.favourite ?? false,
|
||||||
|
versions: [Wire.fassung(id: id, files: files)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire.Started {
|
||||||
|
/// Die Kachel aus „Weiterschauen" als Titel.
|
||||||
|
///
|
||||||
|
/// **Ohne Fassungen.** Die Reihe nennt keine Dateien, und das ist
|
||||||
|
/// richtig so: eine Kachel zeigt Plakat, Titel und Balken. Wer sie
|
||||||
|
/// antippt, holt den Titel einzeln — und bekommt dann alles.
|
||||||
|
func neutral() -> MediaItem {
|
||||||
|
let folge = kind == "episode"
|
||||||
|
return MediaItem(
|
||||||
|
id: id,
|
||||||
|
kind: folge ? .episode : .movie,
|
||||||
|
title: folge ? (subTitle ?? "Folge \(episode ?? 0)") : title,
|
||||||
|
year: year,
|
||||||
|
duration: state.runtimeSeconds.map { Int($0 * 1000) },
|
||||||
|
thumb: folge ? backdropImage : posterImage,
|
||||||
|
art: backdropImage,
|
||||||
|
seriesThumb: folge ? posterImage : nil,
|
||||||
|
index: episode,
|
||||||
|
parentIndex: season,
|
||||||
|
seriesTitle: folge ? title : nil,
|
||||||
|
playCount: state.playCount,
|
||||||
|
resumeMilliseconds: Int(state.positionSeconds * 1000),
|
||||||
|
lastPlayedAt: state.lastPlayedAt,
|
||||||
|
isFavourite: state.favourite
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire.Library {
|
||||||
|
func neutral() -> MediaLibrary {
|
||||||
|
MediaLibrary(
|
||||||
|
id: id,
|
||||||
|
title: name,
|
||||||
|
// Flaro kennt genau zwei Arten. Was es nicht gibt, als `.other`
|
||||||
|
// durchzulassen wäre eine Vorsorge für einen Fall, den der
|
||||||
|
// Server gar nicht erzeugen kann.
|
||||||
|
kind: kind.lowercased() == "shows" ? .show : .movie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire.File {
|
||||||
|
func neutral(itemID: String) -> MediaFile {
|
||||||
|
MediaFile(
|
||||||
|
id: id,
|
||||||
|
key: "/api/v1/files/\(id)/download",
|
||||||
|
itemID: itemID,
|
||||||
|
container: container,
|
||||||
|
size: sizeBytes,
|
||||||
|
duration: durationSeconds.map { Int($0 * 1000) },
|
||||||
|
file: fileName,
|
||||||
|
streams: streams.map { $0.neutral() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire.Stream {
|
||||||
|
func neutral() -> MediaStream {
|
||||||
|
let art: MediaStream.Kind = switch kind.lowercased() {
|
||||||
|
case "video": .video
|
||||||
|
case "subtitle": .subtitle
|
||||||
|
default: .audio
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaStream(
|
||||||
|
// **Die Spurnummer ist die Kennung.** `MediaStream.index` liest
|
||||||
|
// sie aus `id` zurück, und daran hängt die Spurwahl beim
|
||||||
|
// Umpacken. Eine eigene Kennung wäre hier eine, die niemand
|
||||||
|
// zurückrechnen kann.
|
||||||
|
id: String(index),
|
||||||
|
kind: art,
|
||||||
|
codec: codec,
|
||||||
|
language: language,
|
||||||
|
languageCode: language,
|
||||||
|
displayTitle: title ?? language,
|
||||||
|
channels: channels,
|
||||||
|
isSelected: isDefault,
|
||||||
|
isForced: isForced,
|
||||||
|
title: title,
|
||||||
|
isDefault: isDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire {
|
||||||
|
/// Eine Fassung aus den Dateien eines Titels.
|
||||||
|
///
|
||||||
|
/// **Alle Dateien in einer Fassung**, nicht eine Fassung je Datei: Flaro
|
||||||
|
/// führt mehrere Dateien zu einem Titel als Fassungen desselben Films —
|
||||||
|
/// 4K und 1080p nebeneinander — und `MediaVersion.files` ist genau dafür
|
||||||
|
/// da. Die Eckdaten kommen aus der ersten Datei; welche gespielt wird,
|
||||||
|
/// entscheidet die App.
|
||||||
|
static func fassung(id: String, files: [Wire.File]) -> MediaVersion {
|
||||||
|
let erste = files.first
|
||||||
|
let bild = erste?.streams.first { $0.kind.lowercased() == "video" }
|
||||||
|
let ton = erste?.streams.first { $0.kind.lowercased() == "audio" }
|
||||||
|
|
||||||
|
return MediaVersion(
|
||||||
|
id: erste?.id ?? id,
|
||||||
|
container: erste?.container,
|
||||||
|
videoCodec: bild?.codec,
|
||||||
|
audioCodec: ton?.codec,
|
||||||
|
videoResolution: bild?.height.map(String.init),
|
||||||
|
width: bild?.width,
|
||||||
|
height: bild?.height,
|
||||||
|
audioChannels: ton?.channels,
|
||||||
|
duration: erste?.durationSeconds.map { Int($0 * 1000) },
|
||||||
|
files: files.map { $0.neutral(itemID: id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Die Anmeldung an einem Flaro-Server.
|
||||||
|
///
|
||||||
|
/// Getrennt vom Backend, weil sie vorher stattfindet: hier gibt es noch
|
||||||
|
/// keinen Zugang und nichts, was `MediaBackend` versprechen könnte.
|
||||||
|
public enum FlaroSignIn {
|
||||||
|
/// Was der Server über sich sagt, bevor sich jemand anmeldet.
|
||||||
|
public struct Greeting: Sendable, Equatable {
|
||||||
|
public let serverName: String
|
||||||
|
public let version: String?
|
||||||
|
/// Ob der Server überhaupt schon eingerichtet ist.
|
||||||
|
///
|
||||||
|
/// **Ein eigener Fall, keine Fehlermeldung.** Wer einen frischen
|
||||||
|
/// Server anspricht, hat kein Konto — und „Benutzername oder
|
||||||
|
/// Passwort stimmt nicht" wäre dann die unbrauchbarste aller
|
||||||
|
/// Antworten.
|
||||||
|
public let needsSetup: Bool
|
||||||
|
/// Der Pflichthinweis auf die Metadatenquelle.
|
||||||
|
public let attributions: [MetadataAttribution]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MetadataAttribution: Sendable, Equatable {
|
||||||
|
public let text: String
|
||||||
|
public let logoURL: URL?
|
||||||
|
public let url: URL?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fragt einen Server, wer er ist — ohne Anmeldung.
|
||||||
|
///
|
||||||
|
/// Das ist zugleich die Prüfung, ob unter der eingegebenen Adresse
|
||||||
|
/// überhaupt ein Flaro steht. Ein Jellyfin antwortet hier mit 404, ein
|
||||||
|
/// Router mit seiner Anmeldeseite, und beides fällt beim Lesen auf.
|
||||||
|
public static func greet(
|
||||||
|
baseURL: URL,
|
||||||
|
identity: FlaroIdentity,
|
||||||
|
transport: any FlaroTransport = URLSession.shared
|
||||||
|
) async throws(MediaBackendError) -> Greeting {
|
||||||
|
let client = FlaroClient(
|
||||||
|
baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil)
|
||||||
|
let auskunft = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/system/info"), as: Wire.SystemInfo.self)
|
||||||
|
|
||||||
|
return Greeting(
|
||||||
|
serverName: auskunft.serverName ?? baseURL.host() ?? "Flaro",
|
||||||
|
version: auskunft.version,
|
||||||
|
needsSetup: auskunft.needsSetup,
|
||||||
|
attributions: (auskunft.attributions ?? []).map {
|
||||||
|
MetadataAttribution(
|
||||||
|
text: $0.text, logoURL: URL(string: $0.logoUrl), url: URL(string: $0.url))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet an und liefert den Zugang samt fertigem Backend.
|
||||||
|
public static func signIn(
|
||||||
|
baseURL: URL,
|
||||||
|
userName: String,
|
||||||
|
password: String,
|
||||||
|
identity: FlaroIdentity,
|
||||||
|
transport: any FlaroTransport = URLSession.shared,
|
||||||
|
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
|
||||||
|
) async throws(MediaBackendError) -> (token: String, account: FlaroAccount, client: FlaroClient) {
|
||||||
|
let gruss = try await greet(baseURL: baseURL, identity: identity, transport: transport)
|
||||||
|
|
||||||
|
let client = FlaroClient(
|
||||||
|
baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar)
|
||||||
|
|
||||||
|
let antwort = try await client.send(
|
||||||
|
.json(.post, "/api/v1/auth/login", Wire.LoginRequest(
|
||||||
|
userName: userName,
|
||||||
|
password: password,
|
||||||
|
deviceName: identity.deviceName,
|
||||||
|
deviceId: identity.deviceID)),
|
||||||
|
as: Wire.LoginResponse.self)
|
||||||
|
|
||||||
|
// **Erst jetzt das Token setzen.** Vorher hätte die Anmeldung ihre
|
||||||
|
// eigene Kopfzeile mitgeschickt — harmlos, aber auch das Cookie wäre
|
||||||
|
// dann schon gesetzt, bevor feststeht, dass es gilt.
|
||||||
|
await client.setToken(antwort.token)
|
||||||
|
|
||||||
|
return (antwort.token, FlaroAccount(
|
||||||
|
baseURL: baseURL,
|
||||||
|
serverName: gruss.serverName,
|
||||||
|
userID: antwort.user.id,
|
||||||
|
userName: antwort.user.userName,
|
||||||
|
isAdministrator: antwort.user.isAdministrator), client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nimmt ein gespeichertes Token wieder auf.
|
||||||
|
///
|
||||||
|
/// **Ohne Passwort und ohne neue Anmeldung.** Der Server führt das Gerät
|
||||||
|
/// über das Token; ein zweiter Anmeldevorgang legte ein zweites Gerät an,
|
||||||
|
/// und in der Liste stünde dann bei jedem Start eines mehr.
|
||||||
|
public static func restore(
|
||||||
|
baseURL: URL,
|
||||||
|
token: String,
|
||||||
|
identity: FlaroIdentity,
|
||||||
|
transport: any FlaroTransport = URLSession.shared,
|
||||||
|
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
|
||||||
|
) async throws(MediaBackendError) -> (account: FlaroAccount, client: FlaroClient) {
|
||||||
|
let client = FlaroClient(
|
||||||
|
baseURL: baseURL, identity: identity, token: token,
|
||||||
|
transport: transport, cookieJar: cookieJar)
|
||||||
|
|
||||||
|
let wer = try await client.send(FlaroRequest(path: "/api/v1/auth/me"), as: Wire.User.self)
|
||||||
|
let gruss = try? await greet(baseURL: baseURL, identity: identity, transport: transport)
|
||||||
|
|
||||||
|
return (FlaroAccount(
|
||||||
|
baseURL: baseURL,
|
||||||
|
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||||
|
userID: wer.id,
|
||||||
|
userName: wer.userName,
|
||||||
|
isAdministrator: wer.isAdministrator), client)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Der Weg über einen Code
|
||||||
|
|
||||||
|
/// Ein angeforderter Code samt Abholschein.
|
||||||
|
public struct PendingCode: Sendable, Equatable {
|
||||||
|
/// Die sechs Ziffern, die auf dem Fernseher stehen.
|
||||||
|
public let code: String
|
||||||
|
/// **Der Abholschein, und er ist das eigentliche Geheimnis.**
|
||||||
|
///
|
||||||
|
/// Abgeholt wird das Token hiermit, nicht mit dem Code. Täte es der
|
||||||
|
/// Code allein, liessen sich eine Million Möglichkeiten durchprobieren
|
||||||
|
/// — und die sechs Ziffern stehen zum Mitlesen auf einem Bildschirm
|
||||||
|
/// im Wohnzimmer.
|
||||||
|
public let handle: String
|
||||||
|
public let expiresAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CodeState: Sendable, Equatable {
|
||||||
|
case pending
|
||||||
|
case approved(token: String, account: FlaroAccount)
|
||||||
|
case expired
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fordert einen Code an. Der Fernseher zeigt ihn, das Telefon bestätigt.
|
||||||
|
public static func requestCode(
|
||||||
|
baseURL: URL,
|
||||||
|
identity: FlaroIdentity,
|
||||||
|
transport: any FlaroTransport = URLSession.shared
|
||||||
|
) async throws(MediaBackendError) -> PendingCode {
|
||||||
|
let client = FlaroClient(
|
||||||
|
baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil)
|
||||||
|
let antwort = try await client.send(
|
||||||
|
.json(.post, "/api/v1/auth/code", Wire.CodeRequest(
|
||||||
|
deviceName: identity.deviceName, deviceId: identity.deviceID)),
|
||||||
|
as: Wire.CodeResponse.self)
|
||||||
|
|
||||||
|
return PendingCode(
|
||||||
|
code: antwort.code, handle: antwort.handle, expiresAt: antwort.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fragt nach, ob jemand den Code inzwischen bestätigt hat.
|
||||||
|
public static func pollCode(
|
||||||
|
baseURL: URL,
|
||||||
|
pending: PendingCode,
|
||||||
|
identity: FlaroIdentity,
|
||||||
|
transport: any FlaroTransport = URLSession.shared,
|
||||||
|
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
|
||||||
|
) async throws(MediaBackendError) -> (state: CodeState, client: FlaroClient?) {
|
||||||
|
let client = FlaroClient(
|
||||||
|
baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar)
|
||||||
|
|
||||||
|
// **Mit dem Abholschein, nicht mit dem Code.** Siehe `PendingCode`.
|
||||||
|
let zustand = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/auth/code/\(pending.handle)"), as: Wire.CodeState.self)
|
||||||
|
|
||||||
|
switch zustand.state {
|
||||||
|
case "approved":
|
||||||
|
guard let anmeldung = zustand.login else { return (.expired, nil) }
|
||||||
|
await client.setToken(anmeldung.token)
|
||||||
|
let gruss = try? await greet(
|
||||||
|
baseURL: baseURL, identity: identity, transport: transport)
|
||||||
|
return (.approved(token: anmeldung.token, account: FlaroAccount(
|
||||||
|
baseURL: baseURL,
|
||||||
|
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||||
|
userID: anmeldung.user.id,
|
||||||
|
userName: anmeldung.user.userName,
|
||||||
|
isAdministrator: anmeldung.user.isAdministrator)), client)
|
||||||
|
case "expired":
|
||||||
|
return (.expired, nil)
|
||||||
|
default:
|
||||||
|
return (.pending, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet dieses Gerät ab und widerruft sein Token.
|
||||||
|
public static func signOut(client: FlaroClient) async {
|
||||||
|
_ = try? await client.send(FlaroRequest(method: .post, path: "/api/v1/auth/logout"))
|
||||||
|
await client.setToken(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage": "en",
|
||||||
|
"strings": {
|
||||||
|
"flaro.remux.tooLong": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Das Umpacken dauert ungewöhnlich lange. Sieh im Serverprotokoll nach."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Remuxing is taking unusually long. Check the server log."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flaro.row.continue": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Weiterschauen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Continue Watching"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flaro.row.movies": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Filme"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Movies"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flaro.row.nextUp": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nächste Folge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Next Up"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flaro.row.series": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Serien"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Shows"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
@@ -1,65 +1,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet.
|
/// Die Adressraterei für Jellyfin.
|
||||||
///
|
///
|
||||||
/// Jellyfin hat kein Verzeichnis wie plex.tv — die Adresse kommt vom Nutzer.
|
/// Der Inhalt steht in `ServerAddress` — beide Server haben dasselbe Problem,
|
||||||
/// Und die tippt niemand vollständig: „192.168.1.50" ohne Schema, ohne Port,
|
/// und es unterscheidet sie nur der Standardport. Dieser Typ bleibt stehen,
|
||||||
/// oder „medien.example.com" ohne „https://". Dieser Typ rät nicht **eine**
|
/// weil Aufrufer und Prüfungen ihn beim Namen nennen und weil „welcher Port
|
||||||
/// Ergänzung, sondern nennt die plausiblen in der Reihenfolge, in der sie es
|
/// gilt hier" eine Auskunft über Jellyfin ist, nicht über Adressen.
|
||||||
/// wahrscheinlich sind; der Aufrufer probiert sie durch und nimmt die erste,
|
|
||||||
/// die sich als Jellyfin-Server ausweist.
|
|
||||||
///
|
|
||||||
/// Die Reihenfolge ist der ganze Inhalt dieser Datei, und sie ist nicht
|
|
||||||
/// beliebig: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein
|
|
||||||
/// Domänenname fast immer.
|
|
||||||
public enum JellyfinAddress {
|
public enum JellyfinAddress {
|
||||||
/// Der Standardport eines Jellyfin-Servers.
|
/// Der Standardport eines Jellyfin-Servers.
|
||||||
public static let defaultPort = 8096
|
public static let defaultPort = 8096
|
||||||
|
|
||||||
public static func candidates(for input: String) -> [URL] {
|
public static func candidates(for input: String) -> [URL] {
|
||||||
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
ServerAddress.candidates(for: input, defaultPort: defaultPort)
|
||||||
while text.hasSuffix("/") { text.removeLast() }
|
|
||||||
guard !text.isEmpty else { return [] }
|
|
||||||
|
|
||||||
// Wer ein Schema hinschreibt, meint es auch so. Daran wird nichts
|
|
||||||
// ergänzt — sonst landet eine Anfrage unverschlüsselt, obwohl jemand
|
|
||||||
// ausdrücklich „https" getippt hat.
|
|
||||||
if text.contains("://") {
|
|
||||||
return URL(string: text).map { [$0] } ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPv6 in eckigen Klammern: hier wird nicht geraten, weil die
|
|
||||||
// Doppelpunkte sonst als Port gelesen würden.
|
|
||||||
if text.hasPrefix("[") {
|
|
||||||
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
|
||||||
}
|
|
||||||
|
|
||||||
let authority = String(text.split(separator: "/", maxSplits: 1).first ?? "")
|
|
||||||
let host = String(authority.split(separator: ":").first ?? "")
|
|
||||||
let hasExplicitPort = authority.contains(":")
|
|
||||||
&& Int(authority.split(separator: ":").last ?? "") != nil
|
|
||||||
|
|
||||||
if hasExplicitPort {
|
|
||||||
// Port genannt, Schema nicht: erst unverschlüsselt, weil ein
|
|
||||||
// eigener Port fast immer ein Server ohne Zertifikat ist.
|
|
||||||
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eine reine Zahlenadresse, ein Bonjour-Name oder ein Wort ohne Punkt
|
|
||||||
// — alles Dinge, die es nur im eigenen Netz gibt.
|
|
||||||
let isLocalLooking = host.allSatisfy { $0.isNumber || $0 == "." }
|
|
||||||
|| host.hasSuffix(".local")
|
|
||||||
|| !host.contains(".")
|
|
||||||
|
|
||||||
let forms = isLocalLooking
|
|
||||||
? ["http://\(withDefaultPort(text))", "http://\(text)", "https://\(text)"]
|
|
||||||
: ["https://\(text)", "http://\(text)", "http://\(withDefaultPort(text))"]
|
|
||||||
return forms.compactMap(URL.init(string:))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter.
|
|
||||||
private static func withDefaultPort(_ text: String) -> String {
|
|
||||||
guard let slash = text.firstIndex(of: "/") else { return "\(text):\(defaultPort)" }
|
|
||||||
return "\(text[text.startIndex..<slash]):\(defaultPort)\(text[slash...])"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,18 @@ public enum BackendCapability: String, Sendable, Hashable {
|
|||||||
/// Die Verwaltung des Servers aus der App heraus.
|
/// Die Verwaltung des Servers aus der App heraus.
|
||||||
case administration
|
case administration
|
||||||
case transcoding
|
case transcoding
|
||||||
|
/// Ein Download bringt **alle** Ton- und Textspuren mit.
|
||||||
|
///
|
||||||
|
/// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Jellyfins
|
||||||
|
/// Transcode-Endpunkt bildet eine Tonspur je Sitzung ab: was
|
||||||
|
/// heruntergeladen wird, ist einspurig, und die Sprachwahl ist offline
|
||||||
|
/// weg. Einmal hat die App dabei still die Audiodeskription genommen —
|
||||||
|
/// drei Gigabyte Mobilfunk für eine unbrauchbare Datei.
|
||||||
|
///
|
||||||
|
/// Kann der Server es besser, soll die App nicht so tun, als müsste
|
||||||
|
/// vorher gewählt werden: `remuxDownloadURL` entfällt, die Spurauswahl
|
||||||
|
/// im Sheet entfällt, und der gewöhnliche Download liefert alles.
|
||||||
|
case allTracksDownload
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein Ausschnitt aus einer Mediathek.
|
/// Ein Ausschnitt aus einer Mediathek.
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet.
|
||||||
|
///
|
||||||
|
/// Weder Jellyfin noch Flaro haben ein Verzeichnis wie plex.tv — die Adresse
|
||||||
|
/// kommt vom Nutzer. Und die tippt niemand vollständig: „192.168.1.50" ohne
|
||||||
|
/// Schema, ohne Port, oder „medien.example.com" ohne „https://". Dieser Typ
|
||||||
|
/// rät nicht **eine** Ergänzung, sondern nennt die plausiblen in der
|
||||||
|
/// Reihenfolge, in der sie es wahrscheinlich sind; der Aufrufer probiert sie
|
||||||
|
/// durch und nimmt die erste, die sich ausweist.
|
||||||
|
///
|
||||||
|
/// Die Reihenfolge ist der ganze Inhalt dieser Datei, und sie ist nicht
|
||||||
|
/// beliebig: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein
|
||||||
|
/// Domänenname fast immer.
|
||||||
|
///
|
||||||
|
/// **In der neutralen Schicht, weil beide Server dasselbe Problem haben.**
|
||||||
|
/// Nur der Standardport unterscheidet sich, und der ist ein Wert, kein
|
||||||
|
/// Gedanke. Diese Überlegungen zweimal hinzuschreiben hiesse, dass die eine
|
||||||
|
/// Fassung beim nächsten Sonderfall nachgezogen wird und die andere nicht.
|
||||||
|
public enum ServerAddress {
|
||||||
|
public static func candidates(for input: String, defaultPort: Int) -> [URL] {
|
||||||
|
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
while text.hasSuffix("/") { text.removeLast() }
|
||||||
|
guard !text.isEmpty else { return [] }
|
||||||
|
|
||||||
|
// Wer ein Schema hinschreibt, meint es auch so. Daran wird nichts
|
||||||
|
// ergänzt — sonst landet eine Anfrage unverschlüsselt, obwohl jemand
|
||||||
|
// ausdrücklich „https" getippt hat.
|
||||||
|
if text.contains("://") {
|
||||||
|
return URL(string: text).map { [$0] } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 in eckigen Klammern: hier wird nicht geraten, weil die
|
||||||
|
// Doppelpunkte sonst als Port gelesen würden.
|
||||||
|
if text.hasPrefix("[") {
|
||||||
|
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
||||||
|
}
|
||||||
|
|
||||||
|
let authority = String(text.split(separator: "/", maxSplits: 1).first ?? "")
|
||||||
|
let host = String(authority.split(separator: ":").first ?? "")
|
||||||
|
let hasExplicitPort = authority.contains(":")
|
||||||
|
&& Int(authority.split(separator: ":").last ?? "") != nil
|
||||||
|
|
||||||
|
if hasExplicitPort {
|
||||||
|
// Port genannt, Schema nicht: erst unverschlüsselt, weil ein
|
||||||
|
// eigener Port fast immer ein Server ohne Zertifikat ist.
|
||||||
|
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine reine Zahlenadresse, ein Bonjour-Name oder ein Wort ohne Punkt
|
||||||
|
// — alles Dinge, die es nur im eigenen Netz gibt.
|
||||||
|
let isLocalLooking = host.allSatisfy { $0.isNumber || $0 == "." }
|
||||||
|
|| host.hasSuffix(".local")
|
||||||
|
|| !host.contains(".")
|
||||||
|
|
||||||
|
let mitPort = withDefaultPort(text, port: defaultPort)
|
||||||
|
let forms = isLocalLooking
|
||||||
|
? ["http://\(mitPort)", "http://\(text)", "https://\(text)"]
|
||||||
|
: ["https://\(text)", "http://\(text)", "http://\(mitPort)"]
|
||||||
|
return forms.compactMap(URL.init(string:))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter.
|
||||||
|
private static func withDefaultPort(_ text: String, port: Int) -> String {
|
||||||
|
guard let slash = text.firstIndex(of: "/") else { return "\(text):\(port)" }
|
||||||
|
return "\(text[text.startIndex..<slash]):\(port)\(text[slash...])"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import MediaKit
|
||||||
|
@testable import FlaroServerKit
|
||||||
|
|
||||||
|
/// Adressen und Zugang — alles, was ohne Netz entsteht.
|
||||||
|
@Suite("Flaro: Adressen und Zugang")
|
||||||
|
struct AdressenTests {
|
||||||
|
private func backend(_ transport: Attrappe, keksglas: Keksglas? = nil) -> FlaroBackend {
|
||||||
|
FlaroBackend(
|
||||||
|
client: FlaroClient(
|
||||||
|
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
||||||
|
transport: transport, cookieJar: keksglas),
|
||||||
|
account: FlaroAccount(
|
||||||
|
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
||||||
|
userID: "u1", userName: "guido", isAdministrator: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Kein Token in der Adresse — das ist der Unterschied zu Jellyfin.**
|
||||||
|
///
|
||||||
|
/// Dort steckt der Zugang als `?ApiKey=` in jeder Bildadresse, weil eine
|
||||||
|
/// Bildansicht keine Kopfzeilen schickt. Er steht damit in jedem
|
||||||
|
/// Zugriffsprotokoll, in jedem Verlauf und in jedem weitergegebenen Link.
|
||||||
|
/// Flaro geht über ein Cookie.
|
||||||
|
@Test("Bildadressen tragen keinen Zugang")
|
||||||
|
func bildAdresseOhneToken() {
|
||||||
|
let url = backend(Attrappe(rumpf: "{}")).rawImageURL(path: "abc123")
|
||||||
|
|
||||||
|
#expect(url?.path == "/api/v1/images/abc123")
|
||||||
|
#expect(url?.query == nil)
|
||||||
|
#expect(url?.absoluteString.contains("geheim") == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein Zugang wird als Cookie abgelegt")
|
||||||
|
func tokenWirdZuKeks() async {
|
||||||
|
let glas = Keksglas()
|
||||||
|
_ = FlaroClient(
|
||||||
|
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
||||||
|
transport: Attrappe(rumpf: "{}"), cookieJar: glas)
|
||||||
|
|
||||||
|
let keks = glas.kekse.first
|
||||||
|
#expect(keks?.name == "flaro_session")
|
||||||
|
#expect(keks?.value == "geheim-123")
|
||||||
|
#expect(keks?.path == "/")
|
||||||
|
#expect(keks?.domain.hasSuffix("nas") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Nach einem Neustart meldet sich niemand neu an.**
|
||||||
|
///
|
||||||
|
/// Die App hat dann nur das Token aus dem Schlüsselbund. Käme das Cookie
|
||||||
|
/// allein aus der Antwort des Servers, wäre keines da — und jedes Plakat
|
||||||
|
/// bliebe grau, obwohl der Zugang gilt.
|
||||||
|
@Test("Auch ein wiederaufgenommenes Token legt das Cookie an")
|
||||||
|
func keksAuchOhneAnmeldung() async throws {
|
||||||
|
let glas = Keksglas()
|
||||||
|
let transport = Attrappe([
|
||||||
|
("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#),
|
||||||
|
("/api/v1/system/info", 200, Proben.info),
|
||||||
|
])
|
||||||
|
|
||||||
|
_ = try await FlaroSignIn.restore(
|
||||||
|
baseURL: Proben.adresse, token: "geheim-123",
|
||||||
|
identity: Proben.kennung, transport: transport, cookieJar: glas)
|
||||||
|
|
||||||
|
#expect(glas.kekse.contains { $0.name == "flaro_session" && $0.value == "geheim-123" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Download-Adresse zeigt auf die Datei, nicht auf den Titel")
|
||||||
|
func downloadAdresse() {
|
||||||
|
let url = backend(Attrappe(rumpf: "{}")).downloadURL(
|
||||||
|
for: MediaFile(id: "datei-1", itemID: "film-1"))
|
||||||
|
|
||||||
|
#expect(url?.path == "/api/v1/files/datei-1/download")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Der Vertrag fragt nach einer Tonspur, Flaro braucht keine.**
|
||||||
|
///
|
||||||
|
/// `remuxDownloadURL` gibt es nur, weil Jellyfins Transcoder genau eine
|
||||||
|
/// Tonspur je Sitzung abbildet. Hier wäre eine Adresse mit Spurnummer
|
||||||
|
/// eine Lüge über das, was herauskommt — also gibt es keine, und die
|
||||||
|
/// Oberfläche erkennt an der Fähigkeit, dass sie nicht fragen muss.
|
||||||
|
@Test("Kein Umpacken mit Spurwahl — stattdessen die Fähigkeit")
|
||||||
|
func alleSpuren() {
|
||||||
|
let b = backend(Attrappe(rumpf: "{}"))
|
||||||
|
|
||||||
|
#expect(b.capabilities.contains(.allTracksDownload))
|
||||||
|
#expect(b.remuxDownloadURL(
|
||||||
|
itemID: "film-1", versionIndex: 0,
|
||||||
|
audioStreamIndex: 1, subtitleStreamIndex: nil) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zwei Server dürfen „Medien" heissen; derselbe darf umbenannt werden.
|
||||||
|
@Test("Die Kennung hängt an der Adresse, nicht am Namen")
|
||||||
|
func kennung() {
|
||||||
|
#expect(backend(Attrappe(rumpf: "{}")).identity == "flaro:http://nas:8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Anmelden schickt Gerätename und Kennung mit")
|
||||||
|
func anmeldung() async throws {
|
||||||
|
let transport = Attrappe([
|
||||||
|
("/api/v1/system/info", 200, Proben.info),
|
||||||
|
("/api/v1/auth/login", 200, Proben.anmeldung),
|
||||||
|
])
|
||||||
|
|
||||||
|
let (token, konto, _) = try await FlaroSignIn.signIn(
|
||||||
|
baseURL: Proben.adresse, userName: "guido", password: "geheim",
|
||||||
|
identity: Proben.kennung, transport: transport, cookieJar: Keksglas())
|
||||||
|
|
||||||
|
let rumpf = try #require(transport.rumpf(von: "/api/v1/auth/login"))
|
||||||
|
|
||||||
|
#expect(token == "geheim-123")
|
||||||
|
#expect(konto.userName == "guido")
|
||||||
|
#expect(konto.isAdministrator)
|
||||||
|
#expect(konto.serverName == "Wohnzimmer")
|
||||||
|
#expect(rumpf["deviceName"] as? String == "Prüfgerät")
|
||||||
|
#expect(rumpf["deviceId"] as? String == "geraet-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Ein frischer Server ist kein falsches Passwort.**
|
||||||
|
@Test("Ein nicht eingerichteter Server sagt das")
|
||||||
|
func nochNichtEingerichtet() async throws {
|
||||||
|
let transport = Attrappe(rumpf: #"{"version":"0.1.0","serverName":null,"needsSetup":true}"#)
|
||||||
|
|
||||||
|
let gruss = try await FlaroSignIn.greet(
|
||||||
|
baseURL: Proben.adresse, identity: Proben.kennung, transport: transport)
|
||||||
|
|
||||||
|
#expect(gruss.needsSetup)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Flaro begründet seine Absagen, und der Satz zählt mehr als die Zahl.**
|
||||||
|
@Test("Eine Absage mit Begründung kommt als Begründung an")
|
||||||
|
func begruendeteAbsage() async {
|
||||||
|
let transport = Attrappe(rumpf: #"{"detail":"Für diese Bibliothek läuft bereits ein Scan."}"#, status: 409)
|
||||||
|
let b = backend(transport)
|
||||||
|
|
||||||
|
await #expect(throws: MediaBackendError.refused(
|
||||||
|
reason: "Für diese Bibliothek läuft bereits ein Scan.")) {
|
||||||
|
try await b.refreshLibrary(MediaLibrary(id: "b1", title: "Filme", kind: .movie))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein abgelaufener Zugang führt zur Abmeldung, nicht zu einer Meldung")
|
||||||
|
func abgelaufen() async {
|
||||||
|
let b = backend(Attrappe(rumpf: "", status: 401))
|
||||||
|
|
||||||
|
await #expect(throws: MediaBackendError.unauthorized) {
|
||||||
|
_ = try await b.libraries()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import Foundation
|
||||||
|
@testable import FlaroServerKit
|
||||||
|
|
||||||
|
/// Ein Transport, der vorbereitete Antworten gibt und mitschreibt.
|
||||||
|
///
|
||||||
|
/// Dieselbe Trennung wie bei Jellyfin: Prüfungen brauchen keinen Server,
|
||||||
|
/// sondern eine Antwort und die Frage, die gestellt wurde.
|
||||||
|
final class Attrappe: FlaroTransport, @unchecked Sendable {
|
||||||
|
/// Antworten je Pfad. Der erste Treffer gewinnt — so kann eine Prüfung
|
||||||
|
/// `/api/v1/movies` und `/api/v1/movies/{id}` getrennt bedienen.
|
||||||
|
private let antworten: [(pfad: String, status: Int, rumpf: String)]
|
||||||
|
private let sperre = NSLock()
|
||||||
|
private var protokoll: [URLRequest] = []
|
||||||
|
|
||||||
|
init(_ antworten: [(pfad: String, status: Int, rumpf: String)]) {
|
||||||
|
self.antworten = antworten
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(rumpf: String, status: Int = 200) {
|
||||||
|
self.init([("", status, rumpf)])
|
||||||
|
}
|
||||||
|
|
||||||
|
var anfragen: [URLRequest] { sperre.withLock { protokoll } }
|
||||||
|
var letzte: URLRequest? { anfragen.last }
|
||||||
|
|
||||||
|
func anfrage(auf pfad: String) -> URLRequest? {
|
||||||
|
anfragen.last { $0.url?.path == pfad }
|
||||||
|
}
|
||||||
|
|
||||||
|
func rumpf(von pfad: String) -> [String: Any]? {
|
||||||
|
guard let daten = anfrage(auf: pfad)?.httpBody else { return nil }
|
||||||
|
return try? JSONSerialization.jsonObject(with: daten) as? [String: Any]
|
||||||
|
}
|
||||||
|
|
||||||
|
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||||
|
sperre.withLock { protokoll.append(request) }
|
||||||
|
let pfad = request.url?.path ?? ""
|
||||||
|
let treffer = antworten.first { $0.pfad.isEmpty || pfad.hasSuffix($0.pfad) || pfad == $0.pfad }
|
||||||
|
?? ("", 404, "")
|
||||||
|
|
||||||
|
return (
|
||||||
|
Data(treffer.rumpf.utf8),
|
||||||
|
HTTPURLResponse(url: request.url!, statusCode: treffer.status,
|
||||||
|
httpVersion: nil, headerFields: nil)!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Keksglas, das nur mitschreibt.
|
||||||
|
///
|
||||||
|
/// Der Speicher des Systems anzufassen hiesse, dass eine Prüfung die nächste
|
||||||
|
/// beeinflusst — und auf einem Entwicklerrechner auch den Browser.
|
||||||
|
final class Keksglas: FlaroCookieJar, @unchecked Sendable {
|
||||||
|
private let sperre = NSLock()
|
||||||
|
private var inhalt: [HTTPCookie] = []
|
||||||
|
|
||||||
|
var kekse: [HTTPCookie] { sperre.withLock { inhalt } }
|
||||||
|
|
||||||
|
func store(_ cookie: HTTPCookie) { sperre.withLock { inhalt.append(cookie) } }
|
||||||
|
|
||||||
|
func removeCookie(named name: String, host: String) {
|
||||||
|
sperre.withLock { inhalt.removeAll { $0.name == name } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Proben {
|
||||||
|
static let adresse = URL(string: "http://nas:8080")!
|
||||||
|
static let kennung = FlaroIdentity(deviceName: "Prüfgerät", deviceID: "geraet-1")
|
||||||
|
|
||||||
|
/// Ein Film mit zwei Tonspuren — der Fall, um den es geht.
|
||||||
|
static let film = """
|
||||||
|
{
|
||||||
|
"id": "film-1", "title": "Der Untergang", "year": 2004,
|
||||||
|
"overview": "Berlin, April 1945.",
|
||||||
|
"posterImage": "aaa", "backdropImage": "bbb",
|
||||||
|
"files": [{
|
||||||
|
"id": "datei-1", "fileName": "Der Untergang (2004).mkv",
|
||||||
|
"sizeBytes": 9000000000, "container": "matroska", "durationSeconds": 9360.0,
|
||||||
|
"streams": [
|
||||||
|
{"index":0,"kind":"Video","codec":"h264","language":null,"title":null,
|
||||||
|
"isDefault":true,"isForced":false,"channels":null,"width":1920,"height":1080},
|
||||||
|
{"index":1,"kind":"Audio","codec":"ac3","language":"deu","title":"Deutsch",
|
||||||
|
"isDefault":true,"isForced":false,"channels":6,"width":null,"height":null},
|
||||||
|
{"index":2,"kind":"Audio","codec":"ac3","language":"deu","title":"Audiodeskription",
|
||||||
|
"isDefault":false,"isForced":false,"channels":2,"width":null,"height":null},
|
||||||
|
{"index":3,"kind":"Subtitle","codec":"subrip","language":"deu","title":"Deutsch",
|
||||||
|
"isDefault":false,"isForced":false,"channels":null,"width":null,"height":null}
|
||||||
|
]
|
||||||
|
}],
|
||||||
|
"state": {"positionSeconds":300.0,"runtimeSeconds":9360.0,"watched":false,
|
||||||
|
"playCount":0,"favourite":false,"rating":null,"dismissed":false,
|
||||||
|
"lastPlayedAt":"2026-10-03T19:25:04.5209555+00:00"}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
static let info = """
|
||||||
|
{"version":"0.1.0","serverName":"Wohnzimmer","needsSetup":false,
|
||||||
|
"attributions":[{"text":"This product uses TMDB","logoUrl":"https://x/t.svg","url":"https://x"}]}
|
||||||
|
"""
|
||||||
|
|
||||||
|
static let anmeldung = """
|
||||||
|
{"token":"geheim-123","user":{"id":"u1","userName":"guido","isAdministrator":true}}
|
||||||
|
"""
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import MediaKit
|
||||||
|
@testable import FlaroServerKit
|
||||||
|
|
||||||
|
/// Aus Flaros Antworten werden neutrale Typen — ohne Verlust.
|
||||||
|
@Suite("Flaro: Übersetzung")
|
||||||
|
struct UebersetzungTests {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Beide Tonspuren kommen an, mit Namen und Nummer.**
|
||||||
|
///
|
||||||
|
/// Das ist der ganze Anlass: eine Datei mit „Deutsch" und
|
||||||
|
/// „Audiodeskription", und die App muss beide sehen, um nicht blind die
|
||||||
|
/// falsche zu nehmen.
|
||||||
|
@Test("Ein Film behält alle Spuren")
|
||||||
|
func filmMitZweiTonspuren() async throws {
|
||||||
|
let film = try #require(try? FlaroClient.decoder.decode(Wire.Movie.self, from: Data(Proben.film.utf8)))
|
||||||
|
let titel = film.neutral()
|
||||||
|
let datei = try #require(titel.versions.first?.files.first)
|
||||||
|
|
||||||
|
#expect(titel.title == "Der Untergang")
|
||||||
|
#expect(titel.year == 2004)
|
||||||
|
#expect(datei.audioStreams.count == 2)
|
||||||
|
#expect(datei.audioStreams.map(\.title) == ["Deutsch", "Audiodeskription"])
|
||||||
|
// Die Nummer muss zurückrechenbar sein — daran hängt die Spurwahl.
|
||||||
|
#expect(datei.audioStreams.map(\.index) == [1, 2])
|
||||||
|
#expect(datei.subtitleStreams.count == 1)
|
||||||
|
#expect(datei.videoStream?.codec == "h264")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fünf Minuten von zwei Stunden sechsunddreissig — die Stelle, die
|
||||||
|
/// Jellyfin wegwirft.
|
||||||
|
@Test("Der Stand kommt als Fortschritt an")
|
||||||
|
func standWirdFortschritt() throws {
|
||||||
|
let film = try #require(try? FlaroClient.decoder.decode(Wire.Movie.self, from: Data(Proben.film.utf8)))
|
||||||
|
let titel = film.neutral()
|
||||||
|
|
||||||
|
#expect(titel.resumeMilliseconds == 300_000)
|
||||||
|
#expect(titel.duration == 9_360_000)
|
||||||
|
#expect(titel.isWatched == false)
|
||||||
|
let anteil = try #require(titel.watchProgress)
|
||||||
|
#expect(abs(anteil - 300.0 / 9360.0) < 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ASP.NET Core schreibt Bruchteile von Sekunden mit.
|
||||||
|
///
|
||||||
|
/// **Der Standarddekoder scheitert daran**, und zwar stumm bis zur
|
||||||
|
/// Stelle, an der jemand das Datum liest. Hier stehen beide nebeneinander,
|
||||||
|
/// damit sichtbar bleibt, dass die eigene Strategie keine Verzierung ist.
|
||||||
|
@Test("Ein Datum mit Sekundenbruchteilen wird gelesen")
|
||||||
|
func datumMitBruch() async throws {
|
||||||
|
let roh = Data(Proben.film.utf8)
|
||||||
|
|
||||||
|
#expect((try? JSONDecoder().decode(Wire.Movie.self, from: roh)) == nil,
|
||||||
|
"2026-10-03T19:25:04.5209555+00:00 ist für .iso8601 kein Datum")
|
||||||
|
#expect((try? FlaroClient.decoder.decode(Wire.Movie.self, from: roh)) != nil)
|
||||||
|
|
||||||
|
let transport = Attrappe(rumpf: #"{"items":[\#(Proben.film)],"total":1}"#)
|
||||||
|
let seite = try await backend(transport).items(
|
||||||
|
inLibrary: MediaLibrary(id: "b1", title: "Filme", kind: .movie),
|
||||||
|
query: MediaQuery(), offset: 0, limit: 50)
|
||||||
|
|
||||||
|
#expect(seite.items.first?.lastPlayedAt != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Server zählt, was offen ist; die App rechnet mit der Gegenzahl.
|
||||||
|
@Test("Offene Folgen werden zu gesehenen")
|
||||||
|
func serieZaehlt() throws {
|
||||||
|
let roh = """
|
||||||
|
{"id":"s1","title":"Dark","year":2017,"overview":null,
|
||||||
|
"posterImage":"p","backdropImage":"b",
|
||||||
|
"episodeCount":10,"seasons":[1,2],"unwatchedCount":3}
|
||||||
|
"""
|
||||||
|
let serie = try FlaroClient.decoder.decode(Wire.Series.self, from: Data(roh.utf8))
|
||||||
|
let titel = serie.neutral()
|
||||||
|
|
||||||
|
#expect(titel.kind == .show)
|
||||||
|
#expect(titel.episodeCount == 10)
|
||||||
|
#expect(titel.playedEpisodeCount == 7)
|
||||||
|
#expect(titel.childCount == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Oben die Serie, darunter die Folge.** Eine Kachel, die nur
|
||||||
|
/// „Lügen" sagt, hilft beim Wiedererkennen nicht.
|
||||||
|
@Test("Eine Folge aus Weiterschauen trägt ihre Serie mit")
|
||||||
|
func weiterschauenFolge() throws {
|
||||||
|
let roh = """
|
||||||
|
{"id":"f2","kind":"episode","title":"Dark","subTitle":"Lügen",
|
||||||
|
"season":1,"episode":2,"year":null,"posterImage":"p","backdropImage":"still",
|
||||||
|
"state":{"positionSeconds":400.0,"runtimeSeconds":2700.0,"watched":false,
|
||||||
|
"playCount":0,"favourite":false,"rating":null,"dismissed":false,
|
||||||
|
"lastPlayedAt":null}}
|
||||||
|
"""
|
||||||
|
let eintrag = try FlaroClient.decoder.decode(Wire.Started.self, from: Data(roh.utf8))
|
||||||
|
let titel = eintrag.neutral()
|
||||||
|
|
||||||
|
#expect(titel.kind == .episode)
|
||||||
|
#expect(titel.title == "Lügen")
|
||||||
|
#expect(titel.seriesTitle == "Dark")
|
||||||
|
#expect(titel.parentIndex == 1)
|
||||||
|
#expect(titel.index == 2)
|
||||||
|
#expect(titel.resumeMilliseconds == 400_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mediatheken: Shows werden Serien, alles andere Filme")
|
||||||
|
func mediatheken() async throws {
|
||||||
|
let transport = Attrappe(rumpf: """
|
||||||
|
[{"id":"b1","name":"Filme","kind":"Movies","path":"/media/Filme"},
|
||||||
|
{"id":"b2","name":"Serien","kind":"Shows","path":"/media/Serien"}]
|
||||||
|
""")
|
||||||
|
|
||||||
|
let liste = try await backend(transport).libraries()
|
||||||
|
|
||||||
|
#expect(liste.map(\.kind) == [.movie, .show])
|
||||||
|
#expect(liste.map(\.title) == ["Filme", "Serien"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nach der letzten Folge kommt keine — 204, kein Fehler.
|
||||||
|
@Test("Hinter der letzten Folge steht nichts")
|
||||||
|
func keineNaechsteFolge() async throws {
|
||||||
|
let transport = Attrappe(rumpf: "", status: 204)
|
||||||
|
let folge = MediaItem(id: "f9", kind: .episode, title: "Letzte")
|
||||||
|
|
||||||
|
let naechste = try await backend(transport).nextEpisode(after: folge)
|
||||||
|
#expect(naechste == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine Fortschrittsmeldung geht in Sekunden hinaus")
|
||||||
|
func fortschrittMelden() async {
|
||||||
|
let transport = Attrappe(rumpf: "{}")
|
||||||
|
|
||||||
|
await backend(transport).reportProgress(PlaybackReport(
|
||||||
|
itemID: "film-1", state: .playing,
|
||||||
|
time: .seconds(300), duration: .seconds(9360)))
|
||||||
|
|
||||||
|
let rumpf = transport.rumpf(von: "/api/v1/items/film-1/progress")
|
||||||
|
#expect(transport.letzte?.httpMethod == "POST")
|
||||||
|
#expect(rumpf?["positionSeconds"] as? Double == 300)
|
||||||
|
#expect(rumpf?["runtimeSeconds"] as? Double == 9360)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Flaro hat einen eigenen Weg dafür — nur deshalb bleibt die Stelle.**
|
||||||
|
/// Bei Jellyfin wird hier das Benutzerdatenblatt überschrieben.
|
||||||
|
@Test("Weglegen ist nicht dasselbe wie gesehen")
|
||||||
|
func weglegen() async throws {
|
||||||
|
let transport = Attrappe(rumpf: "{}")
|
||||||
|
|
||||||
|
try await backend(transport).removeFromContinueWatching(
|
||||||
|
MediaItem(id: "film-1", title: "Der Untergang"))
|
||||||
|
|
||||||
|
#expect(transport.letzte?.url?.path == "/api/v1/items/film-1/dismiss")
|
||||||
|
#expect(transport.letzte?.httpMethod == "POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Gesehen und ungesehen gehen an dieselbe Stelle, mit anderem Wert")
|
||||||
|
func gesehenMelden() async throws {
|
||||||
|
let transport = Attrappe(rumpf: "{}")
|
||||||
|
let b = backend(transport)
|
||||||
|
|
||||||
|
try await b.markWatched(id: "film-1")
|
||||||
|
#expect(transport.rumpf(von: "/api/v1/items/film-1/watched")?["watched"] as? Bool == true)
|
||||||
|
|
||||||
|
try await b.markUnwatched(id: "film-1")
|
||||||
|
#expect(transport.rumpf(von: "/api/v1/items/film-1/watched")?["watched"] as? Bool == false)
|
||||||
|
#expect(transport.letzte?.httpMethod == "PUT")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Abspielen -----------------------------------------------------
|
||||||
|
|
||||||
|
/// **Direkt spielbar: sofort los.**
|
||||||
|
@Test("Eine MP4 geht ohne Umweg hinaus")
|
||||||
|
func direkteWiedergabe() async throws {
|
||||||
|
let transport = Attrappe(rumpf: """
|
||||||
|
{"method":"directFile","reasons":[],"audioToEncode":[],"subtitlesDropped":[],
|
||||||
|
"packageState":"none","progress":0.0,"error":null}
|
||||||
|
""")
|
||||||
|
|
||||||
|
let ziel = try await backend(transport).preparePlayback(PlaybackIntent(
|
||||||
|
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
|
||||||
|
|
||||||
|
#expect(ziel.url.path == "/api/v1/files/datei-1/download")
|
||||||
|
// Flaro legt für eine Wiedergabe nichts an, das aufzuräumen wäre.
|
||||||
|
#expect(ziel.sessionID == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Der Fehler, den erst der Simulator zeigte.**
|
||||||
|
///
|
||||||
|
/// Muss der Server umpacken, antwortet er auf den Download mit 202 und
|
||||||
|
/// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere
|
||||||
|
/// Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner:
|
||||||
|
/// „Stelle wird wiederhergestellt", 0:00, für immer.
|
||||||
|
@Test("Ein Umpacken wird abgewartet, nicht überholt")
|
||||||
|
func wartetAufsUmpacken() async throws {
|
||||||
|
let transport = Fortschrittsattrappe()
|
||||||
|
|
||||||
|
let ziel = try await backend(transport).preparePlayback(PlaybackIntent(
|
||||||
|
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
|
||||||
|
|
||||||
|
#expect(ziel.url.path == "/api/v1/files/datei-1/download")
|
||||||
|
#expect(transport.abfragen >= 2, "es wurde nachgefragt, nicht einmal geschaut")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Absage des Servers ist eine Absage, kein Weiterwarten.
|
||||||
|
@Test("Ein gescheitertes Umpacken kommt als Begründung an")
|
||||||
|
func gescheitertesUmpacken() async {
|
||||||
|
let transport = Attrappe(rumpf: """
|
||||||
|
{"method":"remux","reasons":["matroska"],"audioToEncode":[],"subtitlesDropped":[],
|
||||||
|
"packageState":"failed","progress":0.0,"error":"ffmpeg ist abgestürzt."}
|
||||||
|
""")
|
||||||
|
let b = backend(transport)
|
||||||
|
|
||||||
|
await #expect(throws: MediaBackendError.refused(reason: "ffmpeg ist abgestürzt.")) {
|
||||||
|
_ = try await b.preparePlayback(PlaybackIntent(
|
||||||
|
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flaro kennt noch keine Schlagwörter — eine leere Liste, kein Fehler.
|
||||||
|
@Test("Ohne Schlagwörter bleibt der Filter leer statt kaputt")
|
||||||
|
func keineSchlagwoerter() async throws {
|
||||||
|
let optionen = try await backend(Attrappe(rumpf: "{}")).filterOptions(
|
||||||
|
inLibrary: MediaLibrary(id: "b1", title: "Filme", kind: .movie), field: .genre)
|
||||||
|
|
||||||
|
#expect(optionen.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Antwortet beim ersten Mal „läuft noch", danach „fertig".
|
||||||
|
///
|
||||||
|
/// Damit prüfbar ist, dass gewartet wird — eine Attrappe mit immer derselben
|
||||||
|
/// Antwort könnte das nicht von „einmal geschaut und losgelaufen" trennen.
|
||||||
|
final class Fortschrittsattrappe: FlaroTransport, @unchecked Sendable {
|
||||||
|
private let sperre = NSLock()
|
||||||
|
private var runde = 0
|
||||||
|
|
||||||
|
var abfragen: Int { sperre.withLock { runde } }
|
||||||
|
|
||||||
|
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||||
|
let pfad = request.url?.path ?? ""
|
||||||
|
let fertig: Bool = sperre.withLock {
|
||||||
|
if pfad.hasSuffix("/delivery") { runde += 1 }
|
||||||
|
return runde > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let rumpf = """
|
||||||
|
{"method":"remux","reasons":["matroska"],"audioToEncode":[],"subtitlesDropped":[],
|
||||||
|
"packageState":"\(fertig ? "ready" : "running")","progress":\(fertig ? 1.0 : 0.4),"error":null}
|
||||||
|
"""
|
||||||
|
return (Data(rumpf.utf8),
|
||||||
|
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Erzeugt einen kleinen Bestand zum Ausprobieren: echte Dateinamen, winzige
|
||||||
|
# Dateien. Jede ist eine Sekunde Testbild mit Ton — damit der Scan, die
|
||||||
|
# Metadaten und das Umpacken etwas zu tun haben, ohne Gigabytes zu brauchen.
|
||||||
|
set -euo pipefail
|
||||||
|
ZIEL="${1:-$HOME/.flaro-medien}"
|
||||||
|
command -v ffmpeg >/dev/null || { echo "ffmpeg fehlt."; exit 1; }
|
||||||
|
|
||||||
|
mach() { # mach <pfad> <schachtel> [zweite-tonspur]
|
||||||
|
mkdir -p "$(dirname "$1")"
|
||||||
|
if [ "${3:-}" = "zwei" ]; then
|
||||||
|
ffmpeg -y -loglevel error \
|
||||||
|
-f lavfi -i "testsrc=duration=2:size=320x240:rate=12" \
|
||||||
|
-f lavfi -i "sine=frequency=440:duration=2" \
|
||||||
|
-f lavfi -i "sine=frequency=880:duration=2" \
|
||||||
|
-map 0:v -map 1:a -map 2:a \
|
||||||
|
-metadata:s:a:0 language=deu -metadata:s:a:0 title=Deutsch \
|
||||||
|
-metadata:s:a:1 language=deu -metadata:s:a:1 title=Audiodeskription \
|
||||||
|
-c:v libx264 -preset ultrafast -c:a aac "$1"
|
||||||
|
else
|
||||||
|
ffmpeg -y -loglevel error \
|
||||||
|
-f lavfi -i "testsrc=duration=2:size=320x240:rate=12" \
|
||||||
|
-f lavfi -i "sine=frequency=440:duration=2" \
|
||||||
|
-c:v libx264 -preset ultrafast -c:a aac "$1"
|
||||||
|
fi
|
||||||
|
echo " $(basename "$1")"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Filme:"
|
||||||
|
mach "$ZIEL/Filme/Der Untergang (2004)/Der Untergang (2004).mkv" mkv zwei
|
||||||
|
mach "$ZIEL/Filme/The Matrix (1999)/The Matrix (1999).mkv" mkv
|
||||||
|
mach "$ZIEL/Filme/Blade Runner 2049 (2017)/Blade Runner 2049 (2017).mp4" mp4
|
||||||
|
mach "$ZIEL/Filme/Inception (2010)/Inception (2010).mkv" mkv
|
||||||
|
|
||||||
|
echo "Serien:"
|
||||||
|
mach "$ZIEL/Serien/Dark/Staffel 1/Dark - S01E01.mkv" mkv
|
||||||
|
mach "$ZIEL/Serien/Dark/Staffel 1/Dark - S01E02.mkv" mkv
|
||||||
|
mach "$ZIEL/Serien/Dark/Staffel 2/Dark - S02E01.mkv" mkv
|
||||||
|
mach "$ZIEL/Serien/Notruf Hafenkante/Staffel 1/Notruf Hafenkante - S01E01.mkv" mkv zwei
|
||||||
|
|
||||||
|
# **Eine zweite Filmbibliothek, und das ist Absicht.** Mit nur „Filme" und
|
||||||
|
# „Serien" lässt sich nicht prüfen, ob mehrere Mediatheken wirklich getrennt
|
||||||
|
# bleiben — zwei Bestände derselben Art sind der Fall, in dem es auffällt.
|
||||||
|
echo "Dokumentationen:"
|
||||||
|
mach "$ZIEL/Dokus/Nomaden der Lüfte (2001)/Nomaden der Lüfte (2001).mkv" mkv
|
||||||
|
mach "$ZIEL/Dokus/Unsere Erde (2007)/Unsere Erde (2007).mp4" mp4
|
||||||
|
|
||||||
|
echo "Konzerte:"
|
||||||
|
mach "$ZIEL/Konzerte/Stop Making Sense (1984)/Stop Making Sense (1984).mkv" mkv zwei
|
||||||
|
|
||||||
|
chmod -R a+rwX "$ZIEL"
|
||||||
|
echo
|
||||||
|
echo "Fertig: $ZIEL"
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Baut das Flaro-Image für beide Architekturen und legt es in die Registry.
|
||||||
|
#
|
||||||
|
# **Der Weg von Hand** — für den Fall, dass kein Gitea-Runner läuft. Sonst
|
||||||
|
# macht das `.gitea/workflows/image.yml` bei jedem Push von selbst.
|
||||||
|
#
|
||||||
|
# Tools/image-veroeffentlichen.sh → :latest
|
||||||
|
# Tools/image-veroeffentlichen.sh 0.2.0 → :latest und :0.2.0
|
||||||
|
#
|
||||||
|
# Vorher einmalig anmelden — das Passwort tippst du selbst ein:
|
||||||
|
#
|
||||||
|
# docker login git.scarriffle.com
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY="${FLARO_REGISTRY:-git.scarriffle.com}"
|
||||||
|
PFAD="${FLARO_IMAGE_PATH:-scarriffle/flaro}"
|
||||||
|
BILD="$REGISTRY/$PFAD"
|
||||||
|
FASSUNG="${1:-}"
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
command -v docker >/dev/null || { echo "docker fehlt."; exit 1; }
|
||||||
|
|
||||||
|
# **Ohne Anmeldung bricht der Push erst nach dem ganzen Bau ab.** Zwei
|
||||||
|
# Minuten Arbeit für eine Fehlermeldung, die am Anfang gestanden hätte.
|
||||||
|
# Kein `docker info | grep -q`: `grep -q` beendet die Pipe beim ersten
|
||||||
|
# Treffer, Docker bekommt SIGPIPE, und mit `set -o pipefail` gilt das als
|
||||||
|
# Fehlschlag. Die Prüfung meldete dann „Docker läuft nicht", während es lief.
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "Docker läuft nicht."; exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q "$REGISTRY" ~/.docker/config.json 2>/dev/null; then
|
||||||
|
echo "Nicht an $REGISTRY angemeldet. Erst:"
|
||||||
|
echo
|
||||||
|
echo " docker login $REGISTRY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
MARKEN=(-t "$BILD:latest")
|
||||||
|
[ -n "$FASSUNG" ] && MARKEN+=(-t "$BILD:$FASSUNG")
|
||||||
|
|
||||||
|
# **Ein eigener Builder.** Der Vorgabe-Builder von Docker Desktop kann kein
|
||||||
|
# Manifest mit zwei Architekturen bauen; `docker buildx create` schon.
|
||||||
|
docker buildx inspect flaro-bauer >/dev/null 2>&1 \
|
||||||
|
|| docker buildx create --name flaro-bauer --use >/dev/null
|
||||||
|
|
||||||
|
echo "Baue $BILD für amd64 und arm64 — das dauert, arm64 läuft unter Emulation."
|
||||||
|
docker buildx build \
|
||||||
|
--builder flaro-bauer \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
-f server/Dockerfile \
|
||||||
|
"${MARKEN[@]}" \
|
||||||
|
--push .
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Fertig. Auf dem NAS:"
|
||||||
|
echo " image: $BILD:${FASSUNG:-latest}"
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
# Flaro auf dem NAS einrichten
|
||||||
|
|
||||||
|
Für ein UGREEN-NAS mit der mitgelieferten Docker-App, und für jedes andere
|
||||||
|
System, auf dem Docker Compose läuft.
|
||||||
|
|
||||||
|
## Die kurze Antwort auf „ein Image oder mehrere?"
|
||||||
|
|
||||||
|
**Ein einziges.** Die Weboberfläche wird beim Bauen mit hineingelegt und vom
|
||||||
|
Server selbst ausgeliefert; es gibt keinen zweiten Container, keine Datenbank
|
||||||
|
daneben, keinen Proxy davor. Ein Container, drei Volumes, fertig.
|
||||||
|
|
||||||
|
```
|
||||||
|
flaro:<fassung> 231 MB
|
||||||
|
├── .NET-Laufzeit + Flaro
|
||||||
|
├── wwwroot/ die Weboberfläche
|
||||||
|
└── ffmpeg + VAAPI-Treiber
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 1: Die Architektur bestimmen
|
||||||
|
|
||||||
|
**Das ist der Schritt, den man am leichtesten überspringt und am teuersten
|
||||||
|
bezahlt.** Ein Image für `arm64` läuft auf einem Intel-NAS nicht, und die
|
||||||
|
Fehlermeldung (`exec format error`) sagt das nicht deutlich.
|
||||||
|
|
||||||
|
Auf dem NAS, über SSH oder die Konsole der Docker-App:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uname -m
|
||||||
|
```
|
||||||
|
|
||||||
|
| Ausgabe | Architektur | Was du bauen musst |
|
||||||
|
|---|---|---|
|
||||||
|
| `x86_64` | amd64 | `--platform linux/amd64` |
|
||||||
|
| `aarch64` | arm64 | `--platform linux/arm64` |
|
||||||
|
|
||||||
|
Die meisten UGREEN-Geräte mit Intel-Prozessor (N100, i5) melden `x86_64`.
|
||||||
|
Ein Mac baut ohne Angabe **arm64** — also gehört die Angabe dazu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 2: Das Image auf das NAS bringen
|
||||||
|
|
||||||
|
Drei Wege. Der erste ist der bequemste, der zweite braucht keine Registry.
|
||||||
|
|
||||||
|
### Weg A — über die Registry deines Gitea (empfohlen)
|
||||||
|
|
||||||
|
Dein Gitea unter `git.scarriffle.com` bringt eine Container-Registry mit.
|
||||||
|
Einmal einrichten, danach ist jedes Update ein `docker compose pull`.
|
||||||
|
|
||||||
|
#### A1 · Gitea baut selbst
|
||||||
|
|
||||||
|
`.gitea/workflows/image.yml` liegt im Projekt. Bei jedem Push auf `main`
|
||||||
|
baut Gitea das Image für **beide** Architekturen und legt es ab — ohne dass
|
||||||
|
jemand daran denken muss, und ohne Anmeldung am Arbeitsrechner.
|
||||||
|
|
||||||
|
**Voraussetzung ist ein registrierter Act-Runner.** Hat dein Gitea keinen,
|
||||||
|
passiert nichts; dann gilt A2. Nachsehen unter
|
||||||
|
*Einstellungen → Actions → Runner*. Ein Runner lässt sich als Container
|
||||||
|
neben Gitea betreiben:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --restart unless-stopped --name gitea-runner \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-e GITEA_INSTANCE_URL=https://git.scarriffle.com \
|
||||||
|
-e GITEA_RUNNER_REGISTRATION_TOKEN=<Token aus den Einstellungen> \
|
||||||
|
gitea/act_runner:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
#### A2 · Von Hand vom Arbeitsrechner
|
||||||
|
|
||||||
|
Einmalig anmelden — das Passwort tippst du selbst ein:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.scarriffle.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach genügt ein Befehl; er baut amd64 **und** arm64 und lädt hoch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Tools/image-veroeffentlichen.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit einer Fassungsnummer, wenn du sie festnageln willst:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Tools/image-veroeffentlichen.sh 0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
#### A3 · Das Paket öffentlich stellen
|
||||||
|
|
||||||
|
Sonst muss sich auch das NAS anmelden. In Gitea unter
|
||||||
|
*dein Profil → Pakete → flaro → Einstellungen* auf öffentlich stellen. Der
|
||||||
|
Quelltext ist ohnehin offen; das Image enthält nichts, was er nicht auch
|
||||||
|
enthält.
|
||||||
|
|
||||||
|
#### A4 · Die Registry im NAS eintragen
|
||||||
|
|
||||||
|
In der Docker-App des UGREEN: **Repository → Zahnrad → Quellname des
|
||||||
|
Images → Hinzufügen**. Dort stehen schon DockerHub und `ghcr.io`; daneben
|
||||||
|
kommt:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Alias | `gitea` |
|
||||||
|
| Link | `https://git.scarriffle.com/` |
|
||||||
|
|
||||||
|
**Bestätigen** — danach findet die Suche in der Docker-App auch
|
||||||
|
`scarriffle/flaro`. Ist das Paket privat, trägst du unter
|
||||||
|
*Registierungseinstellungen* Benutzername und ein Gitea-Zugriffstoken ein
|
||||||
|
(nicht dein Kontopasswort: ein Token lässt sich einzeln widerrufen, unter
|
||||||
|
*Einstellungen → Anwendungen → Zugriffstoken*, Berechtigung `read:package`).
|
||||||
|
|
||||||
|
In der Compose-Datei steht dann:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
image: git.scarriffle.com/scarriffle/flaro:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Weg B — als Datei hochladen
|
||||||
|
|
||||||
|
Ohne Registry, dafür bei jedem Update von Hand.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-f server/Dockerfile -t flaro:latest --load .
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker save flaro:latest | gzip > flaro.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Das ergibt rund **230 MB** — gemessen, nicht geschätzt. Das `gzip` bringt
|
||||||
|
dabei fast nichts: die Ebenen eines Docker-Images sind bereits gepackt.
|
||||||
|
Die Datei über die Dateiverwaltung auf das NAS legen, dann dort:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker load < /volume1/docker/flaro.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
In der Compose-Datei steht dann schlicht `image: flaro:latest`.
|
||||||
|
|
||||||
|
### Weg C — auf dem NAS bauen
|
||||||
|
|
||||||
|
Geht, lohnt sich aber selten: der Bau braucht den Quelltext, Node, das
|
||||||
|
.NET-SDK als Zwischenschritt und ordentlich Arbeitsspeicher. Die Docker-App
|
||||||
|
von UGREEN kann `build:` in einer Compose-Datei ausserdem nicht immer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 3: Die Compose-Datei
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
flaro:
|
||||||
|
image: git.scarriffle.com/scarriffle/flaro:latest
|
||||||
|
container_name: flaro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "8096:8080"
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Zurich
|
||||||
|
# Schreibt Plakate und NFO neben die Mediendateien. Ohne das bleibt
|
||||||
|
# der Medienordner unangetastet.
|
||||||
|
- FLARO_WRITE_SIDECARS=true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Datenbank und Konfiguration. **Das ist das Volume, das du sichern
|
||||||
|
# musst** — darin stehen Benutzer, Zuordnungen und Sehfortschritt.
|
||||||
|
- /volume1/docker/flaro/config:/config
|
||||||
|
# Heruntergeladene Plakate. Verlierbar: wird neu geholt.
|
||||||
|
- /volume1/docker/flaro/cache:/cache
|
||||||
|
# Umgepackte Dateien. Ebenfalls verlierbar, kann aber gross werden.
|
||||||
|
- /volume1/docker/flaro/transcode:/transcode
|
||||||
|
|
||||||
|
# Die Medien. Links der Pfad auf dem NAS, rechts der im Container —
|
||||||
|
# und **rechts** ist der, den du in Flaro einträgst.
|
||||||
|
- /volume1/Medien/Filme:/media/Filme
|
||||||
|
- /volume1/Medien/Serien:/media/Serien
|
||||||
|
|
||||||
|
# Hardware-Beschleunigung. Weglassen, wenn das NAS keine hat.
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rechte
|
||||||
|
|
||||||
|
Der Container läuft als Benutzer `1000:1000` — der erste Benutzer auf fast
|
||||||
|
jedem Linux und auf den üblichen NAS-Geräten. Gehören deine Ordner jemand
|
||||||
|
anderem, setz es ausdrücklich:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
user: "1001:100"
|
||||||
|
```
|
||||||
|
|
||||||
|
Welche Kennung gilt, sagt auf dem NAS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Der Port
|
||||||
|
|
||||||
|
`8096` ist frei gewählt. Läuft dort schon Jellyfin, nimm etwas anderes —
|
||||||
|
links die Zahl, unter der du Flaro erreichst, rechts bleibt `8080`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 4: Starten und einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann im Browser `http://<nas>:8096` öffnen. Es erscheint der
|
||||||
|
Einrichtungsassistent:
|
||||||
|
|
||||||
|
1. **Administratorkonto** anlegen — Benutzername und Passwort, mindestens
|
||||||
|
acht Zeichen.
|
||||||
|
2. Unter **Verwaltung → Mediatheken** eine Mediathek hinzufügen. Beim Ordner
|
||||||
|
gehört der Pfad **im Container** hinein, also `/media/Filme`, nicht
|
||||||
|
`/volume1/Medien/Filme`.
|
||||||
|
3. **Scannen.** Der Scan liest die Dateien, danach holt Flaro die Metadaten.
|
||||||
|
4. Unter **Verwaltung → Server → Metadaten** die Sprache einstellen, etwa
|
||||||
|
`de-DE`. Danach einmal **Metadaten auffrischen** — vorhandene Titel
|
||||||
|
ändern sich sonst nicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sichern
|
||||||
|
|
||||||
|
Was unersetzlich ist, liegt in **einem** Verzeichnis:
|
||||||
|
|
||||||
|
```
|
||||||
|
/volume1/docker/flaro/config/
|
||||||
|
```
|
||||||
|
|
||||||
|
Darin: die SQLite-Datenbank mit Benutzern, Zuordnungen, Sehfortschritt und
|
||||||
|
Einstellungen. Eine Kopie dieser Datei ist die ganze Sicherung — kein
|
||||||
|
Datenbank-Dump, kein Export.
|
||||||
|
|
||||||
|
`cache` und `transcode` dürfen weg; beides baut sich neu auf.
|
||||||
|
|
||||||
|
**Die Mediendateien fasst Flaro nicht an.** Es liest sie, und es legt —
|
||||||
|
sofern `FLARO_WRITE_SIDECARS=true` gesetzt ist — Plakate und NFO-Dateien
|
||||||
|
daneben. Umbenannt oder gelöscht wird nie etwas; es gibt im ganzen Programm
|
||||||
|
keine Stelle, die das könnte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aktualisieren
|
||||||
|
|
||||||
|
Mit Weg A:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrationen der Datenbank laufen beim Start von selbst. Ein Rückschritt auf
|
||||||
|
eine ältere Fassung ist **nicht** vorgesehen — vor einem Sprung lohnt eine
|
||||||
|
Kopie von `config/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wenn etwas nicht geht
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f flaro
|
||||||
|
```
|
||||||
|
|
||||||
|
| Zeichen | Ursache |
|
||||||
|
|---|---|
|
||||||
|
| `exec format error` | falsche Architektur — siehe Schritt 1 |
|
||||||
|
| Mediathek bleibt leer | der Pfad in Flaro ist der des NAS statt der im Container |
|
||||||
|
| „Zugriff verweigert" im Protokoll | Rechte, siehe `user:` oben |
|
||||||
|
| Keine Plakate, Titel wie Dateinamen | kein TMDB-Zugang oder kein Netz |
|
||||||
|
| Hardware-Beschleunigung greift nicht | `/dev/dri` nicht durchgereicht |
|
||||||
|
|
||||||
|
Ein Gesundheitsendpunkt ohne Anmeldung:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://<nas>:8096/health
|
||||||
|
```
|
||||||
@@ -57,6 +57,8 @@ targets:
|
|||||||
# zweiter dazu, steht er daneben.
|
# zweiter dazu, steht er daneben.
|
||||||
- package: FlaroKit
|
- package: FlaroKit
|
||||||
product: JellyfinKit
|
product: JellyfinKit
|
||||||
|
- package: FlaroKit
|
||||||
|
product: FlaroServerKit
|
||||||
- package: FlaroKit
|
- package: FlaroKit
|
||||||
product: FlaroDesign
|
product: FlaroDesign
|
||||||
- package: FlaroKit
|
- package: FlaroKit
|
||||||
@@ -188,6 +190,8 @@ targets:
|
|||||||
product: MediaKit
|
product: MediaKit
|
||||||
- package: FlaroKit
|
- package: FlaroKit
|
||||||
product: JellyfinKit
|
product: JellyfinKit
|
||||||
|
- package: FlaroKit
|
||||||
|
product: FlaroServerKit
|
||||||
- package: FlaroKit
|
- package: FlaroKit
|
||||||
product: FlaroDesign
|
product: FlaroDesign
|
||||||
- package: FlaroKit
|
- package: FlaroKit
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
**/bin/
|
|
||||||
**/obj/
|
|
||||||
**/*.user
|
|
||||||
data/
|
|
||||||
cache/
|
|
||||||
transcode/
|
|
||||||
+15
-4
@@ -1,5 +1,15 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Die Oberfläche zuerst, in einem eigenen Abschnitt: sie braucht Node, der
|
||||||
|
# Server nicht. So landet Node nie im fertigen Image.
|
||||||
|
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
||||||
|
WORKDIR /web
|
||||||
|
COPY web/app/package.json web/app/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/app/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
# Gebaut wird auf der Architektur des Bauwirts, übersetzt wird für die des
|
# Gebaut wird auf der Architektur des Bauwirts, übersetzt wird für die des
|
||||||
# Ziels — `dotnet publish -a` kann das. Ohne diese Trennung liefe der Bau für
|
# Ziels — `dotnet publish -a` kann das. Ohne diese Trennung liefe der Bau für
|
||||||
# arm64 unter Emulation und dauerte ein Vielfaches.
|
# arm64 unter Emulation und dauerte ein Vielfaches.
|
||||||
@@ -13,11 +23,11 @@ WORKDIR /src
|
|||||||
# `--parents` behält die Verzeichnisse bei. **Ein Muster statt einer Liste**,
|
# `--parents` behält die Verzeichnisse bei. **Ein Muster statt einer Liste**,
|
||||||
# weil eine Liste bei jedem neuen Projekt vergessen wird — und dann scheitert
|
# weil eine Liste bei jedem neuen Projekt vergessen wird — und dann scheitert
|
||||||
# erst der Bau im Container, nicht der auf dem Arbeitsrechner.
|
# erst der Bau im Container, nicht der auf dem Arbeitsrechner.
|
||||||
COPY --parents src/*/*.csproj ./
|
COPY --parents server/src/*/*.csproj ./
|
||||||
RUN dotnet restore src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH
|
RUN dotnet restore server/src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH
|
||||||
|
|
||||||
COPY . .
|
COPY server/ ./server/
|
||||||
RUN dotnet publish src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH -c Release -o /app --no-restore
|
RUN dotnet publish server/src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH -c Release -o /app --no-restore
|
||||||
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
@@ -65,6 +75,7 @@ ENV FLARO_DATA=/config \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app .
|
COPY --from=build /app .
|
||||||
|
COPY --from=web /web/dist ./wwwroot
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
VOLUME ["/config", "/cache", "/transcode"]
|
VOLUME ["/config", "/cache", "/transcode"]
|
||||||
|
|||||||
+33
-3
@@ -65,10 +65,34 @@ in Software; das ist langsamer, aber es läuft. **Zum blossen Umpacken wird gar
|
|||||||
keine Hardware gebraucht**, und das ist der Weg, der den meisten Beständen
|
keine Hardware gebraucht**, und das ist der Weg, der den meisten Beständen
|
||||||
hilft.
|
hilft.
|
||||||
|
|
||||||
|
## Weboberfläche
|
||||||
|
|
||||||
|
Der Server liefert sie selbst aus, unter derselben Adresse wie die
|
||||||
|
Schnittstelle. Kein Proxy davor, kein CORS, kein Mischmasch aus http und
|
||||||
|
https. Gebaut wird sie im Image mit; wer nur an ihr arbeitet, nimmt Vite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../web/app
|
||||||
|
npm install
|
||||||
|
FLARO_SERVER=http://localhost:18080 npm run dev # auf http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite reicht `/api` an den Server weiter, damit die Herkunft dieselbe bleibt
|
||||||
|
wie im Betrieb.
|
||||||
|
|
||||||
|
**Der Zugang liegt in einem Cookie, nicht nur in der Kopfzeile.** Ein
|
||||||
|
`<img>`, ein `<video>` und ein Download-Link senden keine eigenen
|
||||||
|
Kopfzeilen; das Token stattdessen in die Adresse zu schreiben hiesse, es in
|
||||||
|
jedes Zugriffsprotokoll und jeden weitergegebenen Link zu setzen. Das Cookie
|
||||||
|
ist `HttpOnly` und `SameSite=Strict`, `Secure` nur über https — sonst liesse
|
||||||
|
sich die Oberfläche im Heimnetz gar nicht benutzen. Die Apps benutzen
|
||||||
|
weiterhin die Kopfzeile: ein zweiter Weg zum selben Token, kein zweites
|
||||||
|
Verfahren.
|
||||||
|
|
||||||
## Entwickeln
|
## Entwickeln
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet test # 293 Tests
|
dotnet test # 313 Tests
|
||||||
dotnet run --project src/Flaro.Api # auf http://localhost:5000
|
dotnet run --project src/Flaro.Api # auf http://localhost:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -76,8 +100,14 @@ Ohne gesetzte Variablen legt der Server `data/`, `cache/` und `transcode/`
|
|||||||
neben dem Programm an.
|
neben dem Programm an.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Image bauen, beide Architekturen
|
# Image bauen, beide Architekturen.
|
||||||
docker buildx build --platform linux/amd64,linux/arm64 -t flaro:dev .
|
# **Aus der Wurzel des Projekts**, nicht aus server/: der Bau braucht
|
||||||
|
# web/app für die Oberfläche.
|
||||||
|
cd .. && docker buildx build --platform linux/amd64,linux/arm64 \
|
||||||
|
-f server/Dockerfile -t flaro:dev .
|
||||||
|
|
||||||
|
# Ein lokaler Server mit Beispielmedien zum Ausprobieren
|
||||||
|
Tools/demo-medien.sh && docker compose -f server/docker-compose.local.yml up -d --build
|
||||||
|
|
||||||
# Eine Migration nach einer Modelländerung
|
# Eine Migration nach einer Modelländerung
|
||||||
dotnet ef migrations add <Name> --project src/Flaro.Data --startup-project src/Flaro.Api --output-dir Migrations
|
dotnet ef migrations add <Name> --project src/Flaro.Data --startup-project src/Flaro.Api --output-dir Migrations
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
flaro:
|
flaro:
|
||||||
image: ghcr.io/scarriffle/flaro:latest
|
# Die Registry des eigenen Gitea. Wer woanders veröffentlicht, trägt
|
||||||
|
# hier etwas anderes ein — siehe docs/NAS-EINRICHTEN.md.
|
||||||
|
image: git.scarriffle.com/scarriffle/flaro:latest
|
||||||
container_name: flaro
|
container_name: flaro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Zum Ausprobieren auf dem eigenen Rechner — nicht für den Betrieb.
|
||||||
|
#
|
||||||
|
# Unterschiede zur Beispieldatei:
|
||||||
|
# * benannte Volumes statt Pfaden auf dem NAS
|
||||||
|
# * Medien aus ~/.flaro-medien, erzeugt von Tools/demo-medien.sh
|
||||||
|
# * Port 8080 bleibt frei, es läuft auf 18080
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.local.yml up -d
|
||||||
|
# docker compose -f docker-compose.local.yml logs -f
|
||||||
|
# docker compose -f docker-compose.local.yml down -v (räumt auch die Daten weg)
|
||||||
|
|
||||||
|
services:
|
||||||
|
flaro:
|
||||||
|
image: flaro:local
|
||||||
|
# Der Bauzusammenhang ist das Repo, nicht server/ — das Image braucht
|
||||||
|
# auch web/ für die Oberfläche.
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: server/Dockerfile
|
||||||
|
container_name: flaro-local
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "18080:8080"
|
||||||
|
|
||||||
|
# **Nur auf dem Mac nötig.** Colimas Dateifreigabe reicht die
|
||||||
|
# Besitzverhältnisse des Wirts nicht in die VM durch — eingehängte Ordner
|
||||||
|
# erscheinen im Container als root. Auf einem Linux-NAS gehören sie dem
|
||||||
|
# Benutzer 1000, und dann gehört diese Zeile weg.
|
||||||
|
user: "0:0"
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Zurich
|
||||||
|
- FLARO_WRITE_SIDECARS=true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- config:/config
|
||||||
|
- cache:/cache
|
||||||
|
- transcode:/transcode
|
||||||
|
- ${HOME}/.flaro-medien:/media
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
config:
|
||||||
|
cache:
|
||||||
|
transcode:
|
||||||
@@ -31,17 +31,43 @@ public sealed class TokenAuthenticationHandler(
|
|||||||
{
|
{
|
||||||
public const string SchemeName = "FlaroToken";
|
public const string SchemeName = "FlaroToken";
|
||||||
public const string DeviceTokenIdClaim = "flaro:device";
|
public const string DeviceTokenIdClaim = "flaro:device";
|
||||||
|
public const string DeviceNameClaim = "flaro:deviceName";
|
||||||
|
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
/// <summary>Derselbe Zugang, nur für den Browser.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Weil <c><img></c>, <c><video></c> und ein Download-Link
|
||||||
|
/// keine Kopfzeile setzen können.</b> Ohne Cookie müsste die Oberfläche
|
||||||
|
/// jedes Plakat und jeden Film über <c>fetch</c> holen und im
|
||||||
|
/// Arbeitsspeicher halten — bei einem Film ist das keine Lösung, und ein
|
||||||
|
/// Token in der Adresse stünde in jedem Zugriffsprotokoll.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>HttpOnly</c>, damit kein Skript herankommt, und
|
||||||
|
/// <c>SameSite=Strict</c> gegen Anfragen von fremden Seiten. Apps benutzen
|
||||||
|
/// weiterhin die Kopfzeile; das Cookie ist ein zweiter Weg zum selben
|
||||||
|
/// Token, kein zweites Verfahren.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public const string CookieName = "flaro_session";
|
||||||
|
|
||||||
|
private string? AusKopfzeile()
|
||||||
{
|
{
|
||||||
if (!Request.Headers.TryGetValue("Authorization", out var kopfzeile)) return AuthenticateResult.NoResult();
|
if (!Request.Headers.TryGetValue("Authorization", out var kopfzeile)) return null;
|
||||||
|
|
||||||
var roh = kopfzeile.ToString();
|
var roh = kopfzeile.ToString();
|
||||||
const string vorsatz = "Bearer ";
|
const string vorsatz = "Bearer ";
|
||||||
if (!roh.StartsWith(vorsatz, StringComparison.OrdinalIgnoreCase)) return AuthenticateResult.NoResult();
|
if (!roh.StartsWith(vorsatz, StringComparison.OrdinalIgnoreCase)) return null;
|
||||||
|
|
||||||
var token = roh[vorsatz.Length..].Trim();
|
var token = roh[vorsatz.Length..].Trim();
|
||||||
if (token.Length == 0) return AuthenticateResult.NoResult();
|
return token.Length == 0 ? null : token;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
var token = AusKopfzeile() ?? Request.Cookies[CookieName];
|
||||||
|
if (string.IsNullOrEmpty(token)) return AuthenticateResult.NoResult();
|
||||||
|
|
||||||
var fingerabdruck = Secrets.Fingerprint(token);
|
var fingerabdruck = Secrets.Fingerprint(token);
|
||||||
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerabdruck);
|
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerabdruck);
|
||||||
@@ -66,6 +92,10 @@ public sealed class TokenAuthenticationHandler(
|
|||||||
new Claim(ClaimTypes.NameIdentifier, benutzer.Id.ToString()),
|
new Claim(ClaimTypes.NameIdentifier, benutzer.Id.ToString()),
|
||||||
new Claim(ClaimTypes.Name, benutzer.UserName),
|
new Claim(ClaimTypes.Name, benutzer.UserName),
|
||||||
new Claim(DeviceTokenIdClaim, eintrag.Id.ToString()),
|
new Claim(DeviceTokenIdClaim, eintrag.Id.ToString()),
|
||||||
|
// Für das Dashboard: woher die Wiedergabe kommt. Der Name steht
|
||||||
|
// schon in der Geräteliste; ihn hier mitzugeben spart bei jeder
|
||||||
|
// Fortschrittsmeldung eine zweite Abfrage.
|
||||||
|
new Claim(DeviceNameClaim, eintrag.DeviceName),
|
||||||
.. benutzer.IsAdministrator ? new[] { new Claim(ClaimTypes.Role, FlaroRoles.Administrator) } : [],
|
.. benutzer.IsAdministrator ? new[] { new Claim(ClaimTypes.Role, FlaroRoles.Administrator) } : [],
|
||||||
], SchemeName);
|
], SchemeName);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public static class AuthEndpoints
|
|||||||
public static void MapAuth(this IEndpointRouteBuilder app)
|
public static void MapAuth(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
app.MapPost("/api/v1/auth/login", async (
|
app.MapPost("/api/v1/auth/login", async (
|
||||||
LoginRequest anfrage, FlaroContext db, TimeProvider clock) =>
|
LoginRequest anfrage, FlaroContext db, TimeProvider clock, HttpContext zusammenhang) =>
|
||||||
{
|
{
|
||||||
var name = anfrage.UserName.Trim();
|
var name = anfrage.UserName.Trim();
|
||||||
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.UserName == name);
|
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.UserName == name);
|
||||||
@@ -45,13 +45,28 @@ public static class AuthEndpoints
|
|||||||
|
|
||||||
var antwort = await TokenAusstellen(db, clock, benutzer, anfrage.DeviceName, anfrage.DeviceId);
|
var antwort = await TokenAusstellen(db, clock, benutzer, anfrage.DeviceName, anfrage.DeviceId);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
CookieSetzen(zusammenhang, antwort.Token, anfrage.StaySignedIn);
|
||||||
return Results.Ok(antwort);
|
return Results.Ok(antwort);
|
||||||
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
|
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
|
||||||
|
|
||||||
app.MapPost("/api/v1/auth/logout", async (ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
// **Abmelden scheitert nie.**
|
||||||
|
//
|
||||||
|
// Vorher verlangte dieser Weg eine gültige Anmeldung — und genau dann,
|
||||||
|
// wenn man ihn am dringendsten braucht, gibt es keine: nach einem
|
||||||
|
// Serverumzug, nach einem geleerten Datenbestand, nach einem
|
||||||
|
// zurückgezogenen Token. Das Cookie blieb dann im Browser stehen und
|
||||||
|
// ging bei **jedem** Bild erneut hinaus, und der Server lehnte es
|
||||||
|
// jedes Mal ab. Achtzig Ablehnungen in einer Stunde, und nichts davon
|
||||||
|
// liess sich von aussen beenden.
|
||||||
|
//
|
||||||
|
// Jetzt wird das Cookie immer gelöscht; widerrufen wird, was sich
|
||||||
|
// widerrufen lässt.
|
||||||
|
app.MapPost("/api/v1/auth/logout", async (
|
||||||
|
ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, HttpContext zusammenhang) =>
|
||||||
{
|
{
|
||||||
|
zusammenhang.Response.Cookies.Delete(FlaroTokenCookie);
|
||||||
if (!Guid.TryParse(wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim), out var id))
|
if (!Guid.TryParse(wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim), out var id))
|
||||||
return Results.Unauthorized();
|
return Results.NoContent();
|
||||||
|
|
||||||
var eintrag = await db.DeviceTokens.FindAsync(id);
|
var eintrag = await db.DeviceTokens.FindAsync(id);
|
||||||
if (eintrag is not null)
|
if (eintrag is not null)
|
||||||
@@ -60,7 +75,7 @@ public static class AuthEndpoints
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
}).RequireAuthorization();
|
}).AllowAnonymous();
|
||||||
|
|
||||||
app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) =>
|
app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) =>
|
||||||
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id)
|
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id)
|
||||||
@@ -178,6 +193,36 @@ public static class AuthEndpoints
|
|||||||
return new LoginResponse(token, new AuthenticatedUser(benutzer.Id, benutzer.UserName, benutzer.IsAdministrator));
|
return new LoginResponse(token, new AuthenticatedUser(benutzer.Id, benutzer.UserName, benutzer.IsAdministrator));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName;
|
||||||
|
|
||||||
|
/// <summary>Legt dasselbe Token als Cookie ab — für den Browser.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <c>HttpOnly</c>: kein Skript kommt heran, also auch keines, das sich
|
||||||
|
/// über eine Lücke in der Oberfläche eingeschlichen hat. <c>Strict</c>:
|
||||||
|
/// eine fremde Seite kann damit nichts auslösen. <c>Secure</c> nur bei
|
||||||
|
/// HTTPS, sonst liesse sich die Oberfläche im Heimnetz über http gar
|
||||||
|
/// nicht benutzen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Mit oder ohne Verfallsdatum, und das entscheidet der Benutzer.</b>
|
||||||
|
/// Ohne <c>MaxAge</c> ist es ein Sitzungscookie: es verschwindet, wenn
|
||||||
|
/// der Browser zugeht. Das ist die richtige Vorgabe an einem fremden
|
||||||
|
/// Rechner — und die falsche am eigenen, wo niemand sich täglich neu
|
||||||
|
/// anmelden will. Deshalb wird gefragt statt geraten.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void CookieSetzen(HttpContext zusammenhang, string token, bool bleiben) =>
|
||||||
|
zusammenhang.Response.Cookies.Append(FlaroTokenCookie, token, new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
SameSite = SameSiteMode.Strict,
|
||||||
|
Secure = zusammenhang.Request.IsHttps,
|
||||||
|
Path = "/",
|
||||||
|
MaxAge = bleiben ? TimeSpan.FromDays(365) : null,
|
||||||
|
});
|
||||||
|
|
||||||
/// <summary>Eine Antwort, die nicht verrät, welche Hälfte falsch war.</summary>
|
/// <summary>Eine Antwort, die nicht verrät, welche Hälfte falsch war.</summary>
|
||||||
private static IResult Abgelehnt() =>
|
private static IResult Abgelehnt() =>
|
||||||
Results.Problem("Benutzername oder Passwort stimmt nicht.", statusCode: StatusCodes.Status401Unauthorized);
|
Results.Problem("Benutzername oder Passwort stimmt nicht.", statusCode: StatusCodes.Status401Unauthorized);
|
||||||
|
|||||||
@@ -5,11 +5,26 @@ 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, bool NeedsSetup, IReadOnlyList<AttributionResponse> Attributions);
|
string Version, string ServerName, bool NeedsSetup,
|
||||||
|
IReadOnlyList<AttributionResponse> Attributions);
|
||||||
|
|
||||||
|
public sealed record ServerNameRequest(string Name);
|
||||||
|
|
||||||
public sealed record SetupRequest(string UserName, string Password);
|
public sealed record SetupRequest(string UserName, string Password);
|
||||||
|
|
||||||
public sealed record LoginRequest(string UserName, string Password, string DeviceName, string DeviceId);
|
/// <param name="StaySignedIn">
|
||||||
|
/// <b>Ob die Anmeldung einen geschlossenen Browser überlebt.</b> Vorgabe ist
|
||||||
|
/// ja — am eigenen Rechner will niemand sich täglich neu anmelden. Wer an
|
||||||
|
/// einem fremden sitzt, schaltet es ab, und das Cookie verfällt mit dem
|
||||||
|
/// Fenster.
|
||||||
|
///
|
||||||
|
/// Das Token selbst läuft in keinem Fall ab; es wird widerrufen, nicht
|
||||||
|
/// vergessen. Was hier entschieden wird, ist allein die Lebensdauer des
|
||||||
|
/// Cookies im Browser.
|
||||||
|
/// </param>
|
||||||
|
public sealed record LoginRequest(
|
||||||
|
string UserName, string Password, string DeviceName, string DeviceId,
|
||||||
|
bool StaySignedIn = true);
|
||||||
|
|
||||||
public sealed record AuthenticatedUser(Guid Id, string UserName, bool IsAdministrator);
|
public sealed record AuthenticatedUser(Guid Id, string UserName, bool IsAdministrator);
|
||||||
|
|
||||||
@@ -29,7 +44,17 @@ 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);
|
||||||
|
|
||||||
public sealed record LibraryResponse(Guid Id, string Name, string Kind, string Path);
|
/// <summary>Eine Bibliothek, wie die Oberfläche sie zeigt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>ItemCount</c> ist <c>null</c>, wenn er nicht mitgezählt wurde — etwa
|
||||||
|
/// direkt nach dem Anlegen oder Umbenennen. Nicht <c>0</c>: „keine Titel" und
|
||||||
|
/// „nicht gezählt" sind verschiedene Auskünfte, und eine frisch angelegte
|
||||||
|
/// Bibliothek als leer auszugeben wäre die falsche.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record LibraryResponse(Guid Id, string Name, string Kind, string Path, int? ItemCount);
|
||||||
|
|
||||||
|
public sealed record RenameLibraryRequest(string Name);
|
||||||
|
|
||||||
public sealed record CreateLibraryRequest(string Name, string Kind, string Path);
|
public sealed record CreateLibraryRequest(string Name, string Kind, string Path);
|
||||||
|
|
||||||
@@ -75,16 +100,51 @@ public sealed record MediaFileResponse(
|
|||||||
public sealed record MovieResponse(
|
public sealed record MovieResponse(
|
||||||
Guid Id, string Title, int? Year, string? Overview,
|
Guid Id, string Title, int? Year, string? Overview,
|
||||||
string? PosterImage, string? BackdropImage,
|
string? PosterImage, string? BackdropImage,
|
||||||
IReadOnlyList<MediaFileResponse> Files);
|
IReadOnlyList<MediaFileResponse> Files,
|
||||||
|
ItemStateResponse? State,
|
||||||
|
/// <summary>Der Schriftzug des Titels — fehlt oft, und das ist in Ordnung.</summary>
|
||||||
|
string? LogoImage = null,
|
||||||
|
int? RuntimeMinutes = null,
|
||||||
|
double? Rating = null,
|
||||||
|
string? Tagline = null,
|
||||||
|
string? ContentRating = null,
|
||||||
|
/// <summary>Leer in Listen, gefüllt beim Einzelabruf.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Eine Seite mit fünfzig Titeln braucht keine Besetzungslisten.</b>
|
||||||
|
/// Sie mitzuschicken hiesse, tausend Namen über die Leitung zu schicken,
|
||||||
|
/// von denen keiner angezeigt wird.
|
||||||
|
/// </remarks>
|
||||||
|
IReadOnlyList<string>? Genres = null,
|
||||||
|
IReadOnlyList<CreditResponse>? Credits = null);
|
||||||
|
|
||||||
|
public sealed record CreditResponse(
|
||||||
|
Guid Id, string Name, string Role, string? Character, string? Image);
|
||||||
|
|
||||||
public sealed record SeriesResponse(
|
public sealed record SeriesResponse(
|
||||||
Guid Id, string Title, int? Year, string? Overview,
|
Guid Id, string Title, int? Year, string? Overview,
|
||||||
string? PosterImage, string? BackdropImage,
|
string? PosterImage, string? BackdropImage,
|
||||||
int EpisodeCount, IReadOnlyList<int> Seasons);
|
int EpisodeCount, IReadOnlyList<int> Seasons,
|
||||||
|
/// <summary>Wie viele Folgen dieser Benutzer noch nicht gesehen hat.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Statt eines Zustands je Serie: eine Serie wird nicht angeschaut,
|
||||||
|
/// ihre Folgen werden es. Was man an der Kachel sehen will, ist die Zahl
|
||||||
|
/// — bei Plex steht sie als Plakette in der Ecke.
|
||||||
|
/// </remarks>
|
||||||
|
int UnwatchedCount,
|
||||||
|
string? LogoImage = null,
|
||||||
|
int? RuntimeMinutes = null,
|
||||||
|
double? Rating = null,
|
||||||
|
string? Tagline = null,
|
||||||
|
string? ContentRating = null,
|
||||||
|
IReadOnlyList<string>? Genres = null,
|
||||||
|
IReadOnlyList<CreditResponse>? Credits = null);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
|
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
|
||||||
|
|
||||||
@@ -132,3 +192,61 @@ public sealed record ApplyMatchRequest(int TmdbId);
|
|||||||
/// <summary>Wie die Zuordnung jetzt aussieht.</summary>
|
/// <summary>Wie die Zuordnung jetzt aussieht.</summary>
|
||||||
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);
|
||||||
|
|
||||||
|
// --- Stufe 4: Fortschritt und Zustand -------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Eine Fortschrittsmeldung.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>In Sekunden, nicht in Ticks.</b> Jellyfin rechnet in Hundertnanosekunden,
|
||||||
|
/// und jeder Client muss das wissen. Sekunden als Gleitkommazahl sind in jeder
|
||||||
|
/// Sprache dasselbe und in der Entwicklerkonsole lesbar.
|
||||||
|
///
|
||||||
|
/// <c>RuntimeSeconds</c> ist freiwillig: kennt der Client die Laufzeit der
|
||||||
|
/// Fassung, die er gerade abspielt, ist seine Angabe genauer als die des
|
||||||
|
/// Servers. Fehlt sie, nimmt der Server die aus der Datei.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record ProgressRequest(
|
||||||
|
double PositionSeconds, double? RuntimeSeconds,
|
||||||
|
/// <summary>Die letzte Meldung einer Wiedergabe.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Nimmt den Eintrag sofort aus „läuft gerade". Ohne sie stünde ein
|
||||||
|
/// beendeter Film dort noch eine Minute, bis das Lebenszeichen ausbleibt
|
||||||
|
/// — und wer gerade den Spieler geschlossen hat, sieht sich selbst noch
|
||||||
|
/// beim Schauen zu.
|
||||||
|
/// </remarks>
|
||||||
|
bool? Stopped = null);
|
||||||
|
|
||||||
|
public sealed record WatchedRequest(bool Watched);
|
||||||
|
public sealed record FavouriteRequest(bool Favourite);
|
||||||
|
public sealed record RatingRequest(int? Rating);
|
||||||
|
|
||||||
|
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
|
||||||
|
public sealed record ItemStateResponse(
|
||||||
|
double PositionSeconds, double? RuntimeSeconds,
|
||||||
|
bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed,
|
||||||
|
DateTimeOffset? LastPlayedAt);
|
||||||
|
|
||||||
|
/// <summary>Eine Zeile in „Weiterschauen".</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Film und Folge in einer Form, weil die Reihe beide mischt. Bei einer Folge
|
||||||
|
/// steht in <c>Title</c> die Serie und in <c>SubTitle</c> die Folge — so liest
|
||||||
|
/// sich die Kachel wie bei Plex: oben, worum es geht, darunter, wo man ist.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record ContinueEntry(
|
||||||
|
Guid Id, string Kind, string Title, string? SubTitle,
|
||||||
|
int? Season, int? Episode, int? Year,
|
||||||
|
string? PosterImage, string? BackdropImage,
|
||||||
|
ItemStateResponse State);
|
||||||
|
|
||||||
|
/// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary>
|
||||||
|
public sealed record SessionResponse(
|
||||||
|
string UserName, string DeviceName,
|
||||||
|
Guid ItemId, string Title, string? SubTitle, string? PosterImage,
|
||||||
|
double PositionSeconds, double? RuntimeSeconds);
|
||||||
|
|
||||||
|
/// <summary>Was der Server gerade tut.</summary>
|
||||||
|
public sealed record ServerStatusResponse(
|
||||||
|
int Playing, int Packaging, int Scanning, double Uptime);
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ public static class DeliveryEndpoints
|
|||||||
return Results.Ok(Auskunft(plan, packer, datei.Id, datei.ModifiedAt));
|
return Results.Ok(Auskunft(plan, packer, datei.Id, datei.ModifiedAt));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/v1/files/{id:guid}/download", async (
|
// **GET und HEAD.** Der Client fragt mit HEAD nach, ob das Umpacken
|
||||||
|
// fertig ist — eine Antwort ohne Rumpf genügt dafür, und einen
|
||||||
|
// halbfertigen Film herunterzuladen, nur um das zu erfahren, wäre
|
||||||
|
// absurd. `MapGet` allein nimmt HEAD nicht an; die Anfrage fiele dann
|
||||||
|
// durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
|
||||||
|
// Genau das ist passiert: der Spieler bekam HTML statt Video.
|
||||||
|
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async (
|
||||||
Guid id, FlaroContext db, PackageCoordinator packer) =>
|
Guid id, FlaroContext db, PackageCoordinator packer) =>
|
||||||
{
|
{
|
||||||
var gefunden = await Laden(db, id);
|
var gefunden = await Laden(db, id);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Flaro.Core.Playback;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -14,6 +16,24 @@ public static class LibraryContentEndpoints
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const int HöchstensJeSeite = 200;
|
private const int HöchstensJeSeite = 200;
|
||||||
|
|
||||||
|
/// <summary>Wonach eine Liste geordnet wird.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Als Wort, nicht als Spaltenname.</b> „added" sagt, was gemeint ist;
|
||||||
|
/// `AddedAt desc` verriete den Aufbau der Datenbank und wäre beim
|
||||||
|
/// nächsten Umbau falsch. Unbekanntes fällt auf den Titel zurück, statt
|
||||||
|
/// die Anfrage abzulehnen — eine Liste in falscher Reihenfolge ist
|
||||||
|
/// besser als keine.
|
||||||
|
/// </remarks>
|
||||||
|
private enum Reihenfolge { Title, Added, Year }
|
||||||
|
|
||||||
|
private static Reihenfolge Ordnung(string? wunsch) => wunsch?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"added" => Reihenfolge.Added,
|
||||||
|
"year" => Reihenfolge.Year,
|
||||||
|
_ => Reihenfolge.Title,
|
||||||
|
};
|
||||||
|
|
||||||
public static void MapLibraryContent(this IEndpointRouteBuilder app)
|
public static void MapLibraryContent(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
MapScan(app);
|
MapScan(app);
|
||||||
@@ -45,6 +65,66 @@ public static class LibraryContentEndpoints
|
|||||||
ILookup<Guid, MediaStreamResponse> spuren) =>
|
ILookup<Guid, MediaStreamResponse> spuren) =>
|
||||||
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList());
|
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList());
|
||||||
|
|
||||||
|
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
|
||||||
|
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
|
||||||
|
await db.GenreLinks.AsNoTracking()
|
||||||
|
.Where(l => film != null ? l.MovieId == film : l.SeriesId == serie)
|
||||||
|
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id, (_, g) => g.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
/// <summary>Besetzung und Stab — Darsteller zuerst, in ihrer Reihenfolge.</summary>
|
||||||
|
private static async Task<IReadOnlyList<CreditResponse>> MitwirkendeZu(
|
||||||
|
FlaroContext db, Guid? film, Guid? serie) =>
|
||||||
|
await db.Credits.AsNoTracking()
|
||||||
|
.Where(c => film != null ? c.MovieId == film : c.SeriesId == serie)
|
||||||
|
.Join(db.People.AsNoTracking(), c => c.PersonId, p => p.Id, (c, p) => new { c, p })
|
||||||
|
.OrderBy(x => x.c.Role).ThenBy(x => x.c.Order)
|
||||||
|
.Select(x => new CreditResponse(
|
||||||
|
x.p.Id, x.p.Name, x.c.Role.ToString(), x.c.Character, x.p.Image))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
/// <summary>Die Stände dieses Benutzers zu den genannten Titeln.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// In einer Abfrage für die ganze Seite. Ein Titel ohne Zeile hat keinen
|
||||||
|
/// Stand — das ist kein Fehler, sondern der Normalfall für alles, was
|
||||||
|
/// noch niemand angefasst hat, und die Antwort trägt dort <c>null</c>.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
|
||||||
|
FlaroContext db, ClaimsPrincipal wer, List<Guid>? filme = null, List<Guid>? folgen = null)
|
||||||
|
{
|
||||||
|
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return [];
|
||||||
|
|
||||||
|
var gesucht = (filme ?? folgen)!;
|
||||||
|
if (gesucht.Count == 0) return [];
|
||||||
|
|
||||||
|
var zeilen = await db.UserItemStates.AsNoTracking()
|
||||||
|
.Where(z => z.UserId == benutzer)
|
||||||
|
.Where(z => filme != null
|
||||||
|
? z.MovieId != null && gesucht.Contains(z.MovieId!.Value)
|
||||||
|
: z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return zeilen.ToDictionary(
|
||||||
|
z => (filme != null ? z.MovieId : z.EpisodeId)!.Value,
|
||||||
|
PlaybackEndpoints.Abbild);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Welche der genannten Folgen dieser Benutzer gesehen hat.</summary>
|
||||||
|
private static async Task<HashSet<Guid>> GeseheneFolgen(
|
||||||
|
FlaroContext db, ClaimsPrincipal wer, List<Guid> folgen)
|
||||||
|
{
|
||||||
|
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer || folgen.Count == 0) return [];
|
||||||
|
|
||||||
|
var ids = await db.UserItemStates.AsNoTracking()
|
||||||
|
.Where(z => z.UserId == benutzer && z.Watched
|
||||||
|
&& z.EpisodeId != null && folgen.Contains(z.EpisodeId!.Value))
|
||||||
|
.Select(z => z.EpisodeId!.Value)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return [.. ids];
|
||||||
|
}
|
||||||
|
|
||||||
private static void MapScan(IEndpointRouteBuilder app)
|
private static void MapScan(IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
app.MapPost("/api/v1/libraries/{id:guid}/scan", async (
|
app.MapPost("/api/v1/libraries/{id:guid}/scan", async (
|
||||||
@@ -59,6 +139,26 @@ public static class LibraryContentEndpoints
|
|||||||
statusCode: StatusCodes.Status409Conflict);
|
statusCode: StatusCodes.Status409Conflict);
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Metadaten neu holen — für alles, nicht nur für Neues.**
|
||||||
|
//
|
||||||
|
// Ein gewöhnlicher Scan fragt nichts erneut: das ist schneller und
|
||||||
|
// hält Handarbeit. Wer aber die Sprache von Englisch auf Deutsch
|
||||||
|
// stellt, bekäme sonst nie deutsche Titel — die Einstellung griffe
|
||||||
|
// erst beim nächsten neuen Film. Geholt wird über die bekannte
|
||||||
|
// Kennung, nicht über eine neue Suche; eine Zuordnung von Hand
|
||||||
|
// bleibt damit stehen.
|
||||||
|
app.MapPost("/api/v1/libraries/{id:guid}/refresh", async (
|
||||||
|
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
|
||||||
|
{
|
||||||
|
if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound();
|
||||||
|
|
||||||
|
return koordinator.TryStart(id, erneut: true)
|
||||||
|
? Results.AcceptedAtRoute("Scanstand", new { id })
|
||||||
|
: Results.Problem(
|
||||||
|
"Für diese Bibliothek läuft bereits ein Lauf.",
|
||||||
|
statusCode: StatusCodes.Status409Conflict);
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
app.MapGet("/api/v1/libraries/{id:guid}/scan", (Guid id, ScanCoordinator koordinator) =>
|
app.MapGet("/api/v1/libraries/{id:guid}/scan", (Guid id, ScanCoordinator koordinator) =>
|
||||||
{
|
{
|
||||||
var zustand = koordinator.State(id);
|
var zustand = koordinator.State(id);
|
||||||
@@ -82,18 +182,38 @@ public static class LibraryContentEndpoints
|
|||||||
private static void MapBrowse(IEndpointRouteBuilder app)
|
private static void MapBrowse(IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
app.MapGet("/api/v1/movies", async (
|
app.MapGet("/api/v1/movies", async (
|
||||||
FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) =>
|
ClaimsPrincipal wer, FlaroContext db,
|
||||||
|
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
|
||||||
{
|
{
|
||||||
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);
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
abfrage = abfrage.Where(m => EF.Functions.Like(m.Title, $"%{search.Trim()}%"));
|
abfrage = abfrage.Where(m => EF.Functions.Like(m.Title, $"%{search.Trim()}%"));
|
||||||
|
if (!string.IsNullOrWhiteSpace(genre))
|
||||||
|
{
|
||||||
|
var gesucht = genre.Trim();
|
||||||
|
abfrage = abfrage.Where(m => db.GenreLinks
|
||||||
|
.Any(l => l.MovieId == m.Id && db.Genres.Any(g => g.Id == l.GenreId && g.Name == gesucht)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Titel immer als letzter Vergleich: zwei Filme aus demselben
|
||||||
|
// Jahr hätten sonst keine feste Reihenfolge, und eine Seite 2
|
||||||
|
// zeigte dann denselben Film noch einmal oder gar nicht.
|
||||||
|
abfrage = Ordnung(sort) switch
|
||||||
|
{
|
||||||
|
Reihenfolge.Added => abfrage.OrderByDescending(m => m.AddedAt).ThenBy(m => m.SortTitleValue),
|
||||||
|
Reihenfolge.Year => abfrage.OrderByDescending(m => m.Year).ThenBy(m => m.SortTitleValue),
|
||||||
|
_ => abfrage.OrderBy(m => m.SortTitleValue).ThenBy(m => m.Year),
|
||||||
|
};
|
||||||
|
|
||||||
var gesamt = await abfrage.CountAsync();
|
var gesamt = await abfrage.CountAsync();
|
||||||
var kennungen = await abfrage
|
var kennungen = await abfrage
|
||||||
.OrderBy(m => m.SortTitleValue).ThenBy(m => m.Year)
|
|
||||||
.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 { m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage })
|
.Select(m => new
|
||||||
|
{
|
||||||
|
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||||
|
m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating,
|
||||||
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Dateien in **einer** Abfrage nachholen statt je Film einer.
|
// Dateien in **einer** Abfrage nachholen statt je Film einer.
|
||||||
@@ -105,48 +225,188 @@ public static class LibraryContentEndpoints
|
|||||||
.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 })
|
||||||
.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 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())).ToList();
|
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(),
|
||||||
|
stände.GetValueOrDefault(m.Id),
|
||||||
|
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<MovieResponse>(items, gesamt));
|
return Results.Ok(new Page<MovieResponse>(items, gesamt));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/v1/series", async (
|
app.MapGet("/api/v1/series", async (
|
||||||
FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) =>
|
ClaimsPrincipal wer, FlaroContext db,
|
||||||
|
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
|
||||||
{
|
{
|
||||||
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()}%"));
|
abfrage = abfrage.Where(s => EF.Functions.Like(s.Title, $"%{search.Trim()}%"));
|
||||||
|
if (!string.IsNullOrWhiteSpace(genre))
|
||||||
|
{
|
||||||
|
var gesucht = genre.Trim();
|
||||||
|
abfrage = abfrage.Where(x => db.GenreLinks
|
||||||
|
.Any(l => l.SeriesId == x.Id && db.Genres.Any(g => g.Id == l.GenreId && g.Name == gesucht)));
|
||||||
|
}
|
||||||
|
|
||||||
|
abfrage = Ordnung(sort) switch
|
||||||
|
{
|
||||||
|
Reihenfolge.Added => abfrage.OrderByDescending(s => s.AddedAt).ThenBy(s => s.SortTitleValue),
|
||||||
|
Reihenfolge.Year => abfrage.OrderByDescending(s => s.Year).ThenBy(s => s.SortTitleValue),
|
||||||
|
_ => abfrage.OrderBy(s => s.SortTitleValue).ThenBy(s => s.Year),
|
||||||
|
};
|
||||||
|
|
||||||
var gesamt = await abfrage.CountAsync();
|
var gesamt = await abfrage.CountAsync();
|
||||||
var serien = await abfrage
|
var serien = await abfrage
|
||||||
.OrderBy(s => s.SortTitleValue)
|
|
||||||
.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 { s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage })
|
.Select(s => new
|
||||||
|
{
|
||||||
|
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage,
|
||||||
|
s.LogoImage, s.RuntimeMinutes, s.Rating, s.ContentRating,
|
||||||
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var ids = serien.Select(s => s.Id).ToList();
|
var ids = serien.Select(s => s.Id).ToList();
|
||||||
var folgen = await db.Episodes.AsNoTracking()
|
var folgen = await db.Episodes.AsNoTracking()
|
||||||
.Where(f => ids.Contains(f.SeriesId))
|
.Where(f => ids.Contains(f.SeriesId))
|
||||||
.Select(f => new { f.SeriesId, f.SeasonNumber })
|
.Select(f => new { f.Id, f.SeriesId, f.SeasonNumber })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Gesehene Folgen in **einer** Abfrage, für alle Serien der Seite
|
||||||
|
// zusammen. Je Serie zu fragen wäre bei fünfzig Serien
|
||||||
|
// einundfünfzig Abfragen.
|
||||||
|
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
|
||||||
|
|
||||||
var items = serien.Select(s =>
|
var items = serien.Select(s =>
|
||||||
{
|
{
|
||||||
var meine = folgen.Where(f => f.SeriesId == s.Id).ToList();
|
var meine = folgen.Where(f => f.SeriesId == s.Id).ToList();
|
||||||
return new SeriesResponse(
|
return new SeriesResponse(
|
||||||
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)),
|
||||||
|
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/v1/series/{id:guid}/episodes", async (Guid id, FlaroContext db, int? season) =>
|
// **Nur die Genres, die in dieser Mediathek auch vorkommen.**
|
||||||
|
//
|
||||||
|
// Alle 19 Genres von TMDB anzubieten hiesse: ein Filter, hinter dem
|
||||||
|
// zu zwei Dritteln nichts steht. Ein Knopf, der zu einer leeren Liste
|
||||||
|
// führt, ist schlimmer als kein Knopf.
|
||||||
|
app.MapGet("/api/v1/libraries/{id:guid}/genres", async (Guid id, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var bibliothek = await db.Libraries.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id);
|
||||||
|
if (bibliothek is null) return Results.NotFound();
|
||||||
|
|
||||||
|
var namen = bibliothek.Kind == Core.Libraries.LibraryKind.Shows
|
||||||
|
? await db.GenreLinks.AsNoTracking()
|
||||||
|
.Where(l => l.SeriesId != null
|
||||||
|
&& db.Series.Any(x => x.Id == l.SeriesId && x.LibraryId == id))
|
||||||
|
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id, (_, g) => g.Name)
|
||||||
|
.Distinct().OrderBy(n => n).ToListAsync()
|
||||||
|
: await db.GenreLinks.AsNoTracking()
|
||||||
|
.Where(l => l.MovieId != null
|
||||||
|
&& db.Movies.Any(m => m.Id == l.MovieId && m.LibraryId == id))
|
||||||
|
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id, (_, g) => g.Name)
|
||||||
|
.Distinct().OrderBy(n => n).ToListAsync();
|
||||||
|
|
||||||
|
return Results.Ok(namen);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// **Einzelabruf, weil ein Client nicht immer aus einer Liste kommt.**
|
||||||
|
// Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus
|
||||||
|
// „Weiterschauen" — in all diesen Fällen hat er nur die Kennung.
|
||||||
|
app.MapGet("/api/v1/movies/{id:guid}", async (
|
||||||
|
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var film = await db.Movies.AsNoTracking()
|
||||||
|
.Where(m => m.Id == id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (film is null) return Results.NotFound();
|
||||||
|
|
||||||
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
|
.Where(f => f.MovieId == id)
|
||||||
|
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration })
|
||||||
|
.ToListAsync();
|
||||||
|
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
||||||
|
var stände = await ZuständeZu(db, wer, filme: [id]);
|
||||||
|
|
||||||
|
return Results.Ok(new MovieResponse(
|
||||||
|
film.Id, film.Title, film.Year, film.Overview, film.PosterImage, film.BackdropImage,
|
||||||
|
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(),
|
||||||
|
stände.GetValueOrDefault(id),
|
||||||
|
film.LogoImage, film.RuntimeMinutes, film.Rating, film.Tagline, film.ContentRating,
|
||||||
|
await GenresZu(db, film: id, serie: null),
|
||||||
|
await MitwirkendeZu(db, film: id, serie: null)));
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapGet("/api/v1/series/{id:guid}", async (
|
||||||
|
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var serie = await db.Series.AsNoTracking()
|
||||||
|
.Where(x => x.Id == id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (serie is null) return Results.NotFound();
|
||||||
|
|
||||||
|
var folgen = await db.Episodes.AsNoTracking()
|
||||||
|
.Where(f => f.SeriesId == id)
|
||||||
|
.Select(f => new { f.Id, f.SeasonNumber })
|
||||||
|
.ToListAsync();
|
||||||
|
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
|
||||||
|
|
||||||
|
return Results.Ok(new SeriesResponse(
|
||||||
|
serie.Id, serie.Title, serie.Year, serie.Overview, serie.PosterImage, serie.BackdropImage,
|
||||||
|
folgen.Count, folgen.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
|
||||||
|
folgen.Count(f => !gesehen.Contains(f.Id)),
|
||||||
|
serie.LogoImage, serie.RuntimeMinutes, serie.Rating, serie.Tagline, serie.ContentRating,
|
||||||
|
await GenresZu(db, film: null, serie: id),
|
||||||
|
await MitwirkendeZu(db, film: null, serie: id)));
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
|
||||||
|
// Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann
|
||||||
|
// der Scan sie fand, nicht wann sie läuft.
|
||||||
|
app.MapGet("/api/v1/episodes/{id:guid}/next", async (
|
||||||
|
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
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 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()
|
||||||
|
.Where(d => d.EpisodeId == naechste.Id)
|
||||||
|
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration })
|
||||||
|
.ToListAsync();
|
||||||
|
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
|
var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]);
|
||||||
|
|
||||||
|
return Results.Ok(new EpisodeResponse(
|
||||||
|
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
|
||||||
|
naechste.Title, naechste.Overview, naechste.StillImage,
|
||||||
|
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
|
||||||
|
stände.GetValueOrDefault(naechste.Id)));
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
|
||||||
|
Guid id, ClaimsPrincipal wer, FlaroContext db, int? season) =>
|
||||||
{
|
{
|
||||||
if (!await db.Series.AnyAsync(s => s.Id == id)) return Results.NotFound();
|
if (!await db.Series.AnyAsync(s => s.Id == id)) return Results.NotFound();
|
||||||
|
|
||||||
@@ -164,11 +424,13 @@ public static class LibraryContentEndpoints
|
|||||||
.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 })
|
||||||
.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 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,
|
||||||
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())).ToList();
|
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
|
||||||
|
stände.GetValueOrDefault(f.Id))).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|||||||
@@ -45,12 +45,32 @@ public static class ManagementEndpoints
|
|||||||
var bibliotheken = app.MapGroup("/api/v1/libraries");
|
var bibliotheken = app.MapGroup("/api/v1/libraries");
|
||||||
|
|
||||||
// Lesen darf jeder Angemeldete — ohne die Liste zeigt die App nichts an.
|
// Lesen darf jeder Angemeldete — ohne die Liste zeigt die App nichts an.
|
||||||
|
//
|
||||||
|
// **Mit Zählwert.** Die Seitenleiste führt jede Bibliothek einzeln
|
||||||
|
// auf, und „Dokumentationen · 0" sagt mehr als ein Eintrag, hinter
|
||||||
|
// dem sich nach dem Klick nichts findet — da ist der Unterschied
|
||||||
|
// zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens
|
||||||
|
// sichtbar. Zwei Abfragen für alle zusammen, nicht eine je Zeile.
|
||||||
bibliotheken.MapGet("/", async (FlaroContext db) =>
|
bibliotheken.MapGet("/", async (FlaroContext db) =>
|
||||||
Results.Ok(await db.Libraries
|
{
|
||||||
.OrderBy(l => l.Name)
|
var liste = await db.Libraries.AsNoTracking().OrderBy(l => l.Name).ToListAsync();
|
||||||
.Select(l => new LibraryResponse(l.Id, l.Name, l.Kind.ToString(), l.Path))
|
|
||||||
.ToListAsync()))
|
var filme = await db.Movies.AsNoTracking()
|
||||||
.RequireAuthorization();
|
.GroupBy(m => m.LibraryId)
|
||||||
|
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
|
||||||
|
|
||||||
|
var serien = await db.Series.AsNoTracking()
|
||||||
|
.GroupBy(x => x.LibraryId)
|
||||||
|
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
|
||||||
|
|
||||||
|
return Results.Ok(liste.Select(l => new LibraryResponse(
|
||||||
|
l.Id, l.Name, l.Kind.ToString(), l.Path,
|
||||||
|
l.Kind == LibraryKind.Shows
|
||||||
|
? serien.GetValueOrDefault(l.Id)
|
||||||
|
: filme.GetValueOrDefault(l.Id))).ToList());
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
|
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
@@ -74,7 +94,45 @@ public static class ManagementEndpoints
|
|||||||
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));
|
new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path, 0));
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in
|
||||||
|
// Serien zu verwandeln; den Pfad zu ändern hiesse, einen ganzen
|
||||||
|
// Bestand gegen einen anderen zu tauschen, während die Einträge
|
||||||
|
// stehenbleiben. Beides ist kein Bearbeiten, sondern eine neue
|
||||||
|
// Bibliothek — und die legt man an.
|
||||||
|
bibliotheken.MapPut("/{id:guid}", async (
|
||||||
|
Guid id, RenameLibraryRequest anfrage, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var name = anfrage.Name.Trim();
|
||||||
|
if (name.Length == 0)
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["name"] = ["Ein Name darf nicht leer sein."],
|
||||||
|
});
|
||||||
|
|
||||||
|
var vorhanden = await db.Libraries.FindAsync(id);
|
||||||
|
if (vorhanden is null) return Results.NotFound();
|
||||||
|
|
||||||
|
vorhanden.Name = name;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.Ok(new LibraryResponse(
|
||||||
|
vorhanden.Id, vorhanden.Name, vorhanden.Kind.ToString(), vorhanden.Path, null));
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Die Mediendateien bleiben liegen.** Entfernt wird, was Flaro über
|
||||||
|
// sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim
|
||||||
|
// Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server,
|
||||||
|
// den man nicht noch einmal benutzt.
|
||||||
|
bibliotheken.MapDelete("/{id:guid}", async (Guid id, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var vorhanden = await db.Libraries.FindAsync(id);
|
||||||
|
if (vorhanden is null) return Results.NotFound();
|
||||||
|
|
||||||
|
db.Libraries.Remove(vorhanden);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.NoContent();
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Flaro.Api.Auth;
|
||||||
|
using Flaro.Core.Playback;
|
||||||
|
using Flaro.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>Fortschritt, Gesehen-Status, Favoriten — alles je Benutzer.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ein Titel wird über seine Kennung allein angesprochen</b>, ohne dass
|
||||||
|
/// der Aufrufer sagen muss, ob es ein Film oder eine Folge ist. Die App hat
|
||||||
|
/// diese Auskunft an der Stelle nicht — sie reicht durch, was eine Liste ihr
|
||||||
|
/// gegeben hat. Der Preis ist eine zusätzliche Abfrage, wenn es eine Folge
|
||||||
|
/// ist; der Gegenwert ist eine Schnittstelle, die man nicht falsch bedienen
|
||||||
|
/// kann.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class PlaybackEndpoints
|
||||||
|
{
|
||||||
|
private const int HöchstensJeSeite = 200;
|
||||||
|
|
||||||
|
public static void MapPlayback(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
MapZustand(app);
|
||||||
|
MapStartseite(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Melden
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static void MapZustand(IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var titel = app.MapGroup("/api/v1/items/{id:guid}").RequireAuthorization();
|
||||||
|
|
||||||
|
// Jede Meldung wird abgelegt — es gibt keine, die zu klein wäre.
|
||||||
|
titel.MapPost("/progress", async (
|
||||||
|
Guid id, ProgressRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
|
||||||
|
TimeProvider clock, PlaybackSessions laufende) =>
|
||||||
|
{
|
||||||
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var zustand = await Zustand(db, benutzer, id);
|
||||||
|
if (zustand is null) return Results.NotFound();
|
||||||
|
|
||||||
|
// Die Laufzeit vom Client hat Vorrang: er misst an der Fassung,
|
||||||
|
// die er gerade abspielt. Fehlt sie, hilft die Datei weiter —
|
||||||
|
// ohne Laufzeit gäbe es keinen Anteil und damit kein „gesehen".
|
||||||
|
var laenge = anfrage.RuntimeSeconds is { } sekunden and > 0
|
||||||
|
? TimeSpan.FromSeconds(sekunden)
|
||||||
|
: zustand.Runtime ?? await Dateilaufzeit(db, zustand);
|
||||||
|
|
||||||
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(anfrage.PositionSeconds), laenge);
|
||||||
|
|
||||||
|
// **Das Häkchen wird gesetzt, nicht genommen.** Wer einen
|
||||||
|
// gesehenen Film erneut anfängt, hat ihn trotzdem gesehen — und
|
||||||
|
// soll beides haben: das Häkchen und die neue Stelle.
|
||||||
|
if (ergebnis.Watched && !zustand.Watched)
|
||||||
|
{
|
||||||
|
zustand.Watched = true;
|
||||||
|
zustand.PlayCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
zustand.Position = ergebnis.Position;
|
||||||
|
zustand.Runtime = laenge;
|
||||||
|
zustand.LastPlayedAt = clock.GetUtcNow();
|
||||||
|
zustand.UpdatedAt = clock.GetUtcNow();
|
||||||
|
|
||||||
|
// Wer weitersieht, hat es sich offenbar anders überlegt.
|
||||||
|
if (ergebnis.Position > TimeSpan.Zero) zustand.Dismissed = false;
|
||||||
|
|
||||||
|
Nachfuehren(zustand);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// **Die Meldung ist zugleich das Lebenszeichen.** Jeder Client
|
||||||
|
// meldet beim Abspielen ohnehin alle paar Sekunden; daraus das
|
||||||
|
// Dashboard zu speisen braucht kein zweites Verfahren, das ein
|
||||||
|
// Client vergessen könnte. Bleibt die Meldung aus, läuft der
|
||||||
|
// Eintrag von selbst ab.
|
||||||
|
//
|
||||||
|
// `stopped` nimmt ihn sofort heraus — sonst stünde ein beendeter
|
||||||
|
// Film noch eine Minute als „läuft".
|
||||||
|
if (anfrage.Stopped == true) laufende.Beenden(Geraet(wer));
|
||||||
|
else await Mitschreiben(db, laufende, wer, benutzer, zustand, clock);
|
||||||
|
|
||||||
|
return Results.Ok(Abbild(zustand));
|
||||||
|
});
|
||||||
|
|
||||||
|
titel.MapPut("/watched", async (
|
||||||
|
Guid id, WatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||||
|
{
|
||||||
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var zustand = await Zustand(db, benutzer, id);
|
||||||
|
if (zustand is null) return Results.NotFound();
|
||||||
|
|
||||||
|
if (anfrage.Watched)
|
||||||
|
{
|
||||||
|
if (!zustand.Watched) zustand.PlayCount++;
|
||||||
|
zustand.Watched = true;
|
||||||
|
zustand.LastPlayedAt = clock.GetUtcNow();
|
||||||
|
// **Von Hand als gesehen markiert heisst: fertig damit.** Hier
|
||||||
|
// wäre eine stehengebliebene Stelle keine Hilfe, sondern eine
|
||||||
|
// Zeile in „Weiterschauen", die niemand bestellt hat.
|
||||||
|
zustand.Position = TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
zustand.Watched = false;
|
||||||
|
zustand.Position = TimeSpan.Zero;
|
||||||
|
zustand.Dismissed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
zustand.UpdatedAt = clock.GetUtcNow();
|
||||||
|
Nachfuehren(zustand);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.Ok(Abbild(zustand));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen.
|
||||||
|
titel.MapPost("/dismiss", async (
|
||||||
|
Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||||
|
{
|
||||||
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var zustand = await Zustand(db, benutzer, id);
|
||||||
|
if (zustand is null) return Results.NotFound();
|
||||||
|
|
||||||
|
zustand.Dismissed = true;
|
||||||
|
zustand.UpdatedAt = clock.GetUtcNow();
|
||||||
|
Nachfuehren(zustand);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.Ok(Abbild(zustand));
|
||||||
|
});
|
||||||
|
|
||||||
|
titel.MapPut("/favourite", async (
|
||||||
|
Guid id, FavouriteRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||||
|
{
|
||||||
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var zustand = await Zustand(db, benutzer, id);
|
||||||
|
if (zustand is null) return Results.NotFound();
|
||||||
|
|
||||||
|
zustand.Favourite = anfrage.Favourite;
|
||||||
|
zustand.UpdatedAt = clock.GetUtcNow();
|
||||||
|
Nachfuehren(zustand);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.Ok(Abbild(zustand));
|
||||||
|
});
|
||||||
|
|
||||||
|
titel.MapPut("/rating", async (
|
||||||
|
Guid id, RatingRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||||
|
{
|
||||||
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
if (anfrage.Rating is { } wert && wert is < 1 or > 10)
|
||||||
|
return Results.Problem("Eine Bewertung liegt zwischen 1 und 10.",
|
||||||
|
statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
var zustand = await Zustand(db, benutzer, id);
|
||||||
|
if (zustand is null) return Results.NotFound();
|
||||||
|
|
||||||
|
zustand.Rating = anfrage.Rating;
|
||||||
|
zustand.UpdatedAt = clock.GetUtcNow();
|
||||||
|
Nachfuehren(zustand);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.Ok(Abbild(zustand));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Startseite
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static void MapStartseite(IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var start = app.MapGroup("/api/v1/home").RequireAuthorization();
|
||||||
|
|
||||||
|
start.MapGet("/continue", async (ClaimsPrincipal wer, FlaroContext db, int take = 20) =>
|
||||||
|
{
|
||||||
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
|
||||||
|
// Die Regel steht in `ProgressRule`; was hier abgefragt wird, ist
|
||||||
|
// ihr festgehaltenes Ergebnis. Siehe `UserItemState.InProgress`.
|
||||||
|
var zustände = await db.UserItemStates.AsNoTracking()
|
||||||
|
.Where(z => z.UserId == benutzer && z.InProgress)
|
||||||
|
.OrderByDescending(z => z.UpdatedAt)
|
||||||
|
.Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new Page<ContinueEntry>(
|
||||||
|
await Eintraege(db, zustände), zustände.Count));
|
||||||
|
});
|
||||||
|
|
||||||
|
// **Die erste ungesehene Folge je angefangener Serie.**
|
||||||
|
//
|
||||||
|
// Ohne Überschneidung mit „Weiterschauen": was dort schon steht, weil
|
||||||
|
// es mitten drin liegt, taucht hier nicht noch einmal auf. Jellyfin
|
||||||
|
// zeigt beides doppelt; auf einer Startseite mit zwei Reihen
|
||||||
|
// nebeneinander sieht das nach einem Fehler aus.
|
||||||
|
start.MapGet("/next", async (ClaimsPrincipal wer, FlaroContext db, int take = 20) =>
|
||||||
|
{
|
||||||
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
|
||||||
|
// Welche Serien der Benutzer überhaupt angefasst hat. Ohne diese
|
||||||
|
// Einschränkung stünde die erste Folge jeder Serie der Mediathek
|
||||||
|
// hier — eine Reihe, die nichts über den Benutzer aussagt.
|
||||||
|
var angefasst = await db.UserItemStates.AsNoTracking()
|
||||||
|
.Where(z => z.UserId == benutzer && z.EpisodeId != null)
|
||||||
|
.Join(db.Episodes.AsNoTracking(), z => z.EpisodeId, f => f.Id,
|
||||||
|
(z, f) => new { f.SeriesId, z.UpdatedAt })
|
||||||
|
.GroupBy(x => x.SeriesId)
|
||||||
|
.Select(g => new { SeriesId = g.Key, Zuletzt = g.Max(x => x.UpdatedAt) })
|
||||||
|
.OrderByDescending(x => x.Zuletzt)
|
||||||
|
.Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (angefasst.Count == 0) return Results.Ok(new Page<ContinueEntry>([], 0));
|
||||||
|
|
||||||
|
var serienIds = angefasst.Select(x => x.SeriesId).ToList();
|
||||||
|
|
||||||
|
var folgen = await db.Episodes.AsNoTracking()
|
||||||
|
.Where(f => serienIds.Contains(f.SeriesId))
|
||||||
|
.Select(f => new { f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.Title, f.StillImage })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var folgenIds = folgen.Select(f => f.Id).ToList();
|
||||||
|
var zustände = await db.UserItemStates.AsNoTracking()
|
||||||
|
.Where(z => z.UserId == benutzer && z.EpisodeId != null && folgenIds.Contains(z.EpisodeId!.Value))
|
||||||
|
.ToDictionaryAsync(z => z.EpisodeId!.Value);
|
||||||
|
|
||||||
|
var serien = await db.Series.AsNoTracking()
|
||||||
|
.Where(x => serienIds.Contains(x.Id))
|
||||||
|
.Select(x => new { x.Id, x.Title, x.PosterImage })
|
||||||
|
.ToDictionaryAsync(x => x.Id);
|
||||||
|
|
||||||
|
var liste = new List<ContinueEntry>();
|
||||||
|
foreach (var x in angefasst)
|
||||||
|
{
|
||||||
|
var naechste = folgen
|
||||||
|
.Where(f => f.SeriesId == x.SeriesId)
|
||||||
|
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
||||||
|
.FirstOrDefault(f => zustände.GetValueOrDefault(f.Id) is not { Watched: true });
|
||||||
|
|
||||||
|
// Nichts offen — die Serie ist durch.
|
||||||
|
if (naechste is null) continue;
|
||||||
|
// Mittendrin: steht bereits in „Weiterschauen".
|
||||||
|
if (zustände.GetValueOrDefault(naechste.Id) is { InProgress: true }) continue;
|
||||||
|
if (!serien.TryGetValue(x.SeriesId, out var serie)) continue;
|
||||||
|
|
||||||
|
var zustand = zustände.GetValueOrDefault(naechste.Id);
|
||||||
|
liste.Add(new ContinueEntry(
|
||||||
|
naechste.Id, "episode", serie.Title, naechste.Title,
|
||||||
|
naechste.SeasonNumber, naechste.EpisodeNumber, null,
|
||||||
|
serie.PosterImage, naechste.StillImage ?? serie.PosterImage,
|
||||||
|
zustand is null ? Leer() : Abbild(zustand)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new Page<ContinueEntry>(liste, liste.Count));
|
||||||
|
});
|
||||||
|
|
||||||
|
// **Ähnliches — „weil du X gesehen hast".**
|
||||||
|
//
|
||||||
|
// Über die Genres der zuletzt gesehenen Titel, nicht über eine
|
||||||
|
// Empfehlungsmaschine. Ein Haushaltsserver hat keine Millionen
|
||||||
|
// Sehverläufe, aus denen sich etwas lernen liesse; was er hat, sind
|
||||||
|
// die Genres dessen, was dieser eine Mensch angefangen hat. Das ist
|
||||||
|
// eine schlichte Regel — und sie ist nachvollziehbar, was eine
|
||||||
|
// undurchschaubare Empfehlung nie ist.
|
||||||
|
start.MapGet("/suggestions", async (ClaimsPrincipal wer, FlaroContext db, int take = 20) =>
|
||||||
|
{
|
||||||
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
|
||||||
|
// Woran dieser Mensch zuletzt dran war — Filme wie Folgen.
|
||||||
|
var zuletzt = await db.UserItemStates.AsNoTracking()
|
||||||
|
.Where(z => z.UserId == benutzer && (z.Watched || z.Position > TimeSpan.Zero))
|
||||||
|
.OrderByDescending(z => z.UpdatedAt)
|
||||||
|
.Take(30)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (zuletzt.Count == 0) return Results.Ok(new Page<MovieResponse>([], 0));
|
||||||
|
|
||||||
|
var filmIds = zuletzt.Where(z => z.MovieId != null).Select(z => z.MovieId!.Value).ToList();
|
||||||
|
var folgenIds = zuletzt.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
|
||||||
|
var serienIds = await db.Episodes.AsNoTracking()
|
||||||
|
.Where(f => folgenIds.Contains(f.Id)).Select(f => f.SeriesId).Distinct().ToListAsync();
|
||||||
|
|
||||||
|
// Die Genres daraus, nach Häufigkeit: wer dreimal Science Fiction
|
||||||
|
// angefangen hat, meint Science Fiction.
|
||||||
|
var genres = await db.GenreLinks.AsNoTracking()
|
||||||
|
.Where(l => (l.MovieId != null && filmIds.Contains(l.MovieId!.Value))
|
||||||
|
|| (l.SeriesId != null && serienIds.Contains(l.SeriesId!.Value)))
|
||||||
|
.GroupBy(l => l.GenreId)
|
||||||
|
.OrderByDescending(g => g.Count())
|
||||||
|
.Select(g => g.Key)
|
||||||
|
.Take(5)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (genres.Count == 0) return Results.Ok(new Page<MovieResponse>([], 0));
|
||||||
|
|
||||||
|
// **Nichts vorschlagen, was schon angefasst wurde.** Ein
|
||||||
|
// Vorschlag, den man gestern gesehen hat, ist kein Vorschlag.
|
||||||
|
var beruehrt = filmIds.ToHashSet();
|
||||||
|
|
||||||
|
var treffer = await db.Movies.AsNoTracking()
|
||||||
|
.Where(m => !beruehrt.Contains(m.Id))
|
||||||
|
.Where(m => db.GenreLinks.Any(l => l.MovieId == m.Id && genres.Contains(l.GenreId)))
|
||||||
|
// Je mehr gemeinsame Genres, desto besser der Vorschlag.
|
||||||
|
.Select(m => new
|
||||||
|
{
|
||||||
|
Film = m,
|
||||||
|
Treffer = db.GenreLinks.Count(l => l.MovieId == m.Id && genres.Contains(l.GenreId)),
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Treffer)
|
||||||
|
.ThenByDescending(x => x.Film.Rating ?? 0)
|
||||||
|
.Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||||
|
.Select(x => x.Film)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var items = treffer.Select(m => new MovieResponse(
|
||||||
|
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||||
|
[], null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).ToList();
|
||||||
|
|
||||||
|
return Results.Ok(new Page<MovieResponse>(items, items.Count));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Zustand eines Titels, den noch niemand angefasst hat.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Lieber eine Antwort mit Nullen als <c>null</c>: die Reihe „Nächste
|
||||||
|
/// Folge" zeigt überwiegend Folgen ohne Stand, und ein Client, der für
|
||||||
|
/// jede davon einen Sonderfall braucht, vergisst ihn irgendwo.
|
||||||
|
/// </remarks>
|
||||||
|
private static ItemStateResponse Leer() => new(0, null, false, 0, false, null, false, null);
|
||||||
|
|
||||||
|
/// <summary>Macht aus Zuständen Einträge mit Titel und Bild.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// In zwei Abfragen statt in einer je Zeile: zwanzig Einträge wären sonst
|
||||||
|
/// zwanzig Abfragen, und SQLite führt die nacheinander aus.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<List<ContinueEntry>> Eintraege(FlaroContext db, List<UserItemState> zustände)
|
||||||
|
{
|
||||||
|
var filme = zustände.Where(z => z.MovieId != null).Select(z => z.MovieId!.Value).ToList();
|
||||||
|
var folgen = zustände.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
|
||||||
|
|
||||||
|
var filmdaten = await db.Movies.AsNoTracking().Where(m => filme.Contains(m.Id))
|
||||||
|
.Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage })
|
||||||
|
.ToDictionaryAsync(m => m.Id);
|
||||||
|
|
||||||
|
var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.Id))
|
||||||
|
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
|
||||||
|
{
|
||||||
|
f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage,
|
||||||
|
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage,
|
||||||
|
})
|
||||||
|
.ToDictionaryAsync(f => f.Id);
|
||||||
|
|
||||||
|
var liste = new List<ContinueEntry>(zustände.Count);
|
||||||
|
foreach (var z in zustände)
|
||||||
|
{
|
||||||
|
if (z.MovieId is { } film && filmdaten.TryGetValue(film, out var m))
|
||||||
|
liste.Add(new ContinueEntry(
|
||||||
|
m.Id, "movie", m.Title, null, null, null, m.Year,
|
||||||
|
m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z)));
|
||||||
|
else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f))
|
||||||
|
liste.Add(new ContinueEntry(
|
||||||
|
f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null,
|
||||||
|
f.PosterImage, f.StillImage ?? f.PosterImage, Abbild(z)));
|
||||||
|
}
|
||||||
|
return liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Gemeinsames
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Hält fest, was die Regel zu dieser Zeile sagt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Nach jeder Änderung aufzurufen. Die einzige Stelle, die
|
||||||
|
/// <see cref="UserItemState.InProgress"/> schreibt — gäbe es eine zweite,
|
||||||
|
/// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen.
|
||||||
|
/// </remarks>
|
||||||
|
private static void Nachfuehren(UserItemState zustand) =>
|
||||||
|
zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand);
|
||||||
|
|
||||||
|
/// <summary>Welches Gerät das ist — die Kennung seines Tokens.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Nicht der Benutzer: derselbe Mensch kann auf dem Fernseher und auf
|
||||||
|
/// dem Telefon gleichzeitig etwas laufen lassen, und beides gehört ins
|
||||||
|
/// Dashboard. Nicht der Gerätename: zwei iPhones heissen beide „iPhone".
|
||||||
|
/// </remarks>
|
||||||
|
private static string Geraet(ClaimsPrincipal wer) =>
|
||||||
|
wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim) ?? "unbekannt";
|
||||||
|
|
||||||
|
/// <summary>Trägt die laufende Wiedergabe ins Dashboard ein.</summary>
|
||||||
|
private static async Task Mitschreiben(
|
||||||
|
FlaroContext db, PlaybackSessions laufende, ClaimsPrincipal wer,
|
||||||
|
Guid benutzer, UserItemState zustand, TimeProvider clock)
|
||||||
|
{
|
||||||
|
string titel;
|
||||||
|
string? untertitel = null;
|
||||||
|
string? plakat;
|
||||||
|
|
||||||
|
if (zustand.MovieId is { } film)
|
||||||
|
{
|
||||||
|
var eintrag = await db.Movies.AsNoTracking()
|
||||||
|
.Where(m => m.Id == film)
|
||||||
|
.Select(m => new { m.Title, m.PosterImage })
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (eintrag is null) return;
|
||||||
|
titel = eintrag.Title;
|
||||||
|
plakat = eintrag.PosterImage;
|
||||||
|
}
|
||||||
|
else if (zustand.EpisodeId is { } folge)
|
||||||
|
{
|
||||||
|
var eintrag = await db.Episodes.AsNoTracking()
|
||||||
|
.Where(f => f.Id == folge)
|
||||||
|
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
|
||||||
|
{
|
||||||
|
SerienTitel = s.Title, s.PosterImage,
|
||||||
|
f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title,
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (eintrag is null) return;
|
||||||
|
titel = eintrag.SerienTitel;
|
||||||
|
untertitel = $"S{eintrag.SeasonNumber} · F{eintrag.EpisodeNumber}"
|
||||||
|
+ (eintrag.FolgenTitel is { Length: > 0 } n ? $" · {n}" : "");
|
||||||
|
plakat = eintrag.PosterImage;
|
||||||
|
}
|
||||||
|
else return;
|
||||||
|
|
||||||
|
laufende.Melden(new PlaybackSessions.Wiedergabe(
|
||||||
|
DeviceKey: Geraet(wer),
|
||||||
|
UserId: benutzer,
|
||||||
|
UserName: wer.FindFirstValue(ClaimTypes.Name) ?? "",
|
||||||
|
ItemId: zustand.MovieId ?? zustand.EpisodeId ?? Guid.Empty,
|
||||||
|
Title: titel,
|
||||||
|
SubTitle: untertitel,
|
||||||
|
PosterImage: plakat,
|
||||||
|
Position: zustand.Position,
|
||||||
|
Runtime: zustand.Runtime,
|
||||||
|
DeviceName: wer.FindFirstValue(TokenAuthenticationHandler.DeviceNameClaim) ?? "Unbekannt",
|
||||||
|
LastSeenAt: clock.GetUtcNow()));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Guid? Benutzer(ClaimsPrincipal wer) =>
|
||||||
|
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
||||||
|
|
||||||
|
/// <summary>Holt den Stand oder legt ihn an — <c>null</c>, wenn es den Titel nicht gibt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Erst Film, dann Folge.</b> Zwei Abfragen im schlechteren Fall. Eine
|
||||||
|
/// gemeinsame Tabelle für beides würde eine sparen und dafür an jeder
|
||||||
|
/// anderen Stelle im Modell kosten.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<UserItemState?> Zustand(FlaroContext db, Guid benutzer, Guid titel)
|
||||||
|
{
|
||||||
|
var vorhanden = await db.UserItemStates
|
||||||
|
.FirstOrDefaultAsync(z => z.UserId == benutzer && (z.MovieId == titel || z.EpisodeId == titel));
|
||||||
|
if (vorhanden is not null) return vorhanden;
|
||||||
|
|
||||||
|
if (await db.Movies.AnyAsync(m => m.Id == titel))
|
||||||
|
{
|
||||||
|
var neu = new UserItemState { UserId = benutzer, MovieId = titel };
|
||||||
|
db.UserItemStates.Add(neu);
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await db.Episodes.AnyAsync(f => f.Id == titel))
|
||||||
|
{
|
||||||
|
var neu = new UserItemState { UserId = benutzer, EpisodeId = titel };
|
||||||
|
db.UserItemStates.Add(neu);
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Laufzeit aus der längsten Datei hinter dem Titel.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Die längste, weil mehrere Fassungen vorliegen können und eine davon
|
||||||
|
/// eine beschnittene sein mag. Gegen die kürzere zu messen hiesse, einen
|
||||||
|
/// Film für gesehen zu halten, der noch zwanzig Minuten vor sich hat.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<TimeSpan?> Dateilaufzeit(FlaroContext db, UserItemState zustand) =>
|
||||||
|
await db.MediaFiles.AsNoTracking()
|
||||||
|
.Where(d => zustand.MovieId != null ? d.MovieId == zustand.MovieId : d.EpisodeId == zustand.EpisodeId)
|
||||||
|
.MaxAsync(d => (TimeSpan?)d.Duration);
|
||||||
|
|
||||||
|
internal static ItemStateResponse Abbild(UserItemState z) => new(
|
||||||
|
z.Position.TotalSeconds, z.Runtime?.TotalSeconds,
|
||||||
|
z.Watched, z.PlayCount, z.Favourite, z.Rating, z.Dismissed, z.LastPlayedAt);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Flaro.Core.Users;
|
using Flaro.Core.Users;
|
||||||
|
using Flaro.Core;
|
||||||
using Flaro.Metadata;
|
using Flaro.Metadata;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -16,11 +17,26 @@ public static class SystemEndpoints
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static readonly SemaphoreSlim EinrichtungsSchloss = new(1, 1);
|
private static readonly SemaphoreSlim EinrichtungsSchloss = new(1, 1);
|
||||||
|
|
||||||
|
/// <summary>Unter welchem Schlüssel der Servername liegt.</summary>
|
||||||
|
public const string NameSchlüssel = "server.name";
|
||||||
|
|
||||||
|
/// <summary>Wann dieser Prozess angefangen hat.</summary>
|
||||||
|
private static readonly DateTimeOffset Gestartet = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
public static void MapSystem(this IEndpointRouteBuilder app)
|
public static void MapSystem(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
app.MapGet("/api/v1/system/info", async (FlaroContext db) =>
|
app.MapGet("/api/v1/system/info", async (FlaroContext db) =>
|
||||||
Results.Ok(new SystemInfo(
|
Results.Ok(new SystemInfo(
|
||||||
Version: typeof(SystemEndpoints).Assembly.GetName().Version?.ToString(3) ?? "0.0.0",
|
Version: typeof(SystemEndpoints).Assembly.GetName().Version?.ToString(3) ?? "0.0.0",
|
||||||
|
// **Wie dieser Server heisst.** Ohne ihn zeigen die Clients
|
||||||
|
// den Hostnamen — „localhost", „192.168.1.50" —, und wer zwei
|
||||||
|
// Server hat, unterscheidet sie an einer Zahl.
|
||||||
|
//
|
||||||
|
// Vorgabe ist der Rechnername des Containers: besser als
|
||||||
|
// nichts und besser als „Flaro", weil davon jeder einen hat.
|
||||||
|
ServerName: (await db.Settings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(e => e.Key == NameSchlüssel))?.Value
|
||||||
|
?? Environment.MachineName,
|
||||||
NeedsSetup: !await db.Users.AnyAsync(),
|
NeedsSetup: !await db.Users.AnyAsync(),
|
||||||
// Ohne Anmeldung abrufbar: ein Client muss den Hinweis auch
|
// Ohne Anmeldung abrufbar: ein Client muss den Hinweis auch
|
||||||
// auf dem Anmeldebildschirm zeigen können.
|
// auf dem Anmeldebildschirm zeigen können.
|
||||||
@@ -30,6 +46,48 @@ public static class SystemEndpoints
|
|||||||
])))
|
])))
|
||||||
.AllowAnonymous();
|
.AllowAnonymous();
|
||||||
|
|
||||||
|
// **Wer schaut gerade was.**
|
||||||
|
//
|
||||||
|
// Nur für Administratoren: wer wann welchen Film anschaut, ist eine
|
||||||
|
// Auskunft über Menschen, nicht über den Server. In einem Haushalt
|
||||||
|
// mag das harmlos sein; die Vorgabe eines offenen Projekts darf es
|
||||||
|
// trotzdem nicht sein.
|
||||||
|
app.MapGet("/api/v1/system/sessions", (PlaybackSessions laufende) =>
|
||||||
|
Results.Ok(laufende.Laufende().Select(w => new SessionResponse(
|
||||||
|
w.UserName, w.DeviceName, w.ItemId, w.Title, w.SubTitle, w.PosterImage,
|
||||||
|
w.Position.TotalSeconds, w.Runtime?.TotalSeconds)).ToList()))
|
||||||
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Was der Server gerade tut.** Keine Kurven über zwei Minuten wie
|
||||||
|
// bei Plex — die bräuchten eine Zeitreihe im Speicher, und für die
|
||||||
|
// Frage „läuft gerade eine Umwandlung?" genügt eine Zahl.
|
||||||
|
app.MapGet("/api/v1/system/status", (
|
||||||
|
PlaybackSessions laufende, PackageCoordinator packer, ScanCoordinator scans,
|
||||||
|
FlaroContext db) =>
|
||||||
|
Results.Ok(new ServerStatusResponse(
|
||||||
|
Playing: laufende.Laufende().Count,
|
||||||
|
Packaging: packer.LaufendeAnzahl,
|
||||||
|
Scanning: scans.LaufendeAnzahl,
|
||||||
|
Uptime: (DateTimeOffset.UtcNow - Gestartet).TotalSeconds)))
|
||||||
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var name = anfrage.Name.Trim();
|
||||||
|
if (name.Length == 0)
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["name"] = ["Ein Name darf nicht leer sein."],
|
||||||
|
});
|
||||||
|
|
||||||
|
var vorhanden = await db.Settings.FirstOrDefaultAsync(e => e.Key == NameSchlüssel);
|
||||||
|
if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = NameSchlüssel, Value = name });
|
||||||
|
else vorhanden.Value = name;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.NoContent();
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) =>
|
app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8)
|
if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8)
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ public sealed class PackageCoordinator(
|
|||||||
|
|
||||||
public PackageState? State(Guid fileId) => _zustand.GetValueOrDefault(fileId);
|
public PackageState? State(Guid fileId) => _zustand.GetValueOrDefault(fileId);
|
||||||
|
|
||||||
|
/// <summary>Wie viele Umwandlungen gerade laufen — für das Dashboard.</summary>
|
||||||
|
public int LaufendeAnzahl => _zustand.Values.Count(z => z.Status == "running");
|
||||||
|
|
||||||
/// <summary>Wohin die umgepackte Datei kommt.</summary>
|
/// <summary>Wohin die umgepackte Datei kommt.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace Flaro.Api;
|
||||||
|
|
||||||
|
/// <summary>Was gerade läuft, je Gerät.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Im Arbeitsspeicher, nicht in der Datenbank.</b> „Wer schaut gerade
|
||||||
|
/// was" ist eine Frage über den Augenblick; sie zu speichern hiesse, nach
|
||||||
|
/// einem Neustart Wiedergaben anzuzeigen, die es nicht mehr gibt. Fällt der
|
||||||
|
/// Container aus, ist die Antwort ohnehin „niemand".
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Abgeleitet aus den Fortschrittsmeldungen, nicht aus einer eigenen
|
||||||
|
/// Anmeldung am Spieler.</b> Jeder Client meldet beim Abspielen ohnehin alle
|
||||||
|
/// paar Sekunden, wo er steht — wer das als Lebenszeichen liest, braucht
|
||||||
|
/// kein zweites Verfahren, das ein Client vergessen könnte. Bleibt die
|
||||||
|
/// Meldung aus, läuft der Eintrag von selbst ab.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class PlaybackSessions(TimeProvider clock)
|
||||||
|
{
|
||||||
|
/// <summary>Wie lange eine Wiedergabe ohne Meldung als laufend gilt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Die Clients melden alle zehn Sekunden. Eine Minute lässt Raum für
|
||||||
|
/// einen Aussetzer, ohne dass eine längst beendete Wiedergabe noch
|
||||||
|
/// minutenlang im Dashboard steht.
|
||||||
|
/// </remarks>
|
||||||
|
public static readonly TimeSpan Lebensdauer = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, Wiedergabe> _laufend = new();
|
||||||
|
|
||||||
|
public sealed record Wiedergabe(
|
||||||
|
string DeviceKey,
|
||||||
|
Guid UserId,
|
||||||
|
string UserName,
|
||||||
|
Guid ItemId,
|
||||||
|
string Title,
|
||||||
|
string? SubTitle,
|
||||||
|
string? PosterImage,
|
||||||
|
TimeSpan Position,
|
||||||
|
TimeSpan? Runtime,
|
||||||
|
string DeviceName,
|
||||||
|
DateTimeOffset LastSeenAt);
|
||||||
|
|
||||||
|
/// <summary>Hält fest, dass dieses Gerät gerade etwas abspielt.</summary>
|
||||||
|
public void Melden(Wiedergabe wiedergabe) =>
|
||||||
|
_laufend[wiedergabe.DeviceKey] = wiedergabe;
|
||||||
|
|
||||||
|
/// <summary>Nimmt eine Wiedergabe heraus — beim Beenden.</summary>
|
||||||
|
public void Beenden(string deviceKey) => _laufend.TryRemove(deviceKey, out _);
|
||||||
|
|
||||||
|
/// <summary>Was gerade läuft. Abgelaufenes fällt dabei heraus.</summary>
|
||||||
|
public IReadOnlyList<Wiedergabe> Laufende()
|
||||||
|
{
|
||||||
|
var grenze = clock.GetUtcNow() - Lebensdauer;
|
||||||
|
|
||||||
|
// Beiläufig aufräumen. Ein eigener Hintergrunddienst für eine
|
||||||
|
// Handvoll Einträge wäre Aufwand ohne Gegenwert.
|
||||||
|
foreach (var (schluessel, eintrag) in _laufend)
|
||||||
|
{
|
||||||
|
if (eintrag.LastSeenAt < grenze) _laufend.TryRemove(schluessel, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _laufend.Values.OrderBy(w => w.UserName).ThenBy(w => w.Title).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ using Flaro.Metadata;
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -92,6 +93,9 @@ builder.Services.AddScoped<MetadataEnricher>();
|
|||||||
builder.Services.AddScoped<MatchApplier>();
|
builder.Services.AddScoped<MatchApplier>();
|
||||||
builder.Services.AddSingleton<ScanCoordinator>();
|
builder.Services.AddSingleton<ScanCoordinator>();
|
||||||
builder.Services.AddSingleton<PackageCoordinator>();
|
builder.Services.AddSingleton<PackageCoordinator>();
|
||||||
|
// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage
|
||||||
|
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
|
||||||
|
builder.Services.AddSingleton<PlaybackSessions>();
|
||||||
builder.Services.AddProblemDetails();
|
builder.Services.AddProblemDetails();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
@@ -106,6 +110,66 @@ using (var bereich = app.Services.CreateScope())
|
|||||||
app.UseExceptionHandler();
|
app.UseExceptionHandler();
|
||||||
app.UseStatusCodePages();
|
app.UseStatusCodePages();
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
|
|
||||||
|
// --- Die Oberfläche -------------------------------------------------------
|
||||||
|
//
|
||||||
|
// **Vom Server selbst ausgeliefert, nicht von einem Proxy davor.** Damit
|
||||||
|
// liegen Seite und Schnittstelle unter derselben Adresse: kein CORS, kein
|
||||||
|
// Mischmasch aus http und https, und der Zugang bleibt beim Server statt im
|
||||||
|
// Browser. Der Node-Proxy aus der Jellyfin-Zeit wird dadurch überflüssig.
|
||||||
|
//
|
||||||
|
// **Vor der Anmeldung, und das ist kein Versehen.** Die Rückfallregel weiter
|
||||||
|
// oben verlangt für jede Anfrage ohne eigenen Endpunkt einen angemeldeten
|
||||||
|
// Benutzer — und `/assets/index.js` hat keinen. Stünde das hier unter der
|
||||||
|
// Anmeldung, bekäme ein neuer Besucher 401 auf genau das Skript, das ihm die
|
||||||
|
// Anmeldemaske zeichnen soll. Die Seite selbst ist kein Geheimnis; sie zeigt
|
||||||
|
// ohne gültigen Zugang nichts als ein Anmeldefeld.
|
||||||
|
var oberfläche = Path.Combine(app.Environment.ContentRootPath, "wwwroot");
|
||||||
|
if (Directory.Exists(oberfläche))
|
||||||
|
{
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
|
||||||
|
// **Zwei Lebensdauern, und die Unterscheidung ist nötig.**
|
||||||
|
//
|
||||||
|
// Die Dateien unter `assets/` tragen eine Prüfsumme im Namen: ändert
|
||||||
|
// sich ihr Inhalt, ändert sich der Name. Sie dürfen deshalb für immer im
|
||||||
|
// Zwischenspeicher liegen — und sollen es auch, sonst lädt jeder Besuch
|
||||||
|
// dasselbe Skript erneut.
|
||||||
|
//
|
||||||
|
// `index.html` trägt keine. Sie nennt die Namen der Skripte, und wer sie
|
||||||
|
// aus dem Zwischenspeicher nimmt, bekommt nach einem Server-Update
|
||||||
|
// weiter die alte App — nicht kaputt, sondern **veraltet**, und das
|
||||||
|
// fällt niemandem auf. Genau das ist hier passiert: der Browser lud
|
||||||
|
// `index-C0K3V8eg.js`, während der Server längst `index-DomHN4_b.js`
|
||||||
|
// auslieferte.
|
||||||
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
|
{
|
||||||
|
OnPrepareResponse = zusammenhang =>
|
||||||
|
{
|
||||||
|
var name = zusammenhang.File.Name;
|
||||||
|
var kopf = zusammenhang.Context.Response.GetTypedHeaders();
|
||||||
|
|
||||||
|
if (name.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
kopf.CacheControl = new CacheControlHeaderValue
|
||||||
|
{
|
||||||
|
NoCache = true,
|
||||||
|
MustRevalidate = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
kopf.CacheControl = new CacheControlHeaderValue
|
||||||
|
{
|
||||||
|
Public = true,
|
||||||
|
MaxAge = TimeSpan.FromDays(365),
|
||||||
|
Extensions = { new NameValueHeaderValue("immutable") },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
@@ -116,8 +180,34 @@ app.MapLibraryContent();
|
|||||||
app.MapSettings();
|
app.MapSettings();
|
||||||
app.MapImages();
|
app.MapImages();
|
||||||
app.MapDelivery();
|
app.MapDelivery();
|
||||||
|
app.MapPlayback();
|
||||||
app.MapMatching();
|
app.MapMatching();
|
||||||
|
|
||||||
|
// **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile
|
||||||
|
// fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die
|
||||||
|
// Webseite mit Status 200 — ein vertippter Aufruf sieht dann aus wie ein
|
||||||
|
// Erfolg, und der Fehler fällt erst dort auf, wo HTML verarbeitet wird.
|
||||||
|
// Buchstaben schlagen Platzhalter, also gewinnt jeder echte Endpunkt hier.
|
||||||
|
app.Map("/api/{*rest}", () => Results.NotFound()).AllowAnonymous();
|
||||||
|
|
||||||
|
// Alles Übrige bekommt die Seite — der Router läuft im Browser. Ohne das
|
||||||
|
// endet ein Neuladen auf /verwaltung in einem 404.
|
||||||
|
if (Directory.Exists(oberfläche))
|
||||||
|
{
|
||||||
|
// Auch hier `no-cache`: der Rückfall liefert dieselbe `index.html`, und
|
||||||
|
// sie über diesen Weg zwischenspeichern zu lassen hätte genau denselben
|
||||||
|
// Effekt wie oben.
|
||||||
|
app.MapFallback((HttpContext zusammenhang) =>
|
||||||
|
{
|
||||||
|
zusammenhang.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
|
||||||
|
{
|
||||||
|
NoCache = true,
|
||||||
|
MustRevalidate = true,
|
||||||
|
};
|
||||||
|
return Results.File(Path.Combine(oberfläche, "index.html"), "text/html");
|
||||||
|
}).AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
/// <summary>Damit die Tests den Server hochfahren können.</summary>
|
/// <summary>Damit die Tests den Server hochfahren können.</summary>
|
||||||
|
|||||||
@@ -40,10 +40,14 @@ public sealed class ScanCoordinator(
|
|||||||
|
|
||||||
public ScanState? State(Guid libraryId) => _zustand.GetValueOrDefault(libraryId);
|
public ScanState? State(Guid libraryId) => _zustand.GetValueOrDefault(libraryId);
|
||||||
|
|
||||||
|
/// <summary>Wie viele Läufe gerade unterwegs sind — für das Dashboard.</summary>
|
||||||
|
public int LaufendeAnzahl =>
|
||||||
|
_zustand.Values.Count(z => z.Status is "running" or "enriching");
|
||||||
|
|
||||||
public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running";
|
public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running";
|
||||||
|
|
||||||
/// <summary>Startet einen Lauf — oder sagt, dass schon einer läuft.</summary>
|
/// <summary>Startet einen Lauf — oder sagt, dass schon einer läuft.</summary>
|
||||||
public bool TryStart(Guid libraryId)
|
public bool TryStart(Guid libraryId, bool erneut = false)
|
||||||
{
|
{
|
||||||
var start = new ScanState("running", clock.GetUtcNow(), null, null, null, null);
|
var start = new ScanState("running", clock.GetUtcNow(), null, null, null, null);
|
||||||
|
|
||||||
@@ -55,11 +59,11 @@ public sealed class ScanCoordinator(
|
|||||||
// Lief schon einer, steht hier dessen Zustand und nicht unserer.
|
// Lief schon einer, steht hier dessen Zustand und nicht unserer.
|
||||||
if (!ReferenceEquals(vorher, start)) return false;
|
if (!ReferenceEquals(vorher, start)) return false;
|
||||||
|
|
||||||
_ = Task.Run(() => Laufen(libraryId));
|
_ = Task.Run(() => Laufen(libraryId, erneut));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Laufen(Guid libraryId)
|
private async Task Laufen(Guid libraryId, bool erneut)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -73,7 +77,7 @@ public sealed class ScanCoordinator(
|
|||||||
_zustand[libraryId] = _zustand[libraryId] with { Status = "enriching", Report = bericht };
|
_zustand[libraryId] = _zustand[libraryId] with { Status = "enriching", Report = bericht };
|
||||||
|
|
||||||
var metadaten = await bereich.ServiceProvider
|
var metadaten = await bereich.ServiceProvider
|
||||||
.GetRequiredService<MetadataEnricher>().EnrichAsync(libraryId);
|
.GetRequiredService<MetadataEnricher>().EnrichAsync(libraryId, erneut);
|
||||||
|
|
||||||
_zustand[libraryId] = _zustand[libraryId] with
|
_zustand[libraryId] = _zustand[libraryId] with
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
namespace Flaro.Core.Library;
|
||||||
|
|
||||||
|
/// <summary>Ein Genre, einmal je Name.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Eine eigene Tabelle und keine Liste im Film.</b> „Science Fiction" als
|
||||||
|
/// Text in jedem Film stehen zu haben hiesse: filtern über <c>LIKE</c>,
|
||||||
|
/// Tippfehler als eigenes Genre, und bei einer Umbenennung vierhundert
|
||||||
|
/// Zeilen anfassen. So ist es eine Zeile und ein Verweis.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class Genre
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||||
|
public required string Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verbindet ein Genre mit einem Film oder einer Serie.</summary>
|
||||||
|
public sealed class GenreLink
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||||
|
public required Guid GenreId { get; init; }
|
||||||
|
public Guid? MovieId { get; init; }
|
||||||
|
public Guid? SeriesId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Jemand, der mitgewirkt hat.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Über die Kennung der Quelle geführt, nicht über den Namen: es gibt zwei
|
||||||
|
/// Schauspieler namens „Chris Evans", und sie über einen Kamm zu scheren
|
||||||
|
/// hiesse, dem einen die Filmografie des anderen anzuhängen.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class Person
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Die Kennung bei TMDB — der eindeutige Schlüssel.</summary>
|
||||||
|
public required int TmdbId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Das Porträt in der Bildablage.</summary>
|
||||||
|
public string? Image { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CreditRole { Actor, Director, Writer }
|
||||||
|
|
||||||
|
/// <summary>Wer in welcher Rolle woran beteiligt war.</summary>
|
||||||
|
public sealed class Credit
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||||
|
public required Guid PersonId { get; init; }
|
||||||
|
public Guid? MovieId { get; init; }
|
||||||
|
public Guid? SeriesId { get; init; }
|
||||||
|
public required CreditRole Role { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Die gespielte Rolle — nur bei Darstellern.</summary>
|
||||||
|
public string? Character { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Die Reihenfolge, wie die Quelle sie nennt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Die Besetzungsliste ist nicht alphabetisch: oben stehen die
|
||||||
|
/// Hauptrollen. Nach Namen zu sortieren hiesse, diese Aussage wegzuwerfen.
|
||||||
|
/// </remarks>
|
||||||
|
public int Order { get; set; }
|
||||||
|
}
|
||||||
@@ -121,6 +121,33 @@ public sealed class Movie
|
|||||||
|
|
||||||
public string? BackdropImage { get; set; }
|
public string? BackdropImage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Die Laufzeit laut Quelle, in Minuten.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Nicht die der Datei — die steht in <see cref="MediaFile.Duration"/>
|
||||||
|
/// und misst, was wirklich vorliegt. Beide sind nützlich und beide
|
||||||
|
/// verschieden: eine Fassung mit Werbepausen ist länger als der Film.
|
||||||
|
/// </remarks>
|
||||||
|
public int? RuntimeMinutes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Die Bewertung der Quelle, 0–10.</summary>
|
||||||
|
public double? Rating { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Die Werbezeile, etwa „Die Wirklichkeit ist eine Lüge".</summary>
|
||||||
|
public string? Tagline { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Die Altersfreigabe des eingestellten Landes, etwa „16".</summary>
|
||||||
|
public string? ContentRating { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Der Schriftzug des Titels in der Bildablage.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Freigestellt auf durchsichtigem Grund. Über dem Hintergrundbild sagt
|
||||||
|
/// er mehr als derselbe Titel in der Systemschrift — und nicht jeder
|
||||||
|
/// Titel hat einen.
|
||||||
|
/// </remarks>
|
||||||
|
public string? LogoImage { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +211,33 @@ public sealed class Series
|
|||||||
|
|
||||||
public string? BackdropImage { get; set; }
|
public string? BackdropImage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Die Laufzeit laut Quelle, in Minuten.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Nicht die der Datei — die steht in <see cref="MediaFile.Duration"/>
|
||||||
|
/// und misst, was wirklich vorliegt. Beide sind nützlich und beide
|
||||||
|
/// verschieden: eine Fassung mit Werbepausen ist länger als der Film.
|
||||||
|
/// </remarks>
|
||||||
|
public int? RuntimeMinutes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Die Bewertung der Quelle, 0–10.</summary>
|
||||||
|
public double? Rating { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Die Werbezeile, etwa „Die Wirklichkeit ist eine Lüge".</summary>
|
||||||
|
public string? Tagline { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Die Altersfreigabe des eingestellten Landes, etwa „16".</summary>
|
||||||
|
public string? ContentRating { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Der Schriftzug des Titels in der Bildablage.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Freigestellt auf durchsichtigem Grund. Über dem Hintergrundbild sagt
|
||||||
|
/// er mehr als derselbe Titel in der Systemschrift — und nicht jeder
|
||||||
|
/// Titel hat einen.
|
||||||
|
/// </remarks>
|
||||||
|
public string? LogoImage { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,78 @@ namespace Flaro.Core.Metadata;
|
|||||||
/// <summary>Ein Bild bei der Quelle — noch nicht heruntergeladen.</summary>
|
/// <summary>Ein Bild bei der Quelle — noch nicht heruntergeladen.</summary>
|
||||||
public sealed record RemoteImage(string Url);
|
public sealed record RemoteImage(string Url);
|
||||||
|
|
||||||
|
/// <summary>Jemand, der mitgewirkt hat.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>Character</c> ist nur bei Darstellern gefüllt. Regie und Drehbuch
|
||||||
|
/// haben keine Rolle, und ein leeres Feld dafür wäre ehrlicher als „—".
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record CreditMetadata(
|
||||||
|
int ProviderId, string Name, CreditKind Kind, string? Character, RemoteImage? Portrait, int Order);
|
||||||
|
|
||||||
|
public enum CreditKind { Actor, Director, Writer }
|
||||||
|
|
||||||
|
/// <summary>Was eine Quelle über einen Film weiss.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Gewachsen, und das hat einen Grund.</b> Mit Titel, Jahr, Beschreibung
|
||||||
|
/// und zwei Bildern lässt sich eine Kachel zeichnen und sonst nichts: keine
|
||||||
|
/// Detailseite, die mehr sagt als der Dateiname, keine Vorschläge, kein
|
||||||
|
/// Filter nach Genre. Alles drei hängt daran, dass hier mehr ankommt.
|
||||||
|
///
|
||||||
|
/// Jedes Feld darf fehlen. Ein Mitschnitt, den keine Quelle kennt, soll
|
||||||
|
/// trotzdem in der Mediathek stehen.
|
||||||
|
/// </remarks>
|
||||||
public sealed record MovieMetadata(
|
public sealed record MovieMetadata(
|
||||||
int ProviderId, string Title, int? Year, string? Overview,
|
int ProviderId, string Title, int? Year, string? Overview,
|
||||||
RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null);
|
RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null,
|
||||||
|
/// <summary>Laufzeit in Minuten, wie die Quelle sie nennt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Nicht die der Datei: die steht in <c>MediaFile.Duration</c> und misst,
|
||||||
|
/// was wirklich vorliegt. Beide sind nützlich und beide verschieden —
|
||||||
|
/// eine Fassung mit Werbepausen ist länger als der Film.
|
||||||
|
/// </remarks>
|
||||||
|
int? RuntimeMinutes = null,
|
||||||
|
/// <summary>Die Bewertung der Quelle, 0–10.</summary>
|
||||||
|
double? Rating = null,
|
||||||
|
string? Tagline = null,
|
||||||
|
/// <summary>Die Altersfreigabe des eingestellten Landes, etwa „16".</summary>
|
||||||
|
string? ContentRating = null,
|
||||||
|
IReadOnlyList<string>? Genres = null,
|
||||||
|
IReadOnlyList<CreditMetadata>? Credits = null,
|
||||||
|
/// <summary>Der Schriftzug des Titels, freigestellt auf durchsichtigem Grund.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Das, was eine Startseite teuer aussehen lässt.</b> „Blade Runner
|
||||||
|
/// 2049" in seiner eigenen Schrift über dem Hintergrundbild sagt mehr als
|
||||||
|
/// derselbe Titel in der Systemschrift — und es ist der Unterschied
|
||||||
|
/// zwischen einer Kachelwand und etwas, das wie ein Filmdienst aussieht.
|
||||||
|
///
|
||||||
|
/// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da.
|
||||||
|
/// </remarks>
|
||||||
|
RemoteImage? Logo = null);
|
||||||
|
|
||||||
public sealed record SeriesMetadata(
|
public sealed record SeriesMetadata(
|
||||||
int ProviderId, string Title, int? Year, string? Overview,
|
int ProviderId, string Title, int? Year, string? Overview,
|
||||||
RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null);
|
RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null,
|
||||||
|
int? RuntimeMinutes = null,
|
||||||
|
double? Rating = null,
|
||||||
|
string? Tagline = null,
|
||||||
|
string? ContentRating = null,
|
||||||
|
IReadOnlyList<string>? Genres = null,
|
||||||
|
IReadOnlyList<CreditMetadata>? Credits = null,
|
||||||
|
/// <summary>Der Schriftzug des Titels, freigestellt auf durchsichtigem Grund.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Das, was eine Startseite teuer aussehen lässt.</b> „Blade Runner
|
||||||
|
/// 2049" in seiner eigenen Schrift über dem Hintergrundbild sagt mehr als
|
||||||
|
/// derselbe Titel in der Systemschrift — und es ist der Unterschied
|
||||||
|
/// zwischen einer Kachelwand und etwas, das wie ein Filmdienst aussieht.
|
||||||
|
///
|
||||||
|
/// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da.
|
||||||
|
/// </remarks>
|
||||||
|
RemoteImage? Logo = null);
|
||||||
|
|
||||||
public sealed record EpisodeMetadata(string? Title, string? Overview, RemoteImage? Still);
|
public sealed record EpisodeMetadata(string? Title, string? Overview, RemoteImage? Still);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
namespace Flaro.Core.Playback;
|
||||||
|
|
||||||
|
/// <summary>Was eine Fortschrittsmeldung am Zustand ändert.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Keine untere Schwelle. Keine Mindestlaufzeit. Keine verworfene
|
||||||
|
/// Position.</b> Das ist der Anlass für diesen Server, und es lohnt, den
|
||||||
|
/// Vergleich festzuhalten, damit später niemand „das macht Jellyfin doch
|
||||||
|
/// genauso" denkt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Jellyfin rechnet in <c>UserDataManager.UpdatePlayState</c> mit drei Werten
|
||||||
|
/// (Vorgaben aus <c>ServerConfiguration</c>): <c>MinResumePct</c> 5,
|
||||||
|
/// <c>MaxResumePct</c> 90, <c>MinResumeDurationSeconds</c> 300. Unterhalb von
|
||||||
|
/// fünf Prozent setzt es die Position auf null — der Kommentar im Quelltext
|
||||||
|
/// lautet wörtlich „ignore progress during the beginning". Bei einem
|
||||||
|
/// Zweistünder sind das die ersten sechs Minuten. Oberhalb von neunzig
|
||||||
|
/// Prozent gilt der Titel als gesehen, und auch dann fällt die Position weg.
|
||||||
|
/// Und alles, was kürzer als fünf Minuten ist, gilt nach dem ersten
|
||||||
|
/// Lebenszeichen als vollständig gesehen, egal an welcher Stelle.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Flaro behält in allen drei Fällen die Position. Ein gesehener Titel und
|
||||||
|
/// eine Stelle, an der man weitermachen kann, schliessen einander nicht aus —
|
||||||
|
/// wer einen Film ein zweites Mal anfängt, soll beides haben.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ProgressRule
|
||||||
|
{
|
||||||
|
/// <summary>Ab welchem Anteil ein Titel als gesehen gilt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Eine Schwelle ist hier unvermeidlich</b>, und das ist kein
|
||||||
|
/// Widerspruch zum Obigen: Niemand sieht einen Film bis zur letzten
|
||||||
|
/// Sekunde des Abspanns. Ohne diese Regel stünde jeder zu Ende geschaute
|
||||||
|
/// Film für immer bei 98 % in „Weiterschauen".
|
||||||
|
///
|
||||||
|
/// Der Unterschied zu Jellyfin ist nicht, <i>dass</i> es diese Grenze
|
||||||
|
/// gibt, sondern was sie anrichtet: hier wird ein Häkchen gesetzt, dort
|
||||||
|
/// zusätzlich die Position weggeworfen.
|
||||||
|
/// </remarks>
|
||||||
|
public const double WatchedFraction = 0.90;
|
||||||
|
|
||||||
|
/// <summary>Was aus einer Meldung folgt.</summary>
|
||||||
|
/// <param name="Position">Die Stelle, die abgelegt wird — nie null gesetzt.</param>
|
||||||
|
/// <param name="Watched">Ob der Titel damit als gesehen gilt.</param>
|
||||||
|
public readonly record struct Outcome(TimeSpan Position, bool Watched);
|
||||||
|
|
||||||
|
/// <summary>Rechnet eine gemeldete Stelle in einen Zustand um.</summary>
|
||||||
|
///
|
||||||
|
/// <param name="position">Wo der Client steht.</param>
|
||||||
|
/// <param name="runtime">
|
||||||
|
/// Wie lang der Titel ist, falls bekannt. Ohne Laufzeit gibt es keinen
|
||||||
|
/// Anteil zu rechnen — dann wird die Stelle abgelegt und sonst nichts.
|
||||||
|
/// Zu raten wäre hier schlimmer als nichts zu tun: „gesehen", weil die
|
||||||
|
/// Laufzeit fehlt, ist der Fehler, den man nicht zurücknehmen kann, ohne
|
||||||
|
/// es zu merken.
|
||||||
|
/// </param>
|
||||||
|
public static Outcome Apply(TimeSpan position, TimeSpan? runtime)
|
||||||
|
{
|
||||||
|
// Eine negative Stelle gibt es nicht, und eine jenseits des Endes
|
||||||
|
// auch nicht. Beides kommt vor: der eine Player meldet beim Spulen
|
||||||
|
// kurz Unsinn, der andere beim Beenden die Laufzeit plus Rundung.
|
||||||
|
var stelle = position < TimeSpan.Zero ? TimeSpan.Zero : position;
|
||||||
|
|
||||||
|
if (runtime is not { } laenge || laenge <= TimeSpan.Zero)
|
||||||
|
return new Outcome(stelle, Watched: false);
|
||||||
|
|
||||||
|
if (stelle > laenge) stelle = laenge;
|
||||||
|
|
||||||
|
return new Outcome(stelle, Watched: stelle >= laenge * WatchedFraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ob ein Zustand in „Weiterschauen" gehört.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Dieselbe Regel wie in der Abfrage, an einer Stelle.</b> Die
|
||||||
|
/// Serverabfrage kann sie nicht aufrufen — sie wird zu SQL übersetzt —,
|
||||||
|
/// aber die Prüfung kann beides gegeneinanderhalten. Läuft das auseinander,
|
||||||
|
/// fällt es hier auf und nicht beim Benutzer.
|
||||||
|
/// </remarks>
|
||||||
|
public static bool BelongsInContinueWatching(UserItemState zustand) =>
|
||||||
|
zustand is { Dismissed: false, Position.Ticks: > 0 }
|
||||||
|
&& (zustand.Runtime is not { } laenge
|
||||||
|
|| laenge <= TimeSpan.Zero
|
||||||
|
|| zustand.Position < laenge * WatchedFraction);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
namespace Flaro.Core.Playback;
|
||||||
|
|
||||||
|
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Je Benutzer, nicht je Server.</b> Zwei Leute im selben Haushalt stehen
|
||||||
|
/// an verschiedenen Stellen desselben Films, und wessen Stand gilt, darf
|
||||||
|
/// nicht davon abhängen, wer zuletzt geschaut hat.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Der Verweis zeigt auf Film <i>oder</i> Folge</b>, wie bei
|
||||||
|
/// <c>MediaFile</c>. Eine gemeinsame Kennung mit einem Typfeld daneben wäre
|
||||||
|
/// kürzer, aber die Datenbank könnte dann nicht mehr dafür einstehen, dass
|
||||||
|
/// der Verweis irgendwohin zeigt.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class UserItemState
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||||
|
public required Guid UserId { get; init; }
|
||||||
|
|
||||||
|
public Guid? MovieId { get; init; }
|
||||||
|
public Guid? EpisodeId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Wo der Benutzer steht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Ab der ersten Sekunde, ohne untere Schwelle.</b> Das ist der
|
||||||
|
/// Unterschied, um dessentwillen dieser Server existiert — siehe
|
||||||
|
/// <see cref="ProgressRule"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public TimeSpan Position { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Wie lang der Titel ist, so wie der Client es gemeldet hat.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Hier abgelegt, damit „wie weit ist er" ohne einen Blick in die Dateien
|
||||||
|
/// zu beantworten ist. Bei zwei Fassungen eines Films — 4K und 1080p —
|
||||||
|
/// können sich die Laufzeiten um Sekunden unterscheiden; was zählt, ist
|
||||||
|
/// die, gegen die gemessen wurde.
|
||||||
|
/// </remarks>
|
||||||
|
public TimeSpan? Runtime { get; set; }
|
||||||
|
|
||||||
|
public bool Watched { get; set; }
|
||||||
|
public int PlayCount { get; set; }
|
||||||
|
public DateTimeOffset? LastPlayedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Aus „Weiterschauen" genommen, ohne als gesehen zu gelten.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Der Fall, für den es sonst nichts gibt.</b> Nach einer halben Stunde
|
||||||
|
/// keine Lust mehr — der Film ist weder gesehen noch ungesehen, er soll
|
||||||
|
/// nur nicht weiter oben stehen. „Als gesehen" wäre eine Lüge, „als
|
||||||
|
/// ungesehen" täte nichts: die Position bliebe.
|
||||||
|
///
|
||||||
|
/// Die Position bleibt auch hier erhalten. Wer doch weitermacht, fängt
|
||||||
|
/// nicht von vorn an.
|
||||||
|
/// </remarks>
|
||||||
|
public bool Dismissed { get; set; }
|
||||||
|
|
||||||
|
public bool Favourite { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Eine eigene Bewertung von 1 bis 10, oder keine.</summary>
|
||||||
|
public int? Rating { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ob dieser Titel in „Weiterschauen" gehört.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Kein zweites Urteil, sondern das festgehaltene Ergebnis des
|
||||||
|
/// ersten.</b> Geschrieben wird es ausschliesslich aus
|
||||||
|
/// <see cref="ProgressRule.BelongsInContinueWatching"/>, bei jeder
|
||||||
|
/// Änderung an dieser Zeile.
|
||||||
|
///
|
||||||
|
/// Warum überhaupt eine Spalte: die Regel vergleicht die Stelle mit einem
|
||||||
|
/// Anteil der Laufzeit, und diese Rechnung lässt sich nicht nach SQL
|
||||||
|
/// übersetzen. Die Regel daneben ein zweites Mal als Abfrage zu schreiben
|
||||||
|
/// wäre die Variante, bei der beide irgendwann auseinanderlaufen — und
|
||||||
|
/// zwar unbemerkt, weil keine von beiden dabei falsch aussieht.
|
||||||
|
/// </remarks>
|
||||||
|
public bool InProgress { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Flaro.Core;
|
|||||||
using Flaro.Core.Auth;
|
using Flaro.Core.Auth;
|
||||||
using Flaro.Core.Libraries;
|
using Flaro.Core.Libraries;
|
||||||
using Flaro.Core.Library;
|
using Flaro.Core.Library;
|
||||||
|
using Flaro.Core.Playback;
|
||||||
using Flaro.Core.Users;
|
using Flaro.Core.Users;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -32,6 +33,11 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
public DbSet<Series> Series => Set<Series>();
|
public DbSet<Series> Series => Set<Series>();
|
||||||
public DbSet<Episode> Episodes => Set<Episode>();
|
public DbSet<Episode> Episodes => Set<Episode>();
|
||||||
public DbSet<MediaStream> MediaStreams => Set<MediaStream>();
|
public DbSet<MediaStream> MediaStreams => Set<MediaStream>();
|
||||||
|
public DbSet<UserItemState> UserItemStates => Set<UserItemState>();
|
||||||
|
public DbSet<Genre> Genres => Set<Genre>();
|
||||||
|
public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
|
||||||
|
public DbSet<Person> People => Set<Person>();
|
||||||
|
public DbSet<Credit> Credits => Set<Credit>();
|
||||||
|
|
||||||
/// <summary>Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.</summary>
|
/// <summary>Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.</summary>
|
||||||
///
|
///
|
||||||
@@ -122,6 +128,8 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
e.Property(x => x.Title).HasMaxLength(512);
|
e.Property(x => x.Title).HasMaxLength(512);
|
||||||
e.Property(x => x.SortTitleValue).HasMaxLength(512);
|
e.Property(x => x.SortTitleValue).HasMaxLength(512);
|
||||||
e.Property(x => x.ImdbId).HasMaxLength(32);
|
e.Property(x => x.ImdbId).HasMaxLength(32);
|
||||||
|
e.Property(x => x.Tagline).HasMaxLength(512);
|
||||||
|
e.Property(x => x.ContentRating).HasMaxLength(16);
|
||||||
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
|
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
@@ -133,6 +141,8 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
e.Property(x => x.Title).HasMaxLength(512);
|
e.Property(x => x.Title).HasMaxLength(512);
|
||||||
e.Property(x => x.SortTitleValue).HasMaxLength(512);
|
e.Property(x => x.SortTitleValue).HasMaxLength(512);
|
||||||
e.Property(x => x.ImdbId).HasMaxLength(32);
|
e.Property(x => x.ImdbId).HasMaxLength(32);
|
||||||
|
e.Property(x => x.Tagline).HasMaxLength(512);
|
||||||
|
e.Property(x => x.ContentRating).HasMaxLength(16);
|
||||||
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
|
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
@@ -150,6 +160,80 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
e.HasOne<MediaFile>().WithMany().HasForeignKey(x => x.MediaFileId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne<MediaFile>().WithMany().HasForeignKey(x => x.MediaFileId).OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
b.Entity<Genre>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
// Ein Name genau einmal. „Science Fiction" zweimal in der Tabelle
|
||||||
|
// hiesse: ein Filter, der die Hälfte übersieht.
|
||||||
|
e.HasIndex(x => x.Name).IsUnique();
|
||||||
|
e.Property(x => x.Name).HasMaxLength(64).UseCollation("NOCASE");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Entity<GenreLink>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.HasIndex(x => new { x.GenreId, x.MovieId }).IsUnique().HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
e.HasIndex(x => new { x.GenreId, x.SeriesId }).IsUnique().HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
e.HasIndex(x => x.MovieId);
|
||||||
|
e.HasIndex(x => x.SeriesId);
|
||||||
|
e.HasOne<Genre>().WithMany().HasForeignKey(x => x.GenreId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Entity<Person>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
// Über die Kennung der Quelle, nicht über den Namen: es gibt zwei
|
||||||
|
// Schauspieler namens „Chris Evans".
|
||||||
|
e.HasIndex(x => x.TmdbId).IsUnique();
|
||||||
|
e.Property(x => x.Name).HasMaxLength(256);
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Entity<Credit>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.HasIndex(x => x.MovieId);
|
||||||
|
e.HasIndex(x => x.SeriesId);
|
||||||
|
e.HasIndex(x => x.PersonId);
|
||||||
|
e.Property(x => x.Character).HasMaxLength(256);
|
||||||
|
e.Property(x => x.Role).HasConversion<string>().HasMaxLength(16);
|
||||||
|
e.HasOne<Person>().WithMany().HasForeignKey(x => x.PersonId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Entity<UserItemState>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
// **Je Benutzer und Titel genau eine Zeile.** Ohne diese Regel
|
||||||
|
// legen zwei Fortschrittsmeldungen, die sich überholen, zwei
|
||||||
|
// Zeilen an — und welche davon später gelesen wird, entscheidet
|
||||||
|
// der Zufall.
|
||||||
|
//
|
||||||
|
// Gefiltert, weil die jeweils andere Spalte leer ist: eine
|
||||||
|
// ungefilterte Regel über (UserId, MovieId) liesse zwar beliebig
|
||||||
|
// viele Folgen-Zeilen zu (NULL gilt als verschieden), aber das
|
||||||
|
// wäre Verlass auf eine Eigenheit statt auf eine Aussage.
|
||||||
|
e.HasIndex(x => new { x.UserId, x.MovieId }).IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
e.HasIndex(x => new { x.UserId, x.EpisodeId }).IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
// Der Weg von „Weiterschauen": die offenen Zeilen eines
|
||||||
|
// Benutzers, neueste zuerst. Ohne Index ein vollständiger
|
||||||
|
// Durchlauf je Aufruf der Startseite.
|
||||||
|
e.HasIndex(x => new { x.UserId, x.InProgress, x.UpdatedAt });
|
||||||
|
|
||||||
|
e.HasOne<User>().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
// Verschwindet ein Film aus der Mediathek, verschwindet auch der
|
||||||
|
// Stand dazu. Ein Fortschritt zu einem Titel, den es nicht mehr
|
||||||
|
// gibt, ist keine Erinnerung, sondern Datenmüll.
|
||||||
|
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
b.Entity<Episode>(e =>
|
b.Entity<Episode>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
|
|||||||
@@ -32,7 +32,18 @@ public sealed class MetadataEnricher(
|
|||||||
ISidecarStore beiwerk,
|
ISidecarStore beiwerk,
|
||||||
ILogger<MetadataEnricher> log)
|
ILogger<MetadataEnricher> log)
|
||||||
{
|
{
|
||||||
public async Task<EnrichReport> EnrichAsync(Guid libraryId, CancellationToken ct = default)
|
/// <param name="erneut">
|
||||||
|
/// <b>Auch holen, was schon zugeordnet ist.</b> Im Normalfall fragt ein
|
||||||
|
/// zweiter Lauf nichts erneut — das ist schneller und hält Handarbeit.
|
||||||
|
/// Aber wer die Sprache von Englisch auf Deutsch stellt, bekommt sonst
|
||||||
|
/// nie deutsche Titel: die Einstellung griffe erst beim nächsten neuen
|
||||||
|
/// Film. Dasselbe gilt, wenn eine Quelle Beschreibungen nachträgt.
|
||||||
|
///
|
||||||
|
/// <b>Nachgeholt wird über die bekannte Kennung, nicht über eine neue
|
||||||
|
/// Suche.</b> Eine Zuordnung von Hand bleibt damit stehen.
|
||||||
|
/// </param>
|
||||||
|
public async Task<EnrichReport> EnrichAsync(
|
||||||
|
Guid libraryId, bool erneut = false, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!await anbieter.IsConfiguredAsync(ct))
|
if (!await anbieter.IsConfiguredAsync(ct))
|
||||||
{
|
{
|
||||||
@@ -43,9 +54,9 @@ public sealed class MetadataEnricher(
|
|||||||
int filme = 0, serien = 0, folgen = 0;
|
int filme = 0, serien = 0, folgen = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
filme = await FilmeZuordnen(libraryId, ct);
|
filme = await FilmeZuordnen(libraryId, erneut, ct);
|
||||||
serien = await SerienZuordnen(libraryId, ct);
|
serien = await SerienZuordnen(libraryId, erneut, ct);
|
||||||
folgen = await FolgenZuordnen(libraryId, ct);
|
folgen = await FolgenZuordnen(libraryId, erneut, ct);
|
||||||
}
|
}
|
||||||
catch (MetadataCredentialsException fehler)
|
catch (MetadataCredentialsException fehler)
|
||||||
{
|
{
|
||||||
@@ -60,9 +71,11 @@ public sealed class MetadataEnricher(
|
|||||||
return new EnrichReport(filme, serien, folgen, Skipped: false, null);
|
return new EnrichReport(filme, serien, folgen, Skipped: false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<int> FilmeZuordnen(Guid libraryId, CancellationToken ct)
|
private async Task<int> FilmeZuordnen(Guid libraryId, bool erneut, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var offen = await db.Movies.Where(m => m.LibraryId == libraryId && m.TmdbId == null).ToListAsync(ct);
|
var offen = await db.Movies
|
||||||
|
.Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null))
|
||||||
|
.ToListAsync(ct);
|
||||||
var zugeordnet = 0;
|
var zugeordnet = 0;
|
||||||
|
|
||||||
foreach (var film in offen)
|
foreach (var film in offen)
|
||||||
@@ -91,7 +104,15 @@ public sealed class MetadataEnricher(
|
|||||||
film.Overview = gefunden.Overview;
|
film.Overview = gefunden.Overview;
|
||||||
film.PosterImage = await BildAblegen(gefunden.Poster, ct);
|
film.PosterImage = await BildAblegen(gefunden.Poster, ct);
|
||||||
film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
|
film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
|
||||||
|
film.LogoImage = await BildAblegen(gefunden.Logo, ct);
|
||||||
|
film.RuntimeMinutes = gefunden.RuntimeMinutes;
|
||||||
|
film.Rating = gefunden.Rating;
|
||||||
|
film.Tagline = gefunden.Tagline;
|
||||||
|
film.ContentRating = gefunden.ContentRating;
|
||||||
film.MatchLocked = festgelegt;
|
film.MatchLocked = festgelegt;
|
||||||
|
|
||||||
|
await GenresSetzen(gefunden.Genres, film: film.Id, serie: null, ct);
|
||||||
|
await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct);
|
||||||
zugeordnet++;
|
zugeordnet++;
|
||||||
|
|
||||||
if (datei is not null) await FilmBeiwerk(film, datei, ct);
|
if (datei is not null) await FilmBeiwerk(film, datei, ct);
|
||||||
@@ -121,9 +142,11 @@ public sealed class MetadataEnricher(
|
|||||||
return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo));
|
return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<int> SerienZuordnen(Guid libraryId, CancellationToken ct)
|
private async Task<int> SerienZuordnen(Guid libraryId, bool erneut, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var offen = await db.Series.Where(s => s.LibraryId == libraryId && s.TmdbId == null).ToListAsync(ct);
|
var offen = await db.Series
|
||||||
|
.Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null))
|
||||||
|
.ToListAsync(ct);
|
||||||
var zugeordnet = 0;
|
var zugeordnet = 0;
|
||||||
|
|
||||||
foreach (var serie in offen)
|
foreach (var serie in offen)
|
||||||
@@ -153,6 +176,14 @@ public sealed class MetadataEnricher(
|
|||||||
serie.Overview = gefunden.Overview;
|
serie.Overview = gefunden.Overview;
|
||||||
serie.PosterImage = await BildAblegen(gefunden.Poster, ct);
|
serie.PosterImage = await BildAblegen(gefunden.Poster, ct);
|
||||||
serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
|
serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
|
||||||
|
serie.LogoImage = await BildAblegen(gefunden.Logo, ct);
|
||||||
|
serie.RuntimeMinutes = gefunden.RuntimeMinutes;
|
||||||
|
serie.Rating = gefunden.Rating;
|
||||||
|
serie.Tagline = gefunden.Tagline;
|
||||||
|
serie.ContentRating = gefunden.ContentRating;
|
||||||
|
|
||||||
|
await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct);
|
||||||
|
await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct);
|
||||||
serie.MatchLocked = festgelegt;
|
serie.MatchLocked = festgelegt;
|
||||||
zugeordnet++;
|
zugeordnet++;
|
||||||
|
|
||||||
@@ -199,7 +230,7 @@ public sealed class MetadataEnricher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind.</summary>
|
/// <summary>Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind.</summary>
|
||||||
private async Task<int> FolgenZuordnen(Guid libraryId, CancellationToken ct)
|
private async Task<int> FolgenZuordnen(Guid libraryId, bool erneut, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var serien = await db.Series
|
var serien = await db.Series
|
||||||
.Where(s => s.LibraryId == libraryId && s.TmdbId != null)
|
.Where(s => s.LibraryId == libraryId && s.TmdbId != null)
|
||||||
@@ -209,7 +240,7 @@ public sealed class MetadataEnricher(
|
|||||||
|
|
||||||
var kennungen = serien.Select(s => s.Id).ToList();
|
var kennungen = serien.Select(s => s.Id).ToList();
|
||||||
var offen = await db.Episodes
|
var offen = await db.Episodes
|
||||||
.Where(f => kennungen.Contains(f.SeriesId) && f.Overview == null)
|
.Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var zugeordnet = 0;
|
var zugeordnet = 0;
|
||||||
@@ -236,6 +267,143 @@ public sealed class MetadataEnricher(
|
|||||||
return zugeordnet;
|
return zugeordnet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Schreibt die Genres eines Titels — alte weg, neue hin.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Ersetzen statt ergänzen.</b> Nach einer Korrektur von Hand ist es
|
||||||
|
/// ein anderer Film; die Genres des vorigen stehen zu lassen hiesse, aus
|
||||||
|
/// zwei Zuordnungen eine falsche dritte zu machen.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task GenresSetzen(
|
||||||
|
IReadOnlyList<string>? namen, Guid? film, Guid? serie, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await db.GenreLinks
|
||||||
|
.Where(l => film != null ? l.MovieId == film : l.SeriesId == serie)
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
if (namen is not { Count: > 0 }) return;
|
||||||
|
|
||||||
|
foreach (var name in namen)
|
||||||
|
{
|
||||||
|
db.GenreLinks.Add(new GenreLink
|
||||||
|
{
|
||||||
|
GenreId = await GenreKennung(name, ct), MovieId = film, SeriesId = serie,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Kennung eines Genres — angelegt, falls es sie nicht gibt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Sofort gespeichert, und mit einem zweiten Blick nach dem Fehlschlag.</b>
|
||||||
|
///
|
||||||
|
/// Der Scan läuft je Bibliothek, und vier Bibliotheken scannen gleichzeitig
|
||||||
|
/// — jede mit eigenem Datenbankkontext. Zwei davon stossen auf „Drama",
|
||||||
|
/// keine sieht die ungespeicherte Zeile der anderen, und beide legen sie
|
||||||
|
/// an. Am Ende scheitert der ganze Lauf an
|
||||||
|
/// <c>UNIQUE constraint failed: Genres.Name</c>, und zwar mitsamt allem,
|
||||||
|
/// was sonst noch im Stapel stand: in der Mediathek erschien kein
|
||||||
|
/// einziger Titel.
|
||||||
|
///
|
||||||
|
/// Statt die Läufe zu serialisieren — das kostete bei zehn Bibliotheken
|
||||||
|
/// das Zehnfache an Zeit — wird der Fehlschlag hingenommen und die Zeile
|
||||||
|
/// des anderen benutzt. Sie steht ja genau dafür da.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<Guid> GenreKennung(string name, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var vorhanden = db.Genres.Local
|
||||||
|
.FirstOrDefault(g => string.Equals(g.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? await db.Genres.FirstOrDefaultAsync(g => g.Name == name, ct);
|
||||||
|
if (vorhanden is not null) return vorhanden.Id;
|
||||||
|
|
||||||
|
var neu = new Genre { Name = name };
|
||||||
|
db.Genres.Add(neu);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return neu.Id;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
// Ein anderer Lauf war schneller. Die eigene Zeile wieder
|
||||||
|
// vergessen, sonst versucht der nächste Speichervorgang sie
|
||||||
|
// erneut — und scheitert wieder.
|
||||||
|
db.Entry(neu).State = EntityState.Detached;
|
||||||
|
var fremd = await db.Genres.FirstOrDefaultAsync(g => g.Name == name, ct);
|
||||||
|
if (fremd is not null) return fremd.Id;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Schreibt Besetzung und Stab — ebenfalls ersetzend.</summary>
|
||||||
|
private async Task MitwirkendeSetzen(
|
||||||
|
IReadOnlyList<CreditMetadata>? mitwirkende, Guid? film, Guid? serie, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await db.Credits
|
||||||
|
.Where(c => film != null ? c.MovieId == film : c.SeriesId == serie)
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
if (mitwirkende is not { Count: > 0 }) return;
|
||||||
|
|
||||||
|
foreach (var eintrag in mitwirkende)
|
||||||
|
{
|
||||||
|
db.Credits.Add(new Credit
|
||||||
|
{
|
||||||
|
PersonId = await PersonKennung(eintrag, ct),
|
||||||
|
MovieId = film,
|
||||||
|
SeriesId = serie,
|
||||||
|
Role = eintrag.Kind switch
|
||||||
|
{
|
||||||
|
CreditKind.Director => CreditRole.Director,
|
||||||
|
CreditKind.Writer => CreditRole.Writer,
|
||||||
|
_ => CreditRole.Actor,
|
||||||
|
},
|
||||||
|
Character = eintrag.Character,
|
||||||
|
Order = eintrag.Order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Kennung einer Person — dieselbe Vorsicht wie beim Genre.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Dieselbe Lage: zwei Bibliotheken, zwei Kontexte, derselbe Schauspieler.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<Guid> PersonKennung(CreditMetadata eintrag, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var vorhanden = db.People.Local.FirstOrDefault(p => p.TmdbId == eintrag.ProviderId)
|
||||||
|
?? await db.People.FirstOrDefaultAsync(p => p.TmdbId == eintrag.ProviderId, ct);
|
||||||
|
|
||||||
|
if (vorhanden is not null)
|
||||||
|
{
|
||||||
|
// Der Name kann sich ändern, und ein Porträt kann nachkommen. Ein
|
||||||
|
// vorhandenes gegen nichts zu tauschen wäre dagegen ein Rückschritt.
|
||||||
|
vorhanden.Name = eintrag.Name;
|
||||||
|
vorhanden.Image ??= await BildAblegen(eintrag.Portrait, ct);
|
||||||
|
return vorhanden.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var neu = new Person
|
||||||
|
{
|
||||||
|
Name = eintrag.Name,
|
||||||
|
TmdbId = eintrag.ProviderId,
|
||||||
|
Image = await BildAblegen(eintrag.Portrait, ct),
|
||||||
|
};
|
||||||
|
db.People.Add(neu);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return neu.Id;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
db.Entry(neu).State = EntityState.Detached;
|
||||||
|
var fremd = await db.People.FirstOrDefaultAsync(p => p.TmdbId == eintrag.ProviderId, ct);
|
||||||
|
if (fremd is not null) return fremd.Id;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string?> BildAblegen(RemoteImage? bild, CancellationToken ct) =>
|
private async Task<string?> BildAblegen(RemoteImage? bild, CancellationToken ct) =>
|
||||||
bild is null ? null : await bilder.StoreAsync(bild, ct);
|
bild is null ? null : await bilder.StoreAsync(bild, ct);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,587 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Flaro.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(FlaroContext))]
|
||||||
|
[Migration("20261003192048_Sehfortschritt")]
|
||||||
|
partial class Sehfortschritt
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ApprovedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("ConsumedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("HandleFingerprint")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code");
|
||||||
|
|
||||||
|
b.HasIndex("HandleFingerprint")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("AuthCodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenFingerprint")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenFingerprint")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("DeviceTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Libraries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EpisodeNumber")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("SeasonNumber")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StillImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Episodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("BitRate")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Container")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("Duration")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("LibraryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("ModifiedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("ProbedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("SeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("SizeBytes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "Path")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MediaFiles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Channels")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Codec")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Height")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Index")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDefault")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsForced")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("MediaFileId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Width")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MediaFileId", "Index")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MediaStreams");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("BackdropImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ImdbId")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("LibraryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ParsedTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParsedYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PosterImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SortTitleValue")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "SortTitleValue");
|
||||||
|
|
||||||
|
b.ToTable("Movies");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("BackdropImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ImdbId")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("LibraryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ParsedTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParsedYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PosterImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SortTitleValue")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "SortTitleValue");
|
||||||
|
|
||||||
|
b.ToTable("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Dismissed")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Favourite")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("InProgress")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPlayedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("PlayCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("Position")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Rating")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("Runtime")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Watched")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "EpisodeId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "MovieId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "InProgress", "UpdatedAt");
|
||||||
|
|
||||||
|
b.ToTable("UserItemStates");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.ToTable("Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Users.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdministrator")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.UseCollation("NOCASE");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Users.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.MediaFile", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MediaFileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Episode", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EpisodeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MovieId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Users.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Sehfortschritt : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "UserItemStates",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
EpisodeId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
Position = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
Runtime = table.Column<long>(type: "INTEGER", nullable: true),
|
||||||
|
Watched = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
PlayCount = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
LastPlayedAt = table.Column<long>(type: "INTEGER", nullable: true),
|
||||||
|
Dismissed = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
Favourite = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
Rating = table.Column<int>(type: "INTEGER", nullable: true),
|
||||||
|
InProgress = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_UserItemStates", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_UserItemStates_Episodes_EpisodeId",
|
||||||
|
column: x => x.EpisodeId,
|
||||||
|
principalTable: "Episodes",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_UserItemStates_Movies_MovieId",
|
||||||
|
column: x => x.MovieId,
|
||||||
|
principalTable: "Movies",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_UserItemStates_Users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UserItemStates_EpisodeId",
|
||||||
|
table: "UserItemStates",
|
||||||
|
column: "EpisodeId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UserItemStates_MovieId",
|
||||||
|
table: "UserItemStates",
|
||||||
|
column: "MovieId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UserItemStates_UserId_EpisodeId",
|
||||||
|
table: "UserItemStates",
|
||||||
|
columns: new[] { "UserId", "EpisodeId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UserItemStates_UserId_InProgress_UpdatedAt",
|
||||||
|
table: "UserItemStates",
|
||||||
|
columns: new[] { "UserId", "InProgress", "UpdatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UserItemStates_UserId_MovieId",
|
||||||
|
table: "UserItemStates",
|
||||||
|
columns: new[] { "UserId", "MovieId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "\"MovieId\" IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "UserItemStates");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,774 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Flaro.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(FlaroContext))]
|
||||||
|
[Migration("20261003210324_Katalog")]
|
||||||
|
partial class Katalog
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ApprovedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("ConsumedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("HandleFingerprint")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code");
|
||||||
|
|
||||||
|
b.HasIndex("HandleFingerprint")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("AuthCodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenFingerprint")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenFingerprint")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("DeviceTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Libraries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Character")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("PersonId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("PersonId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("Credits");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EpisodeNumber")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("SeasonNumber")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StillImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Episodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.UseCollation("NOCASE");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Genres");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("GenreId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("GenreId", "MovieId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("GenreId", "SeriesId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("GenreLinks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("BitRate")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Container")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("Duration")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("LibraryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("ModifiedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("ProbedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("SeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("SizeBytes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "Path")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MediaFiles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Channels")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Codec")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Height")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Index")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDefault")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsForced")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("MediaFileId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Width")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MediaFileId", "Index")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MediaStreams");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("BackdropImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ContentRating")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ImdbId")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("LibraryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("LogoImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ParsedTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParsedYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PosterImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<double?>("Rating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.Property<int?>("RuntimeMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SortTitleValue")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "SortTitleValue");
|
||||||
|
|
||||||
|
b.ToTable("Movies");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Image")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TmdbId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("People");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("BackdropImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ContentRating")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ImdbId")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("LibraryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("LogoImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ParsedTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParsedYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PosterImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<double?>("Rating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.Property<int?>("RuntimeMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SortTitleValue")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "SortTitleValue");
|
||||||
|
|
||||||
|
b.ToTable("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Dismissed")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Favourite")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("InProgress")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPlayedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("PlayCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("Position")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Rating")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("Runtime")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Watched")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "EpisodeId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "MovieId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "InProgress", "UpdatedAt");
|
||||||
|
|
||||||
|
b.ToTable("UserItemStates");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.ToTable("Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Users.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdministrator")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.UseCollation("NOCASE");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Users.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MovieId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Person", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PersonId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Genre", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("GenreId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MovieId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.MediaFile", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MediaFileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LibraryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Episode", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EpisodeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MovieId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Users.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Katalog : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ContentRating",
|
||||||
|
table: "Series",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 16,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "LogoImage",
|
||||||
|
table: "Series",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Rating",
|
||||||
|
table: "Series",
|
||||||
|
type: "REAL",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "RuntimeMinutes",
|
||||||
|
table: "Series",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Tagline",
|
||||||
|
table: "Series",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ContentRating",
|
||||||
|
table: "Movies",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 16,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "LogoImage",
|
||||||
|
table: "Movies",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Rating",
|
||||||
|
table: "Movies",
|
||||||
|
type: "REAL",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "RuntimeMinutes",
|
||||||
|
table: "Movies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Tagline",
|
||||||
|
table: "Movies",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Genres",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false, collation: "NOCASE")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Genres", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "People",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
|
||||||
|
TmdbId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Image = table.Column<string>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_People", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "GenreLinks",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
GenreId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
SeriesId = table.Column<Guid>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_GenreLinks", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GenreLinks_Genres_GenreId",
|
||||||
|
column: x => x.GenreId,
|
||||||
|
principalTable: "Genres",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GenreLinks_Movies_MovieId",
|
||||||
|
column: x => x.MovieId,
|
||||||
|
principalTable: "Movies",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GenreLinks_Series_SeriesId",
|
||||||
|
column: x => x.SeriesId,
|
||||||
|
principalTable: "Series",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Credits",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
PersonId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
SeriesId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
Role = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
|
||||||
|
Character = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||||
|
Order = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Credits", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Credits_Movies_MovieId",
|
||||||
|
column: x => x.MovieId,
|
||||||
|
principalTable: "Movies",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Credits_People_PersonId",
|
||||||
|
column: x => x.PersonId,
|
||||||
|
principalTable: "People",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Credits_Series_SeriesId",
|
||||||
|
column: x => x.SeriesId,
|
||||||
|
principalTable: "Series",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Credits_MovieId",
|
||||||
|
table: "Credits",
|
||||||
|
column: "MovieId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Credits_PersonId",
|
||||||
|
table: "Credits",
|
||||||
|
column: "PersonId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Credits_SeriesId",
|
||||||
|
table: "Credits",
|
||||||
|
column: "SeriesId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GenreLinks_GenreId_MovieId",
|
||||||
|
table: "GenreLinks",
|
||||||
|
columns: new[] { "GenreId", "MovieId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GenreLinks_GenreId_SeriesId",
|
||||||
|
table: "GenreLinks",
|
||||||
|
columns: new[] { "GenreId", "SeriesId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GenreLinks_MovieId",
|
||||||
|
table: "GenreLinks",
|
||||||
|
column: "MovieId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GenreLinks_SeriesId",
|
||||||
|
table: "GenreLinks",
|
||||||
|
column: "SeriesId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Genres_Name",
|
||||||
|
table: "Genres",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_People_TmdbId",
|
||||||
|
table: "People",
|
||||||
|
column: "TmdbId",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Credits");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "GenreLinks");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "People");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Genres");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ContentRating",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LogoImage",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Rating",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RuntimeMinutes",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Tagline",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ContentRating",
|
||||||
|
table: "Movies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LogoImage",
|
||||||
|
table: "Movies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Rating",
|
||||||
|
table: "Movies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RuntimeMinutes",
|
||||||
|
table: "Movies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Tagline",
|
||||||
|
table: "Movies");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,6 +137,44 @@ namespace Flaro.Data.Migrations
|
|||||||
b.ToTable("Libraries");
|
b.ToTable("Libraries");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Character")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("PersonId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("PersonId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("Credits");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
|
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -173,6 +211,58 @@ namespace Flaro.Data.Migrations
|
|||||||
b.ToTable("Episodes");
|
b.ToTable("Episodes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.UseCollation("NOCASE");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Genres");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("GenreId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("GenreId", "MovieId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("GenreId", "SeriesId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("GenreLinks");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -291,6 +381,10 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<string>("BackdropImage")
|
b.Property<string>("BackdropImage")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ContentRating")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("ImdbId")
|
b.Property<string>("ImdbId")
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -298,6 +392,9 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<Guid>("LibraryId")
|
b.Property<Guid>("LibraryId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("LogoImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<bool>("MatchLocked")
|
b.Property<bool>("MatchLocked")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -314,11 +411,21 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<string>("PosterImage")
|
b.Property<string>("PosterImage")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<double?>("Rating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.Property<int?>("RuntimeMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("SortTitleValue")
|
b.Property<string>("SortTitleValue")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(512)
|
.HasMaxLength(512)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(512)
|
.HasMaxLength(512)
|
||||||
@@ -337,6 +444,31 @@ namespace Flaro.Data.Migrations
|
|||||||
b.ToTable("Movies");
|
b.ToTable("Movies");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Image")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TmdbId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("People");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
|
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -349,6 +481,10 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<string>("BackdropImage")
|
b.Property<string>("BackdropImage")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ContentRating")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("ImdbId")
|
b.Property<string>("ImdbId")
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -356,6 +492,9 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<Guid>("LibraryId")
|
b.Property<Guid>("LibraryId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("LogoImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<bool>("MatchLocked")
|
b.Property<bool>("MatchLocked")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -372,11 +511,21 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<string>("PosterImage")
|
b.Property<string>("PosterImage")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<double?>("Rating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.Property<int?>("RuntimeMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("SortTitleValue")
|
b.Property<string>("SortTitleValue")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(512)
|
.HasMaxLength(512)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(512)
|
.HasMaxLength(512)
|
||||||
@@ -395,6 +544,70 @@ namespace Flaro.Data.Migrations
|
|||||||
b.ToTable("Series");
|
b.ToTable("Series");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Dismissed")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Favourite")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("InProgress")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPlayedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("PlayCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("Position")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Rating")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("Runtime")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Watched")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "EpisodeId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "MovieId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "InProgress", "UpdatedAt");
|
||||||
|
|
||||||
|
b.ToTable("UserItemStates");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
|
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Key")
|
b.Property<string>("Key")
|
||||||
@@ -451,6 +664,25 @@ namespace Flaro.Data.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MovieId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Person", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PersonId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
|
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Flaro.Core.Library.Series", null)
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
@@ -460,6 +692,25 @@ namespace Flaro.Data.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Genre", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("GenreId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MovieId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Flaro.Core.Libraries.Library", null)
|
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||||
@@ -495,6 +746,25 @@ namespace Flaro.Data.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Flaro.Core.Library.Episode", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EpisodeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MovieId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Users.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,19 @@ public sealed class TmdbClient(
|
|||||||
private const string PosterSize = "w500";
|
private const string PosterSize = "w500";
|
||||||
private const string BackdropSize = "w1280";
|
private const string BackdropSize = "w1280";
|
||||||
private const string StillSize = "w300";
|
private const string StillSize = "w300";
|
||||||
|
/// <summary>Porträts sind klein und viele — ein Kopf je Kachel.</summary>
|
||||||
|
private const string ProfileSize = "w185";
|
||||||
|
/// <summary>Schriftzüge sind breit und flach — w500 reicht auch gross.</summary>
|
||||||
|
private const string LogoSize = "w500";
|
||||||
|
|
||||||
|
/// <summary>Wie viele Darsteller je Titel übernommen werden.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// TMDB nennt bei grossen Produktionen über hundert. Eine Besetzungsliste,
|
||||||
|
/// die man scrollen muss, bis die Statisten kommen, hilft niemandem — und
|
||||||
|
/// jede Zeile ist ein Porträt, das heruntergeladen und abgelegt wird.
|
||||||
|
/// </remarks>
|
||||||
|
private const int HöchstensDarsteller = 20;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
@@ -58,7 +71,13 @@ public sealed class TmdbClient(
|
|||||||
{
|
{
|
||||||
var treffer = await SuchenAsync<TmdbMovie>("search/movie", title,
|
var treffer = await SuchenAsync<TmdbMovie>("search/movie", title,
|
||||||
year is { } j ? ("year", j.ToString()) : null, m => m.Title, ct);
|
year is { } j ? ("year", j.ToString()) : null, m => m.Title, ct);
|
||||||
return treffer is null ? null : await MitImdb(Als(treffer, title), "movie", ct);
|
if (treffer is null) return null;
|
||||||
|
|
||||||
|
// **Der Treffer der Suche taugt nur als Kennung.** `search/movie`
|
||||||
|
// liefert weder Laufzeit noch Genres noch Besetzung; dafür braucht es
|
||||||
|
// den Einzelabruf. Der kostet eine Anfrage — dieselbe, die vorher
|
||||||
|
// ohnehin für die IMDb-Kennung fällig war.
|
||||||
|
return await GetMovieAsync(treffer.Id, ct) ?? Als(treffer, title);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(
|
public async Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(
|
||||||
@@ -74,9 +93,36 @@ public sealed class TmdbClient(
|
|||||||
var einstellung = await einstellungen.GetAsync(ct);
|
var einstellung = await einstellungen.GetAsync(ct);
|
||||||
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
|
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
|
||||||
|
|
||||||
|
// **Alles in einer Anfrage.** `append_to_response` hängt Besetzung,
|
||||||
|
// Fremdkennungen und Freigaben an den Einzelabruf. Drei getrennte
|
||||||
|
// Anfragen je Titel wären bei vierhundert Filmen zwölfhundert — und
|
||||||
|
// TMDB begrenzt, wie oft man fragen darf.
|
||||||
var film = await HolenAsync<TmdbMovie>(
|
var film = await HolenAsync<TmdbMovie>(
|
||||||
$"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct);
|
$"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}" +
|
||||||
return film is null ? null : await MitImdb(Als(film, ""), "movie", ct);
|
"&append_to_response=credits,external_ids,release_dates,images" +
|
||||||
|
// **Sprachlos ist erwünscht.** Viele Schriftzüge tragen gar keine
|
||||||
|
// Sprache (`null`), weil sie keinen Text enthalten, der übersetzt
|
||||||
|
// werden müsste. Ohne diese Angabe liefert TMDB sie nicht mit.
|
||||||
|
$"&include_image_language={Uri.EscapeDataString(Sprachkürzel(einstellung.Language))},en,null",
|
||||||
|
einstellung, ct);
|
||||||
|
|
||||||
|
// **Kennung null gibt es bei TMDB nicht.** Antwortet etwas anderes
|
||||||
|
// als der erwartete Einzelabruf — ein Anmeldeportal, ein Proxy, eine
|
||||||
|
// Trefferliste —, liest der Dekoder daraus eine Null. Das als Treffer
|
||||||
|
// durchzulassen hiesse, einen Film mit der Kennung 0 anzulegen.
|
||||||
|
if (film is null or { Id: 0 }) return null;
|
||||||
|
|
||||||
|
return Als(film, "") with
|
||||||
|
{
|
||||||
|
ImdbId = Leer(film.ExternalIds?.ImdbId),
|
||||||
|
RuntimeMinutes = film.Runtime > 0 ? film.Runtime : null,
|
||||||
|
Rating = film.VoteAverage > 0 ? film.VoteAverage : null,
|
||||||
|
Tagline = Leer(film.Tagline),
|
||||||
|
ContentRating = Freigabe(film.ReleaseDates, Land(einstellung.Language)),
|
||||||
|
Genres = Namen(film.Genres),
|
||||||
|
Credits = Mitwirkende(film.Credits),
|
||||||
|
Logo = Schriftzug(film.Images, Sprachkürzel(einstellung.Language)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MovieMetadata Als(TmdbMovie roh, string rückfall) => new(
|
private static MovieMetadata Als(TmdbMovie roh, string rückfall) => new(
|
||||||
@@ -87,7 +133,8 @@ public sealed class TmdbClient(
|
|||||||
{
|
{
|
||||||
var treffer = await SuchenAsync<TmdbSeries>("search/tv", title,
|
var treffer = await SuchenAsync<TmdbSeries>("search/tv", title,
|
||||||
year is { } j ? ("first_air_date_year", j.ToString()) : null, s => s.Name, ct);
|
year is { } j ? ("first_air_date_year", j.ToString()) : null, s => s.Name, ct);
|
||||||
return treffer is null ? null : await MitImdb(Als(treffer, title), "tv", ct);
|
if (treffer is null) return null;
|
||||||
|
return await GetSeriesAsync(treffer.Id, ct) ?? Als(treffer, title);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(
|
public async Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(
|
||||||
@@ -104,39 +151,34 @@ public sealed class TmdbClient(
|
|||||||
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
|
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
|
||||||
|
|
||||||
var serie = await HolenAsync<TmdbSeries>(
|
var serie = await HolenAsync<TmdbSeries>(
|
||||||
$"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct);
|
$"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}" +
|
||||||
return serie is null ? null : await MitImdb(Als(serie, ""), "tv", ct);
|
"&append_to_response=credits,external_ids,content_ratings,images" +
|
||||||
|
$"&include_image_language={Uri.EscapeDataString(Sprachkürzel(einstellung.Language))},en,null",
|
||||||
|
einstellung, ct);
|
||||||
|
|
||||||
|
if (serie is null or { Id: 0 }) return null;
|
||||||
|
|
||||||
|
return Als(serie, "") with
|
||||||
|
{
|
||||||
|
ImdbId = Leer(serie.ExternalIds?.ImdbId),
|
||||||
|
// Die übliche Länge einer Folge, nicht die der ganzen Serie.
|
||||||
|
RuntimeMinutes = serie.EpisodeRunTime?.FirstOrDefault(l => l > 0),
|
||||||
|
Rating = serie.VoteAverage > 0 ? serie.VoteAverage : null,
|
||||||
|
Tagline = Leer(serie.Tagline),
|
||||||
|
ContentRating = Freigabe(serie.ContentRatings, Land(einstellung.Language)),
|
||||||
|
Genres = Namen(serie.Genres),
|
||||||
|
Credits = Mitwirkende(serie.Credits),
|
||||||
|
Logo = Schriftzug(serie.Images, Sprachkürzel(einstellung.Language)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SeriesMetadata Als(TmdbSeries roh, string rückfall) => new(
|
private static SeriesMetadata Als(TmdbSeries roh, string rückfall) => new(
|
||||||
roh.Id, roh.Name ?? rückfall, JahrAus(roh.FirstAirDate), Leer(roh.Overview),
|
roh.Id, roh.Name ?? rückfall, JahrAus(roh.FirstAirDate), Leer(roh.Overview),
|
||||||
Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize));
|
Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize));
|
||||||
|
|
||||||
/// <summary>Holt die IMDb-Kennung nach — eine Anfrage je neuem Treffer.</summary>
|
// Der eigene Abruf von `external_ids` ist entfallen: die Kennung hängt
|
||||||
///
|
// jetzt über `append_to_response` am Einzelabruf. Das spart eine Anfrage
|
||||||
/// <remarks>
|
// je Titel — bei vierhundert Filmen vierhundert.
|
||||||
/// <b>Die Suche bei TMDB liefert sie nicht mit.</b> Gebraucht wird sie für
|
|
||||||
/// die NFO-Datei: wer später auf ein anderes Werkzeug wechselt, findet
|
|
||||||
/// damit denselben Titel wieder, auch wenn das Werkzeug TMDB gar nicht
|
|
||||||
/// kennt.
|
|
||||||
///
|
|
||||||
/// Scheitert der Nachschlag, bleibt sie leer — das ist kein Grund, den
|
|
||||||
/// ganzen Treffer wegzuwerfen.
|
|
||||||
/// </remarks>
|
|
||||||
private async Task<MovieMetadata> MitImdb(MovieMetadata film, string art, CancellationToken ct) =>
|
|
||||||
film with { ImdbId = await ImdbAsync(art, film.ProviderId, ct) };
|
|
||||||
|
|
||||||
private async Task<SeriesMetadata> MitImdb(SeriesMetadata serie, string art, CancellationToken ct) =>
|
|
||||||
serie with { ImdbId = await ImdbAsync(art, serie.ProviderId, ct) };
|
|
||||||
|
|
||||||
private async Task<string?> ImdbAsync(string art, int kennung, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var einstellung = await einstellungen.GetAsync(ct);
|
|
||||||
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
|
|
||||||
|
|
||||||
var fremde = await HolenAsync<TmdbExternalIds>($"{art}/{kennung}/external_ids", einstellung, ct);
|
|
||||||
return Leer(fremde?.ImdbId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<EpisodeMetadata?> FindEpisodeAsync(
|
public async Task<EpisodeMetadata?> FindEpisodeAsync(
|
||||||
int seriesProviderId, int season, int episode, CancellationToken ct = default)
|
int seriesProviderId, int season, int episode, CancellationToken ct = default)
|
||||||
@@ -261,6 +303,115 @@ public sealed class TmdbClient(
|
|||||||
return index < 0 ? pfadMitAbfrage : pfadMitAbfrage[..index] + "api_key=…";
|
return index < 0 ? pfadMitAbfrage : pfadMitAbfrage[..index] + "api_key=…";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Das Land aus einer Sprachangabe wie „de-DE".</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Freigaben sind national: die FSK gilt in Deutschland, die MPAA in den
|
||||||
|
/// USA, und TMDB führt beide nebeneinander. Steht kein Land dabei, ist
|
||||||
|
/// „US" die Angabe, die am häufigsten gefüllt ist — besser als keine.
|
||||||
|
/// </remarks>
|
||||||
|
private static string Land(string sprache)
|
||||||
|
{
|
||||||
|
var teile = sprache.Split('-');
|
||||||
|
return teile.Length > 1 && teile[1].Length == 2 ? teile[1].ToUpperInvariant() : "US";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>„de" aus „de-DE".</summary>
|
||||||
|
private static string Sprachkürzel(string sprache) =>
|
||||||
|
sprache.Split('-')[0].ToLowerInvariant();
|
||||||
|
|
||||||
|
/// <summary>Der Schriftzug, möglichst in der eingestellten Sprache.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Die Reihenfolge ist die Aussage.</b> Erst die eigene Sprache — ein
|
||||||
|
/// deutscher Filmtitel in deutscher Schrift —, dann der ohne Sprache
|
||||||
|
/// (meist ein reines Bildzeichen, überall richtig), dann Englisch. Erst
|
||||||
|
/// innerhalb einer Stufe entscheidet die Bewertung bei TMDB, weil es dort
|
||||||
|
/// um Bildqualität geht und nicht mehr um die Sprache.
|
||||||
|
/// </remarks>
|
||||||
|
private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache)
|
||||||
|
{
|
||||||
|
var alle = bilder?.Logos?.Where(l => !string.IsNullOrWhiteSpace(l.FilePath)).ToList();
|
||||||
|
if (alle is not { Count: > 0 }) return null;
|
||||||
|
|
||||||
|
var beste = alle
|
||||||
|
.OrderBy(l => string.Equals(l.Language, sprache, StringComparison.OrdinalIgnoreCase) ? 0
|
||||||
|
: string.IsNullOrEmpty(l.Language) ? 1
|
||||||
|
: string.Equals(l.Language, "en", StringComparison.OrdinalIgnoreCase) ? 2
|
||||||
|
: 3)
|
||||||
|
.ThenByDescending(l => l.VoteAverage ?? 0)
|
||||||
|
.First();
|
||||||
|
|
||||||
|
return Bild(beste.FilePath, LogoSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Freigabe(TmdbReleaseDates? quelle, string land) =>
|
||||||
|
Leer(quelle?.Results?
|
||||||
|
.FirstOrDefault(r => string.Equals(r.Country, land, StringComparison.OrdinalIgnoreCase))?
|
||||||
|
.Releases?
|
||||||
|
.Select(r => r.Certification)
|
||||||
|
.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c)));
|
||||||
|
|
||||||
|
private static string? Freigabe(TmdbContentRatings? quelle, string land) =>
|
||||||
|
Leer(quelle?.Results?
|
||||||
|
.FirstOrDefault(r => string.Equals(r.Country, land, StringComparison.OrdinalIgnoreCase))?
|
||||||
|
.Rating);
|
||||||
|
|
||||||
|
private static IReadOnlyList<string>? Namen(List<TmdbGenre>? genres)
|
||||||
|
{
|
||||||
|
var liste = genres?
|
||||||
|
.Select(g => g.Name?.Trim())
|
||||||
|
.Where(n => !string.IsNullOrEmpty(n))
|
||||||
|
.Select(n => n!)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
return liste is { Count: > 0 } ? liste : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Darsteller, Regie und Drehbuch — in dieser Reihenfolge.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Aus dem Stab nur zwei Rollen.</b> TMDB führt dort auch Beleuchter
|
||||||
|
/// und Caterer; eine Detailseite, die alle nennt, nennt nichts. Regie und
|
||||||
|
/// Drehbuch sind das, wonach jemand sucht.
|
||||||
|
/// </remarks>
|
||||||
|
private static IReadOnlyList<CreditMetadata>? Mitwirkende(TmdbCredits? quelle)
|
||||||
|
{
|
||||||
|
if (quelle is null) return null;
|
||||||
|
|
||||||
|
var liste = new List<CreditMetadata>();
|
||||||
|
|
||||||
|
foreach (var d in (quelle.Cast ?? []).OrderBy(c => c.Order ?? int.MaxValue).Take(HöchstensDarsteller))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(d.Name)) continue;
|
||||||
|
liste.Add(new CreditMetadata(
|
||||||
|
d.Id, d.Name.Trim(), CreditKind.Actor, Leer(d.Character),
|
||||||
|
Bild(d.ProfilePath, ProfileSize), d.Order ?? liste.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
var stab = 0;
|
||||||
|
foreach (var m in quelle.Crew ?? [])
|
||||||
|
{
|
||||||
|
var rolle = m.Job?.Trim() switch
|
||||||
|
{
|
||||||
|
"Director" => CreditKind.Director,
|
||||||
|
"Writer" or "Screenplay" or "Story" => CreditKind.Writer,
|
||||||
|
_ => (CreditKind?)null,
|
||||||
|
};
|
||||||
|
if (rolle is not { } art || string.IsNullOrWhiteSpace(m.Name)) continue;
|
||||||
|
|
||||||
|
// Dieselbe Person steht im Stab mehrfach, wenn sie mehrere
|
||||||
|
// Aufgaben hatte — „Drehbuch" und „Story" sind bei TMDB zwei
|
||||||
|
// Zeilen und auf einer Detailseite ein Name.
|
||||||
|
if (liste.Any(e => e.ProviderId == m.Id && e.Kind == art)) continue;
|
||||||
|
|
||||||
|
liste.Add(new CreditMetadata(
|
||||||
|
m.Id, m.Name.Trim(), art, null, Bild(m.ProfilePath, ProfileSize), stab++));
|
||||||
|
}
|
||||||
|
|
||||||
|
return liste.Count > 0 ? liste : null;
|
||||||
|
}
|
||||||
|
|
||||||
private static RemoteImage? Bild(string? pfad, string grösse) =>
|
private static RemoteImage? Bild(string? pfad, string grösse) =>
|
||||||
string.IsNullOrWhiteSpace(pfad) ? null : new RemoteImage($"https://image.tmdb.org/t/p/{grösse}{pfad}");
|
string.IsNullOrWhiteSpace(pfad) ? null : new RemoteImage($"https://image.tmdb.org/t/p/{grösse}{pfad}");
|
||||||
|
|
||||||
@@ -280,7 +431,16 @@ public sealed class TmdbClient(
|
|||||||
[property: JsonPropertyName("release_date")] string? ReleaseDate,
|
[property: JsonPropertyName("release_date")] string? ReleaseDate,
|
||||||
[property: JsonPropertyName("overview")] string? Overview,
|
[property: JsonPropertyName("overview")] string? Overview,
|
||||||
[property: JsonPropertyName("poster_path")] string? PosterPath,
|
[property: JsonPropertyName("poster_path")] string? PosterPath,
|
||||||
[property: JsonPropertyName("backdrop_path")] string? BackdropPath);
|
[property: JsonPropertyName("backdrop_path")] string? BackdropPath,
|
||||||
|
// Die folgenden liefert nur der Einzelabruf, nicht die Suche.
|
||||||
|
[property: JsonPropertyName("runtime")] int? Runtime = null,
|
||||||
|
[property: JsonPropertyName("vote_average")] double? VoteAverage = null,
|
||||||
|
[property: JsonPropertyName("tagline")] string? Tagline = null,
|
||||||
|
[property: JsonPropertyName("genres")] List<TmdbGenre>? Genres = null,
|
||||||
|
[property: JsonPropertyName("credits")] TmdbCredits? Credits = null,
|
||||||
|
[property: JsonPropertyName("external_ids")] TmdbExternalIds? ExternalIds = null,
|
||||||
|
[property: JsonPropertyName("release_dates")] TmdbReleaseDates? ReleaseDates = null,
|
||||||
|
[property: JsonPropertyName("images")] TmdbImages? Images = null);
|
||||||
|
|
||||||
private sealed record TmdbSeries(
|
private sealed record TmdbSeries(
|
||||||
[property: JsonPropertyName("id")] int Id,
|
[property: JsonPropertyName("id")] int Id,
|
||||||
@@ -288,7 +448,59 @@ public sealed class TmdbClient(
|
|||||||
[property: JsonPropertyName("first_air_date")] string? FirstAirDate,
|
[property: JsonPropertyName("first_air_date")] string? FirstAirDate,
|
||||||
[property: JsonPropertyName("overview")] string? Overview,
|
[property: JsonPropertyName("overview")] string? Overview,
|
||||||
[property: JsonPropertyName("poster_path")] string? PosterPath,
|
[property: JsonPropertyName("poster_path")] string? PosterPath,
|
||||||
[property: JsonPropertyName("backdrop_path")] string? BackdropPath);
|
[property: JsonPropertyName("backdrop_path")] string? BackdropPath,
|
||||||
|
[property: JsonPropertyName("episode_run_time")] List<int>? EpisodeRunTime = null,
|
||||||
|
[property: JsonPropertyName("vote_average")] double? VoteAverage = null,
|
||||||
|
[property: JsonPropertyName("tagline")] string? Tagline = null,
|
||||||
|
[property: JsonPropertyName("genres")] List<TmdbGenre>? Genres = null,
|
||||||
|
[property: JsonPropertyName("credits")] TmdbCredits? Credits = null,
|
||||||
|
[property: JsonPropertyName("external_ids")] TmdbExternalIds? ExternalIds = null,
|
||||||
|
[property: JsonPropertyName("content_ratings")] TmdbContentRatings? ContentRatings = null,
|
||||||
|
[property: JsonPropertyName("images")] TmdbImages? Images = null);
|
||||||
|
|
||||||
|
private sealed record TmdbGenre([property: JsonPropertyName("name")] string? Name);
|
||||||
|
|
||||||
|
private sealed record TmdbImages(
|
||||||
|
[property: JsonPropertyName("logos")] List<TmdbImage>? Logos);
|
||||||
|
|
||||||
|
private sealed record TmdbImage(
|
||||||
|
[property: JsonPropertyName("file_path")] string? FilePath,
|
||||||
|
[property: JsonPropertyName("iso_639_1")] string? Language,
|
||||||
|
[property: JsonPropertyName("vote_average")] double? VoteAverage);
|
||||||
|
|
||||||
|
private sealed record TmdbCredits(
|
||||||
|
[property: JsonPropertyName("cast")] List<TmdbCast>? Cast,
|
||||||
|
[property: JsonPropertyName("crew")] List<TmdbCrew>? Crew);
|
||||||
|
|
||||||
|
private sealed record TmdbCast(
|
||||||
|
[property: JsonPropertyName("id")] int Id,
|
||||||
|
[property: JsonPropertyName("name")] string? Name,
|
||||||
|
[property: JsonPropertyName("character")] string? Character,
|
||||||
|
[property: JsonPropertyName("profile_path")] string? ProfilePath,
|
||||||
|
[property: JsonPropertyName("order")] int? Order);
|
||||||
|
|
||||||
|
private sealed record TmdbCrew(
|
||||||
|
[property: JsonPropertyName("id")] int Id,
|
||||||
|
[property: JsonPropertyName("name")] string? Name,
|
||||||
|
[property: JsonPropertyName("job")] string? Job,
|
||||||
|
[property: JsonPropertyName("profile_path")] string? ProfilePath);
|
||||||
|
|
||||||
|
private sealed record TmdbReleaseDates(
|
||||||
|
[property: JsonPropertyName("results")] List<TmdbReleaseCountry>? Results);
|
||||||
|
|
||||||
|
private sealed record TmdbReleaseCountry(
|
||||||
|
[property: JsonPropertyName("iso_3166_1")] string? Country,
|
||||||
|
[property: JsonPropertyName("release_dates")] List<TmdbRelease>? Releases);
|
||||||
|
|
||||||
|
private sealed record TmdbRelease(
|
||||||
|
[property: JsonPropertyName("certification")] string? Certification);
|
||||||
|
|
||||||
|
private sealed record TmdbContentRatings(
|
||||||
|
[property: JsonPropertyName("results")] List<TmdbContentRating>? Results);
|
||||||
|
|
||||||
|
private sealed record TmdbContentRating(
|
||||||
|
[property: JsonPropertyName("iso_3166_1")] string? Country,
|
||||||
|
[property: JsonPropertyName("rating")] string? Rating);
|
||||||
|
|
||||||
private sealed record TmdbEpisode(
|
private sealed record TmdbEpisode(
|
||||||
[property: JsonPropertyName("name")] string? Name,
|
[property: JsonPropertyName("name")] string? Name,
|
||||||
|
|||||||
@@ -104,7 +104,10 @@ public class CodeSignInTests
|
|||||||
await server.SetUpAndSignIn();
|
await server.SetUpAndSignIn();
|
||||||
var angefordert = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
var angefordert = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||||
|
|
||||||
var versuch = await server.Client.PostAsync($"/api/v1/auth/code/{angefordert!.Code}", null);
|
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
|
||||||
|
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
|
||||||
|
// den Angemeldeten — und bestünde nie wieder.
|
||||||
|
var versuch = await server.OhneGedaechtnis().PostAsync($"/api/v1/auth/code/{angefordert!.Code}", null);
|
||||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||||
|
|
||||||
var danach = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
|
var danach = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Flaro.Api.Endpoints;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>Wer gerade was schaut.</summary>
|
||||||
|
public class DashboardTests
|
||||||
|
{
|
||||||
|
private string _bestand = "";
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void Anlegen() => _bestand = Directory.CreateDirectory(
|
||||||
|
Path.Combine(Path.GetTempPath(), "flaro-dash-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void Aufraeumen()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { /* Kür */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Datei(string relativ)
|
||||||
|
{
|
||||||
|
var voll = Path.Combine(_bestand, relativ);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
|
||||||
|
File.WriteAllBytes(voll, new byte[2048]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HttpClient> Eingelesen(FlaroServer server, string art = "Movies")
|
||||||
|
{
|
||||||
|
var admin = await server.SetUpAndSignIn();
|
||||||
|
var client = server.AlsAngemeldeter(admin.Token);
|
||||||
|
|
||||||
|
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
|
||||||
|
new CreateLibraryRequest("Prüfbestand", art, _bestand));
|
||||||
|
angelegt.EnsureSuccessStatusCode();
|
||||||
|
var bibliothek = (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!;
|
||||||
|
|
||||||
|
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
|
||||||
|
for (var versuch = 0; versuch < 100; versuch++)
|
||||||
|
{
|
||||||
|
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
|
||||||
|
if (stand!.State is "finished") return client;
|
||||||
|
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
Assert.Fail("Der Scan wurde in fünf Sekunden nicht fertig.");
|
||||||
|
return null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Guid> ErsterFilm(HttpClient client) =>
|
||||||
|
(await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Items[0].Id;
|
||||||
|
|
||||||
|
/// <summary>Die Fortschrittsmeldung ist zugleich das Lebenszeichen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Kein zweites Verfahren, das ein Client vergessen könnte: er meldet
|
||||||
|
/// beim Abspielen ohnehin alle paar Sekunden, wo er steht.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Eine_Fortschrittsmeldung_erscheint_im_Dashboard()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
await client.PostAsJsonAsync($"/api/v1/items/{film}/progress",
|
||||||
|
new ProgressRequest(600, 7200));
|
||||||
|
|
||||||
|
var laufend = await client.GetFromJsonAsync<List<SessionResponse>>("/api/v1/system/sessions");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(laufend, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(laufend![0].Title, Is.EqualTo("Matrix"));
|
||||||
|
Assert.That(laufend[0].UserName, Is.EqualTo("guido"));
|
||||||
|
Assert.That(laufend[0].DeviceName, Is.EqualTo("iPhone"));
|
||||||
|
Assert.That(laufend[0].PositionSeconds, Is.EqualTo(600));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Beendet heisst sofort weg, nicht erst nach einer Minute.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Ohne das stünde ein beendeter Film noch eine Minute als „läuft" — und
|
||||||
|
/// wer gerade den Spieler geschlossen hat, sähe sich selbst beim Schauen
|
||||||
|
/// zu.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Eine_beendete_Wiedergabe_verschwindet_sofort()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
await client.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(600, 7200));
|
||||||
|
await client.PostAsJsonAsync($"/api/v1/items/{film}/progress",
|
||||||
|
new ProgressRequest(900, 7200, Stopped: true));
|
||||||
|
|
||||||
|
var laufend = await client.GetFromJsonAsync<List<SessionResponse>>("/api/v1/system/sessions");
|
||||||
|
|
||||||
|
Assert.That(laufend, Is.Empty);
|
||||||
|
// Der Fortschritt bleibt trotzdem erhalten — nur das „läuft gerade"
|
||||||
|
// ist vorbei.
|
||||||
|
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||||
|
Assert.That(seite!.Items[0].State!.PositionSeconds, Is.EqualTo(900));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Eine Folge nennt Serie und Nummer, nicht nur den Folgentitel.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Eine_Folge_erscheint_mit_Serie_und_Nummer()
|
||||||
|
{
|
||||||
|
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server, "Shows");
|
||||||
|
|
||||||
|
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
||||||
|
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||||||
|
$"/api/v1/series/{serien!.Items[0].Id}/episodes");
|
||||||
|
|
||||||
|
await client.PostAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/progress",
|
||||||
|
new ProgressRequest(400, 2700));
|
||||||
|
|
||||||
|
var laufend = await client.GetFromJsonAsync<List<SessionResponse>>("/api/v1/system/sessions");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(laufend![0].Title, Is.EqualTo("Dark"));
|
||||||
|
Assert.That(laufend[0].SubTitle, Does.Contain("S1").And.Contain("F2"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wer wann was schaut, geht nicht jeden an.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Nur_Administratoren_sehen_das_Dashboard()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var admin = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
|
||||||
|
|
||||||
|
(await admin.PostAsJsonAsync("/api/v1/users",
|
||||||
|
new CreateUserRequest("mitbewohner", "ein-langes-passwort", false))).EnsureSuccessStatusCode();
|
||||||
|
var gast = server.AlsAngemeldeter(
|
||||||
|
(await server.SignIn("mitbewohner", "ein-langes-passwort", "iPad", "geraet-2")).Token);
|
||||||
|
|
||||||
|
Assert.Multiple(async () =>
|
||||||
|
{
|
||||||
|
Assert.That((await gast.GetAsync("/api/v1/system/sessions")).StatusCode,
|
||||||
|
Is.EqualTo(HttpStatusCode.Forbidden));
|
||||||
|
Assert.That((await gast.GetAsync("/api/v1/system/status")).StatusCode,
|
||||||
|
Is.EqualTo(HttpStatusCode.Forbidden));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Zwei Geräte desselben Menschen sind zwei Wiedergaben.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Derselbe Mensch kann auf dem Fernseher und auf dem Telefon
|
||||||
|
/// gleichzeitig etwas laufen lassen; beides gehört ins Dashboard.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Zwei_Geraete_erscheinen_getrennt()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var telefon = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(telefon);
|
||||||
|
|
||||||
|
var fernseher = server.AlsAngemeldeter(
|
||||||
|
(await server.SignIn("guido", "ein-langes-passwort", "Apple TV", "geraet-tv")).Token);
|
||||||
|
|
||||||
|
await telefon.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(100, 7200));
|
||||||
|
await fernseher.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(3000, 7200));
|
||||||
|
|
||||||
|
var laufend = await telefon.GetFromJsonAsync<List<SessionResponse>>("/api/v1/system/sessions");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(laufend, Has.Count.EqualTo(2));
|
||||||
|
Assert.That(laufend!.Select(w => w.DeviceName),
|
||||||
|
Is.EquivalentTo(new[] { "iPhone", "Apple TV" }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Der_Serverzustand_zaehlt_was_gerade_laeuft()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
await client.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(60, 7200));
|
||||||
|
var stand = await client.GetFromJsonAsync<ServerStatusResponse>("/api/v1/system/status");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(stand!.Playing, Is.EqualTo(1));
|
||||||
|
Assert.That(stand.Scanning, Is.Zero, "der Scan ist längst durch");
|
||||||
|
Assert.That(stand.Uptime, Is.GreaterThan(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,13 +180,40 @@ public class DeliveryTests
|
|||||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Client fragt mit HEAD nach, ob das Umpacken fertig ist.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Der Fehler, den erst der Browser zeigte.</b> <c>MapGet</c> nimmt
|
||||||
|
/// HEAD nicht an. Die Anfrage fiel durch bis zum SPA-Rückfall, bekam
|
||||||
|
/// <c>200 text/html</c>, und der Spieler hielt die Webseite für den Film:
|
||||||
|
/// „MEDIA_ELEMENT_ERROR: Format error", 531 Byte lang.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task HEAD_auf_den_Download_antwortet_mit_dem_Download()
|
||||||
|
{
|
||||||
|
Film("Kopfpruefung (2021)", "mp4");
|
||||||
|
var (client, datei) = await Eingelesen();
|
||||||
|
|
||||||
|
var antwort = await client.SendAsync(
|
||||||
|
new HttpRequestMessage(HttpMethod.Head, $"/api/v1/files/{datei.Id}/download"));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
Assert.That(antwort.Content.Headers.ContentType?.MediaType, Is.EqualTo("video/mp4"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Ohne_Anmeldung_gibt_es_nichts()
|
public async Task Ohne_Anmeldung_gibt_es_nichts()
|
||||||
{
|
{
|
||||||
Film("Geschuetzt (2020)", "mp4");
|
Film("Geschuetzt (2020)", "mp4");
|
||||||
var (_, datei) = await Eingelesen();
|
var (_, datei) = await Eingelesen();
|
||||||
|
|
||||||
var antwort = await _server!.Client.GetAsync($"/api/v1/files/{datei.Id}/download");
|
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
|
||||||
|
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
|
||||||
|
// den Angemeldeten — und bestünde nie wieder.
|
||||||
|
var antwort = await _server!.OhneGedaechtnis().GetAsync($"/api/v1/files/{datei.Id}/download");
|
||||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using Flaro.Api.Endpoints;
|
using Flaro.Api.Endpoints;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
@@ -24,7 +25,12 @@ public sealed class FlaroServer : IDisposable
|
|||||||
|
|
||||||
public HttpClient Client { get; }
|
public HttpClient Client { get; }
|
||||||
|
|
||||||
public FlaroServer(Action<IServiceCollection>? dienste = null)
|
/// <param name="inhaltswurzel">
|
||||||
|
/// Ein anderes Stammverzeichnis für den Server — nur nötig, wenn ein Test
|
||||||
|
/// eine ausgelieferte Oberfläche braucht, denn die sucht der Server unter
|
||||||
|
/// <c>wwwroot</c> neben sich.
|
||||||
|
/// </param>
|
||||||
|
public FlaroServer(Action<IServiceCollection>? dienste = null, string? inhaltswurzel = null)
|
||||||
{
|
{
|
||||||
_verzeichnis = Path.Combine(Path.GetTempPath(), "flaro-test-" + Guid.CreateVersion7().ToString("N"));
|
_verzeichnis = Path.Combine(Path.GetTempPath(), "flaro-test-" + Guid.CreateVersion7().ToString("N"));
|
||||||
Environment.SetEnvironmentVariable("FLARO_DATA", Path.Combine(_verzeichnis, "config"));
|
Environment.SetEnvironmentVariable("FLARO_DATA", Path.Combine(_verzeichnis, "config"));
|
||||||
@@ -36,27 +42,32 @@ public sealed class FlaroServer : IDisposable
|
|||||||
Environment.SetEnvironmentVariable("FLARO_RATELIMIT_SIGNIN", "10000");
|
Environment.SetEnvironmentVariable("FLARO_RATELIMIT_SIGNIN", "10000");
|
||||||
|
|
||||||
_grundlage = new WebApplicationFactory<Program>();
|
_grundlage = new WebApplicationFactory<Program>();
|
||||||
_fabrik = _grundlage.WithWebHostBuilder(b => b.ConfigureServices(sammlung =>
|
_fabrik = _grundlage.WithWebHostBuilder(b =>
|
||||||
{
|
{
|
||||||
// **Ohne das dauert der ganze Lauf eine Viertelstunde.** Ein Host
|
if (inhaltswurzel is not null) b.UseContentRoot(inhaltswurzel);
|
||||||
// wartet beim Herunterfahren fünf Sekunden auf seine Dienste, und
|
|
||||||
// bei einem Testserver je Test summiert sich das auf mehr Zeit,
|
|
||||||
// als sämtliche Prüfungen zusammen brauchen. Hier gibt es nichts,
|
|
||||||
// worauf zu warten wäre.
|
|
||||||
sammlung.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromMilliseconds(50));
|
|
||||||
|
|
||||||
// **Keine Anfragen nach draussen, solange ein Test es nicht
|
b.ConfigureServices(sammlung =>
|
||||||
// ausdrücklich will.** Seit ein Schlüssel mitgeliefert wird, würde
|
{
|
||||||
// jeder Scan sonst wirklich bei TMDB nachfragen: die Tests hingen
|
// **Ohne das dauert der ganze Lauf eine Viertelstunde.** Ein Host
|
||||||
// am WLAN, und ihre Erwartungen an fremden Daten. „Der Untergang"
|
// wartet beim Herunterfahren fünf Sekunden auf seine Dienste, und
|
||||||
// hiess prompt „Downfall", weil die Voreinstellung Englisch ist.
|
// bei einem Testserver je Test summiert sich das auf mehr Zeit,
|
||||||
//
|
// als sämtliche Prüfungen zusammen brauchen. Hier gibt es nichts,
|
||||||
// Vor `dienste`, damit ein Test seinen eigenen Anbieter
|
// worauf zu warten wäre.
|
||||||
// nachschieben kann — die spätere Eintragung gewinnt.
|
sammlung.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromMilliseconds(50));
|
||||||
sammlung.AddScoped<IMetadataProvider, AbgeschalteterAnbieter>();
|
|
||||||
|
|
||||||
dienste?.Invoke(sammlung);
|
// **Keine Anfragen nach draussen, solange ein Test es nicht
|
||||||
}));
|
// ausdrücklich will.** Seit ein Schlüssel mitgeliefert wird, würde
|
||||||
|
// jeder Scan sonst wirklich bei TMDB nachfragen: die Tests hingen
|
||||||
|
// am WLAN, und ihre Erwartungen an fremden Daten. „Der Untergang"
|
||||||
|
// hiess prompt „Downfall", weil die Voreinstellung Englisch ist.
|
||||||
|
//
|
||||||
|
// Vor `dienste`, damit ein Test seinen eigenen Anbieter
|
||||||
|
// nachschieben kann — die spätere Eintragung gewinnt.
|
||||||
|
sammlung.AddScoped<IMetadataProvider, AbgeschalteterAnbieter>();
|
||||||
|
|
||||||
|
dienste?.Invoke(sammlung);
|
||||||
|
});
|
||||||
|
});
|
||||||
Client = _fabrik.CreateClient();
|
Client = _fabrik.CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +95,16 @@ public sealed class FlaroServer : IDisposable
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein Client, der Cookies weder behält noch mitschickt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Der gewöhnliche Testclient führt im Hintergrund einen Cookie-Behälter
|
||||||
|
/// mit. Wer prüfen will, <i>ob</i> ein Cookie wirkt, darf sich nicht
|
||||||
|
/// darauf verlassen — sonst prüft er den Behälter, nicht den Server.
|
||||||
|
/// </remarks>
|
||||||
|
public HttpClient OhneGedaechtnis() =>
|
||||||
|
_fabrik.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false });
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_fabrik.Dispose();
|
_fabrik.Dispose();
|
||||||
|
|||||||
@@ -383,7 +383,10 @@ public class ImageEndpointTests
|
|||||||
using var server = new FlaroServer();
|
using var server = new FlaroServer();
|
||||||
await server.SetUpAndSignIn();
|
await server.SetUpAndSignIn();
|
||||||
|
|
||||||
var antwort = await server.Client.GetAsync($"/api/v1/images/{new string('a', 64)}");
|
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
|
||||||
|
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
|
||||||
|
// den Angemeldeten — und bestünde nie wieder.
|
||||||
|
var antwort = await server.OhneGedaechtnis().GetAsync($"/api/v1/images/{new string('a', 64)}");
|
||||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Flaro.Api.Endpoints;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>Mehrere Mediatheken nebeneinander — wie bei Plex und Jellyfin.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Zwei Filmbibliotheken sind keine doppelte Filmbibliothek.</b> „Filme"
|
||||||
|
/// und „Dokumentationen" liegen in verschiedenen Ordnern, gehören in der
|
||||||
|
/// Seitenleiste getrennt und dürfen sich in keiner Liste vermischen. Dass
|
||||||
|
/// das Modell es kann, hiess bisher nicht, dass es geprüft war.
|
||||||
|
/// </remarks>
|
||||||
|
public class MultipleLibraryTests
|
||||||
|
{
|
||||||
|
private readonly List<string> _ordner = [];
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void Aufraeumen()
|
||||||
|
{
|
||||||
|
foreach (var o in _ordner)
|
||||||
|
{
|
||||||
|
try { Directory.Delete(o, recursive: true); } catch (IOException) { /* Kür */ }
|
||||||
|
}
|
||||||
|
_ordner.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein Ordner mit den genannten Dateien darin.</summary>
|
||||||
|
private string Bestand(params string[] dateien)
|
||||||
|
{
|
||||||
|
var wurzel = Directory.CreateDirectory(
|
||||||
|
Path.Combine(Path.GetTempPath(), "flaro-mehrere-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||||
|
_ordner.Add(wurzel);
|
||||||
|
|
||||||
|
foreach (var relativ in dateien)
|
||||||
|
{
|
||||||
|
var voll = Path.Combine(wurzel, relativ);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
|
||||||
|
File.WriteAllBytes(voll, new byte[2048]);
|
||||||
|
}
|
||||||
|
return wurzel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<LibraryResponse> Anlegen(
|
||||||
|
HttpClient client, string name, string art, string pfad)
|
||||||
|
{
|
||||||
|
var antwort = await client.PostAsJsonAsync("/api/v1/libraries",
|
||||||
|
new CreateLibraryRequest(name, art, pfad));
|
||||||
|
antwort.EnsureSuccessStatusCode();
|
||||||
|
return (await antwort.Content.ReadFromJsonAsync<LibraryResponse>())!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task Scannen(HttpClient client, Guid bibliothek)
|
||||||
|
{
|
||||||
|
(await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null)).EnsureSuccessStatusCode();
|
||||||
|
for (var versuch = 0; versuch < 100; versuch++)
|
||||||
|
{
|
||||||
|
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek}/scan");
|
||||||
|
if (stand!.State is "finished") return;
|
||||||
|
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
Assert.Fail("Der Scan wurde in fünf Sekunden nicht fertig.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Zwei_Filmbibliotheken_bleiben_getrennt()
|
||||||
|
{
|
||||||
|
var spielfilme = Bestand("Matrix (1999)/Matrix (1999).mkv", "Inception (2010)/Inception (2010).mkv");
|
||||||
|
var dokus = Bestand("Winged Migration (2001)/Winged Migration (2001).mkv");
|
||||||
|
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
|
||||||
|
|
||||||
|
var a = await Anlegen(client, "Filme", "Movies", spielfilme);
|
||||||
|
var b = await Anlegen(client, "Dokumentationen", "Movies", dokus);
|
||||||
|
await Scannen(client, a.Id);
|
||||||
|
await Scannen(client, b.Id);
|
||||||
|
|
||||||
|
var inA = await client.GetFromJsonAsync<Page<MovieResponse>>($"/api/v1/movies?library={a.Id}");
|
||||||
|
var inB = await client.GetFromJsonAsync<Page<MovieResponse>>($"/api/v1/movies?library={b.Id}");
|
||||||
|
var alle = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(inA!.Total, Is.EqualTo(2));
|
||||||
|
Assert.That(inB!.Total, Is.EqualTo(1));
|
||||||
|
Assert.That(inB.Items[0].Title, Is.EqualTo("Winged Migration"));
|
||||||
|
// Ohne Einschränkung alle zusammen — für die Suche quer durch alles.
|
||||||
|
Assert.That(alle!.Total, Is.EqualTo(3));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Seitenleiste braucht zu jeder Zeile eine Zahl.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Die_Liste_nennt_je_Bibliothek_die_Anzahl()
|
||||||
|
{
|
||||||
|
var filme = Bestand("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
var serien = Bestand("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv",
|
||||||
|
"Dark/Season 01/Dark - S01E02 - Luegen.mkv");
|
||||||
|
var leer = Bestand();
|
||||||
|
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
|
||||||
|
|
||||||
|
var a = await Anlegen(client, "Filme", "Movies", filme);
|
||||||
|
var b = await Anlegen(client, "Serien", "Shows", serien);
|
||||||
|
await Anlegen(client, "Noch nichts drin", "Movies", leer);
|
||||||
|
await Scannen(client, a.Id);
|
||||||
|
await Scannen(client, b.Id);
|
||||||
|
|
||||||
|
var liste = await client.GetFromJsonAsync<List<LibraryResponse>>("/api/v1/libraries");
|
||||||
|
var nachNamen = liste!.ToDictionary(l => l.Name);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(nachNamen["Filme"].ItemCount, Is.EqualTo(1));
|
||||||
|
// **Serien, nicht Folgen.** Zwei Folgen einer Serie sind eine Zeile.
|
||||||
|
Assert.That(nachNamen["Serien"].ItemCount, Is.EqualTo(1));
|
||||||
|
Assert.That(nachNamen["Noch nichts drin"].ItemCount, Is.Zero);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Eine_Bibliothek_laesst_sich_umbenennen()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
|
||||||
|
var angelegt = await Anlegen(client, "Flime", "Movies", Bestand());
|
||||||
|
|
||||||
|
var antwort = await client.PutAsJsonAsync($"/api/v1/libraries/{angelegt.Id}",
|
||||||
|
new RenameLibraryRequest("Filme"));
|
||||||
|
var geaendert = await antwort.Content.ReadFromJsonAsync<LibraryResponse>();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(geaendert!.Name, Is.EqualTo("Filme"));
|
||||||
|
Assert.That(geaendert.Path, Is.EqualTo(angelegt.Path), "der Pfad bleibt, wo er war");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Ein_leerer_Name_wird_abgelehnt()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
|
||||||
|
var angelegt = await Anlegen(client, "Filme", "Movies", Bestand());
|
||||||
|
|
||||||
|
var antwort = await client.PutAsJsonAsync($"/api/v1/libraries/{angelegt.Id}",
|
||||||
|
new RenameLibraryRequest(" "));
|
||||||
|
|
||||||
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Entfernt wird der Eintrag — nicht der Film auf der Platte.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Das ist die wichtigste Zusage dieses Servers.</b> Ein Server, der
|
||||||
|
/// beim Aufräumen in der Oberfläche fremde Mediendateien löscht, ist
|
||||||
|
/// einer, den man nicht noch einmal benutzt.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Loeschen_nimmt_die_Eintraege_und_laesst_die_Dateien_liegen()
|
||||||
|
{
|
||||||
|
var ordner = Bestand("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
var datei = Path.Combine(ordner, "Matrix (1999)", "Matrix (1999).mkv");
|
||||||
|
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
|
||||||
|
var angelegt = await Anlegen(client, "Filme", "Movies", ordner);
|
||||||
|
await Scannen(client, angelegt.Id);
|
||||||
|
|
||||||
|
Assert.That((await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Total,
|
||||||
|
Is.EqualTo(1), "vorher ist er da");
|
||||||
|
|
||||||
|
var geloescht = await client.DeleteAsync($"/api/v1/libraries/{angelegt.Id}");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(geloescht.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
|
||||||
|
Assert.That(File.Exists(datei), Is.True, "die Datei liegt noch da");
|
||||||
|
});
|
||||||
|
|
||||||
|
var danach = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||||
|
var liste = await client.GetFromJsonAsync<List<LibraryResponse>>("/api/v1/libraries");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(danach!.Total, Is.Zero);
|
||||||
|
Assert.That(liste, Is.Empty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Nur_Administratoren_duerfen_loeschen_und_umbenennen()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var admin = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
|
||||||
|
var angelegt = await Anlegen(admin, "Filme", "Movies", Bestand());
|
||||||
|
|
||||||
|
(await admin.PostAsJsonAsync("/api/v1/users",
|
||||||
|
new CreateUserRequest("mitbewohner", "ein-langes-passwort", false))).EnsureSuccessStatusCode();
|
||||||
|
var gast = server.AlsAngemeldeter(
|
||||||
|
(await server.SignIn("mitbewohner", "ein-langes-passwort", "iPad", "geraet-2")).Token);
|
||||||
|
|
||||||
|
var umbenannt = await gast.PutAsJsonAsync($"/api/v1/libraries/{angelegt.Id}",
|
||||||
|
new RenameLibraryRequest("Meins jetzt"));
|
||||||
|
var geloescht = await gast.DeleteAsync($"/api/v1/libraries/{angelegt.Id}");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(umbenannt.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||||||
|
Assert.That(geloescht.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>„Zuletzt hinzugefügt" braucht eine andere Reihenfolge als A–Z.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Nach_Aufnahme_sortiert_steht_das_Neueste_oben()
|
||||||
|
{
|
||||||
|
var ordner = Bestand("Zzz Zuletzt (2024)/Zzz Zuletzt (2024).mkv");
|
||||||
|
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
|
||||||
|
var angelegt = await Anlegen(client, "Filme", "Movies", ordner);
|
||||||
|
await Scannen(client, angelegt.Id);
|
||||||
|
|
||||||
|
// Ein zweiter Film, nachweislich später aufgenommen.
|
||||||
|
var voll = Path.Combine(ordner, "Aaa Zuerst (1999)", "Aaa Zuerst (1999).mkv");
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
|
||||||
|
await File.WriteAllBytesAsync(voll, new byte[2048]);
|
||||||
|
await Task.Delay(1100);
|
||||||
|
await Scannen(client, angelegt.Id);
|
||||||
|
|
||||||
|
var alphabetisch = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||||
|
var nachAufnahme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies?sort=added");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(alphabetisch!.Items.Select(m => m.Title),
|
||||||
|
Is.EqualTo(new[] { "Aaa Zuerst", "Zzz Zuletzt" }));
|
||||||
|
Assert.That(nachAufnahme!.Items[0].Title, Is.EqualTo("Aaa Zuerst"),
|
||||||
|
"zuletzt gefunden steht oben");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Flaro.Api.Endpoints;
|
||||||
|
using Flaro.Core.Playback;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>Fortschritt, Gesehen-Status und die Startseite — je Benutzer.</summary>
|
||||||
|
public class PlaybackTests
|
||||||
|
{
|
||||||
|
private string _bestand = "";
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void Anlegen() => _bestand = Directory.CreateDirectory(
|
||||||
|
Path.Combine(Path.GetTempPath(), "flaro-medien-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void Aufraeumen()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Datei(string relativ)
|
||||||
|
{
|
||||||
|
var voll = Path.Combine(_bestand, relativ);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
|
||||||
|
File.WriteAllBytes(voll, new byte[2048]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Legt eine Bibliothek an, scannt sie und meldet den Client.</summary>
|
||||||
|
private async Task<HttpClient> Eingelesen(FlaroServer server, string art = "Movies")
|
||||||
|
{
|
||||||
|
var admin = await server.SetUpAndSignIn();
|
||||||
|
var client = server.AlsAngemeldeter(admin.Token);
|
||||||
|
|
||||||
|
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
|
||||||
|
new CreateLibraryRequest("Prüfbestand", art, _bestand));
|
||||||
|
angelegt.EnsureSuccessStatusCode();
|
||||||
|
var bibliothek = (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!;
|
||||||
|
|
||||||
|
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
|
||||||
|
for (var versuch = 0; versuch < 100; versuch++)
|
||||||
|
{
|
||||||
|
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
|
||||||
|
if (stand!.State is "finished") return client;
|
||||||
|
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
Assert.Fail("Der Scan wurde in fünf Sekunden nicht fertig.");
|
||||||
|
return null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Guid> ErsterFilm(HttpClient client)
|
||||||
|
{
|
||||||
|
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||||
|
return seite!.Items[0].Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<HttpResponseMessage> Melden(HttpClient c, Guid id, double stelle, double? laenge) =>
|
||||||
|
c.PostAsJsonAsync($"/api/v1/items/{id}/progress", new ProgressRequest(stelle, laenge));
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Der Fall, um dessentwillen der Server existiert.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Jellyfin verwirft bei zwei Stunden alles unterhalb von sechs Minuten —
|
||||||
|
/// <c>MinResumePct</c> steht auf 5. Hier kommt die Stelle zurück.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Fuenf_Minuten_von_zwei_Stunden_bleiben_erhalten()
|
||||||
|
{
|
||||||
|
Datei("Der Untergang (2004)/Der Untergang (2004).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
var antwort = await Melden(client, film, 300, 7200);
|
||||||
|
var stand = (await antwort.Content.ReadFromJsonAsync<ItemStateResponse>())!;
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(stand.PositionSeconds, Is.EqualTo(300));
|
||||||
|
Assert.That(stand.Watched, Is.False);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Eine_Sekunde_wird_schon_gemerkt()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
await Melden(client, film, 1, 7200);
|
||||||
|
|
||||||
|
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||||
|
Assert.That(seite!.Items[0].State!.PositionSeconds, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Kurz_vor_Schluss_gilt_er_als_gesehen_und_behaelt_die_Stelle()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
var antwort = await Melden(client, film, 7000, 7200);
|
||||||
|
var stand = (await antwort.Content.ReadFromJsonAsync<ItemStateResponse>())!;
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(stand.Watched, Is.True);
|
||||||
|
Assert.That(stand.PositionSeconds, Is.EqualTo(7000));
|
||||||
|
Assert.That(stand.PlayCount, Is.EqualTo(1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Angefangenes_steht_in_Weiterschauen()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
await Melden(client, film, 600, 7200);
|
||||||
|
|
||||||
|
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(reihe!.Items, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(reihe.Items[0].Title, Is.EqualTo("Matrix"));
|
||||||
|
Assert.That(reihe.Items[0].Kind, Is.EqualTo("movie"));
|
||||||
|
Assert.That(reihe.Items[0].State.PositionSeconds, Is.EqualTo(600));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Durchgeschautes_verlaesst_Weiterschauen()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
await Melden(client, film, 600, 7200);
|
||||||
|
await Melden(client, film, 7000, 7200);
|
||||||
|
|
||||||
|
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
|
||||||
|
Assert.That(reihe!.Items, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein zweites Mal angefangen — mit Häkchen und Stelle zugleich.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Ein_gesehener_Film_kommt_beim_zweiten_Anlauf_zurueck()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
await Melden(client, film, 7000, 7200);
|
||||||
|
await Melden(client, film, 120, 7200);
|
||||||
|
|
||||||
|
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(reihe!.Items, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(reihe.Items[0].State.Watched, Is.True, "gesehen hat er ihn ja");
|
||||||
|
Assert.That(reihe.Items[0].State.PositionSeconds, Is.EqualTo(120));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Weglegen_nimmt_aus_der_Reihe_ohne_gesehen_zu_behaupten()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
await Melden(client, film, 1800, 7200);
|
||||||
|
(await client.PostAsync($"/api/v1/items/{film}/dismiss", null)).EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
|
||||||
|
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(reihe!.Items, Is.Empty);
|
||||||
|
Assert.That(seite!.Items[0].State!.Watched, Is.False, "weggelegt heisst nicht gesehen");
|
||||||
|
Assert.That(seite.Items[0].State!.PositionSeconds, Is.EqualTo(1800), "die Stelle bleibt");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Wer_doch_weiterschaut_holt_es_zurueck()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
await Melden(client, film, 1800, 7200);
|
||||||
|
await client.PostAsync($"/api/v1/items/{film}/dismiss", null);
|
||||||
|
await Melden(client, film, 1900, 7200);
|
||||||
|
|
||||||
|
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
|
||||||
|
Assert.That(reihe!.Items, Has.Count.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Von_Hand_als_gesehen_raeumt_die_Stelle_weg()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
await Melden(client, film, 1800, 7200);
|
||||||
|
var antwort = await client.PutAsJsonAsync($"/api/v1/items/{film}/watched", new WatchedRequest(true));
|
||||||
|
var stand = (await antwort.Content.ReadFromJsonAsync<ItemStateResponse>())!;
|
||||||
|
|
||||||
|
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(stand.Watched, Is.True);
|
||||||
|
Assert.That(stand.PositionSeconds, Is.Zero);
|
||||||
|
Assert.That(reihe!.Items, Is.Empty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Favorit_und_Bewertung_bleiben_stehen()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
await client.PutAsJsonAsync($"/api/v1/items/{film}/favourite", new FavouriteRequest(true));
|
||||||
|
await client.PutAsJsonAsync($"/api/v1/items/{film}/rating", new RatingRequest(9));
|
||||||
|
|
||||||
|
var einzeln = await client.GetFromJsonAsync<MovieResponse>($"/api/v1/movies/{film}");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(einzeln!.State!.Favourite, Is.True);
|
||||||
|
Assert.That(einzeln.State!.Rating, Is.EqualTo(9));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Eine_Bewertung_ausserhalb_von_eins_bis_zehn_wird_abgelehnt()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
var antwort = await client.PutAsJsonAsync($"/api/v1/items/{film}/rating", new RatingRequest(11));
|
||||||
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Zwei Leute im selben Haushalt stehen an verschiedenen Stellen.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Der_Stand_gehoert_dem_Benutzer_nicht_dem_Server()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var admin = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(admin);
|
||||||
|
|
||||||
|
(await admin.PostAsJsonAsync("/api/v1/users",
|
||||||
|
new CreateUserRequest("mitbewohner", "ein-langes-passwort", false))).EnsureSuccessStatusCode();
|
||||||
|
var zweiter = server.AlsAngemeldeter(
|
||||||
|
(await server.SignIn("mitbewohner", "ein-langes-passwort", "iPad", "geraet-2")).Token);
|
||||||
|
|
||||||
|
await Melden(admin, film, 600, 7200);
|
||||||
|
await Melden(zweiter, film, 3000, 7200);
|
||||||
|
|
||||||
|
var seiteA = await admin.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||||
|
var seiteB = await zweiter.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(seiteA!.Items[0].State!.PositionSeconds, Is.EqualTo(600));
|
||||||
|
Assert.That(seiteB!.Items[0].State!.PositionSeconds, Is.EqualTo(3000));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Einen_Titel_den_es_nicht_gibt_kann_man_nicht_melden()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
|
||||||
|
var antwort = await Melden(client, Guid.CreateVersion7(), 60, 7200);
|
||||||
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ohne Angabe nimmt der Server die Laufzeit aus der Datei.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Die Prüfdateien haben keine lesbare Laufzeit — ffprobe findet nichts in
|
||||||
|
/// zweitausend Nullbytes. Ohne Laufzeit darf nichts vermutet werden, und
|
||||||
|
/// genau das wird hier festgehalten.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Ohne_Laufzeit_wird_nichts_als_gesehen_vermutet()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
var antwort = await Melden(client, film, 99999, null);
|
||||||
|
var stand = (await antwort.Content.ReadFromJsonAsync<ItemStateResponse>())!;
|
||||||
|
|
||||||
|
Assert.That(stand.Watched, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Serien --------------------------------------------------------
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Ungesehene_Folgen_werden_gezaehlt()
|
||||||
|
{
|
||||||
|
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
|
||||||
|
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
|
||||||
|
Datei("Dark/Season 01/Dark - S01E03 - Gestern und heute.mkv");
|
||||||
|
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server, "Shows");
|
||||||
|
|
||||||
|
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
||||||
|
var serie = serien!.Items[0];
|
||||||
|
Assert.That(serie.UnwatchedCount, Is.EqualTo(3));
|
||||||
|
|
||||||
|
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>($"/api/v1/series/{serie.Id}/episodes");
|
||||||
|
await client.PutAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/watched", new WatchedRequest(true));
|
||||||
|
|
||||||
|
var danach = await client.GetFromJsonAsync<SeriesResponse>($"/api/v1/series/{serie.Id}");
|
||||||
|
Assert.That(danach!.UnwatchedCount, Is.EqualTo(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Die_naechste_Folge_richtet_sich_nach_Staffel_und_Nummer()
|
||||||
|
{
|
||||||
|
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
|
||||||
|
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
|
||||||
|
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server, "Shows");
|
||||||
|
|
||||||
|
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
||||||
|
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||||||
|
$"/api/v1/series/{serien!.Items[0].Id}/episodes");
|
||||||
|
|
||||||
|
var naechste = await client.GetFromJsonAsync<EpisodeResponse>(
|
||||||
|
$"/api/v1/episodes/{folgen!.Items[0].Id}/next");
|
||||||
|
var hinterDerLetzten = await client.GetAsync($"/api/v1/episodes/{folgen.Items[1].Id}/next");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(naechste!.Episode, Is.EqualTo(2));
|
||||||
|
Assert.That(hinterDerLetzten.StatusCode, Is.EqualTo(HttpStatusCode.NoContent),
|
||||||
|
"nach der letzten Folge kommt keine — das ist kein Fehler");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Die_erste_ungesehene_Folge_steht_unter_Naechste_Folge()
|
||||||
|
{
|
||||||
|
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
|
||||||
|
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
|
||||||
|
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server, "Shows");
|
||||||
|
|
||||||
|
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
||||||
|
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||||||
|
$"/api/v1/series/{serien!.Items[0].Id}/episodes");
|
||||||
|
|
||||||
|
await client.PutAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/watched", new WatchedRequest(true));
|
||||||
|
|
||||||
|
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/next");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(reihe!.Items, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(reihe.Items[0].Episode, Is.EqualTo(2));
|
||||||
|
Assert.That(reihe.Items[0].Title, Is.EqualTo("Dark"), "oben die Serie");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Was in „Weiterschauen" steht, steht nicht nochmal daneben.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Eine_angefangene_Folge_erscheint_nur_in_einer_Reihe()
|
||||||
|
{
|
||||||
|
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
|
||||||
|
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
|
||||||
|
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server, "Shows");
|
||||||
|
|
||||||
|
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
||||||
|
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||||||
|
$"/api/v1/series/{serien!.Items[0].Id}/episodes");
|
||||||
|
|
||||||
|
await Melden(client, folgen!.Items[0].Id, 400, 2700);
|
||||||
|
|
||||||
|
var weiter = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
|
||||||
|
var naechste = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/next");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(weiter!.Items, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(weiter.Items[0].Episode, Is.EqualTo(1));
|
||||||
|
Assert.That(naechste!.Items, Is.Empty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Spalte und die Regel dürfen nicht auseinanderlaufen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>InProgress</c> ist das festgehaltene Ergebnis von
|
||||||
|
/// <see cref="ProgressRule.BelongsInContinueWatching"/>. Wer die Regel
|
||||||
|
/// ändert und das Nachführen vergisst, bekommt eine Startseite, die lügt —
|
||||||
|
/// und zwar lautlos. Deshalb hier gegeneinandergehalten.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Die_Reihe_zeigt_genau_das_was_die_Regel_sagt()
|
||||||
|
{
|
||||||
|
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var client = await Eingelesen(server);
|
||||||
|
var film = await ErsterFilm(client);
|
||||||
|
|
||||||
|
foreach (var (stelle, laenge) in new (double, double?)[]
|
||||||
|
{ (1, 7200), (300, 7200), (7000, 7200), (120, 7200) })
|
||||||
|
{
|
||||||
|
await Melden(client, film, stelle, laenge);
|
||||||
|
|
||||||
|
var erwartet = ProgressRule.BelongsInContinueWatching(new UserItemState
|
||||||
|
{
|
||||||
|
UserId = Guid.Empty,
|
||||||
|
MovieId = film,
|
||||||
|
Position = TimeSpan.FromSeconds(stelle),
|
||||||
|
Runtime = laenge is { } l ? TimeSpan.FromSeconds(l) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
|
||||||
|
Assert.That(reihe!.Items.Count == 1, Is.EqualTo(erwartet),
|
||||||
|
$"bei {stelle}s von {laenge}s");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,10 @@ public class SignInTests
|
|||||||
using var server = new FlaroServer();
|
using var server = new FlaroServer();
|
||||||
await server.SetUpAndSignIn();
|
await server.SetUpAndSignIn();
|
||||||
|
|
||||||
var antwort = await server.Client.GetAsync("/api/v1/auth/me");
|
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
|
||||||
|
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
|
||||||
|
// den Angemeldeten — und bestünde nie wieder.
|
||||||
|
var antwort = await server.OhneGedaechtnis().GetAsync("/api/v1/auth/me");
|
||||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Flaro.Api.Auth;
|
||||||
|
using Flaro.Api.Endpoints;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>Was der Browser braucht und die Apps nicht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Eine App legt ihr Token in jede Anfrage. Ein Browser kann das nicht:
|
||||||
|
/// <c><img></c>, <c><video></c> und ein Download-Link senden keine
|
||||||
|
/// eigenen Kopfzeilen. Das Token in die Adresse zu schreiben wäre der
|
||||||
|
/// naheliegende Ausweg — und stünde danach in jedem Zugriffsprotokoll, in
|
||||||
|
/// jedem Verlauf und in jedem weitergegebenen Link. Also ein Cookie.
|
||||||
|
/// </remarks>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class WebInterfaceTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task Anmeldung_legt_ein_Cookie_ab()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
await server.SetUpAndSignIn();
|
||||||
|
|
||||||
|
var antwort = await server.OhneGedaechtnis().PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new LoginRequest("guido", "ein-langes-passwort", "Browser", "browser-1"));
|
||||||
|
|
||||||
|
var gesetzt = antwort.Headers.GetValues("Set-Cookie").Single();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(gesetzt, Does.StartWith($"{TokenAuthenticationHandler.CookieName}="));
|
||||||
|
// Kein Skript kommt heran, auch keines, das sich eingeschlichen hat.
|
||||||
|
Assert.That(gesetzt, Does.Contain("httponly").IgnoreCase);
|
||||||
|
// Eine fremde Seite kann damit nichts auslösen.
|
||||||
|
Assert.That(gesetzt, Does.Contain("samesite=strict").IgnoreCase);
|
||||||
|
// Über http wäre „secure" ein Cookie, das der Browser nie schickt —
|
||||||
|
// und damit eine Oberfläche, die sich im Heimnetz nicht bedienen lässt.
|
||||||
|
Assert.That(gesetzt, Does.Not.Contain("secure").IgnoreCase);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Cookie_allein_genuegt_fuer_eine_Anfrage()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
await server.SetUpAndSignIn();
|
||||||
|
|
||||||
|
var client = server.OhneGedaechtnis();
|
||||||
|
var angemeldet = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new LoginRequest("guido", "ein-langes-passwort", "Browser", "browser-1"));
|
||||||
|
var keks = Keks(angemeldet);
|
||||||
|
|
||||||
|
// Ohne Kopfzeile — genau so, wie ein <img> fragt.
|
||||||
|
var anfrage = new HttpRequestMessage(HttpMethod.Get, "/api/v1/movies");
|
||||||
|
anfrage.Headers.Add("Cookie", keks);
|
||||||
|
var antwort = await client.SendAsync(anfrage);
|
||||||
|
|
||||||
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Ohne_Cookie_und_ohne_Kopfzeile_bleibt_es_bei_401()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
await server.SetUpAndSignIn();
|
||||||
|
|
||||||
|
var antwort = await server.OhneGedaechtnis().GetAsync("/api/v1/movies");
|
||||||
|
|
||||||
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Abmelden_entwertet_das_Cookie()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
await server.SetUpAndSignIn();
|
||||||
|
|
||||||
|
var client = server.OhneGedaechtnis();
|
||||||
|
var angemeldet = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new LoginRequest("guido", "ein-langes-passwort", "Browser", "browser-1"));
|
||||||
|
var keks = Keks(angemeldet);
|
||||||
|
|
||||||
|
var abmelden = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/logout");
|
||||||
|
abmelden.Headers.Add("Cookie", keks);
|
||||||
|
(await client.SendAsync(abmelden)).EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
// **Das Token muss serverseitig weg sein, nicht nur das Cookie im
|
||||||
|
// Browser.** Ein gelöschtes Cookie schützt niemanden, der den Wert
|
||||||
|
// vorher mitgeschrieben hat.
|
||||||
|
var danach = new HttpRequestMessage(HttpMethod.Get, "/api/v1/movies");
|
||||||
|
danach.Headers.Add("Cookie", keks);
|
||||||
|
|
||||||
|
Assert.That((await client.SendAsync(danach)).StatusCode,
|
||||||
|
Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Oberfläche kommt heraus, bevor jemand angemeldet ist.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Der Fehler, den dieser Test festhält.</b> Die Rückfallregel verlangt
|
||||||
|
/// für jede Anfrage ohne eigenen Endpunkt einen angemeldeten Benutzer, und
|
||||||
|
/// <c>/assets/index.js</c> hat keinen. Standen die statischen Dateien unter
|
||||||
|
/// <c>UseAuthorization</c>, bekam ein neuer Besucher 401 auf genau das
|
||||||
|
/// Skript, das ihm die Anmeldemaske zeichnen sollte: eine weisse Seite,
|
||||||
|
/// ohne Hinweis worauf.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Die_Oberflaeche_braucht_keine_Anmeldung()
|
||||||
|
{
|
||||||
|
var wurzel = Path.Combine(Path.GetTempPath(), "flaro-www-" + Guid.CreateVersion7().ToString("N"));
|
||||||
|
Directory.CreateDirectory(Path.Combine(wurzel, "wwwroot", "assets"));
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "index.html"), "<!doctype html><title>Flaro</title>");
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "assets", "app.js"), "export const da = true;");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer(inhaltswurzel: wurzel);
|
||||||
|
var client = server.OhneGedaechtnis();
|
||||||
|
|
||||||
|
var seite = await client.GetAsync("/");
|
||||||
|
var skript = await client.GetAsync("/assets/app.js");
|
||||||
|
// Der Router läuft im Browser: ein Neuladen auf einer Unterseite
|
||||||
|
// darf nicht in einem 404 enden.
|
||||||
|
var unterseite = await client.GetAsync("/verwaltung");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(seite.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
Assert.That(skript.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
Assert.That(unterseite.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(await skript.Content.ReadAsStringAsync(), Is.EqualTo("export const da = true;"));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(wurzel, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Seite darf nicht im Zwischenspeicher hängenbleiben.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Der Fehler, den der Browser zeigte.</b> Die Dateien unter
|
||||||
|
/// <c>assets/</c> tragen eine Prüfsumme im Namen; <c>index.html</c> nicht
|
||||||
|
/// — sie nennt nur deren Namen. Lag sie im Zwischenspeicher, bekam der
|
||||||
|
/// Browser nach einem Server-Update weiter die alte App: nicht kaputt,
|
||||||
|
/// sondern veraltet, und das fällt niemandem auf. Nachgemessen hat es
|
||||||
|
/// sich so: geladen war <c>index-C0K3V8eg.js</c>, ausgeliefert wurde
|
||||||
|
/// längst <c>index-DomHN4_b.js</c>.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Die_Seite_wird_nicht_zwischengespeichert_die_Bausteine_schon()
|
||||||
|
{
|
||||||
|
var wurzel = Path.Combine(Path.GetTempPath(), "flaro-www-" + Guid.CreateVersion7().ToString("N"));
|
||||||
|
Directory.CreateDirectory(Path.Combine(wurzel, "wwwroot", "assets"));
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "index.html"), "<!doctype html><title>Flaro</title>");
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "assets", "index-abc123.js"), "export const da = true;");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer(inhaltswurzel: wurzel);
|
||||||
|
var client = server.OhneGedaechtnis();
|
||||||
|
|
||||||
|
var seite = await client.GetAsync("/");
|
||||||
|
var unterseite = await client.GetAsync("/verwaltung");
|
||||||
|
var skript = await client.GetAsync("/assets/index-abc123.js");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(seite.Headers.CacheControl?.NoCache, Is.True);
|
||||||
|
Assert.That(unterseite.Headers.CacheControl?.NoCache, Is.True,
|
||||||
|
"der SPA-Rückfall liefert dieselbe Datei");
|
||||||
|
Assert.That(skript.Headers.CacheControl?.MaxAge, Is.EqualTo(TimeSpan.FromDays(365)));
|
||||||
|
Assert.That(skript.Headers.CacheControl?.ToString(), Does.Contain("immutable"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(wurzel, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein unbekannter Pfad unter /api ist ein Fehler, keine Seite.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Der Fund, der dahintersteckt.</b> Der Rückfall für den Router im
|
||||||
|
/// Browser nimmt jeden Pfad ohne Dateiendung — auch
|
||||||
|
/// <c>/api/v1/vertippt</c>. Ein vertippter Aufruf bekam die Webseite mit
|
||||||
|
/// Status 200 zurück und sah damit wie ein Erfolg aus.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Ein_unbekannter_Schnittstellenpfad_gibt_404()
|
||||||
|
{
|
||||||
|
var wurzel = Path.Combine(Path.GetTempPath(), "flaro-www-" + Guid.CreateVersion7().ToString("N"));
|
||||||
|
Directory.CreateDirectory(Path.Combine(wurzel, "wwwroot"));
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "index.html"), "<!doctype html><title>Flaro</title>");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer(inhaltswurzel: wurzel);
|
||||||
|
var antwort = await server.OhneGedaechtnis().GetAsync("/api/v1/gibtsnicht");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||||
|
Assert.That(antwort.Content.Headers.ContentType?.MediaType, Is.Not.EqualTo("text/html"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(wurzel, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>„Angemeldet bleiben" entscheidet über die Lebensdauer.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Ohne <c>max-age</c> ist es ein Sitzungscookie und verschwindet mit dem
|
||||||
|
/// Browserfenster — richtig an einem fremden Rechner, falsch am eigenen.
|
||||||
|
/// Deshalb wird gefragt statt geraten.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Angemeldet_bleiben_setzt_ein_dauerhaftes_Cookie()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
await server.SetUpAndSignIn();
|
||||||
|
var client = server.OhneGedaechtnis();
|
||||||
|
|
||||||
|
var dauerhaft = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new LoginRequest("guido", "ein-langes-passwort", "Browser", "b1", StaySignedIn: true));
|
||||||
|
var nurJetzt = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new LoginRequest("guido", "ein-langes-passwort", "Browser", "b1", StaySignedIn: false));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(GanzerKeks(dauerhaft), Does.Contain("max-age=31536000").IgnoreCase);
|
||||||
|
Assert.That(GanzerKeks(nurJetzt), Does.Not.Contain("max-age").IgnoreCase);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Vorgabe ist „bleiben" — ein Client darf es weglassen.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Ohne_Angabe_bleibt_man_angemeldet()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
await server.SetUpAndSignIn();
|
||||||
|
|
||||||
|
var antwort = await server.OhneGedaechtnis().PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new LoginRequest("guido", "ein-langes-passwort", "Browser", "b1"));
|
||||||
|
|
||||||
|
Assert.That(GanzerKeks(antwort), Does.Contain("max-age=31536000").IgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Abmelden scheitert nie — auch nicht ohne gültiges Token.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Der Fehler dahinter.</b> Vorher verlangte dieser Weg eine gültige
|
||||||
|
/// Anmeldung, und genau dann, wenn man ihn am dringendsten braucht, gibt
|
||||||
|
/// es keine: nach einem geleerten Datenbestand zeigt das Cookie im
|
||||||
|
/// Browser auf ein Token, das es nicht mehr gibt. Es ging bei jedem Bild
|
||||||
|
/// erneut hinaus, der Server lehnte es jedes Mal ab — achtzig
|
||||||
|
/// Ablehnungen in einer Stunde —, und nichts davon liess sich von aussen
|
||||||
|
/// beenden.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Abmelden_loescht_das_Cookie_auch_ohne_gueltiges_Token()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
await server.SetUpAndSignIn();
|
||||||
|
var client = server.OhneGedaechtnis();
|
||||||
|
|
||||||
|
var anfrage = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/logout");
|
||||||
|
anfrage.Headers.Add("Cookie", $"{TokenAuthenticationHandler.CookieName}=laengst-ungueltig");
|
||||||
|
var antwort = await client.SendAsync(anfrage);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
|
||||||
|
// Ein gelöschtes Cookie ist eines mit abgelaufenem Datum.
|
||||||
|
Assert.That(antwort.Headers.GetValues("Set-Cookie").Single(),
|
||||||
|
Does.Contain(TokenAuthenticationHandler.CookieName));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein gültiges Token wird dabei trotzdem widerrufen.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Abmelden_zieht_ein_gueltiges_Token_weiterhin_zurueck()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var anmeldung = await server.SetUpAndSignIn();
|
||||||
|
var client = server.AlsAngemeldeter(anmeldung.Token);
|
||||||
|
|
||||||
|
(await client.PostAsync("/api/v1/auth/logout", null)).EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
Assert.That((await client.GetAsync("/api/v1/auth/me")).StatusCode,
|
||||||
|
Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Nimmt den Namen und den Wert aus der Set-Cookie-Kopfzeile.</summary>
|
||||||
|
private static string Keks(HttpResponseMessage antwort) =>
|
||||||
|
GanzerKeks(antwort).Split(';')[0];
|
||||||
|
|
||||||
|
/// <summary>Die ganze Kopfzeile — samt Lebensdauer und Schutzangaben.</summary>
|
||||||
|
private static string GanzerKeks(HttpResponseMessage antwort) =>
|
||||||
|
antwort.Headers.GetValues("Set-Cookie").Single();
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using Flaro.Core.Playback;
|
||||||
|
|
||||||
|
namespace Flaro.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>Die Regel, um derentwillen dieser Server existiert.</summary>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class ProgressRuleTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Zweistünder = TimeSpan.FromHours(2);
|
||||||
|
|
||||||
|
/// <summary>Der Fall, der den Ausschlag gab.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Jellyfins <c>MinResumePct</c> steht auf 5. Bei zwei Stunden sind das
|
||||||
|
/// sechs Minuten, und wer nach fünf abbricht, findet beim nächsten Mal
|
||||||
|
/// nichts vor. Hier ist genau diese Stelle abgelegt.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public void Die_ersten_Minuten_zaehlen_auch()
|
||||||
|
{
|
||||||
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(5), Zweistünder);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(5)));
|
||||||
|
Assert.That(ergebnis.Watched, Is.False);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Eine_einzige_Sekunde_zaehlt_auch()
|
||||||
|
{
|
||||||
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(1), Zweistünder);
|
||||||
|
|
||||||
|
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromSeconds(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Jellyfins <c>MinResumeDurationSeconds</c> steht auf 300.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Dort gilt eine vierminütige Folge nach dreissig Sekunden als
|
||||||
|
/// vollständig gesehen. Hier ist sie das, was sie ist: angefangen.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public void Ein_kurzer_Titel_gilt_nicht_sofort_als_gesehen()
|
||||||
|
{
|
||||||
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(4));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(ergebnis.Watched, Is.False);
|
||||||
|
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromSeconds(30)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Kurz_vor_Schluss_gilt_er_als_gesehen()
|
||||||
|
{
|
||||||
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(115), Zweistünder);
|
||||||
|
|
||||||
|
Assert.That(ergebnis.Watched, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Und die Stelle bleibt trotzdem stehen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Jellyfin setzt sie an dieser Stelle auf null. Wer den Abspann sehen
|
||||||
|
/// oder die letzte Szene nochmal will, fängt dort von vorn an.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public void Gesehen_wirft_die_Stelle_nicht_weg()
|
||||||
|
{
|
||||||
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(118), Zweistünder);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(ergebnis.Watched, Is.True);
|
||||||
|
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(118)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Genau_an_der_Grenze_gilt_er_als_gesehen()
|
||||||
|
{
|
||||||
|
var ergebnis = ProgressRule.Apply(Zweistünder * ProgressRule.WatchedFraction, Zweistünder);
|
||||||
|
|
||||||
|
Assert.That(ergebnis.Watched, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Eine_Sekunde_davor_noch_nicht()
|
||||||
|
{
|
||||||
|
var ergebnis = ProgressRule.Apply(
|
||||||
|
Zweistünder * ProgressRule.WatchedFraction - TimeSpan.FromSeconds(1), Zweistünder);
|
||||||
|
|
||||||
|
Assert.That(ergebnis.Watched, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ohne Laufzeit gibt es keinen Anteil — also kein Urteil.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Jellyfin nimmt in diesem Fall an, der Titel sei vollständig gesehen
|
||||||
|
/// („we'll just have to assume it was fully played"). Das ist die
|
||||||
|
/// Annahme, die man nicht zurücknehmen kann, ohne sie zu bemerken.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public void Ohne_Laufzeit_wird_nichts_vermutet()
|
||||||
|
{
|
||||||
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(90), null);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(ergebnis.Watched, Is.False);
|
||||||
|
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(90)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Eine_Laufzeit_von_null_ist_keine()
|
||||||
|
{
|
||||||
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(3), TimeSpan.Zero);
|
||||||
|
|
||||||
|
Assert.That(ergebnis.Watched, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Spieler melden beim Spulen und beim Beenden Unsinn.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Eine_negative_Stelle_wird_zu_null()
|
||||||
|
{
|
||||||
|
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(-5), Zweistünder);
|
||||||
|
|
||||||
|
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.Zero));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Jenseits_des_Endes_wird_auf_das_Ende_gekuerzt()
|
||||||
|
{
|
||||||
|
var ergebnis = ProgressRule.Apply(Zweistünder + TimeSpan.FromSeconds(3), Zweistünder);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(ergebnis.Position, Is.EqualTo(Zweistünder));
|
||||||
|
Assert.That(ergebnis.Watched, Is.True);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Weiterschauen ---------------------------------------------------
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Angefangen_gehoert_in_Weiterschauen()
|
||||||
|
{
|
||||||
|
Assert.That(ProgressRule.BelongsInContinueWatching(
|
||||||
|
Zustand(TimeSpan.FromMinutes(20), Zweistünder)), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Nie_angefangen_gehoert_nicht_hinein()
|
||||||
|
{
|
||||||
|
Assert.That(ProgressRule.BelongsInContinueWatching(
|
||||||
|
Zustand(TimeSpan.Zero, Zweistünder)), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Durchgeschaut_gehoert_nicht_hinein()
|
||||||
|
{
|
||||||
|
Assert.That(ProgressRule.BelongsInContinueWatching(
|
||||||
|
Zustand(TimeSpan.FromMinutes(118), Zweistünder)), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Weggelegtes_gehoert_nicht_hinein()
|
||||||
|
{
|
||||||
|
var zustand = Zustand(TimeSpan.FromMinutes(20), Zweistünder);
|
||||||
|
zustand.Dismissed = true;
|
||||||
|
|
||||||
|
Assert.That(ProgressRule.BelongsInContinueWatching(zustand), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein zweites Mal angefangen — dann steht er wieder da.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Das Häkchen bleibt: gesehen hat man ihn ja. Beides nebeneinander ist
|
||||||
|
/// genau das, was ohne weggeworfene Position möglich wird.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public void Ein_gesehener_Titel_kommt_beim_zweiten_Mal_zurueck()
|
||||||
|
{
|
||||||
|
var zustand = Zustand(TimeSpan.FromMinutes(10), Zweistünder);
|
||||||
|
zustand.Watched = true;
|
||||||
|
|
||||||
|
Assert.That(ProgressRule.BelongsInContinueWatching(zustand), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserItemState Zustand(TimeSpan stelle, TimeSpan? laenge) =>
|
||||||
|
new() { UserId = Guid.CreateVersion7(), MovieId = Guid.CreateVersion7(), Position = stelle, Runtime = laenge };
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
using Flaro.Core.Metadata;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Flaro.Metadata.Tests;
|
||||||
|
|
||||||
|
/// <summary>Was ausser Titel und Plakat noch ankommt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Daran hängen Detailseite, Vorschläge und der Filter nach Genre.</b> Mit
|
||||||
|
/// Titel, Jahr, Beschreibung und zwei Bildern lässt sich eine Kachel zeichnen
|
||||||
|
/// und sonst nichts.
|
||||||
|
/// </remarks>
|
||||||
|
public class AnreicherungTests
|
||||||
|
{
|
||||||
|
private static TmdbClient Client(Attrappe attrappe, string sprache = "de-DE") =>
|
||||||
|
new(new HttpClient(attrappe) { BaseAddress = new Uri(TmdbClient.BaseAddress) },
|
||||||
|
FesteEinstellungen.MitSchluessel(sprache), NullLogger<TmdbClient>.Instance);
|
||||||
|
|
||||||
|
private const string Suche = """{"results":[{"id":603,"title":"Matrix"}]}""";
|
||||||
|
|
||||||
|
private const string Einzeln = """
|
||||||
|
{"id":603,"title":"Matrix","release_date":"1999-03-30",
|
||||||
|
"overview":"Neo zweifelt an der Wirklichkeit.",
|
||||||
|
"poster_path":"/abc.jpg","backdrop_path":"/def.jpg",
|
||||||
|
"runtime":136,"vote_average":8.2,"tagline":"Die Wirklichkeit ist eine Lüge.",
|
||||||
|
"genres":[{"name":"Science Fiction"},{"name":"Action"}],
|
||||||
|
"external_ids":{"imdb_id":"tt0133093"},
|
||||||
|
"release_dates":{"results":[
|
||||||
|
{"iso_3166_1":"US","release_dates":[{"certification":"R"}]},
|
||||||
|
{"iso_3166_1":"DE","release_dates":[{"certification":""},{"certification":"16"}]}
|
||||||
|
]},
|
||||||
|
"images":{"logos":[
|
||||||
|
{"file_path":"/englisch.png","iso_639_1":"en","vote_average":9.0},
|
||||||
|
{"file_path":"/deutsch.png","iso_639_1":"de","vote_average":2.0},
|
||||||
|
{"file_path":"/ohne.png","iso_639_1":null,"vote_average":5.0}
|
||||||
|
]},
|
||||||
|
"credits":{
|
||||||
|
"cast":[
|
||||||
|
{"id":6384,"name":"Keanu Reeves","character":"Neo","profile_path":"/k.jpg","order":0},
|
||||||
|
{"id":2975,"name":"Laurence Fishburne","character":"Morpheus","profile_path":null,"order":1}
|
||||||
|
],
|
||||||
|
"crew":[
|
||||||
|
{"id":9339,"name":"Lana Wachowski","job":"Director","profile_path":"/l.jpg"},
|
||||||
|
{"id":9339,"name":"Lana Wachowski","job":"Writer","profile_path":"/l.jpg"},
|
||||||
|
{"id":9339,"name":"Lana Wachowski","job":"Story","profile_path":"/l.jpg"},
|
||||||
|
{"id":111,"name":"Jemand vom Licht","job":"Gaffer","profile_path":null}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static Attrappe Vollstaendig() =>
|
||||||
|
Attrappe.NachPfad(("search/movie", Suche), ("movie/603", Einzeln));
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Laufzeit_Bewertung_und_Werbezeile_kommen_an()
|
||||||
|
{
|
||||||
|
var film = await Client(Vollstaendig()).FindMovieAsync("Matrix", 1999);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(film!.ProviderId, Is.EqualTo(603));
|
||||||
|
Assert.That(film.RuntimeMinutes, Is.EqualTo(136));
|
||||||
|
Assert.That(film.Rating, Is.EqualTo(8.2));
|
||||||
|
Assert.That(film.Tagline, Is.EqualTo("Die Wirklichkeit ist eine Lüge."));
|
||||||
|
Assert.That(film.ImdbId, Is.EqualTo("tt0133093"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Genres_kommen_als_Namen_an()
|
||||||
|
{
|
||||||
|
var film = await Client(Vollstaendig()).FindMovieAsync("Matrix", 1999);
|
||||||
|
Assert.That(film!.Genres, Is.EqualTo(new[] { "Science Fiction", "Action" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Freigaben sind national — die FSK gilt nicht in Kansas.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Die_Freigabe_richtet_sich_nach_dem_eingestellten_Land()
|
||||||
|
{
|
||||||
|
var deutsch = await Client(Vollstaendig(), "de-DE").FindMovieAsync("Matrix", 1999);
|
||||||
|
var amerikanisch = await Client(Vollstaendig(), "en-US").FindMovieAsync("Matrix", 1999);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
// Der erste DE-Eintrag ist leer; genommen wird der erste gefüllte.
|
||||||
|
Assert.That(deutsch!.ContentRating, Is.EqualTo("16"));
|
||||||
|
Assert.That(amerikanisch!.ContentRating, Is.EqualTo("R"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Schriftzug in der eigenen Sprache schlägt den besser bewerteten.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Das_Logo_kommt_in_der_eingestellten_Sprache()
|
||||||
|
{
|
||||||
|
var film = await Client(Vollstaendig(), "de-DE").FindMovieAsync("Matrix", 1999);
|
||||||
|
|
||||||
|
Assert.That(film!.Logo!.Url, Does.EndWith("/deutsch.png"),
|
||||||
|
"auch wenn das englische bei TMDB besser bewertet ist");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ohne eigene Sprache gewinnt das sprachlose vor dem englischen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Ein Schriftzug ohne Sprachangabe enthält meist gar keinen Text —
|
||||||
|
/// er ist überall richtig.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Ohne_passende_Sprache_gewinnt_das_sprachlose()
|
||||||
|
{
|
||||||
|
var film = await Client(Vollstaendig(), "fr-FR").FindMovieAsync("Matrix", 1999);
|
||||||
|
Assert.That(film!.Logo!.Url, Does.EndWith("/ohne.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Darsteller_kommen_in_der_Reihenfolge_der_Quelle()
|
||||||
|
{
|
||||||
|
var film = await Client(Vollstaendig()).FindMovieAsync("Matrix", 1999);
|
||||||
|
var darsteller = film!.Credits!.Where(c => c.Kind == CreditKind.Actor).ToList();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(darsteller.Select(d => d.Name),
|
||||||
|
Is.EqualTo(new[] { "Keanu Reeves", "Laurence Fishburne" }));
|
||||||
|
Assert.That(darsteller[0].Character, Is.EqualTo("Neo"));
|
||||||
|
Assert.That(darsteller[0].Portrait!.Url, Does.EndWith("/k.jpg"));
|
||||||
|
Assert.That(darsteller[1].Portrait, Is.Null, "kein Bild ist null, keine kaputte Adresse");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Aus dem Stab nur Regie und Drehbuch.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// TMDB führt dort auch Beleuchter und Caterer. Eine Detailseite, die
|
||||||
|
/// alle nennt, nennt nichts.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Vom_Stab_kommen_nur_Regie_und_Drehbuch()
|
||||||
|
{
|
||||||
|
var film = await Client(Vollstaendig()).FindMovieAsync("Matrix", 1999);
|
||||||
|
var stab = film!.Credits!.Where(c => c.Kind != CreditKind.Actor).ToList();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(stab.Count(c => c.Kind == CreditKind.Director), Is.EqualTo(1));
|
||||||
|
// „Writer" und „Story" sind bei TMDB zwei Zeilen und hier ein Name.
|
||||||
|
Assert.That(stab.Count(c => c.Kind == CreditKind.Writer), Is.EqualTo(1));
|
||||||
|
Assert.That(stab.Any(c => c.Name.Contains("Licht")), Is.False);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Was ein Einzelabruf nicht hergibt, darf den Treffer nicht kippen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Eine Antwort, die keine Kennung enthält — ein Anmeldeportal, ein Proxy,
|
||||||
|
/// eine Trefferliste —, liest der Dekoder als Null. Das als Treffer
|
||||||
|
/// durchzulassen hiesse, einen Film mit der Kennung 0 anzulegen; statt
|
||||||
|
/// dessen bleibt, was die Suche schon wusste.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public async Task Ein_leerer_Einzelabruf_faellt_auf_den_Suchtreffer_zurueck()
|
||||||
|
{
|
||||||
|
var attrappe = Attrappe.NachPfad(
|
||||||
|
("search/movie", """{"results":[{"id":603,"title":"Matrix","release_date":"1999-03-30"}]}"""),
|
||||||
|
("movie/603", "{}"));
|
||||||
|
|
||||||
|
var film = await Client(attrappe).FindMovieAsync("Matrix", 1999);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(film!.ProviderId, Is.EqualTo(603));
|
||||||
|
Assert.That(film.Title, Is.EqualTo("Matrix"));
|
||||||
|
Assert.That(film.Genres, Is.Null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Alles in einer Anfrage statt in vieren.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Der_Einzelabruf_holt_alles_auf_einmal()
|
||||||
|
{
|
||||||
|
var attrappe = Vollstaendig();
|
||||||
|
await Client(attrappe).FindMovieAsync("Matrix", 1999);
|
||||||
|
|
||||||
|
var einzeln = attrappe.Aufrufe.Single(a => a.Contains("movie/603"));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(einzeln, Does.Contain("credits"));
|
||||||
|
Assert.That(einzeln, Does.Contain("external_ids"));
|
||||||
|
Assert.That(einzeln, Does.Contain("release_dates"));
|
||||||
|
Assert.That(einzeln, Does.Contain("images"));
|
||||||
|
// Sprachlose Schriftzüge liefert TMDB nur, wenn man sie nennt.
|
||||||
|
Assert.That(einzeln, Does.Contain("null"));
|
||||||
|
Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(2), "Suche und Einzelabruf, mehr nicht");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Eine_Serie_bekommt_die_Folgenlaenge_als_Laufzeit()
|
||||||
|
{
|
||||||
|
var attrappe = Attrappe.NachPfad(
|
||||||
|
("search/tv", """{"results":[{"id":70523,"name":"Dark"}]}"""),
|
||||||
|
("tv/70523", """
|
||||||
|
{"id":70523,"name":"Dark","first_air_date":"2017-12-01",
|
||||||
|
"episode_run_time":[60],"vote_average":8.4,
|
||||||
|
"genres":[{"name":"Mystery"}],
|
||||||
|
"content_ratings":{"results":[{"iso_3166_1":"DE","rating":"16"}]}}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
var serie = await Client(attrappe).FindSeriesAsync("Dark", null);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(serie!.RuntimeMinutes, Is.EqualTo(60));
|
||||||
|
Assert.That(serie.Rating, Is.EqualTo(8.4));
|
||||||
|
Assert.That(serie.Genres, Is.EqualTo(new[] { "Mystery" }));
|
||||||
|
Assert.That(serie.ContentRating, Is.EqualTo("16"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,28 @@ public sealed class Attrappe(Func<HttpRequestMessage, HttpResponseMessage> antwo
|
|||||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// <summary>Antwortet je nach Pfad verschieden.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Nötig, seit ein Treffer zwei Anfragen kostet.</b> Die Suche liefert
|
||||||
|
/// nur eine Kennung; Laufzeit, Genres, Besetzung und Schriftzug stehen
|
||||||
|
/// erst im Einzelabruf. Eine Attrappe mit einer Antwort für alles gäbe
|
||||||
|
/// dem Einzelabruf die Trefferliste zurück — und der läse daraus eine
|
||||||
|
/// Kennung von null.
|
||||||
|
///
|
||||||
|
/// Der erste Eintrag, dessen Schlüssel im Pfad vorkommt, gewinnt.
|
||||||
|
/// </remarks>
|
||||||
|
public static Attrappe NachPfad(params (string Teil, string Json)[] antworten) =>
|
||||||
|
new(anfrage =>
|
||||||
|
{
|
||||||
|
var pfad = anfrage.RequestUri!.AbsoluteUri;
|
||||||
|
var treffer = antworten.FirstOrDefault(a => pfad.Contains(a.Teil, StringComparison.Ordinal));
|
||||||
|
return new HttpResponseMessage(treffer.Json is null ? HttpStatusCode.NotFound : HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(treffer.Json ?? "{}", Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
public static Attrappe MitStatus(HttpStatusCode code) => new(_ => new HttpResponseMessage(code));
|
public static Attrappe MitStatus(HttpStatusCode code) => new(_ => new HttpResponseMessage(code));
|
||||||
|
|
||||||
public static Attrappe DieWirft(Exception fehler) => new(_ => throw fehler);
|
public static Attrappe DieWirft(Exception fehler) => new(_ => throw fehler);
|
||||||
|
|||||||
+3
-1
@@ -3,12 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#0B0B0D">
|
||||||
<title>Flaro</title>
|
<title>Flaro</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<section id="login"></section>
|
<section id="login"></section>
|
||||||
|
<header id="topbar" class="hidden"></header>
|
||||||
<div id="app" class="hidden">
|
<div id="app" class="hidden">
|
||||||
<nav id="chrome"><div class="island" id="island"></div></nav>
|
<nav id="chrome"></nav>
|
||||||
<main id="content"></main>
|
<main id="content"></main>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
|||||||
+408
-125
@@ -1,151 +1,434 @@
|
|||||||
import type { Wiedergabeauskunft } from './types';
|
/* Die Schnittstelle von Flaro, wie der Browser sie sieht.
|
||||||
|
*
|
||||||
|
* Eine Datei, weil es eine Schnittstelle ist: wer wissen will, was der Server
|
||||||
|
* kann, liest hier und nicht in fünf Ansichten nach. */
|
||||||
|
|
||||||
/* Der Draht zum Server.
|
export type Rolle = { id: string; userName: string; isAdministrator: boolean };
|
||||||
----------------------------------------------------------------------------
|
export type Spur = {
|
||||||
**Alles geht über `/api`, nicht über die Adresse des Medienservers.** Das
|
index: number; kind: 'Video' | 'Audio' | 'Subtitle';
|
||||||
ist der Grund, warum es diesen Server überhaupt gibt: Er reicht die
|
codec: string | null; language: string | null; title: string | null;
|
||||||
Anfragen weiter, und damit haben Seite und Daten dieselbe Herkunft. Kein
|
isDefault: boolean; isForced: boolean;
|
||||||
CORS, kein Ärger mit gemischtem HTTP und HTTPS, und der Zugangsschlüssel
|
channels: number | null; width: number | null; height: number | null;
|
||||||
bleibt beim Server statt im Browser zu liegen.
|
};
|
||||||
|
export type Datei = {
|
||||||
Die Endpunkte selbst sind dieselben, die auch die App benutzt — übernommen
|
id: string; fileName: string; sizeBytes: number;
|
||||||
aus JellyfinKit, nicht geraten. */
|
container: string | null; durationSeconds: number | null; streams: Spur[];
|
||||||
|
};
|
||||||
const BASIS = '/api';
|
/** Was dieser Benutzer mit einem Titel zu tun hatte. */
|
||||||
|
export type Stand = {
|
||||||
export const sitzung = {
|
positionSeconds: number; runtimeSeconds: number | null;
|
||||||
token: localStorage.getItem('flaro.token') ?? '',
|
watched: boolean; playCount: number;
|
||||||
userId: localStorage.getItem('flaro.user') ?? '',
|
favourite: boolean; rating: number | null; dismissed: boolean;
|
||||||
geraet:
|
lastPlayedAt: string | null;
|
||||||
localStorage.getItem('flaro.device') ??
|
};
|
||||||
(() => {
|
export type Mitwirkender = {
|
||||||
const id = 'flaro-web-' + Math.random().toString(36).slice(2, 12);
|
id: string; name: string;
|
||||||
localStorage.setItem('flaro.device', id);
|
role: 'Actor' | 'Director' | 'Writer';
|
||||||
return id;
|
character: string | null; image: string | null;
|
||||||
})(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Die Kopfzeile, mit oder ohne Schlüssel.
|
export type Film = {
|
||||||
|
id: string; title: string; year: number | null; overview: string | null;
|
||||||
|
posterImage: string | null; backdropImage: string | null; files: Datei[];
|
||||||
|
state: Stand | null;
|
||||||
|
logoImage: string | null;
|
||||||
|
runtimeMinutes: number | null;
|
||||||
|
rating: number | null;
|
||||||
|
tagline: string | null;
|
||||||
|
contentRating: string | null;
|
||||||
|
/** Leer in Listen, gefüllt beim Einzelabruf. */
|
||||||
|
genres: string[] | null;
|
||||||
|
credits: Mitwirkender[] | null;
|
||||||
|
};
|
||||||
|
export type Serie = {
|
||||||
|
id: string; title: string; year: number | null; overview: string | null;
|
||||||
|
posterImage: string | null; backdropImage: string | null;
|
||||||
|
episodeCount: number; seasons: number[]; unwatchedCount: number;
|
||||||
|
logoImage: string | null;
|
||||||
|
runtimeMinutes: number | null;
|
||||||
|
rating: number | null;
|
||||||
|
tagline: string | null;
|
||||||
|
contentRating: string | null;
|
||||||
|
genres: string[] | null;
|
||||||
|
credits: Mitwirkender[] | null;
|
||||||
|
};
|
||||||
|
export type Folge = {
|
||||||
|
id: string; season: number; episode: number;
|
||||||
|
title: string | null; overview: string | null; stillImage: string | null; files: Datei[];
|
||||||
|
state: Stand | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Eine Kachel in „Weiterschauen" oder „Nächste Folge" — Film wie Folge. */
|
||||||
|
export type Angefangen = {
|
||||||
|
id: string; kind: 'movie' | 'episode';
|
||||||
|
title: string; subTitle: string | null;
|
||||||
|
season: number | null; episode: number | null; year: number | null;
|
||||||
|
posterImage: string | null; backdropImage: string | null;
|
||||||
|
state: Stand;
|
||||||
|
};
|
||||||
|
export type Bibliothek = {
|
||||||
|
id: string; name: string;
|
||||||
|
/** „Movies" oder „Shows" — so nennt der Server es. */
|
||||||
|
kind: 'Movies' | 'Shows';
|
||||||
|
path: string;
|
||||||
|
/** `null` heisst „nicht gezählt", nicht „leer". */
|
||||||
|
itemCount: number | null;
|
||||||
|
};
|
||||||
|
export type Seite<T> = { items: T[]; total: number };
|
||||||
|
export type Lieferung = {
|
||||||
|
method: 'directFile' | 'remux'; reasons: string[];
|
||||||
|
audioToEncode: number[]; subtitlesDropped: number[];
|
||||||
|
packageState: string; progress: number; error: string | null;
|
||||||
|
};
|
||||||
|
export type Scanstand = {
|
||||||
|
state: string; filesFound: number | null; added: number | null;
|
||||||
|
removed: number | null; unrecognised: number | null; probed: number | null;
|
||||||
|
problems: { path: string; reason: string }[] | null; error: string | null;
|
||||||
|
metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number;
|
||||||
|
skipped: boolean; error: string | null } | null;
|
||||||
|
};
|
||||||
|
export type Vorschlag = {
|
||||||
|
tmdbId: number; title: string; year: number | null;
|
||||||
|
overview: string | null; posterUrl: string | null;
|
||||||
|
};
|
||||||
|
export type Hinweis = { text: string; logoUrl: string; url: string };
|
||||||
|
export type Systemauskunft = {
|
||||||
|
version: string; serverName: string | null;
|
||||||
|
needsSetup: boolean; attributions: Hinweis[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- Sitzung ---------- */
|
||||||
|
|
||||||
|
const SCHLUESSEL = 'flaro.session';
|
||||||
|
|
||||||
|
export const sitzung: { token: string | null; wer: Rolle | null } = {
|
||||||
|
token: null, wer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ob die Anmeldung einen geschlossenen Browser überleben soll. */
|
||||||
|
let bleibenAngemeldet = true;
|
||||||
|
|
||||||
|
/** Wo die Sitzung liegt — und das ist die ganze Umsetzung von „bleiben".
|
||||||
*
|
*
|
||||||
* Die Anführungszeichen sind Pflicht, auch um Werte ohne Leerzeichen —
|
* `localStorage` überlebt das Schliessen des Browsers, `sessionStorage`
|
||||||
* Jellyfin zerlegt die Zeile sonst falsch und meldet, man sei nicht
|
* nicht. Ein Verfallsdatum selbst zu verwalten wäre die schlechtere
|
||||||
* angemeldet, obwohl der Schlüssel stimmt. */
|
* Variante: eine Uhr, die man stellen kann, gegen zwei Speicher, die der
|
||||||
function kopfzeile(): string {
|
* Browser ohnehin richtig aufräumt. */
|
||||||
const teile = [
|
function ablage(dauerhaft: boolean): Storage | null {
|
||||||
'Client="Flaro Web"',
|
try { return dauerhaft ? localStorage : sessionStorage; } catch { return null; }
|
||||||
'Device="Browser"',
|
|
||||||
`DeviceId="${sitzung.geraet}"`,
|
|
||||||
'Version="0.1"',
|
|
||||||
];
|
|
||||||
if (sitzung.token) teile.push(`Token="${sitzung.token}"`);
|
|
||||||
return 'MediaBrowser ' + teile.join(', ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ServerFehler extends Error {
|
try {
|
||||||
constructor(public status: number, text: string) {
|
// Erst der dauerhafte, dann der fürs Fenster. Liegt in beiden etwas, gilt
|
||||||
super(text);
|
// der dauerhafte — er ist der zuletzt ausdrücklich gewählte.
|
||||||
|
const roh = ablage(true)?.getItem(SCHLUESSEL) ?? ablage(false)?.getItem(SCHLUESSEL);
|
||||||
|
if (roh) {
|
||||||
|
Object.assign(sitzung, JSON.parse(roh));
|
||||||
|
bleibenAngemeldet = ablage(true)?.getItem(SCHLUESSEL) != null;
|
||||||
}
|
}
|
||||||
|
} catch { /* Privates Fenster oder gesperrter Speicher — dann eben anmelden. */ }
|
||||||
|
|
||||||
|
function merken(): void {
|
||||||
|
try {
|
||||||
|
// Immer aus beiden entfernen, bevor geschrieben wird: sonst bliebe nach
|
||||||
|
// einem Wechsel von „bleiben" auf „nicht bleiben" die alte Kopie im
|
||||||
|
// dauerhaften Speicher liegen — und überlebte genau das, was sie nicht
|
||||||
|
// überleben sollte.
|
||||||
|
ablage(true)?.removeItem(SCHLUESSEL);
|
||||||
|
ablage(false)?.removeItem(SCHLUESSEL);
|
||||||
|
if (sitzung.token) ablage(bleibenAngemeldet)?.setItem(SCHLUESSEL, JSON.stringify(sitzung));
|
||||||
|
} catch { /* Nicht schlimm: die Sitzung gilt dann nur bis zum Neuladen. */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function api<T = unknown>(pfad: string, opts: RequestInit = {}): Promise<T> {
|
export const angemeldet = (): boolean => Boolean(sitzung.token);
|
||||||
const res = await fetch(BASIS + pfad, {
|
export const istAdmin = (): boolean => Boolean(sitzung.wer?.isAdministrator);
|
||||||
...opts,
|
|
||||||
headers: {
|
/* ---------- Anfragen ---------- */
|
||||||
Authorization: kopfzeile(),
|
|
||||||
Accept: 'application/json',
|
export class ApiFehler extends Error {
|
||||||
...(opts.body ? { 'Content-Type': 'application/json' } : {}),
|
constructor(public status: number, nachricht: string) { super(nachricht); }
|
||||||
...opts.headers,
|
}
|
||||||
},
|
|
||||||
|
async function anfrage<T>(pfad: string, opts: RequestInit = {}): Promise<T> {
|
||||||
|
const kopf = new Headers(opts.headers);
|
||||||
|
if (sitzung.token) kopf.set('Authorization', `Bearer ${sitzung.token}`);
|
||||||
|
if (opts.body && !kopf.has('Content-Type')) kopf.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
const antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf });
|
||||||
|
|
||||||
|
// **Eine 401 ist noch kein Grund, die Anmeldung wegzuwerfen.**
|
||||||
|
//
|
||||||
|
// Hier stand vorher ein sofortiges `abmelden()` — und das rief
|
||||||
|
// `/auth/logout`, der Server zog das Token also *aktiv zurück*. War die
|
||||||
|
// Ablehnung nur ein Aussetzer, war eine intakte Anmeldung damit
|
||||||
|
// absichtlich zerstört, und zwar unwiderruflich.
|
||||||
|
//
|
||||||
|
// Jetzt wird einmal nachgefragt: gilt das Token wirklich nicht mehr, ist
|
||||||
|
// die Sitzung vorbei; antwortet der Server, war es diese eine Anfrage.
|
||||||
|
if (antwort.status === 401 && sitzung.token && !pfad.startsWith('/auth/')) {
|
||||||
|
if (await sitzungTot()) { verwerfen(); location.reload(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!antwort.ok) {
|
||||||
|
let text = `${antwort.status}`;
|
||||||
|
try {
|
||||||
|
const fehler = await antwort.json();
|
||||||
|
text = fehler.detail ?? fehler.title
|
||||||
|
?? Object.values(fehler.errors ?? {}).flat().join(' ') ?? text;
|
||||||
|
} catch { /* Keine JSON-Antwort — dann bleibt der Status. */ }
|
||||||
|
throw new ApiFehler(antwort.status, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return antwort.status === 204 ? (undefined as T) : antwort.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Bilder ---------- */
|
||||||
|
|
||||||
|
/** Die Adresse eines Bildes.
|
||||||
|
*
|
||||||
|
* Schlicht ein Pfad, denn der Browser weist sich über das Sitzungs-Cookie
|
||||||
|
* aus — `<img>`, `<video>` und ein Download-Link können das von sich aus.
|
||||||
|
* Ohne das Cookie müsste jedes Plakat über fetch kommen und im
|
||||||
|
* Arbeitsspeicher liegen. */
|
||||||
|
export const bildAdresse = (schluessel: string | null): string | null =>
|
||||||
|
schluessel ? `/api/v1/images/${schluessel}` : null;
|
||||||
|
|
||||||
|
/* ---------- System und Anmeldung ---------- */
|
||||||
|
|
||||||
|
export const systemauskunft = () =>
|
||||||
|
anfrage<Systemauskunft>('/system/info');
|
||||||
|
|
||||||
|
export const einrichten = (benutzer: string, passwort: string) =>
|
||||||
|
anfrage<Rolle>('/system/setup', {
|
||||||
|
method: 'POST', body: JSON.stringify({ userName: benutzer, password: passwort }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
export async function anmelden(
|
||||||
throw new ServerFehler(
|
benutzer: string, passwort: string, bleiben = true,
|
||||||
res.status,
|
): Promise<void> {
|
||||||
res.status === 401 ? 'Nicht angemeldet' : `HTTP ${res.status}`,
|
const antwort = await anfrage<{ token: string; user: Rolle }>('/auth/login', {
|
||||||
);
|
method: 'POST',
|
||||||
}
|
body: JSON.stringify({
|
||||||
return res.status === 204 ? (null as T) : ((await res.json()) as T);
|
userName: benutzer, password: passwort,
|
||||||
|
deviceName: geraetename(), deviceId: geraetekennung(),
|
||||||
|
staySignedIn: bleiben,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
bleibenAngemeldet = bleiben;
|
||||||
|
sitzung.token = antwort.token;
|
||||||
|
sitzung.wer = antwort.user;
|
||||||
|
merken();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Eine Adresse für das <video>-Element oder ein <img>.
|
/** Prüft einmal, ob das Token wirklich nicht mehr gilt.
|
||||||
*
|
*
|
||||||
* Beide schicken keine Kopfzeilen mit, deshalb hängt der Schlüssel in der
|
* Mehrere gleichzeitige Anfragen laufen in dieselbe Prüfung — sonst stellte
|
||||||
* Adresse. Denselben Umweg geht die App. */
|
* eine Startseite mit acht Abfragen acht Mal dieselbe Frage. */
|
||||||
export function medienURL(pfad: string, query: Record<string, string | number> = {}): string {
|
let laufendePruefung: Promise<boolean> | null = null;
|
||||||
const u = new URL(BASIS + pfad, location.origin);
|
|
||||||
for (const [k, v] of Object.entries(query)) {
|
function sitzungTot(): Promise<boolean> {
|
||||||
if (v !== '' && v != null) u.searchParams.set(k, String(v));
|
laufendePruefung ??= fetch('/api/v1/auth/me', {
|
||||||
}
|
headers: { Authorization: `Bearer ${sitzung.token}` },
|
||||||
if (sitzung.token) u.searchParams.set('api_key', sitzung.token);
|
})
|
||||||
return u.href;
|
.then(a => a.status === 401)
|
||||||
|
// Antwortet der Server gar nicht, ist das kein Beleg gegen das Token.
|
||||||
|
// Jemanden abzumelden, weil das WLAN hakt, ist der falsche Schluss.
|
||||||
|
.catch(() => false)
|
||||||
|
.finally(() => { window.setTimeout(() => { laufendePruefung = null; }, 2000); });
|
||||||
|
|
||||||
|
return laufendePruefung;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Anmeldung ---------- */
|
/** Wirft die Sitzung hier weg — ohne den Server zu fragen. */
|
||||||
|
function verwerfen(): void {
|
||||||
export async function anmelden(benutzer: string, passwort: string): Promise<void> {
|
sitzung.token = null; sitzung.wer = null;
|
||||||
sitzung.token = '';
|
merken();
|
||||||
const r = await api<{ AccessToken: string; User: { Id: string } }>(
|
|
||||||
'/Users/AuthenticateByName',
|
|
||||||
{ method: 'POST', body: JSON.stringify({ Username: benutzer, Pw: passwort }) },
|
|
||||||
);
|
|
||||||
sitzung.token = r.AccessToken;
|
|
||||||
sitzung.userId = r.User.Id;
|
|
||||||
localStorage.setItem('flaro.token', sitzung.token);
|
|
||||||
localStorage.setItem('flaro.user', sitzung.userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Meldet ab und zieht das Token am Server zurück.
|
||||||
|
*
|
||||||
|
* **Nur auf ausdrücklichen Wunsch.** Der Weg hierher führt über den Knopf,
|
||||||
|
* nicht über eine abgelehnte Anfrage.
|
||||||
|
*/
|
||||||
export function abmelden(): void {
|
export function abmelden(): void {
|
||||||
localStorage.removeItem('flaro.token');
|
// Auch ohne gültiges Token aufrufen: der Server löscht dabei das Cookie,
|
||||||
localStorage.removeItem('flaro.user');
|
// und genau das bleibt sonst im Browser stehen und geht bei jedem Bild
|
||||||
location.reload();
|
// erneut hinaus.
|
||||||
|
void fetch('/api/v1/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: sitzung.token ? { Authorization: `Bearer ${sitzung.token}` } : {},
|
||||||
|
}).catch(() => {});
|
||||||
|
verwerfen();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const angemeldet = (): boolean => Boolean(sitzung.token && sitzung.userId);
|
function geraetename(): string {
|
||||||
|
const u = navigator.userAgent;
|
||||||
|
const was = /iPhone/.test(u) ? 'iPhone' : /iPad/.test(u) ? 'iPad'
|
||||||
|
: /Macintosh/.test(u) ? 'Mac' : /Windows/.test(u) ? 'Windows' : 'Browser';
|
||||||
|
return `${was} · ${/Firefox/.test(u) ? 'Firefox' : /Chrome/.test(u) ? 'Chrome' : 'Safari'}`;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Wiedergabe ---------- */
|
/** Bleibt gleich, damit derselbe Browser nicht als zehn Geräte dasteht. */
|
||||||
|
function geraetekennung(): string {
|
||||||
|
const name = 'flaro.device';
|
||||||
|
try {
|
||||||
|
let vorhanden = localStorage.getItem(name);
|
||||||
|
if (!vorhanden) { vorhanden = crypto.randomUUID(); localStorage.setItem(name, vorhanden); }
|
||||||
|
return vorhanden;
|
||||||
|
} catch { return crypto.randomUUID(); }
|
||||||
|
}
|
||||||
|
|
||||||
/** Handelt mit dem Server aus, wie abgespielt wird.
|
/* ---------- Mediathek ---------- */
|
||||||
|
|
||||||
|
/** Wonach eine Liste geordnet wird — die Wörter des Servers. */
|
||||||
|
export type Ordnung = 'title' | 'added' | 'year';
|
||||||
|
|
||||||
|
type Suchfrage = {
|
||||||
|
library?: string; search?: string; sort?: Ordnung; genre?: string;
|
||||||
|
skip?: number; take?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filme = (q: Suchfrage = {}) => anfrage<Seite<Film>>(`/movies${abfrage(q)}`);
|
||||||
|
|
||||||
|
export const serien = (q: Suchfrage = {}) => anfrage<Seite<Serie>>(`/series${abfrage(q)}`);
|
||||||
|
|
||||||
|
/** Holt aus einer Mediathek, was auch immer darin liegt.
|
||||||
*
|
*
|
||||||
* Das Geräteprofil sagt, was dieses Ende kann; die Antwort sagt, was daraus
|
* Die Unterscheidung Film/Serie steckt in der Mediathek, nicht im Aufrufer —
|
||||||
* folgt. Leere `DirectPlayProfiles` heißt: nichts geht unverändert, rechne
|
* wer eine Reihe zeichnet, will Kacheln und nicht zwei Codepfade. */
|
||||||
* um. Im Browser ist das die ehrliche Auskunft — ein <video>-Element kann
|
export const inhalt = (b: Bibliothek, q: Omit<Suchfrage, 'library'> = {}) =>
|
||||||
* kein MKV und kein DTS. */
|
b.kind === 'Shows'
|
||||||
export function wiedergabeauskunft(
|
? serien({ ...q, library: b.id })
|
||||||
itemID: string,
|
: filme({ ...q, library: b.id });
|
||||||
wahl: { quality?: number; audio?: number | null; subtitle?: number | null } = {},
|
|
||||||
): Promise<Wiedergabeauskunft> {
|
|
||||||
const grenze = (wahl.quality ?? 0) > 0 ? wahl.quality! : 20000;
|
|
||||||
const profil = {
|
|
||||||
MaxStreamingBitrate: grenze * 1000,
|
|
||||||
DirectPlayProfiles: [],
|
|
||||||
TranscodingProfiles: [
|
|
||||||
{
|
|
||||||
Type: 'Video', Container: 'ts', Protocol: 'hls', VideoCodec: 'h264',
|
|
||||||
AudioCodec: 'aac,mp3', Context: 'Streaming', MaxAudioChannels: '2',
|
|
||||||
MinSegments: 1, BreakOnNonKeyFrames: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
SubtitleProfiles: [
|
|
||||||
{ Format: 'vtt', Method: 'Hls' },
|
|
||||||
{ Format: 'srt', Method: 'External' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
return api<Wiedergabeauskunft>(
|
export const folgen = (serie: string, staffel?: number) =>
|
||||||
`/Items/${itemID}/PlaybackInfo?` + new URLSearchParams({ userId: sitzung.userId }),
|
anfrage<Seite<Folge>>(`/series/${serie}/episodes${abfrage({ season: staffel })}`);
|
||||||
{
|
|
||||||
method: 'POST',
|
export const film = (id: string) => anfrage<Film>(`/movies/${id}`);
|
||||||
body: JSON.stringify({
|
export const serie = (id: string) => anfrage<Serie>(`/series/${id}`);
|
||||||
DeviceProfile: profil,
|
|
||||||
UserId: sitzung.userId,
|
export const lieferung = (datei: string) => anfrage<Lieferung>(`/files/${datei}/delivery`);
|
||||||
MaxStreamingBitrate: profil.MaxStreamingBitrate,
|
|
||||||
EnableDirectPlay: false,
|
/* ---------- Stand je Benutzer ---------- */
|
||||||
EnableDirectStream: false,
|
|
||||||
EnableTranscoding: true,
|
export const weiterschauen = (take = 20) =>
|
||||||
AutoOpenLiveStream: true,
|
anfrage<Seite<Angefangen>>(`/home/continue${abfrage({ take })}`);
|
||||||
...(wahl.audio != null ? { AudioStreamIndex: wahl.audio } : {}),
|
|
||||||
...((wahl.subtitle ?? -1) >= 0 ? { SubtitleStreamIndex: wahl.subtitle } : {}),
|
export const naechsteFolgen = (take = 20) =>
|
||||||
}),
|
anfrage<Seite<Angefangen>>(`/home/next${abfrage({ take })}`);
|
||||||
},
|
|
||||||
);
|
/** Meldet, wo man steht. Jede Meldung zählt — es gibt keine zu kleine. */
|
||||||
|
/** Meldet, wo man steht. Jede Meldung zählt — es gibt keine zu kleine.
|
||||||
|
*
|
||||||
|
* `beendet` nimmt die Wiedergabe sofort aus „läuft gerade". Ohne das stünde
|
||||||
|
* ein geschlossener Spieler dort noch eine Minute. */
|
||||||
|
export const fortschritt = (
|
||||||
|
id: string, stelle: number, laenge?: number | null, beendet = false,
|
||||||
|
) =>
|
||||||
|
anfrage<Stand>(`/items/${id}/progress`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
positionSeconds: stelle, runtimeSeconds: laenge ?? null,
|
||||||
|
stopped: beendet || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const gesehen = (id: string, wert: boolean) =>
|
||||||
|
anfrage<Stand>(`/items/${id}/watched`, {
|
||||||
|
method: 'PUT', body: JSON.stringify({ watched: wert }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen. */
|
||||||
|
export const weglegen = (id: string) =>
|
||||||
|
anfrage<Stand>(`/items/${id}/dismiss`, { method: 'POST' });
|
||||||
|
|
||||||
|
export const favorit = (id: string, wert: boolean) =>
|
||||||
|
anfrage<Stand>(`/items/${id}/favourite`, {
|
||||||
|
method: 'PUT', body: JSON.stringify({ favourite: wert }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const downloadAdresse = (datei: string) => `/api/v1/files/${datei}/download`;
|
||||||
|
|
||||||
|
/* ---------- Verwaltung ---------- */
|
||||||
|
|
||||||
|
export const bibliotheken = () => anfrage<Bibliothek[]>('/libraries');
|
||||||
|
|
||||||
|
/** Nur die Genres, die in dieser Mediathek auch vorkommen. */
|
||||||
|
export const genresVon = (id: string) => anfrage<string[]>(`/libraries/${id}/genres`);
|
||||||
|
|
||||||
|
/** „Weil du Ähnliches gesehen hast" — über die Genres des Gesehenen. */
|
||||||
|
export const empfehlungen = (take = 20) =>
|
||||||
|
anfrage<Seite<Film>>(`/home/suggestions${abfrage({ take })}`);
|
||||||
|
|
||||||
|
export const metadatenAuffrischen = (id: string) =>
|
||||||
|
anfrage<void>(`/libraries/${id}/refresh`, { method: 'POST' });
|
||||||
|
|
||||||
|
/** Eine laufende Wiedergabe — nur für Administratoren sichtbar. */
|
||||||
|
export type Wiedergabe = {
|
||||||
|
userName: string; deviceName: string;
|
||||||
|
itemId: string; title: string; subTitle: string | null; posterImage: string | null;
|
||||||
|
positionSeconds: number; runtimeSeconds: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Serverzustand = {
|
||||||
|
playing: number; packaging: number; scanning: number; uptime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const laufendeWiedergaben = () =>
|
||||||
|
anfrage<Wiedergabe[]>('/system/sessions');
|
||||||
|
|
||||||
|
export const serverzustand = () => anfrage<Serverzustand>('/system/status');
|
||||||
|
|
||||||
|
export const servernameSetzen = (name: string) =>
|
||||||
|
anfrage<void>('/system/name', { method: 'PUT', body: JSON.stringify({ name }) });
|
||||||
|
|
||||||
|
export const bibliothekUmbenennen = (id: string, name: string) =>
|
||||||
|
anfrage<Bibliothek>(`/libraries/${id}`, {
|
||||||
|
method: 'PUT', body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Entfernt den Eintrag — die Mediendateien bleiben liegen. */
|
||||||
|
export const bibliothekLoeschen = (id: string) =>
|
||||||
|
anfrage<void>(`/libraries/${id}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
export const bibliothekAnlegen = (name: string, art: string, pfad: string) =>
|
||||||
|
anfrage<Bibliothek>('/libraries', {
|
||||||
|
method: 'POST', body: JSON.stringify({ name, kind: art, path: pfad }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const scanStarten = (id: string) =>
|
||||||
|
anfrage<unknown>(`/libraries/${id}/scan`, { method: 'POST' });
|
||||||
|
|
||||||
|
export const scanstand = (id: string) => anfrage<Scanstand>(`/libraries/${id}/scan`);
|
||||||
|
|
||||||
|
export const benutzer = () => anfrage<Rolle[]>('/users');
|
||||||
|
|
||||||
|
export const benutzerAnlegen = (name: string, passwort: string, admin: boolean) =>
|
||||||
|
anfrage<Rolle>('/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userName: name, password: passwort, isAdministrator: admin }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadatenEinstellung = () =>
|
||||||
|
anfrage<{ hasApiKey: boolean; language: string; usingProjectKey: boolean }>('/settings/metadata');
|
||||||
|
|
||||||
|
export const metadatenSetzen = (werte: { apiKey?: string; language?: string }) =>
|
||||||
|
anfrage<void>('/settings/metadata', { method: 'PUT', body: JSON.stringify(werte) });
|
||||||
|
|
||||||
|
/* ---------- Zuordnung ---------- */
|
||||||
|
|
||||||
|
export const vorschlaege = (art: 'movies' | 'series', id: string, suche?: string, jahr?: number) =>
|
||||||
|
anfrage<Vorschlag[]>(`/${art}/${id}/matches${abfrage({ query: suche, year: jahr })}`);
|
||||||
|
|
||||||
|
export const zuordnen = (art: 'movies' | 'series', id: string, tmdbId: number) =>
|
||||||
|
anfrage<{ tmdbId: number; imdbId: string | null; locked: boolean; title: string; year: number | null }>(
|
||||||
|
`/${art}/${id}/match`, { method: 'PUT', body: JSON.stringify({ tmdbId }) });
|
||||||
|
|
||||||
|
/* ---------- Hilfsmittel ---------- */
|
||||||
|
|
||||||
|
function abfrage(werte: Record<string, string | number | undefined>): string {
|
||||||
|
const teile = Object.entries(werte)
|
||||||
|
.filter(([, v]) => v !== undefined && v !== '')
|
||||||
|
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`);
|
||||||
|
return teile.length ? `?${teile.join('&')}` : '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { bildAdresse, type Film, type Serie, type Angefangen, type Stand } from './api';
|
||||||
|
import { esc, anteil } from './util';
|
||||||
|
import { icons } from './icons';
|
||||||
|
|
||||||
|
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
|
||||||
|
export type Kachel = {
|
||||||
|
id: string;
|
||||||
|
art: 'filme' | 'serien';
|
||||||
|
/** Die obere, fette Zeile. Bei einer Folge die Serie. */
|
||||||
|
titel: string;
|
||||||
|
/** Die zweite Zeile. Bei einer Folge der Folgentitel, sonst das Jahr. */
|
||||||
|
zweite?: string | null;
|
||||||
|
/** Die dritte Zeile — „S14 · F22". Nur bei Folgen. */
|
||||||
|
dritte?: string | null;
|
||||||
|
bild: string | null;
|
||||||
|
stand?: Stand | null;
|
||||||
|
/** Die Zahl offener Folgen, als Plakette oben rechts. */
|
||||||
|
offen?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- Umwandlungen ---------- */
|
||||||
|
|
||||||
|
export const alsKachel = (e: Film | Serie): Kachel => ({
|
||||||
|
id: e.id,
|
||||||
|
art: 'episodeCount' in e ? 'serien' : 'filme',
|
||||||
|
titel: e.title,
|
||||||
|
zweite: e.year ? String(e.year) : null,
|
||||||
|
bild: e.posterImage,
|
||||||
|
stand: 'state' in e ? e.state : null,
|
||||||
|
offen: 'unwatchedCount' in e ? e.unwatchedCount : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Eine Kachel aus „Weiterschauen" — bei Folgen dreizeilig, wie bei Plex. */
|
||||||
|
export const angefangenAlsKachel = (e: Angefangen): Kachel => e.kind === 'episode'
|
||||||
|
? {
|
||||||
|
id: e.id, art: 'serien',
|
||||||
|
titel: e.title,
|
||||||
|
zweite: e.subTitle,
|
||||||
|
dritte: `S${e.season} · F${e.episode}`,
|
||||||
|
bild: e.posterImage, stand: e.state,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: e.id, art: 'filme',
|
||||||
|
titel: e.title,
|
||||||
|
zweite: e.year ? String(e.year) : null,
|
||||||
|
bild: e.posterImage, stand: e.state,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- Die Kachel selbst ---------- */
|
||||||
|
|
||||||
|
/** Baut eine Plakatkachel.
|
||||||
|
*
|
||||||
|
* **Alles auf einer Kachel steht an einer Stelle**, weil sie an fünf Stellen
|
||||||
|
* vorkommt — Startseite, Mediathek, Vorschläge, Suche, Staffeln. Jede davon
|
||||||
|
* eigen zu zeichnen hiesse: der Haken für Gesehenes erscheint an vier
|
||||||
|
* Stellen und an der fünften nicht, und niemand weiss, warum. */
|
||||||
|
export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string) => void): HTMLElement {
|
||||||
|
const knopf = document.createElement('button');
|
||||||
|
knopf.className = 'kachel';
|
||||||
|
knopf.onclick = () => oeffnen(k.art, k.id);
|
||||||
|
|
||||||
|
const rahmen = document.createElement('div');
|
||||||
|
rahmen.className = 'plakat';
|
||||||
|
rahmen.setAttribute('role', 'img');
|
||||||
|
rahmen.setAttribute('aria-label', k.titel);
|
||||||
|
|
||||||
|
const adresse = bildAdresse(k.bild);
|
||||||
|
if (adresse) {
|
||||||
|
const bild = document.createElement('img');
|
||||||
|
bild.src = adresse;
|
||||||
|
bild.alt = '';
|
||||||
|
bild.loading = 'lazy';
|
||||||
|
// Erst einblenden, wenn es da ist — sonst blitzt der Platzhalter durch.
|
||||||
|
bild.onload = () => rahmen.classList.add('geladen');
|
||||||
|
rahmen.appendChild(bild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Der Haken sitzt in der Ecke, nicht in der Beschriftung.** So wie bei
|
||||||
|
// Plex: ein Blick über das Raster zeigt, was noch offen ist, ohne eine
|
||||||
|
// einzige Zeile zu lesen.
|
||||||
|
if (k.stand?.watched) {
|
||||||
|
const haken = document.createElement('span');
|
||||||
|
haken.className = 'ecke haken';
|
||||||
|
haken.innerHTML = icons.haken;
|
||||||
|
haken.title = 'Gesehen';
|
||||||
|
rahmen.appendChild(haken);
|
||||||
|
} else if (k.offen != null && k.offen > 0) {
|
||||||
|
const plakette = document.createElement('span');
|
||||||
|
plakette.className = 'ecke zahl';
|
||||||
|
plakette.textContent = String(k.offen);
|
||||||
|
plakette.title = `${k.offen} Folgen offen`;
|
||||||
|
rahmen.appendChild(plakette);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Balken unten — ab der ersten Sekunde, mit Mindestbreite, sonst wären
|
||||||
|
// zwei Minuten eines Zweistünders anderthalb Bildpunkte.
|
||||||
|
const wie = anteil(k.stand);
|
||||||
|
if (wie != null && wie > 0 && !k.stand?.watched) {
|
||||||
|
const balken = document.createElement('div');
|
||||||
|
balken.className = 'fortschritt';
|
||||||
|
const gefuellt = document.createElement('div');
|
||||||
|
gefuellt.style.width = `${Math.min(100, wie * 100)}%`;
|
||||||
|
balken.appendChild(gefuellt);
|
||||||
|
rahmen.appendChild(balken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Überfahren ein Abspielknopf in der Mitte — auch das von Plex.
|
||||||
|
const schweben = document.createElement('span');
|
||||||
|
schweben.className = 'schweber';
|
||||||
|
schweben.innerHTML = icons.abspielen;
|
||||||
|
rahmen.appendChild(schweben);
|
||||||
|
|
||||||
|
knopf.appendChild(rahmen);
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'kacheltext';
|
||||||
|
text.innerHTML =
|
||||||
|
`<strong>${esc(k.titel)}</strong>`
|
||||||
|
+ (k.zweite ? `<span>${esc(k.zweite)}</span>` : '')
|
||||||
|
+ (k.dritte ? `<span class="dritte">${esc(k.dritte)}</span>` : '');
|
||||||
|
knopf.appendChild(text);
|
||||||
|
|
||||||
|
return knopf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine waagerechte Reihe mit Überschrift und Pfeilen. */
|
||||||
|
export function reihe(
|
||||||
|
titel: string,
|
||||||
|
kacheln: HTMLElement[],
|
||||||
|
mehr?: { text: string; tun: () => void },
|
||||||
|
): HTMLElement {
|
||||||
|
const bereich = document.createElement('section');
|
||||||
|
bereich.className = 'reihe';
|
||||||
|
|
||||||
|
const kopf = document.createElement('div');
|
||||||
|
kopf.className = 'reihe-kopf';
|
||||||
|
|
||||||
|
const h = document.createElement('h2');
|
||||||
|
h.textContent = titel;
|
||||||
|
kopf.appendChild(h);
|
||||||
|
|
||||||
|
const streifen = document.createElement('div');
|
||||||
|
streifen.className = 'strip';
|
||||||
|
kacheln.forEach(k => streifen.appendChild(k));
|
||||||
|
|
||||||
|
// **Pfeile, weil eine Reihe mit zwanzig Kacheln nicht in den Bildschirm
|
||||||
|
// passt.** Scrollen mit dem Rad geht quer nur mit Umschalttaste, und ein
|
||||||
|
// Trackpad hat nicht jeder.
|
||||||
|
const steuerung = document.createElement('div');
|
||||||
|
steuerung.className = 'reihe-pfeile';
|
||||||
|
(['links', 'rechts'] as const).forEach(richtung => {
|
||||||
|
const p = document.createElement('button');
|
||||||
|
p.className = 'pfeil';
|
||||||
|
p.innerHTML = icons[richtung];
|
||||||
|
p.setAttribute('aria-label', richtung === 'links' ? 'Zurück' : 'Weiter');
|
||||||
|
p.onclick = () => streifen.scrollBy({
|
||||||
|
left: (richtung === 'links' ? -1 : 1) * Math.max(240, streifen.clientWidth * 0.8),
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
steuerung.appendChild(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mehr) {
|
||||||
|
const m = document.createElement('button');
|
||||||
|
m.className = 'mehr';
|
||||||
|
m.textContent = mehr.text;
|
||||||
|
m.onclick = mehr.tun;
|
||||||
|
kopf.appendChild(m);
|
||||||
|
}
|
||||||
|
kopf.appendChild(steuerung);
|
||||||
|
|
||||||
|
bereich.append(kopf, streifen);
|
||||||
|
return bereich;
|
||||||
|
}
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/* Kontrastrechnung nach WCAG 2.1 — dieselbe wie in der App.
|
|
||||||
----------------------------------------------------------------------------
|
|
||||||
Flaro lässt jede Farbe zu, aber ein dunkles Marineblau auf fast schwarzem
|
|
||||||
Grund ist als Schrift unlesbar. Solche Farben werden angehoben, bis sie den
|
|
||||||
geforderten Kontrast erreichen — und es wird gesagt, statt die Farbe
|
|
||||||
stillschweigend zu verändern.
|
|
||||||
|
|
||||||
Zuerst die Helligkeit, weil das den Farbton erhält: eine gewählte
|
|
||||||
Akzentfarbe soll erkennbar dieselbe bleiben. Reicht selbst volle Helligkeit
|
|
||||||
nicht, wird die Sättigung zurückgenommen, und die Farbe wandert Richtung
|
|
||||||
Weiß. */
|
|
||||||
|
|
||||||
export type RGB = [number, number, number];
|
|
||||||
|
|
||||||
const MINDESTENS = 4.5;
|
|
||||||
|
|
||||||
export function leuchtdichte([r, g, b]: RGB): number {
|
|
||||||
// Der Knick bei 0,03928 ist kein Rundungsfehler, sondern Teil der
|
|
||||||
// sRGB-Definition: unterhalb davon ist die Kurve linear.
|
|
||||||
const linear = (c: number): number =>
|
|
||||||
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
||||||
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verhaeltnis(a: RGB, b: RGB): number {
|
|
||||||
const la = leuchtdichte(a);
|
|
||||||
const lb = leuchtdichte(b);
|
|
||||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function lesbarMachen(farbe: RGB, grund: RGB): { farbe: RGB; geaendert: boolean } {
|
|
||||||
if (verhaeltnis(farbe, grund) >= MINDESTENS) return { farbe, geaendert: false };
|
|
||||||
|
|
||||||
const [h, s, v] = zuHSV(farbe);
|
|
||||||
// Beide Schritte sind monoton — mehr Helligkeit und weniger Sättigung
|
|
||||||
// erhöhen die Leuchtdichte immer. Deshalb genügt eine binäre Suche, und
|
|
||||||
// sie endet garantiert.
|
|
||||||
const suche = (tief: number, hoch: number, bauen: (x: number) => RGB): RGB | null => {
|
|
||||||
if (verhaeltnis(bauen(hoch), grund) < MINDESTENS) return null;
|
|
||||||
let u = tief;
|
|
||||||
let o = hoch;
|
|
||||||
for (let i = 0; i < 24; i++) {
|
|
||||||
const mitte = (u + o) / 2;
|
|
||||||
if (verhaeltnis(bauen(mitte), grund) >= MINDESTENS) o = mitte;
|
|
||||||
else u = mitte;
|
|
||||||
}
|
|
||||||
return bauen(o);
|
|
||||||
};
|
|
||||||
|
|
||||||
const heller = suche(v, 1, (x) => ausHSV(h, s, x));
|
|
||||||
if (heller) return { farbe: heller, geaendert: true };
|
|
||||||
|
|
||||||
const blasser = suche(s, 0, (x) => ausHSV(h, x, 1));
|
|
||||||
return { farbe: blasser ?? [1, 1, 1], geaendert: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function zuHSV([r, g, b]: RGB): RGB {
|
|
||||||
const max = Math.max(r, g, b);
|
|
||||||
const min = Math.min(r, g, b);
|
|
||||||
const d = max - min;
|
|
||||||
let h = 0;
|
|
||||||
if (d) {
|
|
||||||
if (max === r) h = ((g - b) / d) % 6;
|
|
||||||
else if (max === g) h = (b - r) / d + 2;
|
|
||||||
else h = (r - g) / d + 4;
|
|
||||||
h /= 6;
|
|
||||||
if (h < 0) h += 1;
|
|
||||||
}
|
|
||||||
return [h, max ? d / max : 0, max];
|
|
||||||
}
|
|
||||||
|
|
||||||
function ausHSV(h: number, s: number, v: number): RGB {
|
|
||||||
const i = Math.floor(h * 6);
|
|
||||||
const f = h * 6 - i;
|
|
||||||
const p = v * (1 - s);
|
|
||||||
const q = v * (1 - f * s);
|
|
||||||
const t = v * (1 - (1 - f) * s);
|
|
||||||
const saetze: RGB[] = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]];
|
|
||||||
return saetze[i % 6]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ausHex(hex: string): RGB | null {
|
|
||||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
||||||
if (!m) return null;
|
|
||||||
const n = parseInt(m[1]!, 16);
|
|
||||||
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function alsHex([r, g, b]: RGB): string {
|
|
||||||
return (
|
|
||||||
'#' +
|
|
||||||
[r, g, b]
|
|
||||||
.map((c) => Math.round(Math.min(Math.max(c, 0), 1) * 255).toString(16).padStart(2, '0'))
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { icons } from './icons';
|
||||||
|
import { knopf } from './util';
|
||||||
|
|
||||||
|
/* Dialoge über dem Inhalt statt Blöcken darin.
|
||||||
|
*
|
||||||
|
* **Mit dem `<dialog>` des Browsers, nicht mit einem nachgebauten.** Der
|
||||||
|
* kann, was ein `<div>` erst lernen müsste: Escape schliesst, der
|
||||||
|
* Hintergrund wird unbedienbar, der Tastaturfokus bleibt gefangen, und
|
||||||
|
* Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das selbst
|
||||||
|
* baut, ist eine Zeile, die es schlechter kann. */
|
||||||
|
|
||||||
|
export type Feldart = 'text' | 'password' | 'number';
|
||||||
|
|
||||||
|
export type Dialogfeld = {
|
||||||
|
name: string;
|
||||||
|
beschriftung: string;
|
||||||
|
art?: Feldart;
|
||||||
|
wert?: string;
|
||||||
|
platzhalter?: string;
|
||||||
|
hinweis?: string;
|
||||||
|
/** Eine Auswahl statt eines Feldes. */
|
||||||
|
auswahl?: { wert: string; text: string }[];
|
||||||
|
/** Ein Schalter statt eines Feldes. */
|
||||||
|
schalter?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Liest einen Feldwert — nie `undefined`, ein unbekannter Name ergibt „".
|
||||||
|
*
|
||||||
|
* Als Funktion statt als Objekt: ein Tippfehler im Feldnamen gäbe sonst
|
||||||
|
* stillschweigend `undefined` zurück und landete so im Server. */
|
||||||
|
export type Dialogwerte = (name: string) => string;
|
||||||
|
|
||||||
|
/** Zeigt einen Dialog mit Feldern und gibt zurück, was eingetippt wurde.
|
||||||
|
*
|
||||||
|
* `null` heisst abgebrochen — nicht „leer". Die beiden zu verwechseln hiesse,
|
||||||
|
* ein Abbrechen wie ein Speichern zu behandeln. */
|
||||||
|
export function dialog(optionen: {
|
||||||
|
titel: string;
|
||||||
|
text?: string;
|
||||||
|
felder?: Dialogfeld[];
|
||||||
|
bestaetigen: string;
|
||||||
|
/** Färbt den Knopf rot — für alles, was etwas wegnimmt. */
|
||||||
|
gefaehrlich?: boolean;
|
||||||
|
/** Wird mit den Werten gerufen; wirft sie, bleibt der Dialog offen. */
|
||||||
|
tun: (wert: Dialogwerte) => Promise<void>;
|
||||||
|
}): void {
|
||||||
|
const d = document.createElement('dialog');
|
||||||
|
d.className = 'dlg';
|
||||||
|
|
||||||
|
const kopf = document.createElement('div');
|
||||||
|
kopf.className = 'dlg-kopf';
|
||||||
|
const h = document.createElement('h2');
|
||||||
|
h.textContent = optionen.titel;
|
||||||
|
const zu = document.createElement('button');
|
||||||
|
zu.className = 'dlg-zu';
|
||||||
|
zu.innerHTML = icons.schliessen;
|
||||||
|
zu.setAttribute('aria-label', 'Schliessen');
|
||||||
|
zu.onclick = () => d.close();
|
||||||
|
kopf.append(h, zu);
|
||||||
|
|
||||||
|
const koerper = document.createElement('div');
|
||||||
|
koerper.className = 'dlg-koerper';
|
||||||
|
|
||||||
|
if (optionen.text) {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'hinweis';
|
||||||
|
p.textContent = optionen.text;
|
||||||
|
koerper.appendChild(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eingaben = new Map<string, HTMLInputElement | HTMLSelectElement>();
|
||||||
|
|
||||||
|
(optionen.felder ?? []).forEach(f => {
|
||||||
|
const block = document.createElement('label');
|
||||||
|
block.className = 'dlg-feld';
|
||||||
|
|
||||||
|
const titel = document.createElement('span');
|
||||||
|
titel.className = 'dlg-beschriftung';
|
||||||
|
titel.textContent = f.beschriftung;
|
||||||
|
block.appendChild(titel);
|
||||||
|
|
||||||
|
if (f.auswahl) {
|
||||||
|
const wahl = document.createElement('select');
|
||||||
|
wahl.className = 'feld';
|
||||||
|
f.auswahl.forEach(o => {
|
||||||
|
const e = document.createElement('option');
|
||||||
|
e.value = o.wert; e.textContent = o.text;
|
||||||
|
wahl.appendChild(e);
|
||||||
|
});
|
||||||
|
if (f.wert) wahl.value = f.wert;
|
||||||
|
eingaben.set(f.name, wahl);
|
||||||
|
block.appendChild(wahl);
|
||||||
|
} else if (f.schalter) {
|
||||||
|
const e = document.createElement('input');
|
||||||
|
e.type = 'checkbox';
|
||||||
|
e.checked = f.wert === 'true';
|
||||||
|
eingaben.set(f.name, e);
|
||||||
|
// Der Schalter steht vor seiner Beschriftung, nicht darunter.
|
||||||
|
block.classList.add('waagerecht');
|
||||||
|
block.prepend(e);
|
||||||
|
titel.remove();
|
||||||
|
block.append(titel);
|
||||||
|
} else {
|
||||||
|
const e = document.createElement('input');
|
||||||
|
e.className = 'feld';
|
||||||
|
e.type = f.art ?? 'text';
|
||||||
|
e.value = f.wert ?? '';
|
||||||
|
e.placeholder = f.platzhalter ?? '';
|
||||||
|
eingaben.set(f.name, e);
|
||||||
|
block.appendChild(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f.hinweis) {
|
||||||
|
const kleingedrucktes = document.createElement('span');
|
||||||
|
kleingedrucktes.className = 'winzig';
|
||||||
|
kleingedrucktes.textContent = f.hinweis;
|
||||||
|
block.appendChild(kleingedrucktes);
|
||||||
|
}
|
||||||
|
|
||||||
|
koerper.appendChild(block);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fehlerplatz = document.createElement('div');
|
||||||
|
koerper.appendChild(fehlerplatz);
|
||||||
|
|
||||||
|
const fuss = document.createElement('div');
|
||||||
|
fuss.className = 'dlg-fuss';
|
||||||
|
const abbrechen = knopf('Abbrechen', 'btn');
|
||||||
|
abbrechen.onclick = () => d.close();
|
||||||
|
const los = knopf(optionen.bestaetigen,
|
||||||
|
optionen.gefaehrlich ? 'btn btn-gefahr' : 'btn btn-primary');
|
||||||
|
fuss.append(abbrechen, los);
|
||||||
|
|
||||||
|
los.onclick = async () => {
|
||||||
|
fehlerplatz.innerHTML = '';
|
||||||
|
los.disabled = true;
|
||||||
|
const gesammelt = new Map<string, string>();
|
||||||
|
eingaben.forEach((e, name) => {
|
||||||
|
gesammelt.set(name, e instanceof HTMLInputElement && e.type === 'checkbox'
|
||||||
|
? String(e.checked)
|
||||||
|
: e.value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await optionen.tun(name => gesammelt.get(name) ?? '');
|
||||||
|
d.close();
|
||||||
|
} catch (fehler) {
|
||||||
|
// **Offen bleiben.** Einen Dialog bei einem Fehler zu schliessen
|
||||||
|
// hiesse, die Eingabe wegzuwerfen und die Meldung dorthin zu
|
||||||
|
// schreiben, wo gerade niemand hinsieht.
|
||||||
|
const m = document.createElement('div');
|
||||||
|
m.className = 'fehler';
|
||||||
|
m.textContent = (fehler as Error).message;
|
||||||
|
fehlerplatz.appendChild(m);
|
||||||
|
los.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
d.append(kopf, koerper, fuss);
|
||||||
|
// Der Dialog räumt sich selbst weg — sonst sammeln sich im Dokument
|
||||||
|
// beliebig viele geschlossene an.
|
||||||
|
d.addEventListener('close', () => d.remove());
|
||||||
|
// Ein Klick auf den Hintergrund schliesst, ein Klick in den Dialog nicht.
|
||||||
|
d.addEventListener('click', e => { if (e.target === d) d.close(); });
|
||||||
|
|
||||||
|
document.body.appendChild(d);
|
||||||
|
d.showModal();
|
||||||
|
|
||||||
|
const erstes = koerper.querySelector<HTMLElement>('input, select');
|
||||||
|
erstes?.focus();
|
||||||
|
|
||||||
|
// Enter im Feld bestätigt — ausser im mehrzeiligen Fall, den es hier
|
||||||
|
// nicht gibt.
|
||||||
|
koerper.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter' && !(e.target as HTMLElement).matches('select')) {
|
||||||
|
e.preventDefault();
|
||||||
|
los.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fragt einmal nach — für alles, was etwas wegnimmt. */
|
||||||
|
export function nachfragen(optionen: {
|
||||||
|
titel: string;
|
||||||
|
text: string;
|
||||||
|
bestaetigen: string;
|
||||||
|
tun: () => Promise<void>;
|
||||||
|
}): void {
|
||||||
|
dialog({
|
||||||
|
titel: optionen.titel,
|
||||||
|
text: optionen.text,
|
||||||
|
bestaetigen: optionen.bestaetigen,
|
||||||
|
gefaehrlich: true,
|
||||||
|
tun: () => optionen.tun(),
|
||||||
|
});
|
||||||
|
}
|
||||||
+45
-47
@@ -1,51 +1,49 @@
|
|||||||
/* Die Symbole der Oberfläche.
|
/* Icons als SVG, nicht als Zeichen aus einer Schriftart.
|
||||||
----------------------------------------------------------------------------
|
*
|
||||||
Als Vektor, nicht als Schriftzeichen. Ein Wiedergabedreieck oder ein
|
* **Keine Emojis.** Ein ▶ oder ✓ aus Unicode sieht auf jedem System anders
|
||||||
Lautsprecher-Emoji wird auf jeder Plattform anders gezeichnet, trägt fremde
|
* aus, lässt sich nicht einfärben, nicht in der Strichstärke anpassen und
|
||||||
Farben, sitzt schief auf der Grundlinie und lässt sich weder in der
|
* springt in der Grundlinie. Diese hier sind auf 24×24 gezeichnet, erben die
|
||||||
Strichstärke noch in der Farbe anpassen — es sieht nach Platzhalter aus,
|
* Textfarbe und skalieren mit der Schriftgrösse.
|
||||||
weil es einer ist.
|
*
|
||||||
|
* Der Strichstil ist durchgehend derselbe: 1.8 Einheiten, runde Enden. */
|
||||||
|
|
||||||
`currentColor` erbt Farbe und Zustand vom Knopf darum. */
|
const rahmen = (inhalt: string, gefuellt = false): string =>
|
||||||
|
`<svg viewBox="0 0 24 24" aria-hidden="true" fill="${gefuellt ? 'currentColor' : 'none'}"`
|
||||||
|
+ ` stroke="${gefuellt ? 'none' : 'currentColor'}" stroke-width="1.8"`
|
||||||
|
+ ` stroke-linecap="round" stroke-linejoin="round">${inhalt}</svg>`;
|
||||||
|
|
||||||
type Zeichner = string | ((n: number) => string);
|
export const icons = {
|
||||||
|
start: rahmen('<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>'),
|
||||||
|
/** Filmstreifen — für eine Filmmediathek. */
|
||||||
|
filme: rahmen('<rect x="3" y="4" width="18" height="16" rx="2"/>'
|
||||||
|
+ '<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>'),
|
||||||
|
/** Bildschirm — für eine Serienmediathek. */
|
||||||
|
serien: rahmen('<rect x="2.5" y="4.5" width="19" height="12" rx="2"/><path d="M8 20h8"/>'),
|
||||||
|
verwaltung: rahmen('<circle cx="12" cy="12" r="3.2"/>'
|
||||||
|
+ '<path d="M19.4 14.5a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.5 1.6 1.6 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z"/>'),
|
||||||
|
suche: rahmen('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
|
||||||
|
abspielen: rahmen('<path d="M8 5.5v13l11-6.5z"/>', true),
|
||||||
|
haken: rahmen('<path d="m5 12.5 4.5 4.5L19 7"/>'),
|
||||||
|
hakenKreis: rahmen('<circle cx="12" cy="12" r="9"/><path d="m8 12.3 2.8 2.7L16 9.6"/>'),
|
||||||
|
stift: rahmen('<path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z"/>'),
|
||||||
|
download: rahmen('<path d="M12 3v12"/><path d="m7.5 11 4.5 4.5L16.5 11"/><path d="M4 20h16"/>'),
|
||||||
|
links: rahmen('<path d="M15 5 8 12l7 7"/>'),
|
||||||
|
rechts: rahmen('<path d="m9 5 7 7-7 7"/>'),
|
||||||
|
mehr: rahmen('<circle cx="12" cy="5.5" r="1.3" fill="currentColor" stroke="none"/>'
|
||||||
|
+ '<circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"/>'
|
||||||
|
+ '<circle cx="12" cy="18.5" r="1.3" fill="currentColor" stroke="none"/>'),
|
||||||
|
abmelden: rahmen('<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3"/>'
|
||||||
|
+ '<path d="M10 16.5 14.5 12 10 7.5"/><path d="M14.5 12H4"/>'),
|
||||||
|
schliessen: rahmen('<path d="M6 6l12 12M18 6 6 18"/>'),
|
||||||
|
stern: rahmen('<path d="m12 3.6 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.8l5.9-.9z"/>'),
|
||||||
|
} as const;
|
||||||
|
|
||||||
const ICONS: Record<string, Zeichner> = {
|
export type IconName = keyof typeof icons;
|
||||||
play: '<path d="M8 5.5v13l11-6.5z" fill="currentColor" stroke="none"/>',
|
|
||||||
waveform: '<path d="M3 12h2M7 8v8M11 4.5v15M15 8v8M19 11h2"/>',
|
/** Ein Icon als Element — für alles, was nicht aus einer Zeichenkette gebaut wird. */
|
||||||
speed: '<path d="M12 20a8 8 0 100-16 8 8 0 000 16z"/><path d="M12 12l4-3"/>',
|
export function icon(name: IconName, klasse = 'icon'): HTMLElement {
|
||||||
// Kreispfeile mit der Sekundenzahl darin — dasselbe Bild wie
|
const h = document.createElement('span');
|
||||||
// `gobackward.10` und `goforward.30` auf dem iPad.
|
h.className = klasse;
|
||||||
goBack: (n) => `<path d="M11.5 4.5L7 8l4.5 3.5" /><path d="M7 8h4.5a7.5 7.5 0 11-7.2 9.6"/>`
|
h.innerHTML = icons[name];
|
||||||
+ `<text x="12" y="17.6" text-anchor="middle" font-size="7.5" font-weight="700"`
|
return h;
|
||||||
+ ` fill="currentColor" stroke="none" font-family="inherit">${n}</text>`,
|
|
||||||
goFwd: (n) => `<path d="M12.5 4.5L17 8l-4.5 3.5"/><path d="M17 8h-4.5a7.5 7.5 0 107.2 9.6"/>`
|
|
||||||
+ `<text x="12" y="17.6" text-anchor="middle" font-size="7.5" font-weight="700"`
|
|
||||||
+ ` fill="currentColor" stroke="none" font-family="inherit">${n}</text>`,
|
|
||||||
captions: '<rect x="3" y="5.5" width="18" height="13" rx="3"/><path d="M9.5 10.5a2.2 2.2 0 100 3M16 10.5a2.2 2.2 0 100 3"/>',
|
|
||||||
captionsOn: '<rect x="3" y="5.5" width="18" height="13" rx="3" fill="currentColor" stroke="none"/><path d="M9.5 10.5a2.2 2.2 0 100 3M16 10.5a2.2 2.2 0 100 3" stroke="#0B0B0D"/>',
|
|
||||||
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
||||||
// Rechteck mit Balken oben bzw. Spalte links — die Leiste zieht dorthin.
|
|
||||||
barTop: '<rect x="2" y="3.5" width="20" height="17" rx="3"/><path d="M2 9h20"/>'
|
|
||||||
+ '<rect x="2" y="3.5" width="20" height="5.5" rx="3" fill="currentColor" stroke="none" opacity=".9"/>',
|
|
||||||
barSide: '<rect x="2" y="3.5" width="20" height="17" rx="3"/><path d="M8.5 3.5v17"/>'
|
|
||||||
+ '<rect x="2" y="3.5" width="6.5" height="17" rx="3" fill="currentColor" stroke="none" opacity=".9"/>',
|
|
||||||
pause: '<path d="M9 5.5v13M15 5.5v13"/>',
|
|
||||||
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
|
|
||||||
sound: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9a4 4 0 010 6"/><path d="M18.5 6.5a7.5 7.5 0 010 11"/>',
|
|
||||||
muted: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9.5l5 5M21 9.5l-5 5"/>',
|
|
||||||
expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
|
|
||||||
left: '<path d="M14.5 5.5L8 12l6.5 6.5"/>',
|
|
||||||
right: '<path d="M9.5 5.5L16 12l-6.5 6.5"/>',
|
|
||||||
up: '<path d="M12 18.5v-13M6.5 11L12 5.5 17.5 11"/>',
|
|
||||||
down: '<path d="M12 5.5v13M6.5 13L12 18.5 17.5 13"/>',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ein Symbol als Vektor. `size` nur, wo es von der Vorgabe abweicht. */
|
|
||||||
export function icon(name: keyof typeof ICONS, size?: number, arg?: number): string {
|
|
||||||
const eintrag = ICONS[name];
|
|
||||||
const inhalt = typeof eintrag === 'function' ? eintrag(arg ?? 0) : eintrag;
|
|
||||||
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
|
|
||||||
${size ? `style="width:${size}px;height:${size}px"` : ''}>${inhalt}</svg>`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+189
-171
@@ -1,193 +1,211 @@
|
|||||||
import './style.css';
|
import './style.css';
|
||||||
import type { Item, Seite } from './types';
|
import {
|
||||||
import { api, sitzung, angemeldet, abmelden } from './api';
|
systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken,
|
||||||
import { P, anwenden, setzen } from './prefs';
|
type Hinweis, type Bibliothek,
|
||||||
import { icon } from './icons';
|
} from './api';
|
||||||
import { esc, FELDER } from './util';
|
import { icons, icon } from './icons';
|
||||||
|
import { einrichtung } from './views/setup';
|
||||||
import { anmeldung } from './views/login';
|
import { anmeldung } from './views/login';
|
||||||
import { startseite } from './views/home';
|
import { startseite } from './views/home';
|
||||||
import { mediathek } from './views/library';
|
import { mediathek } from './views/library';
|
||||||
import { einstellungen } from './views/settings';
|
import { detailseite } from './views/detail';
|
||||||
import { blatt } from './views/cards';
|
import { verwaltung } from './views/admin';
|
||||||
import { playerOeffnen } from './views/player';
|
import { suchseite } from './views/search';
|
||||||
|
import { esc } from './util';
|
||||||
|
|
||||||
|
const anmeldeflaeche = document.getElementById('login')!;
|
||||||
|
const kopfleiste = document.getElementById('topbar')!;
|
||||||
const app = document.getElementById('app')!;
|
const app = document.getElementById('app')!;
|
||||||
|
const leiste = document.getElementById('chrome')!;
|
||||||
const inhalt = document.getElementById('content')!;
|
const inhalt = document.getElementById('content')!;
|
||||||
const insel = document.getElementById('island')!;
|
|
||||||
const chrome = document.getElementById('chrome')!;
|
|
||||||
|
|
||||||
let ansichten: Item[] = [];
|
let hinweise: Hinweis[] = [];
|
||||||
let wo = 'home';
|
let mediatheken: Bibliothek[] = [];
|
||||||
|
let serverName = 'Flaro';
|
||||||
|
|
||||||
/* ---------- Navigation ---------- */
|
/* ---------- Start ---------- */
|
||||||
|
|
||||||
function inselBauen(): void {
|
void hochfahren();
|
||||||
insel.innerHTML = '';
|
|
||||||
|
|
||||||
// Der Umschalter zuerst, wie auf dem iPad: an der Seite oben in der Ecke,
|
async function hochfahren(): Promise<void> {
|
||||||
// oben ganz links vor den Einträgen.
|
try {
|
||||||
const nav = document.createElement('button');
|
const auskunft = await systemauskunft();
|
||||||
nav.className = 'toggle-nav';
|
hinweise = auskunft.attributions;
|
||||||
nav.title = P.nav === 'side' ? 'Leiste nach oben' : 'Leiste an die Seite';
|
serverName = auskunft.serverName ?? 'Flaro';
|
||||||
nav.setAttribute('aria-label', nav.title);
|
|
||||||
nav.innerHTML = icon(P.nav === 'side' ? 'barTop' : 'barSide', 18);
|
|
||||||
nav.onclick = () => leisteUmziehen(P.nav === 'side' ? 'top' : 'side');
|
|
||||||
insel.appendChild(nav);
|
|
||||||
|
|
||||||
const stueck = (klasse: string, text?: string): void => {
|
if (auskunft.needsSetup) { zeigeAnmeldung(einrichtung(() => location.reload())); return; }
|
||||||
|
if (!angemeldet()) { zeigeAnmeldung(anmeldung(() => location.reload())); return; }
|
||||||
|
|
||||||
|
anmeldeflaeche.classList.add('hidden');
|
||||||
|
kopfleiste.classList.remove('hidden');
|
||||||
|
app.classList.remove('hidden');
|
||||||
|
|
||||||
|
// **Vor dem ersten Zeichnen.** Die Seitenleiste führt jede Mediathek
|
||||||
|
// einzeln auf; ohne die Liste könnte der Router `#/m/{id}` nicht
|
||||||
|
// auflösen.
|
||||||
|
mediatheken = await bibliotheken();
|
||||||
|
|
||||||
|
kopfleisteBauen();
|
||||||
|
leisteBauen();
|
||||||
|
window.addEventListener('hashchange', zeichnen);
|
||||||
|
zeichnen();
|
||||||
|
} catch (fehler) {
|
||||||
|
// Der Server ist nicht erreichbar. Die Anmeldemaske zu zeigen wäre eine
|
||||||
|
// Lüge — sie würde bei jedem Versuch scheitern.
|
||||||
|
anmeldeflaeche.innerHTML =
|
||||||
|
`<div class="login-card"><h1>Flaro</h1>`
|
||||||
|
+ `<div class="fehler">Der Server antwortet nicht: ${esc((fehler as Error).message)}</div>`
|
||||||
|
+ `<div class="hinweis">Läuft er? Stimmt die Adresse?</div></div>`;
|
||||||
|
anmeldeflaeche.classList.remove('hidden');
|
||||||
|
kopfleiste.classList.add('hidden');
|
||||||
|
app.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeAnmeldung(karte: HTMLElement): void {
|
||||||
|
anmeldeflaeche.innerHTML = '';
|
||||||
|
anmeldeflaeche.appendChild(karte);
|
||||||
|
anmeldeflaeche.classList.remove('hidden');
|
||||||
|
kopfleiste.classList.add('hidden');
|
||||||
|
app.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mediathekenNeuLaden(): Promise<void> {
|
||||||
|
mediatheken = await bibliotheken();
|
||||||
|
leisteBauen();
|
||||||
|
zeichnen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Kopfleiste ---------- */
|
||||||
|
|
||||||
|
function kopfleisteBauen(): void {
|
||||||
|
kopfleiste.innerHTML = '';
|
||||||
|
|
||||||
|
const marke = document.createElement('div');
|
||||||
|
marke.className = 'marke';
|
||||||
|
marke.textContent = 'Flaro';
|
||||||
|
|
||||||
|
// **Die Suche gehört nach oben, nicht in jede Mediathek.** Bei Plex steht
|
||||||
|
// sie in der Kopfleiste und geht quer durch alles; wer einen Titel sucht,
|
||||||
|
// weiss oft gar nicht, in welcher Mediathek er liegt.
|
||||||
|
const suche = document.createElement('form');
|
||||||
|
suche.className = 'suchfeld';
|
||||||
|
suche.innerHTML = icons.suche;
|
||||||
|
const eingabe = document.createElement('input');
|
||||||
|
eingabe.type = 'search';
|
||||||
|
eingabe.placeholder = 'Suchen';
|
||||||
|
eingabe.setAttribute('aria-label', 'Mediathek durchsuchen');
|
||||||
|
suche.appendChild(eingabe);
|
||||||
|
suche.onsubmit = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = eingabe.value.trim();
|
||||||
|
if (text) location.hash = `#/suche/${encodeURIComponent(text)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rechts = document.createElement('div');
|
||||||
|
rechts.className = 'rechts';
|
||||||
|
|
||||||
|
const konto = document.createElement('button');
|
||||||
|
konto.className = 'konto';
|
||||||
|
const name = sitzung.wer?.userName ?? '';
|
||||||
|
konto.innerHTML = `<span class="kreis">${esc(name.slice(0, 1).toUpperCase())}</span>`;
|
||||||
|
konto.title = `${name} — abmelden`;
|
||||||
|
konto.appendChild(icon('abmelden'));
|
||||||
|
konto.onclick = () => { abmelden(); location.reload(); };
|
||||||
|
rechts.appendChild(konto);
|
||||||
|
|
||||||
|
kopfleiste.append(marke, suche, rechts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Seitenleiste ---------- */
|
||||||
|
|
||||||
|
function leisteBauen(): void {
|
||||||
|
leiste.innerHTML = '';
|
||||||
|
|
||||||
|
eintrag('Start', '#/', 'start');
|
||||||
|
|
||||||
|
// **Jede Mediathek ihre eigene Zeile**, mit eigenem Icon und Zahl — eine
|
||||||
|
// Zeile „Filme", hinter der zwei getrennt angelegte Bestände liegen, nähme
|
||||||
|
// die Entscheidung zurück, sie zu trennen.
|
||||||
|
if (mediatheken.length) {
|
||||||
|
gruppe('Mediatheken');
|
||||||
|
mediatheken.forEach(b => eintrag(
|
||||||
|
b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung', 'verwaltung'); }
|
||||||
|
|
||||||
|
function gruppe(text: string): void {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.className = klasse;
|
d.className = 'nav-gruppe';
|
||||||
if (text) d.textContent = text;
|
d.textContent = text;
|
||||||
insel.appendChild(d);
|
leiste.appendChild(d);
|
||||||
};
|
}
|
||||||
const eintrag = (text: string, ziel: string): void => {
|
|
||||||
|
function eintrag(
|
||||||
|
text: string, ziel: string, bild: keyof typeof icons, anzahl?: number | null,
|
||||||
|
): void {
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
b.textContent = text;
|
b.dataset.ziel = ziel;
|
||||||
b.dataset.key = ziel;
|
b.innerHTML = `<span class="icon">${icons[bild]}</span>`
|
||||||
b.onclick = () => gehe(ziel);
|
+ `<span class="name">${esc(text)}</span>`
|
||||||
insel.appendChild(b);
|
+ (anzahl != null ? `<span class="zahl">${anzahl}</span>` : '');
|
||||||
};
|
b.onclick = () => { location.hash = ziel; };
|
||||||
|
leiste.appendChild(b);
|
||||||
stueck('brand', 'Flaro');
|
|
||||||
eintrag('Start', 'home');
|
|
||||||
eintrag('Einstellungen', 'settings');
|
|
||||||
|
|
||||||
if (ansichten.length) {
|
|
||||||
stueck('sep');
|
|
||||||
stueck('group-label', 'Mediatheken');
|
|
||||||
for (const v of ansichten) eintrag(v.Name ?? '—', 'lib:' + v.Id);
|
|
||||||
}
|
|
||||||
stueck('sep');
|
|
||||||
eintrag('Abmelden', 'out');
|
|
||||||
|
|
||||||
[...insel.querySelectorAll('button[data-key]')].forEach((b) =>
|
|
||||||
b.classList.toggle('active', (b as HTMLElement).dataset.key === wo));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lässt die Leiste von ihrer alten Lage in die neue fahren.
|
|
||||||
----------------------------------------------------------------------------
|
|
||||||
**Zwei Züge, nicht einer.** Ein reiner Weg von A nach B sieht aus, als würde
|
|
||||||
ein Kasten verschoben. Die Leiste zieht sich erst *in sich* zusammen und
|
|
||||||
zieht dann erst um — wie ein Gummiband, das sich sammelt, bevor es schnellt.
|
|
||||||
|
|
||||||
Gemacht mit FLIP: alte Lage messen, umbauen, neue Lage messen, und vom alten
|
|
||||||
Zustand in den neuen fahren. Über CSS allein ginge das nicht — `right: auto`
|
|
||||||
und `bottom: auto` lassen sich nicht animieren, und genau die wechseln. */
|
|
||||||
function leisteUmziehen(nach: 'top' | 'side'): void {
|
|
||||||
const vorher = insel.getBoundingClientRect();
|
|
||||||
const sparsam = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
||||||
|
|
||||||
setzen('nav', nach);
|
|
||||||
inselBauen();
|
|
||||||
|
|
||||||
if (sparsam) return;
|
|
||||||
const nachher = insel.getBoundingClientRect();
|
|
||||||
if (!vorher.width || !nachher.width) return;
|
|
||||||
|
|
||||||
const dx = vorher.left - nachher.left;
|
|
||||||
const dy = vorher.top - nachher.top;
|
|
||||||
const sx = vorher.width / nachher.width;
|
|
||||||
const sy = vorher.height / nachher.height;
|
|
||||||
|
|
||||||
// Welche Richtung sich zuerst sammelt, hängt davon ab, wohin es geht: Wer
|
|
||||||
// nach oben zieht, wird flach — also klappt zuerst die Höhe zusammen.
|
|
||||||
const gesammelt = nach === 'top'
|
|
||||||
? `translate(${dx}px, ${dy}px) scale(${sx}, 1)`
|
|
||||||
: `translate(${dx}px, ${dy}px) scale(1, ${sy})`;
|
|
||||||
|
|
||||||
insel.animate(
|
|
||||||
[
|
|
||||||
{ transformOrigin: 'top left', transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`,
|
|
||||||
easing: 'cubic-bezier(.5,0,.75,0)', offset: 0 },
|
|
||||||
{ transformOrigin: 'top left', transform: gesammelt,
|
|
||||||
easing: 'cubic-bezier(.22,1,.36,1)', offset: 0.42 },
|
|
||||||
{ transformOrigin: 'top left', transform: 'none', offset: 1 },
|
|
||||||
],
|
|
||||||
{ duration: 520 },
|
|
||||||
);
|
|
||||||
insel.animate([{ opacity: 0.35 }, { opacity: 0.5, offset: 0.42 }, { opacity: 1 }],
|
|
||||||
{ duration: 420, easing: 'ease-out' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function gehe(ziel: string): void {
|
|
||||||
if (ziel === 'out') return abmelden();
|
|
||||||
wo = ziel;
|
|
||||||
[...insel.querySelectorAll('button[data-key]')].forEach((b) =>
|
|
||||||
b.classList.toggle('active', (b as HTMLElement).dataset.key === ziel));
|
|
||||||
scrollTo({ top: 0 });
|
|
||||||
inhalt.classList.remove('pad-top');
|
|
||||||
|
|
||||||
if (ziel === 'home') void startseite(inhalt, ansichten, tippen);
|
|
||||||
else if (ziel === 'settings') einstellungen(inhalt, () => gehe('home'));
|
|
||||||
else void mediathek(inhalt, ziel.slice(4), tippen);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Was beim Antippen geschieht ---------- */
|
|
||||||
|
|
||||||
function tippen(item: Item): void {
|
|
||||||
blatt(item, starten);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Startet — und bei einer Serie die Folge, die als Nächstes dran ist.
|
|
||||||
*
|
|
||||||
* Eine Serie gar nicht abzuspielen war die falsche Antwort: die
|
|
||||||
* offensichtliche Erwartung ist, dass es losgeht. */
|
|
||||||
async function starten(item: Item): Promise<void> {
|
|
||||||
const ordner = ['Series', 'Season'].includes(item.Type ?? '');
|
|
||||||
const ziel = ordner ? await naechsteFolge(item) : item;
|
|
||||||
if (!ziel) return;
|
|
||||||
playerOeffnen(ziel, () => { if (wo === 'home') void startseite(inhalt, ansichten, tippen); });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function naechsteFolge(item: Item): Promise<Item | null> {
|
|
||||||
const serie = item.Type === 'Season' ? (item.SeriesId ?? item.Id) : item.Id;
|
|
||||||
try {
|
|
||||||
// Erst „Als Nächstes": wer eine Serie anfängt und zurückkommt, meint die
|
|
||||||
// Folge, bei der er stehengeblieben ist — nicht die erste.
|
|
||||||
const weiter = await api<Seite>('/Shows/NextUp?' + new URLSearchParams({
|
|
||||||
userId: sitzung.userId, seriesId: serie, Limit: '1', Fields: FELDER,
|
|
||||||
}));
|
|
||||||
const folge = weiter.Items?.[0];
|
|
||||||
if (folge && (item.Type !== 'Season' || folge.SeasonId === item.Id)) return folge;
|
|
||||||
} catch { /* dann eben die erste */ }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const q: Record<string, string> = { userId: sitzung.userId, Limit: '1', Fields: FELDER };
|
|
||||||
if (item.Type === 'Season') q.seasonId = item.Id;
|
|
||||||
const alle = await api<Seite>(`/Shows/${serie}/Episodes?` + new URLSearchParams(q));
|
|
||||||
return alle.Items?.[0] ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Los ---------- */
|
/* ---------- Router ---------- */
|
||||||
|
|
||||||
async function starte(): Promise<void> {
|
function zeichnen(): void {
|
||||||
document.getElementById('login')!.classList.add('hidden');
|
const weg = location.hash.replace(/^#\/?/, '');
|
||||||
app.classList.remove('hidden');
|
const teile = weg.split('/').filter(Boolean);
|
||||||
anwenden();
|
|
||||||
inhalt.innerHTML = '<div class="spinner"></div>';
|
|
||||||
|
|
||||||
try {
|
leiste.querySelectorAll('button[data-ziel]').forEach(b => {
|
||||||
const r = await api<Seite>(`/Users/${sitzung.userId}/Views`);
|
const ziel = (b as HTMLElement).dataset.ziel!;
|
||||||
ansichten = (r.Items ?? []).filter((v) => ['movies', 'tvshows'].includes(v.CollectionType ?? ''));
|
b.classList.toggle('check', ziel === `#/${teile.slice(0, 2).join('/')}`);
|
||||||
inselBauen();
|
});
|
||||||
gehe('home');
|
|
||||||
} catch (e) {
|
inhalt.innerHTML = '';
|
||||||
inhalt.innerHTML = `<div class="empty pad-top">${esc((e as Error).message)}<br><br>
|
window.scrollTo(0, 0);
|
||||||
<button class="btn" id="raus">Abmelden</button></div>`;
|
|
||||||
document.getElementById('raus')!.onclick = abmelden;
|
const oeffnen = (art: 'filme' | 'serien', id: string) => { location.hash = `#/${art}/${id}`; };
|
||||||
|
const alleZeigen = (b: Bibliothek) => { location.hash = `#/m/${b.id}`; };
|
||||||
|
|
||||||
|
const art = teile[0] === 'filme' || teile[0] === 'serien' ? teile[0] : null;
|
||||||
|
const kennung = teile[1];
|
||||||
|
|
||||||
|
if (art && kennung) {
|
||||||
|
inhalt.appendChild(detailseite(art, kennung, () => history.back()));
|
||||||
|
} else if (teile[0] === 'm' && kennung) {
|
||||||
|
const b = mediatheken.find(x => x.id === kennung);
|
||||||
|
// Eine Mediathek, die es nicht mehr gibt — etwa, weil sie in einem
|
||||||
|
// anderen Fenster gelöscht wurde. Zurück auf Start ist hier richtiger
|
||||||
|
// als eine Fehlermeldung über eine Kennung, die niemand gelesen hat.
|
||||||
|
if (b) inhalt.appendChild(mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen));
|
||||||
|
else location.hash = '#/';
|
||||||
|
} else if (teile[0] === 'suche' && kennung) {
|
||||||
|
inhalt.appendChild(suchseite(decodeURIComponent(kennung), oeffnen));
|
||||||
|
} else if (teile[0] === 'verwaltung' && istAdmin()) {
|
||||||
|
inhalt.appendChild(verwaltung(mediathekenNeuLaden));
|
||||||
|
} else {
|
||||||
|
inhalt.appendChild(startseite(mediatheken, oeffnen, alleZeigen));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inhalt.appendChild(hinweisleiste());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die Insel oben fährt beim Scrollen weg und kommt zurück, sobald man wieder
|
/** Der Pflichthinweis auf TMDB — Bedingung ihrer Lizenz, nicht Höflichkeit. */
|
||||||
// oben steht. An der Seite bleibt sie stehen — dort nimmt sie niemandem etwas.
|
function hinweisleiste(): HTMLElement {
|
||||||
addEventListener('scroll', () => {
|
const d = document.createElement('div');
|
||||||
if (P.nav === 'side') return;
|
d.className = 'attribution';
|
||||||
chrome.classList.toggle('away', scrollY > 8);
|
hinweise.forEach(h => {
|
||||||
}, { passive: true });
|
const a = document.createElement('a');
|
||||||
|
a.href = h.url;
|
||||||
anwenden();
|
a.target = '_blank';
|
||||||
if (angemeldet()) void starte();
|
a.rel = 'noreferrer';
|
||||||
else anmeldung(document.getElementById('login')!, () => void starte());
|
a.innerHTML = `<img src="${esc(h.logoUrl)}" alt="TMDB">`;
|
||||||
|
d.append(a, document.createTextNode(h.text));
|
||||||
|
});
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
/* Die Einstellungen.
|
|
||||||
----------------------------------------------------------------------------
|
|
||||||
**Pro Gerät, nicht über den Server.** Am Fernseher will man größere Kacheln
|
|
||||||
als am Laptop, und das eine soll das andere nicht umstellen. Dieselbe
|
|
||||||
Entscheidung wie in der App, dort nachzulesen in der Spec zum Abgleich.
|
|
||||||
|
|
||||||
Namen und Wertebereiche decken sich mit denen der App — ein Regler von
|
|
||||||
70 bis 180 Prozent heißt auf beiden Seiten dasselbe. Nur der Speicherort
|
|
||||||
unterscheidet sich, hier localStorage statt UserDefaults. */
|
|
||||||
|
|
||||||
export type Navigation = 'top' | 'side';
|
|
||||||
export type Aufmacherquelle = 'continueFirst' | 'latestOnly';
|
|
||||||
|
|
||||||
interface Einstellungen {
|
|
||||||
accent: string;
|
|
||||||
nav: Navigation;
|
|
||||||
cardW: number;
|
|
||||||
heroSource: Aufmacherquelle;
|
|
||||||
heroCount: number;
|
|
||||||
heroHeight: number;
|
|
||||||
combineContinue: boolean;
|
|
||||||
hideWatched: boolean;
|
|
||||||
hiddenRows: string[];
|
|
||||||
rowOrder: string[];
|
|
||||||
rate: number;
|
|
||||||
skipForward: number;
|
|
||||||
skipBackward: number;
|
|
||||||
quality: number;
|
|
||||||
audioLanguage: string;
|
|
||||||
subtitleLanguage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vorgabe: Einstellungen = {
|
|
||||||
accent: '#C04AC9',
|
|
||||||
nav: 'top',
|
|
||||||
cardW: 150,
|
|
||||||
heroSource: 'continueFirst',
|
|
||||||
heroCount: 5,
|
|
||||||
heroHeight: 82,
|
|
||||||
combineContinue: true,
|
|
||||||
hideWatched: false,
|
|
||||||
hiddenRows: [],
|
|
||||||
rowOrder: [],
|
|
||||||
rate: 1,
|
|
||||||
skipForward: 30,
|
|
||||||
skipBackward: 10,
|
|
||||||
quality: 0,
|
|
||||||
audioLanguage: '',
|
|
||||||
subtitleLanguage: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
function lesen<K extends keyof Einstellungen>(schluessel: K): Einstellungen[K] {
|
|
||||||
const roh = localStorage.getItem('flaro.' + schluessel);
|
|
||||||
if (roh == null) return vorgabe[schluessel];
|
|
||||||
const art = typeof vorgabe[schluessel];
|
|
||||||
if (Array.isArray(vorgabe[schluessel])) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(roh) as Einstellungen[K];
|
|
||||||
} catch {
|
|
||||||
return vorgabe[schluessel];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (art === 'number') return Number(roh) as Einstellungen[K];
|
|
||||||
if (art === 'boolean') return (roh === 'true') as Einstellungen[K];
|
|
||||||
return roh as Einstellungen[K];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ausgeschrieben statt über `Object.fromEntries` zusammengesetzt: dort geht
|
|
||||||
der Typ jedes einzelnen Feldes verloren, und es bliebe nur ein doppelter
|
|
||||||
Cast, der die Prüfung stilllegt statt sie zu bestehen. */
|
|
||||||
export const P: Einstellungen = {
|
|
||||||
accent: lesen('accent'),
|
|
||||||
nav: lesen('nav'),
|
|
||||||
cardW: lesen('cardW'),
|
|
||||||
heroSource: lesen('heroSource'),
|
|
||||||
heroCount: lesen('heroCount'),
|
|
||||||
heroHeight: lesen('heroHeight'),
|
|
||||||
combineContinue: lesen('combineContinue'),
|
|
||||||
hideWatched: lesen('hideWatched'),
|
|
||||||
hiddenRows: lesen('hiddenRows'),
|
|
||||||
rowOrder: lesen('rowOrder'),
|
|
||||||
rate: lesen('rate'),
|
|
||||||
skipForward: lesen('skipForward'),
|
|
||||||
skipBackward: lesen('skipBackward'),
|
|
||||||
quality: lesen('quality'),
|
|
||||||
audioLanguage: lesen('audioLanguage'),
|
|
||||||
subtitleLanguage: lesen('subtitleLanguage'),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function setzen<K extends keyof Einstellungen>(schluessel: K, wert: Einstellungen[K]): void {
|
|
||||||
P[schluessel] = wert;
|
|
||||||
// Listen müssen als JSON abgelegt werden — ein Array landete sonst als
|
|
||||||
// "a,b,c" im Speicher und käme als Zeichenkette zurück.
|
|
||||||
localStorage.setItem(
|
|
||||||
'flaro.' + schluessel,
|
|
||||||
Array.isArray(wert) ? JSON.stringify(wert) : String(wert),
|
|
||||||
);
|
|
||||||
anwenden();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function anwenden(): void {
|
|
||||||
const r = document.documentElement.style;
|
|
||||||
r.setProperty('--accent', P.accent);
|
|
||||||
r.setProperty('--card-w', P.cardW + 'px');
|
|
||||||
r.setProperty('--hero-h', P.heroHeight + 'vh');
|
|
||||||
document.getElementById('app')?.classList.toggle('side', P.nav === 'side');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Die Palette der App, damit die Farben sich decken. */
|
|
||||||
export const PALETTE: readonly [string, string][] = [
|
|
||||||
['Ember', '#E5484D'], ['Tangerine', '#F76B15'], ['Amber', '#FFB224'],
|
|
||||||
['Lime', '#99D52A'], ['Mint', '#3DD68C'], ['Teal', '#12A594'],
|
|
||||||
['Sky', '#52A9FF'], ['Indigo', '#7C77E8'], ['Orchid', '#C04AC9'],
|
|
||||||
['Rose', '#F76190'],
|
|
||||||
];
|
|
||||||
|
|
||||||
export const RATES = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0] as const;
|
|
||||||
export const SKIPS = [5, 10, 15, 30, 45] as const;
|
|
||||||
export const QUALITIES: readonly [number, string][] = [
|
|
||||||
[0, 'Original'], [12000, 'Hoch — 12 Mbit/s'], [8000, 'Mittel — 8 Mbit/s'],
|
|
||||||
[4000, 'Niedrig — 4 Mbit/s'], [2000, 'Sparsam — 2 Mbit/s'],
|
|
||||||
];
|
|
||||||
export const LANGUAGES: readonly [string, string][] = [
|
|
||||||
['', 'Serverstandard'], ['ger', 'Deutsch'], ['eng', 'Englisch'],
|
|
||||||
['fre', 'Französisch'], ['spa', 'Spanisch'], ['ita', 'Italienisch'],
|
|
||||||
['jpn', 'Japanisch'], ['kor', 'Koreanisch'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/* Anordnung der Reihen — dieselbe Rechnung wie `Arrangement` in MediaKit,
|
|
||||||
samt ihrer wichtigsten Eigenschaft: **unbekannte Einträge landen am Ende
|
|
||||||
statt zu verschwinden.** Sie zu verwerfen wäre die einfachere Regel — und
|
|
||||||
der Grund dafür, dass eine neu angelegte Mediathek nie aufgetaucht wäre. */
|
|
||||||
export function anordnen<T extends { id: string }>(reihen: T[]): T[] {
|
|
||||||
const sichtbar = reihen.filter((r) => !P.hiddenRows.includes(r.id));
|
|
||||||
if (!P.rowOrder.length) return sichtbar;
|
|
||||||
|
|
||||||
const platz = new Map(P.rowOrder.map((id, i) => [id, i]));
|
|
||||||
return sichtbar
|
|
||||||
.map((reihe, i) => ({ reihe, rang: platz.get(reihe.id) ?? Infinity, i }))
|
|
||||||
// Bei gleichem Rang gilt die ursprüngliche Folge — sonst springen zwei
|
|
||||||
// unbekannte Reihen bei jedem Laden gegeneinander.
|
|
||||||
.sort((a, b) => a.rang - b.rang || a.i - b.i)
|
|
||||||
.map((x) => x.reihe);
|
|
||||||
}
|
|
||||||
+508
-497
File diff suppressed because it is too large
Load Diff
@@ -1,58 +0,0 @@
|
|||||||
/** Was Jellyfin über einen Eintrag schickt — nur das, was wir lesen. */
|
|
||||||
export interface Item {
|
|
||||||
Id: string;
|
|
||||||
Name?: string;
|
|
||||||
Type?: string;
|
|
||||||
Overview?: string;
|
|
||||||
ProductionYear?: number;
|
|
||||||
RunTimeTicks?: number;
|
|
||||||
OfficialRating?: string;
|
|
||||||
IndexNumber?: number;
|
|
||||||
ParentIndexNumber?: number;
|
|
||||||
SeriesName?: string;
|
|
||||||
SeriesId?: string;
|
|
||||||
SeasonId?: string;
|
|
||||||
CollectionType?: string;
|
|
||||||
ImageTags?: Record<string, string>;
|
|
||||||
BackdropImageTags?: string[];
|
|
||||||
ParentBackdropItemId?: string;
|
|
||||||
ParentBackdropImageTags?: string[];
|
|
||||||
SeriesPrimaryImageTag?: string;
|
|
||||||
UserData?: { PlayedPercentage?: number; Played?: boolean };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Seite<T = Item> {
|
|
||||||
Items?: T[];
|
|
||||||
TotalRecordCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Eine Spur, wie der Server sie in der Wiedergabe-Auskunft nennt. */
|
|
||||||
export interface Spur {
|
|
||||||
Type: 'Video' | 'Audio' | 'Subtitle';
|
|
||||||
Index: number;
|
|
||||||
DisplayTitle?: string;
|
|
||||||
Language?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Quelle {
|
|
||||||
Id?: string;
|
|
||||||
Container?: string;
|
|
||||||
TranscodingUrl?: string;
|
|
||||||
SupportsTranscoding?: boolean;
|
|
||||||
MediaStreams?: Spur[];
|
|
||||||
DefaultAudioStreamIndex?: number;
|
|
||||||
DefaultSubtitleStreamIndex?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Wiedergabeauskunft {
|
|
||||||
PlaySessionId?: string;
|
|
||||||
MediaSources?: Quelle[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Eine Reihe auf der Startseite. */
|
|
||||||
export interface Reihe {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
items: Item[];
|
|
||||||
wide?: boolean;
|
|
||||||
}
|
|
||||||
+51
-60
@@ -1,74 +1,65 @@
|
|||||||
import type { Item } from './types';
|
|
||||||
import { medienURL } from './api';
|
|
||||||
|
|
||||||
/** Entschärft Text, der in HTML landet. */
|
|
||||||
export function esc(wert: unknown): string {
|
export function esc(wert: unknown): string {
|
||||||
return String(wert ?? '').replace(/[&<>"']/g, (c) =>
|
return String(wert ?? '').replace(/[&<>"']/g, z =>
|
||||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[z]!));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Laufzeit als „2 Std 16 Min" oder „46 Min". */
|
/** Sekunden als 1:02:03 oder 2:03. */
|
||||||
export function laufzeit(item: Item): string {
|
export function dauer(sekunden: number | null): string {
|
||||||
if (!item.RunTimeTicks) return '';
|
if (!sekunden || sekunden <= 0) return '';
|
||||||
const min = Math.round(item.RunTimeTicks / 600_000_000);
|
const s = Math.round(sekunden);
|
||||||
return min >= 60 ? `${Math.floor(min / 60)} Std ${min % 60} Min` : `${min} Min`;
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
||||||
|
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||||
|
: `${m}:${String(s % 60).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zeit als Uhrwerk: „1:02:03" oder „7:15". */
|
export function groesse(bytes: number): string {
|
||||||
export function uhr(sekunden: number): string {
|
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||||
if (!isFinite(sekunden)) return '–:--';
|
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)} MB`;
|
||||||
const s = Math.max(0, Math.floor(sekunden));
|
return `${Math.round(bytes / 1024)} KB`;
|
||||||
const h = Math.floor(s / 3600);
|
|
||||||
const m = Math.floor((s % 3600) / 60);
|
|
||||||
const r = s % 60;
|
|
||||||
return (h ? `${h}:${String(m).padStart(2, '0')}` : `${m}`) + ':' + String(r).padStart(2, '0');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der Folgentitel: „S2:E4 · Der Fall". */
|
/** Wie weit ein Stand gediehen ist, als Anteil — oder nichts. */
|
||||||
export function folgentitel(item: Item): string {
|
export function anteil(stand: { positionSeconds: number; runtimeSeconds: number | null } | null | undefined)
|
||||||
const nummer =
|
: number | null {
|
||||||
item.ParentIndexNumber != null && item.IndexNumber != null
|
if (!stand?.runtimeSeconds || stand.runtimeSeconds <= 0) return null;
|
||||||
? `S${item.ParentIndexNumber}:E${item.IndexNumber}`
|
return stand.positionSeconds / stand.runtimeSeconds;
|
||||||
: '';
|
|
||||||
return [nummer, item.Name].filter(Boolean).join(' · ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ob ein Eintrag ein Bild dieser Art hat. */
|
/** „noch 42 Min." — was auf einer Kachel wirklich interessiert. */
|
||||||
export function hatBild(item: Item, art: 'Primary' | 'Backdrop'): boolean {
|
export function verbleibend(stand: { positionSeconds: number; runtimeSeconds: number | null }): string {
|
||||||
return art === 'Backdrop'
|
if (!stand.runtimeSeconds) return '';
|
||||||
? Boolean(item.BackdropImageTags?.length || item.ParentBackdropImageTags?.length)
|
const rest = Math.max(0, stand.runtimeSeconds - stand.positionSeconds);
|
||||||
: Boolean(item.ImageTags?.[art]);
|
return rest < 60 ? 'fast durch' : `noch ${Math.round(rest / 60)} Min.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Das Bild eines Eintrags — mit den Ersatzwegen, die Jellyfin nötig macht.
|
export function knopf(text: string, klasse = 'btn'): HTMLButtonElement {
|
||||||
*
|
const b = document.createElement('button');
|
||||||
* Eine Folge hat selten ein eigenes Hintergrundbild, aber ihre Serie hat
|
b.className = klasse;
|
||||||
* eins. Ohne diese Kette bliebe die halbe Startseite grau. */
|
b.textContent = text;
|
||||||
export function bild(item: Item, art: 'Primary' | 'Backdrop', breite = 400): string {
|
return b;
|
||||||
if (art === 'Backdrop') {
|
|
||||||
if (item.BackdropImageTags?.length) {
|
|
||||||
return medienURL(`/Items/${item.Id}/Images/Backdrop`, {
|
|
||||||
tag: item.BackdropImageTags[0]!, maxWidth: breite, quality: 90,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (item.ParentBackdropItemId) {
|
|
||||||
return medienURL(`/Items/${item.ParentBackdropItemId}/Images/Backdrop`, {
|
|
||||||
tag: item.ParentBackdropImageTags?.[0] ?? '', maxWidth: breite, quality: 90,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item.ImageTags?.Primary) {
|
|
||||||
return medienURL(`/Items/${item.Id}/Images/Primary`, {
|
|
||||||
tag: item.ImageTags.Primary, maxWidth: breite, quality: 90,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (item.SeriesPrimaryImageTag && item.SeriesId) {
|
|
||||||
return medienURL(`/Items/${item.SeriesId}/Images/Primary`, {
|
|
||||||
tag: item.SeriesPrimaryImageTag, maxWidth: breite, quality: 90,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die Felder, die die Startseite braucht. */
|
export function feld(platzhalter: string, art = 'text'): HTMLInputElement {
|
||||||
export const FELDER = 'Overview,Genres,MediaSources,ParentBackdropItemId,ParentBackdropImageTags';
|
const i = document.createElement('input');
|
||||||
|
i.className = 'feld';
|
||||||
|
i.type = art;
|
||||||
|
i.placeholder = platzhalter;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt einen Fehler, ohne die Seite zu verlassen. */
|
||||||
|
export function fehlerzeile(text: string): HTMLElement {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'fehler';
|
||||||
|
d.textContent = text;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function leer(text: string): HTMLElement {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'leer';
|
||||||
|
d.textContent = text;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import {
|
||||||
|
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen,
|
||||||
|
scanStarten, scanstand, metadatenAuffrischen,
|
||||||
|
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
|
||||||
|
servernameSetzen, systemauskunft,
|
||||||
|
laufendeWiedergaben, serverzustand, bildAdresse,
|
||||||
|
type Bibliothek, type Wiedergabe,
|
||||||
|
} from '../api';
|
||||||
|
import { esc, dauer, knopf, fehlerzeile, leer } from '../util';
|
||||||
|
import { dialog, nachfragen } from '../dialog';
|
||||||
|
import { icon } from '../icons';
|
||||||
|
|
||||||
|
/** Die Verwaltung.
|
||||||
|
*
|
||||||
|
* **Was etwas anlegt oder ändert, steht in einem Dialog**, nicht als Block
|
||||||
|
* in der Seite. Ein Formular, das immer dasteht, nimmt Platz für etwas weg,
|
||||||
|
* das man einmal im Monat braucht — und sieht aus wie eine Eingabe, die
|
||||||
|
* noch aussteht.
|
||||||
|
*
|
||||||
|
* `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die
|
||||||
|
* Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
|
||||||
|
export function verwaltung(geaendert: () => void = () => {}): HTMLElement {
|
||||||
|
const wurzel = document.createElement('div');
|
||||||
|
wurzel.append(dashboard(), bibliotheksbereich(geaendert), benutzerbereich(), serverbereich());
|
||||||
|
return wurzel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Überschrift mit einem Knopf rechts daneben. */
|
||||||
|
function abschnitt(titel: string, knoepfe: HTMLElement[] = []): HTMLElement {
|
||||||
|
const kopf = document.createElement('div');
|
||||||
|
kopf.className = 'reihe-kopf';
|
||||||
|
const h = document.createElement('h2');
|
||||||
|
h.textContent = titel;
|
||||||
|
kopf.appendChild(h);
|
||||||
|
if (knoepfe.length) {
|
||||||
|
const rechts = document.createElement('div');
|
||||||
|
rechts.className = 'knopfreihe';
|
||||||
|
rechts.style.marginLeft = 'auto';
|
||||||
|
knoepfe.forEach(k => rechts.appendChild(k));
|
||||||
|
kopf.appendChild(rechts);
|
||||||
|
}
|
||||||
|
return kopf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Dashboard ---------- */
|
||||||
|
|
||||||
|
/** Was gerade läuft.
|
||||||
|
*
|
||||||
|
* **Fragt alle fünf Sekunden nach.** Ein dauerhaft offener Kanal wäre
|
||||||
|
* sparsamer, aber für eine Handvoll Zeilen auf einer Seite, die man selten
|
||||||
|
* offen hat, ist er der grössere Aufwand — auf beiden Seiten. */
|
||||||
|
function dashboard(): HTMLElement {
|
||||||
|
const bereich = document.createElement('section');
|
||||||
|
bereich.appendChild(abschnitt('Wird abgespielt'));
|
||||||
|
|
||||||
|
const liste = document.createElement('div');
|
||||||
|
liste.className = 'einstellungen';
|
||||||
|
liste.innerHTML = '<div class="spinner"></div>';
|
||||||
|
|
||||||
|
const zustand = document.createElement('div');
|
||||||
|
zustand.className = 'hinweis';
|
||||||
|
|
||||||
|
bereich.append(liste, zustand);
|
||||||
|
|
||||||
|
let timer: number | undefined;
|
||||||
|
void laden();
|
||||||
|
|
||||||
|
// Aufhören, sobald die Seite weg ist — sonst fragt eine unsichtbare
|
||||||
|
// Verwaltung bis zum Neuladen weiter.
|
||||||
|
const waechter = new MutationObserver(() => {
|
||||||
|
if (!bereich.isConnected) { window.clearTimeout(timer); waechter.disconnect(); }
|
||||||
|
});
|
||||||
|
waechter.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
async function laden(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [laufend, stand] = await Promise.all([laufendeWiedergaben(), serverzustand()]);
|
||||||
|
|
||||||
|
liste.innerHTML = '';
|
||||||
|
if (laufend.length === 0) {
|
||||||
|
liste.appendChild(leer('Derzeit wird nichts gespielt.'));
|
||||||
|
} else {
|
||||||
|
laufend.forEach(w => liste.appendChild(wiedergabe(w)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const teile = [`Läuft seit ${laufzeit(stand.uptime)}`];
|
||||||
|
if (stand.packaging > 0) teile.push(`${stand.packaging} Umwandlung(en) laufen`);
|
||||||
|
if (stand.scanning > 0) teile.push(`${stand.scanning} Scan(s) laufen`);
|
||||||
|
zustand.textContent = teile.join(' · ');
|
||||||
|
} catch (fehler) {
|
||||||
|
liste.innerHTML = '';
|
||||||
|
liste.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
|
return; // Nicht weiter nachfragen, wenn es scheitert.
|
||||||
|
}
|
||||||
|
timer = window.setTimeout(() => void laden(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wiedergabe(w: Wiedergabe): HTMLElement {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'einstellung laeuft';
|
||||||
|
|
||||||
|
const bild = document.createElement('div');
|
||||||
|
bild.className = 'laeuft-bild';
|
||||||
|
const adresse = bildAdresse(w.posterImage);
|
||||||
|
if (adresse) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = adresse;
|
||||||
|
img.alt = '';
|
||||||
|
bild.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'zeile';
|
||||||
|
text.style.flex = '1';
|
||||||
|
const wie = w.runtimeSeconds
|
||||||
|
? `${dauer(w.positionSeconds)} von ${dauer(w.runtimeSeconds)}`
|
||||||
|
: dauer(w.positionSeconds);
|
||||||
|
text.innerHTML = `<strong>${esc(w.title)}</strong>`
|
||||||
|
+ (w.subTitle ? `<span class="winzig">${esc(w.subTitle)}</span>` : '')
|
||||||
|
+ `<span class="winzig">${esc(w.userName)} · ${esc(w.deviceName)} · ${esc(wie)}</span>`;
|
||||||
|
|
||||||
|
const innen = document.createElement('div');
|
||||||
|
innen.className = 'knopfreihe';
|
||||||
|
innen.append(bild, text);
|
||||||
|
d.appendChild(innen);
|
||||||
|
|
||||||
|
if (w.runtimeSeconds) {
|
||||||
|
const balken = document.createElement('div');
|
||||||
|
balken.className = 'fortschritt breit';
|
||||||
|
const gefuellt = document.createElement('div');
|
||||||
|
gefuellt.style.width = `${Math.min(100, (w.positionSeconds / w.runtimeSeconds) * 100)}%`;
|
||||||
|
balken.appendChild(gefuellt);
|
||||||
|
d.appendChild(balken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bereich;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „3 Std. 12 Min." — aus Sekunden. */
|
||||||
|
function laufzeit(sekunden: number): string {
|
||||||
|
const tage = Math.floor(sekunden / 86400);
|
||||||
|
const stunden = Math.floor((sekunden % 86400) / 3600);
|
||||||
|
const minuten = Math.floor((sekunden % 3600) / 60);
|
||||||
|
if (tage > 0) return `${tage} Tg. ${stunden} Std.`;
|
||||||
|
if (stunden > 0) return `${stunden} Std. ${minuten} Min.`;
|
||||||
|
return `${minuten} Min.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Bibliotheken ---------- */
|
||||||
|
|
||||||
|
function bibliotheksbereich(geaendert: () => void): HTMLElement {
|
||||||
|
const bereich = document.createElement('section');
|
||||||
|
|
||||||
|
const anlegen = knopf('Mediathek hinzufügen', 'btn btn-primary');
|
||||||
|
anlegen.onclick = () => dialog({
|
||||||
|
titel: 'Mediathek hinzufügen',
|
||||||
|
felder: [
|
||||||
|
{ name: 'name', beschriftung: 'Name', platzhalter: 'etwa Filme' },
|
||||||
|
{
|
||||||
|
name: 'art', beschriftung: 'Art',
|
||||||
|
auswahl: [{ wert: 'Movies', text: 'Filme' }, { wert: 'Shows', text: 'Serien' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pfad', beschriftung: 'Ordner',
|
||||||
|
platzhalter: '/media/Filme',
|
||||||
|
hinweis: 'Der Pfad im Container, nicht der auf dem NAS — '
|
||||||
|
+ 'also das, was in der Compose-Datei hinter dem Doppelpunkt steht.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bestaetigen: 'Anlegen',
|
||||||
|
tun: async wert => {
|
||||||
|
await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad'));
|
||||||
|
await laden();
|
||||||
|
geaendert();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
bereich.appendChild(abschnitt('Mediatheken', [anlegen]));
|
||||||
|
|
||||||
|
const liste = document.createElement('div');
|
||||||
|
liste.className = 'einstellungen';
|
||||||
|
bereich.appendChild(liste);
|
||||||
|
void laden();
|
||||||
|
|
||||||
|
async function laden(): Promise<void> {
|
||||||
|
liste.innerHTML = '<div class="spinner"></div>';
|
||||||
|
try {
|
||||||
|
const alle = await bibliotheken();
|
||||||
|
liste.innerHTML = '';
|
||||||
|
if (alle.length === 0) {
|
||||||
|
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alle.forEach(b => liste.appendChild(block(b)));
|
||||||
|
} catch (fehler) {
|
||||||
|
liste.innerHTML = '';
|
||||||
|
liste.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function block(b: Bibliothek): HTMLElement {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'einstellung';
|
||||||
|
|
||||||
|
const serie = b.kind === 'Shows';
|
||||||
|
// „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat.
|
||||||
|
const anzahl = b.itemCount == null ? ''
|
||||||
|
: ` · ${b.itemCount} ` + (b.itemCount === 1
|
||||||
|
? (serie ? 'Serie' : 'Film')
|
||||||
|
: (serie ? 'Serien' : 'Filme'));
|
||||||
|
|
||||||
|
const kopf = document.createElement('div');
|
||||||
|
kopf.className = 'zeile';
|
||||||
|
kopf.innerHTML = `<strong>${esc(b.name)}</strong>`
|
||||||
|
+ `<span class="winzig">${esc(b.path)}${esc(anzahl)}</span>`;
|
||||||
|
d.appendChild(kopf);
|
||||||
|
|
||||||
|
const stand = document.createElement('div');
|
||||||
|
stand.className = 'hinweis';
|
||||||
|
|
||||||
|
const los = knopf('Scannen', 'btn');
|
||||||
|
los.onclick = async () => {
|
||||||
|
los.disabled = true;
|
||||||
|
try { await scanStarten(b.id); await verfolgen(); }
|
||||||
|
catch (fehler) { stand.textContent = (fehler as Error).message; }
|
||||||
|
los.disabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const auffrischen = knopf('Metadaten auffrischen', 'btn');
|
||||||
|
auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — '
|
||||||
|
+ 'nötig nach einer Sprachänderung.';
|
||||||
|
auffrischen.onclick = () => nachfragen({
|
||||||
|
titel: `Metadaten von „${b.name}" auffrischen?`,
|
||||||
|
text: 'Alle Titel werden erneut bei TMDB geholt — über die bekannte Kennung, '
|
||||||
|
+ 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. '
|
||||||
|
+ 'Bei vielen Titeln dauert es.',
|
||||||
|
bestaetigen: 'Auffrischen',
|
||||||
|
tun: async () => { await metadatenAuffrischen(b.id); void verfolgen(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const umbenennen = knopf('Umbenennen', 'btn');
|
||||||
|
umbenennen.onclick = () => dialog({
|
||||||
|
titel: 'Mediathek umbenennen',
|
||||||
|
felder: [{ name: 'name', beschriftung: 'Name', wert: b.name }],
|
||||||
|
bestaetigen: 'Speichern',
|
||||||
|
tun: async wert => {
|
||||||
|
await bibliothekUmbenennen(b.id, wert('name'));
|
||||||
|
await laden();
|
||||||
|
geaendert();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entfernen = knopf('Entfernen', 'btn');
|
||||||
|
entfernen.onclick = () => nachfragen({
|
||||||
|
titel: `„${b.name}" entfernen?`,
|
||||||
|
text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die '
|
||||||
|
+ 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.',
|
||||||
|
bestaetigen: 'Entfernen',
|
||||||
|
tun: async () => { await bibliothekLoeschen(b.id); await laden(); geaendert(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */
|
||||||
|
async function verfolgen(): Promise<void> {
|
||||||
|
for (let i = 0; i < 900; i++) {
|
||||||
|
const s = await scanstand(b.id);
|
||||||
|
if (s.state === 'running') stand.textContent = 'Wird durchsucht…';
|
||||||
|
else if (s.state === 'enriching') stand.textContent = 'Metadaten werden geholt…';
|
||||||
|
else if (s.state === 'failed') { stand.textContent = s.error ?? 'Gescheitert.'; return; }
|
||||||
|
else if (s.state === 'finished') {
|
||||||
|
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
|
||||||
|
if (s.unrecognised) teile.push(`${s.unrecognised} ohne Folgennummer`);
|
||||||
|
if (s.metadata?.skipped) teile.push('Metadaten übersprungen');
|
||||||
|
else if (s.metadata) teile.push(`${s.metadata.moviesMatched + s.metadata.seriesMatched} zugeordnet`);
|
||||||
|
if (s.metadata?.error) teile.push(s.metadata.error);
|
||||||
|
stand.textContent = teile.join(' · ');
|
||||||
|
await laden();
|
||||||
|
geaendert();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const knoepfe = document.createElement('div');
|
||||||
|
knoepfe.className = 'knopfreihe';
|
||||||
|
knoepfe.append(los, auffrischen, umbenennen, entfernen);
|
||||||
|
d.append(knoepfe, stand);
|
||||||
|
void verfolgen();
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bereich;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Benutzer ---------- */
|
||||||
|
|
||||||
|
function benutzerbereich(): HTMLElement {
|
||||||
|
const bereich = document.createElement('section');
|
||||||
|
|
||||||
|
const anlegen = knopf('Benutzer hinzufügen', 'btn');
|
||||||
|
anlegen.onclick = () => dialog({
|
||||||
|
titel: 'Benutzer hinzufügen',
|
||||||
|
felder: [
|
||||||
|
{ name: 'name', beschriftung: 'Benutzername' },
|
||||||
|
{
|
||||||
|
name: 'passwort', beschriftung: 'Passwort', art: 'password',
|
||||||
|
hinweis: 'Mindestens acht Zeichen.',
|
||||||
|
},
|
||||||
|
{ name: 'admin', beschriftung: 'Administrator', schalter: true },
|
||||||
|
],
|
||||||
|
bestaetigen: 'Anlegen',
|
||||||
|
tun: async wert => {
|
||||||
|
await benutzerAnlegen(wert('name'), wert('passwort'), wert('admin') === 'true');
|
||||||
|
await laden();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
bereich.appendChild(abschnitt('Benutzer', [anlegen]));
|
||||||
|
|
||||||
|
const liste = document.createElement('div');
|
||||||
|
liste.className = 'einstellungen';
|
||||||
|
bereich.appendChild(liste);
|
||||||
|
void laden();
|
||||||
|
|
||||||
|
async function laden(): Promise<void> {
|
||||||
|
liste.innerHTML = '<div class="spinner"></div>';
|
||||||
|
try {
|
||||||
|
const alle = await benutzer();
|
||||||
|
liste.innerHTML = '';
|
||||||
|
alle.forEach(b => {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'einstellung';
|
||||||
|
d.innerHTML = `<div class="zeile"><strong>${esc(b.userName)}</strong>`
|
||||||
|
+ `<span class="winzig">${b.isAdministrator ? 'Administrator' : 'Benutzer'}</span></div>`;
|
||||||
|
liste.appendChild(d);
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
liste.innerHTML = '';
|
||||||
|
liste.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bereich;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Server ---------- */
|
||||||
|
|
||||||
|
function serverbereich(): HTMLElement {
|
||||||
|
const bereich = document.createElement('section');
|
||||||
|
bereich.appendChild(abschnitt('Server'));
|
||||||
|
|
||||||
|
const liste = document.createElement('div');
|
||||||
|
liste.className = 'einstellungen';
|
||||||
|
bereich.appendChild(liste);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]);
|
||||||
|
liste.innerHTML = '';
|
||||||
|
|
||||||
|
liste.appendChild(zeile(
|
||||||
|
'Name', auskunft.serverName ?? '—',
|
||||||
|
'So nennen ihn die Apps und die Kopfzeile.',
|
||||||
|
() => dialog({
|
||||||
|
titel: 'Server umbenennen',
|
||||||
|
felder: [{ name: 'name', beschriftung: 'Name', wert: auskunft.serverName ?? '' }],
|
||||||
|
bestaetigen: 'Speichern',
|
||||||
|
tun: async wert => { await servernameSetzen(wert('name')); location.reload(); },
|
||||||
|
})));
|
||||||
|
|
||||||
|
liste.appendChild(zeile(
|
||||||
|
'Metadaten',
|
||||||
|
stand.usingProjectKey ? `Mitgelieferter TMDB-Zugang · ${stand.language}`
|
||||||
|
: stand.hasApiKey ? `Eigener TMDB-Zugang · ${stand.language}`
|
||||||
|
: 'Kein Zugang hinterlegt',
|
||||||
|
stand.usingProjectKey
|
||||||
|
? 'Ein eigener Zugang löst den mitgelieferten ab.'
|
||||||
|
: 'Nach einer Sprachänderung die Metadaten auffrischen — '
|
||||||
|
+ 'vorhandene Titel ändern sich sonst nicht.',
|
||||||
|
() => dialog({
|
||||||
|
titel: 'Metadaten',
|
||||||
|
felder: [
|
||||||
|
{
|
||||||
|
name: 'schluessel', beschriftung: 'Eigener TMDB-Zugang', art: 'password',
|
||||||
|
platzhalter: 'Read Access Token oder API Key',
|
||||||
|
hinweis: 'Leer lassen, um den mitgelieferten zu behalten.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sprache', beschriftung: 'Sprache', wert: stand.language,
|
||||||
|
hinweis: 'Etwa de-DE. Bestimmt auch, welche Altersfreigabe gilt.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bestaetigen: 'Speichern',
|
||||||
|
tun: async wert => {
|
||||||
|
await metadatenSetzen({
|
||||||
|
apiKey: wert('schluessel') || undefined,
|
||||||
|
language: wert('sprache') || undefined,
|
||||||
|
});
|
||||||
|
location.reload();
|
||||||
|
},
|
||||||
|
})));
|
||||||
|
|
||||||
|
liste.appendChild(zeile('Fassung', auskunft.version, null));
|
||||||
|
} catch (fehler) {
|
||||||
|
liste.innerHTML = '';
|
||||||
|
liste.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function zeile(
|
||||||
|
name: string, wert: string, hinweis: string | null, bearbeiten?: () => void,
|
||||||
|
): HTMLElement {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'einstellung';
|
||||||
|
|
||||||
|
const oben = document.createElement('div');
|
||||||
|
oben.className = 'knopfreihe';
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'zeile';
|
||||||
|
text.style.flex = '1';
|
||||||
|
text.innerHTML = `<strong>${esc(name)}</strong><span class="winzig">${esc(wert)}</span>`;
|
||||||
|
oben.appendChild(text);
|
||||||
|
|
||||||
|
if (bearbeiten) {
|
||||||
|
const b = knopf('Ändern', 'btn');
|
||||||
|
b.prepend(icon('stift'));
|
||||||
|
b.onclick = bearbeiten;
|
||||||
|
oben.appendChild(b);
|
||||||
|
}
|
||||||
|
d.appendChild(oben);
|
||||||
|
|
||||||
|
if (hinweis) {
|
||||||
|
const h = document.createElement('div');
|
||||||
|
h.className = 'hinweis';
|
||||||
|
h.textContent = hinweis;
|
||||||
|
d.appendChild(h);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
liste.innerHTML = '<div class="spinner"></div>';
|
||||||
|
return bereich;
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import type { Item } from '../types';
|
|
||||||
import { esc, bild, laufzeit } from '../util';
|
|
||||||
|
|
||||||
/** Eine Kachel. Querformat für Angefangenes, hochkant für alles andere —
|
|
||||||
* dieselbe Entscheidung wie in der App, und sie ist keine Einstellung:
|
|
||||||
* Angefangenes erkennt man am Szenenbild wieder, Neues sucht man nach
|
|
||||||
* Plakaten aus. */
|
|
||||||
export function kachel(item: Item, quer: boolean, beimTippen: (item: Item) => void): HTMLElement {
|
|
||||||
const b = document.createElement('button');
|
|
||||||
b.className = 'card' + (quer ? ' wide' : '');
|
|
||||||
const anteil = item.UserData?.PlayedPercentage;
|
|
||||||
const art = bild(item, quer ? 'Backdrop' : 'Primary', quer ? 500 : 300);
|
|
||||||
|
|
||||||
b.innerHTML = `
|
|
||||||
<div class="art" ${art ? `style="background-image:url('${art}')"` : ''}>
|
|
||||||
${anteil ? `<div class="bar"><span style="width:${Math.min(anteil, 100)}%"></span></div>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="title">${esc(item.SeriesName || item.Name)}</div>
|
|
||||||
<div class="sub">${esc(unterzeile(item))}</div>`;
|
|
||||||
b.onclick = () => beimTippen(item);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unterzeile(item: Item): string {
|
|
||||||
if (item.Type === 'Episode' && item.ParentIndexNumber != null) {
|
|
||||||
return `S${item.ParentIndexNumber}:E${item.IndexNumber} · ${item.Name}`;
|
|
||||||
}
|
|
||||||
return String(item.ProductionYear ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Das Blatt zu einem Titel. */
|
|
||||||
export function blatt(item: Item, starten: (item: Item) => void): void {
|
|
||||||
const hinten = document.createElement('div');
|
|
||||||
hinten.id = 'sheet';
|
|
||||||
|
|
||||||
// Serien und Staffeln sind Ordner — abgespielt wird trotzdem, nur eben
|
|
||||||
// nicht der Ordner, sondern die Folge, die als Nächstes dran ist.
|
|
||||||
const ordner = ['Series', 'Season'].includes(item.Type ?? '');
|
|
||||||
const spielbar = ordner || !['BoxSet', 'Folder', 'CollectionFolder'].includes(item.Type ?? '');
|
|
||||||
const teile = [item.ProductionYear, laufzeit(item), item.OfficialRating].filter(Boolean);
|
|
||||||
|
|
||||||
hinten.innerHTML = `
|
|
||||||
<div class="panel">
|
|
||||||
<h2>${esc(item.SeriesName ? item.SeriesName + ' — ' + item.Name : item.Name)}</h2>
|
|
||||||
<div class="meta">${teile.join(' · ')}</div>
|
|
||||||
<p>${esc(item.Overview ?? '')}</p>
|
|
||||||
<div class="btn-row">
|
|
||||||
${spielbar ? '<button class="btn btn-primary" id="sheet-play"></button>' : ''}
|
|
||||||
<button class="btn" id="sheet-close">Schließen</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
hinten.onclick = (e) => { if (e.target === hinten) hinten.remove(); };
|
|
||||||
document.body.appendChild(hinten);
|
|
||||||
hinten.querySelector<HTMLElement>('#sheet-close')!.onclick = () => hinten.remove();
|
|
||||||
|
|
||||||
if (!spielbar) return;
|
|
||||||
const knopf = hinten.querySelector<HTMLButtonElement>('#sheet-play')!;
|
|
||||||
knopf.textContent = ordner ? '▶ Weiterschauen' : '▶ Abspielen';
|
|
||||||
knopf.onclick = () => { starten(item); hinten.remove(); };
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
import {
|
||||||
|
film as filmLaden, serie as serieLaden, folgen, lieferung, downloadAdresse,
|
||||||
|
vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen, favorit, weglegen,
|
||||||
|
type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand,
|
||||||
|
type Mitwirkender,
|
||||||
|
} from '../api';
|
||||||
|
import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil } from '../util';
|
||||||
|
import { icons, icon } from '../icons';
|
||||||
|
|
||||||
|
type Art = 'filme' | 'serien';
|
||||||
|
|
||||||
|
/** Die Detailseite — im Aufbau von Plex.
|
||||||
|
*
|
||||||
|
* Unscharfer Hintergrund über die ganze Seite, Plakat links, rechts der
|
||||||
|
* Schriftzug des Films statt der Systemschrift, darunter Eckdaten,
|
||||||
|
* Bewertung, Knöpfe, Beschreibung, die Medienangaben als Tabelle und zuletzt
|
||||||
|
* die Besetzung mit runden Porträts. */
|
||||||
|
export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElement {
|
||||||
|
const wurzel = document.createElement('div');
|
||||||
|
wurzel.className = 'detail';
|
||||||
|
void laden();
|
||||||
|
|
||||||
|
async function laden(): Promise<void> {
|
||||||
|
wurzel.innerHTML = '<div class="spinner"></div>';
|
||||||
|
try {
|
||||||
|
if (art === 'filme') {
|
||||||
|
const eintrag = await filmLaden(id);
|
||||||
|
wurzel.innerHTML = '';
|
||||||
|
wurzel.append(
|
||||||
|
hintergrund(eintrag.backdropImage),
|
||||||
|
zurueckKnopf(),
|
||||||
|
kopfbereich(eintrag, eintrag.state, eintrag.files[0] ?? null),
|
||||||
|
dateiliste(eintrag.files, eintrag.id, eintrag.state),
|
||||||
|
besetzung(eintrag.credits),
|
||||||
|
zuordnungsbereich(eintrag.title, eintrag.year));
|
||||||
|
} else {
|
||||||
|
const eintrag = await serieLaden(id);
|
||||||
|
wurzel.innerHTML = '';
|
||||||
|
wurzel.append(
|
||||||
|
hintergrund(eintrag.backdropImage),
|
||||||
|
zurueckKnopf(),
|
||||||
|
kopfbereich(eintrag, null, null),
|
||||||
|
await staffeln(eintrag),
|
||||||
|
besetzung(eintrag.credits),
|
||||||
|
zuordnungsbereich(eintrag.title, eintrag.year));
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
wurzel.innerHTML = '';
|
||||||
|
wurzel.appendChild(fehler instanceof Error && /404/.test(fehler.message)
|
||||||
|
? leer(art === 'filme' ? 'Diesen Film gibt es nicht mehr.' : 'Diese Serie gibt es nicht mehr.')
|
||||||
|
: fehlerzeile((fehler as Error).message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zurueckKnopf(): HTMLElement {
|
||||||
|
const b = knopf('Zurück', 'btn');
|
||||||
|
b.prepend(icon('links'));
|
||||||
|
b.style.marginBottom = 'var(--s-loose)';
|
||||||
|
b.onclick = zurueck;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Hintergrund ---------- */
|
||||||
|
|
||||||
|
function hintergrund(schluessel: string | null): HTMLElement {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'detail-hintergrund';
|
||||||
|
const adresse = bildAdresse(schluessel);
|
||||||
|
if (adresse) d.style.backgroundImage = `url("${adresse}")`;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Kopf ---------- */
|
||||||
|
|
||||||
|
function kopfbereich(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement {
|
||||||
|
const kopf = document.createElement('header');
|
||||||
|
kopf.className = 'detail-kopf';
|
||||||
|
|
||||||
|
// Plakat
|
||||||
|
const rahmen = document.createElement('div');
|
||||||
|
rahmen.className = 'plakat geladen';
|
||||||
|
const plakatAdresse = bildAdresse(eintrag.posterImage);
|
||||||
|
if (plakatAdresse) {
|
||||||
|
const bild = document.createElement('img');
|
||||||
|
bild.src = plakatAdresse;
|
||||||
|
bild.alt = '';
|
||||||
|
rahmen.appendChild(bild);
|
||||||
|
}
|
||||||
|
if (anteil(stand)) {
|
||||||
|
const balken = document.createElement('div');
|
||||||
|
balken.className = 'fortschritt';
|
||||||
|
const gefuellt = document.createElement('div');
|
||||||
|
gefuellt.style.width = `${Math.min(100, anteil(stand)! * 100)}%`;
|
||||||
|
balken.appendChild(gefuellt);
|
||||||
|
rahmen.appendChild(balken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'detail-text';
|
||||||
|
|
||||||
|
// **Der Schriftzug, wenn es einen gibt.** „Blade Runner 2049" in seiner
|
||||||
|
// eigenen Schrift sagt mehr als derselbe Titel in der Systemschrift.
|
||||||
|
const logo = bildAdresse(eintrag.logoImage);
|
||||||
|
if (logo) {
|
||||||
|
const bild = document.createElement('img');
|
||||||
|
bild.className = 'schriftzug';
|
||||||
|
bild.src = logo;
|
||||||
|
bild.alt = eintrag.title;
|
||||||
|
text.appendChild(bild);
|
||||||
|
} else {
|
||||||
|
const h = document.createElement('h1');
|
||||||
|
h.textContent = eintrag.title;
|
||||||
|
text.appendChild(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
text.appendChild(eckdaten(eintrag));
|
||||||
|
|
||||||
|
if (eintrag.tagline) {
|
||||||
|
const zeile = document.createElement('p');
|
||||||
|
zeile.className = 'werbezeile';
|
||||||
|
zeile.textContent = eintrag.tagline;
|
||||||
|
text.appendChild(zeile);
|
||||||
|
}
|
||||||
|
|
||||||
|
text.appendChild(aktionsreihe(eintrag, stand, datei));
|
||||||
|
|
||||||
|
const beschreibung = document.createElement('p');
|
||||||
|
beschreibung.className = 'beschreibung';
|
||||||
|
beschreibung.textContent = eintrag.overview ?? 'Keine Beschreibung.';
|
||||||
|
text.appendChild(beschreibung);
|
||||||
|
|
||||||
|
if (datei) text.appendChild(medienangaben(datei));
|
||||||
|
|
||||||
|
kopf.append(rahmen, text);
|
||||||
|
return kopf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jahr · Genres · Laufzeit · Freigabe · Bewertung — eine Zeile. */
|
||||||
|
function eckdaten(eintrag: Film | Serie): HTMLElement {
|
||||||
|
const zeile = document.createElement('div');
|
||||||
|
zeile.className = 'eckdaten';
|
||||||
|
|
||||||
|
const teile: string[] = [];
|
||||||
|
if (eintrag.year) teile.push(String(eintrag.year));
|
||||||
|
if (eintrag.genres?.length) teile.push(eintrag.genres.join(', '));
|
||||||
|
if (eintrag.runtimeMinutes) teile.push(`${eintrag.runtimeMinutes} Min`);
|
||||||
|
if ('episodeCount' in eintrag) {
|
||||||
|
teile.push(`${eintrag.episodeCount} Folgen`);
|
||||||
|
if (eintrag.unwatchedCount > 0) teile.push(`${eintrag.unwatchedCount} offen`);
|
||||||
|
}
|
||||||
|
if (teile.length) {
|
||||||
|
const t = document.createElement('span');
|
||||||
|
t.textContent = teile.join(' · ');
|
||||||
|
zeile.appendChild(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eintrag.contentRating) {
|
||||||
|
const f = document.createElement('span');
|
||||||
|
f.className = 'freigabe';
|
||||||
|
f.textContent = eintrag.contentRating;
|
||||||
|
f.title = 'Altersfreigabe';
|
||||||
|
zeile.appendChild(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eintrag.rating) {
|
||||||
|
const b = document.createElement('span');
|
||||||
|
b.className = 'bewertung';
|
||||||
|
b.innerHTML = icons.stern;
|
||||||
|
b.append(document.createTextNode(eintrag.rating.toFixed(1)));
|
||||||
|
b.title = 'Bewertung bei TMDB';
|
||||||
|
zeile.appendChild(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
return zeile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Knöpfe ---------- */
|
||||||
|
|
||||||
|
function aktionsreihe(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement {
|
||||||
|
const reihe = document.createElement('div');
|
||||||
|
reihe.className = 'aktionen';
|
||||||
|
const meldung = document.createElement('div');
|
||||||
|
meldung.className = 'hinweis';
|
||||||
|
|
||||||
|
if (datei) {
|
||||||
|
const stelle = stand?.positionSeconds ?? 0;
|
||||||
|
const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
|
||||||
|
los.prepend(icon('abspielen'));
|
||||||
|
los.onclick = () => void starten(datei, eintrag.id, stelle, los, meldung);
|
||||||
|
reihe.appendChild(los);
|
||||||
|
|
||||||
|
if (stelle > 0) {
|
||||||
|
const vonVorn = knopf('Von vorn', 'btn');
|
||||||
|
vonVorn.onclick = () => void starten(datei, eintrag.id, 0, vonVorn, meldung);
|
||||||
|
reihe.appendChild(vonVorn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const laden = knopf('Herunterladen', 'btn');
|
||||||
|
laden.prepend(icon('download'));
|
||||||
|
laden.onclick = () => void herunterladen(datei, laden, meldung);
|
||||||
|
reihe.appendChild(laden);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Runde Knöpfe ohne Beschriftung, wie bei Plex.** Gesehen, Favorit,
|
||||||
|
// weglegen — drei Dinge, die man oft tut und nie liest.
|
||||||
|
if ('files' in eintrag) {
|
||||||
|
reihe.appendChild(rundKnopf('hakenKreis', stand?.watched ?? false,
|
||||||
|
stand?.watched ? 'Als ungesehen markieren' : 'Als gesehen markieren',
|
||||||
|
async () => { await gesehen(eintrag.id, !stand?.watched); await laden(); }));
|
||||||
|
|
||||||
|
reihe.appendChild(rundKnopf('stern', stand?.favourite ?? false,
|
||||||
|
stand?.favourite ? 'Aus Favoriten nehmen' : 'Zu Favoriten',
|
||||||
|
async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); }));
|
||||||
|
|
||||||
|
if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) {
|
||||||
|
reihe.appendChild(rundKnopf('mehr', false, 'Aus „Weiterschauen" nehmen',
|
||||||
|
async () => { await weglegen(eintrag.id); await laden(); }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const huelle = document.createElement('div');
|
||||||
|
huelle.append(reihe, meldung);
|
||||||
|
return huelle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rundKnopf(
|
||||||
|
bild: keyof typeof icons, an: boolean, titel: string, tun: () => Promise<void>,
|
||||||
|
): HTMLButtonElement {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = an ? 'rund an' : 'rund';
|
||||||
|
b.innerHTML = icons[bild];
|
||||||
|
b.title = titel;
|
||||||
|
b.setAttribute('aria-label', titel);
|
||||||
|
b.onclick = async () => { b.disabled = true; try { await tun(); } catch { b.disabled = false; } };
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Medienangaben ---------- */
|
||||||
|
|
||||||
|
/** Video / Ton / Untertitel — Plex' Tabelle unter der Beschreibung. */
|
||||||
|
function medienangaben(datei: Datei): HTMLElement {
|
||||||
|
const liste = document.createElement('dl');
|
||||||
|
liste.className = 'medientabelle';
|
||||||
|
|
||||||
|
const bild = datei.streams.find(s => s.kind === 'Video');
|
||||||
|
const ton = datei.streams.filter(s => s.kind === 'Audio');
|
||||||
|
const text = datei.streams.filter(s => s.kind === 'Subtitle');
|
||||||
|
|
||||||
|
zeile('Datei', [datei.container?.toUpperCase(), groesse(datei.sizeBytes),
|
||||||
|
dauer(datei.durationSeconds)].filter(Boolean).join(' · '));
|
||||||
|
if (bild) zeile('Video', `${bild.height ? `${bild.height}p` : '—'} (${(bild.codec ?? '?').toUpperCase()})`);
|
||||||
|
if (ton.length) zeile('Ton', ton.map(spurname).join(' · '));
|
||||||
|
if (text.length) zeile('Untertitel', text.map(spurname).join(' · '));
|
||||||
|
|
||||||
|
function zeile(name: string, wert: string): void {
|
||||||
|
const dt = document.createElement('dt');
|
||||||
|
dt.textContent = name;
|
||||||
|
const dd = document.createElement('dd');
|
||||||
|
dd.textContent = wert;
|
||||||
|
liste.append(dt, dd);
|
||||||
|
}
|
||||||
|
|
||||||
|
return liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spurname(s: Datei['streams'][number]): string {
|
||||||
|
const teile = [s.title, s.language?.toUpperCase(), s.codec?.toUpperCase()]
|
||||||
|
.filter(Boolean);
|
||||||
|
const kanaele = s.channels ? `${s.channels}.0` : null;
|
||||||
|
return [teile.join(' '), kanaele].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Besetzung ---------- */
|
||||||
|
|
||||||
|
function besetzung(mitwirkende: Mitwirkender[] | null): HTMLElement {
|
||||||
|
const bereich = document.createElement('section');
|
||||||
|
if (!mitwirkende?.length) return bereich;
|
||||||
|
|
||||||
|
const h = document.createElement('h2');
|
||||||
|
h.textContent = 'Besetzung & Stab';
|
||||||
|
|
||||||
|
const reihe = document.createElement('div');
|
||||||
|
reihe.className = 'besetzung';
|
||||||
|
|
||||||
|
mitwirkende.forEach(m => {
|
||||||
|
const k = document.createElement('div');
|
||||||
|
k.className = 'kopf-kachel';
|
||||||
|
|
||||||
|
const portrait = document.createElement('div');
|
||||||
|
portrait.className = 'portrait';
|
||||||
|
const adresse = bildAdresse(m.image);
|
||||||
|
if (adresse) {
|
||||||
|
const bild = document.createElement('img');
|
||||||
|
bild.src = adresse;
|
||||||
|
bild.alt = '';
|
||||||
|
bild.loading = 'lazy';
|
||||||
|
portrait.appendChild(bild);
|
||||||
|
} else {
|
||||||
|
// Kein Bild: die Anfangsbuchstaben. Ein leerer Kreis sieht aus wie
|
||||||
|
// ein Ladefehler.
|
||||||
|
portrait.textContent = m.name.split(' ').map(t => t[0]).slice(0, 2).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolle = m.role === 'Director' ? 'Regie'
|
||||||
|
: m.role === 'Writer' ? 'Drehbuch'
|
||||||
|
: m.character ?? '';
|
||||||
|
|
||||||
|
k.innerHTML += '';
|
||||||
|
k.append(portrait);
|
||||||
|
const name = document.createElement('strong');
|
||||||
|
name.textContent = m.name;
|
||||||
|
const unten = document.createElement('span');
|
||||||
|
unten.textContent = rolle;
|
||||||
|
k.append(name, unten);
|
||||||
|
reihe.appendChild(k);
|
||||||
|
});
|
||||||
|
|
||||||
|
bereich.append(h, reihe);
|
||||||
|
return bereich;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Dateien ---------- */
|
||||||
|
|
||||||
|
function dateiliste(dateien: Datei[], titel: string, stand: Stand | null): HTMLElement {
|
||||||
|
const bereich = document.createElement('section');
|
||||||
|
if (dateien.length <= 1) return bereich;
|
||||||
|
|
||||||
|
// Nur bei mehreren Fassungen: bei einer stehen die Angaben schon oben.
|
||||||
|
const h = document.createElement('h2');
|
||||||
|
h.textContent = 'Fassungen';
|
||||||
|
bereich.appendChild(h);
|
||||||
|
|
||||||
|
const liste = document.createElement('div');
|
||||||
|
liste.className = 'einstellungen';
|
||||||
|
dateien.forEach(datei => {
|
||||||
|
const block = document.createElement('div');
|
||||||
|
block.className = 'einstellung';
|
||||||
|
block.innerHTML = `<div class="zeile"><strong>${esc(datei.fileName)}</strong>`
|
||||||
|
+ `<span class="winzig">${esc([datei.container?.toUpperCase(), groesse(datei.sizeBytes),
|
||||||
|
dauer(datei.durationSeconds)].filter(Boolean).join(' · '))}</span></div>`;
|
||||||
|
block.appendChild(aktionsreihe({ ...({} as Film), id: titel, files: dateien } as Film, stand, datei));
|
||||||
|
liste.appendChild(block);
|
||||||
|
});
|
||||||
|
bereich.appendChild(liste);
|
||||||
|
return bereich;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Staffeln und Folgen ---------- */
|
||||||
|
|
||||||
|
async function staffeln(serie: Serie): Promise<HTMLElement> {
|
||||||
|
const bereich = document.createElement('section');
|
||||||
|
const alle = await folgen(serie.id);
|
||||||
|
|
||||||
|
serie.seasons.forEach(nummer => {
|
||||||
|
const h = document.createElement('h2');
|
||||||
|
h.textContent = `Staffel ${nummer}`;
|
||||||
|
bereich.appendChild(h);
|
||||||
|
|
||||||
|
const liste = document.createElement('div');
|
||||||
|
liste.className = 'einstellungen';
|
||||||
|
alle.items.filter(f => f.season === nummer).forEach(folge => {
|
||||||
|
liste.appendChild(folgenblock(folge));
|
||||||
|
});
|
||||||
|
bereich.appendChild(liste);
|
||||||
|
});
|
||||||
|
return bereich;
|
||||||
|
}
|
||||||
|
|
||||||
|
function folgenblock(folge: Folge): HTMLElement {
|
||||||
|
const block = document.createElement('div');
|
||||||
|
block.className = 'einstellung';
|
||||||
|
|
||||||
|
const zeile = document.createElement('div');
|
||||||
|
zeile.className = 'zeile';
|
||||||
|
const nebenbei = folge.state?.watched ? 'Gesehen'
|
||||||
|
: folge.state && folge.state.positionSeconds > 0
|
||||||
|
? `noch ${Math.round(((folge.state.runtimeSeconds ?? 0) - folge.state.positionSeconds) / 60)} Min`
|
||||||
|
: '';
|
||||||
|
zeile.innerHTML =
|
||||||
|
`<strong>${String(folge.episode).padStart(2, '0')} · ${esc(folge.title ?? 'Ohne Titel')}</strong>`
|
||||||
|
+ (nebenbei ? `<span class="winzig">${esc(nebenbei)}</span>` : '');
|
||||||
|
block.appendChild(zeile);
|
||||||
|
|
||||||
|
if (folge.overview) {
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'hinweis';
|
||||||
|
text.textContent = folge.overview;
|
||||||
|
block.appendChild(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
folge.files.forEach(datei => block.appendChild(
|
||||||
|
aktionsreihe({ ...({} as Film), id: folge.id, files: folge.files } as Film, folge.state, datei)));
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Abspielen und Herunterladen ---------- */
|
||||||
|
|
||||||
|
/** Sorgt dafür, dass die Datei abholbereit ist — packt um, falls nötig. */
|
||||||
|
async function bereitstellen(datei: Datei, meldung: HTMLElement): Promise<boolean> {
|
||||||
|
let auskunft: Lieferung;
|
||||||
|
try { auskunft = await lieferung(datei.id); }
|
||||||
|
catch (fehler) { meldung.textContent = (fehler as Error).message; return false; }
|
||||||
|
|
||||||
|
if (auskunft.method === 'directFile') return true;
|
||||||
|
if (auskunft.packageState === 'ready') return true;
|
||||||
|
|
||||||
|
// **Hier steht, was passieren wird, bevor es passiert.**
|
||||||
|
const gruende: string[] = [];
|
||||||
|
if (auskunft.audioToEncode.length) gruende.push(`${auskunft.audioToEncode.length} Tonspur(en) werden neu gerechnet`);
|
||||||
|
if (auskunft.subtitlesDropped.length) gruende.push(`${auskunft.subtitlesDropped.length} Bilduntertitel können nicht mit`);
|
||||||
|
meldung.textContent = `Wird in eine MP4 umgepackt${gruende.length ? ` — ${gruende.join(', ')}` : ''}…`;
|
||||||
|
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
const antwort = await fetch(downloadAdresse(datei.id), { method: 'HEAD' });
|
||||||
|
if (antwort.ok) { meldung.textContent = ''; return true; }
|
||||||
|
if (antwort.status !== 202) { meldung.textContent = `Umpacken gescheitert (${antwort.status}).`; return false; }
|
||||||
|
|
||||||
|
const wie = await lieferung(datei.id);
|
||||||
|
if (wie.error) { meldung.textContent = wie.error; return false; }
|
||||||
|
meldung.textContent = `Wird umgepackt… ${Math.round(wie.progress * 100)} %`;
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
meldung.textContent = 'Das Umpacken dauert ungewöhnlich lange.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function starten(
|
||||||
|
datei: Datei, titel: string, ab: number, knopfDavon: HTMLButtonElement, meldung: HTMLElement,
|
||||||
|
): Promise<void> {
|
||||||
|
knopfDavon.disabled = true;
|
||||||
|
if (await bereitstellen(datei, meldung)) spieler(downloadAdresse(datei.id), titel, ab, laden);
|
||||||
|
knopfDavon.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function herunterladen(
|
||||||
|
datei: Datei, knopfDavon: HTMLButtonElement, meldung: HTMLElement,
|
||||||
|
): Promise<void> {
|
||||||
|
knopfDavon.disabled = true;
|
||||||
|
if (await bereitstellen(datei, meldung)) {
|
||||||
|
// Ein echter Link, kein Blob: der Browser lädt im Hintergrund und kann
|
||||||
|
// fortsetzen, statt den ganzen Film in den Speicher zu nehmen.
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = downloadAdresse(datei.id);
|
||||||
|
a.download = '';
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
knopfDavon.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Zuordnung korrigieren ---------- */
|
||||||
|
|
||||||
|
function zuordnungsbereich(titel: string, jahr: number | null): HTMLElement {
|
||||||
|
const bereich = document.createElement('section');
|
||||||
|
if (!istAdmin()) return bereich;
|
||||||
|
|
||||||
|
const oeffner = knopf('Zuordnung korrigieren', 'btn');
|
||||||
|
oeffner.prepend(icon('stift'));
|
||||||
|
bereich.appendChild(oeffner);
|
||||||
|
|
||||||
|
oeffner.onclick = () => {
|
||||||
|
oeffner.remove();
|
||||||
|
bereich.appendChild(zuordnungsmaske(titel, jahr));
|
||||||
|
};
|
||||||
|
return bereich;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zuordnungsmaske(titel: string, jahr: number | null): HTMLElement {
|
||||||
|
const maske = document.createElement('div');
|
||||||
|
maske.className = 'einstellungen';
|
||||||
|
|
||||||
|
const suche = feld('Anderer Suchbegriff');
|
||||||
|
suche.value = titel;
|
||||||
|
const jahrfeld = feld('Jahr', 'number');
|
||||||
|
if (jahr) jahrfeld.value = String(jahr);
|
||||||
|
const suchen = knopf('Suchen', 'btn');
|
||||||
|
|
||||||
|
const reihe = document.createElement('div');
|
||||||
|
reihe.className = 'knopfreihe';
|
||||||
|
reihe.append(suche, jahrfeld, suchen);
|
||||||
|
|
||||||
|
const treffer = document.createElement('div');
|
||||||
|
maske.append(reihe, treffer);
|
||||||
|
|
||||||
|
async function fragen(): Promise<void> {
|
||||||
|
treffer.innerHTML = '<div class="spinner"></div>';
|
||||||
|
try {
|
||||||
|
const liste = await vorschlaege(art === 'filme' ? 'movies' : 'series', id,
|
||||||
|
suche.value.trim(), jahrfeld.value ? Number(jahrfeld.value) : undefined);
|
||||||
|
|
||||||
|
treffer.innerHTML = '';
|
||||||
|
if (liste.length === 0) { treffer.appendChild(leer('Nichts gefunden.')); return; }
|
||||||
|
|
||||||
|
liste.forEach(v => {
|
||||||
|
const zeile = document.createElement('button');
|
||||||
|
zeile.className = 'einstellung waehlbar';
|
||||||
|
zeile.innerHTML =
|
||||||
|
`<div class="zeile"><strong>${esc(v.title)}</strong>`
|
||||||
|
+ `<span class="winzig">${esc(v.year ?? '')} · TMDB ${v.tmdbId}</span></div>`
|
||||||
|
+ `<div class="hinweis">${esc((v.overview ?? '').slice(0, 160))}</div>`;
|
||||||
|
zeile.onclick = async () => {
|
||||||
|
zeile.disabled = true;
|
||||||
|
try {
|
||||||
|
await zuordnen(art === 'filme' ? 'movies' : 'series', id, v.tmdbId);
|
||||||
|
await laden();
|
||||||
|
} catch (fehler) { treffer.appendChild(fehlerzeile((fehler as Error).message)); }
|
||||||
|
};
|
||||||
|
treffer.appendChild(zeile);
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
treffer.innerHTML = '';
|
||||||
|
treffer.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suchen.onclick = () => void fragen();
|
||||||
|
void fragen();
|
||||||
|
return maske;
|
||||||
|
}
|
||||||
|
|
||||||
|
return wurzel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Der Spieler ---------- */
|
||||||
|
|
||||||
|
/** Wie oft der Stand zum Server geht. */
|
||||||
|
const MELDEABSTAND = 10_000;
|
||||||
|
|
||||||
|
function spieler(adresse: string, titel: string, ab: number, danach: () => void): void {
|
||||||
|
const schicht = document.createElement('div');
|
||||||
|
schicht.className = 'pl-fill';
|
||||||
|
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.src = adresse;
|
||||||
|
video.controls = true;
|
||||||
|
video.autoplay = true;
|
||||||
|
video.style.cssText = 'width:100%;height:100%;background:#000';
|
||||||
|
if (ab > 0) video.currentTime = ab;
|
||||||
|
|
||||||
|
// **Alle zehn Sekunden, nicht bei jedem Bild.** `timeupdate` feuert
|
||||||
|
// viermal je Sekunde; daraus Anfragen zu machen hiesse, den Server mit
|
||||||
|
// Meldungen zu bewerfen, die sich um 250 ms unterscheiden.
|
||||||
|
let zuletzt = 0;
|
||||||
|
const melden = (erzwingen = false, beendet = false) => {
|
||||||
|
const jetzt = Date.now();
|
||||||
|
if (!erzwingen && jetzt - zuletzt < MELDEABSTAND) return;
|
||||||
|
zuletzt = jetzt;
|
||||||
|
const laenge = Number.isFinite(video.duration) ? video.duration : null;
|
||||||
|
// Eine verlorene Meldung kostet ein paar Sekunden Fortschritt. Dafür
|
||||||
|
// mitten im Film eine Fehlermeldung zu zeigen wäre die schlechtere
|
||||||
|
// Hälfte des Tauschs.
|
||||||
|
void fortschritt(titel, video.currentTime, laenge, beendet).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
video.ontimeupdate = () => melden();
|
||||||
|
video.onpause = () => melden(true);
|
||||||
|
video.onended = () => melden(true, true);
|
||||||
|
|
||||||
|
const schliessen = knopf('Schliessen', 'btn');
|
||||||
|
schliessen.style.cssText = 'position:absolute;top:16px;right:16px;z-index:2';
|
||||||
|
schliessen.onclick = () => {
|
||||||
|
video.pause();
|
||||||
|
// **Beendet melden, nicht nur den Stand.** Sonst steht der Film im
|
||||||
|
// Dashboard noch eine Minute als „läuft", und wer gerade geschlossen
|
||||||
|
// hat, sieht sich selbst beim Schauen zu.
|
||||||
|
melden(true, true);
|
||||||
|
schicht.remove();
|
||||||
|
danach();
|
||||||
|
};
|
||||||
|
|
||||||
|
schicht.append(video, schliessen);
|
||||||
|
schicht.onkeydown = e => { if (e.key === 'Escape') schliessen.click(); };
|
||||||
|
document.body.appendChild(schicht);
|
||||||
|
video.focus();
|
||||||
|
}
|
||||||
+79
-199
@@ -1,207 +1,87 @@
|
|||||||
import type { Item, Reihe, Seite } from '../types';
|
import {
|
||||||
import { api, sitzung } from '../api';
|
inhalt, weiterschauen, naechsteFolgen, empfehlungen,
|
||||||
import { P, anordnen } from '../prefs';
|
type Bibliothek,
|
||||||
import { icon } from '../icons';
|
} from '../api';
|
||||||
import { esc, bild, laufzeit, hatBild, FELDER } from '../util';
|
import { leer, fehlerzeile } from '../util';
|
||||||
import { kachel } from './cards';
|
import { kachel, alsKachel, angefangenAlsKachel, reihe } from '../card';
|
||||||
|
|
||||||
/** Welche Reihen es bei diesem Server gibt — für die Einstellungen. */
|
type Art = 'filme' | 'serien';
|
||||||
export let bekannteReihen: { id: string; title: string }[] = [];
|
|
||||||
|
|
||||||
export async function startseite(
|
/** Die Startseite — waagerechte Reihen, wie bei Plex.
|
||||||
wurzel: HTMLElement,
|
*
|
||||||
ansichten: Item[],
|
* Oben, was angefangen ist. Darunter **je Mediathek eine Reihe** mit dem
|
||||||
oeffnen: (item: Item) => void,
|
* zuletzt Hinzugefügten: wer „Filme" und „Dokumentationen" getrennt angelegt
|
||||||
): Promise<void> {
|
* hat, hat sie getrennt gemeint, und eine Startseite, die sie wieder
|
||||||
|
* zusammenwirft, nimmt die Entscheidung zurück.
|
||||||
|
*
|
||||||
|
* Eine Reihe ohne Inhalt wird gar nicht erst gezeigt. */
|
||||||
|
export function startseite(
|
||||||
|
bibliotheken: Bibliothek[],
|
||||||
|
oeffnen: (art: Art, id: string) => void,
|
||||||
|
alleZeigen: (b: Bibliothek) => void,
|
||||||
|
): HTMLElement {
|
||||||
|
const wurzel = document.createElement('div');
|
||||||
wurzel.innerHTML = '<div class="spinner"></div>';
|
wurzel.innerHTML = '<div class="spinner"></div>';
|
||||||
|
|
||||||
const hole = async (pfad: string, q: Record<string, string> = {}): Promise<Item[]> => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const r = await api<Seite | Item[]>(
|
// **Alles auf einmal anfragen.** Bei fünf Mediatheken wären es sonst
|
||||||
pfad + '?' + new URLSearchParams({ Fields: FELDER, Limit: '20', ...q }),
|
// acht Anfragen nacheinander, und die Startseite baut sich vor den
|
||||||
);
|
// Augen des Benutzers Zeile für Zeile auf.
|
||||||
// „Latest" liefert ein blankes Feld, alles andere eine Seite mit Items.
|
const [weiter, naechste, vorschlag, ...bestaende] = await Promise.all([
|
||||||
return Array.isArray(r) ? r : (r.Items ?? []);
|
weiterschauen(),
|
||||||
} catch {
|
naechsteFolgen(),
|
||||||
return [];
|
empfehlungen(24).catch(() => ({ items: [], total: 0 })),
|
||||||
|
...bibliotheken.map(b => inhalt(b, { sort: 'added', take: 24 })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
wurzel.innerHTML = '';
|
||||||
|
|
||||||
|
if (bibliotheken.length === 0) {
|
||||||
|
wurzel.appendChild(leer('Noch keine Mediathek. Lege unter Verwaltung eine an '
|
||||||
|
+ 'und starte einen Scan.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (weiter.items.length) {
|
||||||
|
wurzel.appendChild(reihe('Fortsetzen',
|
||||||
|
weiter.items.map(e => kachel(angefangenAlsKachel(e), oeffnen))));
|
||||||
|
}
|
||||||
|
if (naechste.items.length) {
|
||||||
|
wurzel.appendChild(reihe('Nächste Folge',
|
||||||
|
naechste.items.map(e => kachel(angefangenAlsKachel(e), oeffnen))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let etwasDa = false;
|
||||||
|
bibliotheken.forEach((b, i) => {
|
||||||
|
const seite = bestaende[i];
|
||||||
|
if (!seite?.items.length) return;
|
||||||
|
etwasDa = true;
|
||||||
|
wurzel.appendChild(reihe(
|
||||||
|
// Plex schreibt den Namen der Mediathek in die Überschrift —
|
||||||
|
// „Zuletzt in Filme hinzugefügt". Bei mehreren Mediatheken ist das
|
||||||
|
// der Unterschied zwischen einer Reihe und sieben gleich
|
||||||
|
// aussehenden.
|
||||||
|
`Zuletzt in ${b.name} hinzugefügt`,
|
||||||
|
seite.items.map(e => kachel(alsKachel(e), oeffnen)),
|
||||||
|
seite.total > seite.items.length
|
||||||
|
? { text: 'Alle zeigen', tun: () => alleZeigen(b) }
|
||||||
|
: undefined));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (vorschlag.items.length) {
|
||||||
|
wurzel.appendChild(reihe('Weil du Ähnliches gesehen hast',
|
||||||
|
vorschlag.items.map(e => kachel(alsKachel(e), oeffnen))));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!etwasDa && !weiter.items.length && !naechste.items.length) {
|
||||||
|
wurzel.appendChild(leer('Noch nichts da. Starte unter Verwaltung einen Scan.'));
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
wurzel.innerHTML = '';
|
||||||
|
wurzel.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
}
|
}
|
||||||
};
|
})();
|
||||||
|
|
||||||
const [weiter, naechstes, ...neues] = await Promise.all([
|
return wurzel;
|
||||||
hole(`/Users/${sitzung.userId}/Items/Resume`, { MediaTypes: 'Video' }),
|
|
||||||
hole('/Shows/NextUp', { userId: sitzung.userId }),
|
|
||||||
...ansichten.map((v) => hole(`/Users/${sitzung.userId}/Items/Latest`, { ParentId: v.Id })),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let reihen: Reihe[];
|
|
||||||
if (P.combineContinue) {
|
|
||||||
const gesehen = new Set<string>();
|
|
||||||
const zusammen = [...weiter!, ...naechstes!].filter((i) => !gesehen.has(i.Id) && gesehen.add(i.Id));
|
|
||||||
// Die zusammengelegte Reihe behält die Kennung der ersten — sonst wäre sie
|
|
||||||
// für die gespeicherte Reihenfolge eine neue und stünde wieder unten.
|
|
||||||
reihen = [{ id: 'resume', title: 'Weiterschauen', items: zusammen, wide: true }];
|
|
||||||
} else {
|
|
||||||
reihen = [
|
|
||||||
{ id: 'resume', title: 'Weiterschauen', items: weiter!, wide: true },
|
|
||||||
{ id: 'nextup', title: 'Als Nächstes', items: naechstes!, wide: true },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
reihen.push(...ansichten.map((v, i) => ({
|
|
||||||
id: 'latest.' + v.Id,
|
|
||||||
title: `Neu in ${v.Name}`,
|
|
||||||
// Gesehenes ausblenden gilt nur für Neuzugänge: in „Weiterschauen" wäre
|
|
||||||
// es sinnlos, dort ist alles angefangen.
|
|
||||||
items: (neues[i] ?? []).filter((it) => !P.hideWatched || !it.UserData?.Played),
|
|
||||||
})));
|
|
||||||
reihen = reihen.filter((r) => r.items.length);
|
|
||||||
|
|
||||||
// **Vor** dem Ausblenden merken: die Einstellungen müssen auch die Reihen
|
|
||||||
// anbieten, die gerade versteckt sind.
|
|
||||||
bekannteReihen = reihen.map((r) => ({ id: r.id, title: r.title }));
|
|
||||||
reihen = anordnen(reihen);
|
|
||||||
|
|
||||||
const auswahl = P.heroSource === 'latestOnly'
|
|
||||||
? neues.flat()
|
|
||||||
: [...weiter!, ...naechstes!, ...neues.flat()];
|
|
||||||
const oben = aufmacherWahl(auswahl);
|
|
||||||
|
|
||||||
wurzel.innerHTML = '';
|
|
||||||
wurzel.classList.toggle('pad-top', oben.length === 0);
|
|
||||||
if (oben.length) wurzel.appendChild(aufmacher(oben, oeffnen));
|
|
||||||
for (const r of reihen) wurzel.appendChild(reihe(r, oeffnen));
|
|
||||||
|
|
||||||
if (!reihen.length && !oben.length) {
|
|
||||||
wurzel.innerHTML = '<div class="empty pad-top">Hier ist noch nichts.</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wählt aus, was oben groß steht — dieselbe Regel wie in der App.
|
|
||||||
----------------------------------------------------------------------------
|
|
||||||
Die Auswahl wechselt alle sechs Stunden, und ein Titel ohne Hintergrundbild
|
|
||||||
zieht ein schlechteres Los. Bei jedem Laden zu würfeln wäre unruhig — man
|
|
||||||
tippt auf etwas, geht zurück, und es ist weg. Einmal täglich wäre zu träge. */
|
|
||||||
function aufmacherWahl(items: Item[]): Item[] {
|
|
||||||
let keim = (Math.floor(Date.now() / (6 * 3600 * 1000)) * 2654435761) % 2147483647;
|
|
||||||
const wuerfeln = (): number => (keim = (keim * 16807) % 2147483647) / 2147483647;
|
|
||||||
|
|
||||||
const gesehen = new Set<string>();
|
|
||||||
return items
|
|
||||||
.filter((it) => !gesehen.has(it.Id) && gesehen.add(it.Id))
|
|
||||||
// Wer ein Hintergrundbild hat, zieht aus [0,1) — wer keines hat, nur aus
|
|
||||||
// [0,0.2). Solange es genug mit Bild gibt, kommt er nicht nach vorn; gibt
|
|
||||||
// es wenige, hat er seine Chance.
|
|
||||||
.map((it) => ({ it, los: wuerfeln() * (hatBild(it, 'Backdrop') ? 1 : 0.2) }))
|
|
||||||
.sort((a, b) => b.los - a.los)
|
|
||||||
.slice(0, P.heroCount)
|
|
||||||
.map((x) => x.it);
|
|
||||||
}
|
|
||||||
|
|
||||||
function aufmacher(items: Item[], oeffnen: (item: Item) => void): HTMLElement {
|
|
||||||
const aussen = document.createElement('div');
|
|
||||||
const rahmen = document.createElement('section');
|
|
||||||
rahmen.id = 'hero';
|
|
||||||
|
|
||||||
const bilder = items.map((it, i) => {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.className = 'shot' + (i === 0 ? ' on' : '');
|
|
||||||
d.style.backgroundImage = `url("${bild(it, 'Backdrop', 1600)}")`;
|
|
||||||
return d;
|
|
||||||
});
|
|
||||||
bilder.forEach((b) => rahmen.appendChild(b));
|
|
||||||
rahmen.insertAdjacentHTML('beforeend', '<div class="veil"></div>');
|
|
||||||
|
|
||||||
const koerper = document.createElement('div');
|
|
||||||
koerper.className = 'body';
|
|
||||||
rahmen.appendChild(koerper);
|
|
||||||
|
|
||||||
const punkte = document.createElement('div');
|
|
||||||
punkte.className = 'dots';
|
|
||||||
|
|
||||||
let bei = 0;
|
|
||||||
let uhr: ReturnType<typeof setInterval> | null = null;
|
|
||||||
const anhalten = (): void => { if (uhr) { clearInterval(uhr); uhr = null; } };
|
|
||||||
const schritt = (um: number): void => {
|
|
||||||
anhalten();
|
|
||||||
bei = (bei + um + items.length) % items.length;
|
|
||||||
malen();
|
|
||||||
};
|
|
||||||
|
|
||||||
items.forEach((_, i) => {
|
|
||||||
const d = document.createElement('button');
|
|
||||||
d.className = i ? '' : 'on';
|
|
||||||
d.setAttribute('aria-label', `Titel ${i + 1}`);
|
|
||||||
d.onclick = () => { anhalten(); bei = i; malen(); };
|
|
||||||
punkte.appendChild(d);
|
|
||||||
});
|
|
||||||
|
|
||||||
function malen(): void {
|
|
||||||
const it = items[bei]!;
|
|
||||||
const teile = [it.ProductionYear, laufzeit(it), it.OfficialRating].filter(Boolean);
|
|
||||||
const zeichen = it.ImageTags?.Logo ? bild(it, 'Primary', 600) : '';
|
|
||||||
koerper.innerHTML = `
|
|
||||||
${zeichen && it.ImageTags?.Logo ? `<img class="logo" src="${zeichen}" alt="${esc(it.Name)}">`
|
|
||||||
: `<h2>${esc(it.Name)}</h2>`}
|
|
||||||
<div class="meta">${teile.join(' · ')}</div>
|
|
||||||
<div class="summary">${esc(it.Overview ?? '')}</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn btn-primary">${icon('play', 15)} Abspielen</button>
|
|
||||||
<button class="btn">Details</button>
|
|
||||||
</div>`;
|
|
||||||
const knoepfe = koerper.querySelectorAll('.btn');
|
|
||||||
(knoepfe[0] as HTMLElement).onclick = () => oeffnen(it);
|
|
||||||
(knoepfe[1] as HTMLElement).onclick = () => oeffnen(it);
|
|
||||||
bilder.forEach((b, i) => b.classList.toggle('on', i === bei));
|
|
||||||
[...punkte.children].forEach((d, i) => d.classList.toggle('on', i === bei));
|
|
||||||
}
|
|
||||||
malen();
|
|
||||||
|
|
||||||
if (items.length > 1) {
|
|
||||||
const zurueck = document.createElement('button');
|
|
||||||
zurueck.className = 'hero-nav prev';
|
|
||||||
zurueck.innerHTML = icon('left', 22);
|
|
||||||
zurueck.setAttribute('aria-label', 'Vorheriger Titel');
|
|
||||||
zurueck.onclick = () => schritt(-1);
|
|
||||||
|
|
||||||
const vor = document.createElement('button');
|
|
||||||
vor.className = 'hero-nav next';
|
|
||||||
vor.innerHTML = icon('right', 22);
|
|
||||||
vor.setAttribute('aria-label', 'Nächster Titel');
|
|
||||||
vor.onclick = () => schritt(1);
|
|
||||||
rahmen.append(zurueck, vor);
|
|
||||||
|
|
||||||
// Wischen — erst ab einem ordentlichen Weg und nur, wenn es waagerecht
|
|
||||||
// gemeint ist. Sonst nimmt die Geste dem Scrollen den Vortritt.
|
|
||||||
let x0: number | null = null;
|
|
||||||
let y0 = 0;
|
|
||||||
rahmen.addEventListener('touchstart', (e) => {
|
|
||||||
x0 = e.touches[0]!.clientX; y0 = e.touches[0]!.clientY;
|
|
||||||
}, { passive: true });
|
|
||||||
rahmen.addEventListener('touchend', (e) => {
|
|
||||||
if (x0 == null) return;
|
|
||||||
const dx = e.changedTouches[0]!.clientX - x0;
|
|
||||||
const dy = e.changedTouches[0]!.clientY - y0;
|
|
||||||
if (Math.abs(dx) > 48 && Math.abs(dx) > Math.abs(dy)) schritt(dx < 0 ? 1 : -1);
|
|
||||||
x0 = null;
|
|
||||||
}, { passive: true });
|
|
||||||
|
|
||||||
// Sieben Sekunden — lang genug, um den Satz zu Ende zu lesen.
|
|
||||||
uhr = setInterval(() => {
|
|
||||||
if (!document.body.contains(rahmen)) return anhalten();
|
|
||||||
bei = (bei + 1) % items.length;
|
|
||||||
malen();
|
|
||||||
}, 7000);
|
|
||||||
}
|
|
||||||
|
|
||||||
aussen.append(rahmen, punkte);
|
|
||||||
return aussen;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reihe(r: Reihe, oeffnen: (item: Item) => void): HTMLElement {
|
|
||||||
const abschnitt = document.createElement('section');
|
|
||||||
abschnitt.className = 'row';
|
|
||||||
abschnitt.innerHTML = `<h3>${esc(r.title)}</h3><div class="strip"></div>`;
|
|
||||||
const streifen = abschnitt.querySelector('.strip')!;
|
|
||||||
for (const it of r.items) streifen.appendChild(kachel(it, Boolean(r.wide), oeffnen));
|
|
||||||
return abschnitt;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+306
-24
@@ -1,28 +1,310 @@
|
|||||||
import type { Item, Seite } from '../types';
|
import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, type Ordnung } from '../api';
|
||||||
import { api, sitzung } from '../api';
|
import { leer, fehlerzeile } from '../util';
|
||||||
import { esc, FELDER } from '../util';
|
import { icons } from '../icons';
|
||||||
import { kachel } from './cards';
|
import { kachel, alsKachel, reihe } from '../card';
|
||||||
|
|
||||||
export async function mediathek(
|
type Art = 'filme' | 'serien';
|
||||||
wurzel: HTMLElement,
|
type Reiter = 'empfehlungen' | 'mediathek' | 'kategorien';
|
||||||
id: string,
|
|
||||||
oeffnen: (item: Item) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
wurzel.classList.add('pad-top');
|
|
||||||
wurzel.innerHTML = '<div class="spinner"></div>';
|
|
||||||
|
|
||||||
try {
|
/** Eine Mediathek — mit Reitern, wie bei Plex.
|
||||||
const r = await api<Seite>(
|
*
|
||||||
`/Users/${sitzung.userId}/Items?` + new URLSearchParams({
|
* **Drei Sichten auf denselben Bestand.** „Empfehlungen" ist das, womit man
|
||||||
ParentId: id, Recursive: 'true', IncludeItemTypes: 'Movie,Series',
|
* ankommt; „Mediathek" ist das vollständige Raster für alle, die etwas
|
||||||
SortBy: 'SortName', SortOrder: 'Ascending', Limit: '200', Fields: FELDER,
|
* Bestimmtes suchen; „Kategorien" ist der Weg hinein, wenn man noch nicht
|
||||||
}),
|
* weiss, wonach. Alles in eine Ansicht zu legen hiesse, zwei davon unter ein
|
||||||
);
|
* Ausklappmenü zu schieben. */
|
||||||
const items = r.Items ?? [];
|
export function mediathek(
|
||||||
wurzel.innerHTML = items.length ? '<div class="grid"></div>' : '<div class="empty">Leer.</div>';
|
bibliothek: Bibliothek,
|
||||||
const raster = wurzel.querySelector('.grid');
|
servername: string,
|
||||||
if (raster) for (const it of items) raster.appendChild(kachel(it, false, oeffnen));
|
reiter: string,
|
||||||
} catch (e) {
|
oeffnen: (art: Art, id: string) => void,
|
||||||
wurzel.innerHTML = `<div class="empty">${esc((e as Error).message)}</div>`;
|
): HTMLElement {
|
||||||
|
const wurzel = document.createElement('div');
|
||||||
|
const welcher: Reiter =
|
||||||
|
reiter === 'empfehlungen' || reiter === 'kategorien' ? reiter : 'mediathek';
|
||||||
|
|
||||||
|
wurzel.appendChild(kopf());
|
||||||
|
const buehne = document.createElement('div');
|
||||||
|
wurzel.appendChild(buehne);
|
||||||
|
|
||||||
|
if (welcher === 'empfehlungen') empfehlungen(buehne);
|
||||||
|
else if (welcher === 'kategorien') kategorien(buehne);
|
||||||
|
else raster(buehne);
|
||||||
|
|
||||||
|
/* ---------- Kopf mit Reitern ---------- */
|
||||||
|
|
||||||
|
function kopf(): HTMLElement {
|
||||||
|
const k = document.createElement('div');
|
||||||
|
k.className = 'seitenkopf';
|
||||||
|
|
||||||
|
const wer = document.createElement('div');
|
||||||
|
wer.className = 'wer';
|
||||||
|
const h = document.createElement('h1');
|
||||||
|
h.textContent = bibliothek.name;
|
||||||
|
const quelle = document.createElement('div');
|
||||||
|
quelle.className = 'quelle';
|
||||||
|
quelle.textContent = servername;
|
||||||
|
wer.append(h, quelle);
|
||||||
|
|
||||||
|
const reiterleiste = document.createElement('nav');
|
||||||
|
reiterleiste.className = 'reiter';
|
||||||
|
([
|
||||||
|
['empfehlungen', 'Empfehlungen'],
|
||||||
|
['mediathek', 'Mediathek'],
|
||||||
|
['kategorien', 'Kategorien'],
|
||||||
|
] as [Reiter, string][]).forEach(([wert, text]) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.textContent = text;
|
||||||
|
b.classList.toggle('check', wert === welcher);
|
||||||
|
b.onclick = () => { location.hash = `#/m/${bibliothek.id}/${wert}`; };
|
||||||
|
reiterleiste.appendChild(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
k.append(wer, reiterleiste);
|
||||||
|
return k;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Reiter „Mediathek" ---------- */
|
||||||
|
|
||||||
|
function raster(ziel: HTMLElement): void {
|
||||||
|
let suche = '';
|
||||||
|
let ordnung: Ordnung = 'title';
|
||||||
|
let genre = '';
|
||||||
|
let tippt: number | undefined;
|
||||||
|
|
||||||
|
let gesehen: '' | 'offen' | 'gesehen' = '';
|
||||||
|
|
||||||
|
const zeile = document.createElement('div');
|
||||||
|
zeile.className = 'filterzeile';
|
||||||
|
|
||||||
|
const anzahl = document.createElement('span');
|
||||||
|
anzahl.className = 'anzahl';
|
||||||
|
anzahl.textContent = '…';
|
||||||
|
|
||||||
|
/** Ein Filter als Pille — ohne Rahmen, gesetzt wird er farbig. */
|
||||||
|
function pille(
|
||||||
|
vorgabe: string,
|
||||||
|
werte: [string, string][],
|
||||||
|
gewaehlt: () => string,
|
||||||
|
setzen: (wert: string) => void,
|
||||||
|
): { huelle: HTMLElement; wahl: HTMLSelectElement } {
|
||||||
|
const huelle = document.createElement('div');
|
||||||
|
huelle.className = 'pille';
|
||||||
|
const wahl = document.createElement('select');
|
||||||
|
[[ '', vorgabe ] as [string, string], ...werte].forEach(([w, t]) => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = w; o.textContent = t;
|
||||||
|
wahl.appendChild(o);
|
||||||
|
});
|
||||||
|
wahl.onchange = () => {
|
||||||
|
setzen(wahl.value);
|
||||||
|
huelle.classList.toggle('gesetzt', wahl.value !== '');
|
||||||
|
aufraeumen();
|
||||||
|
void laden();
|
||||||
|
};
|
||||||
|
huelle.classList.toggle('gesetzt', gewaehlt() !== '');
|
||||||
|
huelle.appendChild(wahl);
|
||||||
|
return { huelle, wahl };
|
||||||
|
}
|
||||||
|
|
||||||
|
const genrePille = pille('Alle Genres', [], () => genre, w => { genre = w; });
|
||||||
|
|
||||||
|
const standPille = pille('Alle', [
|
||||||
|
['offen', 'Noch nicht gesehen'],
|
||||||
|
['gesehen', 'Gesehen'],
|
||||||
|
], () => gesehen, w => { gesehen = w as typeof gesehen; });
|
||||||
|
|
||||||
|
// Die Sortierung ist kein Filter: sie ist immer gesetzt und bleibt grau.
|
||||||
|
const sortierHuelle = document.createElement('div');
|
||||||
|
sortierHuelle.className = 'pille';
|
||||||
|
const sortierung = document.createElement('select');
|
||||||
|
([
|
||||||
|
['title', 'Nach Titel'],
|
||||||
|
['added', 'Zuletzt hinzugefügt'],
|
||||||
|
['year', 'Nach Jahr'],
|
||||||
|
] as [Ordnung, string][]).forEach(([wert, text]) => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = wert; o.textContent = text;
|
||||||
|
sortierung.appendChild(o);
|
||||||
|
});
|
||||||
|
sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); };
|
||||||
|
sortierHuelle.appendChild(sortierung);
|
||||||
|
|
||||||
|
// Erscheint nur, wenn es etwas zurückzusetzen gibt.
|
||||||
|
const zuruecksetzen = document.createElement('button');
|
||||||
|
zuruecksetzen.className = 'zuruecksetzen';
|
||||||
|
zuruecksetzen.textContent = 'Filter zurücksetzen';
|
||||||
|
zuruecksetzen.hidden = true;
|
||||||
|
zuruecksetzen.onclick = () => {
|
||||||
|
genre = ''; gesehen = '';
|
||||||
|
genrePille.wahl.value = ''; standPille.wahl.value = '';
|
||||||
|
genrePille.huelle.classList.remove('gesetzt');
|
||||||
|
standPille.huelle.classList.remove('gesetzt');
|
||||||
|
aufraeumen();
|
||||||
|
void laden();
|
||||||
|
};
|
||||||
|
|
||||||
|
function aufraeumen(): void {
|
||||||
|
zuruecksetzen.hidden = genre === '' && gesehen === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const suchhuelle = document.createElement('div');
|
||||||
|
suchhuelle.className = 'suchhuelle';
|
||||||
|
suchhuelle.innerHTML = icons.suche;
|
||||||
|
const suchfeld = document.createElement('input');
|
||||||
|
suchfeld.type = 'search';
|
||||||
|
suchfeld.placeholder = `In ${bibliothek.name} suchen`;
|
||||||
|
suchfeld.oninput = () => {
|
||||||
|
// Nicht bei jedem Tastendruck fragen: das wären bei „Untergang" neun
|
||||||
|
// Anfragen, von denen acht niemanden interessieren.
|
||||||
|
window.clearTimeout(tippt);
|
||||||
|
tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250);
|
||||||
|
};
|
||||||
|
suchhuelle.appendChild(suchfeld);
|
||||||
|
|
||||||
|
zeile.append(genrePille.huelle, standPille.huelle, sortierHuelle,
|
||||||
|
anzahl, zuruecksetzen, suchhuelle);
|
||||||
|
|
||||||
|
const gitter = document.createElement('div');
|
||||||
|
gitter.className = 'raster';
|
||||||
|
ziel.append(zeile, gitter);
|
||||||
|
|
||||||
|
// Die Genres der Mediathek nachladen — die Liste steht erst, wenn der
|
||||||
|
// Server sie genannt hat, und solange bleibt „Alle Genres" allein.
|
||||||
|
void genresVon(bibliothek.id).then(namen => {
|
||||||
|
namen.forEach(n => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = n; o.textContent = n;
|
||||||
|
genrePille.wahl.appendChild(o);
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
void laden();
|
||||||
|
|
||||||
|
async function laden(): Promise<void> {
|
||||||
|
gitter.innerHTML = '<div class="spinner"></div>';
|
||||||
|
try {
|
||||||
|
const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, genre, take: 200 });
|
||||||
|
|
||||||
|
// **Gesehen/offen siebt hier, nicht am Server.** Er kann es noch
|
||||||
|
// nicht; es jetzt wegzulassen hiesse, einen Filter anzubieten, den
|
||||||
|
// es nicht gibt. Der Preis steht in der Zahl daneben: sie zählt,
|
||||||
|
// was übrig bleibt, nicht was der Server gefunden hat.
|
||||||
|
const uebrig = gesehen === '' ? seite.items
|
||||||
|
: seite.items.filter(e => {
|
||||||
|
const ist = 'state' in e ? e.state?.watched === true
|
||||||
|
: 'unwatchedCount' in e ? e.unwatchedCount === 0 : false;
|
||||||
|
return gesehen === 'gesehen' ? ist : !ist;
|
||||||
|
});
|
||||||
|
|
||||||
|
anzahl.textContent = gesehen === ''
|
||||||
|
? String(seite.total)
|
||||||
|
: `${uebrig.length} von ${seite.total}`;
|
||||||
|
|
||||||
|
gitter.innerHTML = '';
|
||||||
|
if (uebrig.length === 0) {
|
||||||
|
gitter.appendChild(leer(suche || genre || gesehen
|
||||||
|
? 'Nichts gefunden.'
|
||||||
|
: `„${bibliothek.name}" ist leer. Starte unter Verwaltung einen Scan.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uebrig.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen)));
|
||||||
|
} catch (fehler) {
|
||||||
|
gitter.innerHTML = '';
|
||||||
|
gitter.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Reiter „Empfehlungen" ---------- */
|
||||||
|
|
||||||
|
function empfehlungen(ziel: HTMLElement): void {
|
||||||
|
ziel.innerHTML = '<div class="spinner"></div>';
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const [neu, beliebt, vorschlag] = await Promise.all([
|
||||||
|
inhalt(bibliothek, { sort: 'added', take: 24 }),
|
||||||
|
inhalt(bibliothek, { sort: 'year', take: 24 }),
|
||||||
|
holeEmpfehlungen(24).catch(() => ({ items: [], total: 0 })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ziel.innerHTML = '';
|
||||||
|
if (neu.items.length === 0) {
|
||||||
|
ziel.appendChild(leer(`„${bibliothek.name}" ist leer.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ziel.appendChild(reihe(
|
||||||
|
`Zuletzt in ${bibliothek.name} hinzugefügt`,
|
||||||
|
neu.items.map(e => kachel(alsKachel(e), oeffnen))));
|
||||||
|
|
||||||
|
if (vorschlag.items.length) {
|
||||||
|
ziel.appendChild(reihe(
|
||||||
|
'Weil du Ähnliches gesehen hast',
|
||||||
|
vorschlag.items.map(e => kachel(alsKachel(e), oeffnen))));
|
||||||
|
}
|
||||||
|
|
||||||
|
ziel.appendChild(reihe(
|
||||||
|
'Nach Jahr',
|
||||||
|
beliebt.items.map(e => kachel(alsKachel(e), oeffnen))));
|
||||||
|
} catch (fehler) {
|
||||||
|
ziel.innerHTML = '';
|
||||||
|
ziel.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Reiter „Kategorien" ---------- */
|
||||||
|
|
||||||
|
function kategorien(ziel: HTMLElement): void {
|
||||||
|
ziel.innerHTML = '<div class="spinner"></div>';
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const namen = await genresVon(bibliothek.id);
|
||||||
|
ziel.innerHTML = '';
|
||||||
|
if (namen.length === 0) {
|
||||||
|
ziel.appendChild(leer('Für diese Mediathek sind noch keine Genres bekannt. '
|
||||||
|
+ 'Frisch unter Verwaltung die Metadaten auf.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitter = document.createElement('div');
|
||||||
|
gitter.className = 'kategorien';
|
||||||
|
namen.forEach(name => {
|
||||||
|
const k = document.createElement('button');
|
||||||
|
k.className = 'kategorie';
|
||||||
|
k.textContent = name;
|
||||||
|
// **Die Farbe aus dem Namen, nicht aus einer Liste.** Eine feste
|
||||||
|
// Zuordnung Genre→Farbe müsste jedes Genre kennen, das TMDB je
|
||||||
|
// erfindet. So bekommt jedes eine eigene und immer dieselbe.
|
||||||
|
k.style.background = farbeAus(name);
|
||||||
|
k.onclick = () => {
|
||||||
|
location.hash = `#/m/${bibliothek.id}/mediathek`;
|
||||||
|
// Der Filter wird über die Adresse gesetzt, sobald das Raster
|
||||||
|
// steht — sonst schriebe man in ein Element, das es nicht gibt.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const wahl = document.querySelector<HTMLSelectElement>('.filterzeile select');
|
||||||
|
if (wahl) { wahl.value = name; wahl.dispatchEvent(new Event('change')); }
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
gitter.appendChild(k);
|
||||||
|
});
|
||||||
|
ziel.appendChild(gitter);
|
||||||
|
} catch (fehler) {
|
||||||
|
ziel.innerHTML = '';
|
||||||
|
ziel.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
return wurzel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine immer gleiche Farbe aus einem Namen. */
|
||||||
|
function farbeAus(name: string): string {
|
||||||
|
let summe = 0;
|
||||||
|
for (const z of name) summe = (summe * 31 + z.charCodeAt(0)) % 360;
|
||||||
|
return `linear-gradient(135deg, hsl(${summe} 42% 28%), hsl(${(summe + 40) % 360} 38% 20%))`;
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-42
@@ -1,50 +1,48 @@
|
|||||||
import { anmelden } from '../api';
|
import { anmelden } from '../api';
|
||||||
import { esc } from '../util';
|
import { knopf, feld, fehlerzeile } from '../util';
|
||||||
|
|
||||||
export function anmeldung(wurzel: HTMLElement, fertig: () => void): void {
|
export function anmeldung(fertig: () => void): HTMLElement {
|
||||||
wurzel.innerHTML = `
|
const karte = document.createElement('div');
|
||||||
<form class="login-card" id="login-form">
|
karte.className = 'login-card';
|
||||||
<h1>Flaro</h1>
|
|
||||||
<p class="sub">Deine Mediathek, schöner.</p>
|
|
||||||
<div class="field">
|
|
||||||
<label for="user">Benutzer</label>
|
|
||||||
<input id="user" type="text" autocomplete="username" required>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="pass">Passwort</label>
|
|
||||||
<input id="pass" type="password" autocomplete="current-password">
|
|
||||||
</div>
|
|
||||||
<p class="error" id="login-error"></p>
|
|
||||||
<button class="btn btn-primary" type="submit" id="login-btn">Anmelden</button>
|
|
||||||
<p class="note">
|
|
||||||
Der Zugangsschlüssel bleibt in diesem Browser. Das Passwort geht an
|
|
||||||
deinen eigenen Server und wird nirgends gespeichert.
|
|
||||||
</p>
|
|
||||||
</form>`;
|
|
||||||
|
|
||||||
const form = wurzel.querySelector<HTMLFormElement>('#login-form')!;
|
const titel = document.createElement('h1');
|
||||||
const knopf = wurzel.querySelector<HTMLButtonElement>('#login-btn')!;
|
titel.textContent = 'Flaro';
|
||||||
const fehler = wurzel.querySelector<HTMLElement>('#login-error')!;
|
|
||||||
|
|
||||||
form.onsubmit = async (e) => {
|
const name = feld('Benutzername');
|
||||||
e.preventDefault();
|
name.autocomplete = 'username';
|
||||||
knopf.disabled = true;
|
const passwort = feld('Passwort', 'password');
|
||||||
knopf.textContent = 'Verbinde …';
|
passwort.autocomplete = 'current-password';
|
||||||
fehler.textContent = '';
|
|
||||||
|
// **Vorgabe ist „bleiben".** Am eigenen Rechner will sich niemand täglich
|
||||||
|
// neu anmelden; wer an einem fremden sitzt, schaltet es ab. Ohne Haken
|
||||||
|
// verfällt die Anmeldung mit dem Browserfenster.
|
||||||
|
const bleiben = document.createElement('label');
|
||||||
|
bleiben.className = 'bleiben';
|
||||||
|
const haken = document.createElement('input');
|
||||||
|
haken.type = 'checkbox';
|
||||||
|
haken.checked = true;
|
||||||
|
bleiben.append(haken, document.createTextNode(' Angemeldet bleiben'));
|
||||||
|
|
||||||
|
const los = knopf('Anmelden', 'btn btn-primary');
|
||||||
|
|
||||||
|
karte.append(titel, name, passwort, bleiben, los);
|
||||||
|
|
||||||
|
async function absenden(): Promise<void> {
|
||||||
|
karte.querySelector('.fehler')?.remove();
|
||||||
|
los.disabled = true;
|
||||||
try {
|
try {
|
||||||
await anmelden(
|
await anmelden(name.value.trim(), passwort.value, haken.checked);
|
||||||
wurzel.querySelector<HTMLInputElement>('#user')!.value,
|
|
||||||
wurzel.querySelector<HTMLInputElement>('#pass')!.value,
|
|
||||||
);
|
|
||||||
fertig();
|
fertig();
|
||||||
} catch (e2) {
|
} catch (fehler) {
|
||||||
// Die Serveradresse steht nicht mehr hier, sondern beim Server davor —
|
karte.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
// deshalb kann sie auch nicht mehr falsch eingetippt sein. Bleibt die
|
los.disabled = false;
|
||||||
// Anmeldung selbst, oder ein Server, der nicht läuft.
|
passwort.select();
|
||||||
fehler.textContent = 'Anmeldung fehlgeschlagen: ' + esc((e2 as Error).message);
|
|
||||||
} finally {
|
|
||||||
knopf.disabled = false;
|
|
||||||
knopf.textContent = 'Anmelden';
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
los.onclick = () => void absenden();
|
||||||
|
name.onkeydown = e => { if (e.key === 'Enter') passwort.focus(); };
|
||||||
|
passwort.onkeydown = e => { if (e.key === 'Enter') void absenden(); };
|
||||||
|
setTimeout(() => name.focus(), 50);
|
||||||
|
return karte;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,499 +0,0 @@
|
|||||||
import type HlsTyp from 'hls.js';
|
|
||||||
import type { Item, Quelle, Spur } from '../types';
|
|
||||||
import { api, medienURL, sitzung, wiedergabeauskunft } from '../api';
|
|
||||||
import { P, QUALITIES, RATES } from '../prefs';
|
|
||||||
import { icon } from '../icons';
|
|
||||||
import { esc, folgentitel, uhr } from '../util';
|
|
||||||
|
|
||||||
/* Der Player.
|
|
||||||
----------------------------------------------------------------------------
|
|
||||||
Vollbild und eigene Bedienung, nach dem Bauplan der iPad-App: Titel und die
|
|
||||||
Wahl oben, Transport in der Mitte, unten **nur** der Regler mit den beiden
|
|
||||||
Zeiten. Und keine Knopfflächen — dort sind es blanke weiße Symbole, der
|
|
||||||
einzige Hintergrund ist die Abdunklung über dem ganzen Bild. */
|
|
||||||
|
|
||||||
interface Lauf {
|
|
||||||
quality: number;
|
|
||||||
audio: number | null;
|
|
||||||
subtitle: number | null;
|
|
||||||
streams: Spur[];
|
|
||||||
stelle: number;
|
|
||||||
sitzung?: Sitzungsmeldung;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Sitzungsmeldung {
|
|
||||||
id?: string;
|
|
||||||
quelleID: string;
|
|
||||||
itemID: string;
|
|
||||||
stelle: number;
|
|
||||||
melden: (weg: string, stelle?: number, extra?: Record<string, unknown>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function playerOeffnen(item: Item, beimSchliessen?: () => void): void {
|
|
||||||
document.getElementById('player')?.remove();
|
|
||||||
|
|
||||||
const ebene = document.createElement('div');
|
|
||||||
ebene.id = 'player';
|
|
||||||
ebene.innerHTML = `
|
|
||||||
<video playsinline></video>
|
|
||||||
<div class="pl-chrome">
|
|
||||||
<div class="pl-top">
|
|
||||||
<button class="pl-icon" id="pl-close" aria-label="Schließen">${icon('close')}</button>
|
|
||||||
<div class="pl-title">
|
|
||||||
<strong>${esc(item.SeriesName || item.Name)}</strong>
|
|
||||||
${item.SeriesName ? `<span>${esc(folgentitel(item))}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
<span class="pl-spacer"></span>
|
|
||||||
<button class="pl-icon" id="pl-audio" aria-label="Tonspur" hidden>${icon('waveform')}</button>
|
|
||||||
<button class="pl-icon" id="pl-subs" aria-label="Untertitel" hidden>${icon('captions')}</button>
|
|
||||||
<button class="pl-icon pl-quality" id="pl-qual" aria-label="Qualität">Original</button>
|
|
||||||
<button class="pl-icon" id="pl-speed" aria-label="Geschwindigkeit">${icon('speed')}</button>
|
|
||||||
<button class="pl-icon" id="pl-mute" aria-label="Ton">${icon('sound')}</button>
|
|
||||||
<button class="pl-icon" id="pl-full" aria-label="Vollbild">${icon('expand')}</button>
|
|
||||||
</div>
|
|
||||||
<div class="pl-center">
|
|
||||||
<button class="pl-round" id="pl-back" aria-label="Zurückspulen">${icon('goBack', 40, P.skipBackward)}</button>
|
|
||||||
<button class="pl-round pl-play" id="pl-toggle" aria-label="Abspielen">${icon('play', 52)}</button>
|
|
||||||
<button class="pl-round" id="pl-fwd" aria-label="Vorspulen">${icon('goFwd', 40, P.skipForward)}</button>
|
|
||||||
</div>
|
|
||||||
<div class="pl-bottom">
|
|
||||||
<div class="pl-track" id="pl-track"><div class="pl-buf"></div><div class="pl-fill"></div><i></i></div>
|
|
||||||
<div class="pl-times"><span id="pl-now">0:00</span><span id="pl-left">–:--</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pl-menu" id="pl-menu" hidden></div>
|
|
||||||
<div class="pl-busy" id="pl-busy"><i></i></div>`;
|
|
||||||
document.body.appendChild(ebene);
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
const v = ebene.querySelector('video')!;
|
|
||||||
const chrome = ebene.querySelector('.pl-chrome')!;
|
|
||||||
const el = <T extends HTMLElement>(id: string): T => ebene.querySelector('#' + id) as T;
|
|
||||||
|
|
||||||
const lauf: Lauf = {
|
|
||||||
quality: P.quality, audio: null, subtitle: null, streams: [], stelle: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- Fehler sichtbar machen ---------- */
|
|
||||||
let hls: HlsTyp | null = null;
|
|
||||||
/** Ob gerade auf Daten gewartet wird — am Anfang immer. */
|
|
||||||
let wartet = true;
|
|
||||||
|
|
||||||
const scheitern = (satz: string, hinweis?: string, adresse?: string): void => {
|
|
||||||
ebene.classList.remove('loading');
|
|
||||||
el('pl-busy').classList.remove('on');
|
|
||||||
// Sonst dreht sich der Kreisel im Knopf hinter dem Fehlerkasten weiter,
|
|
||||||
// als käme noch etwas.
|
|
||||||
wartet = false;
|
|
||||||
malen();
|
|
||||||
ebene.querySelector('.pl-error')?.remove();
|
|
||||||
const kasten = document.createElement('div');
|
|
||||||
kasten.className = 'pl-error';
|
|
||||||
kasten.innerHTML = `<strong>${esc(satz)}</strong>${hinweis ? `<span>${esc(hinweis)}</span>` : ''}
|
|
||||||
${adresse ? `<code>${esc(adresse.replace(/api_key=[^&]*/, 'api_key=…'))}</code>` : ''}
|
|
||||||
<button class="btn">Schließen</button>`;
|
|
||||||
kasten.querySelector('button')!.onclick = schliessen;
|
|
||||||
ebene.appendChild(kasten);
|
|
||||||
};
|
|
||||||
|
|
||||||
const diagnose = { text: '', url: '' };
|
|
||||||
v.addEventListener('error', () => {
|
|
||||||
const c = v.error?.code;
|
|
||||||
scheitern('Der Browser kann diesen Strom nicht abspielen.',
|
|
||||||
(c === 4 ? 'Das Format wird nicht unterstützt. ' : `Fehlercode ${c ?? '?'}. `) + diagnose.text,
|
|
||||||
diagnose.url);
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ---------- Bedienung ein- und ausblenden ---------- */
|
|
||||||
let ausblenden: ReturnType<typeof setTimeout>;
|
|
||||||
const zeigen = (): void => {
|
|
||||||
chrome.classList.add('on');
|
|
||||||
clearTimeout(ausblenden);
|
|
||||||
// Solange etwas steht, bleibt die Bedienung: sie auszublenden, während
|
|
||||||
// niemand spielt, versteckt den einzigen Knopf, der weiterhilft.
|
|
||||||
if (!v.paused) {
|
|
||||||
ausblenden = setTimeout(() => {
|
|
||||||
chrome.classList.remove('on');
|
|
||||||
kreisel();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
kreisel();
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Der freistehende Kreisel — nur, wenn die Bedienung aus ist.
|
|
||||||
*
|
|
||||||
* Sonst stünden zwei übereinander: einer in der Mitte des Bildes und einer
|
|
||||||
* im Abspielknopf, der genau davor sitzt. Genau so sah es vorher aus. */
|
|
||||||
const kreisel = (): void => {
|
|
||||||
el('pl-busy').classList.toggle('on', wartet && !chrome.classList.contains('on'));
|
|
||||||
};
|
|
||||||
ebene.addEventListener('mousemove', zeigen);
|
|
||||||
ebene.addEventListener('touchstart', zeigen, { passive: true });
|
|
||||||
|
|
||||||
/* ---------- Transport ---------- */
|
|
||||||
const umschalten = (): void => { void (v.paused ? v.play() : v.pause()); };
|
|
||||||
el('pl-toggle').onclick = umschalten;
|
|
||||||
el('pl-back').onclick = () => { v.currentTime = Math.max(0, v.currentTime - P.skipBackward); zeigen(); };
|
|
||||||
el('pl-fwd').onclick = () => { v.currentTime = Math.min(v.duration || 1e9, v.currentTime + P.skipForward); zeigen(); };
|
|
||||||
v.addEventListener('click', umschalten);
|
|
||||||
|
|
||||||
/** Zeichnet den mittleren Knopf: Kreisel, Dreieck oder Doppelbalken.
|
|
||||||
*
|
|
||||||
* Solange auf Daten gewartet wird, steht dort der Kreisel — **kein**
|
|
||||||
* Dreieck. Ein Dreieck heißt „angehalten, drück mich", und beides stimmte
|
|
||||||
* nicht: es lief bereits, und der Klick bewirkte nichts. */
|
|
||||||
const malen = (): void => {
|
|
||||||
const knopf = el('pl-toggle');
|
|
||||||
knopf.innerHTML = wartet ? '<i class="pl-wait"></i>' : icon(v.paused ? 'play' : 'pause', 52);
|
|
||||||
knopf.setAttribute('aria-label', wartet ? 'Lädt' : v.paused ? 'Abspielen' : 'Anhalten');
|
|
||||||
};
|
|
||||||
|
|
||||||
const warten = (an: boolean): void => {
|
|
||||||
wartet = an;
|
|
||||||
malen();
|
|
||||||
kreisel();
|
|
||||||
};
|
|
||||||
|
|
||||||
v.addEventListener('play', () => { malen(); zeigen(); });
|
|
||||||
v.addEventListener('pause', () => { warten(false); zeigen(); });
|
|
||||||
v.addEventListener('waiting', () => warten(true));
|
|
||||||
v.addEventListener('playing', () => {
|
|
||||||
warten(false);
|
|
||||||
ebene.classList.remove('loading');
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ---------- Fortschrittsleiste ---------- */
|
|
||||||
const spur = el('pl-track');
|
|
||||||
v.addEventListener('timeupdate', () => {
|
|
||||||
const teil = v.duration ? v.currentTime / v.duration : 0;
|
|
||||||
spur.querySelector<HTMLElement>('.pl-fill')!.style.width = teil * 100 + '%';
|
|
||||||
spur.querySelector<HTMLElement>('i')!.style.left = teil * 100 + '%';
|
|
||||||
el('pl-now').textContent = uhr(v.currentTime);
|
|
||||||
// Restzeit statt Gesamtlänge: „noch 42 Minuten" beantwortet die Frage,
|
|
||||||
// die man sich beim Schauen tatsächlich stellt.
|
|
||||||
el('pl-left').textContent = '−' + uhr((v.duration || 0) - v.currentTime);
|
|
||||||
});
|
|
||||||
v.addEventListener('progress', () => {
|
|
||||||
if (!v.buffered.length || !v.duration) return;
|
|
||||||
spur.querySelector<HTMLElement>('.pl-buf')!.style.width =
|
|
||||||
(v.buffered.end(v.buffered.length - 1) / v.duration) * 100 + '%';
|
|
||||||
});
|
|
||||||
|
|
||||||
const springen = (e: MouseEvent | TouchEvent): void => {
|
|
||||||
const r = spur.getBoundingClientRect();
|
|
||||||
const x = ((e instanceof MouseEvent ? e.clientX : e.touches[0]!.clientX) - r.left) / r.width;
|
|
||||||
if (v.duration) v.currentTime = Math.min(Math.max(x, 0), 1) * v.duration;
|
|
||||||
zeigen();
|
|
||||||
};
|
|
||||||
spur.onclick = springen;
|
|
||||||
spur.onmousedown = (e) => {
|
|
||||||
springen(e);
|
|
||||||
const ziehen = (ev: MouseEvent): void => springen(ev);
|
|
||||||
const los = (): void => {
|
|
||||||
removeEventListener('mousemove', ziehen);
|
|
||||||
removeEventListener('mouseup', los);
|
|
||||||
};
|
|
||||||
addEventListener('mousemove', ziehen);
|
|
||||||
addEventListener('mouseup', los);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- Die drei Listen ----------
|
|
||||||
Getrennt, weil es drei verschiedene Fragen sind: die Tonspur stellt man
|
|
||||||
einmal am Anfang um, die Untertitel mitten im Film, die Qualität, wenn es
|
|
||||||
hakt. Eine gemeinsame Liste hieße jedes Mal an den anderen vorbeisuchen. */
|
|
||||||
const menue = el('pl-menu');
|
|
||||||
const menueZu = (): void => { menue.hidden = true; };
|
|
||||||
const spuren = (art: Spur['Type']): Spur[] => lauf.streams.filter((x) => x.Type === art);
|
|
||||||
const nenne = (t: Spur): string => t.DisplayTitle || t.Language || `Spur ${t.Index}`;
|
|
||||||
|
|
||||||
interface Eintrag { text: string; gewaehlt: boolean; waehlen: () => void }
|
|
||||||
|
|
||||||
function liste(knopf: HTMLElement, titel: string, eintraege: Eintrag[]): void {
|
|
||||||
if (!menue.hidden && menue.dataset.von === knopf.id) return menueZu();
|
|
||||||
menue.dataset.von = knopf.id;
|
|
||||||
menue.innerHTML = `<div class="pl-group">${esc(titel)}</div>`;
|
|
||||||
|
|
||||||
for (const { text, gewaehlt, waehlen } of eintraege) {
|
|
||||||
const b = document.createElement('button');
|
|
||||||
b.className = 'pl-item' + (gewaehlt ? ' on' : '');
|
|
||||||
b.innerHTML = `<span>${esc(text)}</span>${gewaehlt ? icon('check', 16) : ''}`;
|
|
||||||
b.onclick = () => (gewaehlt ? menueZu() : waehlen());
|
|
||||||
menue.appendChild(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unter seinem Knopf, nicht in der Mitte: so ist zu sehen, wozu die Liste
|
|
||||||
// gehört, wenn mehrere davon nebeneinander sitzen.
|
|
||||||
const r = knopf.getBoundingClientRect();
|
|
||||||
menue.style.right = Math.max(12, innerWidth - r.right) + 'px';
|
|
||||||
menue.style.top = r.bottom + 8 + 'px';
|
|
||||||
menue.hidden = false;
|
|
||||||
zeigen();
|
|
||||||
}
|
|
||||||
|
|
||||||
const neuLaden = (): void => {
|
|
||||||
lauf.stelle = v.currentTime || 0;
|
|
||||||
ebene.querySelector('.pl-error')?.remove();
|
|
||||||
ebene.classList.add('loading');
|
|
||||||
warten(true);
|
|
||||||
menueZu();
|
|
||||||
void strom();
|
|
||||||
};
|
|
||||||
|
|
||||||
el('pl-audio').onclick = () => liste(el('pl-audio'), 'Tonspur',
|
|
||||||
spuren('Audio').map((t) => ({
|
|
||||||
text: nenne(t), gewaehlt: lauf.audio === t.Index,
|
|
||||||
waehlen: () => { lauf.audio = t.Index; neuLaden(); },
|
|
||||||
})));
|
|
||||||
|
|
||||||
el('pl-subs').onclick = () => liste(el('pl-subs'), 'Untertitel', [
|
|
||||||
{ text: 'Aus', gewaehlt: (lauf.subtitle ?? -1) < 0, waehlen: () => { lauf.subtitle = -1; neuLaden(); } },
|
|
||||||
...spuren('Subtitle').map((t) => ({
|
|
||||||
text: nenne(t), gewaehlt: lauf.subtitle === t.Index,
|
|
||||||
waehlen: () => { lauf.subtitle = t.Index; neuLaden(); },
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
|
|
||||||
el('pl-qual').onclick = () => liste(el('pl-qual'), 'Qualität',
|
|
||||||
QUALITIES.map(([wert, text]) => ({
|
|
||||||
text, gewaehlt: lauf.quality === wert,
|
|
||||||
waehlen: () => { lauf.quality = wert; neuLaden(); },
|
|
||||||
})));
|
|
||||||
|
|
||||||
el('pl-speed').onclick = () => liste(el('pl-speed'), 'Geschwindigkeit',
|
|
||||||
RATES.map((r) => ({
|
|
||||||
text: r === 1 ? 'Normal' : r + '×',
|
|
||||||
gewaehlt: Math.abs(v.playbackRate - r) < 0.01,
|
|
||||||
waehlen: () => { v.playbackRate = r; menueZu(); zeigen(); },
|
|
||||||
})));
|
|
||||||
|
|
||||||
ebene.addEventListener('click', (e) => {
|
|
||||||
const ziel = e.target as HTMLElement;
|
|
||||||
if (menue.hidden || menue.contains(ziel) || ziel.closest('.pl-icon')) return;
|
|
||||||
menueZu();
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ---------- Ton ---------- */
|
|
||||||
const tonMalen = (): void => {
|
|
||||||
el('pl-mute').innerHTML = icon(v.muted || v.volume === 0 ? 'muted' : 'sound');
|
|
||||||
el('pl-mute').title = `Lautstärke ${Math.round(v.volume * 100)} %`;
|
|
||||||
};
|
|
||||||
el('pl-mute').onclick = () => { v.muted = !v.muted; tonMalen(); };
|
|
||||||
el('pl-mute').addEventListener('wheel', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
v.volume = Math.min(1, Math.max(0, v.volume - Math.sign(e.deltaY) * 0.05));
|
|
||||||
v.muted = v.volume === 0;
|
|
||||||
tonMalen();
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
el('pl-full').onclick = () => {
|
|
||||||
void (document.fullscreenElement ? document.exitFullscreen() : ebene.requestFullscreen?.());
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- Tastatur ---------- */
|
|
||||||
const tasten = (e: KeyboardEvent): void => {
|
|
||||||
const ziel = e.target as HTMLElement;
|
|
||||||
if (ziel.tagName === 'SELECT' || ziel.tagName === 'INPUT') return;
|
|
||||||
const wege: Record<string, () => void> = {
|
|
||||||
' ': umschalten, k: umschalten,
|
|
||||||
ArrowLeft: () => { v.currentTime -= P.skipBackward; },
|
|
||||||
ArrowRight: () => { v.currentTime += P.skipForward; },
|
|
||||||
ArrowUp: () => { v.volume = Math.min(1, v.volume + 0.1); tonMalen(); },
|
|
||||||
ArrowDown: () => { v.volume = Math.max(0, v.volume - 0.1); tonMalen(); },
|
|
||||||
f: () => el('pl-full').click(),
|
|
||||||
Escape: () => schliessen(),
|
|
||||||
};
|
|
||||||
const tun = wege[e.key];
|
|
||||||
if (!tun) return;
|
|
||||||
e.preventDefault();
|
|
||||||
tun();
|
|
||||||
zeigen();
|
|
||||||
};
|
|
||||||
addEventListener('keydown', tasten);
|
|
||||||
|
|
||||||
/* ---------- Zurück aus dem Hintergrund ----------
|
|
||||||
Wer die Anwendung wechselt, kommt zu einem stehenden Bild bei laufendem
|
|
||||||
Ton zurück: der Browser hält das Entpacken an, während der Tab verdeckt
|
|
||||||
ist, und hls.js findet danach nicht von selbst in den Puffer zurück. */
|
|
||||||
const zurueck = (): void => {
|
|
||||||
if (document.hidden || v.paused || !hls) return;
|
|
||||||
const stelle = v.currentTime;
|
|
||||||
hls.startLoad(stelle);
|
|
||||||
// Ein winziger Sprung erzwingt einen neuen Schlüsselbildpunkt. Ohne ihn
|
|
||||||
// bleibt das zuletzt gezeigte Bild stehen, obwohl Segmente ankommen.
|
|
||||||
if (stelle > 0.5) v.currentTime = stelle - 0.1;
|
|
||||||
};
|
|
||||||
document.addEventListener('visibilitychange', zurueck);
|
|
||||||
|
|
||||||
/* ---------- Schließen ---------- */
|
|
||||||
function schliessen(): void {
|
|
||||||
removeEventListener('keydown', tasten);
|
|
||||||
document.removeEventListener('visibilitychange', zurueck);
|
|
||||||
clearTimeout(ausblenden);
|
|
||||||
if (document.fullscreenElement) void document.exitFullscreen?.();
|
|
||||||
|
|
||||||
// **Erst melden, dann abräumen.** Andersherum steht die Stelle schon auf
|
|
||||||
// null, wenn die Meldung hinausgeht — und Jellyfin löscht daraufhin den
|
|
||||||
// Fortschritt, statt ihn zu speichern.
|
|
||||||
lauf.sitzung?.melden('/Sessions/Playing/Stopped');
|
|
||||||
|
|
||||||
hls?.destroy();
|
|
||||||
hls = null;
|
|
||||||
v.pause();
|
|
||||||
v.removeAttribute('src');
|
|
||||||
v.load();
|
|
||||||
ebene.remove();
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
beimSchliessen?.();
|
|
||||||
}
|
|
||||||
el('pl-close').onclick = schliessen;
|
|
||||||
|
|
||||||
/* ---------- Der Strom ---------- */
|
|
||||||
async function strom(): Promise<void> {
|
|
||||||
hls?.destroy();
|
|
||||||
hls = null;
|
|
||||||
|
|
||||||
let auskunft;
|
|
||||||
try {
|
|
||||||
auskunft = await wiedergabeauskunft(item.Id, lauf);
|
|
||||||
} catch (e) {
|
|
||||||
return scheitern('Der Server hat die Wiedergabe abgelehnt.', (e as Error).message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const quelle: Quelle | undefined = auskunft.MediaSources?.[0];
|
|
||||||
if (!quelle) return scheitern('Der Server nennt keine abspielbare Quelle.');
|
|
||||||
|
|
||||||
// Was zur Auswahl steht, weiß man erst jetzt. Beim ersten Lauf gilt die
|
|
||||||
// Vorauswahl des Servers.
|
|
||||||
lauf.streams = quelle.MediaStreams ?? [];
|
|
||||||
if (lauf.audio == null) {
|
|
||||||
lauf.audio = quelle.DefaultAudioStreamIndex ?? spuren('Audio')[0]?.Index ?? null;
|
|
||||||
}
|
|
||||||
if (lauf.subtitle == null) lauf.subtitle = quelle.DefaultSubtitleStreamIndex ?? -1;
|
|
||||||
spurenBekannt();
|
|
||||||
|
|
||||||
// **Die Adresse kommt vom Server.** Nennt er keine, wird ausdrücklich ein
|
|
||||||
// HLS-Strom verlangt — nicht die Rohdatei. Ein MKV mit DTS ist für einen
|
|
||||||
// Browser unlesbar, und genau dieser Rückfall war der Grund, warum lange
|
|
||||||
// gar nichts lief.
|
|
||||||
const adresse = quelle.TranscodingUrl
|
|
||||||
? new URL(quelle.TranscodingUrl, location.origin + '/api').href
|
|
||||||
: medienURL(`/Videos/${item.Id}/master.m3u8`, {
|
|
||||||
mediaSourceId: quelle.Id ?? item.Id,
|
|
||||||
PlaySessionId: auskunft.PlaySessionId ?? '',
|
|
||||||
DeviceId: sitzung.geraet,
|
|
||||||
VideoCodec: 'h264', AudioCodec: 'aac,mp3',
|
|
||||||
TranscodingMaxAudioChannels: '2', SegmentContainer: 'ts',
|
|
||||||
MinSegments: '1', BreakOnNonKeyFrames: 'true', TranscodingProtocol: 'hls',
|
|
||||||
...(lauf.audio != null ? { AudioStreamIndex: lauf.audio } : {}),
|
|
||||||
...((lauf.subtitle ?? -1) >= 0 ? { SubtitleStreamIndex: lauf.subtitle! } : {}),
|
|
||||||
...(lauf.quality > 0 ? { MaxStreamingBitrate: lauf.quality * 1000 } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
diagnose.text = [
|
|
||||||
quelle.TranscodingUrl ? 'Server nannte eine Adresse' : 'Server nannte keine Adresse (Rückfall auf HLS)',
|
|
||||||
quelle.Container ? `Container ${quelle.Container}` : null,
|
|
||||||
].filter(Boolean).join(' · ');
|
|
||||||
diagnose.url = adresse;
|
|
||||||
|
|
||||||
v.addEventListener('loadedmetadata', () => {
|
|
||||||
v.playbackRate = P.rate;
|
|
||||||
// Zurück an die Stelle, an der umgeschaltet wurde — sonst begänne jeder
|
|
||||||
// Wechsel der Tonspur den Film von vorn.
|
|
||||||
if (lauf.stelle > 1) v.currentTime = lauf.stelle;
|
|
||||||
void v.play();
|
|
||||||
}, { once: true });
|
|
||||||
|
|
||||||
lauf.sitzung = fortschritt(v, {
|
|
||||||
id: auskunft.PlaySessionId,
|
|
||||||
quelleID: quelle.Id ?? item.Id,
|
|
||||||
itemID: item.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (v.canPlayType('application/vnd.apple.mpegurl')) {
|
|
||||||
v.src = adresse;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Erst hier geladen.** hls.js ist größer als der ganze Rest zusammen,
|
|
||||||
// und wer nur stöbert, braucht es nie. Safari schon gar nicht — dort
|
|
||||||
// sind wir oben schon heraus.
|
|
||||||
const { default: Hls } = await import('hls.js');
|
|
||||||
const h = new Hls();
|
|
||||||
hls = h;
|
|
||||||
h.on(Hls.Events.ERROR, (_e, daten) => {
|
|
||||||
if (!daten.fatal) return;
|
|
||||||
scheitern('Die Wiedergabe ist abgebrochen.',
|
|
||||||
`${daten.type}: ${daten.details}` +
|
|
||||||
(daten.response?.code ? ` (HTTP ${daten.response.code})` : ''));
|
|
||||||
h.destroy();
|
|
||||||
});
|
|
||||||
h.loadSource(adresse);
|
|
||||||
h.attachMedia(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
function spurenBekannt(): void {
|
|
||||||
el('pl-audio').hidden = spuren('Audio').length === 0;
|
|
||||||
el('pl-subs').hidden = spuren('Subtitle').length === 0;
|
|
||||||
// Gefülltes Symbol, solange Untertitel laufen — wie auf dem iPad.
|
|
||||||
el('pl-subs').innerHTML = icon((lauf.subtitle ?? -1) >= 0 ? 'captionsOn' : 'captions');
|
|
||||||
el('pl-qual').textContent =
|
|
||||||
(QUALITIES.find(([w]) => w === lauf.quality)?.[1] ?? 'Original').split(' — ').pop()!;
|
|
||||||
}
|
|
||||||
|
|
||||||
zeigen();
|
|
||||||
tonMalen();
|
|
||||||
ebene.classList.add('loading');
|
|
||||||
warten(true);
|
|
||||||
void strom();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Meldet dem Server, wo man steht.
|
|
||||||
----------------------------------------------------------------------------
|
|
||||||
Die Stelle wird **getrennt vom Element** gehalten. Beim Schließen fällt
|
|
||||||
`currentTime` auf null, und stünde die Meldung erst danach an, bekäme
|
|
||||||
Jellyfin „gestoppt bei 0:00" und löschte den Fortschritt.
|
|
||||||
|
|
||||||
Nicht ohne Hinsehen: ein verschlucktes `catch` hat lange verborgen, ob die
|
|
||||||
Meldungen überhaupt ankommen. */
|
|
||||||
function fortschritt(
|
|
||||||
video: HTMLVideoElement,
|
|
||||||
sitz: { id?: string; quelleID: string; itemID: string },
|
|
||||||
): Sitzungsmeldung {
|
|
||||||
const s: Sitzungsmeldung = {
|
|
||||||
...sitz,
|
|
||||||
stelle: 0,
|
|
||||||
melden: (weg, stelle, extra = {}) => {
|
|
||||||
const wo = stelle ?? s.stelle;
|
|
||||||
void api(weg, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
ItemId: s.itemID, PlaySessionId: s.id, MediaSourceId: s.quelleID,
|
|
||||||
PositionTicks: Math.round(wo * 10_000_000),
|
|
||||||
// Ohne die ordnet Jellyfin die Meldung keiner Wiedergabe zu.
|
|
||||||
PlayMethod: 'Transcode', CanSeek: true,
|
|
||||||
...extra,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.then(() => console.debug(`[Flaro] ${weg} → ${(wo / 60).toFixed(1)} min angenommen`))
|
|
||||||
.catch((e: Error) => console.warn(`[Flaro] ${weg} abgelehnt: ${e.message}`));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
video.addEventListener('playing', () => s.melden('/Sessions/Playing'), { once: true });
|
|
||||||
|
|
||||||
let zuletzt = 0;
|
|
||||||
video.addEventListener('timeupdate', () => {
|
|
||||||
if (video.currentTime > 0) s.stelle = video.currentTime;
|
|
||||||
if (video.currentTime - zuletzt < 10) return;
|
|
||||||
zuletzt = video.currentTime;
|
|
||||||
s.melden('/Sessions/Playing/Progress', s.stelle, { IsPaused: video.paused });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Anhalten sofort melden: wer pausiert und weggeht, hatte sonst bis zur
|
|
||||||
// nächsten Zehn-Sekunden-Marke nichts hinterlassen.
|
|
||||||
video.addEventListener('pause', () => s.melden('/Sessions/Playing/Progress', s.stelle, { IsPaused: true }));
|
|
||||||
video.addEventListener('play', () => s.melden('/Sessions/Playing/Progress', s.stelle, { IsPaused: false }));
|
|
||||||
video.addEventListener('ended', () => s.melden('/Sessions/Playing/Stopped', video.duration || s.stelle));
|
|
||||||
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { filme, serien } from '../api';
|
||||||
|
import { esc, leer, fehlerzeile } from '../util';
|
||||||
|
import { kachel, alsKachel } from '../card';
|
||||||
|
|
||||||
|
/** Suchergebnisse quer durch alle Mediatheken.
|
||||||
|
*
|
||||||
|
* **Nicht je Mediathek.** Wer einen Titel sucht, weiss oft gar nicht, in
|
||||||
|
* welcher er liegt — genau deshalb steht die Suche oben in der Kopfleiste
|
||||||
|
* und nicht in jeder Mediathek einzeln. */
|
||||||
|
export function suchseite(
|
||||||
|
text: string,
|
||||||
|
oeffnen: (art: 'filme' | 'serien', id: string) => void,
|
||||||
|
): HTMLElement {
|
||||||
|
const wurzel = document.createElement('div');
|
||||||
|
|
||||||
|
const kopf = document.createElement('div');
|
||||||
|
kopf.className = 'seitenkopf';
|
||||||
|
kopf.innerHTML = `<div class="wer"><h1>${esc(text)}</h1>`
|
||||||
|
+ '<div class="quelle">Suchergebnisse</div></div>';
|
||||||
|
wurzel.appendChild(kopf);
|
||||||
|
|
||||||
|
const buehne = document.createElement('div');
|
||||||
|
buehne.innerHTML = '<div class="spinner"></div>';
|
||||||
|
wurzel.appendChild(buehne);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const [f, s] = await Promise.all([
|
||||||
|
filme({ search: text, take: 100 }),
|
||||||
|
serien({ search: text, take: 100 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
buehne.innerHTML = '';
|
||||||
|
if (f.total === 0 && s.total === 0) {
|
||||||
|
buehne.appendChild(leer(`Nichts gefunden für „${text}".`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f.total > 0) buehne.appendChild(abschnitt(`Filme · ${f.total}`, f.items));
|
||||||
|
if (s.total > 0) buehne.appendChild(abschnitt(`Serien · ${s.total}`, s.items));
|
||||||
|
} catch (fehler) {
|
||||||
|
buehne.innerHTML = '';
|
||||||
|
buehne.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function abschnitt(titel: string, eintraege: Parameters<typeof alsKachel>[0][]): HTMLElement {
|
||||||
|
const bereich = document.createElement('section');
|
||||||
|
const h = document.createElement('h2');
|
||||||
|
h.textContent = titel;
|
||||||
|
const gitter = document.createElement('div');
|
||||||
|
gitter.className = 'raster';
|
||||||
|
eintraege.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen)));
|
||||||
|
bereich.append(h, gitter);
|
||||||
|
return bereich;
|
||||||
|
}
|
||||||
|
|
||||||
|
return wurzel;
|
||||||
|
}
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import { P, PALETTE, QUALITIES, RATES, SKIPS, LANGUAGES, setzen, anordnen } from '../prefs';
|
|
||||||
import { abmelden } from '../api';
|
|
||||||
import { icon } from '../icons';
|
|
||||||
import { esc } from '../util';
|
|
||||||
import { bekannteReihen } from './home';
|
|
||||||
import { lesbarMachen, alsHex, ausHex } from '../contrast';
|
|
||||||
|
|
||||||
/* Die Einstellungen.
|
|
||||||
----------------------------------------------------------------------------
|
|
||||||
Dieselben fünf Abschnitte in derselben Reihenfolge wie in der App:
|
|
||||||
Darstellung, Startseite, Wiedergabe, Sprachen, Server. Downloads und
|
|
||||||
Diagnose fehlen — dort fehlt die Funktion, nicht die Einstellung. */
|
|
||||||
export function einstellungen(wurzel: HTMLElement, neuZeichnen: () => void): void {
|
|
||||||
wurzel.classList.add('pad-top');
|
|
||||||
wurzel.innerHTML = `
|
|
||||||
<div class="settings">
|
|
||||||
<h2>Einstellungen</h2>
|
|
||||||
|
|
||||||
<h3 class="group">Darstellung</h3>
|
|
||||||
<div class="setting">
|
|
||||||
<h4>Akzentfarbe</h4>
|
|
||||||
<p>Dieselbe Palette wie in der App.</p>
|
|
||||||
<div class="swatches" id="set-accent"></div>
|
|
||||||
<div class="hexrow">
|
|
||||||
<input type="text" id="set-hex" placeholder="#C04AC9" maxlength="7" spellcheck="false">
|
|
||||||
<button class="btn" id="set-hex-apply">Übernehmen</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint" id="set-hex-note"></p>
|
|
||||||
</div>
|
|
||||||
<div class="setting">
|
|
||||||
<h4>Kachelgröße</h4>
|
|
||||||
<p>Wie groß Plakate und Vorschaubilder erscheinen — auf der Startseite
|
|
||||||
und in den Mediatheken. Gilt nur auf diesem Gerät.</p>
|
|
||||||
<div class="range-row"><input type="range" id="set-zoom" min="100" max="280" step="10"><output id="set-zoom-out"></output></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="group">Startseite</h3>
|
|
||||||
<div class="setting">
|
|
||||||
<h4>Aufmacher</h4>
|
|
||||||
<p>Woher die Titel oben kommen. Die Auswahl wechselt alle sechs Stunden;
|
|
||||||
Titel ohne Hintergrundbild ziehen dabei ein schlechteres Los.</p>
|
|
||||||
<div class="seg" id="set-herosource">
|
|
||||||
<button data-v="continueFirst">Angefangenes zuerst</button>
|
|
||||||
<button data-v="latestOnly">Nur Neuzugänge</button>
|
|
||||||
</div>
|
|
||||||
<div class="range-row"><input type="range" id="set-herocount" min="1" max="10" step="1"><output id="set-herocount-out"></output></div>
|
|
||||||
<div class="range-row"><input type="range" id="set-heroheight" min="40" max="95" step="5"><output id="set-heroheight-out"></output></div>
|
|
||||||
</div>
|
|
||||||
<div class="setting">
|
|
||||||
<h4>Reihen</h4>
|
|
||||||
<label class="check"><input type="checkbox" id="set-combine"><span>„Weiterschauen“ und „Als Nächstes“ zusammenlegen</span></label>
|
|
||||||
<label class="check"><input type="checkbox" id="set-hidewatched"><span>Gesehenes aus den Neuzugängen ausblenden</span></label>
|
|
||||||
</div>
|
|
||||||
<div class="setting">
|
|
||||||
<h4>Reihen der Startseite</h4>
|
|
||||||
<p>Welche Reihen erscheinen und in welcher Folge. Was der Server neu
|
|
||||||
anlegt, kommt ans Ende, statt zu fehlen.</p>
|
|
||||||
<div id="set-rows"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="group">Wiedergabe</h3>
|
|
||||||
<div class="setting">
|
|
||||||
<h4>Geschwindigkeit</h4>
|
|
||||||
<p>Womit ein Titel beginnt. Im Player lässt sie sich jederzeit ändern.</p>
|
|
||||||
<select id="set-rate" class="pick"></select>
|
|
||||||
</div>
|
|
||||||
<div class="setting">
|
|
||||||
<h4>Spulweite</h4>
|
|
||||||
<div class="pickrow">
|
|
||||||
<label>Zurück <select id="set-skipback" class="pick"></select></label>
|
|
||||||
<label>Vor <select id="set-skipfwd" class="pick"></select></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting">
|
|
||||||
<h4>Qualität</h4>
|
|
||||||
<p>„Original“ schickt die Datei unverändert, alles andere lässt den
|
|
||||||
Server umrechnen. Die App unterscheidet WLAN und Mobilfunk — im
|
|
||||||
Browser lässt sich das nicht verlässlich feststellen, deshalb eine
|
|
||||||
Stufe.</p>
|
|
||||||
<select id="set-quality" class="pick"></select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="group">Sprachen</h3>
|
|
||||||
<div class="setting">
|
|
||||||
<h4>Bevorzugte Sprachen</h4>
|
|
||||||
<p>Welche Spur beim Start gewählt wird, sofern der Titel sie mitbringt.</p>
|
|
||||||
<div class="pickrow">
|
|
||||||
<label>Ton <select id="set-audio" class="pick"></select></label>
|
|
||||||
<label>Untertitel <select id="set-subs" class="pick"></select></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="group">Server</h3>
|
|
||||||
<div class="setting">
|
|
||||||
<h4>Verbindung</h4>
|
|
||||||
<p>Die Adresse steht beim Server, der diese Seite ausliefert — nicht im
|
|
||||||
Browser.</p>
|
|
||||||
<button class="btn" id="set-signout">Abmelden</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
/* ---- Akzentfarbe ---- */
|
|
||||||
const felder = wurzel.querySelector('#set-accent')!;
|
|
||||||
for (const [name, hex] of PALETTE) {
|
|
||||||
const b = document.createElement('button');
|
|
||||||
b.className = 'swatch' + (hex.toLowerCase() === P.accent.toLowerCase() ? ' on' : '');
|
|
||||||
b.style.background = hex;
|
|
||||||
b.title = name;
|
|
||||||
b.dataset.hex = hex;
|
|
||||||
b.onclick = () => { setzen('accent', hex); einstellungen(wurzel, neuZeichnen); };
|
|
||||||
felder.appendChild(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hex = wurzel.querySelector<HTMLInputElement>('#set-hex')!;
|
|
||||||
const notiz = wurzel.querySelector<HTMLElement>('#set-hex-note')!;
|
|
||||||
hex.value = P.accent;
|
|
||||||
const uebernehmen = (): void => {
|
|
||||||
const rgb = ausHex(hex.value);
|
|
||||||
if (!rgb) { notiz.textContent = 'Das ist kein Farbwert — erwartet wird etwa #C04AC9.'; return; }
|
|
||||||
const { farbe, geaendert } = lesbarMachen(rgb, ausHex('#0B0B0D')!);
|
|
||||||
const wert = alsHex(farbe);
|
|
||||||
setzen('accent', wert);
|
|
||||||
hex.value = wert;
|
|
||||||
notiz.textContent = geaendert
|
|
||||||
? `Auf ${wert} aufgehellt — die gewählte Farbe wäre auf dem dunklen Grund nicht lesbar gewesen.`
|
|
||||||
: '';
|
|
||||||
felder.querySelectorAll<HTMLElement>('.swatch')
|
|
||||||
.forEach((b) => b.classList.toggle('on', b.dataset.hex === wert));
|
|
||||||
};
|
|
||||||
wurzel.querySelector<HTMLElement>('#set-hex-apply')!.onclick = uebernehmen;
|
|
||||||
hex.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); uebernehmen(); } };
|
|
||||||
|
|
||||||
/* ---- Die wiederkehrenden Bauteile ---- */
|
|
||||||
const regler = (id: string, feld: 'cardW' | 'heroCount' | 'heroHeight', einheit: string): void => {
|
|
||||||
const r = wurzel.querySelector<HTMLInputElement>('#' + id)!;
|
|
||||||
const o = wurzel.querySelector<HTMLOutputElement>('#' + id + '-out')!;
|
|
||||||
r.value = String(P[feld]);
|
|
||||||
o.textContent = P[feld] + einheit;
|
|
||||||
r.oninput = () => { o.textContent = r.value + einheit; setzen(feld, Number(r.value)); };
|
|
||||||
};
|
|
||||||
regler('set-zoom', 'cardW', ' px');
|
|
||||||
regler('set-herocount', 'heroCount', ' Titel');
|
|
||||||
regler('set-heroheight', 'heroHeight', ' % Höhe');
|
|
||||||
|
|
||||||
const schalter = wurzel.querySelector('#set-herosource')!;
|
|
||||||
[...schalter.children].forEach((b) => {
|
|
||||||
const el = b as HTMLElement;
|
|
||||||
el.classList.toggle('on', el.dataset.v === P.heroSource);
|
|
||||||
el.onclick = () => {
|
|
||||||
setzen('heroSource', el.dataset.v as typeof P.heroSource);
|
|
||||||
einstellungen(wurzel, neuZeichnen);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const haken = (id: string, feld: 'combineContinue' | 'hideWatched'): void => {
|
|
||||||
const c = wurzel.querySelector<HTMLInputElement>('#' + id)!;
|
|
||||||
c.checked = P[feld];
|
|
||||||
c.onchange = () => setzen(feld, c.checked);
|
|
||||||
};
|
|
||||||
haken('set-combine', 'combineContinue');
|
|
||||||
haken('set-hidewatched', 'hideWatched');
|
|
||||||
|
|
||||||
const auswahl = (id: string, feld: keyof typeof P, werte: readonly (readonly [string | number, string])[]): void => {
|
|
||||||
const s = wurzel.querySelector<HTMLSelectElement>('#' + id)!;
|
|
||||||
s.innerHTML = '';
|
|
||||||
for (const [wert, text] of werte) {
|
|
||||||
const o = document.createElement('option');
|
|
||||||
o.value = String(wert);
|
|
||||||
o.textContent = text;
|
|
||||||
o.selected = String(wert) === String(P[feld]);
|
|
||||||
s.appendChild(o);
|
|
||||||
}
|
|
||||||
s.onchange = () => {
|
|
||||||
const roh = typeof P[feld] === 'number' ? Number(s.value) : s.value;
|
|
||||||
setzen(feld as never, roh as never);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
auswahl('set-rate', 'rate', RATES.map((r) => [r, r === 1 ? 'Normal' : r + '×'] as const));
|
|
||||||
auswahl('set-skipback', 'skipBackward', SKIPS.map((n) => [n, n + ' s'] as const));
|
|
||||||
auswahl('set-skipfwd', 'skipForward', SKIPS.map((n) => [n, n + ' s'] as const));
|
|
||||||
auswahl('set-quality', 'quality', QUALITIES);
|
|
||||||
auswahl('set-audio', 'audioLanguage', LANGUAGES);
|
|
||||||
auswahl('set-subs', 'subtitleLanguage', LANGUAGES);
|
|
||||||
|
|
||||||
wurzel.querySelector<HTMLElement>('#set-signout')!.onclick = abmelden;
|
|
||||||
reihenListe(wurzel, neuZeichnen);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Welche Reihen erscheinen und in welcher Folge.
|
|
||||||
----------------------------------------------------------------------------
|
|
||||||
Knöpfe statt Ziehen: auf dem Zeigegerät ist Ziehen schöner, auf einem
|
|
||||||
Trackpad fummelig und mit der Tastatur gar nicht zu machen. Zwei Pfeile
|
|
||||||
können alle drei. */
|
|
||||||
function reihenListe(wurzel: HTMLElement, neuZeichnen: () => void): void {
|
|
||||||
const ort = wurzel.querySelector('#set-rows');
|
|
||||||
if (!ort) return;
|
|
||||||
|
|
||||||
if (!bekannteReihen.length) {
|
|
||||||
ort.innerHTML = '<p class="hint">Erst sichtbar, wenn die Startseite einmal geladen war.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ausgeblendete hinten dran, damit man sie wiederfindet.
|
|
||||||
const geordnet = [
|
|
||||||
...anordnen(bekannteReihen),
|
|
||||||
...bekannteReihen.filter((r) => P.hiddenRows.includes(r.id)),
|
|
||||||
];
|
|
||||||
|
|
||||||
ort.innerHTML = '';
|
|
||||||
geordnet.forEach((reihe, stelle) => {
|
|
||||||
const aus = P.hiddenRows.includes(reihe.id);
|
|
||||||
const zeile = document.createElement('div');
|
|
||||||
zeile.className = 'rowline' + (aus ? ' off' : '');
|
|
||||||
zeile.innerHTML = `
|
|
||||||
<label class="check" style="flex:1;padding:0">
|
|
||||||
<input type="checkbox" ${aus ? '' : 'checked'}>
|
|
||||||
<span>${esc(reihe.title)}</span>
|
|
||||||
</label>
|
|
||||||
<button class="tiny" aria-label="Nach oben" ${stelle === 0 ? 'disabled' : ''}>${icon('up', 16)}</button>
|
|
||||||
<button class="tiny" aria-label="Nach unten" ${stelle === geordnet.length - 1 ? 'disabled' : ''}>${icon('down', 16)}</button>`;
|
|
||||||
|
|
||||||
zeile.querySelector('input')!.onchange = (e) => {
|
|
||||||
const an = (e.target as HTMLInputElement).checked;
|
|
||||||
setzen('hiddenRows', an
|
|
||||||
? P.hiddenRows.filter((id) => id !== reihe.id)
|
|
||||||
: [...P.hiddenRows, reihe.id]);
|
|
||||||
reihenListe(wurzel, neuZeichnen);
|
|
||||||
neuZeichnen();
|
|
||||||
};
|
|
||||||
|
|
||||||
const schieben = (um: number): void => {
|
|
||||||
// Die aktuelle Folge festschreiben, bevor darin verschoben wird.
|
|
||||||
const ids = geordnet.map((r) => r.id);
|
|
||||||
const nach = stelle + um;
|
|
||||||
if (nach < 0 || nach >= ids.length) return;
|
|
||||||
[ids[stelle], ids[nach]] = [ids[nach]!, ids[stelle]!];
|
|
||||||
setzen('rowOrder', ids);
|
|
||||||
reihenListe(wurzel, neuZeichnen);
|
|
||||||
neuZeichnen();
|
|
||||||
};
|
|
||||||
const [hoch, runter] = zeile.querySelectorAll('button');
|
|
||||||
(hoch as HTMLElement).onclick = () => schieben(-1);
|
|
||||||
(runter as HTMLElement).onclick = () => schieben(1);
|
|
||||||
ort.appendChild(zeile);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { einrichten, anmelden } from '../api';
|
||||||
|
import { knopf, feld, fehlerzeile } from '../util';
|
||||||
|
|
||||||
|
/** Der erste Start: ein Administrator muss angelegt werden.
|
||||||
|
*
|
||||||
|
* **Die einzige Stelle, an der jemand ohne Anmeldung etwas ändern darf** —
|
||||||
|
* und sie verschliesst sich selbst, sobald ein Benutzer existiert. */
|
||||||
|
export function einrichtung(fertig: () => void): HTMLElement {
|
||||||
|
const karte = document.createElement('div');
|
||||||
|
karte.className = 'login-card';
|
||||||
|
|
||||||
|
const titel = document.createElement('h1');
|
||||||
|
titel.textContent = 'Flaro einrichten';
|
||||||
|
const text = document.createElement('p');
|
||||||
|
text.className = 'hinweis';
|
||||||
|
text.textContent = 'Lege das erste Konto an. Es wird Administrator und kann '
|
||||||
|
+ 'später Bibliotheken und weitere Benutzer verwalten.';
|
||||||
|
|
||||||
|
const name = feld('Benutzername');
|
||||||
|
name.autocomplete = 'username';
|
||||||
|
const passwort = feld('Passwort, mindestens 8 Zeichen', 'password');
|
||||||
|
passwort.autocomplete = 'new-password';
|
||||||
|
const los = knopf('Anlegen und anmelden', 'btn btn-primary');
|
||||||
|
|
||||||
|
karte.append(titel, text, name, passwort, los);
|
||||||
|
|
||||||
|
async function absenden(): Promise<void> {
|
||||||
|
karte.querySelector('.error')?.remove();
|
||||||
|
los.disabled = true;
|
||||||
|
try {
|
||||||
|
await einrichten(name.value.trim(), passwort.value);
|
||||||
|
await anmelden(name.value.trim(), passwort.value);
|
||||||
|
fertig();
|
||||||
|
} catch (fehler) {
|
||||||
|
karte.appendChild(fehlerzeile((fehler as Error).message));
|
||||||
|
los.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
los.onclick = () => void absenden();
|
||||||
|
passwort.onkeydown = e => { if (e.key === 'Enter') void absenden(); };
|
||||||
|
setTimeout(() => name.focus(), 50);
|
||||||
|
return karte;
|
||||||
|
}
|
||||||
@@ -6,11 +6,11 @@ export default defineConfig({
|
|||||||
// Im Entwicklungsbetrieb übernimmt Vite, was sonst der eigene Server tut:
|
// Im Entwicklungsbetrieb übernimmt Vite, was sonst der eigene Server tut:
|
||||||
// Anfragen an /api gehen an den Medienserver weiter. Damit ist die
|
// Anfragen an /api gehen an den Medienserver weiter. Damit ist die
|
||||||
// Herkunft dieselbe wie die der Seite — und CORS nie wieder ein Thema.
|
// Herkunft dieselbe wie die der Seite — und CORS nie wieder ein Thema.
|
||||||
|
// Der Pfad bleibt unverändert: Flaro bedient /api/v1 selbst.
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.FLARO_SERVER ?? 'http://localhost:8096',
|
target: process.env.FLARO_SERVER ?? 'http://localhost:18080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (pfad) => pfad.replace(/^\/api/, ''),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user