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 FlaroDownloads
|
||||
import FlaroPlayback
|
||||
import FlaroServerKit
|
||||
import JellyfinKit
|
||||
import MediaKit
|
||||
|
||||
@@ -45,6 +46,7 @@ struct FlaroApp: App {
|
||||
client: Branding.appName,
|
||||
version: Branding.version
|
||||
),
|
||||
flaroIdentity: FlaroIdentity.current(),
|
||||
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": {
|
||||
"extractionState": "stale",
|
||||
"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": {
|
||||
"extractionState": "stale",
|
||||
"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": {
|
||||
"localizations": {
|
||||
"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": {
|
||||
"localizations": {
|
||||
"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": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -6601,6 +6873,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"signIn.password": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Passwort"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Password"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"signIn.privacy": {
|
||||
"localizations": {
|
||||
"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": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
|
||||
@@ -122,7 +122,8 @@ struct DownloadService {
|
||||
// hinterher niemanden mehr, der das nachholen könnte — und seit es
|
||||
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
|
||||
// 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?
|
||||
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
|
||||
/// werden muss.
|
||||
///
|
||||
@@ -211,6 +223,12 @@ struct DownloadService {
|
||||
/// falsche Weg.
|
||||
func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool {
|
||||
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
|
||||
? item.versions[mediaIndex] : item.preferredVersion,
|
||||
let part = media.files.first
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import FlaroServerKit
|
||||
import JellyfinKit
|
||||
import MediaKit
|
||||
import Observation
|
||||
@@ -31,12 +32,41 @@ final class FlaroSession {
|
||||
// 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 {
|
||||
/// 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 jellyfinUserID = "flaro.jellyfin.userID"
|
||||
static let jellyfinUserName = "flaro.jellyfin.userName"
|
||||
static let jellyfinServerName = "flaro.jellyfin.serverName"
|
||||
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.
|
||||
@@ -45,6 +75,7 @@ final class FlaroSession {
|
||||
/// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
|
||||
/// später daneben passt, ohne diesen zu verdrängen.
|
||||
private static let jellyfinTokenAccount = "jellyfin.server"
|
||||
private static let flaroTokenAccount = "flaro.server"
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
var accountName: String? { jellyfinAccount?.userName }
|
||||
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
|
||||
|
||||
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
|
||||
/// erscheinen gar nicht erst.
|
||||
@@ -101,7 +138,12 @@ final class FlaroSession {
|
||||
var serverName: String { backend?.serverName ?? "" }
|
||||
|
||||
/// 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.
|
||||
///
|
||||
@@ -136,30 +178,28 @@ final class FlaroSession {
|
||||
|
||||
/// Warum gerade nicht abgespielt werden kann.
|
||||
///
|
||||
/// Zwei verschiedene Lagen, und sie dürfen nicht verwechselt werden: ist
|
||||
/// ein Server da, ist die Anmeldung fertig und nur die Wiedergabe noch
|
||||
/// nicht — „nicht erreichbar" wäre dann gelogen. Ist **keiner** da, ist
|
||||
/// niemand angemeldet, und „Wiedergabe steht aus" wäre es.
|
||||
///
|
||||
/// 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.
|
||||
/// **Nur noch ein Fall, und das ist eine Bereinigung.** Hier stand eine
|
||||
/// Fallunterscheidung aus der Plex-Zeit: war Jellyfin aktiv, hiess es
|
||||
/// „die Wiedergabe von Jellyfin ist noch nicht fertig". Sie ist längst
|
||||
/// fertig — und der Satz war ohnehin unerreichbar, denn der Player zeigt
|
||||
/// diese Meldung ausschliesslich dann, wenn gar kein Server da ist.
|
||||
var playbackUnavailableMessage: String {
|
||||
backend != nil
|
||||
? String(localized: "player.jellyfinPending")
|
||||
: String(localized: "error.serverUnreachable")
|
||||
String(localized: "error.serverUnreachable")
|
||||
}
|
||||
|
||||
private let jellyfinIdentity: JellyfinIdentity
|
||||
private let flaroIdentity: FlaroIdentity
|
||||
private let tokenStore: any TokenStoring
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(
|
||||
jellyfinIdentity: JellyfinIdentity,
|
||||
flaroIdentity: FlaroIdentity,
|
||||
tokenStore: any TokenStoring,
|
||||
defaults: UserDefaults = .standard
|
||||
) {
|
||||
self.jellyfinIdentity = jellyfinIdentity
|
||||
self.flaroIdentity = flaroIdentity
|
||||
self.tokenStore = tokenStore
|
||||
self.defaults = defaults
|
||||
}
|
||||
@@ -174,7 +214,13 @@ final class FlaroSession {
|
||||
if await adoptStubServerIfRequested() { return }
|
||||
#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
|
||||
}
|
||||
|
||||
@@ -212,7 +258,16 @@ final class FlaroSession {
|
||||
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
|
||||
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
|
||||
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()
|
||||
forgetFlaro()
|
||||
defaults.removeObject(forKey: Key.activeKind)
|
||||
clearActiveServer()
|
||||
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
|
||||
// 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)
|
||||
clearActiveServer()
|
||||
jellyfinAccount = account
|
||||
activeKind = .jellyfin
|
||||
self.backend = backend
|
||||
isLocalConnection = await backend.isInLocalNetwork()
|
||||
|
||||
do {
|
||||
libraries = try await loadLibraries(from: backend)
|
||||
} catch {
|
||||
self.backend = nil
|
||||
jellyfinAccount = nil
|
||||
clearActiveServer()
|
||||
phase = .failed(message: ErrorText.describe(error))
|
||||
return
|
||||
}
|
||||
@@ -320,6 +375,7 @@ final class FlaroSession {
|
||||
|
||||
clearActiveServer()
|
||||
jellyfinAccount = account
|
||||
activeKind = .jellyfin
|
||||
self.backend = backend
|
||||
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
|
||||
// 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) {
|
||||
defaults.set(ServerKind.jellyfin.rawValue, forKey: Key.activeKind)
|
||||
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
|
||||
defaults.set(account.userID, forKey: Key.jellyfinUserID)
|
||||
defaults.set(account.userName, forKey: Key.jellyfinUserName)
|
||||
@@ -385,12 +533,18 @@ final class FlaroSession {
|
||||
private func clearActiveServer() {
|
||||
backend = nil
|
||||
jellyfinAccount = nil
|
||||
flaroAccount = nil
|
||||
flaroClient = nil
|
||||
activeKind = nil
|
||||
libraries = []
|
||||
}
|
||||
|
||||
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -400,4 +554,13 @@ final class FlaroSession {
|
||||
/// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App
|
||||
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
|
||||
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) {
|
||||
foundServerHeader(model: model)
|
||||
|
||||
TextField("signIn.jellyfin.userName", text: $model.userName)
|
||||
TextField("signIn.userName", text: $model.userName)
|
||||
.textContentType(.username)
|
||||
.flaroPlainEntry()
|
||||
.submitLabel(.next)
|
||||
@@ -203,7 +203,7 @@ struct JellyfinSignInView: View {
|
||||
.padding(FlaroSpacing.regular)
|
||||
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
||||
|
||||
SecureField("signIn.jellyfin.password", text: $model.password)
|
||||
SecureField("signIn.password", text: $model.password)
|
||||
.textContentType(.password)
|
||||
.submitLabel(.go)
|
||||
.focused($focusedField, equals: .password)
|
||||
|
||||
@@ -3,16 +3,23 @@ import FlaroDesign
|
||||
|
||||
/// Der Anmeldebildschirm.
|
||||
///
|
||||
/// **Ein Weg, seit es einen Server gibt.** Hier standen zwei Knöpfe
|
||||
/// nebeneinander, und der obere war der auffälligere — er führte zu Plex.
|
||||
/// Bleibt einer übrig, erbt er die Hervorhebung: ein Knopf, der aussieht wie
|
||||
/// die zweite Wahl, lässt den Benutzer nach der ersten suchen.
|
||||
/// **Wieder zwei Knöpfe, und diesmal zu zwei Servern.** Hier standen schon
|
||||
/// einmal zwei — Plex oben, Jellyfin darunter —, und nach dem Entfernen von
|
||||
/// Plex blieb einer übrig. Jetzt gibt es Flaro, und die Frage stellt sich
|
||||
/// neu: welcher steht oben?
|
||||
///
|
||||
/// Jellyfin hat kein Verzeichnis, an dem die eigenen Server hängen. Deshalb
|
||||
/// wird die Adresse eingetippt und, wann immer der Server es anbietet, ein
|
||||
/// Code bestätigt statt eines Passworts — das führt `JellyfinSignInView`.
|
||||
/// **Flaro.** Nicht aus Eitelkeit, sondern weil die App dort mehr kann: der
|
||||
/// Download bringt alle Tonspuren mit, der Fortschritt wird ab der ersten
|
||||
/// 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 {
|
||||
@Environment(\.flaroAccent) private var accent
|
||||
@State private var showsFlaroSignIn = false
|
||||
@State private var showsJellyfinSignIn = false
|
||||
|
||||
var body: some View {
|
||||
@@ -37,6 +44,17 @@ struct SignInView: View {
|
||||
Spacer()
|
||||
|
||||
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 {
|
||||
showsJellyfinSignIn = true
|
||||
} label: {
|
||||
@@ -45,8 +63,7 @@ struct SignInView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, FlaroSpacing.regular)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Text("signIn.privacy")
|
||||
.font(.flaroCaption)
|
||||
@@ -60,6 +77,9 @@ struct SignInView: View {
|
||||
.frame(maxWidth: 420)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.flaroCanvas)
|
||||
.sheet(isPresented: $showsFlaroSignIn) {
|
||||
FlaroSignInView()
|
||||
}
|
||||
.sheet(isPresented: $showsJellyfinSignIn) {
|
||||
JellyfinSignInView()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import SwiftUI
|
||||
import FlaroDesign
|
||||
import FlaroPlayback
|
||||
import MediaKit
|
||||
import FlaroServerKit
|
||||
import JellyfinKit
|
||||
|
||||
/// Die App auf dem Fernseher.
|
||||
@@ -45,6 +46,7 @@ struct FlaroTVApp: App {
|
||||
client: Branding.appName,
|
||||
version: Branding.version
|
||||
),
|
||||
flaroIdentity: FlaroIdentity.current(),
|
||||
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 FlaroDesign
|
||||
import FlaroServerKit
|
||||
import JellyfinKit
|
||||
|
||||
/// Die Anmeldung am Fernseher.
|
||||
///
|
||||
/// **Dieselben vier Schritte wie am Telefon, andere Reihenfolge.** Das Modell
|
||||
/// `JellyfinSignInModel` ist unverändert dasselbe — nur bekommt es hier
|
||||
/// `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.
|
||||
/// **Erst die Frage, welcher Server.** Seit es zwei gibt, steht sie vorn —
|
||||
/// und Flaro oben, weil die App dort mehr kann.
|
||||
///
|
||||
/// Der Weg über Benutzername und Passwort bleibt erreichbar — für Server, auf
|
||||
/// denen Quick Connect abgeschaltet ist.
|
||||
/// Danach dieselben Schritte wie am Telefon, nur andere Reihenfolge: beide
|
||||
/// 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 {
|
||||
@Environment(FlaroSession.self) private var session
|
||||
|
||||
@State private var art: FlaroSession.ServerKind?
|
||||
@State private var model: JellyfinSignInModel?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.flaroCanvas.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
switch art {
|
||||
case nil:
|
||||
auswahl()
|
||||
case .flaro:
|
||||
TVFlaroSignInView(zurueck: { art = nil })
|
||||
case .jellyfin:
|
||||
if let model {
|
||||
inhalt(for: model)
|
||||
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
|
||||
// liest sich aus drei Metern nicht mehr als Zeile.
|
||||
.frame(maxWidth: 900)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
}
|
||||
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
|
||||
// liest sich aus drei Metern nicht mehr als Zeile.
|
||||
.frame(maxWidth: 900)
|
||||
}
|
||||
.onChange(of: art) { _, neu in
|
||||
guard neu == .jellyfin, model == nil else { return }
|
||||
// Erst hier, nicht im Eigenschaftswert: das Modell braucht die
|
||||
// Gerätekennung der Sitzung. Baut es sich eine eigene, steht die
|
||||
// App nach dem Anmelden ein zweites Mal in der Geräteliste.
|
||||
guard model == nil else { return }
|
||||
let frisch = JellyfinSignInModel(
|
||||
identity: session.jellyfinDeviceIdentity,
|
||||
preferring: .quickConnect
|
||||
@@ -51,6 +62,19 @@ struct TVSignInView: View {
|
||||
.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
|
||||
private func inhalt(for model: JellyfinSignInModel) -> some View {
|
||||
@Bindable var model = model
|
||||
@@ -129,9 +153,9 @@ struct TVSignInView: View {
|
||||
if let name = model.serverName {
|
||||
Text(name).font(.flaroRowHeader)
|
||||
}
|
||||
TextField("signIn.jellyfin.userName", text: $model.userName)
|
||||
TextField("signIn.userName", text: $model.userName)
|
||||
.flaroPlainEntry()
|
||||
SecureField("signIn.jellyfin.password", text: $model.password)
|
||||
SecureField("signIn.password", text: $model.password)
|
||||
Text("signIn.jellyfin.passwordNote")
|
||||
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ let package = Package(
|
||||
products: [
|
||||
.library(name: "MediaKit", targets: ["MediaKit"]),
|
||||
.library(name: "JellyfinKit", targets: ["JellyfinKit"]),
|
||||
.library(name: "FlaroServerKit", targets: ["FlaroServerKit"]),
|
||||
.library(name: "FlaroDesign", targets: ["FlaroDesign"]),
|
||||
.library(name: "FlaroPlayback", targets: ["FlaroPlayback"]),
|
||||
.library(name: "FlaroDownloads", targets: ["FlaroDownloads"]),
|
||||
@@ -39,6 +40,12 @@ let package = Package(
|
||||
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,
|
||||
// ohne Gerät, ohne Upload. Eine Runde dauert damit Sekunden statt
|
||||
// eines halben Tages, und das Upload-Kontingent bleibt für Builds.
|
||||
|
||||
@@ -125,11 +125,24 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
||||
|
||||
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: [
|
||||
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
|
||||
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
|
||||
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
|
||||
AVURLAssetPreferPreciseDurationAndTimingKey: false,
|
||||
AVURLAssetHTTPCookiesKey: kekse,
|
||||
])
|
||||
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 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.
|
||||
/// 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 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.
|
||||
/// Der Inhalt steht in `ServerAddress` — beide Server haben dasselbe Problem,
|
||||
/// und es unterscheidet sie nur der Standardport. Dieser Typ bleibt stehen,
|
||||
/// weil Aufrufer und Prüfungen ihn beim Namen nennen und weil „welcher Port
|
||||
/// gilt hier" eine Auskunft über Jellyfin ist, nicht über Adressen.
|
||||
public enum JellyfinAddress {
|
||||
/// Der Standardport eines Jellyfin-Servers.
|
||||
public static let defaultPort = 8096
|
||||
|
||||
public static func candidates(for input: String) -> [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 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...])"
|
||||
ServerAddress.candidates(for: input, defaultPort: defaultPort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,18 @@ public enum BackendCapability: String, Sendable, Hashable {
|
||||
/// Die Verwaltung des Servers aus der App heraus.
|
||||
case administration
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
- package: FlaroKit
|
||||
product: JellyfinKit
|
||||
- package: FlaroKit
|
||||
product: FlaroServerKit
|
||||
- package: FlaroKit
|
||||
product: FlaroDesign
|
||||
- package: FlaroKit
|
||||
@@ -188,6 +190,8 @@ targets:
|
||||
product: MediaKit
|
||||
- package: FlaroKit
|
||||
product: JellyfinKit
|
||||
- package: FlaroKit
|
||||
product: FlaroServerKit
|
||||
- package: FlaroKit
|
||||
product: FlaroDesign
|
||||
- package: FlaroKit
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/*.user
|
||||
data/
|
||||
cache/
|
||||
transcode/
|
||||
+15
-4
@@ -1,5 +1,15 @@
|
||||
# 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
|
||||
# Ziels — `dotnet publish -a` kann das. Ohne diese Trennung liefe der Bau für
|
||||
# arm64 unter Emulation und dauerte ein Vielfaches.
|
||||
@@ -13,11 +23,11 @@ WORKDIR /src
|
||||
# `--parents` behält die Verzeichnisse bei. **Ein Muster statt einer Liste**,
|
||||
# weil eine Liste bei jedem neuen Projekt vergessen wird — und dann scheitert
|
||||
# erst der Bau im Container, nicht der auf dem Arbeitsrechner.
|
||||
COPY --parents src/*/*.csproj ./
|
||||
RUN dotnet restore src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH
|
||||
COPY --parents server/src/*/*.csproj ./
|
||||
RUN dotnet restore server/src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH
|
||||
|
||||
COPY . .
|
||||
RUN dotnet publish src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH -c Release -o /app --no-restore
|
||||
COPY server/ ./server/
|
||||
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
|
||||
@@ -65,6 +75,7 @@ ENV FLARO_DATA=/config \
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
COPY --from=web /web/dist ./wwwroot
|
||||
|
||||
EXPOSE 8080
|
||||
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
|
||||
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
|
||||
|
||||
```bash
|
||||
dotnet test # 293 Tests
|
||||
dotnet test # 313 Tests
|
||||
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.
|
||||
|
||||
```bash
|
||||
# Image bauen, beide Architekturen
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t flaro:dev .
|
||||
# Image bauen, beide Architekturen.
|
||||
# **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
|
||||
dotnet ef migrations add <Name> --project src/Flaro.Data --startup-project src/Flaro.Api --output-dir Migrations
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
services:
|
||||
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
|
||||
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 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();
|
||||
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();
|
||||
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 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.Name, benutzer.UserName),
|
||||
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) } : [],
|
||||
], SchemeName);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ public static class AuthEndpoints
|
||||
public static void MapAuth(this IEndpointRouteBuilder app)
|
||||
{
|
||||
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 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);
|
||||
await db.SaveChangesAsync();
|
||||
CookieSetzen(zusammenhang, antwort.Token, anfrage.StaySignedIn);
|
||||
return Results.Ok(antwort);
|
||||
}).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))
|
||||
return Results.Unauthorized();
|
||||
return Results.NoContent();
|
||||
|
||||
var eintrag = await db.DeviceTokens.FindAsync(id);
|
||||
if (eintrag is not null)
|
||||
@@ -60,7 +75,7 @@ public static class AuthEndpoints
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Results.NoContent();
|
||||
}).RequireAuthorization();
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) =>
|
||||
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));
|
||||
}
|
||||
|
||||
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>
|
||||
private static IResult Abgelehnt() =>
|
||||
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.
|
||||
|
||||
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 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);
|
||||
|
||||
@@ -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 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);
|
||||
|
||||
@@ -75,16 +100,51 @@ public sealed record MediaFileResponse(
|
||||
public sealed record MovieResponse(
|
||||
Guid Id, string Title, int? Year, string? Overview,
|
||||
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(
|
||||
Guid Id, string Title, int? Year, string? Overview,
|
||||
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(
|
||||
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);
|
||||
|
||||
@@ -132,3 +192,61 @@ public sealed record ApplyMatchRequest(int TmdbId);
|
||||
/// <summary>Wie die Zuordnung jetzt aussieht.</summary>
|
||||
public sealed record MatchState(
|
||||
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));
|
||||
}).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) =>
|
||||
{
|
||||
var gefunden = await Laden(db, id);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using Flaro.Core.Playback;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -14,6 +16,24 @@ public static class LibraryContentEndpoints
|
||||
/// </remarks>
|
||||
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)
|
||||
{
|
||||
MapScan(app);
|
||||
@@ -45,6 +65,66 @@ public static class LibraryContentEndpoints
|
||||
ILookup<Guid, MediaStreamResponse> spuren) =>
|
||||
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)
|
||||
{
|
||||
app.MapPost("/api/v1/libraries/{id:guid}/scan", async (
|
||||
@@ -59,6 +139,26 @@ public static class LibraryContentEndpoints
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}).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) =>
|
||||
{
|
||||
var zustand = koordinator.State(id);
|
||||
@@ -82,18 +182,38 @@ public static class LibraryContentEndpoints
|
||||
private static void MapBrowse(IEndpointRouteBuilder app)
|
||||
{
|
||||
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();
|
||||
if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
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 kennungen = await abfrage
|
||||
.OrderBy(m => m.SortTitleValue).ThenBy(m => m.Year)
|
||||
.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();
|
||||
|
||||
// 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 })
|
||||
.ToListAsync();
|
||||
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(
|
||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||
dateien.Where(f => f.MovieId == m.Id)
|
||||
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList())).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));
|
||||
}).RequireAuthorization();
|
||||
|
||||
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();
|
||||
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
abfrage = abfrage.Where(s => EF.Functions.Like(s.Title, $"%{search.Trim()}%"));
|
||||
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 serien = await abfrage
|
||||
.OrderBy(s => s.SortTitleValue)
|
||||
.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();
|
||||
|
||||
var ids = serien.Select(s => s.Id).ToList();
|
||||
var folgen = await db.Episodes.AsNoTracking()
|
||||
.Where(f => ids.Contains(f.SeriesId))
|
||||
.Select(f => new { f.SeriesId, f.SeasonNumber })
|
||||
.Select(f => new { f.Id, f.SeriesId, f.SeasonNumber })
|
||||
.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 meine = folgen.Where(f => f.SeriesId == s.Id).ToList();
|
||||
return new SeriesResponse(
|
||||
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();
|
||||
|
||||
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
||||
}).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();
|
||||
|
||||
@@ -164,11 +424,13 @@ public static class LibraryContentEndpoints
|
||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId })
|
||||
.ToListAsync();
|
||||
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(
|
||||
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage,
|
||||
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));
|
||||
}).RequireAuthorization();
|
||||
|
||||
@@ -45,12 +45,32 @@ public static class ManagementEndpoints
|
||||
var bibliotheken = app.MapGroup("/api/v1/libraries");
|
||||
|
||||
// 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) =>
|
||||
Results.Ok(await db.Libraries
|
||||
.OrderBy(l => l.Name)
|
||||
.Select(l => new LibraryResponse(l.Id, l.Name, l.Kind.ToString(), l.Path))
|
||||
.ToListAsync()))
|
||||
.RequireAuthorization();
|
||||
{
|
||||
var liste = await db.Libraries.AsNoTracking().OrderBy(l => l.Name).ToListAsync();
|
||||
|
||||
var filme = await db.Movies.AsNoTracking()
|
||||
.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) =>
|
||||
{
|
||||
@@ -74,7 +94,45 @@ public static class ManagementEndpoints
|
||||
db.Libraries.Add(neu);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Created($"/api/v1/libraries/{neu.Id}",
|
||||
new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
using Flaro.Metadata;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -16,11 +17,26 @@ public static class SystemEndpoints
|
||||
/// </remarks>
|
||||
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)
|
||||
{
|
||||
app.MapGet("/api/v1/system/info", async (FlaroContext db) =>
|
||||
Results.Ok(new SystemInfo(
|
||||
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(),
|
||||
// Ohne Anmeldung abrufbar: ein Client muss den Hinweis auch
|
||||
// auf dem Anmeldebildschirm zeigen können.
|
||||
@@ -30,6 +46,48 @@ public static class SystemEndpoints
|
||||
])))
|
||||
.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) =>
|
||||
{
|
||||
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);
|
||||
|
||||
/// <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>
|
||||
///
|
||||
/// <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.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -92,6 +93,9 @@ builder.Services.AddScoped<MetadataEnricher>();
|
||||
builder.Services.AddScoped<MatchApplier>();
|
||||
builder.Services.AddSingleton<ScanCoordinator>();
|
||||
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();
|
||||
|
||||
var app = builder.Build();
|
||||
@@ -106,6 +110,66 @@ using (var bereich = app.Services.CreateScope())
|
||||
app.UseExceptionHandler();
|
||||
app.UseStatusCodePages();
|
||||
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.UseAuthorization();
|
||||
|
||||
@@ -116,8 +180,34 @@ app.MapLibraryContent();
|
||||
app.MapSettings();
|
||||
app.MapImages();
|
||||
app.MapDelivery();
|
||||
app.MapPlayback();
|
||||
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();
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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";
|
||||
|
||||
/// <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);
|
||||
|
||||
@@ -55,11 +59,11 @@ public sealed class ScanCoordinator(
|
||||
// Lief schon einer, steht hier dessen Zustand und nicht unserer.
|
||||
if (!ReferenceEquals(vorher, start)) return false;
|
||||
|
||||
_ = Task.Run(() => Laufen(libraryId));
|
||||
_ = Task.Run(() => Laufen(libraryId, erneut));
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task Laufen(Guid libraryId)
|
||||
private async Task Laufen(Guid libraryId, bool erneut)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -73,7 +77,7 @@ public sealed class ScanCoordinator(
|
||||
_zustand[libraryId] = _zustand[libraryId] with { Status = "enriching", Report = bericht };
|
||||
|
||||
var metadaten = await bereich.ServiceProvider
|
||||
.GetRequiredService<MetadataEnricher>().EnrichAsync(libraryId);
|
||||
.GetRequiredService<MetadataEnricher>().EnrichAsync(libraryId, erneut);
|
||||
|
||||
_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; }
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
@@ -184,6 +211,33 @@ public sealed class Series
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,78 @@ namespace Flaro.Core.Metadata;
|
||||
/// <summary>Ein Bild bei der Quelle — noch nicht heruntergeladen.</summary>
|
||||
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(
|
||||
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(
|
||||
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);
|
||||
|
||||
|
||||
@@ -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.Libraries;
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Core.Playback;
|
||||
using Flaro.Core.Users;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -32,6 +33,11 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
||||
public DbSet<Series> Series => Set<Series>();
|
||||
public DbSet<Episode> Episodes => Set<Episode>();
|
||||
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>
|
||||
///
|
||||
@@ -122,6 +128,8 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
||||
e.Property(x => x.Title).HasMaxLength(512);
|
||||
e.Property(x => x.SortTitleValue).HasMaxLength(512);
|
||||
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)
|
||||
.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.SortTitleValue).HasMaxLength(512);
|
||||
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)
|
||||
.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);
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
|
||||
@@ -32,7 +32,18 @@ public sealed class MetadataEnricher(
|
||||
ISidecarStore beiwerk,
|
||||
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))
|
||||
{
|
||||
@@ -43,9 +54,9 @@ public sealed class MetadataEnricher(
|
||||
int filme = 0, serien = 0, folgen = 0;
|
||||
try
|
||||
{
|
||||
filme = await FilmeZuordnen(libraryId, ct);
|
||||
serien = await SerienZuordnen(libraryId, ct);
|
||||
folgen = await FolgenZuordnen(libraryId, ct);
|
||||
filme = await FilmeZuordnen(libraryId, erneut, ct);
|
||||
serien = await SerienZuordnen(libraryId, erneut, ct);
|
||||
folgen = await FolgenZuordnen(libraryId, erneut, ct);
|
||||
}
|
||||
catch (MetadataCredentialsException fehler)
|
||||
{
|
||||
@@ -60,9 +71,11 @@ public sealed class MetadataEnricher(
|
||||
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;
|
||||
|
||||
foreach (var film in offen)
|
||||
@@ -91,7 +104,15 @@ public sealed class MetadataEnricher(
|
||||
film.Overview = gefunden.Overview;
|
||||
film.PosterImage = await BildAblegen(gefunden.Poster, 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;
|
||||
|
||||
await GenresSetzen(gefunden.Genres, film: film.Id, serie: null, ct);
|
||||
await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct);
|
||||
zugeordnet++;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
foreach (var serie in offen)
|
||||
@@ -153,6 +176,14 @@ public sealed class MetadataEnricher(
|
||||
serie.Overview = gefunden.Overview;
|
||||
serie.PosterImage = await BildAblegen(gefunden.Poster, 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;
|
||||
zugeordnet++;
|
||||
|
||||
@@ -199,7 +230,7 @@ public sealed class MetadataEnricher(
|
||||
}
|
||||
|
||||
/// <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
|
||||
.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 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);
|
||||
|
||||
var zugeordnet = 0;
|
||||
@@ -236,6 +267,143 @@ public sealed class MetadataEnricher(
|
||||
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) =>
|
||||
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");
|
||||
});
|
||||
|
||||
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")
|
||||
@@ -173,6 +211,58 @@ namespace Flaro.Data.Migrations
|
||||
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")
|
||||
@@ -291,6 +381,10 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<string>("BackdropImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ContentRating")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImdbId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -298,6 +392,9 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<Guid>("LibraryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LogoImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("MatchLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -314,11 +411,21 @@ namespace Flaro.Data.Migrations
|
||||
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)
|
||||
@@ -337,6 +444,31 @@ namespace Flaro.Data.Migrations
|
||||
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")
|
||||
@@ -349,6 +481,10 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<string>("BackdropImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ContentRating")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImdbId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -356,6 +492,9 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<Guid>("LibraryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LogoImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("MatchLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -372,11 +511,21 @@ namespace Flaro.Data.Migrations
|
||||
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)
|
||||
@@ -395,6 +544,70 @@ namespace Flaro.Data.Migrations
|
||||
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")
|
||||
@@ -451,6 +664,25 @@ namespace Flaro.Data.Migrations
|
||||
.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)
|
||||
@@ -460,6 +692,25 @@ namespace Flaro.Data.Migrations
|
||||
.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)
|
||||
@@ -495,6 +746,25 @@ namespace Flaro.Data.Migrations
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,19 @@ public sealed class TmdbClient(
|
||||
private const string PosterSize = "w500";
|
||||
private const string BackdropSize = "w1280";
|
||||
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);
|
||||
|
||||
@@ -58,7 +71,13 @@ public sealed class TmdbClient(
|
||||
{
|
||||
var treffer = await SuchenAsync<TmdbMovie>("search/movie", title,
|
||||
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(
|
||||
@@ -74,9 +93,36 @@ public sealed class TmdbClient(
|
||||
var einstellung = await einstellungen.GetAsync(ct);
|
||||
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>(
|
||||
$"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct);
|
||||
return film is null ? null : await MitImdb(Als(film, ""), "movie", ct);
|
||||
$"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}" +
|
||||
"&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(
|
||||
@@ -87,7 +133,8 @@ public sealed class TmdbClient(
|
||||
{
|
||||
var treffer = await SuchenAsync<TmdbSeries>("search/tv", title,
|
||||
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(
|
||||
@@ -104,39 +151,34 @@ public sealed class TmdbClient(
|
||||
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
|
||||
|
||||
var serie = await HolenAsync<TmdbSeries>(
|
||||
$"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct);
|
||||
return serie is null ? null : await MitImdb(Als(serie, ""), "tv", ct);
|
||||
$"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}" +
|
||||
"&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(
|
||||
roh.Id, roh.Name ?? rückfall, JahrAus(roh.FirstAirDate), Leer(roh.Overview),
|
||||
Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize));
|
||||
|
||||
/// <summary>Holt die IMDb-Kennung nach — eine Anfrage je neuem Treffer.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <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);
|
||||
}
|
||||
// Der eigene Abruf von `external_ids` ist entfallen: die Kennung hängt
|
||||
// jetzt über `append_to_response` am Einzelabruf. Das spart eine Anfrage
|
||||
// je Titel — bei vierhundert Filmen vierhundert.
|
||||
|
||||
public async Task<EpisodeMetadata?> FindEpisodeAsync(
|
||||
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=…";
|
||||
}
|
||||
|
||||
/// <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) =>
|
||||
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("overview")] string? Overview,
|
||||
[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(
|
||||
[property: JsonPropertyName("id")] int Id,
|
||||
@@ -288,7 +448,59 @@ public sealed class TmdbClient(
|
||||
[property: JsonPropertyName("first_air_date")] string? FirstAirDate,
|
||||
[property: JsonPropertyName("overview")] string? Overview,
|
||||
[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(
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
|
||||
@@ -104,7 +104,10 @@ public class CodeSignInTests
|
||||
await server.SetUpAndSignIn();
|
||||
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));
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/// <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]
|
||||
public async Task Ohne_Anmeldung_gibt_es_nichts()
|
||||
{
|
||||
Film("Geschuetzt (2020)", "mp4");
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -24,7 +25,12 @@ public sealed class FlaroServer : IDisposable
|
||||
|
||||
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"));
|
||||
Environment.SetEnvironmentVariable("FLARO_DATA", Path.Combine(_verzeichnis, "config"));
|
||||
@@ -36,7 +42,11 @@ public sealed class FlaroServer : IDisposable
|
||||
Environment.SetEnvironmentVariable("FLARO_RATELIMIT_SIGNIN", "10000");
|
||||
|
||||
_grundlage = new WebApplicationFactory<Program>();
|
||||
_fabrik = _grundlage.WithWebHostBuilder(b => b.ConfigureServices(sammlung =>
|
||||
_fabrik = _grundlage.WithWebHostBuilder(b =>
|
||||
{
|
||||
if (inhaltswurzel is not null) b.UseContentRoot(inhaltswurzel);
|
||||
|
||||
b.ConfigureServices(sammlung =>
|
||||
{
|
||||
// **Ohne das dauert der ganze Lauf eine Viertelstunde.** Ein Host
|
||||
// wartet beim Herunterfahren fünf Sekunden auf seine Dienste, und
|
||||
@@ -56,7 +66,8 @@ public sealed class FlaroServer : IDisposable
|
||||
sammlung.AddScoped<IMetadataProvider, AbgeschalteterAnbieter>();
|
||||
|
||||
dienste?.Invoke(sammlung);
|
||||
}));
|
||||
});
|
||||
});
|
||||
Client = _fabrik.CreateClient();
|
||||
}
|
||||
|
||||
@@ -84,6 +95,16 @@ public sealed class FlaroServer : IDisposable
|
||||
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()
|
||||
{
|
||||
_fabrik.Dispose();
|
||||
|
||||
@@ -383,7 +383,10 @@ public class ImageEndpointTests
|
||||
using var server = new FlaroServer();
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
/// <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 DieWirft(Exception fehler) => new(_ => throw fehler);
|
||||
|
||||
+3
-1
@@ -3,12 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0B0B0D">
|
||||
<title>Flaro</title>
|
||||
</head>
|
||||
<body>
|
||||
<section id="login"></section>
|
||||
<header id="topbar" class="hidden"></header>
|
||||
<div id="app" class="hidden">
|
||||
<nav id="chrome"><div class="island" id="island"></div></nav>
|
||||
<nav id="chrome"></nav>
|
||||
<main id="content"></main>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
+419
-136
@@ -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.
|
||||
----------------------------------------------------------------------------
|
||||
**Alles geht über `/api`, nicht über die Adresse des Medienservers.** Das
|
||||
ist der Grund, warum es diesen Server überhaupt gibt: Er reicht die
|
||||
Anfragen weiter, und damit haben Seite und Daten dieselbe Herkunft. Kein
|
||||
CORS, kein Ärger mit gemischtem HTTP und HTTPS, und der Zugangsschlüssel
|
||||
bleibt beim Server statt im Browser zu liegen.
|
||||
|
||||
Die Endpunkte selbst sind dieselben, die auch die App benutzt — übernommen
|
||||
aus JellyfinKit, nicht geraten. */
|
||||
|
||||
const BASIS = '/api';
|
||||
|
||||
export const sitzung = {
|
||||
token: localStorage.getItem('flaro.token') ?? '',
|
||||
userId: localStorage.getItem('flaro.user') ?? '',
|
||||
geraet:
|
||||
localStorage.getItem('flaro.device') ??
|
||||
(() => {
|
||||
const id = 'flaro-web-' + Math.random().toString(36).slice(2, 12);
|
||||
localStorage.setItem('flaro.device', id);
|
||||
return id;
|
||||
})(),
|
||||
export type Rolle = { id: string; userName: string; isAdministrator: boolean };
|
||||
export type Spur = {
|
||||
index: number; kind: 'Video' | 'Audio' | 'Subtitle';
|
||||
codec: string | null; language: string | null; title: string | null;
|
||||
isDefault: boolean; isForced: boolean;
|
||||
channels: number | null; width: number | null; height: number | null;
|
||||
};
|
||||
export type Datei = {
|
||||
id: string; fileName: string; sizeBytes: number;
|
||||
container: string | null; durationSeconds: number | null; streams: Spur[];
|
||||
};
|
||||
/** Was dieser Benutzer mit einem Titel zu tun hatte. */
|
||||
export type Stand = {
|
||||
positionSeconds: number; runtimeSeconds: number | null;
|
||||
watched: boolean; playCount: number;
|
||||
favourite: boolean; rating: number | null; dismissed: boolean;
|
||||
lastPlayedAt: string | null;
|
||||
};
|
||||
export type Mitwirkender = {
|
||||
id: string; name: string;
|
||||
role: 'Actor' | 'Director' | 'Writer';
|
||||
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 —
|
||||
* Jellyfin zerlegt die Zeile sonst falsch und meldet, man sei nicht
|
||||
* angemeldet, obwohl der Schlüssel stimmt. */
|
||||
function kopfzeile(): string {
|
||||
const teile = [
|
||||
'Client="Flaro Web"',
|
||||
'Device="Browser"',
|
||||
`DeviceId="${sitzung.geraet}"`,
|
||||
'Version="0.1"',
|
||||
];
|
||||
if (sitzung.token) teile.push(`Token="${sitzung.token}"`);
|
||||
return 'MediaBrowser ' + teile.join(', ');
|
||||
* `localStorage` überlebt das Schliessen des Browsers, `sessionStorage`
|
||||
* nicht. Ein Verfallsdatum selbst zu verwalten wäre die schlechtere
|
||||
* Variante: eine Uhr, die man stellen kann, gegen zwei Speicher, die der
|
||||
* Browser ohnehin richtig aufräumt. */
|
||||
function ablage(dauerhaft: boolean): Storage | null {
|
||||
try { return dauerhaft ? localStorage : sessionStorage; } catch { return null; }
|
||||
}
|
||||
|
||||
export class ServerFehler extends Error {
|
||||
constructor(public status: number, text: string) {
|
||||
super(text);
|
||||
try {
|
||||
// Erst der dauerhafte, dann der fürs Fenster. Liegt in beiden etwas, gilt
|
||||
// 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> {
|
||||
const res = await fetch(BASIS + pfad, {
|
||||
...opts,
|
||||
headers: {
|
||||
Authorization: kopfzeile(),
|
||||
Accept: 'application/json',
|
||||
...(opts.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...opts.headers,
|
||||
},
|
||||
export const angemeldet = (): boolean => Boolean(sitzung.token);
|
||||
export const istAdmin = (): boolean => Boolean(sitzung.wer?.isAdministrator);
|
||||
|
||||
/* ---------- Anfragen ---------- */
|
||||
|
||||
export class ApiFehler extends Error {
|
||||
constructor(public status: number, nachricht: string) { super(nachricht); }
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new ServerFehler(
|
||||
res.status,
|
||||
res.status === 401 ? 'Nicht angemeldet' : `HTTP ${res.status}`,
|
||||
);
|
||||
}
|
||||
return res.status === 204 ? (null as T) : ((await res.json()) as T);
|
||||
}
|
||||
|
||||
/** Eine Adresse für das <video>-Element oder ein <img>.
|
||||
*
|
||||
* Beide schicken keine Kopfzeilen mit, deshalb hängt der Schlüssel in der
|
||||
* Adresse. Denselben Umweg geht die App. */
|
||||
export function medienURL(pfad: string, query: Record<string, string | number> = {}): string {
|
||||
const u = new URL(BASIS + pfad, location.origin);
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== '' && v != null) u.searchParams.set(k, String(v));
|
||||
}
|
||||
if (sitzung.token) u.searchParams.set('api_key', sitzung.token);
|
||||
return u.href;
|
||||
}
|
||||
|
||||
/* ---------- Anmeldung ---------- */
|
||||
|
||||
export async function anmelden(benutzer: string, passwort: string): Promise<void> {
|
||||
sitzung.token = '';
|
||||
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);
|
||||
}
|
||||
|
||||
export function abmelden(): void {
|
||||
localStorage.removeItem('flaro.token');
|
||||
localStorage.removeItem('flaro.user');
|
||||
location.reload();
|
||||
}
|
||||
|
||||
export const angemeldet = (): boolean => Boolean(sitzung.token && sitzung.userId);
|
||||
|
||||
/* ---------- Wiedergabe ---------- */
|
||||
|
||||
/** Handelt mit dem Server aus, wie abgespielt wird.
|
||||
*
|
||||
* Das Geräteprofil sagt, was dieses Ende kann; die Antwort sagt, was daraus
|
||||
* folgt. Leere `DirectPlayProfiles` heißt: nichts geht unverändert, rechne
|
||||
* um. Im Browser ist das die ehrliche Auskunft — ein <video>-Element kann
|
||||
* kein MKV und kein DTS. */
|
||||
export function wiedergabeauskunft(
|
||||
itemID: string,
|
||||
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>(
|
||||
`/Items/${itemID}/PlaybackInfo?` + new URLSearchParams({ userId: sitzung.userId }),
|
||||
{
|
||||
export async function anmelden(
|
||||
benutzer: string, passwort: string, bleiben = true,
|
||||
): Promise<void> {
|
||||
const antwort = await anfrage<{ token: string; user: Rolle }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
DeviceProfile: profil,
|
||||
UserId: sitzung.userId,
|
||||
MaxStreamingBitrate: profil.MaxStreamingBitrate,
|
||||
EnableDirectPlay: false,
|
||||
EnableDirectStream: false,
|
||||
EnableTranscoding: true,
|
||||
AutoOpenLiveStream: true,
|
||||
...(wahl.audio != null ? { AudioStreamIndex: wahl.audio } : {}),
|
||||
...((wahl.subtitle ?? -1) >= 0 ? { SubtitleStreamIndex: wahl.subtitle } : {}),
|
||||
userName: benutzer, password: passwort,
|
||||
deviceName: geraetename(), deviceId: geraetekennung(),
|
||||
staySignedIn: bleiben,
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
bleibenAngemeldet = bleiben;
|
||||
sitzung.token = antwort.token;
|
||||
sitzung.wer = antwort.user;
|
||||
merken();
|
||||
}
|
||||
|
||||
/** Prüft einmal, ob das Token wirklich nicht mehr gilt.
|
||||
*
|
||||
* Mehrere gleichzeitige Anfragen laufen in dieselbe Prüfung — sonst stellte
|
||||
* eine Startseite mit acht Abfragen acht Mal dieselbe Frage. */
|
||||
let laufendePruefung: Promise<boolean> | null = null;
|
||||
|
||||
function sitzungTot(): Promise<boolean> {
|
||||
laufendePruefung ??= fetch('/api/v1/auth/me', {
|
||||
headers: { Authorization: `Bearer ${sitzung.token}` },
|
||||
})
|
||||
.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;
|
||||
}
|
||||
|
||||
/** Wirft die Sitzung hier weg — ohne den Server zu fragen. */
|
||||
function verwerfen(): void {
|
||||
sitzung.token = null; sitzung.wer = null;
|
||||
merken();
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
// Auch ohne gültiges Token aufrufen: der Server löscht dabei das Cookie,
|
||||
// und genau das bleibt sonst im Browser stehen und geht bei jedem Bild
|
||||
// erneut hinaus.
|
||||
void fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: sitzung.token ? { Authorization: `Bearer ${sitzung.token}` } : {},
|
||||
}).catch(() => {});
|
||||
verwerfen();
|
||||
}
|
||||
|
||||
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'}`;
|
||||
}
|
||||
|
||||
/** 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(); }
|
||||
}
|
||||
|
||||
/* ---------- 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.
|
||||
*
|
||||
* Die Unterscheidung Film/Serie steckt in der Mediathek, nicht im Aufrufer —
|
||||
* wer eine Reihe zeichnet, will Kacheln und nicht zwei Codepfade. */
|
||||
export const inhalt = (b: Bibliothek, q: Omit<Suchfrage, 'library'> = {}) =>
|
||||
b.kind === 'Shows'
|
||||
? serien({ ...q, library: b.id })
|
||||
: filme({ ...q, library: b.id });
|
||||
|
||||
export const folgen = (serie: string, staffel?: number) =>
|
||||
anfrage<Seite<Folge>>(`/series/${serie}/episodes${abfrage({ season: staffel })}`);
|
||||
|
||||
export const film = (id: string) => anfrage<Film>(`/movies/${id}`);
|
||||
export const serie = (id: string) => anfrage<Serie>(`/series/${id}`);
|
||||
|
||||
export const lieferung = (datei: string) => anfrage<Lieferung>(`/files/${datei}/delivery`);
|
||||
|
||||
/* ---------- Stand je Benutzer ---------- */
|
||||
|
||||
export const weiterschauen = (take = 20) =>
|
||||
anfrage<Seite<Angefangen>>(`/home/continue${abfrage({ take })}`);
|
||||
|
||||
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.
|
||||
----------------------------------------------------------------------------
|
||||
Als Vektor, nicht als Schriftzeichen. Ein Wiedergabedreieck oder ein
|
||||
Lautsprecher-Emoji wird auf jeder Plattform anders gezeichnet, trägt fremde
|
||||
Farben, sitzt schief auf der Grundlinie und lässt sich weder in der
|
||||
Strichstärke noch in der Farbe anpassen — es sieht nach Platzhalter aus,
|
||||
weil es einer ist.
|
||||
/* Icons als SVG, nicht als Zeichen aus einer Schriftart.
|
||||
*
|
||||
* **Keine Emojis.** Ein ▶ oder ✓ aus Unicode sieht auf jedem System anders
|
||||
* aus, lässt sich nicht einfärben, nicht in der Strichstärke anpassen und
|
||||
* springt in der Grundlinie. Diese hier sind auf 24×24 gezeichnet, erben die
|
||||
* Textfarbe und skalieren mit der Schriftgrösse.
|
||||
*
|
||||
* 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> = {
|
||||
play: '<path d="M8 5.5v13l11-6.5z" fill="currentColor" stroke="none"/>',
|
||||
waveform: '<path d="M3 12h2M7 8v8M11 4.5v15M15 8v8M19 11h2"/>',
|
||||
speed: '<path d="M12 20a8 8 0 100-16 8 8 0 000 16z"/><path d="M12 12l4-3"/>',
|
||||
// Kreispfeile mit der Sekundenzahl darin — dasselbe Bild wie
|
||||
// `gobackward.10` und `goforward.30` auf dem iPad.
|
||||
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"/>`
|
||||
+ `<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>`,
|
||||
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>`;
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
/** Ein Icon als Element — für alles, was nicht aus einer Zeichenkette gebaut wird. */
|
||||
export function icon(name: IconName, klasse = 'icon'): HTMLElement {
|
||||
const h = document.createElement('span');
|
||||
h.className = klasse;
|
||||
h.innerHTML = icons[name];
|
||||
return h;
|
||||
}
|
||||
|
||||
+191
-173
@@ -1,193 +1,211 @@
|
||||
import './style.css';
|
||||
import type { Item, Seite } from './types';
|
||||
import { api, sitzung, angemeldet, abmelden } from './api';
|
||||
import { P, anwenden, setzen } from './prefs';
|
||||
import { icon } from './icons';
|
||||
import { esc, FELDER } from './util';
|
||||
import {
|
||||
systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken,
|
||||
type Hinweis, type Bibliothek,
|
||||
} from './api';
|
||||
import { icons, icon } from './icons';
|
||||
import { einrichtung } from './views/setup';
|
||||
import { anmeldung } from './views/login';
|
||||
import { startseite } from './views/home';
|
||||
import { mediathek } from './views/library';
|
||||
import { einstellungen } from './views/settings';
|
||||
import { blatt } from './views/cards';
|
||||
import { playerOeffnen } from './views/player';
|
||||
import { detailseite } from './views/detail';
|
||||
import { verwaltung } from './views/admin';
|
||||
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 leiste = document.getElementById('chrome')!;
|
||||
const inhalt = document.getElementById('content')!;
|
||||
const insel = document.getElementById('island')!;
|
||||
const chrome = document.getElementById('chrome')!;
|
||||
|
||||
let ansichten: Item[] = [];
|
||||
let wo = 'home';
|
||||
let hinweise: Hinweis[] = [];
|
||||
let mediatheken: Bibliothek[] = [];
|
||||
let serverName = 'Flaro';
|
||||
|
||||
/* ---------- Navigation ---------- */
|
||||
/* ---------- Start ---------- */
|
||||
|
||||
function inselBauen(): void {
|
||||
insel.innerHTML = '';
|
||||
void hochfahren();
|
||||
|
||||
// Der Umschalter zuerst, wie auf dem iPad: an der Seite oben in der Ecke,
|
||||
// oben ganz links vor den Einträgen.
|
||||
const nav = document.createElement('button');
|
||||
nav.className = 'toggle-nav';
|
||||
nav.title = P.nav === 'side' ? 'Leiste nach oben' : 'Leiste an die Seite';
|
||||
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 => {
|
||||
const d = document.createElement('div');
|
||||
d.className = klasse;
|
||||
if (text) d.textContent = text;
|
||||
insel.appendChild(d);
|
||||
};
|
||||
const eintrag = (text: string, ziel: string): void => {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = text;
|
||||
b.dataset.key = ziel;
|
||||
b.onclick = () => gehe(ziel);
|
||||
insel.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;
|
||||
async function hochfahren(): Promise<void> {
|
||||
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 */ }
|
||||
const auskunft = await systemauskunft();
|
||||
hinweise = auskunft.attributions;
|
||||
serverName = auskunft.serverName ?? 'Flaro';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (auskunft.needsSetup) { zeigeAnmeldung(einrichtung(() => location.reload())); return; }
|
||||
if (!angemeldet()) { zeigeAnmeldung(anmeldung(() => location.reload())); return; }
|
||||
|
||||
/* ---------- Los ---------- */
|
||||
|
||||
async function starte(): Promise<void> {
|
||||
document.getElementById('login')!.classList.add('hidden');
|
||||
anmeldeflaeche.classList.add('hidden');
|
||||
kopfleiste.classList.remove('hidden');
|
||||
app.classList.remove('hidden');
|
||||
anwenden();
|
||||
inhalt.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
try {
|
||||
const r = await api<Seite>(`/Users/${sitzung.userId}/Views`);
|
||||
ansichten = (r.Items ?? []).filter((v) => ['movies', 'tvshows'].includes(v.CollectionType ?? ''));
|
||||
inselBauen();
|
||||
gehe('home');
|
||||
} catch (e) {
|
||||
inhalt.innerHTML = `<div class="empty pad-top">${esc((e as Error).message)}<br><br>
|
||||
<button class="btn" id="raus">Abmelden</button></div>`;
|
||||
document.getElementById('raus')!.onclick = abmelden;
|
||||
// **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');
|
||||
}
|
||||
}
|
||||
|
||||
// Die Insel oben fährt beim Scrollen weg und kommt zurück, sobald man wieder
|
||||
// oben steht. An der Seite bleibt sie stehen — dort nimmt sie niemandem etwas.
|
||||
addEventListener('scroll', () => {
|
||||
if (P.nav === 'side') return;
|
||||
chrome.classList.toggle('away', scrollY > 8);
|
||||
}, { passive: true });
|
||||
function zeigeAnmeldung(karte: HTMLElement): void {
|
||||
anmeldeflaeche.innerHTML = '';
|
||||
anmeldeflaeche.appendChild(karte);
|
||||
anmeldeflaeche.classList.remove('hidden');
|
||||
kopfleiste.classList.add('hidden');
|
||||
app.classList.add('hidden');
|
||||
}
|
||||
|
||||
anwenden();
|
||||
if (angemeldet()) void starte();
|
||||
else anmeldung(document.getElementById('login')!, () => void starte());
|
||||
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');
|
||||
d.className = 'nav-gruppe';
|
||||
d.textContent = text;
|
||||
leiste.appendChild(d);
|
||||
}
|
||||
|
||||
function eintrag(
|
||||
text: string, ziel: string, bild: keyof typeof icons, anzahl?: number | null,
|
||||
): void {
|
||||
const b = document.createElement('button');
|
||||
b.dataset.ziel = ziel;
|
||||
b.innerHTML = `<span class="icon">${icons[bild]}</span>`
|
||||
+ `<span class="name">${esc(text)}</span>`
|
||||
+ (anzahl != null ? `<span class="zahl">${anzahl}</span>` : '');
|
||||
b.onclick = () => { location.hash = ziel; };
|
||||
leiste.appendChild(b);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Router ---------- */
|
||||
|
||||
function zeichnen(): void {
|
||||
const weg = location.hash.replace(/^#\/?/, '');
|
||||
const teile = weg.split('/').filter(Boolean);
|
||||
|
||||
leiste.querySelectorAll('button[data-ziel]').forEach(b => {
|
||||
const ziel = (b as HTMLElement).dataset.ziel!;
|
||||
b.classList.toggle('check', ziel === `#/${teile.slice(0, 2).join('/')}`);
|
||||
});
|
||||
|
||||
inhalt.innerHTML = '';
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
/** Der Pflichthinweis auf TMDB — Bedingung ihrer Lizenz, nicht Höflichkeit. */
|
||||
function hinweisleiste(): HTMLElement {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'attribution';
|
||||
hinweise.forEach(h => {
|
||||
const a = document.createElement('a');
|
||||
a.href = h.url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noreferrer';
|
||||
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 {
|
||||
return String(wert ?? '').replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||
return String(wert ?? '').replace(/[&<>"']/g, z =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[z]!));
|
||||
}
|
||||
|
||||
/** Laufzeit als „2 Std 16 Min" oder „46 Min". */
|
||||
export function laufzeit(item: Item): string {
|
||||
if (!item.RunTimeTicks) return '';
|
||||
const min = Math.round(item.RunTimeTicks / 600_000_000);
|
||||
return min >= 60 ? `${Math.floor(min / 60)} Std ${min % 60} Min` : `${min} Min`;
|
||||
/** Sekunden als 1:02:03 oder 2:03. */
|
||||
export function dauer(sekunden: number | null): string {
|
||||
if (!sekunden || sekunden <= 0) return '';
|
||||
const s = Math.round(sekunden);
|
||||
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 uhr(sekunden: number): string {
|
||||
if (!isFinite(sekunden)) return '–:--';
|
||||
const s = Math.max(0, Math.floor(sekunden));
|
||||
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');
|
||||
export function groesse(bytes: number): string {
|
||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)} MB`;
|
||||
return `${Math.round(bytes / 1024)} KB`;
|
||||
}
|
||||
|
||||
/** Der Folgentitel: „S2:E4 · Der Fall". */
|
||||
export function folgentitel(item: Item): string {
|
||||
const nummer =
|
||||
item.ParentIndexNumber != null && item.IndexNumber != null
|
||||
? `S${item.ParentIndexNumber}:E${item.IndexNumber}`
|
||||
: '';
|
||||
return [nummer, item.Name].filter(Boolean).join(' · ');
|
||||
/** Wie weit ein Stand gediehen ist, als Anteil — oder nichts. */
|
||||
export function anteil(stand: { positionSeconds: number; runtimeSeconds: number | null } | null | undefined)
|
||||
: number | null {
|
||||
if (!stand?.runtimeSeconds || stand.runtimeSeconds <= 0) return null;
|
||||
return stand.positionSeconds / stand.runtimeSeconds;
|
||||
}
|
||||
|
||||
/** Ob ein Eintrag ein Bild dieser Art hat. */
|
||||
export function hatBild(item: Item, art: 'Primary' | 'Backdrop'): boolean {
|
||||
return art === 'Backdrop'
|
||||
? Boolean(item.BackdropImageTags?.length || item.ParentBackdropImageTags?.length)
|
||||
: Boolean(item.ImageTags?.[art]);
|
||||
/** „noch 42 Min." — was auf einer Kachel wirklich interessiert. */
|
||||
export function verbleibend(stand: { positionSeconds: number; runtimeSeconds: number | null }): string {
|
||||
if (!stand.runtimeSeconds) return '';
|
||||
const rest = Math.max(0, stand.runtimeSeconds - stand.positionSeconds);
|
||||
return rest < 60 ? 'fast durch' : `noch ${Math.round(rest / 60)} Min.`;
|
||||
}
|
||||
|
||||
/** Das Bild eines Eintrags — mit den Ersatzwegen, die Jellyfin nötig macht.
|
||||
*
|
||||
* Eine Folge hat selten ein eigenes Hintergrundbild, aber ihre Serie hat
|
||||
* eins. Ohne diese Kette bliebe die halbe Startseite grau. */
|
||||
export function bild(item: Item, art: 'Primary' | 'Backdrop', breite = 400): string {
|
||||
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 '';
|
||||
export function knopf(text: string, klasse = 'btn'): HTMLButtonElement {
|
||||
const b = document.createElement('button');
|
||||
b.className = klasse;
|
||||
b.textContent = text;
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Die Felder, die die Startseite braucht. */
|
||||
export const FELDER = 'Overview,Genres,MediaSources,ParentBackdropItemId,ParentBackdropImageTags';
|
||||
export function feld(platzhalter: string, art = 'text'): HTMLInputElement {
|
||||
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();
|
||||
}
|
||||
+69
-189
@@ -1,207 +1,87 @@
|
||||
import type { Item, Reihe, Seite } from '../types';
|
||||
import { api, sitzung } from '../api';
|
||||
import { P, anordnen } from '../prefs';
|
||||
import { icon } from '../icons';
|
||||
import { esc, bild, laufzeit, hatBild, FELDER } from '../util';
|
||||
import { kachel } from './cards';
|
||||
import {
|
||||
inhalt, weiterschauen, naechsteFolgen, empfehlungen,
|
||||
type Bibliothek,
|
||||
} from '../api';
|
||||
import { leer, fehlerzeile } from '../util';
|
||||
import { kachel, alsKachel, angefangenAlsKachel, reihe } from '../card';
|
||||
|
||||
/** Welche Reihen es bei diesem Server gibt — für die Einstellungen. */
|
||||
export let bekannteReihen: { id: string; title: string }[] = [];
|
||||
type Art = 'filme' | 'serien';
|
||||
|
||||
export async function startseite(
|
||||
wurzel: HTMLElement,
|
||||
ansichten: Item[],
|
||||
oeffnen: (item: Item) => void,
|
||||
): Promise<void> {
|
||||
/** Die Startseite — waagerechte Reihen, wie bei Plex.
|
||||
*
|
||||
* Oben, was angefangen ist. Darunter **je Mediathek eine Reihe** mit dem
|
||||
* zuletzt Hinzugefügten: wer „Filme" und „Dokumentationen" getrennt angelegt
|
||||
* 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>';
|
||||
|
||||
const hole = async (pfad: string, q: Record<string, string> = {}): Promise<Item[]> => {
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await api<Seite | Item[]>(
|
||||
pfad + '?' + new URLSearchParams({ Fields: FELDER, Limit: '20', ...q }),
|
||||
);
|
||||
// „Latest" liefert ein blankes Feld, alles andere eine Seite mit Items.
|
||||
return Array.isArray(r) ? r : (r.Items ?? []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const [weiter, naechstes, ...neues] = await Promise.all([
|
||||
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 })),
|
||||
// **Alles auf einmal anfragen.** Bei fünf Mediatheken wären es sonst
|
||||
// acht Anfragen nacheinander, und die Startseite baut sich vor den
|
||||
// Augen des Benutzers Zeile für Zeile auf.
|
||||
const [weiter, naechste, vorschlag, ...bestaende] = await Promise.all([
|
||||
weiterschauen(),
|
||||
naechsteFolgen(),
|
||||
empfehlungen(24).catch(() => ({ items: [], total: 0 })),
|
||||
...bibliotheken.map(b => inhalt(b, { sort: 'added', take: 24 })),
|
||||
]);
|
||||
|
||||
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>';
|
||||
if (bibliotheken.length === 0) {
|
||||
wurzel.appendChild(leer('Noch keine Mediathek. Lege unter Verwaltung eine an '
|
||||
+ 'und starte einen Scan.'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
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))));
|
||||
}
|
||||
|
||||
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);
|
||||
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));
|
||||
});
|
||||
|
||||
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);
|
||||
if (vorschlag.items.length) {
|
||||
wurzel.appendChild(reihe('Weil du Ähnliches gesehen hast',
|
||||
vorschlag.items.map(e => kachel(alsKachel(e), oeffnen))));
|
||||
}
|
||||
|
||||
aussen.append(rahmen, punkte);
|
||||
return aussen;
|
||||
}
|
||||
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));
|
||||
}
|
||||
})();
|
||||
|
||||
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;
|
||||
return wurzel;
|
||||
}
|
||||
|
||||
+306
-24
@@ -1,28 +1,310 @@
|
||||
import type { Item, Seite } from '../types';
|
||||
import { api, sitzung } from '../api';
|
||||
import { esc, FELDER } from '../util';
|
||||
import { kachel } from './cards';
|
||||
import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, type Ordnung } from '../api';
|
||||
import { leer, fehlerzeile } from '../util';
|
||||
import { icons } from '../icons';
|
||||
import { kachel, alsKachel, reihe } from '../card';
|
||||
|
||||
export async function mediathek(
|
||||
wurzel: HTMLElement,
|
||||
id: string,
|
||||
oeffnen: (item: Item) => void,
|
||||
): Promise<void> {
|
||||
wurzel.classList.add('pad-top');
|
||||
wurzel.innerHTML = '<div class="spinner"></div>';
|
||||
type Art = 'filme' | 'serien';
|
||||
type Reiter = 'empfehlungen' | 'mediathek' | 'kategorien';
|
||||
|
||||
try {
|
||||
const r = await api<Seite>(
|
||||
`/Users/${sitzung.userId}/Items?` + new URLSearchParams({
|
||||
ParentId: id, Recursive: 'true', IncludeItemTypes: 'Movie,Series',
|
||||
SortBy: 'SortName', SortOrder: 'Ascending', Limit: '200', Fields: FELDER,
|
||||
}),
|
||||
);
|
||||
const items = r.Items ?? [];
|
||||
wurzel.innerHTML = items.length ? '<div class="grid"></div>' : '<div class="empty">Leer.</div>';
|
||||
const raster = wurzel.querySelector('.grid');
|
||||
if (raster) for (const it of items) raster.appendChild(kachel(it, false, oeffnen));
|
||||
} catch (e) {
|
||||
wurzel.innerHTML = `<div class="empty">${esc((e as Error).message)}</div>`;
|
||||
/** Eine Mediathek — mit Reitern, wie bei Plex.
|
||||
*
|
||||
* **Drei Sichten auf denselben Bestand.** „Empfehlungen" ist das, womit man
|
||||
* ankommt; „Mediathek" ist das vollständige Raster für alle, die etwas
|
||||
* 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. */
|
||||
export function mediathek(
|
||||
bibliothek: Bibliothek,
|
||||
servername: string,
|
||||
reiter: string,
|
||||
oeffnen: (art: Art, id: string) => void,
|
||||
): 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 { esc } from '../util';
|
||||
import { knopf, feld, fehlerzeile } from '../util';
|
||||
|
||||
export function anmeldung(wurzel: HTMLElement, fertig: () => void): void {
|
||||
wurzel.innerHTML = `
|
||||
<form class="login-card" id="login-form">
|
||||
<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>`;
|
||||
export function anmeldung(fertig: () => void): HTMLElement {
|
||||
const karte = document.createElement('div');
|
||||
karte.className = 'login-card';
|
||||
|
||||
const form = wurzel.querySelector<HTMLFormElement>('#login-form')!;
|
||||
const knopf = wurzel.querySelector<HTMLButtonElement>('#login-btn')!;
|
||||
const fehler = wurzel.querySelector<HTMLElement>('#login-error')!;
|
||||
const titel = document.createElement('h1');
|
||||
titel.textContent = 'Flaro';
|
||||
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
knopf.disabled = true;
|
||||
knopf.textContent = 'Verbinde …';
|
||||
fehler.textContent = '';
|
||||
const name = feld('Benutzername');
|
||||
name.autocomplete = 'username';
|
||||
const passwort = feld('Passwort', 'password');
|
||||
passwort.autocomplete = 'current-password';
|
||||
|
||||
// **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 {
|
||||
await anmelden(
|
||||
wurzel.querySelector<HTMLInputElement>('#user')!.value,
|
||||
wurzel.querySelector<HTMLInputElement>('#pass')!.value,
|
||||
);
|
||||
await anmelden(name.value.trim(), passwort.value, haken.checked);
|
||||
fertig();
|
||||
} catch (e2) {
|
||||
// Die Serveradresse steht nicht mehr hier, sondern beim Server davor —
|
||||
// deshalb kann sie auch nicht mehr falsch eingetippt sein. Bleibt die
|
||||
// Anmeldung selbst, oder ein Server, der nicht läuft.
|
||||
fehler.textContent = 'Anmeldung fehlgeschlagen: ' + esc((e2 as Error).message);
|
||||
} finally {
|
||||
knopf.disabled = false;
|
||||
knopf.textContent = 'Anmelden';
|
||||
} catch (fehler) {
|
||||
karte.appendChild(fehlerzeile((fehler as Error).message));
|
||||
los.disabled = false;
|
||||
passwort.select();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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:
|
||||
// Anfragen an /api gehen an den Medienserver weiter. Damit ist die
|
||||
// Herkunft dieselbe wie die der Seite — und CORS nie wieder ein Thema.
|
||||
// Der Pfad bleibt unverändert: Flaro bedient /api/v1 selbst.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.FLARO_SERVER ?? 'http://localhost:8096',
|
||||
target: process.env.FLARO_SERVER ?? 'http://localhost:18080',
|
||||
changeOrigin: true,
|
||||
rewrite: (pfad) => pfad.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user