Compare commits
81
Commits
aa1e14cdab
...
2f7768136b
+19
-10
@@ -1,20 +1,29 @@
|
|||||||
# Baut das Flaro-Image und legt es in die Registry dieses Gitea.
|
# Baut das Flaro-Image und legt es in die Registry dieses Gitea.
|
||||||
#
|
#
|
||||||
# **Warum hier und nicht auf dem Arbeitsrechner.** Ein Bau von Hand heisst:
|
# **Zur Zeit schläft diese Datei.** Gebaut wird auf dem Arbeitsrechner mit
|
||||||
# jemand muss daran denken, muss die richtige Architektur angeben und muss
|
# `Tools/image-veroeffentlichen.sh` — der Rechner ist für die Apps ohnehin
|
||||||
# angemeldet sein. Hier passiert es bei jedem Push von selbst, immer für
|
# nötig, weil Xcode nirgendwo sonst läuft.
|
||||||
# beide Architekturen, und die Anmeldung besorgt Gitea.
|
|
||||||
#
|
#
|
||||||
# **Voraussetzung ist ein Runner.** Ohne einen registrierten Act-Runner
|
# **Weil Gitea nicht selbst baut.** Es verteilt Arbeit an einen Act-Runner,
|
||||||
# läuft diese Datei nie an — dann bleibt der Weg von Hand aus
|
# und der müsste auf dem NAS laufen. Dort scheitert er: sein Zweck ist das
|
||||||
# `Tools/image-veroeffentlichen.sh`.
|
# Bauen von Images, also braucht er Zugriff auf Docker, und die Docker-App
|
||||||
|
# des UGREEN lehnt den Einhängepunkt `/var/run/docker.sock` ab. Übrig
|
||||||
|
# blieben `privileged: true` oder der Weg über SSH an der Oberfläche vorbei
|
||||||
|
# — beides gibt dem Runner faktisch Root auf dem NAS.
|
||||||
|
#
|
||||||
|
# **Deshalb nur `workflow_dispatch`.** Mit einem Push-Auslöser legt Gitea
|
||||||
|
# bei jedem Push einen Lauf an, den niemand abarbeitet; nach einer Woche
|
||||||
|
# steht die Actions-Seite voll mit Leichen. Kommt irgendwann ein Runner
|
||||||
|
# dazu, lässt sich der Lauf von Hand starten — und der Auslöser unten ist
|
||||||
|
# mit drei Zeilen wieder da.
|
||||||
|
|
||||||
name: Image bauen und veröffentlichen
|
name: Image bauen und veröffentlichen
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
# Wieder automatisch, sobald ein Runner existiert:
|
||||||
branches: [main]
|
# push:
|
||||||
tags: ['v*']
|
# branches: [main]
|
||||||
|
# tags: ['v*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m11.0721 5.13152c.3321-.84203 1.5237-.84203 1.8558 0l1.6803 4.26031 4.2603 1.68027c.842.3321.842 1.5237 0 1.8558l-4.2603 1.6803-1.6803 4.2603c-.3321.842-1.5237.842-1.8558 0l-1.68027-4.2603-4.26031-1.6803c-.84203-.3321-.84203-1.5237 0-1.8558l4.26031-1.68027zm.9279 1.73562-1.2928 3.27806c-.1014.2571-.3049.4606-.562.562l-3.27806 1.2928 3.27806 1.2928c.2571.1014.4606.3049.562.562l1.2928 3.2781 1.2928-3.2781c.1014-.2571.3049-.4606.562-.562l3.2781-1.2928-3.2781-1.2928c-.2571-.1014-.4606-.3049-.562-.562z" fill="rgb(0,0,0)" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 673 B |
@@ -1 +0,0 @@
|
|||||||
<svg id="Layer_1" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-name="Layer 1"><linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="-52.588" x2="390.826" y1="178.158" y2="434.163"><stop offset="0" stop-color="#55d6ff"/><stop offset="1" stop-color="#ce55ff"/></linearGradient><path d="m485.036 256a54.706 54.706 0 0 1 -27.636 47.868l-347.527 200.644a54.742 54.742 0 0 1 -55.272 0 54.713 54.713 0 0 1 -27.637-47.868v-401.288a54.718 54.718 0 0 1 27.636-47.869 54.75 54.75 0 0 1 55.273 0l347.527 200.645a54.708 54.708 0 0 1 27.636 47.868z" fill="url(#linear-gradient)"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 675 B |
@@ -2,7 +2,7 @@
|
|||||||
"fill-specializations" : [
|
"fill-specializations" : [
|
||||||
{
|
{
|
||||||
"value" : {
|
"value" : {
|
||||||
"automatic-gradient" : "display-p3:0.44346,0.27892,0.96783,1.00000"
|
"automatic-gradient" : "display-p3:0.56409,0.38672,0.96783,1.00000"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -17,15 +17,15 @@
|
|||||||
"layers" : [
|
"layers" : [
|
||||||
{
|
{
|
||||||
"fill" : {
|
"fill" : {
|
||||||
"automatic-gradient" : "display-p3:0.73918,0.46760,0.96783,1.00000"
|
"automatic-gradient" : "display-p3:0.51566,0.11264,0.96783,1.00000"
|
||||||
},
|
},
|
||||||
"image-name" : "play.svg",
|
"image-name" : "flare (1).svg",
|
||||||
"name" : "play",
|
"name" : "flare (1)",
|
||||||
"position" : {
|
"position" : {
|
||||||
"scale" : 1.18,
|
"scale" : 100,
|
||||||
"translation-in-points" : [
|
"translation-in-points" : [
|
||||||
49.7265625,
|
0,
|
||||||
1.468530916781674e-05
|
0
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7837,6 +7837,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"upcoming.from %@": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Ab %@ Uhr"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "From %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"upcoming.title": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Bald ist es soweit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Coming soon"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "1.1"
|
"version": "1.1"
|
||||||
|
|||||||
@@ -130,6 +130,16 @@ final class FlaroSession {
|
|||||||
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
||||||
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
|
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
|
||||||
|
|
||||||
|
/// Das Profilbild des angemeldeten Kontos, als Adresse.
|
||||||
|
///
|
||||||
|
/// **Nur Flaro hat eines.** Jellyfin gibt kein Profilbild heraus; dort
|
||||||
|
/// bleibt es beim Buchstaben, und das ist keine Lücke, sondern der Stand
|
||||||
|
/// der Dinge.
|
||||||
|
var accountAvatarURL: URL? {
|
||||||
|
guard let schluessel = flaroAccount?.avatarImage else { return nil }
|
||||||
|
return backend?.rawImageURL(path: schluessel)
|
||||||
|
}
|
||||||
|
|
||||||
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
|
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
|
||||||
/// erscheinen gar nicht erst.
|
/// erscheinen gar nicht erst.
|
||||||
private(set) var libraries: [MediaLibrary] = []
|
private(set) var libraries: [MediaLibrary] = []
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ final class HomeFeed {
|
|||||||
case .continueFirst:
|
case .continueFirst:
|
||||||
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
|
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
|
||||||
// Angefangenes bleibt vorn, wechselt aber untereinander.
|
// Angefangenes bleibt vorn, wechselt aber untereinander.
|
||||||
let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil })
|
let ordered = HeroSelection.ordered(unique.filter(\.isResumable))
|
||||||
+ HeroSelection.ordered(unique.filter { $0.watchProgress == nil })
|
+ HeroSelection.ordered(unique.filter { !$0.isResumable })
|
||||||
return Array(ordered.prefix(layout.heroCount))
|
return Array(ordered.prefix(layout.heroCount))
|
||||||
case .latestOnly:
|
case .latestOnly:
|
||||||
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
|
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ enum TopShelfWriter {
|
|||||||
width: posterWidth,
|
width: posterWidth,
|
||||||
height: posterHeight
|
height: posterHeight
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct ItemActionEntries: View {
|
|||||||
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
|
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
|
||||||
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
|
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
|
||||||
// Fehler bei sich.
|
// Fehler bei sich.
|
||||||
if item.watchProgress != nil {
|
if item.isResumable {
|
||||||
Button {
|
Button {
|
||||||
run { try await $0.removeFromContinueWatching(item) }
|
run { try await $0.removeFromContinueWatching(item) }
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
/// Ob es überhaupt etwas zu starten gibt.
|
/// Ob es überhaupt etwas zu starten gibt.
|
||||||
///
|
///
|
||||||
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
|
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
|
||||||
private var isPlayable: Bool { item.kind != .collection }
|
/// Ein geplanter Titel ebenso: der Server lehnt ihn bis zum Termin ab,
|
||||||
|
/// und ein Tipp auf die Kachel startete trotzdem — gemessen am Simulator,
|
||||||
|
/// der Spieler kam bis „Wird vorbereitet …". Dort führt der Tipp jetzt
|
||||||
|
/// auf die Seite, wo der Termin steht.
|
||||||
|
private var isPlayable: Bool { item.kind != .collection && !item.isUpcoming }
|
||||||
|
|
||||||
private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) }
|
private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlaroDesign
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Steht dort, wo sonst „Abspielen" stünde.
|
||||||
|
///
|
||||||
|
/// **Ein Knopf, der nichts tut, ist schlimmer als keiner.** Ein geplanter
|
||||||
|
/// Titel steht in der Mediathek, damit man weiss, dass er kommt — das ist der
|
||||||
|
/// Sinn des Planens. Der Server lehnt ihn bis zum Termin ab; stünde der Knopf
|
||||||
|
/// trotzdem da, wäre die Reihenfolge: draufdrücken, freuen, Absage lesen.
|
||||||
|
///
|
||||||
|
/// Ein Baustein für Aufmacher und Detailseite, weil sonst an der einen Stelle
|
||||||
|
/// der Termin stünde und an der anderen weiter der Knopf.
|
||||||
|
struct UpcomingNotice: View {
|
||||||
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
|
let date: Date
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.font(.flaroCardTitle)
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("upcoming.title")
|
||||||
|
.font(.flaroCardTitle)
|
||||||
|
Text("upcoming.from \(Self.formatted(date))")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
|
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „Dienstag, 6. Oktober 2026 um 18:00" — in der Sprache des Geräts.
|
||||||
|
static func formatted(_ date: Date) -> String {
|
||||||
|
date.formatted(
|
||||||
|
.dateTime.weekday(.wide).day().month(.wide).year().hour().minute())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,35 +97,43 @@ struct PlayableDetailView: View {
|
|||||||
// Karussell auf dem Telefon quer ist.
|
// Karussell auf dem Telefon quer ist.
|
||||||
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
|
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
|
||||||
) {
|
) {
|
||||||
Button {
|
// Geplant heisst sichtbar, nicht abspielbar. Weder Abspielen
|
||||||
playback.play(item, mediaIndex: selectedVersion)
|
// noch Herunterladen — der Server lehnt beides bis zum Termin ab.
|
||||||
} label: {
|
if item.isUpcoming, let ab = item.availableFrom {
|
||||||
Label(
|
UpcomingNotice(date: ab)
|
||||||
item.watchProgress == nil ? "hero.play" : "hero.resume",
|
} else {
|
||||||
systemImage: "play.fill"
|
|
||||||
)
|
|
||||||
.font(.flaroCardTitle)
|
|
||||||
.padding(.horizontal, FlaroSpacing.loose)
|
|
||||||
.padding(.vertical, FlaroSpacing.snug)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(accent)
|
|
||||||
.disabled(currentMedia?.files.first == nil)
|
|
||||||
|
|
||||||
if let progress = item.watchProgress, progress > 0 {
|
|
||||||
Button {
|
Button {
|
||||||
playback.play(item, mediaIndex: selectedVersion, resuming: false)
|
playback.play(item, mediaIndex: selectedVersion)
|
||||||
} label: {
|
} label: {
|
||||||
Label("detail.playFromStart", systemImage: "gobackward")
|
Label(
|
||||||
.labelStyle(.iconOnly)
|
item.isResumable ? "hero.resume" : "hero.play",
|
||||||
.font(.flaroCardTitle)
|
systemImage: "play.fill"
|
||||||
.padding(FlaroSpacing.snug)
|
)
|
||||||
|
.font(.flaroCardTitle)
|
||||||
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(Color.flaroTextSecondary)
|
.tint(accent)
|
||||||
}
|
.disabled(currentMedia?.files.first == nil)
|
||||||
|
|
||||||
DownloadButton(item: item, mediaIndex: selectedVersion)
|
// „Von vorn" nur, wenn es ein „weiter" gibt — dieselbe
|
||||||
|
// Entscheidung wie beim Knopf daneben.
|
||||||
|
if item.isResumable {
|
||||||
|
Button {
|
||||||
|
playback.play(item, mediaIndex: selectedVersion, resuming: false)
|
||||||
|
} label: {
|
||||||
|
Label("detail.playFromStart", systemImage: "gobackward")
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.font(.flaroCardTitle)
|
||||||
|
.padding(FlaroSpacing.snug)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(Color.flaroTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
DownloadButton(item: item, mediaIndex: selectedVersion)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ private struct ChildRow: View {
|
|||||||
.frame(width: item.kind == .season ? 48 : 96)
|
.frame(width: item.kind == .season ? 48 : 96)
|
||||||
.clipShape(.rect(cornerRadius: 4))
|
.clipShape(.rect(cornerRadius: 4))
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
if let progress = item.watchProgress {
|
if let progress = item.resumeProgress {
|
||||||
WatchProgressBar(fraction: progress)
|
WatchProgressBar(fraction: progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,21 +47,28 @@ struct HeroSection: View {
|
|||||||
// Bildschirm leer.
|
// Bildschirm leer.
|
||||||
fillsAvailableSpace: sizeClass != .compact
|
fillsAvailableSpace: sizeClass != .compact
|
||||||
) {
|
) {
|
||||||
Button {
|
// Geplant heisst sichtbar, nicht abspielbar — statt des Knopfes
|
||||||
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher
|
// der Termin.
|
||||||
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert
|
if item.isUpcoming, let ab = item.availableFrom {
|
||||||
// neue Folgen als ihre Serie —, schickte der direkte Weg
|
UpcomingNotice(date: ab)
|
||||||
// deren eigene Kennung an den Player, und der scheiterte mit
|
} else {
|
||||||
// „keine abspielbare Datei". Eine Serie hat keine Fassungen.
|
Button {
|
||||||
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
||||||
} label: {
|
// Aufmacher eine Serie — und Jellyfins „Zuletzt
|
||||||
Label(playLabel, systemImage: "play.fill")
|
// hinzugefügt" liefert neue Folgen als ihre Serie —,
|
||||||
.font(.flaroCardTitle)
|
// schickte der direkte Weg deren eigene Kennung an den
|
||||||
.padding(.horizontal, FlaroSpacing.loose)
|
// Player, und der scheiterte mit „keine abspielbare
|
||||||
.padding(.vertical, FlaroSpacing.snug)
|
// Datei". Eine Serie hat keine Fassungen.
|
||||||
|
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
||||||
|
} label: {
|
||||||
|
Label(playLabel, systemImage: "play.fill")
|
||||||
|
.font(.flaroCardTitle)
|
||||||
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(accent)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(accent)
|
|
||||||
|
|
||||||
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
||||||
Label("hero.details", systemImage: "info.circle")
|
Label("hero.details", systemImage: "info.circle")
|
||||||
@@ -77,7 +84,7 @@ struct HeroSection: View {
|
|||||||
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
||||||
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
||||||
private var playLabel: LocalizedStringKey {
|
private var playLabel: LocalizedStringKey {
|
||||||
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
item.isResumable ? "hero.resume" : "hero.play"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var metadata: String? { Self.metadata(for: item) }
|
private var metadata: String? { Self.metadata(for: item) }
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ struct MediaRow: View {
|
|||||||
width: 440,
|
width: 440,
|
||||||
height: 248
|
height: 248
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
.frame(width: landscapeWidth)
|
.frame(width: landscapeWidth)
|
||||||
} else {
|
} else {
|
||||||
@@ -73,7 +73,7 @@ struct MediaRow: View {
|
|||||||
width: 300,
|
width: 300,
|
||||||
height: 450
|
height: 450
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
.frame(width: posterWidth)
|
.frame(width: posterWidth)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ struct LibraryView: View {
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
subtitle: item.cardSubtitle,
|
subtitle: item.cardSubtitle,
|
||||||
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.task { await loadMoreIfNeeded(reaching: item) }
|
.task { await loadMoreIfNeeded(reaching: item) }
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
|
||||||
/// Der erste Buchstabe auf einer Scheibe.
|
/// Das eigene Bild auf einer Scheibe — oder der erste Buchstabe.
|
||||||
///
|
///
|
||||||
/// **Kein Platzhalterbild.** Jellyfin gibt kein Profilbild heraus, ohne dass
|
/// **Der Buchstabe ist keine Notlösung.** Jellyfin gibt kein Profilbild
|
||||||
/// eines hinterlegt ist, und am Konto führt die App heute keines mit. Ein
|
/// heraus, und auch an einem Flaro-Konto muss keines hängen. Ein Buchstabe
|
||||||
/// Buchstabe ist ehrlicher als eine graue Silhouette, die bei jedem gleich
|
/// ist ehrlicher als eine graue Silhouette, die bei jedem gleich aussieht —
|
||||||
/// aussieht — und man erkennt an ihm wenigstens, mit welchem Konto man
|
/// und man erkennt an ihm wenigstens, mit welchem Konto man angemeldet ist.
|
||||||
/// angemeldet ist.
|
|
||||||
///
|
///
|
||||||
/// Stand bis eben in `AccountBar` und wird jetzt zweimal gebraucht: dort in
|
/// Steht ein Bild im Konto, tritt es an seine Stelle. Lädt es nicht, kommt
|
||||||
/// der Seitenleiste und auf dem iPhone oben rechts, wo seit dem Wegfall des
|
/// der Buchstabe zurück: eine leere Scheibe sagt weniger als ein P.
|
||||||
/// Einstellungen-Tabs der Weg dorthin liegt.
|
///
|
||||||
|
/// Stand einmal in `AccountBar` und wird inzwischen dreimal gebraucht: dort in
|
||||||
|
/// der Seitenleiste, auf dem iPhone oben rechts und in der Werkzeugleiste.
|
||||||
struct AccountAvatar: View {
|
struct AccountAvatar: View {
|
||||||
let name: String
|
let name: String
|
||||||
var diameter: CGFloat = 26
|
var diameter: CGFloat = 26
|
||||||
|
/// Das Bild des Kontos, falls eines hinterlegt ist.
|
||||||
|
var image: URL?
|
||||||
|
|
||||||
@Environment(\.flaroAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
buchstabe
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.background(accent, in: .circle)
|
||||||
|
.overlay {
|
||||||
|
if let image {
|
||||||
|
AsyncImage(url: image) { bild in
|
||||||
|
bild.resizable().scaledToFill()
|
||||||
|
} placeholder: {
|
||||||
|
// Nichts: darunter liegt der Buchstabe und füllt die
|
||||||
|
// Lücke, bis das Bild da ist.
|
||||||
|
Color.clear
|
||||||
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.clipShape(.circle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buchstabe: some View {
|
||||||
Text(name.prefix(1).uppercased())
|
Text(name.prefix(1).uppercased())
|
||||||
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
|
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
|
||||||
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
|
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
|
||||||
@@ -26,8 +48,6 @@ struct AccountAvatar: View {
|
|||||||
// Fehler.
|
// Fehler.
|
||||||
.font(.system(size: diameter * 0.46, weight: .semibold))
|
.font(.system(size: diameter * 0.46, weight: .semibold))
|
||||||
.foregroundStyle(Color.flaroCanvas)
|
.foregroundStyle(Color.flaroCanvas)
|
||||||
.frame(width: diameter, height: diameter)
|
|
||||||
.background(accent, in: .circle)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +73,8 @@ struct AccountToolbarButton: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: openSettings) {
|
Button(action: openSettings) {
|
||||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
|
||||||
|
image: session.accountAvatarURL)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(Text("sidebar.settings"))
|
.accessibilityLabel(Text("sidebar.settings"))
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ struct AccountBar: View {
|
|||||||
isExpanded.toggle()
|
isExpanded.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: FlaroSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
AccountAvatar(name: title)
|
AccountAvatar(name: title, image: session.accountAvatarURL)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text(title)
|
Text(title)
|
||||||
|
|||||||
@@ -514,7 +514,8 @@ struct RootView: View {
|
|||||||
Button {
|
Button {
|
||||||
showsSettings = true
|
showsSettings = true
|
||||||
} label: {
|
} label: {
|
||||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
|
||||||
|
image: session.accountAvatarURL)
|
||||||
.padding(FlaroSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
// Damit er auch über einem hellen Aufmacher steht.
|
// Damit er auch über einem hellen Aufmacher steht.
|
||||||
.background(.black.opacity(0.28), in: .circle)
|
.background(.black.opacity(0.28), in: .circle)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct TVHeroSection: View {
|
|||||||
|
|
||||||
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde.
|
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde.
|
||||||
private var abspielTitel: LocalizedStringKey {
|
private var abspielTitel: LocalizedStringKey {
|
||||||
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
item.isResumable ? "hero.resume" : "hero.play"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var metadaten: String? {
|
private var metadaten: String? {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ struct TVMediaRow: View {
|
|||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: session.backend?.artworkURL(
|
||||||
path: item.rowArtworkPath, width: 880, height: 496
|
path: item.rowArtworkPath, width: 880, height: 496
|
||||||
),
|
),
|
||||||
progress: item.watchProgress,
|
progress: item.resumeProgress,
|
||||||
onFocusChange: { if $0 { onFocus(item) } }
|
onFocusChange: { if $0 { onFocus(item) } }
|
||||||
) { onSelect(item) }
|
) { onSelect(item) }
|
||||||
} else {
|
} else {
|
||||||
@@ -65,7 +65,7 @@ struct TVMediaRow: View {
|
|||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: session.backend?.artworkURL(
|
||||||
path: item.thumb, width: 560, height: 840
|
path: item.thumb, width: 560, height: 840
|
||||||
),
|
),
|
||||||
progress: item.watchProgress,
|
progress: item.resumeProgress,
|
||||||
onFocusChange: { if $0 { onFocus(item) } }
|
onFocusChange: { if $0 { onFocus(item) } }
|
||||||
) { onSelect(item) }
|
) { onSelect(item) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ struct TVLibraryView: View {
|
|||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: session.backend?.artworkURL(
|
||||||
path: item.posterArtworkPath, width: 560, height: 840
|
path: item.posterArtworkPath, width: 560, height: 840
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
) {
|
) {
|
||||||
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
||||||
// Raster eine Serie, hat die keine Fassungen; der
|
// Raster eine Serie, hat die keine Fassungen; der
|
||||||
|
|||||||
@@ -54,13 +54,12 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
|
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
|
||||||
// **Vier Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der eine
|
// **Mehrere Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der
|
||||||
// fertige Startseite liefert, und das ist Absicht: welche Reihen eine
|
// eine fertige Startseite liefert, und das ist Absicht: welche Reihen
|
||||||
// App zeigt, entscheidet die App. Der Server liefert die Zutaten.
|
// eine App zeigt, entscheidet die App. Der Server liefert die Zutaten.
|
||||||
async let weiter = reihe(pfad: "/api/v1/home/continue")
|
async let weiter = reihe(pfad: "/api/v1/home/continue")
|
||||||
async let naechste = reihe(pfad: "/api/v1/home/next")
|
async let naechste = reihe(pfad: "/api/v1/home/next")
|
||||||
async let filme = seite(pfad: "/api/v1/movies", take: 24, as: Wire.Movie.self)
|
let mediatheken = (try? await libraries()) ?? []
|
||||||
async let serien = seite(pfad: "/api/v1/series", take: 24, as: Wire.Series.self)
|
|
||||||
|
|
||||||
var reihen: [MediaRowGroup] = []
|
var reihen: [MediaRowGroup] = []
|
||||||
|
|
||||||
@@ -76,18 +75,46 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
title: String(localized: "flaro.row.nextUp", bundle: .module),
|
title: String(localized: "flaro.row.nextUp", bundle: .module),
|
||||||
items: eintraege))
|
items: eintraege))
|
||||||
}
|
}
|
||||||
if let gefunden = try? await filme, !gefunden.items.isEmpty {
|
|
||||||
|
// **Je Mediathek eine Reihe mit dem zuletzt Hinzugefügten.**
|
||||||
|
//
|
||||||
|
// Vorher standen hier zwei Reihen, „Filme" und „Serien", mit dem
|
||||||
|
// Anfang der Mediathek in der Reihenfolge der Datenbank — also immer
|
||||||
|
// demselben. Drei Dinge waren damit falsch: es war kein Neuzugang,
|
||||||
|
// es warf getrennte Mediatheken wieder zusammen, und die Kennungen
|
||||||
|
// begannen nicht mit `latest`. An dieser Vorsilbe hängen zwei
|
||||||
|
// Einstellungen — „Gesehenes in Neuzugängen ausblenden" und
|
||||||
|
// „Aufmacher nur aus Neuzugängen" —, und beide taten an einem
|
||||||
|
// Flaro-Server deshalb nichts.
|
||||||
|
for mediathek in mediatheken {
|
||||||
|
let pfad = mediathek.kind == .show ? "/api/v1/series" : "/api/v1/movies"
|
||||||
|
let abfrage = [
|
||||||
|
URLQueryItem(name: "library", value: mediathek.id),
|
||||||
|
URLQueryItem(name: "sort", value: "added"),
|
||||||
|
URLQueryItem(name: "take", value: "24"),
|
||||||
|
]
|
||||||
|
let titel: [MediaItem]
|
||||||
|
if mediathek.kind == .show {
|
||||||
|
guard let seite = try? await client.send(
|
||||||
|
FlaroRequest(path: pfad, query: abfrage),
|
||||||
|
as: Wire.Page<Wire.Series>.self) else { continue }
|
||||||
|
titel = seite.items.map { $0.neutral() }
|
||||||
|
} else {
|
||||||
|
guard let seite = try? await client.send(
|
||||||
|
FlaroRequest(path: pfad, query: abfrage),
|
||||||
|
as: Wire.Page<Wire.Movie>.self) else { continue }
|
||||||
|
titel = seite.items.map { $0.neutral() }
|
||||||
|
}
|
||||||
|
guard !titel.isEmpty else { continue }
|
||||||
reihen.append(MediaRowGroup(
|
reihen.append(MediaRowGroup(
|
||||||
id: "flaro.movies",
|
id: "latest.\(mediathek.id)",
|
||||||
title: String(localized: "flaro.row.movies", bundle: .module),
|
title: String(
|
||||||
items: gefunden.items.map { $0.neutral() }))
|
localized: "flaro.row.latestIn \(mediathek.title)",
|
||||||
}
|
bundle: .module),
|
||||||
if let gefunden = try? await serien, !gefunden.items.isEmpty {
|
libraryID: mediathek.id,
|
||||||
reihen.append(MediaRowGroup(
|
items: titel))
|
||||||
id: "flaro.series",
|
|
||||||
title: String(localized: "flaro.row.series", bundle: .module),
|
|
||||||
items: gefunden.items.map { $0.neutral() }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return reihen
|
return reihen
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +162,21 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
|
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
|
||||||
return serie.neutral()
|
return serie.neutral()
|
||||||
}
|
}
|
||||||
|
// **Und zuletzt eine Folge.**
|
||||||
|
//
|
||||||
|
// Sie fehlte, und das war der Grund für „Dieser Titel ist auf dem
|
||||||
|
// Server nicht mehr vorhanden" direkt von der Startseite: in
|
||||||
|
// „Weiterschauen" steht eine Folge mit ihrer eigenen Kennung, und die
|
||||||
|
// ist weder ein Film noch eine Serie.
|
||||||
|
if let folge = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/episodes/\(id)"), as: Wire.Episode.self) {
|
||||||
|
var serie: Wire.Series?
|
||||||
|
if let kennung = folge.seriesId {
|
||||||
|
serie = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series/\(kennung)"), as: Wire.Series.self)
|
||||||
|
}
|
||||||
|
return folge.neutral(series: serie)
|
||||||
|
}
|
||||||
throw .notFound
|
throw .notFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,17 +443,26 @@ public struct FlaroAccount: Sendable, Equatable {
|
|||||||
public let userName: String
|
public let userName: String
|
||||||
public let isAdministrator: Bool
|
public let isAdministrator: Bool
|
||||||
|
|
||||||
|
/// Das eigene Bild, als Schlüssel in die Bildablage — `nil`: der Buchstabe.
|
||||||
|
///
|
||||||
|
/// **Mit Vorgabewert, damit Jellyfin unberührt bleibt.** Dort gibt es
|
||||||
|
/// kein Profilbild, und ein Pflichtfeld hier zwänge jeden Aufrufer, sich
|
||||||
|
/// dazu zu äussern.
|
||||||
|
public let avatarImage: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
baseURL: URL,
|
baseURL: URL,
|
||||||
serverName: String,
|
serverName: String,
|
||||||
userID: String,
|
userID: String,
|
||||||
userName: String,
|
userName: String,
|
||||||
isAdministrator: Bool
|
isAdministrator: Bool,
|
||||||
|
avatarImage: String? = nil
|
||||||
) {
|
) {
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
self.serverName = serverName
|
self.serverName = serverName
|
||||||
self.userID = userID
|
self.userID = userID
|
||||||
self.userName = userName
|
self.userName = userName
|
||||||
self.isAdministrator = isAdministrator
|
self.isAdministrator = isAdministrator
|
||||||
|
self.avatarImage = avatarImage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ enum Wire {
|
|||||||
let id: String
|
let id: String
|
||||||
let userName: String
|
let userName: String
|
||||||
let isAdministrator: Bool
|
let isAdministrator: Bool
|
||||||
|
/// Das Profilbild, als Schlüssel in die Bildablage des Servers.
|
||||||
|
let avatarImage: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Library: Decodable, Sendable {
|
struct Library: Decodable, Sendable {
|
||||||
@@ -78,6 +80,12 @@ enum Wire {
|
|||||||
let rating: Int?
|
let rating: Int?
|
||||||
let dismissed: Bool
|
let dismissed: Bool
|
||||||
let lastPlayedAt: Date?
|
let lastPlayedAt: Date?
|
||||||
|
/// Ob der Server den Titel in „Weiterschauen" führt.
|
||||||
|
///
|
||||||
|
/// **Nicht dasselbe wie „Stelle über null".** Seit es eine
|
||||||
|
/// einstellbare Untergrenze gibt, sind das zwei Fragen: eine Sekunde
|
||||||
|
/// wird gemerkt, zählt aber nicht als angefangen.
|
||||||
|
let inProgress: Bool?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Movie: Decodable, Sendable {
|
struct Movie: Decodable, Sendable {
|
||||||
@@ -89,6 +97,8 @@ enum Wire {
|
|||||||
let backdropImage: String?
|
let backdropImage: String?
|
||||||
let files: [File]
|
let files: [File]
|
||||||
let state: ItemState?
|
let state: ItemState?
|
||||||
|
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
|
||||||
|
let publishAt: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Series: Decodable, Sendable {
|
struct Series: Decodable, Sendable {
|
||||||
@@ -101,6 +111,7 @@ enum Wire {
|
|||||||
let episodeCount: Int
|
let episodeCount: Int
|
||||||
let seasons: [Int]
|
let seasons: [Int]
|
||||||
let unwatchedCount: Int
|
let unwatchedCount: Int
|
||||||
|
let publishAt: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Episode: Decodable, Sendable {
|
struct Episode: Decodable, Sendable {
|
||||||
@@ -112,6 +123,10 @@ enum Wire {
|
|||||||
let stillImage: String?
|
let stillImage: String?
|
||||||
let files: [File]
|
let files: [File]
|
||||||
let state: ItemState?
|
let state: ItemState?
|
||||||
|
let publishAt: Date?
|
||||||
|
/// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich
|
||||||
|
/// nicht darstellbar: Titel und Bild der Serie fehlten.
|
||||||
|
let seriesId: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
|
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
|
||||||
@@ -126,6 +141,12 @@ enum Wire {
|
|||||||
let posterImage: String?
|
let posterImage: String?
|
||||||
let backdropImage: String?
|
let backdropImage: String?
|
||||||
let state: ItemState
|
let state: ItemState
|
||||||
|
/// Zu welcher Serie die Folge gehört.
|
||||||
|
let seriesId: String?
|
||||||
|
/// Das breite Bild der Serie — für den Aufmacher, nicht für die
|
||||||
|
/// Kachel: die will das Standbild der Folge.
|
||||||
|
let seriesBackdropImage: String?
|
||||||
|
let publishAt: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CodeResponse: Decodable, Sendable {
|
struct CodeResponse: Decodable, Sendable {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ extension Wire.Movie {
|
|||||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
lastPlayedAt: state?.lastPlayedAt,
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
isFavourite: state?.favourite ?? false,
|
isFavourite: state?.favourite ?? false,
|
||||||
|
isInProgress: state?.inProgress,
|
||||||
|
availableFrom: publishAt,
|
||||||
versions: [Wire.fassung(id: id, files: files)]
|
versions: [Wire.fassung(id: id, files: files)]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -39,6 +41,7 @@ extension Wire.Series {
|
|||||||
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
||||||
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
||||||
// zu derselben Aussage zu haben.
|
// zu derselben Aussage zu haben.
|
||||||
|
availableFrom: publishAt,
|
||||||
episodeCount: episodeCount,
|
episodeCount: episodeCount,
|
||||||
playedEpisodeCount: episodeCount - unwatchedCount,
|
playedEpisodeCount: episodeCount - unwatchedCount,
|
||||||
childCount: seasons.count
|
childCount: seasons.count
|
||||||
@@ -60,11 +63,15 @@ extension Wire.Episode {
|
|||||||
index: episode,
|
index: episode,
|
||||||
parentIndex: season,
|
parentIndex: season,
|
||||||
seriesTitle: series?.title,
|
seriesTitle: series?.title,
|
||||||
seriesID: series?.id,
|
// Die Serie aus dem Vertrag, wenn sie nicht mitgegeben wurde:
|
||||||
|
// ohne sie führte der Weg zurück zur Serie ins Leere.
|
||||||
|
seriesID: series?.id ?? seriesId,
|
||||||
playCount: state?.playCount,
|
playCount: state?.playCount,
|
||||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
lastPlayedAt: state?.lastPlayedAt,
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
isFavourite: state?.favourite ?? false,
|
isFavourite: state?.favourite ?? false,
|
||||||
|
isInProgress: state?.inProgress,
|
||||||
|
availableFrom: publishAt,
|
||||||
versions: [Wire.fassung(id: id, files: files)]
|
versions: [Wire.fassung(id: id, files: files)]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -85,15 +92,22 @@ extension Wire.Started {
|
|||||||
year: year,
|
year: year,
|
||||||
duration: state.runtimeSeconds.map { Int($0 * 1000) },
|
duration: state.runtimeSeconds.map { Int($0 * 1000) },
|
||||||
thumb: folge ? backdropImage : posterImage,
|
thumb: folge ? backdropImage : posterImage,
|
||||||
art: backdropImage,
|
// **Für die Fläche über den halben Bildschirm das Bild der
|
||||||
|
// Serie.** `backdropImage` ist bei einer Folge ihr Standbild —
|
||||||
|
// richtig für die Kachel, falsch für den Aufmacher: wer der
|
||||||
|
// Serie ein Bild ausgesucht hatte, fand dort ein anderes vor.
|
||||||
|
art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage,
|
||||||
seriesThumb: folge ? posterImage : nil,
|
seriesThumb: folge ? posterImage : nil,
|
||||||
index: episode,
|
index: episode,
|
||||||
parentIndex: season,
|
parentIndex: season,
|
||||||
seriesTitle: folge ? title : nil,
|
seriesTitle: folge ? title : nil,
|
||||||
|
seriesID: folge ? seriesId : nil,
|
||||||
playCount: state.playCount,
|
playCount: state.playCount,
|
||||||
resumeMilliseconds: Int(state.positionSeconds * 1000),
|
resumeMilliseconds: Int(state.positionSeconds * 1000),
|
||||||
lastPlayedAt: state.lastPlayedAt,
|
lastPlayedAt: state.lastPlayedAt,
|
||||||
isFavourite: state.favourite
|
isFavourite: state.favourite,
|
||||||
|
isInProgress: state.inProgress,
|
||||||
|
availableFrom: publishAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ public enum FlaroSignIn {
|
|||||||
serverName: gruss.serverName,
|
serverName: gruss.serverName,
|
||||||
userID: antwort.user.id,
|
userID: antwort.user.id,
|
||||||
userName: antwort.user.userName,
|
userName: antwort.user.userName,
|
||||||
isAdministrator: antwort.user.isAdministrator), client)
|
isAdministrator: antwort.user.isAdministrator,
|
||||||
|
avatarImage: antwort.user.avatarImage), client)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nimmt ein gespeichertes Token wieder auf.
|
/// Nimmt ein gespeichertes Token wieder auf.
|
||||||
@@ -111,7 +112,8 @@ public enum FlaroSignIn {
|
|||||||
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||||
userID: wer.id,
|
userID: wer.id,
|
||||||
userName: wer.userName,
|
userName: wer.userName,
|
||||||
isAdministrator: wer.isAdministrator), client)
|
isAdministrator: wer.isAdministrator,
|
||||||
|
avatarImage: wer.avatarImage), client)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Der Weg über einen Code
|
// MARK: - Der Weg über einen Code
|
||||||
@@ -179,7 +181,8 @@ public enum FlaroSignIn {
|
|||||||
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||||
userID: anmeldung.user.id,
|
userID: anmeldung.user.id,
|
||||||
userName: anmeldung.user.userName,
|
userName: anmeldung.user.userName,
|
||||||
isAdministrator: anmeldung.user.isAdministrator)), client)
|
isAdministrator: anmeldung.user.isAdministrator,
|
||||||
|
avatarImage: anmeldung.user.avatarImage)), client)
|
||||||
case "expired":
|
case "expired":
|
||||||
return (.expired, nil)
|
return (.expired, nil)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -35,6 +35,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"flaro.row.latestIn %@": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Neu in %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "New in %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"flaro.row.movies": {
|
"flaro.row.movies": {
|
||||||
"extractionState": "manual",
|
"extractionState": "manual",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|||||||
@@ -71,6 +71,26 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
/// eine Fähigkeit, die es nur auf einem gibt.
|
/// eine Fähigkeit, die es nur auf einem gibt.
|
||||||
public let isFavourite: Bool
|
public let isFavourite: Bool
|
||||||
|
|
||||||
|
/// Ob der Server den Titel als angefangen führt.
|
||||||
|
///
|
||||||
|
/// **Die Entscheidung gehört dem Server, nicht der Beschriftung.** „Hat
|
||||||
|
/// eine Stelle über null" und „ist angefangen" waren dasselbe, bis es eine
|
||||||
|
/// einstellbare Untergrenze gab. Seitdem heisst ein Titel, in den jemand
|
||||||
|
/// eine Sekunde hineingeschaut hat, nicht mehr „Weiterschauen" — er steht
|
||||||
|
/// ja auch nicht mehr in der Reihe.
|
||||||
|
///
|
||||||
|
/// `nil` heisst: der Server sagt nichts dazu (Jellyfin, oder ein älterer
|
||||||
|
/// Flaro). Dann gilt wie früher die blosse Stelle.
|
||||||
|
public let isInProgress: Bool?
|
||||||
|
|
||||||
|
/// Ab wann es den Titel gibt — `nil` heisst: jetzt.
|
||||||
|
///
|
||||||
|
/// **Ein geplanter Titel steht in der Mediathek, läuft aber nicht.** Das
|
||||||
|
/// ist der Sinn des Planens: man soll sehen, dass er kommt. Ohne dieses
|
||||||
|
/// Feld bot die App „Abspielen" an und bekam vom Server eine Absage —
|
||||||
|
/// ein Knopf, der ein Versprechen gibt und es zurücknimmt.
|
||||||
|
public let availableFrom: Date?
|
||||||
|
|
||||||
// Zählwerte bei Serien und Staffeln.
|
// Zählwerte bei Serien und Staffeln.
|
||||||
public let episodeCount: Int?
|
public let episodeCount: Int?
|
||||||
public let playedEpisodeCount: Int?
|
public let playedEpisodeCount: Int?
|
||||||
@@ -117,6 +137,8 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
lastPlayedAt: Date? = nil,
|
lastPlayedAt: Date? = nil,
|
||||||
addedAt: Date? = nil,
|
addedAt: Date? = nil,
|
||||||
isFavourite: Bool = false,
|
isFavourite: Bool = false,
|
||||||
|
isInProgress: Bool? = nil,
|
||||||
|
availableFrom: Date? = nil,
|
||||||
episodeCount: Int? = nil,
|
episodeCount: Int? = nil,
|
||||||
playedEpisodeCount: Int? = nil,
|
playedEpisodeCount: Int? = nil,
|
||||||
childCount: Int? = nil,
|
childCount: Int? = nil,
|
||||||
@@ -155,6 +177,8 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
self.lastPlayedAt = lastPlayedAt
|
self.lastPlayedAt = lastPlayedAt
|
||||||
self.addedAt = addedAt
|
self.addedAt = addedAt
|
||||||
self.isFavourite = isFavourite
|
self.isFavourite = isFavourite
|
||||||
|
self.isInProgress = isInProgress
|
||||||
|
self.availableFrom = availableFrom
|
||||||
self.episodeCount = episodeCount
|
self.episodeCount = episodeCount
|
||||||
self.playedEpisodeCount = playedEpisodeCount
|
self.playedEpisodeCount = playedEpisodeCount
|
||||||
self.childCount = childCount
|
self.childCount = childCount
|
||||||
@@ -225,6 +249,29 @@ extension MediaItem {
|
|||||||
|
|
||||||
public var isWatched: Bool { (playCount ?? 0) > 0 }
|
public var isWatched: Bool { (playCount ?? 0) > 0 }
|
||||||
|
|
||||||
|
/// Ob die Beschriftung „Weiterschauen" heissen soll.
|
||||||
|
///
|
||||||
|
/// Eine Stelle, die beide Fälle kennt: was der Server sagt, und was ohne
|
||||||
|
/// seine Auskunft gilt. Stünde der Zweifel an jedem Knopf einzeln, hiesse
|
||||||
|
/// derselbe Titel auf der Startseite anders als auf seiner Seite.
|
||||||
|
public var isResumable: Bool { isInProgress ?? (watchProgress != nil) }
|
||||||
|
|
||||||
|
/// Der Balken unter der Kachel — nur, wenn der Titel wirklich angefangen ist.
|
||||||
|
///
|
||||||
|
/// **Sonst sagt die Kachel etwas anderes als der Knopf.** Ein Strich unter
|
||||||
|
/// dem Plakat heisst „du bist mittendrin". Für eine Sekunde, in die jemand
|
||||||
|
/// hineingeschaut hat, ist das keine Auskunft, sondern ein Fussel.
|
||||||
|
public var resumeProgress: Double? { isResumable ? watchProgress : nil }
|
||||||
|
|
||||||
|
/// Ob der Titel erst noch kommt.
|
||||||
|
///
|
||||||
|
/// Gefragt wird an einer Stelle, beantwortet an einer: sonst steht an
|
||||||
|
/// einem Bildschirm „Abspielen" und am nächsten der Termin.
|
||||||
|
public var isUpcoming: Bool {
|
||||||
|
guard let availableFrom else { return false }
|
||||||
|
return availableFrom > Date()
|
||||||
|
}
|
||||||
|
|
||||||
/// Ab wo fortgesetzt wird.
|
/// Ab wo fortgesetzt wird.
|
||||||
public var resumeTime: Duration {
|
public var resumeTime: Duration {
|
||||||
.milliseconds(resumeMilliseconds ?? 0)
|
.milliseconds(resumeMilliseconds ?? 0)
|
||||||
|
|||||||
@@ -43,22 +43,106 @@ if ! grep -q "$REGISTRY" ~/.docker/config.json 2>/dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# **Vor dem Bauen, nicht danach.** Laufen App und Browser farblich
|
||||||
|
# auseinander, soll das auffallen, bevor ein Image daraus wird.
|
||||||
|
if command -v python3 >/dev/null && [ -f Tools/tokens-pruefen.py ]; then
|
||||||
|
python3 Tools/tokens-pruefen.py || {
|
||||||
|
echo
|
||||||
|
echo "Abgebrochen. Mit FLARO_DRIFT_EGAL=1 trotzdem bauen."
|
||||||
|
[ "${FLARO_DRIFT_EGAL:-}" = "1" ] || exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fragt die Registry, welcher Stand dort unter `latest` liegt.
|
||||||
|
#
|
||||||
|
# Anonym — das Paket ist öffentlich lesbar. Schlägt es fehl, ist die Antwort
|
||||||
|
# leer, und der Aufrufer entscheidet, was das bedeutet.
|
||||||
|
registry_digest() {
|
||||||
|
local token
|
||||||
|
token=$(curl -fsS --max-time 20 \
|
||||||
|
"https://$REGISTRY/v2/token?scope=repository:$PFAD:pull&service=container_registry" \
|
||||||
|
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("token",""))' 2>/dev/null) || return 1
|
||||||
|
[ -n "$token" ] || return 1
|
||||||
|
|
||||||
|
curl -fsS --max-time 20 -D- -o /dev/null \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H "Accept: application/vnd.oci.image.index.v1+json" \
|
||||||
|
"https://$REGISTRY/v2/$PFAD/manifests/latest" 2>/dev/null \
|
||||||
|
| awk 'BEGIN{IGNORECASE=1} /^docker-content-digest:/ {print $2}' | tr -d '\r'
|
||||||
|
}
|
||||||
|
|
||||||
MARKEN=(-t "$BILD:latest")
|
MARKEN=(-t "$BILD:latest")
|
||||||
[ -n "$FASSUNG" ] && MARKEN+=(-t "$BILD:$FASSUNG")
|
[ -n "$FASSUNG" ] && MARKEN+=(-t "$BILD:$FASSUNG")
|
||||||
|
|
||||||
|
# **Eine Kennung je Bau.**
|
||||||
|
#
|
||||||
|
# Sechs Zeichen, jedes Mal neu — auch bei unverändertem Quelltext. Sie landet
|
||||||
|
# im Abbild und steht danach in der Oberfläche unter „Einstellungen" sowie
|
||||||
|
# unter `/health`. Damit ist die Frage „läuft auf dem NAS wirklich der Bau von
|
||||||
|
# eben?" in zwei Sekunden beantwortet statt in einem halben Tag.
|
||||||
|
#
|
||||||
|
# Ohne I, O, 0 und 1: diese Kennung wird vorgelesen und abgetippt.
|
||||||
|
#
|
||||||
|
# **Nicht `tr < /dev/urandom | head -c 6`.** `head` schliesst die Pipe nach
|
||||||
|
# sechs Zeichen, `tr` bekommt SIGPIPE, und mit `set -o pipefail` gilt das als
|
||||||
|
# Fehlschlag — das Skript brach mit 141 ab, bevor es irgendetwas gebaut hatte.
|
||||||
|
# Dieselbe Falle wie oben bei `grep -q`, und sie hat hier zweimal zugeschlagen.
|
||||||
|
BUILD=$(python3 -c "import secrets; print(''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(6)))")
|
||||||
|
echo "Build-Kennung dieses Laufs: $BUILD"
|
||||||
|
|
||||||
# **Ein eigener Builder.** Der Vorgabe-Builder von Docker Desktop kann kein
|
# **Ein eigener Builder.** Der Vorgabe-Builder von Docker Desktop kann kein
|
||||||
# Manifest mit zwei Architekturen bauen; `docker buildx create` schon.
|
# Manifest mit zwei Architekturen bauen; `docker buildx create` schon.
|
||||||
docker buildx inspect flaro-bauer >/dev/null 2>&1 \
|
docker buildx inspect flaro-bauer >/dev/null 2>&1 \
|
||||||
|| docker buildx create --name flaro-bauer --use >/dev/null
|
|| docker buildx create --name flaro-bauer --use >/dev/null
|
||||||
|
|
||||||
|
# **Den Digest festhalten, bevor gebaut wird.** Nur so lässt sich hinterher
|
||||||
|
# sagen, ob wirklich etwas Neues angekommen ist.
|
||||||
|
vorher=$(registry_digest || true)
|
||||||
|
|
||||||
echo "Baue $BILD für $PLATTFORMEN."
|
echo "Baue $BILD für $PLATTFORMEN."
|
||||||
docker buildx build \
|
if ! docker buildx build \
|
||||||
--builder flaro-bauer \
|
--builder flaro-bauer \
|
||||||
--platform "$PLATTFORMEN" \
|
--platform "$PLATTFORMEN" \
|
||||||
-f server/Dockerfile \
|
-f server/Dockerfile \
|
||||||
|
--build-arg "FLARO_BUILD=$BUILD" \
|
||||||
"${MARKEN[@]}" \
|
"${MARKEN[@]}" \
|
||||||
--push .
|
--push . ; then
|
||||||
|
echo
|
||||||
|
echo "Der Bau oder das Hochladen ist gescheitert. In der Registry liegt weiter:"
|
||||||
|
echo " ${vorher:-nichts}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# **Nachsehen, statt zu glauben.**
|
||||||
|
#
|
||||||
|
# `docker buildx build --push` hat bei einem abgebrochenen Upload schon mit
|
||||||
|
# Rückgabewert 0 beendet, nachdem es „ERROR: failed to build" gedruckt hatte —
|
||||||
|
# ein TLS-Zeitlimit mitten im Hochladen. Das Skript meldete Erfolg, in der
|
||||||
|
# Registry lag das alte Image, und auf dem NAS kam eine Fassung an, die
|
||||||
|
# niemand gebaut hatte. Seitdem wird gefragt, nicht vertraut.
|
||||||
|
nachher=$(registry_digest || true)
|
||||||
|
|
||||||
|
if [ -z "$nachher" ]; then
|
||||||
|
echo
|
||||||
|
echo "Die Registry antwortet nicht auf $BILD:latest — hochgeladen wurde offenbar nichts."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$vorher" ] && [ "$vorher" = "$nachher" ]; then
|
||||||
|
echo
|
||||||
|
echo "In der Registry liegt noch derselbe Stand wie vorher:"
|
||||||
|
echo " $nachher"
|
||||||
|
echo "Das Hochladen ist also nicht durchgekommen. Noch einmal versuchen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Fertig. Auf dem NAS:"
|
echo "Fertig. In der Registry liegt jetzt:"
|
||||||
|
echo " $nachher"
|
||||||
|
echo
|
||||||
|
echo "Build-Kennung: $BUILD"
|
||||||
|
echo " Dieselbe muss nach dem Ziehen unter Einstellungen stehen."
|
||||||
|
echo " Steht dort eine andere, läuft auf dem NAS ein älteres Abbild."
|
||||||
|
echo
|
||||||
|
echo "Auf dem NAS:"
|
||||||
echo " image: $BILD:${FASSUNG:-latest}"
|
echo " image: $BILD:${FASSUNG:-latest}"
|
||||||
|
|||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prüft, ob App und Weboberfläche dieselben Farben und Formen benutzen.
|
||||||
|
|
||||||
|
**Warum es das braucht.** Die Werte stehen an mehreren Stellen von Hand
|
||||||
|
abgeschrieben — in Swift für die Apps, in CSS für den Browser. Sie sind
|
||||||
|
auseinandergelaufen, und zwar unsichtbar: `#1D1D21` gegen `#1D1D22`,
|
||||||
|
`#A0A0A8` gegen `#B4B4BE`, Radius 8 gegen 6. Einzeln merkt das niemand.
|
||||||
|
Zusammen sorgen sie dafür, dass App und Browser nicht wie dasselbe Programm
|
||||||
|
aussehen.
|
||||||
|
|
||||||
|
**Warum kein Generator.** Einer müsste Swift erzeugen, und erzeugter Swift
|
||||||
|
lässt sich schlechter lesen und kommentieren als geschriebener. Diese Prüfung
|
||||||
|
kostet weniger und fängt dasselbe ab: sie sagt, dass etwas auseinanderläuft,
|
||||||
|
und überlässt die Entscheidung dem Menschen.
|
||||||
|
|
||||||
|
Aufruf: python3 Tools/tokens-pruefen.py
|
||||||
|
Rückgabe: 0, wenn alles übereinstimmt; sonst 1.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WURZEL = Path(__file__).resolve().parent.parent
|
||||||
|
SWIFT_FARBEN = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/Color/FlaroColor.swift"
|
||||||
|
SWIFT_AKZENT = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/Color/AccentOption.swift"
|
||||||
|
SWIFT_FORMEN = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/FlaroFont.swift"
|
||||||
|
CSS = WURZEL / "web/app/src/style.css"
|
||||||
|
|
||||||
|
# Was womit zusammengehört. Links der Swift-Name, rechts das CSS-Token.
|
||||||
|
FARBEN = {
|
||||||
|
"canvas": "--canvas",
|
||||||
|
"surface": "--surface",
|
||||||
|
"elevated": "--elevated",
|
||||||
|
"placeholder": "--placeholder",
|
||||||
|
"textPrimary": "--text",
|
||||||
|
"textSecondary": "--text-dim",
|
||||||
|
"textMuted": "--text-muted",
|
||||||
|
"destructive": "--destructive",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Radien: Swift-Name → CSS-Token. `sheet` und `panel` haben im Browser kein
|
||||||
|
# Gegenstück — dort gibt es keine Blätter und keine schwebende Seitenleiste.
|
||||||
|
RADIEN = {
|
||||||
|
"card": "--r-karte",
|
||||||
|
"control": "--r-flaeche",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hex_aus_swift(text: str, name: str) -> str | None:
|
||||||
|
"""Liest `static let name = RGB(hex: 0xAABBCC)` oder `RGB(0xAABBCC)`."""
|
||||||
|
m = re.search(rf"\b{name}\b[^\n=]*=\s*[^\n]*?0x([0-9A-Fa-f]{{6}})", text)
|
||||||
|
return "#" + m.group(1).upper() if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def ohne_tvos(text: str) -> str:
|
||||||
|
"""Entfernt die tvOS-Zweige.
|
||||||
|
|
||||||
|
**Sonst vergleicht man den falschen Wert.** `FlaroRadius.card` ist 8 —
|
||||||
|
und auf tvOS 12, weil aus drei Metern eine stärkere Rundung nötig ist.
|
||||||
|
Der Browser läuft nie auf einem Fernseher; massgeblich ist der andere Zweig.
|
||||||
|
"""
|
||||||
|
return re.sub(r"#if os\(tvOS\).*?(?:#else(.*?))?#endif", lambda m: m.group(1) or "",
|
||||||
|
text, flags=re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def zahl_aus_swift(text: str, name: str) -> float | None:
|
||||||
|
"""Liest den ersten Zahlenwert hinter `static let name`."""
|
||||||
|
m = re.search(rf"\b{name}\b\s*:\s*CGFloat\s*(?:\{{[^}}]*?return\s*)?([0-9]+(?:\.[0-9]+)?)", text, re.S)
|
||||||
|
if m:
|
||||||
|
return float(m.group(1))
|
||||||
|
m = re.search(rf"\b{name}\b[^\n=]*=\s*([0-9]+(?:\.[0-9]+)?)", text)
|
||||||
|
return float(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def token_aus_css(text: str, name: str) -> str | None:
|
||||||
|
m = re.search(rf"{re.escape(name)}\s*:\s*([^;]+);", text)
|
||||||
|
return m.group(1).strip() if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
fehlend = [p for p in (SWIFT_FARBEN, SWIFT_AKZENT, SWIFT_FORMEN, CSS) if not p.exists()]
|
||||||
|
if fehlend:
|
||||||
|
for p in fehlend:
|
||||||
|
print(f"Nicht gefunden: {p.relative_to(WURZEL)}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
farben_swift = SWIFT_FARBEN.read_text(encoding="utf-8")
|
||||||
|
akzent_swift = SWIFT_AKZENT.read_text(encoding="utf-8")
|
||||||
|
formen_swift = ohne_tvos(SWIFT_FORMEN.read_text(encoding="utf-8"))
|
||||||
|
css = CSS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
abweichungen: list[str] = []
|
||||||
|
geprueft = 0
|
||||||
|
|
||||||
|
for swift_name, css_name in FARBEN.items():
|
||||||
|
a = hex_aus_swift(farben_swift, swift_name)
|
||||||
|
b = token_aus_css(css, css_name)
|
||||||
|
if a is None or b is None:
|
||||||
|
abweichungen.append(f" {swift_name} / {css_name}: nicht gefunden ({a} / {b})")
|
||||||
|
continue
|
||||||
|
geprueft += 1
|
||||||
|
if a.upper() != b.upper():
|
||||||
|
abweichungen.append(f" {swift_name}: Swift {a}, CSS {css_name} {b}")
|
||||||
|
|
||||||
|
# Die Vorgabefarbe der App gegen den Akzent im Browser.
|
||||||
|
vorgabe = re.search(r'identifier\s*==\s*"([a-z]+)"', akzent_swift)
|
||||||
|
if not vorgabe:
|
||||||
|
vorgabe = re.search(r'\.first\s*\{\s*\$0\.id\s*==\s*"([a-z]+)"', akzent_swift)
|
||||||
|
if vorgabe:
|
||||||
|
hex_vorgabe = hex_aus_swift(akzent_swift, vorgabe.group(1))
|
||||||
|
akzent_css = token_aus_css(css, "--accent")
|
||||||
|
if hex_vorgabe and akzent_css:
|
||||||
|
geprueft += 1
|
||||||
|
if hex_vorgabe.upper() != akzent_css.upper():
|
||||||
|
abweichungen.append(
|
||||||
|
f" Vorgabe-Akzent: Swift {vorgabe.group(1)} {hex_vorgabe}, CSS {akzent_css}")
|
||||||
|
|
||||||
|
for swift_name, css_name in RADIEN.items():
|
||||||
|
a = zahl_aus_swift(formen_swift, swift_name)
|
||||||
|
b = token_aus_css(css, css_name)
|
||||||
|
if a is None or b is None:
|
||||||
|
abweichungen.append(f" Radius {swift_name} / {css_name}: nicht gefunden ({a} / {b})")
|
||||||
|
continue
|
||||||
|
geprueft += 1
|
||||||
|
b_zahl = float(re.sub(r"[^0-9.]", "", b) or 0)
|
||||||
|
if abs(a - b_zahl) > 0.01:
|
||||||
|
abweichungen.append(f" Radius {swift_name}: Swift {a:g}, CSS {css_name} {b}")
|
||||||
|
|
||||||
|
if abweichungen:
|
||||||
|
print(f"App und Weboberfläche laufen auseinander ({len(abweichungen)} Stelle(n)):")
|
||||||
|
print("\n".join(abweichungen))
|
||||||
|
print("\nFlaroDesign ist massgeblich — die CSS zieht nach.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"App und Weboberfläche stimmen überein ({geprueft} Werte geprüft).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+59
-31
@@ -42,41 +42,36 @@ Ein Mac baut ohne Angabe **arm64** — also gehört die Angabe dazu.
|
|||||||
|
|
||||||
## Schritt 2: Das Image auf das NAS bringen
|
## Schritt 2: Das Image auf das NAS bringen
|
||||||
|
|
||||||
Drei Wege. Der erste ist der bequemste, der zweite braucht keine Registry.
|
**Das NAS baut nicht, es lädt nur.** Genau wie bei Jellyfin oder jedem
|
||||||
|
anderen Container: dort baut jemand anderes und veröffentlicht, du ziehst
|
||||||
|
das Ergebnis. Bei Flaro ist dieser Jemand dein Arbeitsrechner — er ist für
|
||||||
|
die Apps ohnehin nötig, weil Xcode nirgendwo sonst läuft.
|
||||||
|
|
||||||
|
Drei Wege. Der erste ist der vorgesehene; der zweite braucht keine Registry.
|
||||||
|
|
||||||
### Weg A — über die Registry deines Gitea (empfohlen)
|
### Weg A — über die Registry deines Gitea (empfohlen)
|
||||||
|
|
||||||
Dein Gitea unter `git.scarriffle.com` bringt eine Container-Registry mit.
|
Dein Gitea unter `git.scarriffle.com` bringt eine Container-Registry mit.
|
||||||
Einmal einrichten, danach ist jedes Update ein `docker compose pull`.
|
Einmal einrichten, danach ist jedes Update ein `docker compose pull`.
|
||||||
|
|
||||||
#### A1 · Gitea baut selbst
|
#### A1 · Anmelden, einmalig
|
||||||
|
|
||||||
`.gitea/workflows/image.yml` liegt im Projekt. Bei jedem Push auf `main`
|
Nicht mit dem Kontopasswort, sondern mit einem Zugriffstoken: *Gitea →
|
||||||
baut Gitea das Image für **beide** Architekturen und legt es ab — ohne dass
|
Einstellungen → Anwendungen → Zugriffstoken*, Berechtigungen
|
||||||
jemand daran denken muss, und ohne Anmeldung am Arbeitsrechner.
|
`write:package` und `read:package`. Ein Token lässt sich einzeln widerrufen,
|
||||||
|
und bei eingeschalteter Zwei-Faktor-Anmeldung funktioniert das Passwort hier
|
||||||
**Voraussetzung ist ein registrierter Act-Runner.** Gitea verteilt Arbeit
|
gar nicht.
|
||||||
nur, es baut nicht selbst: ohne Runner legt es bei jedem Push einen Lauf an,
|
|
||||||
der dann liegenbleibt. Nachsehen unter
|
|
||||||
*Repo → Einstellungen → Actions → Runner*.
|
|
||||||
|
|
||||||
Der Runner gehört **auf das NAS** — dort läuft Docker ohnehin, und damit
|
|
||||||
braucht es für ein neues Image nie wieder einen Arbeitsrechner. Fertig
|
|
||||||
vorbereitet liegt er in `server/docker-compose.runner.yml`; hineingehört nur
|
|
||||||
das Registrierungstoken aus derselben Gitea-Seite.
|
|
||||||
|
|
||||||
Danach erscheint der Runner in der Liste, und der wartende Lauf startet —
|
|
||||||
oder spätestens der nächste Push.
|
|
||||||
|
|
||||||
#### A2 · Von Hand vom Arbeitsrechner
|
|
||||||
|
|
||||||
Einmalig anmelden — das Passwort tippst du selbst ein:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker login git.scarriffle.com
|
docker login git.scarriffle.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach genügt ein Befehl; er baut amd64 **und** arm64 und lädt hoch:
|
Benutzername ist dein Gitea-Name, als Passwort das Token.
|
||||||
|
|
||||||
|
#### A2 · Bauen und hochladen
|
||||||
|
|
||||||
|
Ein Befehl; er baut amd64 **und** arm64 und lädt beides als ein Manifest
|
||||||
|
hoch:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
Tools/image-veroeffentlichen.sh
|
Tools/image-veroeffentlichen.sh
|
||||||
@@ -88,6 +83,13 @@ Mit einer Fassungsnummer, wenn du sie festnageln willst:
|
|||||||
Tools/image-veroeffentlichen.sh 0.2.0
|
Tools/image-veroeffentlichen.sh 0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Nur die Architektur des NAS — deutlich schneller, weil die fremde sonst
|
||||||
|
unter Emulation gebaut wird:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FLARO_PLATTFORMEN=linux/amd64 Tools/image-veroeffentlichen.sh
|
||||||
|
```
|
||||||
|
|
||||||
#### A3 · Das Paket öffentlich stellen
|
#### A3 · Das Paket öffentlich stellen
|
||||||
|
|
||||||
Sonst muss sich auch das NAS anmelden. In Gitea unter
|
Sonst muss sich auch das NAS anmelden. In Gitea unter
|
||||||
@@ -108,9 +110,15 @@ kommt:
|
|||||||
|
|
||||||
**Bestätigen** — danach findet die Suche in der Docker-App auch
|
**Bestätigen** — danach findet die Suche in der Docker-App auch
|
||||||
`scarriffle/flaro`. Ist das Paket privat, trägst du unter
|
`scarriffle/flaro`. Ist das Paket privat, trägst du unter
|
||||||
*Registierungseinstellungen* Benutzername und ein Gitea-Zugriffstoken ein
|
*Registierungseinstellungen* Benutzername und dasselbe Zugriffstoken ein.
|
||||||
(nicht dein Kontopasswort: ein Token lässt sich einzeln widerrufen, unter
|
|
||||||
*Einstellungen → Anwendungen → Zugriffstoken*, Berechtigung `read:package`).
|
#### A5 · Das Image herunterladen, bevor du das Projekt anlegst
|
||||||
|
|
||||||
|
**Die Docker-App zieht Images nicht selbst.** Steht in einer Compose-Datei
|
||||||
|
ein Image, das lokal fehlt, bricht sie das Anlegen mit „Bitte überprüfen Sie
|
||||||
|
die Konfigurationsinformationen" ab — ohne zu sagen, woran es liegt. Also
|
||||||
|
vorher **Image → Herunterladen**, Registry `gitea`, Quellname
|
||||||
|
`scarriffle/flaro:latest`.
|
||||||
|
|
||||||
In der Compose-Datei steht dann:
|
In der Compose-Datei steht dann:
|
||||||
|
|
||||||
@@ -141,11 +149,23 @@ docker load < /volume1/docker/flaro.tar.gz
|
|||||||
|
|
||||||
In der Compose-Datei steht dann schlicht `image: flaro:latest`.
|
In der Compose-Datei steht dann schlicht `image: flaro:latest`.
|
||||||
|
|
||||||
### Weg C — auf dem NAS bauen
|
### Weg C — Gitea baut selbst
|
||||||
|
|
||||||
Geht, lohnt sich aber selten: der Bau braucht den Quelltext, Node, das
|
`.gitea/workflows/image.yml` liegt im Projekt und kann denselben Bau auf
|
||||||
.NET-SDK als Zwischenschritt und ordentlich Arbeitsspeicher. Die Docker-App
|
einem Act-Runner ausführen. **Gitea verteilt Arbeit nur, es baut nicht
|
||||||
von UGREEN kann `build:` in einer Compose-Datei ausserdem nicht immer.
|
selbst** — ohne registrierten Runner passiert nichts.
|
||||||
|
|
||||||
|
Der Runner müsste auf dem NAS laufen, und daran scheitert es dort: er
|
||||||
|
braucht Zugriff auf Docker, denn sein Zweck ist das Bauen von Images. Die
|
||||||
|
Docker-App des UGREEN lehnt den Einhängepunkt `/var/run/docker.sock` ab —
|
||||||
|
geprüft, indem die Compose-Datei Zeile für Zeile aufgebaut wurde: ohne diese
|
||||||
|
eine Zeile wird das Projekt angelegt, mit ihr nicht. Übrig bleiben
|
||||||
|
`privileged: true` mit einem Docker-in-Docker-Image oder der Weg über SSH an
|
||||||
|
der Oberfläche vorbei. Beide geben dem Runner faktisch Root auf dem NAS.
|
||||||
|
|
||||||
|
Deshalb löst der Workflow **nicht** mehr bei jedem Push aus — sonst sammeln
|
||||||
|
sich Läufe an, die nie jemand abarbeitet. Er steht auf `workflow_dispatch`
|
||||||
|
und lässt sich in Gitea von Hand starten, sobald einmal ein Runner da ist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -253,7 +273,15 @@ keine Stelle, die das könnte.
|
|||||||
|
|
||||||
## Aktualisieren
|
## Aktualisieren
|
||||||
|
|
||||||
Mit Weg A:
|
**Zwei Schritte, zwei Maschinen.** Erst baut der Arbeitsrechner die neue
|
||||||
|
Fassung und legt sie in die Registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Tools/image-veroeffentlichen.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann holt das NAS sie. In der Docker-App genügt das Aktualisieren des
|
||||||
|
Projekts; über die Kommandozeile:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull && docker compose up -d
|
docker compose pull && docker compose up -d
|
||||||
|
|||||||
+35
-2
@@ -48,7 +48,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
ffmpeg libva2 libva-drm2 mesa-va-drivers \
|
ffmpeg libva2 libva-drm2 mesa-va-drivers curl \
|
||||||
&& if [ "$TARGETARCH" = "amd64" ]; then \
|
&& if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \
|
apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \
|
||||||
fi \
|
fi \
|
||||||
@@ -67,17 +67,50 @@ RUN mkdir -p /config /cache /transcode && chown -R 1000:1000 /config /cache /tra
|
|||||||
# braucht weder ein Startskript noch root im Container.
|
# braucht weder ein Startskript noch root im Container.
|
||||||
USER 1000:1000
|
USER 1000:1000
|
||||||
|
|
||||||
|
# **Kein eigenes ASPNETCORE_URLS.** Das Basisimage setzt seit .NET 8 selbst
|
||||||
|
# ASPNETCORE_HTTP_PORTS=8080; beides zusammen ergab bei jedem Start die
|
||||||
|
# Warnung „Overriding HTTP_PORTS '8080'". Zwei Wege zum selben Port sind
|
||||||
|
# einer zu viel.
|
||||||
ENV FLARO_DATA=/config \
|
ENV FLARO_DATA=/config \
|
||||||
FLARO_CACHE=/cache \
|
FLARO_CACHE=/cache \
|
||||||
FLARO_TRANSCODE=/transcode \
|
FLARO_TRANSCODE=/transcode \
|
||||||
ASPNETCORE_URLS=http://+:8080 \
|
|
||||||
DOTNET_gcServer=0
|
DOTNET_gcServer=0
|
||||||
|
|
||||||
|
# **Welches Abbild das hier ist.**
|
||||||
|
#
|
||||||
|
# Sechs Zeichen, bei jedem Bau neu gewürfelt — auch dann, wenn am Quelltext
|
||||||
|
# nichts anders ist. Die Version sagt, welcher Stand gemeint ist; diese
|
||||||
|
# Kennung sagt, welches Abbild wirklich läuft.
|
||||||
|
#
|
||||||
|
# Das ist keine Spielerei: zwei Funktionen standen nachweislich im Quelltext
|
||||||
|
# und waren auf dem Server trotzdem nicht zu sehen. Ob dort ein älteres
|
||||||
|
# Abbild lief, war nicht feststellbar, und die Fehlersuche lief einen halben
|
||||||
|
# Tag ins Leere. Seitdem beantwortet ein Blick die erste aller Fragen.
|
||||||
|
#
|
||||||
|
# Ganz unten, weil jede Änderung daran alle folgenden Schichten neu baut —
|
||||||
|
# und das sind hier nur noch die beiden COPY-Zeilen.
|
||||||
|
ARG FLARO_BUILD=DEV
|
||||||
|
ENV FLARO_BUILD=${FLARO_BUILD}
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app .
|
COPY --from=build /app .
|
||||||
COPY --from=web /web/dist ./wwwroot
|
COPY --from=web /web/dist ./wwwroot
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# **Eine Gesundheitsprüfung, die den Server fragt — nicht die Laufzeit.**
|
||||||
|
#
|
||||||
|
# Ohne sie weiss Docker nur, dass ein Prozess läuft. Die Oberfläche eines NAS
|
||||||
|
# zeigt dann minutenlang „wird bereitgestellt", während der Server längst
|
||||||
|
# antwortet, und niemand kann unterscheiden, ob er noch wandert oder hängt.
|
||||||
|
#
|
||||||
|
# `/health` und nicht `dotnet --info`: letzteres sagt nur, dass die Laufzeit
|
||||||
|
# da ist, und das war sie auch, als der Server noch migrierte.
|
||||||
|
#
|
||||||
|
# 60 Sekunden Anlaufzeit, weil die Migrationen beim ersten Start einer
|
||||||
|
# gefüllten Datenbank länger brauchen als ein Startvorgang sonst.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -fsS http://localhost:8080/health || exit 1
|
||||||
VOLUME ["/config", "/cache", "/transcode"]
|
VOLUME ["/config", "/cache", "/transcode"]
|
||||||
|
|
||||||
ENTRYPOINT ["dotnet", "Flaro.Api.dll"]
|
ENTRYPOINT ["dotnet", "Flaro.Api.dll"]
|
||||||
|
|||||||
@@ -31,6 +31,20 @@ services:
|
|||||||
# Datenbank oder ein zweiter Server finden Zuordnungen und Bilder wieder.
|
# Datenbank oder ein zweiter Server finden Zuordnungen und Bilder wieder.
|
||||||
- FLARO_WRITE_SIDECARS=true
|
- FLARO_WRITE_SIDECARS=true
|
||||||
|
|
||||||
|
# Womit das Bild gerechnet wird, wenn eine Wiedergabe umkodiert werden
|
||||||
|
# muss — weil der Browser den Codec nicht kennt oder eine kleinere
|
||||||
|
# Auflösung gewählt wurde.
|
||||||
|
#
|
||||||
|
# auto prüft, ob /dev/dri/renderD128 da ist, und nimmt sonst
|
||||||
|
# den Hauptprozessor. Das ist die Voreinstellung.
|
||||||
|
# vaapi erzwingt die Grafikeinheit. Fehlt sie, scheitert die
|
||||||
|
# Wiedergabe — nützlich, um zu prüfen, ob sie wirklich greift.
|
||||||
|
# none immer in Software. Langsam, aber überall gleich.
|
||||||
|
#
|
||||||
|
# **Zum blossen Umpacken wird nichts davon gebraucht.** Eine MKV mit
|
||||||
|
# H.264 nach MP4 zu schachteln kostet den Server fast nichts.
|
||||||
|
# - FLARO_HWACCEL=auto
|
||||||
|
|
||||||
# Wem die Dateien auf dem Wirt gehören. 1000:1000 ist die Voreinstellung
|
# Wem die Dateien auf dem Wirt gehören. 1000:1000 ist die Voreinstellung
|
||||||
# im Image; wer andere Kennungen hat, trägt sie hier ein — `id -u` und
|
# im Image; wer andere Kennungen hat, trägt sie hier ein — `id -u` und
|
||||||
# `id -g` auf dem Wirt sagen, welche.
|
# `id -g` auf dem Wirt sagen, welche.
|
||||||
@@ -67,9 +81,7 @@ services:
|
|||||||
devices:
|
devices:
|
||||||
- /dev/dri:/dev/dri
|
- /dev/dri:/dev/dri
|
||||||
|
|
||||||
healthcheck:
|
# Die Gesundheitsprüfung steckt im Image und fragt /health ab — hier
|
||||||
test: ["CMD", "dotnet", "--info"]
|
# braucht es sie nicht noch einmal. Die alte Fassung prüfte `dotnet
|
||||||
interval: 30s
|
# --info` und sagte damit nur, dass die Laufzeit da ist; das war sie auch,
|
||||||
timeout: 5s
|
# während der Server noch migrierte.
|
||||||
retries: 3
|
|
||||||
start_period: 20s
|
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Ein Gitea-Runner auf dem NAS.
|
# Ein Gitea-Runner auf dem NAS.
|
||||||
#
|
#
|
||||||
|
# **Nicht in Betrieb — und auf einem UGREEN-NAS nicht über die Docker-App
|
||||||
|
# anzulegen.** Deren Compose-Prüfung lehnt den Einhängepunkt
|
||||||
|
# `/var/run/docker.sock` ab. Nachgewiesen durch Halbieren: dieselbe Datei
|
||||||
|
# ohne diese eine Zeile wird angelegt, mit ihr kommt nur „Bitte überprüfen
|
||||||
|
# Sie die Konfigurationsinformationen", ohne Angabe eines Grundes.
|
||||||
|
#
|
||||||
|
# Was bliebe: `gitea/act_runner:latest-dind-rootless` mit `privileged: true`
|
||||||
|
# statt des Socket, oder die Datei über SSH ablegen und dort von Hand
|
||||||
|
# starten. Beides gibt dem Runner faktisch Root auf dem NAS, und bei einem
|
||||||
|
# öffentlichen Repo reicht dann unter Umständen ein fremder Pull Request,
|
||||||
|
# um Code darauf auszuführen. Deshalb baut bis auf Weiteres der
|
||||||
|
# Arbeitsrechner — siehe `Tools/image-veroeffentlichen.sh`.
|
||||||
|
#
|
||||||
|
# Die Datei bleibt liegen, damit der Weg beim nächsten Mal nicht noch
|
||||||
|
# einmal von vorn untersucht werden muss.
|
||||||
|
#
|
||||||
# **Wozu.** Gitea verteilt Arbeit nur, es baut nicht selbst. Ohne einen
|
# **Wozu.** Gitea verteilt Arbeit nur, es baut nicht selbst. Ohne einen
|
||||||
# registrierten Runner legt es bei jedem Push einen Lauf an, der dann
|
# registrierten Runner legt es bei jedem Push einen Lauf an, der dann
|
||||||
# liegenbleibt. Mit diesem hier baut das NAS das Flaro-Image selbst und legt
|
# liegenbleibt. Mit diesem hier baut das NAS das Flaro-Image selbst und legt
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace Flaro.Api;
|
||||||
|
|
||||||
|
/// <summary>Wie hoch der Aufmacher auf der Startseite steht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Vier Stufen, keine Zahl.</b> Eine Pixelzahl einzustellen hiesse, sie
|
||||||
|
/// für jeden Bildschirm neu einzustellen — der Aufmacher misst sich an der
|
||||||
|
/// Fensterhöhe, nicht an einer festen Grösse. Vier Wörter sind das, was man
|
||||||
|
/// dazu zu sagen hat.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Was die Stufen bedeuten, steht im Regelwerk</b>, nicht hier: der Server
|
||||||
|
/// weiss nichts von Bildpunkten, er merkt sich nur die Wahl. Nachzulesen bei
|
||||||
|
/// <c>.hero</c> in <c>style.css</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Serverweit.</b> Dieselbe Linie wie bei den Wiedergaberegeln: eine
|
||||||
|
/// Entscheidung des Gastgebers, nicht des Gastes.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Aufmacherhoehe
|
||||||
|
{
|
||||||
|
public const string Schlüssel = "darstellung.aufmacher.hoehe";
|
||||||
|
|
||||||
|
/// <summary>Die Mitte — das, was vorher fest verdrahtet war.</summary>
|
||||||
|
public const string Vorgabe = "mittel";
|
||||||
|
|
||||||
|
private static readonly string[] Stufen = ["niedrig", "mittel", "hoch", "sehrhoch"];
|
||||||
|
|
||||||
|
/// <summary>Macht aus dem Gespeicherten eine der vier Stufen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Unbekanntes wird zur Vorgabe statt zu einem Fehler: in der Spalte
|
||||||
|
/// steht, was irgendwann einmal hineingeschrieben wurde, und eine
|
||||||
|
/// Startseite, die daran scheitert, wäre die schlechtere Antwort.
|
||||||
|
/// </remarks>
|
||||||
|
public static string Lesen(string? wert)
|
||||||
|
{
|
||||||
|
var blank = wert?.Trim().ToLowerInvariant() ?? "";
|
||||||
|
return Stufen.Contains(blank) ? blank : Vorgabe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ob das eine Stufe ist, die es gibt.</summary>
|
||||||
|
public static bool Kennt(string? wert) =>
|
||||||
|
wert is not null && Stufen.Contains(wert.Trim().ToLowerInvariant());
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace Flaro.Api;
|
||||||
|
|
||||||
|
/// <summary>Welche Version läuft — und welcher Build genau.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum zwei Angaben.</b> Die Version sagt, welcher Stand gemeint ist;
|
||||||
|
/// sie steht fest und ändert sich nur von Hand. Der Build sagt, welches
|
||||||
|
/// Abbild tatsächlich läuft, und ändert sich bei <i>jedem</i> Bau — auch
|
||||||
|
/// dann, wenn am Quelltext nichts anders ist.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Und warum das nötig wurde.</b> Es hat einen halben Tag gekostet: zwei
|
||||||
|
/// Funktionen waren nachweislich im Quelltext und auf dem Server trotzdem
|
||||||
|
/// nicht zu sehen. Ob dort ein älteres Abbild lief, liess sich nicht
|
||||||
|
/// feststellen — der Server sagte es nirgends. Jede Fehlersuche lief damit
|
||||||
|
/// ins Leere, weil die erste Frage unbeantwortbar war: reden wir vom selben
|
||||||
|
/// Build?
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Sechs Zeichen, Grossbuchstaben und Ziffern.</b> Kurz genug, um sie
|
||||||
|
/// vorzulesen und abzutippen, lang genug, um nicht zufällig zweimal
|
||||||
|
/// vorzukommen. Ohne <c>I</c>, <c>O</c>, <c>0</c> und <c>1</c>, weil die
|
||||||
|
/// beim Vorlesen verwechselt werden.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Buildkennung
|
||||||
|
{
|
||||||
|
/// <summary>Die feste Version. Steigt nur, wenn jemand sie hier ändert.</summary>
|
||||||
|
public const string Version = "1.0.0";
|
||||||
|
|
||||||
|
/// <summary>Die Kennung dieses Abbilds — oder <c>DEV</c> ausserhalb eines Containers.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Gesetzt wird sie beim Bau des Abbilds über <c>FLARO_BUILD</c>. Fehlt
|
||||||
|
/// sie, läuft der Server aus dem Quellbaum: dann ist <c>DEV</c> die
|
||||||
|
/// ehrlichere Angabe als eine erfundene Kennung, die nichts bezeichnet.
|
||||||
|
/// </remarks>
|
||||||
|
public static string Build { get; } =
|
||||||
|
Environment.GetEnvironmentVariable("FLARO_BUILD") is { Length: > 0 } wert
|
||||||
|
? wert.Trim()
|
||||||
|
: "DEV";
|
||||||
|
|
||||||
|
/// <summary>„1.0.0 · K7F2QX" — für Protokoll und Anzeige.</summary>
|
||||||
|
public static string Beides => $"{Version} · {Build}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
namespace Flaro.Api;
|
||||||
|
|
||||||
|
/// <summary>Ein abgelehnter Versuch — mit einem Satz, den die Clients auch lesen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Es gab drei Formen, und zwei davon kamen nie an.</b> Gemessen: die
|
||||||
|
/// Oberfläche las bei jedem abgelehnten Versuch „Die Anfrage war so nicht zu
|
||||||
|
/// gebrauchen" — ihren eigenen Ersatzsatz für eine 400 ohne Begründung. Dabei
|
||||||
|
/// hatte der Server sehr wohl einen deutschen Satz geschrieben, nur an eine
|
||||||
|
/// Stelle, an der niemand nachsieht.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>Results.ValidationProblem</c> legt ihn unter <c>errors</c> ab,
|
||||||
|
/// <c>Results.BadRequest(new { fehler = … })</c> unter <c>fehler</c>. Beide
|
||||||
|
/// Clients lesen <c>detail</c> — der Browser in <c>api.ts</c>, die Apps in
|
||||||
|
/// <c>FlaroClient.swift</c> —, und zwar aus gutem Grund: in <c>title</c> und in
|
||||||
|
/// <c>errors</c> stehen sonst die englischen Prüftexte des Rahmenwerks, und
|
||||||
|
/// „Not Found" ist keine Auskunft. Nur stand in <c>detail</c> an 28 Stellen
|
||||||
|
/// eben gar nichts.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Also eine Form.</b> <c>Results.Problem(detail, …)</c> füllt genau das
|
||||||
|
/// Feld, das gelesen wird. Dass es hier durch drei benannte Türen geht statt
|
||||||
|
/// durch einen Aufruf mit Zahl, hat einen Grund: so lässt sich nachsehen, wo
|
||||||
|
/// ein Server etwas ablehnt, und man sieht am Wort, ob es eine Bitte um eine
|
||||||
|
/// bessere Eingabe war, etwas Fehlendes oder ein Zusammenstoss.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nicht für Ausnahmen.</b> Was unerwartet schiefgeht, übersetzt
|
||||||
|
/// <see cref="Fehlertext"/>. Hier geht es um Absichten, die der Server
|
||||||
|
/// zurückweist — nicht um Unfälle.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Einwand
|
||||||
|
{
|
||||||
|
/// <summary>400 — so war die Eingabe nicht zu gebrauchen.</summary>
|
||||||
|
public static IResult Bitte(string satz) =>
|
||||||
|
Results.Problem(satz, statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
/// <summary>404 — das, worauf es sich bezieht, gibt es nicht.</summary>
|
||||||
|
public static IResult Fehlt(string satz) =>
|
||||||
|
Results.Problem(satz, statusCode: StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
/// <summary>409 — es gibt schon etwas, das im Weg steht.</summary>
|
||||||
|
public static IResult Belegt(string satz) =>
|
||||||
|
Results.Problem(satz, statusCode: StatusCodes.Status409Conflict);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using Flaro.Core.Metadata;
|
||||||
|
using Flaro.Data;
|
||||||
|
using Flaro.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>Plakat, Hintergrund und Schriftzug von Hand wählen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Wofür.</b> Es gab genau einen Bild-Endpunkt, und der lieferte nur aus.
|
||||||
|
/// Wählte die Metadatenquelle ein schlechtes Plakat, blieb es — und die
|
||||||
|
/// einzige Abhilfe war, den ganzen Titel einem anderen Eintrag zuzuordnen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Die Kandidaten kosten nichts.</b> TMDB liefert sie beim Einzelabruf
|
||||||
|
/// ohnehin mit; bisher wurde alles ausser dem Schriftzug weggeworfen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ArtworkEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Höchstens so gross darf ein eigenes Bild sein.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Zehn Megabyte reichen für jedes Plakat. Ohne Grenze lädt irgendwann
|
||||||
|
/// jemand ein Foto hoch, und der Cache eines NAS ist nicht dafür da.
|
||||||
|
/// </remarks>
|
||||||
|
private const long HoechstensBytes = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
public static void MapArtwork(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var gruppe = app.MapGroup("/api/v1")
|
||||||
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
gruppe.MapGet("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||||
|
string art, Guid id, FlaroContext db, TmdbClient tmdb, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var serie = art == "series";
|
||||||
|
var kennung = serie
|
||||||
|
? await db.Series.Where(s => s.Id == id).Select(s => s.TmdbId).FirstOrDefaultAsync(ct)
|
||||||
|
: await db.Movies.Where(m => m.Id == id).Select(m => m.TmdbId).FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var aktuell = serie
|
||||||
|
? await db.Series.Where(s => s.Id == id)
|
||||||
|
.Select(s => new Bildstand(s.PosterImage, s.BackdropImage, s.LogoImage,
|
||||||
|
s.PosterPinned, s.BackdropPinned, s.LogoPinned)).FirstOrDefaultAsync(ct)
|
||||||
|
: await db.Movies.Where(m => m.Id == id)
|
||||||
|
.Select(m => new Bildstand(m.PosterImage, m.BackdropImage, m.LogoImage,
|
||||||
|
m.PosterPinned, m.BackdropPinned, m.LogoPinned)).FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (aktuell is null) return Results.NotFound();
|
||||||
|
|
||||||
|
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die
|
||||||
|
// Zuordnung das Problem, nicht das Bild.
|
||||||
|
var kandidaten = kennung is { } k
|
||||||
|
? await tmdb.BilderAsync(k, serie, ct)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return Results.Ok(new Bildauswahl(aktuell, kandidaten));
|
||||||
|
});
|
||||||
|
|
||||||
|
gruppe.MapPut("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||||
|
string art, Guid id, BildWahlRequest anfrage,
|
||||||
|
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (anfrage.Kind is not ("poster" or "backdrop" or "logo"))
|
||||||
|
return Einwand.Bitte("Erwartet: poster, backdrop oder logo.");
|
||||||
|
|
||||||
|
// Das Bild wandert in den Cache, bevor irgendetwas gesetzt wird —
|
||||||
|
// sonst zeigt der Eintrag auf etwas, das es hier nicht gibt.
|
||||||
|
var schluessel = string.IsNullOrWhiteSpace(anfrage.Url)
|
||||||
|
? null
|
||||||
|
: await ablage.StoreAsync(new RemoteImage(anfrage.Url), ct);
|
||||||
|
|
||||||
|
if (anfrage.Url is { Length: > 0 } && schluessel is null)
|
||||||
|
return Results.Problem("Das Bild liess sich nicht laden.", statusCode: 502);
|
||||||
|
|
||||||
|
var gesetzt = art == "series"
|
||||||
|
? await Setzen(db.Series.Where(s => s.Id == id), anfrage.Kind, schluessel, ct)
|
||||||
|
: await Setzen(db.Movies.Where(m => m.Id == id), anfrage.Kind, schluessel, ct);
|
||||||
|
|
||||||
|
return gesetzt ? Results.NoContent() : Results.NotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
gruppe.MapPost("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||||
|
string art, Guid id, string kind, HttpRequest anfrage,
|
||||||
|
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (kind is not ("poster" or "backdrop" or "logo"))
|
||||||
|
return Einwand.Bitte("Erwartet: poster, backdrop oder logo.");
|
||||||
|
if (!anfrage.HasFormContentType) return Einwand.Bitte("Kein Formular.");
|
||||||
|
|
||||||
|
var form = await anfrage.ReadFormAsync(ct);
|
||||||
|
var datei = form.Files.FirstOrDefault();
|
||||||
|
if (datei is null || datei.Length == 0)
|
||||||
|
return Einwand.Bitte("Keine Datei dabei.");
|
||||||
|
if (datei.Length > HoechstensBytes)
|
||||||
|
return Einwand.Bitte("Höchstens 10 MB.");
|
||||||
|
|
||||||
|
await using var strom = datei.OpenReadStream();
|
||||||
|
var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct);
|
||||||
|
if (schluessel is null) return Results.Problem("Das Bild liess sich nicht ablegen.", statusCode: 502);
|
||||||
|
|
||||||
|
var gesetzt = art == "series"
|
||||||
|
? await Setzen(db.Series.Where(s => s.Id == id), kind, schluessel, ct)
|
||||||
|
: await Setzen(db.Movies.Where(m => m.Id == id), kind, schluessel, ct);
|
||||||
|
|
||||||
|
return gesetzt ? Results.Ok(new { image = schluessel }) : Results.NotFound();
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Setzt ein Bild und merkt sich, dass es von Hand kam.</summary>
|
||||||
|
private static async Task<bool> Setzen<T>(
|
||||||
|
IQueryable<T> abfrage, string art, string? schluessel, CancellationToken ct)
|
||||||
|
where T : class, IBebildert
|
||||||
|
{
|
||||||
|
var betroffen = art switch
|
||||||
|
{
|
||||||
|
"poster" => await abfrage.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(x => x.PosterImage, schluessel)
|
||||||
|
.SetProperty(x => x.PosterPinned, schluessel != null), ct),
|
||||||
|
"backdrop" => await abfrage.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(x => x.BackdropImage, schluessel)
|
||||||
|
.SetProperty(x => x.BackdropPinned, schluessel != null), ct),
|
||||||
|
_ => await abfrage.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(x => x.LogoImage, schluessel)
|
||||||
|
.SetProperty(x => x.LogoPinned, schluessel != null), ct),
|
||||||
|
};
|
||||||
|
|
||||||
|
return betroffen > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,14 +77,22 @@ public static class AuthEndpoints
|
|||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
}).AllowAnonymous();
|
}).AllowAnonymous();
|
||||||
|
|
||||||
app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) =>
|
// **Aus der Datenbank, nicht nur aus dem Token.** Name und Rolle stehen
|
||||||
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id)
|
// in den Ansprüchen; Sprache und Bild ändern sich, ohne dass ein neues
|
||||||
? Results.Ok(new AuthenticatedUser(
|
// Token ausgestellt wird. Wer sein Bild tauscht und die Seite neu lädt,
|
||||||
id,
|
// sähe sonst das alte, bis er sich neu anmeldet.
|
||||||
wer.FindFirstValue(ClaimTypes.Name) ?? "",
|
app.MapGet("/api/v1/auth/me", async (ClaimsPrincipal wer, FlaroContext db) =>
|
||||||
wer.IsInRole(FlaroRoles.Administrator)))
|
{
|
||||||
: Results.Unauthorized())
|
if (!Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id))
|
||||||
.RequireAuthorization();
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
var benutzer = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id);
|
||||||
|
if (benutzer is null) return Results.Unauthorized();
|
||||||
|
|
||||||
|
return Results.Ok(new AuthenticatedUser(
|
||||||
|
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
|
||||||
|
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
MapCodeFlow(app);
|
MapCodeFlow(app);
|
||||||
}
|
}
|
||||||
@@ -152,7 +160,7 @@ public static class AuthEndpoints
|
|||||||
c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt);
|
c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt);
|
||||||
|
|
||||||
if (offen is null)
|
if (offen is null)
|
||||||
return Results.Problem("Dieser Code gilt nicht mehr.", statusCode: StatusCodes.Status404NotFound);
|
return Einwand.Fehlt("Dieser Code gilt nicht mehr.");
|
||||||
|
|
||||||
offen.ApprovedByUserId = benutzerId;
|
offen.ApprovedByUserId = benutzerId;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -190,7 +198,9 @@ public static class AuthEndpoints
|
|||||||
LastSeenAt = jetzt,
|
LastSeenAt = jetzt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new LoginResponse(token, new AuthenticatedUser(benutzer.Id, benutzer.UserName, benutzer.IsAdministrator));
|
return new LoginResponse(token, new AuthenticatedUser(
|
||||||
|
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
|
||||||
|
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
|
||||||
}
|
}
|
||||||
|
|
||||||
private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName;
|
private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName;
|
||||||
|
|||||||
@@ -5,8 +5,70 @@ namespace Flaro.Api.Endpoints;
|
|||||||
// und bricht Clients, die niemand mehr aktualisiert.
|
// und bricht Clients, die niemand mehr aktualisiert.
|
||||||
|
|
||||||
public sealed record SystemInfo(
|
public sealed record SystemInfo(
|
||||||
string Version, string ServerName, bool NeedsSetup,
|
string Version, string Build, string ServerName, bool NeedsSetup,
|
||||||
IReadOnlyList<AttributionResponse> Attributions);
|
IReadOnlyList<AttributionResponse> Attributions,
|
||||||
|
/// <summary>Wie hoch der Aufmacher steht: „niedrig", „mittel", „hoch", „sehrhoch".</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Hier und nicht in einer eigenen Abfrage.</b> Diese Auskunft holt
|
||||||
|
/// der Client ohnehin als Erstes, noch vor der Anmeldung. Käme die Höhe
|
||||||
|
/// später, stünde der Aufmacher einen Augenblick in der Vorgabe und
|
||||||
|
/// spränge dann — genau das, was eine einstellbare Höhe vermeiden soll.
|
||||||
|
/// </remarks>
|
||||||
|
string HeroHeight = "mittel");
|
||||||
|
|
||||||
|
/// <summary>Ein Ablageort des Servers, mitsamt Belegung.</summary>
|
||||||
|
///
|
||||||
|
/// <param name="Variable">
|
||||||
|
/// Die Umgebungsvariable, über die er gesetzt wird. <b>Sie wird mit
|
||||||
|
/// angezeigt</b>, weil sie die Antwort auf die nächste Frage ist: „und wie
|
||||||
|
/// ändere ich das?" Im Betrieb umbiegen lässt sich ein Pfad nicht — dann
|
||||||
|
/// lägen die Dateien woanders als die Datenbank sie vermutet.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Bilder">Wie viel davon auf den Bildspeicher entfällt; sonst <c>null</c>.</param>
|
||||||
|
public sealed record Speicherort(
|
||||||
|
string Name, string Variable, string Pfad, long Belegt, long? Frei, long? Bilder);
|
||||||
|
|
||||||
|
/// <summary>Dateien, die zu keinem Titel gehören — mit Begründung.</summary>
|
||||||
|
public sealed record Unzugeordnet(int Gesamt, IReadOnlyList<UnzugeordneteDatei> Dateien);
|
||||||
|
|
||||||
|
/// <summary>Ein Titel, zu dem die Metadatenquelle nichts hergab.</summary>
|
||||||
|
///
|
||||||
|
/// <param name="Art">„movie" oder „series" — damit der Client weiss, wohin.</param>
|
||||||
|
/// <param name="Dateien">Wie viele Dateien daran hängen.</param>
|
||||||
|
/// <param name="Beispiel">Ein Dateiname, damit man den Titel wiedererkennt.</param>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Weil die Zahl allein nichts erklärt.</b> In der Übersicht standen 448
|
||||||
|
/// Filme, in Plex 451 — und nirgends stand, welche drei fehlen oder warum.
|
||||||
|
/// Eine Mediathek, die etwas nicht zuordnen konnte, muss sagen was.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record OhneZuordnung(
|
||||||
|
Guid Id, string Art, string Titel, int? Jahr, int Dateien, string? Beispiel);
|
||||||
|
|
||||||
|
/// <param name="Titel">Titel mit mehr als einer Datei — dort versteckt sich Falsches.</param>
|
||||||
|
public sealed record Mehrfachbericht(
|
||||||
|
int Gesamt, IReadOnlyList<Mehrfachtitel> Titel);
|
||||||
|
|
||||||
|
/// <param name="Dateien">Die Dateien, die an diesem einen Titel hängen.</param>
|
||||||
|
public sealed record Mehrfachtitel(
|
||||||
|
Guid Id, string Art, string Titel, int? Jahr, IReadOnlyList<Fassungsdatei> Dateien);
|
||||||
|
|
||||||
|
/// <summary>Eine Datei an einem Titel — mit Kennung, damit sie sich herauslösen lässt.</summary>
|
||||||
|
public sealed record Fassungsdatei(Guid Id, string Name);
|
||||||
|
|
||||||
|
public sealed record UnzugeordneteDatei(Guid Id, string Pfad, string Grund);
|
||||||
|
|
||||||
|
/// <summary>Wie viele Titel in welcher Sprache vorliegen.</summary>
|
||||||
|
///
|
||||||
|
/// <param name="Sprache"><c>null</c> heisst: vor der Einführung dieser Spalte geholt.</param>
|
||||||
|
public sealed record Sprachanteil(string? Sprache, int Anzahl);
|
||||||
|
|
||||||
|
/// <param name="Abweichend">Wie viele nicht in der Serversprache vorliegen.</param>
|
||||||
|
public sealed record Sprachverteilung(
|
||||||
|
string Serversprache, IReadOnlyList<Sprachanteil> Anteile, int Abweichend);
|
||||||
|
|
||||||
|
public sealed record TitelRequest(string? Title);
|
||||||
|
|
||||||
public sealed record ServerNameRequest(string Name);
|
public sealed record ServerNameRequest(string Name);
|
||||||
|
|
||||||
@@ -26,7 +88,28 @@ public sealed record LoginRequest(
|
|||||||
string UserName, string Password, string DeviceName, string DeviceId,
|
string UserName, string Password, string DeviceName, string DeviceId,
|
||||||
bool StaySignedIn = true);
|
bool StaySignedIn = true);
|
||||||
|
|
||||||
public sealed record AuthenticatedUser(Guid Id, string UserName, bool IsAdministrator);
|
public sealed record AuthenticatedUser(
|
||||||
|
Guid Id, string UserName, bool IsAdministrator,
|
||||||
|
/// <summary>Die bevorzugte Sprache — <c>null</c> heisst: die des Servers.</summary>
|
||||||
|
string? Language = null,
|
||||||
|
|
||||||
|
/// <summary>Das eigene Bild, als Schlüssel in die Bildablage.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Hier und nicht in einer eigenen Abfrage.</b> Die Kopfleiste zeigt es
|
||||||
|
/// auf jeder Seite; es nach der Anmeldung noch einmal holen zu müssen
|
||||||
|
/// hiesse, dass der Buchstabenkreis kurz aufblitzt und dann wechselt.
|
||||||
|
/// </remarks>
|
||||||
|
string? AvatarImage = null,
|
||||||
|
|
||||||
|
/// <summary>Der Besitzer — ihm nimmt niemand das Adminrecht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Hinten angehängt, wie jedes neue Feld in dieser Datei: in der Mitte
|
||||||
|
/// eingeschoben bricht es jede Aufrufstelle, die die Werte der Reihe nach
|
||||||
|
/// übergibt — und davon gibt es hier drei.
|
||||||
|
/// </remarks>
|
||||||
|
bool IsOwner = false);
|
||||||
|
|
||||||
public sealed record LoginResponse(string Token, AuthenticatedUser User);
|
public sealed record LoginResponse(string Token, AuthenticatedUser User);
|
||||||
|
|
||||||
@@ -44,6 +127,12 @@ public sealed record CodeState(string State, LoginResponse? Login);
|
|||||||
|
|
||||||
public sealed record CreateUserRequest(string UserName, string Password, bool IsAdministrator);
|
public sealed record CreateUserRequest(string UserName, string Password, bool IsAdministrator);
|
||||||
|
|
||||||
|
/// <summary>Adminrecht geben oder nehmen.</summary>
|
||||||
|
public sealed record RechtRequest(bool IsAdministrator);
|
||||||
|
|
||||||
|
/// <summary>Eine der vier Stufen für den Aufmacher.</summary>
|
||||||
|
public sealed record HoeheRequest(string Height);
|
||||||
|
|
||||||
/// <summary>Eine Bibliothek, wie die Oberfläche sie zeigt.</summary>
|
/// <summary>Eine Bibliothek, wie die Oberfläche sie zeigt.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -52,11 +141,126 @@ public sealed record CreateUserRequest(string UserName, string Password, bool Is
|
|||||||
/// „nicht gezählt" sind verschiedene Auskünfte, und eine frisch angelegte
|
/// „nicht gezählt" sind verschiedene Auskünfte, und eine frisch angelegte
|
||||||
/// Bibliothek als leer auszugeben wäre die falsche.
|
/// Bibliothek als leer auszugeben wäre die falsche.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record LibraryResponse(Guid Id, string Name, string Kind, string Path, int? ItemCount);
|
public sealed record LibraryResponse(
|
||||||
|
Guid Id, string Name, string Kind, string Path, int? ItemCount,
|
||||||
|
/// <summary>Ob neue Funde erst geprüft werden.</summary>
|
||||||
|
bool ReviewNewItems = false,
|
||||||
|
/// <summary>Wie viele Titel dieser Mediathek noch auf Freigabe warten.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Hier und nicht nur in der Prüfliste.</b> Wer eine Mediathek
|
||||||
|
/// scannt, sieht das Ergebnis an ihr — dass zwanzig Titel irgendwo anders
|
||||||
|
/// warten, erfährt er sonst nur, wenn er weiterscrollt.
|
||||||
|
/// </remarks>
|
||||||
|
int PendingCount = 0,
|
||||||
|
|
||||||
|
/// <summary>Wie viele Mediendateien in dieser Mediathek liegen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Weil „455 Filme" und „461 Dateien" beide stimmen können.</b> Plex
|
||||||
|
/// zählte 451, Flaro 449 — und der Scan lief über 461 Dateien. Drei
|
||||||
|
/// Zahlen für drei verschiedene Dinge, und keine davon falsch. Nebeneinander
|
||||||
|
/// zu sehen, wie sie sich aufteilen, beendet die Frage; eine davon allein
|
||||||
|
/// zu zeigen hält sie offen.
|
||||||
|
/// </remarks>
|
||||||
|
int FileCount = 0,
|
||||||
|
|
||||||
|
/// <summary>Wie viele Folgen — nur bei Serien, sonst <c>null</c>.</summary>
|
||||||
|
int? EpisodeCount = null,
|
||||||
|
|
||||||
|
/// <summary>Dateien, die an keinem Titel hängen.</summary>
|
||||||
|
int UnassignedCount = 0,
|
||||||
|
|
||||||
|
/// <summary>Dateien über die erste je Titel hinaus — zweite Fassungen.</summary>
|
||||||
|
int ExtraVersionCount = 0);
|
||||||
|
|
||||||
public sealed record RenameLibraryRequest(string Name);
|
public sealed record RenameLibraryRequest(string Name);
|
||||||
|
|
||||||
public sealed record CreateLibraryRequest(string Name, string Kind, string Path);
|
public sealed record CreateLibraryRequest(
|
||||||
|
string Name, string Kind, string Path,
|
||||||
|
/// <summary>Ob neue Funde erst geprüft werden. Vorgabe: nein.</summary>
|
||||||
|
bool ReviewNewItems = false);
|
||||||
|
|
||||||
|
public sealed record PruefungRequest(bool ReviewNewItems);
|
||||||
|
|
||||||
|
/// <summary>Die Reihenfolge der Mediatheken, von oben nach unten.</summary>
|
||||||
|
public sealed record ReihenfolgeRequest(IReadOnlyList<Guid>? Ids);
|
||||||
|
|
||||||
|
public sealed record SprachRequest(string? Language);
|
||||||
|
|
||||||
|
/// <summary>Welche Sprache gilt — und wie weit sie abgedeckt ist.</summary>
|
||||||
|
public sealed record SprachStand(string? Language, int Translated, int Total);
|
||||||
|
|
||||||
|
/// <summary>Welche Bilder gerade gesetzt sind — und welche von Hand.</summary>
|
||||||
|
public sealed record Bildstand(
|
||||||
|
string? Poster, string? Backdrop, string? Logo,
|
||||||
|
bool PosterPinned, bool BackdropPinned, bool LogoPinned);
|
||||||
|
|
||||||
|
public sealed record Bildauswahl(
|
||||||
|
Bildstand Current, IReadOnlyList<Flaro.Core.Metadata.Bildkandidat> Candidates);
|
||||||
|
|
||||||
|
public sealed record BildWahlRequest(string Kind, string? Url);
|
||||||
|
|
||||||
|
/// <summary>Ein Titel, der noch wartet oder geplant ist.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Eine Zeile je Serie, nicht je Folge.</b> Bei zwanzig neuen Folgen
|
||||||
|
/// derselben Serie wären zwanzig Zeilen zwanzig Entscheidungen für eine
|
||||||
|
/// Sache. Die Folgen stecken in <see cref="Ids"/>; freigegeben werden sie
|
||||||
|
/// gemeinsam.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record Pruefeintrag(
|
||||||
|
/// <summary><c>movie</c> oder <c>series</c>.</summary>
|
||||||
|
string Kind,
|
||||||
|
Guid Id, string Title, int? Year, string? PosterImage,
|
||||||
|
/// <summary><c>null</c> heisst: wartet. Ein Zeitpunkt in der Zukunft heisst: geplant.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Bei einer Serie nur gesetzt, wenn alle Folgen denselben Termin tragen —
|
||||||
|
/// sonst wäre es eine Behauptung über zwanzig Folgen, von denen eine
|
||||||
|
/// anders liegt.
|
||||||
|
/// </remarks>
|
||||||
|
DateTimeOffset? PublishAt,
|
||||||
|
string? Path,
|
||||||
|
/// <summary>Ob eine Zuordnung besteht — ohne sie fehlen Titel und Bilder.</summary>
|
||||||
|
bool Matched,
|
||||||
|
/// <summary>Wie viele Titel dahinterstecken — bei einer Serie die Zahl der Folgen.</summary>
|
||||||
|
int Count,
|
||||||
|
/// <summary>Worauf eine Freigabe wirkt.</summary>
|
||||||
|
IReadOnlyList<Guid> Ids,
|
||||||
|
|
||||||
|
/// <summary>Warum der Eintrag hier steht: <c>neu</c> oder <c>geaendert</c>.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Zwei verschiedene Dinge in einer Liste, und das mit Absicht.</b> Ein
|
||||||
|
/// neuer Fund wartet auf Freigabe und ist bis dahin unsichtbar. Ein
|
||||||
|
/// geänderter Titel steht längst in der Mediathek — er soll nur nicht
|
||||||
|
/// unbemerkt anders heissen oder ein anderes Plakat tragen. Beides gehört
|
||||||
|
/// vor dieselben Augen, aber nicht unter denselben Knopf: das eine wird
|
||||||
|
/// veröffentlicht, das andere nur zur Kenntnis genommen.
|
||||||
|
/// </remarks>
|
||||||
|
string Grund = "neu");
|
||||||
|
|
||||||
|
public sealed record FreigabeRequest(
|
||||||
|
IReadOnlyList<Guid>? Ids,
|
||||||
|
DateTimeOffset? PublishAt,
|
||||||
|
/// <summary>Statt einzelner Kennungen: alles Wartende dieser Mediathek.</summary>
|
||||||
|
Guid? LibraryId = null);
|
||||||
|
|
||||||
|
/// <summary>Ein Protokolltag.</summary>
|
||||||
|
public sealed record Protokolltag(string Date, long Bytes);
|
||||||
|
|
||||||
|
/// <summary>Eine Datei, die der letzte Scan nicht mehr gefunden hat.</summary>
|
||||||
|
public sealed record VermissteDatei(
|
||||||
|
Guid FileId,
|
||||||
|
string Path,
|
||||||
|
string? VersionLabel,
|
||||||
|
DateTimeOffset MissingSince,
|
||||||
|
string LibraryName,
|
||||||
|
/// <summary>Zu welchem Titel sie gehört — damit klar ist, was fehlen würde.</summary>
|
||||||
|
string? Title,
|
||||||
|
Guid? MovieId,
|
||||||
|
Guid? EpisodeId);
|
||||||
|
|
||||||
// --- Stufe 2: Bibliothek -------------------------------------------------
|
// --- Stufe 2: Bibliothek -------------------------------------------------
|
||||||
|
|
||||||
@@ -74,7 +278,11 @@ public sealed record ScanStatus(
|
|||||||
int? Probed,
|
int? Probed,
|
||||||
IReadOnlyList<ScanProblem>? Problems,
|
IReadOnlyList<ScanProblem>? Problems,
|
||||||
string? Error,
|
string? Error,
|
||||||
MetadataProgress? Metadata);
|
MetadataProgress? Metadata,
|
||||||
|
/// <summary>Wie viele Dateien dieser Lauf als verschwunden gemeldet hat.</summary>
|
||||||
|
int? Missing = null,
|
||||||
|
/// <summary>Woran der Lauf gerade ist, in einem Satz.</summary>
|
||||||
|
string? Step = null);
|
||||||
|
|
||||||
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
|
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
|
||||||
public sealed record MetadataProgress(
|
public sealed record MetadataProgress(
|
||||||
@@ -83,7 +291,38 @@ public sealed record MetadataProgress(
|
|||||||
/// <summary>Eine Spur in einer Datei.</summary>
|
/// <summary>Eine Spur in einer Datei.</summary>
|
||||||
public sealed record MediaStreamResponse(
|
public sealed record MediaStreamResponse(
|
||||||
int Index, string Kind, string? Codec, string? Language, string? Title,
|
int Index, string Kind, string? Codec, string? Language, string? Title,
|
||||||
bool IsDefault, bool IsForced, int? Channels, int? Width, int? Height);
|
bool IsDefault, bool IsForced, int? Channels, int? Width, int? Height,
|
||||||
|
/// <summary>Die Zeile, wie sie angezeigt werden soll.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Vom Server formuliert, nicht vom Client.</b> „DEU EAC3 6.0" wird
|
||||||
|
/// „Deutsch · Dolby Digital Plus · 5.1" — und die Apps brauchen denselben
|
||||||
|
/// Satz. Zweimal formuliert driftet er auseinander, und dann heisst
|
||||||
|
/// dieselbe Tonspur am Fernseher anders als im Browser. Die Rohwerte
|
||||||
|
/// stehen weiterhin daneben; wer sie braucht, hat sie.
|
||||||
|
/// </remarks>
|
||||||
|
string? Display = null,
|
||||||
|
/// <summary>Die Auflösung als Wort — „4K", „Full HD", „HD", „SD".</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Eigenes Feld, damit der Client nichts zerlegen muss.</b> Die
|
||||||
|
/// Oberfläche las die Stufe bisher mit <c>display.split(' · ')[0]</c>
|
||||||
|
/// heraus — richtig, solange vor der Auflösung nie etwas anderes steht,
|
||||||
|
/// und falsch an dem Tag, an dem doch.
|
||||||
|
/// </remarks>
|
||||||
|
string? ResolutionLabel = null,
|
||||||
|
/// <summary>Dieselbe Auflösung genau — „1080p". Gehört in den Tooltip.</summary>
|
||||||
|
string? ResolutionExact = null,
|
||||||
|
|
||||||
|
/// <summary>Die Sprache als Wort — „Deutsch" statt „deu".</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit eine Liste von Sprachen ohne Rätselraten entsteht.</b> Die
|
||||||
|
/// Detailansicht nennt, in welchen Sprachen ein Film vorliegt; sie dafür
|
||||||
|
/// aus <see cref="Display"/> herauszuschneiden hiesse, sich auf dessen
|
||||||
|
/// Satzbau zu verlassen.
|
||||||
|
/// </remarks>
|
||||||
|
string? LanguageName = null);
|
||||||
|
|
||||||
/// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary>
|
/// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary>
|
||||||
///
|
///
|
||||||
@@ -93,9 +332,32 @@ public sealed record MediaStreamResponse(
|
|||||||
/// App spricht Dateien über ihre Kennung an. Zum Unterscheiden zweier
|
/// App spricht Dateien über ihre Kennung an. Zum Unterscheiden zweier
|
||||||
/// Fassungen genügt der Name.
|
/// Fassungen genügt der Name.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <param name="VersionLabel">
|
||||||
|
/// <b>Wie diese Fassung heisst</b> — „Mobil", „4K", „Director's Cut" — oder
|
||||||
|
/// <c>null</c>, wenn es nur eine gibt. Ohne diese Angabe standen zwei Dateien
|
||||||
|
/// derselben Folge als zwei gleich aussehende Knopfreihen untereinander, und
|
||||||
|
/// die Folge wirkte doppelt.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Folder">
|
||||||
|
/// <b>Der Ordner unterhalb der Mediathek</b> — <c>null</c> für alle, die nicht
|
||||||
|
/// verwalten dürfen.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Weil man einer falschen Zuordnung am Ordner sofort ansieht, dass sie
|
||||||
|
/// falsch ist.</b> Steht über der Beschreibung „Hanni & Nanni 3" und der
|
||||||
|
/// Ordner heisst <c>Hanni und Nanni</c>, ist die Sache in einer Sekunde klar
|
||||||
|
/// — ohne Umweg über den Zuordnungsdialog.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nur der Teil unterhalb der Mediathek, und nur für Administratoren.</b>
|
||||||
|
/// Der volle Pfad verriete jedem Angemeldeten den Aufbau des Servers; der
|
||||||
|
/// relative sagt genau das, was zum Beurteilen nötig ist.
|
||||||
|
/// </para>
|
||||||
|
/// </param>
|
||||||
public sealed record MediaFileResponse(
|
public sealed record MediaFileResponse(
|
||||||
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
|
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
|
||||||
IReadOnlyList<MediaStreamResponse> Streams);
|
IReadOnlyList<MediaStreamResponse> Streams, string? VersionLabel = null,
|
||||||
|
string? Folder = null);
|
||||||
|
|
||||||
public sealed record MovieResponse(
|
public sealed record MovieResponse(
|
||||||
Guid Id, string Title, int? Year, string? Overview,
|
Guid Id, string Title, int? Year, string? Overview,
|
||||||
@@ -116,7 +378,35 @@ public sealed record MovieResponse(
|
|||||||
/// von denen keiner angezeigt wird.
|
/// von denen keiner angezeigt wird.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
IReadOnlyList<string>? Genres = null,
|
IReadOnlyList<string>? Genres = null,
|
||||||
IReadOnlyList<CreditResponse>? Credits = null);
|
IReadOnlyList<CreditResponse>? Credits = null,
|
||||||
|
|
||||||
|
/// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary>
|
||||||
|
string? OriginalTitle = null,
|
||||||
|
|
||||||
|
/// <summary>Ab wann sichtbar — <c>null</c> heisst: wartet auf Freigabe.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit die Mediathek es zeigen kann.</b> Für einen Administrator
|
||||||
|
/// stehen wartende Titel mitten zwischen den freigegebenen, ohne dass
|
||||||
|
/// etwas sie unterscheidet — und wer einen davon sucht, findet ihn nur
|
||||||
|
/// über die Prüfliste.
|
||||||
|
/// </remarks>
|
||||||
|
DateTimeOffset? PublishAt = null,
|
||||||
|
|
||||||
|
/// <summary>Ob der Titel von Hand gesetzt wurde.</summary>
|
||||||
|
bool TitlePinned = false,
|
||||||
|
|
||||||
|
/// <summary>Wonach einsortiert wird, wenn nicht nach dem Titel.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit der Client weiss, wohin ein Titel gehört.</b> Nach einer
|
||||||
|
/// Korrektur stand „WALL·E" weiter unter E, bis man die Seite neu lud —
|
||||||
|
/// die Kachel wusste schlicht nicht, dass sie woandershin gehört.
|
||||||
|
/// </remarks>
|
||||||
|
string? SortTitle = null,
|
||||||
|
|
||||||
|
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
|
||||||
|
bool SortTitlePinned = false);
|
||||||
|
|
||||||
public sealed record CreditResponse(
|
public sealed record CreditResponse(
|
||||||
Guid Id, string Name, string Role, string? Character, string? Image);
|
Guid Id, string Name, string Role, string? Character, string? Image);
|
||||||
@@ -139,12 +429,63 @@ public sealed record SeriesResponse(
|
|||||||
string? Tagline = null,
|
string? Tagline = null,
|
||||||
string? ContentRating = null,
|
string? ContentRating = null,
|
||||||
IReadOnlyList<string>? Genres = null,
|
IReadOnlyList<string>? Genres = null,
|
||||||
IReadOnlyList<CreditResponse>? Credits = null);
|
IReadOnlyList<CreditResponse>? Credits = null,
|
||||||
|
|
||||||
|
/// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary>
|
||||||
|
string? OriginalTitle = null,
|
||||||
|
|
||||||
|
/// <summary>Ab wann sichtbar — <c>null</c> heisst: wartet auf Freigabe.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit die Mediathek es zeigen kann.</b> Für einen Administrator
|
||||||
|
/// stehen wartende Titel mitten zwischen den freigegebenen, ohne dass
|
||||||
|
/// etwas sie unterscheidet — und wer einen davon sucht, findet ihn nur
|
||||||
|
/// über die Prüfliste.
|
||||||
|
/// </remarks>
|
||||||
|
DateTimeOffset? PublishAt = null,
|
||||||
|
|
||||||
|
/// <summary>Favorit, Bewertung, Häkchen — zur Serie als Ganzem.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Neben <see cref="UnwatchedCount"/>, nicht an dessen Stelle.</b> Die
|
||||||
|
/// Zahl sagt, wie viel noch aussteht; dieser Zustand sagt, was der
|
||||||
|
/// Benutzer über die Serie selbst festgehalten hat. Ohne ihn konnte eine
|
||||||
|
/// Kachel nicht wissen, ob „Als gesehen markieren" oder „Als ungesehen
|
||||||
|
/// markieren" darauf gehört — sie bot immer das erste an.
|
||||||
|
/// </remarks>
|
||||||
|
ItemStateResponse? State = null,
|
||||||
|
|
||||||
|
/// <summary>Ob der Titel von Hand gesetzt wurde.</summary>
|
||||||
|
bool TitlePinned = false,
|
||||||
|
|
||||||
|
/// <summary>Wonach einsortiert wird, wenn nicht nach dem Titel.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit der Client weiss, wohin ein Titel gehört.</b> Nach einer
|
||||||
|
/// Korrektur stand „WALL·E" weiter unter E, bis man die Seite neu lud —
|
||||||
|
/// die Kachel wusste schlicht nicht, dass sie woandershin gehört.
|
||||||
|
/// </remarks>
|
||||||
|
string? SortTitle = null,
|
||||||
|
|
||||||
|
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
|
||||||
|
bool SortTitlePinned = false);
|
||||||
|
|
||||||
public sealed record EpisodeResponse(
|
public sealed record EpisodeResponse(
|
||||||
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
||||||
IReadOnlyList<MediaFileResponse> Files,
|
IReadOnlyList<MediaFileResponse> Files,
|
||||||
ItemStateResponse? State);
|
ItemStateResponse? State,
|
||||||
|
DateTimeOffset? PublishAt = null,
|
||||||
|
|
||||||
|
/// <summary>Zu welcher Serie sie gehört.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit eine Folge für sich geöffnet werden kann.</b> Die Apps
|
||||||
|
/// reichen eine Kennung durch, ohne zu wissen, was dahintersteckt. War es
|
||||||
|
/// eine Folge, fand sich weder ein Film noch eine Serie, und die
|
||||||
|
/// Startseite meldete „Dieser Titel ist auf dem Server nicht mehr
|
||||||
|
/// vorhanden" — über einen Titel, den sie selbst gerade angezeigt hatte.
|
||||||
|
/// </remarks>
|
||||||
|
Guid? SeriesId = null);
|
||||||
|
|
||||||
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
|
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
|
||||||
|
|
||||||
@@ -163,6 +504,30 @@ public sealed record AttributionResponse(string Text, string LogoUrl, string Url
|
|||||||
|
|
||||||
public sealed record MetadataSettingsRequest(string? ApiKey, string? Language);
|
public sealed record MetadataSettingsRequest(string? ApiKey, string? Language);
|
||||||
|
|
||||||
|
/// <summary>Was der Server über die Wiedergabe entscheidet.</summary>
|
||||||
|
///
|
||||||
|
/// <param name="ResumeAfterSeconds">
|
||||||
|
/// Wie weit man gekommen sein muss, damit ein Titel in „Weiterschauen" steht.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="WatchedFraction">Ab welchem Anteil er als gesehen gilt, 0 bis 1.</param>
|
||||||
|
/// <param name="Encoder">„qualitaet", „ausgewogen" oder „tempo".</param>
|
||||||
|
public sealed record PlaybackSettingsResponse(
|
||||||
|
double ResumeAfterSeconds, double WatchedFraction, string Encoder);
|
||||||
|
|
||||||
|
/// <summary>Was geändert werden soll — <c>null</c> heisst: unverändert.</summary>
|
||||||
|
public sealed record PlaybackSettingsRequest(
|
||||||
|
double? ResumeAfterSeconds, double? WatchedFraction, string? Encoder);
|
||||||
|
|
||||||
|
/// <summary>Was danach gilt, und wie viele Zeilen das umgeworfen hat.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Die Zahl gehört in die Antwort.</b> Eine neue Schwelle wirkt rückwirkend
|
||||||
|
/// auf alles, was schon in „Weiterschauen" steht; ohne diese Rückmeldung
|
||||||
|
/// änderte sich die Startseite scheinbar von selbst.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record PlaybackSettingsApplied(
|
||||||
|
double ResumeAfterSeconds, double WatchedFraction, string Encoder, int Reevaluated);
|
||||||
|
|
||||||
// --- Stufe 3: Ausliefern -------------------------------------------------
|
// --- Stufe 3: Ausliefern -------------------------------------------------
|
||||||
|
|
||||||
/// <summary>Was mit einer Datei geschehen wird, bevor sie hinausgeht.</summary>
|
/// <summary>Was mit einer Datei geschehen wird, bevor sie hinausgeht.</summary>
|
||||||
@@ -190,8 +555,26 @@ public sealed record MatchCandidate(
|
|||||||
public sealed record ApplyMatchRequest(int TmdbId);
|
public sealed record ApplyMatchRequest(int TmdbId);
|
||||||
|
|
||||||
/// <summary>Wie die Zuordnung jetzt aussieht.</summary>
|
/// <summary>Wie die Zuordnung jetzt aussieht.</summary>
|
||||||
|
///
|
||||||
|
/// <param name="Id">
|
||||||
|
/// <b>Welcher Eintrag das jetzt ist.</b> Meist der, den man korrigiert hat —
|
||||||
|
/// nach einem Zusammenlegen aber der überlebende, und der hat eine andere
|
||||||
|
/// Kennung.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Merged">
|
||||||
|
/// <b>Ob dabei zwei Einträge zu einem wurden.</b>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Ohne diese Auskunft stand der korrigierte Titel weiter in der Liste,
|
||||||
|
/// obwohl es ihn nicht mehr gab: wer ihn dann bearbeiten wollte, bekam eine
|
||||||
|
/// 404 — und zwar als englisches „Not Found", weil auch das nicht stimmte.
|
||||||
|
/// Der Client kann die Kachel jetzt wegnehmen, statt auf einen gelöschten
|
||||||
|
/// Eintrag zu zeigen.
|
||||||
|
/// </para>
|
||||||
|
/// </param>
|
||||||
public sealed record MatchState(
|
public sealed record MatchState(
|
||||||
int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year);
|
int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year,
|
||||||
|
Guid Id = default, bool Merged = false);
|
||||||
|
|
||||||
// --- Stufe 4: Fortschritt und Zustand -------------------------------------
|
// --- Stufe 4: Fortschritt und Zustand -------------------------------------
|
||||||
|
|
||||||
@@ -220,13 +603,109 @@ public sealed record ProgressRequest(
|
|||||||
|
|
||||||
public sealed record WatchedRequest(bool Watched);
|
public sealed record WatchedRequest(bool Watched);
|
||||||
public sealed record FavouriteRequest(bool Favourite);
|
public sealed record FavouriteRequest(bool Favourite);
|
||||||
|
|
||||||
|
/// <summary>Mehrere Titel auf einmal abhaken — oder das Häkchen wegnehmen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Eine Anfrage statt zweihundert.</b> „Alle als gesehen markieren" über
|
||||||
|
/// eine Mediathek hinweg wäre sonst ein Anfragensturm, bei dem mittendrin
|
||||||
|
/// etwas scheitert und niemand weiss, was durchging.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record BulkWatchedRequest(IReadOnlyList<Guid> Ids, bool Watched);
|
||||||
|
|
||||||
|
/// <param name="Changed">Wie viele Titel sich dadurch geändert haben.</param>
|
||||||
|
/// <param name="Unknown">
|
||||||
|
/// Kennungen, zu denen es nichts gibt — stillschweigend zu übergehen hiesse,
|
||||||
|
/// einen Teilerfolg als vollen auszugeben.
|
||||||
|
/// </param>
|
||||||
|
public sealed record BulkWatchedResponse(int Changed, IReadOnlyList<Guid> Unknown);
|
||||||
|
|
||||||
|
/// <summary>Welche Titel neu geholt werden sollen — leer heisst: alle.</summary>
|
||||||
|
public sealed record RefreshRequest(IReadOnlyList<Guid>? Ids);
|
||||||
|
|
||||||
|
/// <summary>Was ein Spieler kann und was er sehen will.</summary>
|
||||||
|
///
|
||||||
|
/// <param name="Containers">Schachteln, die das Gerät öffnet — „mp4", „webm".</param>
|
||||||
|
/// <param name="Height">
|
||||||
|
/// Die gewünschte Stufe: 1080, 720 oder 480. <c>null</c> heisst Originalauflösung.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="AudioStream">
|
||||||
|
/// Welche Tonspur — die Nummer aus der Datei. <c>null</c> nimmt die erste.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="StartSeconds">Ab welcher Sekunde des Films.</param>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Das Gerät sagt, was es kann, statt dass der Server es rät.</b> Vorher
|
||||||
|
/// bekam jeder Browser Apples Profil — und mit ihm HEVC, AC3 und EAC3, von
|
||||||
|
/// denen er keines öffnen kann.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record StreamRequest(
|
||||||
|
IReadOnlyList<string>? Containers,
|
||||||
|
IReadOnlyList<string>? VideoCodecs,
|
||||||
|
IReadOnlyList<string>? AudioCodecs,
|
||||||
|
int? Height = null,
|
||||||
|
int? AudioStream = null,
|
||||||
|
double? StartSeconds = null,
|
||||||
|
|
||||||
|
/// <summary>Umkodieren, egal was das Profil sagt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Für den zweiten Versuch.</b> Der Spieler schickt sein Profil aus
|
||||||
|
/// <c>canPlayType</c> — und das ist eine Behauptung: „kenne ich" heisst
|
||||||
|
/// nicht „kann ich diese Datei". Scheitert die Wiedergabe trotzdem, weiss
|
||||||
|
/// der Browser mehr als jede Abfrage vorher, und sein Urteil schlägt
|
||||||
|
/// seine eigene Auskunft.
|
||||||
|
/// </remarks>
|
||||||
|
bool Transcode = false,
|
||||||
|
|
||||||
|
/// <summary>Um wie viele Millisekunden der Ton verschoben wird.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Für Dateien, bei denen es ab Werk nicht stimmt.</b> Positiv heisst
|
||||||
|
/// später, negativ früher. Der Server schiebt dabei nicht am Bild, sondern
|
||||||
|
/// liest die Datei ein zweites Mal mit <c>-itsoffset</c> und nimmt von dort
|
||||||
|
/// nur den Ton — mit einer Eingabe verschöbe sich beides gemeinsam und
|
||||||
|
/// nichts wäre gewonnen.
|
||||||
|
/// </remarks>
|
||||||
|
int AudioOffsetMs = 0);
|
||||||
|
|
||||||
|
/// <summary>Eine wählbare Spur, so wie sie im Menü steht.</summary>
|
||||||
|
public sealed record SpurAuskunft(int Index, string Label, string? Language, bool IsDefault);
|
||||||
|
|
||||||
|
/// <param name="StartSeconds">
|
||||||
|
/// Ab welcher Sekunde des Films dieser Strom beginnt. <b>Der Spieler zählt ab
|
||||||
|
/// null</b> — ohne diesen Wert ginge jeder gemeldete Stand um den Vorlauf daneben.
|
||||||
|
/// </param>
|
||||||
|
public sealed record StreamResponse(
|
||||||
|
Guid SessionId,
|
||||||
|
string Url,
|
||||||
|
string Method,
|
||||||
|
double StartSeconds,
|
||||||
|
int? Height,
|
||||||
|
int? AudioStream,
|
||||||
|
IReadOnlyList<SpurAuskunft> AudioStreams,
|
||||||
|
IReadOnlyList<SpurAuskunft> SubtitleStreams);
|
||||||
public sealed record RatingRequest(int? Rating);
|
public sealed record RatingRequest(int? Rating);
|
||||||
|
|
||||||
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
|
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
|
||||||
public sealed record ItemStateResponse(
|
public sealed record ItemStateResponse(
|
||||||
double PositionSeconds, double? RuntimeSeconds,
|
double PositionSeconds, double? RuntimeSeconds,
|
||||||
bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed,
|
bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed,
|
||||||
DateTimeOffset? LastPlayedAt);
|
DateTimeOffset? LastPlayedAt,
|
||||||
|
|
||||||
|
/// <summary>Ob der Titel in „Weiterschauen" steht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit die Beschriftung dasselbe sagt wie die Reihe.</b> Web und Apps
|
||||||
|
/// entschieden „Weiterschauen" gegen „Abspielen" selbst, allein daran, ob
|
||||||
|
/// eine Stelle über null steht. Seit es eine einstellbare Untergrenze gibt,
|
||||||
|
/// lief das auseinander: ein Titel, in den man eine Sekunde hineingeschaut
|
||||||
|
/// hat, stand nicht mehr in der Reihe — hiess aber weiter „Weiterschauen".
|
||||||
|
///
|
||||||
|
/// Der Server hat diese Frage ohnehin beantwortet; sie noch einmal zu
|
||||||
|
/// stellen hiesse, dieselbe Regel an vier Stellen zu pflegen.
|
||||||
|
/// </remarks>
|
||||||
|
bool InProgress = false);
|
||||||
|
|
||||||
/// <summary>Eine Zeile in „Weiterschauen".</summary>
|
/// <summary>Eine Zeile in „Weiterschauen".</summary>
|
||||||
///
|
///
|
||||||
@@ -235,11 +714,77 @@ public sealed record ItemStateResponse(
|
|||||||
/// steht in <c>Title</c> die Serie und in <c>SubTitle</c> die Folge — so liest
|
/// steht in <c>Title</c> die Serie und in <c>SubTitle</c> die Folge — so liest
|
||||||
/// sich die Kachel wie bei Plex: oben, worum es geht, darunter, wo man ist.
|
/// sich die Kachel wie bei Plex: oben, worum es geht, darunter, wo man ist.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <param name="Id">
|
||||||
|
/// Die Kennung des Titels selbst — bei einer Folge also die der Folge. Dorthin
|
||||||
|
/// geht der Fortschritt.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="SeriesId">
|
||||||
|
/// <b>Zu welcher Serie die Folge gehört</b> — <c>null</c> bei einem Film.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ohne diese Angabe führte die Kachel ins Leere.</b> Sie trug die
|
||||||
|
/// Folgenkennung und sollte damit eine Serienseite öffnen; die gibt es unter
|
||||||
|
/// dieser Kennung nie, und heraus kam „Diese Serie gibt es nicht mehr" —
|
||||||
|
/// ausgerechnet unter „Nächste Folge", wo man gerade weiterschauen wollte.
|
||||||
|
/// </para>
|
||||||
|
/// </param>
|
||||||
public sealed record ContinueEntry(
|
public sealed record ContinueEntry(
|
||||||
Guid Id, string Kind, string Title, string? SubTitle,
|
Guid Id, string Kind, string Title, string? SubTitle,
|
||||||
int? Season, int? Episode, int? Year,
|
int? Season, int? Episode, int? Year,
|
||||||
string? PosterImage, string? BackdropImage,
|
string? PosterImage, string? BackdropImage,
|
||||||
ItemStateResponse State);
|
ItemStateResponse State,
|
||||||
|
Guid? SeriesId = null,
|
||||||
|
|
||||||
|
/// <summary>Die Datei, die abgespielt würde.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit „Abspielen" abspielt.</b> Der Aufmacher der Startseite hatte
|
||||||
|
/// keine Datei zur Hand und konnte deshalb nur die Detailseite öffnen —
|
||||||
|
/// ein Knopf, auf dem „Abspielen" steht und der etwas anderes tut.
|
||||||
|
/// </remarks>
|
||||||
|
Guid? FileId = null,
|
||||||
|
double? DurationSeconds = null,
|
||||||
|
|
||||||
|
/// <summary>Ab wann abspielbar. In der Zukunft heisst: noch nicht.</summary>
|
||||||
|
DateTimeOffset? PublishAt = null,
|
||||||
|
|
||||||
|
/// <summary>Das breite Bild der Serie, zu der die Folge gehört.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Neben dem Folgenbild, nicht statt seiner.</b> Die Kachel will das
|
||||||
|
/// Bild der Folge — es zeigt, wo man steht. Der Aufmacher füllt die halbe
|
||||||
|
/// Seite und will das Bild der Serie; dort stand bisher das Folgenbild,
|
||||||
|
/// und wer das Serienbild selbst ausgesucht hatte, fand ein anderes vor.
|
||||||
|
/// </remarks>
|
||||||
|
string? SeriesBackdropImage = null);
|
||||||
|
|
||||||
|
/// <summary>Was jemand geschaut hat — nur für ihn selbst.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Zwei Arten Zahl, und der Unterschied gehört dazu.</b>
|
||||||
|
/// <c>LoggedSeconds</c> kommt aus dem Sehprotokoll und fängt an dem Tag an,
|
||||||
|
/// an dem es eingebaut wurde. <c>WatchedTitles</c> und
|
||||||
|
/// <c>WatchedRuntimeSeconds</c> kommen aus den Sehständen und sind für die
|
||||||
|
/// ganze Vergangenheit vollständig. Beides als eine Zahl auszugeben hiesse,
|
||||||
|
/// eine davon zu behaupten.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record StatistikResponse(
|
||||||
|
double LoggedSeconds,
|
||||||
|
int WatchedTitles,
|
||||||
|
double WatchedRuntimeSeconds,
|
||||||
|
DateTimeOffset? Since,
|
||||||
|
IReadOnlyList<Wochenwert> Weeks,
|
||||||
|
IReadOnlyList<Zuletztgesehen> Recent,
|
||||||
|
IReadOnlyList<Genrewert> Genres);
|
||||||
|
|
||||||
|
/// <param name="Start">Der Montag dieser Woche.</param>
|
||||||
|
public sealed record Wochenwert(DateOnly Start, double Seconds);
|
||||||
|
|
||||||
|
public sealed record Zuletztgesehen(
|
||||||
|
Guid Id, string Kind, string Title, string? SubTitle,
|
||||||
|
string? PosterImage, DateTimeOffset At, bool Watched);
|
||||||
|
|
||||||
|
public sealed record Genrewert(string Name, double Seconds);
|
||||||
|
|
||||||
/// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary>
|
/// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary>
|
||||||
public sealed record SessionResponse(
|
public sealed record SessionResponse(
|
||||||
@@ -250,3 +795,82 @@ public sealed record SessionResponse(
|
|||||||
/// <summary>Was der Server gerade tut.</summary>
|
/// <summary>Was der Server gerade tut.</summary>
|
||||||
public sealed record ServerStatusResponse(
|
public sealed record ServerStatusResponse(
|
||||||
int Playing, int Packaging, int Scanning, double Uptime);
|
int Playing, int Packaging, int Scanning, double Uptime);
|
||||||
|
|
||||||
|
/* ---------- Meldungen ---------- */
|
||||||
|
|
||||||
|
/// <summary>Was jemand an einem Titel gemeldet hat.</summary>
|
||||||
|
public sealed record MeldungRequest(
|
||||||
|
/// <summary><c>bild</c>, <c>ton</c>, <c>untertitel</c>, <c>daten</c> oder <c>sonstiges</c>.</summary>
|
||||||
|
string Kind,
|
||||||
|
string Comment,
|
||||||
|
/// <summary>Eine Stelle wie <c>0:34:12</c> — oder nichts.</summary>
|
||||||
|
string? Position = null,
|
||||||
|
/// <summary>Nur bei einer Serie: eine einzelne Staffel statt der ganzen.</summary>
|
||||||
|
int? SeasonNumber = null);
|
||||||
|
|
||||||
|
/// <summary>Eine Meldung, wie sie gelesen wird.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b><c>ItemId</c> ist die Kennung der Detailseite, nicht die des
|
||||||
|
/// Gemeldeten.</b> Bei einer Folge und bei einer Staffel steht hier die Serie —
|
||||||
|
/// die Detailseite gehört ihr, wie bei „Weiterschauen" auch. Was genau gemeint
|
||||||
|
/// ist, steht in <c>Target</c> und <c>SubTitle</c>.
|
||||||
|
///
|
||||||
|
/// <b><c>Title</c> ist leer, wenn der Titel nicht mehr sichtbar ist.</b> Ein
|
||||||
|
/// Administrator kann einen Titel zurückziehen, nachdem jemand ihn gemeldet
|
||||||
|
/// hat; die Meldung bleibt dann in der eigenen Liste des Melders stehen, nur
|
||||||
|
/// ohne Namen. Sie verschwinden zu lassen hiesse, jemandem seine eigene Eingabe
|
||||||
|
/// wegen einer fremden Entscheidung wegzunehmen.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record Meldungseintrag(
|
||||||
|
Guid Id,
|
||||||
|
/// <summary><c>movie</c>, <c>series</c>, <c>season</c> oder <c>episode</c>.</summary>
|
||||||
|
string Target,
|
||||||
|
Guid ItemId,
|
||||||
|
string Title,
|
||||||
|
/// <summary>„Staffel 2" oder „S2 · F5 — Der Pakt" — sonst <c>null</c>.</summary>
|
||||||
|
string? SubTitle,
|
||||||
|
string? PosterImage,
|
||||||
|
string Kind,
|
||||||
|
double? PositionSeconds,
|
||||||
|
string Comment,
|
||||||
|
DateTimeOffset ReportedAt,
|
||||||
|
string ReportedBy,
|
||||||
|
/// <summary><c>null</c> heisst: offen.</summary>
|
||||||
|
DateTimeOffset? ResolvedAt);
|
||||||
|
|
||||||
|
/* ---------- Einladungen ---------- */
|
||||||
|
|
||||||
|
/// <summary>Was beim Erstellen eines Einladungslinks gewählt wird.</summary>
|
||||||
|
public sealed record EinladungRequest(
|
||||||
|
/// <summary>Wie viele Tage er gilt.</summary>
|
||||||
|
int Days = 7,
|
||||||
|
/// <summary>Wie oft er sich einlösen lässt. <c>null</c> heisst: unbegrenzt.</summary>
|
||||||
|
int? MaxUses = 1);
|
||||||
|
|
||||||
|
/// <summary>Der frisch erstellte Link — der Token steht hier zum einzigen Mal.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Die Adresse baut der Client.</b> Der Server weiss nicht, unter welchem
|
||||||
|
/// Namen er von aussen erreichbar ist; hinter einem Reverse Proxy rät er
|
||||||
|
/// falsch, und ein falscher Einladungslink ist schlimmer als keiner.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record EinladungErstellt(Guid Id, string Token, DateTimeOffset ExpiresAt, int? MaxUses);
|
||||||
|
|
||||||
|
/// <summary>Eine Einladung in der Verwaltungsliste — ohne Token.</summary>
|
||||||
|
public sealed record Einladungseintrag(
|
||||||
|
Guid Id,
|
||||||
|
string CreatedBy,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset ExpiresAt,
|
||||||
|
int? MaxUses,
|
||||||
|
int UsedCount,
|
||||||
|
DateTimeOffset? RevokedAt,
|
||||||
|
/// <summary>Ob sich damit jetzt noch ein Konto anlegen lässt.</summary>
|
||||||
|
bool Valid);
|
||||||
|
|
||||||
|
/// <summary>Was die Einladungsseite zeigt, bevor jemand etwas eintippt.</summary>
|
||||||
|
public sealed record Einladungsschau(string ServerName, string InvitedBy, DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
|
/// <summary>Namen und Passwort, die sich der Eingeladene selbst gibt.</summary>
|
||||||
|
public sealed record EinloesenRequest(string UserName, string Password);
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ public static class DeliveryEndpoints
|
|||||||
// durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
|
// durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
|
||||||
// Genau das ist passiert: der Spieler bekam HTML statt Video.
|
// Genau das ist passiert: der Spieler bekam HTML statt Video.
|
||||||
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async (
|
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async (
|
||||||
Guid id, FlaroContext db, PackageCoordinator packer) =>
|
Guid id, FlaroContext db, PackageCoordinator packer, TimeProvider uhr) =>
|
||||||
{
|
{
|
||||||
var gefunden = await Laden(db, id);
|
var gefunden = await Laden(db, id);
|
||||||
if (gefunden is not var (datei, plan)) return Results.NotFound();
|
if (gefunden is not var (datei, plan)) return Results.NotFound();
|
||||||
|
|
||||||
|
if (await FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht) return NochNicht(nochNicht);
|
||||||
|
|
||||||
// Passt schon alles, geht die Datei unverändert hinaus — mit
|
// Passt schon alles, geht die Datei unverändert hinaus — mit
|
||||||
// Längenangabe und Bereichsanfragen, also mit Fortschritt und
|
// Längenangabe und Bereichsanfragen, also mit Fortschritt und
|
||||||
// Fortsetzen.
|
// Fortsetzen.
|
||||||
@@ -70,6 +72,41 @@ public static class DeliveryEndpoints
|
|||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Ab wann diese Datei herausgehen darf — <c>null</c> heisst: sofort.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Hier und nicht im Sichtbarkeitsfilter.</b> Ein geplanter Titel steht
|
||||||
|
/// mit „Verfügbar ab …" in der Mediathek — das ist der Sinn des Planens.
|
||||||
|
/// Sichtbar heisst aber nicht abspielbar: ohne diese Prüfung liesse sich
|
||||||
|
/// die Datei trotzdem herunterladen, und die Angabe wäre eine Zierde.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Für alle, auch für Administratoren.</b> Wer die Freigabe vorziehen
|
||||||
|
/// will, hat dafür den Knopf „Jetzt veröffentlichen" — eine Hintertür für
|
||||||
|
/// eine Rolle wäre eine Regel, die manchmal gilt.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static async Task<DateTimeOffset?> FreiAb(
|
||||||
|
FlaroContext db, MediaFile datei, DateTimeOffset jetzt)
|
||||||
|
{
|
||||||
|
DateTimeOffset? wann = datei.MovieId is { } film
|
||||||
|
? await db.Movies.AsNoTracking().Where(m => m.Id == film)
|
||||||
|
.Select(m => m.PublishAt).FirstOrDefaultAsync()
|
||||||
|
: datei.EpisodeId is { } folge
|
||||||
|
? await db.Episodes.AsNoTracking().Where(f => f.Id == folge)
|
||||||
|
.Select(f => f.PublishAt).FirstOrDefaultAsync()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return wann is { } termin && termin > jetzt ? termin : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Satz, der statt der Datei kommt.</summary>
|
||||||
|
internal static IResult NochNicht(DateTimeOffset wann) =>
|
||||||
|
Results.Problem(
|
||||||
|
$"Dieser Titel ist ab {wann.ToLocalTime():dd.MM.yyyy, HH:mm} Uhr verfügbar.",
|
||||||
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext db, Guid id)
|
private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext db, Guid id)
|
||||||
{
|
{
|
||||||
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
|
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Flaro.Core.Auth;
|
||||||
|
using Flaro.Core.Users;
|
||||||
|
using Flaro.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>Jemanden über einen Link einladen, statt ein Konto für ihn zu erfinden.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum.</b> Ein Konto von Hand anzulegen heisst, sich ein Passwort für
|
||||||
|
/// einen anderen auszudenken und es ihm durchzusagen — über denselben Kanal,
|
||||||
|
/// über den er den Link bekommen hätte. Wer eingeladen wird, soll sich Namen
|
||||||
|
/// und Passwort selbst geben.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Zwei Welten in einer Datei.</b> Erstellen, auflisten und zurückziehen
|
||||||
|
/// ist Verwaltung. Ansehen und Einlösen muss jemand können, der noch gar kein
|
||||||
|
/// Konto hat — also anonym, und damit gedrosselt: es gibt keine globale
|
||||||
|
/// Drossel in diesem Server, ein anonymer Endpunkt ohne eigene wäre
|
||||||
|
/// ungebremst.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class EinladungEndpoints
|
||||||
|
{
|
||||||
|
private const int HoechstensTage = 365;
|
||||||
|
|
||||||
|
public static void MapEinladungen(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var verwalten = app.MapGroup("/api/v1/invites")
|
||||||
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
verwalten.MapPost("", async (
|
||||||
|
EinladungRequest anfrage, ClaimsPrincipal wer,
|
||||||
|
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (Kennung(wer) is not { } ich) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var tage = Math.Clamp(anfrage.Days, 1, HoechstensTage);
|
||||||
|
if (anfrage.MaxUses is { } anzahl && anzahl < 1)
|
||||||
|
return Einwand.Bitte("Ein Link, der nullmal gilt, ist keiner.");
|
||||||
|
|
||||||
|
// **Das Geheimnis verlässt den Server genau hier.** Gespeichert
|
||||||
|
// wird nur sein Fingerabdruck; aus der Datenbank lässt sich kein
|
||||||
|
// Link mehr bauen, und eine verlorene Einladung wird deshalb nicht
|
||||||
|
// wiedergefunden, sondern neu gemacht.
|
||||||
|
var token = Secrets.Create();
|
||||||
|
var jetzt = uhr.GetUtcNow();
|
||||||
|
var einladung = new Einladung
|
||||||
|
{
|
||||||
|
TokenFingerprint = Secrets.Fingerprint(token),
|
||||||
|
CreatedByUserId = ich,
|
||||||
|
CreatedAt = jetzt,
|
||||||
|
ExpiresAt = jetzt.AddDays(tage),
|
||||||
|
MaxUses = anfrage.MaxUses,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Einladungen.Add(einladung);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new EinladungErstellt(
|
||||||
|
einladung.Id, token, einladung.ExpiresAt, einladung.MaxUses));
|
||||||
|
});
|
||||||
|
|
||||||
|
verwalten.MapGet("", async (FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var jetzt = uhr.GetUtcNow();
|
||||||
|
var roh = await db.Einladungen.AsNoTracking()
|
||||||
|
.OrderByDescending(e => e.CreatedAt)
|
||||||
|
.Join(db.Users.AsNoTracking(), e => e.CreatedByUserId, u => u.Id,
|
||||||
|
(e, u) => new { E = e, u.UserName })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(roh.Select(z => new Einladungseintrag(
|
||||||
|
z.E.Id, z.UserName, z.E.CreatedAt, z.E.ExpiresAt,
|
||||||
|
z.E.MaxUses, z.E.UsedCount, z.E.RevokedAt, z.E.Gilt(jetzt))).ToList());
|
||||||
|
});
|
||||||
|
|
||||||
|
// **Zurückziehen, nicht löschen.** Eine Zeile wegzuwerfen hiesse,
|
||||||
|
// hinterher nicht mehr zu wissen, dass es den Link je gab — und genau
|
||||||
|
// das will man wissen, wenn jemand Unerwartetes im Haushalt auftaucht.
|
||||||
|
verwalten.MapDelete("/{id:guid}", async (
|
||||||
|
Guid id, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var einladung = await db.Einladungen.FirstOrDefaultAsync(e => e.Id == id, ct);
|
||||||
|
if (einladung is null) return Einwand.Fehlt("Diese Einladung gibt es nicht mehr.");
|
||||||
|
|
||||||
|
einladung.RevokedAt ??= uhr.GetUtcNow();
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- Was ohne Konto erreichbar sein muss ---------- */
|
||||||
|
|
||||||
|
app.MapGet("/api/v1/invites/{token}", async (
|
||||||
|
string token, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var gefunden = await Suchen(db, token, ct);
|
||||||
|
if (Pruefen(gefunden, uhr.GetUtcNow()) is { } einwand) return einwand;
|
||||||
|
|
||||||
|
var name = await db.Users.AsNoTracking()
|
||||||
|
.Where(u => u.Id == gefunden!.CreatedByUserId)
|
||||||
|
.Select(u => u.UserName).FirstOrDefaultAsync(ct) ?? "";
|
||||||
|
|
||||||
|
var servername = await db.Settings.AsNoTracking()
|
||||||
|
.Where(s => s.Key == SystemEndpoints.NameSchlüssel)
|
||||||
|
.Select(s => s.Value).FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new Einladungsschau(
|
||||||
|
string.IsNullOrWhiteSpace(servername) ? "Flaro" : servername,
|
||||||
|
name, gefunden!.ExpiresAt));
|
||||||
|
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
|
||||||
|
|
||||||
|
app.MapPost("/api/v1/invites/{token}/redeem", async (
|
||||||
|
string token, EinloesenRequest anfrage,
|
||||||
|
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var name = anfrage.UserName?.Trim() ?? "";
|
||||||
|
if (name.Length == 0 || (anfrage.Password?.Length ?? 0) < 8)
|
||||||
|
{
|
||||||
|
return Einwand.Bitte(
|
||||||
|
"Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var jetzt = uhr.GetUtcNow();
|
||||||
|
var gefunden = await Suchen(db, token, ct);
|
||||||
|
if (Pruefen(gefunden, jetzt) is { } einwand) return einwand;
|
||||||
|
|
||||||
|
// **Verbrauchen und anlegen gehören zusammen.**
|
||||||
|
//
|
||||||
|
// Zwei Leute, die gleichzeitig denselben einmaligen Link öffnen,
|
||||||
|
// läsen beide `UsedCount = 0` und schrieben beide `1` — und hätten
|
||||||
|
// beide ein Konto. Der Zähler wird deshalb mit einer Bedingung
|
||||||
|
// hochgesetzt, die die Gültigkeit noch einmal prüft; wer dabei
|
||||||
|
// keine Zeile trifft, war der Zweite.
|
||||||
|
//
|
||||||
|
// Und das Ganze in einer Transaktion: scheitert das Anlegen am
|
||||||
|
// Namen, rollt der Verbrauch mit zurück. Sonst wäre ein einmaliger
|
||||||
|
// Link an einem Tippfehler verbrannt.
|
||||||
|
await using var geschaeft = await db.Database.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var verbraucht = await db.Einladungen
|
||||||
|
.Where(e => e.Id == gefunden!.Id
|
||||||
|
&& e.RevokedAt == null
|
||||||
|
&& e.ExpiresAt > jetzt
|
||||||
|
&& (e.MaxUses == null || e.UsedCount < e.MaxUses))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(e => e.UsedCount, e => e.UsedCount + 1), ct);
|
||||||
|
|
||||||
|
if (verbraucht != 1)
|
||||||
|
return Einwand.Bitte("Dieser Einladungslink wurde schon benutzt.");
|
||||||
|
|
||||||
|
if (await db.Users.AnyAsync(u => u.UserName == name, ct))
|
||||||
|
return Einwand.Belegt($"„{name}“ gibt es schon. Nimm einen anderen.");
|
||||||
|
|
||||||
|
db.Users.Add(new User
|
||||||
|
{
|
||||||
|
UserName = name,
|
||||||
|
PasswordHash = PasswordHasher.Hash(anfrage.Password!),
|
||||||
|
IsAdministrator = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await geschaeft.CommitAsync(ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<Einladung?> Suchen(FlaroContext db, string token, CancellationToken ct) =>
|
||||||
|
db.Einladungen.FirstOrDefaultAsync(
|
||||||
|
e => e.TokenFingerprint == Secrets.Fingerprint(token ?? ""), ct);
|
||||||
|
|
||||||
|
/// <summary>Der Grund, warum ein Link nicht gilt — oder <c>null</c>.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Unterschiedliche Sätze, mit Absicht.</b> Alles in ein „gilt nicht"
|
||||||
|
/// zusammenzufassen wäre verschwiegener, aber hier ist Verschwiegenheit
|
||||||
|
/// am falschen Platz: wer einen Link bekommen hat und abgewiesen wird,
|
||||||
|
/// muss wissen, ob er zu spät ist oder ob jemand ihn zurückgezogen hat —
|
||||||
|
/// sonst fragt er nach, und der Gastgeber rät mit.
|
||||||
|
/// </remarks>
|
||||||
|
private static IResult? Pruefen(Einladung? einladung, DateTimeOffset jetzt) => einladung switch
|
||||||
|
{
|
||||||
|
null => Einwand.Fehlt("Diesen Einladungslink gibt es nicht."),
|
||||||
|
{ RevokedAt: not null } => Einwand.Bitte("Dieser Einladungslink wurde zurückgezogen."),
|
||||||
|
var e when jetzt >= e.ExpiresAt => Einwand.Bitte("Dieser Einladungslink ist abgelaufen."),
|
||||||
|
var e when e.MaxUses is { } h && e.UsedCount >= h =>
|
||||||
|
Einwand.Bitte("Dieser Einladungslink wurde schon benutzt."),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Guid? Kennung(ClaimsPrincipal wer) =>
|
||||||
|
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using Flaro.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>Neue Funde prüfen, veröffentlichen oder auf einen Zeitpunkt legen.</summary>
|
||||||
|
public static class FreigabeEndpoints
|
||||||
|
{
|
||||||
|
public static void MapFreigabe(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var gruppe = app.MapGroup("/api/v1/review")
|
||||||
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Was noch wartet, und was schon geplant ist.**
|
||||||
|
//
|
||||||
|
// Beides in einer Liste: ein geplanter Titel ist nicht erledigt, er
|
||||||
|
// hat nur schon einen Termin. Ihn verschwinden zu lassen hiesse, dass
|
||||||
|
// man ihn nur findet, wenn man sich den Termin gemerkt hat.
|
||||||
|
gruppe.MapGet("/", async (FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var jetzt = uhr.GetUtcNow();
|
||||||
|
|
||||||
|
var filme = await db.Movies.AsNoTracking()
|
||||||
|
.Where(m => m.PublishAt == null || m.PublishAt > jetzt || m.ChangedSince != null)
|
||||||
|
.OrderBy(m => m.SortTitleValue)
|
||||||
|
.Select(m => new Pruefeintrag(
|
||||||
|
"movie", m.Id, m.Title, m.Year, m.PosterImage, m.PublishAt,
|
||||||
|
db.MediaFiles.Where(f => f.MovieId == m.Id).Select(f => f.Path).FirstOrDefault(),
|
||||||
|
m.TmdbId != null, 1, new List<Guid> { m.Id },
|
||||||
|
// **Neu sticht geändert.** Ein Titel, der noch gar nicht
|
||||||
|
// sichtbar ist, braucht eine Freigabe — dass sich dabei
|
||||||
|
// auch etwas geändert hat, ist die kleinere Nachricht.
|
||||||
|
m.PublishAt == null || m.PublishAt > jetzt ? "neu" : "geaendert"))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// **Folgen werden nach Serie gebündelt.** Zwanzig neue Folgen
|
||||||
|
// derselben Serie sind eine Entscheidung, nicht zwanzig.
|
||||||
|
var offeneFolgen = await db.Episodes.AsNoTracking()
|
||||||
|
.Where(f => f.PublishAt == null || f.PublishAt > jetzt)
|
||||||
|
.Select(f => new
|
||||||
|
{
|
||||||
|
f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.PublishAt,
|
||||||
|
Pfad = db.MediaFiles.Where(d => d.EpisodeId == f.Id)
|
||||||
|
.Select(d => d.Path).FirstOrDefault(),
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var serienIds = offeneFolgen.Select(f => f.SeriesId).Distinct().ToList();
|
||||||
|
var serien = await db.Series.AsNoTracking()
|
||||||
|
.Where(s => serienIds.Contains(s.Id))
|
||||||
|
.Select(s => new { s.Id, s.Title, s.Year, s.PosterImage, Zugeordnet = s.TmdbId != null })
|
||||||
|
.ToDictionaryAsync(s => s.Id, ct);
|
||||||
|
|
||||||
|
var gebuendelt = offeneFolgen
|
||||||
|
.GroupBy(f => f.SeriesId)
|
||||||
|
.Select(g =>
|
||||||
|
{
|
||||||
|
var serie = serien.GetValueOrDefault(g.Key);
|
||||||
|
var termine = g.Select(f => f.PublishAt).Distinct().ToList();
|
||||||
|
|
||||||
|
return new Pruefeintrag(
|
||||||
|
"series", g.Key,
|
||||||
|
serie?.Title ?? "Unbekannte Serie",
|
||||||
|
serie?.Year,
|
||||||
|
serie?.PosterImage,
|
||||||
|
// Nur wenn alle denselben Termin tragen — sonst wäre es
|
||||||
|
// eine Behauptung über Folgen, die anders liegen.
|
||||||
|
termine.Count == 1 ? termine[0] : null,
|
||||||
|
Pfad(g.Select(f => f.Pfad).FirstOrDefault()),
|
||||||
|
serie?.Zugeordnet ?? false,
|
||||||
|
g.Count(),
|
||||||
|
g.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
||||||
|
.Select(f => f.Id).ToList());
|
||||||
|
})
|
||||||
|
.OrderBy(e => e.Title)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// **Geänderte Serien stehen für sich.** Die Bündelung oben geht
|
||||||
|
// über wartende Folgen; eine Serie, deren Titel oder Plakat sich
|
||||||
|
// geändert hat, hat damit nichts zu tun — ihre Folgen sind längst
|
||||||
|
// sichtbar.
|
||||||
|
var schonDa = gebuendelt.Select(e => e.Id).ToHashSet();
|
||||||
|
var geaenderteSerien = await db.Series.AsNoTracking()
|
||||||
|
.Where(x => x.ChangedSince != null && !schonDa.Contains(x.Id))
|
||||||
|
.OrderBy(x => x.SortTitleValue)
|
||||||
|
.Select(x => new Pruefeintrag(
|
||||||
|
"series", x.Id, x.Title, x.Year, x.PosterImage, null,
|
||||||
|
null, x.TmdbId != null,
|
||||||
|
db.Episodes.Count(f => f.SeriesId == x.Id),
|
||||||
|
new List<Guid> { x.Id },
|
||||||
|
"geaendert"))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(filme.Concat(gebuendelt).Concat(geaenderteSerien).ToList());
|
||||||
|
|
||||||
|
// Bei einer Serie sagt der Ordner mehr als die erste Folgendatei.
|
||||||
|
static string? Pfad(string? datei) =>
|
||||||
|
datei is null ? null : System.IO.Path.GetDirectoryName(datei);
|
||||||
|
});
|
||||||
|
|
||||||
|
// **Zur Kenntnis genommen.**
|
||||||
|
//
|
||||||
|
// Für Titel, die längst sichtbar sind und sich nur geändert haben.
|
||||||
|
// Veröffentlichen wäre dort das falsche Wort — es gibt nichts
|
||||||
|
// freizugeben, nur etwas abzuhaken.
|
||||||
|
gruppe.MapPost("/acknowledge", async (
|
||||||
|
FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var ids = anfrage.Ids ?? [];
|
||||||
|
|
||||||
|
var filme = ids.Count == 0
|
||||||
|
? await db.Movies.Where(m => m.ChangedSince != null)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.ChangedSince, (DateTimeOffset?)null), ct)
|
||||||
|
: await db.Movies.Where(m => ids.Contains(m.Id) && m.ChangedSince != null)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.ChangedSince, (DateTimeOffset?)null), ct);
|
||||||
|
|
||||||
|
var serien = ids.Count == 0
|
||||||
|
? await db.Series.Where(x => x.ChangedSince != null)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ChangedSince, (DateTimeOffset?)null), ct)
|
||||||
|
: await db.Series.Where(x => ids.Contains(x.Id) && x.ChangedSince != null)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ChangedSince, (DateTimeOffset?)null), ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { Acknowledged = filme + serien });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Veröffentlichen oder planen. Ohne Zeitpunkt heisst: jetzt.
|
||||||
|
gruppe.MapPost("/publish", async (
|
||||||
|
FreigabeRequest anfrage, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var wann = anfrage.PublishAt ?? uhr.GetUtcNow();
|
||||||
|
|
||||||
|
// Alles Wartende einer einzelnen Mediathek — der Knopf, der dort
|
||||||
|
// steht, wo man gerade gescannt hat.
|
||||||
|
if (anfrage.LibraryId is { } mediathek)
|
||||||
|
{
|
||||||
|
var jetztDort = uhr.GetUtcNow();
|
||||||
|
|
||||||
|
var dortFilme = await db.Movies
|
||||||
|
.Where(m => m.LibraryId == mediathek
|
||||||
|
&& (m.PublishAt == null || m.PublishAt > jetztDort))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct);
|
||||||
|
|
||||||
|
var dortFolgen = await db.Episodes
|
||||||
|
.Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek)
|
||||||
|
&& (f.PublishAt == null || f.PublishAt > jetztDort))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { published = dortFilme + dortFolgen });
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Alles auf einmal, wenn keine Kennungen dabei sind.** Bei
|
||||||
|
// vierhundert neuen Titeln wäre einzeln Klicken keine Bedienung.
|
||||||
|
if (anfrage.Ids is null or { Count: 0 })
|
||||||
|
{
|
||||||
|
// **Alles, was noch nicht sichtbar ist** — Wartendes und
|
||||||
|
// Geplantes. Nur das Wartende zu fassen hiesse: ein auf
|
||||||
|
// nächste Woche gelegter Titel bliebe liegen, obwohl er in
|
||||||
|
// derselben Liste stand und „Alle veröffentlichen" gedrückt
|
||||||
|
// wurde.
|
||||||
|
var jetzt = uhr.GetUtcNow();
|
||||||
|
|
||||||
|
var alleFilme = await db.Movies
|
||||||
|
.Where(m => m.PublishAt == null || m.PublishAt > jetzt)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct);
|
||||||
|
var alleFolgen = await db.Episodes
|
||||||
|
.Where(f => f.PublishAt == null || f.PublishAt > jetzt)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { published = alleFilme + alleFolgen });
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids = anfrage.Ids;
|
||||||
|
var filme = await db.Movies.Where(m => ids.Contains(m.Id))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct);
|
||||||
|
var folgen = await db.Episodes.Where(f => ids.Contains(f.Id))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { published = filme + folgen });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zurückziehen — wieder in die Prüfung.
|
||||||
|
gruppe.MapPost("/withhold", async (
|
||||||
|
FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (anfrage.Ids is null or { Count: 0 })
|
||||||
|
return Einwand.Bitte("Ohne Kennungen gibt es nichts zurückzuziehen.");
|
||||||
|
|
||||||
|
var ids = anfrage.Ids;
|
||||||
|
var filme = await db.Movies.Where(m => ids.Contains(m.Id))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, (DateTimeOffset?)null), ct);
|
||||||
|
var folgen = await db.Episodes.Where(f => ids.Contains(f.Id))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, (DateTimeOffset?)null), ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { withheld = filme + folgen });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ public static class ImageEndpoints
|
|||||||
// Der Schlüssel ist der Hash des Inhalts, also ändert sich das Bild
|
// Der Schlüssel ist der Hash des Inhalts, also ändert sich das Bild
|
||||||
// unter dieser Adresse nie. Ein Jahr zwischenspeichern spart bei
|
// unter dieser Adresse nie. Ein Jahr zwischenspeichern spart bei
|
||||||
// einer Mediathek mit tausend Plakaten das meiste an Verkehr.
|
// einer Mediathek mit tausend Plakaten das meiste an Verkehr.
|
||||||
return Results.File(pfad, "image/jpeg", enableRangeProcessing: true);
|
return Results.File(pfad, ImageCache.MedientypVon(pfad), enableRangeProcessing: true);
|
||||||
})
|
})
|
||||||
.RequireAuthorization()
|
.RequireAuthorization()
|
||||||
.AddEndpointFilter(async (zusammenhang, weiter) =>
|
.AddEndpointFilter(async (zusammenhang, weiter) =>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using Flaro.Api.Auth;
|
||||||
|
using Flaro.Core.Library;
|
||||||
|
using Flaro.Core.Media;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Flaro.Core.Playback;
|
using Flaro.Core.Playback;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
@@ -16,6 +19,34 @@ public static class LibraryContentEndpoints
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const int HöchstensJeSeite = 200;
|
private const int HöchstensJeSeite = 200;
|
||||||
|
|
||||||
|
/// <summary>Wie viele Titel eine Auswahl höchstens umfasst.</summary>
|
||||||
|
private const int HöchstensJeZug = 1000;
|
||||||
|
|
||||||
|
/// <summary>Welche Titel im Rumpf stehen — oder <c>null</c>, wenn keiner da ist.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Ein fehlender Rumpf ist kein Fehler, sondern die Voreinstellung.</b>
|
||||||
|
/// „Alles auffrischen" schickt nichts mit, und das muss so bleiben: der
|
||||||
|
/// Knopf dafür gibt es länger als die Auswahl.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<IReadOnlyList<Guid>?> Auswahl(HttpRequest anfrage)
|
||||||
|
{
|
||||||
|
if (anfrage.ContentLength is null or 0) return null;
|
||||||
|
if (!anfrage.HasJsonContentType()) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var gelesen = await anfrage.ReadFromJsonAsync<RefreshRequest>();
|
||||||
|
return gelesen?.Ids;
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException)
|
||||||
|
{
|
||||||
|
// Unlesbarer Rumpf heisst: keine Auswahl. Alles aufzufrischen ist
|
||||||
|
// hier die harmlosere Auslegung — und sie kostet nur Zeit.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Wonach eine Liste geordnet wird.</summary>
|
/// <summary>Wonach eine Liste geordnet wird.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -57,13 +88,84 @@ public static class LibraryContentEndpoints
|
|||||||
|
|
||||||
return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse(
|
return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse(
|
||||||
s.Index, s.Kind.ToString(), s.Codec, s.Language, s.Title,
|
s.Index, s.Kind.ToString(), s.Codec, s.Language, s.Title,
|
||||||
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height));
|
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height,
|
||||||
|
Display: s.Kind switch
|
||||||
|
{
|
||||||
|
MediaStreamKind.Video => Benennung.Bildspur(s.Codec, s.Width, s.Height),
|
||||||
|
MediaStreamKind.Audio => Benennung.Tonspur(s.Language, s.Codec, s.Channels, s.Title),
|
||||||
|
_ => Benennung.Untertitelspur(s.Language, s.IsForced, fuerHoergeschaedigte: false),
|
||||||
|
},
|
||||||
|
LanguageName: Benennung.Sprache(s.Language),
|
||||||
|
ResolutionLabel: s.Kind == MediaStreamKind.Video
|
||||||
|
? Benennung.Aufloesungswort(s.Width, s.Height) : null,
|
||||||
|
ResolutionExact: s.Kind == MediaStreamKind.Video
|
||||||
|
? Benennung.Aufloesung(s.Width, s.Height) : null));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MediaFileResponse Datei(
|
private static MediaFileResponse Datei(
|
||||||
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
|
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
|
||||||
ILookup<Guid, MediaStreamResponse> spuren) =>
|
ILookup<Guid, MediaStreamResponse> spuren, string? fassung = null,
|
||||||
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList());
|
string? mediathekspfad = null) =>
|
||||||
|
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds,
|
||||||
|
spuren[id].ToList(),
|
||||||
|
// **Drei Quellen, in dieser Reihenfolge.** Was in der Datenbank
|
||||||
|
// steht, kam aus dem Dateinamen und ist das Genaueste. Sonst wird
|
||||||
|
// der Pfad gelesen — ein Bestand von vor dieser Änderung soll
|
||||||
|
// nicht erst neu gescannt werden müssen, um „Mobil" zu zeigen.
|
||||||
|
// Und sonst die Messung: hier gerechnet und nicht gespeichert,
|
||||||
|
// damit ein neues Wort nicht erst einen Scan braucht.
|
||||||
|
fassung ?? FileNames.Fassung(pfad) ?? AusDenSpuren(spuren[id]),
|
||||||
|
Ordner(pfad, mediathekspfad));
|
||||||
|
|
||||||
|
/// <summary>Die Fassung aus der gemessenen Bildgrösse — „Full HD", „SD".</summary>
|
||||||
|
private static string? AusDenSpuren(IEnumerable<MediaStreamResponse> spuren) =>
|
||||||
|
spuren.FirstOrDefault(s => s.Kind == nameof(MediaStreamKind.Video))?.ResolutionLabel;
|
||||||
|
|
||||||
|
/// <summary>Der Pfad der Mediathek — nur für Administratoren.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Hier entscheidet sich, wer den Ordner sieht.</b> Ohne Wurzel bleibt
|
||||||
|
/// <see cref="Ordner"/> bei <c>null</c>, und das Feld kommt erst gar nicht
|
||||||
|
/// in der Antwort an — statt es im Client zu verstecken, wo es jeder
|
||||||
|
/// auslesen könnte.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<string?> MediathekspfadFuer(
|
||||||
|
FlaroContext db, ClaimsPrincipal wer, Guid bibliothek)
|
||||||
|
{
|
||||||
|
if (!wer.IsInRole(FlaroRoles.Administrator)) return null;
|
||||||
|
|
||||||
|
return await db.Libraries.AsNoTracking()
|
||||||
|
.Where(l => l.Id == bibliothek)
|
||||||
|
.Select(l => l.Path)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Ordner unterhalb der Mediathek — oder <c>null</c>.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Nur der Teil unterhalb, nie der volle Pfad.</b> Der verriete jedem
|
||||||
|
/// Angemeldeten den Aufbau des Servers; was zum Beurteilen einer Zuordnung
|
||||||
|
/// nötig ist, ist der Ordnername — „Hanni und Nanni" neben dem Titel
|
||||||
|
/// „Hanni & Nanni 3" sagt in einer Sekunde, dass etwas nicht stimmt.
|
||||||
|
///
|
||||||
|
/// <c>null</c> für alle, die nicht verwalten dürfen: dort wird der
|
||||||
|
/// Mediathekspfad gar nicht erst hereingereicht.
|
||||||
|
/// </remarks>
|
||||||
|
private static string? Ordner(string pfad, string? mediathekspfad)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(mediathekspfad)) return null;
|
||||||
|
|
||||||
|
var ordner = System.IO.Path.GetDirectoryName(pfad);
|
||||||
|
if (string.IsNullOrEmpty(ordner)) return null;
|
||||||
|
|
||||||
|
var wurzel = mediathekspfad.TrimEnd('/', '\\');
|
||||||
|
if (!ordner.StartsWith(wurzel, StringComparison.Ordinal)) return null;
|
||||||
|
|
||||||
|
var darunter = ordner[wurzel.Length..].Trim('/', '\\');
|
||||||
|
// Liegt die Datei unmittelbar in der Mediathek, gibt es keinen Ordner
|
||||||
|
// — und „" wäre eine Zeile, die nichts sagt.
|
||||||
|
return darunter.Length > 0 ? darunter : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
|
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
|
||||||
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
|
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
|
||||||
@@ -91,22 +193,25 @@ public static class LibraryContentEndpoints
|
|||||||
/// noch niemand angefasst hat, und die Antwort trägt dort <c>null</c>.
|
/// noch niemand angefasst hat, und die Antwort trägt dort <c>null</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
|
private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
|
||||||
FlaroContext db, ClaimsPrincipal wer, List<Guid>? filme = null, List<Guid>? folgen = null)
|
FlaroContext db, ClaimsPrincipal wer,
|
||||||
|
List<Guid>? filme = null, List<Guid>? folgen = null, List<Guid>? serien = null)
|
||||||
{
|
{
|
||||||
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return [];
|
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return [];
|
||||||
|
|
||||||
var gesucht = (filme ?? folgen)!;
|
var gesucht = (filme ?? folgen ?? serien)!;
|
||||||
if (gesucht.Count == 0) return [];
|
if (gesucht.Count == 0) return [];
|
||||||
|
|
||||||
var zeilen = await db.UserItemStates.AsNoTracking()
|
var zeilen = await db.UserItemStates.AsNoTracking()
|
||||||
.Where(z => z.UserId == benutzer)
|
.Where(z => z.UserId == benutzer)
|
||||||
.Where(z => filme != null
|
.Where(z => filme != null
|
||||||
? z.MovieId != null && gesucht.Contains(z.MovieId!.Value)
|
? z.MovieId != null && gesucht.Contains(z.MovieId!.Value)
|
||||||
: z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value))
|
: folgen != null
|
||||||
|
? z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value)
|
||||||
|
: z.SeriesId != null && gesucht.Contains(z.SeriesId!.Value))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return zeilen.ToDictionary(
|
return zeilen.ToDictionary(
|
||||||
z => (filme != null ? z.MovieId : z.EpisodeId)!.Value,
|
z => (filme != null ? z.MovieId : folgen != null ? z.EpisodeId : z.SeriesId)!.Value,
|
||||||
PlaybackEndpoints.Abbild);
|
PlaybackEndpoints.Abbild);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,12 +252,33 @@ public static class LibraryContentEndpoints
|
|||||||
// erst beim nächsten neuen Film. Geholt wird über die bekannte
|
// erst beim nächsten neuen Film. Geholt wird über die bekannte
|
||||||
// Kennung, nicht über eine neue Suche; eine Zuordnung von Hand
|
// Kennung, nicht über eine neue Suche; eine Zuordnung von Hand
|
||||||
// bleibt damit stehen.
|
// bleibt damit stehen.
|
||||||
|
//
|
||||||
|
// **Mit einer Auswahl im Rumpf nur diese Titel.** Zehn ausgewählte
|
||||||
|
// Filme kosten zehn Anfragen bei TMDB; der ganze Bestand viertausend
|
||||||
|
// und eine Viertelstunde. Ohne Rumpf bleibt es beim ganzen Bestand.
|
||||||
|
//
|
||||||
|
// **Der Rumpf wird von Hand gelesen, und das hat einen Grund.** Als
|
||||||
|
// `RefreshRequest?` im Kopf der Methode lehnte der Server jede Anfrage
|
||||||
|
// ohne Rumpf mit 404 ab — also genau den Aufruf „die ganze Mediathek
|
||||||
|
// auffrischen", den es vorher schon gab. Gemessen, nicht vermutet: ohne
|
||||||
|
// Rumpf 404, mit `{}` 202.
|
||||||
app.MapPost("/api/v1/libraries/{id:guid}/refresh", async (
|
app.MapPost("/api/v1/libraries/{id:guid}/refresh", async (
|
||||||
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
|
Guid id, HttpRequest anfrage, FlaroContext db, ScanCoordinator koordinator) =>
|
||||||
{
|
{
|
||||||
if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound();
|
if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound();
|
||||||
|
|
||||||
return koordinator.TryStart(id, erneut: true)
|
IReadOnlySet<Guid>? nurDiese = null;
|
||||||
|
if (await Auswahl(anfrage) is { Count: > 0 } gewaehlt)
|
||||||
|
{
|
||||||
|
if (gewaehlt.Count > HöchstensJeZug)
|
||||||
|
return Results.Problem(
|
||||||
|
$"Mehr als {HöchstensJeZug} Titel auf einmal gehen nicht. "
|
||||||
|
+ "Nimm weniger und wiederhole es.",
|
||||||
|
statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
nurDiese = gewaehlt.ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
return koordinator.TryStart(id, erneut: true, nurDiese: nurDiese)
|
||||||
? Results.AcceptedAtRoute("Scanstand", new { id })
|
? Results.AcceptedAtRoute("Scanstand", new { id })
|
||||||
: Results.Problem(
|
: Results.Problem(
|
||||||
"Für diese Bibliothek läuft bereits ein Lauf.",
|
"Für diese Bibliothek läuft bereits ein Lauf.",
|
||||||
@@ -175,20 +301,90 @@ public static class LibraryContentEndpoints
|
|||||||
zustand.Error,
|
zustand.Error,
|
||||||
metadaten is null ? null : new MetadataProgress(
|
metadaten is null ? null : new MetadataProgress(
|
||||||
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
|
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
|
||||||
metadaten.Skipped, metadaten.Error)));
|
metadaten.Skipped, metadaten.Error),
|
||||||
|
bericht?.Missing,
|
||||||
|
zustand.Schritt?.Text()));
|
||||||
}).WithName("Scanstand").RequireAuthorization();
|
}).WithName("Scanstand").RequireAuthorization();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Ähnliche Titel — aus dem eigenen Bestand, nicht aus dem Netz.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nur, was man besitzt.</b> Ein Vorschlag, den man nicht abspielen
|
||||||
|
/// kann, ist Werbung. TMDB hätte eine Empfehlungsliste, aber die besteht
|
||||||
|
/// zur Hälfte aus Filmen, die hier nicht liegen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Aus Genres und Menschen, ohne eine einzige fremde Anfrage.</b> Der
|
||||||
|
/// Katalog steht ohnehin in der Datenbank. Ein gemeinsamer Mensch wiegt
|
||||||
|
/// schwerer als ein gemeinsames Genre: dass zwei Filme „Drama" sind, sagt
|
||||||
|
/// wenig; dass beide von derselben Regie stammen, sagt viel.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<List<Guid>> AehnlicheAsync(
|
||||||
|
FlaroContext db, Guid filmId, int wieViele)
|
||||||
|
{
|
||||||
|
var genres = await db.GenreLinks.Where(g => g.MovieId == filmId)
|
||||||
|
.Select(g => g.GenreId).ToListAsync();
|
||||||
|
var leute = await db.Credits.Where(c => c.MovieId == filmId)
|
||||||
|
.Select(c => c.PersonId).ToListAsync();
|
||||||
|
|
||||||
|
if (genres.Count == 0 && leute.Count == 0) return [];
|
||||||
|
|
||||||
|
var bibliothek = await db.Movies.Where(m => m.Id == filmId)
|
||||||
|
.Select(m => m.LibraryId).FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
var punkte = await db.Movies
|
||||||
|
.Where(m => m.Id != filmId && m.LibraryId == bibliothek)
|
||||||
|
.Select(m => new
|
||||||
|
{
|
||||||
|
m.Id,
|
||||||
|
Punkte =
|
||||||
|
db.GenreLinks.Count(g => g.MovieId == m.Id && genres.Contains(g.GenreId))
|
||||||
|
+ db.Credits.Count(c => c.MovieId == m.Id && leute.Contains(c.PersonId)) * 3,
|
||||||
|
})
|
||||||
|
.Where(x => x.Punkte > 0)
|
||||||
|
.OrderByDescending(x => x.Punkte)
|
||||||
|
.Take(wieViele)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return punkte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Sprache dieses Benutzers — oder <c>null</c> für die des Servers.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Einmal je Anfrage, nicht je Zeile.</b> Ein Abruf für fünfzig Titel
|
||||||
|
/// wäre fünfzig Abfragen für eine Angabe, die sich dabei nicht ändert.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<string?> SpracheVon(ClaimsPrincipal wer, FlaroContext db)
|
||||||
|
{
|
||||||
|
if (PlaybackEndpoints.Benutzer(wer) is not { } kennung) return null;
|
||||||
|
return await db.Users.Where(u => u.Id == kennung).Select(u => u.Language).FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private static void MapBrowse(IEndpointRouteBuilder app)
|
private static void MapBrowse(IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
app.MapGet("/api/v1/movies", async (
|
app.MapGet("/api/v1/movies", async (
|
||||||
ClaimsPrincipal wer, FlaroContext db,
|
ClaimsPrincipal wer, FlaroContext db,
|
||||||
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
|
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
|
||||||
{
|
{
|
||||||
|
var sprache = await SpracheVon(wer, db);
|
||||||
var abfrage = db.Movies.AsNoTracking();
|
var abfrage = db.Movies.AsNoTracking();
|
||||||
if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung);
|
if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung);
|
||||||
|
// **Über beide Titel.** Wer „Despicable Me" tippt, soll „Ich einfach
|
||||||
|
// unverbesserlich" finden — und umgekehrt. Nur über den
|
||||||
|
// angezeigten zu suchen hiesse, die halbe Mediathek unauffindbar
|
||||||
|
// zu machen, sobald jemand den anderen Namen im Kopf hat.
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
abfrage = abfrage.Where(m => EF.Functions.Like(m.Title, $"%{search.Trim()}%"));
|
{
|
||||||
|
var muster = $"%{search.Trim()}%";
|
||||||
|
abfrage = abfrage.Where(m =>
|
||||||
|
EF.Functions.Like(m.Title, muster)
|
||||||
|
|| (m.OriginalTitle != null && EF.Functions.Like(m.OriginalTitle, muster)));
|
||||||
|
}
|
||||||
if (!string.IsNullOrWhiteSpace(genre))
|
if (!string.IsNullOrWhiteSpace(genre))
|
||||||
{
|
{
|
||||||
var gesucht = genre.Trim();
|
var gesucht = genre.Trim();
|
||||||
@@ -211,8 +407,28 @@ public static class LibraryContentEndpoints
|
|||||||
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||||
.Select(m => new
|
.Select(m => new
|
||||||
{
|
{
|
||||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
m.Id,
|
||||||
|
// **Die Übersetzung sticht, wenn es eine gibt.** Fehlt sie,
|
||||||
|
// steht der Titel in der Sprache des Servers da — nicht
|
||||||
|
// leer. Ein Film ohne englische Übersetzung heisst dann
|
||||||
|
// eben weiter „Das Boot", und das ist richtiger als eine
|
||||||
|
// Lücke.
|
||||||
|
Title = sprache == null ? m.Title
|
||||||
|
: db.Translations
|
||||||
|
.Where(t => t.MovieId == m.Id && t.Language == sprache)
|
||||||
|
.Select(t => t.Title).FirstOrDefault() ?? m.Title,
|
||||||
|
m.OriginalTitle, m.Year,
|
||||||
|
Overview = sprache == null ? m.Overview
|
||||||
|
: db.Translations
|
||||||
|
.Where(t => t.MovieId == m.Id && t.Language == sprache)
|
||||||
|
.Select(t => t.Overview).FirstOrDefault() ?? m.Overview,
|
||||||
|
m.PosterImage, m.BackdropImage,
|
||||||
m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating,
|
m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating,
|
||||||
|
m.PublishAt,
|
||||||
|
// **Damit die Kachel weiss, wohin sie gehört.** Nach einer
|
||||||
|
// Korrektur stand „WALL·E" weiter unter E, bis man die
|
||||||
|
// Seite neu lud.
|
||||||
|
m.SortTitleValue, m.TitlePinned, m.SortTitlePinned,
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -222,7 +438,7 @@ public static class LibraryContentEndpoints
|
|||||||
var ids = kennungen.Select(m => m.Id).ToList();
|
var ids = kennungen.Select(m => m.Id).ToList();
|
||||||
var dateien = await db.MediaFiles.AsNoTracking()
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
.Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value))
|
.Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value))
|
||||||
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId })
|
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId, f.VersionLabel })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, filme: ids);
|
var stände = await ZuständeZu(db, wer, filme: ids);
|
||||||
@@ -230,9 +446,14 @@ public static class LibraryContentEndpoints
|
|||||||
var items = kennungen.Select(m => new MovieResponse(
|
var items = kennungen.Select(m => new MovieResponse(
|
||||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||||
dateien.Where(f => f.MovieId == m.Id)
|
dateien.Where(f => f.MovieId == m.Id)
|
||||||
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(),
|
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel)).ToList(),
|
||||||
stände.GetValueOrDefault(m.Id),
|
stände.GetValueOrDefault(m.Id),
|
||||||
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).ToList();
|
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
|
||||||
|
OriginalTitle: m.OriginalTitle,
|
||||||
|
PublishAt: m.PublishAt,
|
||||||
|
TitlePinned: m.TitlePinned,
|
||||||
|
SortTitle: m.SortTitleValue,
|
||||||
|
SortTitlePinned: m.SortTitlePinned)).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<MovieResponse>(items, gesamt));
|
return Results.Ok(new Page<MovieResponse>(items, gesamt));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
@@ -241,10 +462,16 @@ public static class LibraryContentEndpoints
|
|||||||
ClaimsPrincipal wer, FlaroContext db,
|
ClaimsPrincipal wer, FlaroContext db,
|
||||||
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
|
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
|
||||||
{
|
{
|
||||||
|
var sprache = await SpracheVon(wer, db);
|
||||||
var abfrage = db.Series.AsNoTracking();
|
var abfrage = db.Series.AsNoTracking();
|
||||||
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung);
|
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung);
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
abfrage = abfrage.Where(s => EF.Functions.Like(s.Title, $"%{search.Trim()}%"));
|
{
|
||||||
|
var muster = $"%{search.Trim()}%";
|
||||||
|
abfrage = abfrage.Where(x =>
|
||||||
|
EF.Functions.Like(x.Title, muster)
|
||||||
|
|| (x.OriginalTitle != null && EF.Functions.Like(x.OriginalTitle, muster)));
|
||||||
|
}
|
||||||
if (!string.IsNullOrWhiteSpace(genre))
|
if (!string.IsNullOrWhiteSpace(genre))
|
||||||
{
|
{
|
||||||
var gesucht = genre.Trim();
|
var gesucht = genre.Trim();
|
||||||
@@ -264,8 +491,24 @@ public static class LibraryContentEndpoints
|
|||||||
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||||
.Select(s => new
|
.Select(s => new
|
||||||
{
|
{
|
||||||
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage,
|
s.Id,
|
||||||
|
Title = sprache == null ? s.Title
|
||||||
|
: db.Translations
|
||||||
|
.Where(t => t.SeriesId == s.Id && t.Language == sprache)
|
||||||
|
.Select(t => t.Title).FirstOrDefault() ?? s.Title,
|
||||||
|
s.OriginalTitle, s.Year,
|
||||||
|
Overview = sprache == null ? s.Overview
|
||||||
|
: db.Translations
|
||||||
|
.Where(t => t.SeriesId == s.Id && t.Language == sprache)
|
||||||
|
.Select(t => t.Overview).FirstOrDefault() ?? s.Overview,
|
||||||
|
s.PosterImage, s.BackdropImage,
|
||||||
s.LogoImage, s.RuntimeMinutes, s.Rating, s.ContentRating,
|
s.LogoImage, s.RuntimeMinutes, s.Rating, s.ContentRating,
|
||||||
|
// **Eine Serie wartet, solange keine Folge sichtbar ist.**
|
||||||
|
// Der früheste Zeitpunkt sagt, ab wann überhaupt etwas da
|
||||||
|
// ist; steht dort nichts, wartet alles.
|
||||||
|
PublishAt = db.Episodes.Where(f => f.SeriesId == s.Id)
|
||||||
|
.Min(f => f.PublishAt),
|
||||||
|
s.SortTitleValue, s.TitlePinned, s.SortTitlePinned,
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -279,6 +522,7 @@ public static class LibraryContentEndpoints
|
|||||||
// zusammen. Je Serie zu fragen wäre bei fünfzig Serien
|
// zusammen. Je Serie zu fragen wäre bei fünfzig Serien
|
||||||
// einundfünfzig Abfragen.
|
// einundfünfzig Abfragen.
|
||||||
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
|
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
|
||||||
|
var stände = await ZuständeZu(db, wer, serien: ids);
|
||||||
|
|
||||||
var items = serien.Select(s =>
|
var items = serien.Select(s =>
|
||||||
{
|
{
|
||||||
@@ -287,7 +531,13 @@ public static class LibraryContentEndpoints
|
|||||||
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count,
|
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count,
|
||||||
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
|
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
|
||||||
meine.Count(f => !gesehen.Contains(f.Id)),
|
meine.Count(f => !gesehen.Contains(f.Id)),
|
||||||
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating);
|
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating,
|
||||||
|
OriginalTitle: s.OriginalTitle,
|
||||||
|
PublishAt: s.PublishAt,
|
||||||
|
State: stände.GetValueOrDefault(s.Id),
|
||||||
|
TitlePinned: s.TitlePinned,
|
||||||
|
SortTitle: s.SortTitleValue,
|
||||||
|
SortTitlePinned: s.SortTitlePinned);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
||||||
@@ -321,6 +571,41 @@ public static class LibraryContentEndpoints
|
|||||||
// **Einzelabruf, weil ein Client nicht immer aus einer Liste kommt.**
|
// **Einzelabruf, weil ein Client nicht immer aus einer Liste kommt.**
|
||||||
// Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus
|
// Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus
|
||||||
// „Weiterschauen" — in all diesen Fällen hat er nur die Kennung.
|
// „Weiterschauen" — in all diesen Fällen hat er nur die Kennung.
|
||||||
|
// Ähnliche Titel als eigener Abruf: die Detailseite soll stehen,
|
||||||
|
// bevor diese Rechnung durch ist.
|
||||||
|
app.MapGet("/api/v1/movies/{id:guid}/similar", async (
|
||||||
|
Guid id, ClaimsPrincipal wer, FlaroContext db, int take = 12) =>
|
||||||
|
{
|
||||||
|
var kennungen = await AehnlicheAsync(db, id, Math.Clamp(take, 1, 24));
|
||||||
|
if (kennungen.Count == 0) return Results.Ok(new Page<MovieResponse>([], 0));
|
||||||
|
|
||||||
|
var sprache = await SpracheVon(wer, db);
|
||||||
|
var filme = await db.Movies.AsNoTracking()
|
||||||
|
.Where(m => kennungen.Contains(m.Id))
|
||||||
|
.Select(m => new
|
||||||
|
{
|
||||||
|
m.Id,
|
||||||
|
Title = sprache == null ? m.Title
|
||||||
|
: db.Translations.Where(t => t.MovieId == m.Id && t.Language == sprache)
|
||||||
|
.Select(t => t.Title).FirstOrDefault() ?? m.Title,
|
||||||
|
m.OriginalTitle, m.Year, m.PosterImage, m.BackdropImage, m.LogoImage,
|
||||||
|
m.RuntimeMinutes, m.Rating, m.ContentRating,
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// In der Reihenfolge der Punkte, nicht in der der Datenbank.
|
||||||
|
var items = kennungen
|
||||||
|
.Select(k => filme.FirstOrDefault(f => f.Id == k))
|
||||||
|
.Where(f => f != null)
|
||||||
|
.Select(f => new MovieResponse(
|
||||||
|
f!.Id, f.Title, f.Year, null, f.PosterImage, f.BackdropImage,
|
||||||
|
[], null, f.LogoImage, f.RuntimeMinutes, f.Rating, null, f.ContentRating,
|
||||||
|
OriginalTitle: f.OriginalTitle))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Results.Ok(new Page<MovieResponse>(items, items.Count));
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/v1/movies/{id:guid}", async (
|
app.MapGet("/api/v1/movies/{id:guid}", async (
|
||||||
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
@@ -331,18 +616,30 @@ public static class LibraryContentEndpoints
|
|||||||
|
|
||||||
var dateien = await db.MediaFiles.AsNoTracking()
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
.Where(f => f.MovieId == id)
|
.Where(f => f.MovieId == id)
|
||||||
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration })
|
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.VersionLabel })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, filme: [id]);
|
var stände = await ZuständeZu(db, wer, filme: [id]);
|
||||||
|
var wurzel = await MediathekspfadFuer(db, wer, film.LibraryId);
|
||||||
|
|
||||||
|
var sprache = await SpracheVon(wer, db);
|
||||||
|
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.MovieId == id && t.Language == sprache);
|
||||||
|
|
||||||
return Results.Ok(new MovieResponse(
|
return Results.Ok(new MovieResponse(
|
||||||
film.Id, film.Title, film.Year, film.Overview, film.PosterImage, film.BackdropImage,
|
film.Id, uebersetzt?.Title ?? film.Title, film.Year,
|
||||||
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(),
|
uebersetzt?.Overview ?? film.Overview, film.PosterImage, film.BackdropImage,
|
||||||
|
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel, wurzel)).ToList(),
|
||||||
stände.GetValueOrDefault(id),
|
stände.GetValueOrDefault(id),
|
||||||
film.LogoImage, film.RuntimeMinutes, film.Rating, film.Tagline, film.ContentRating,
|
film.LogoImage, film.RuntimeMinutes, film.Rating,
|
||||||
|
uebersetzt?.Tagline ?? film.Tagline, film.ContentRating,
|
||||||
await GenresZu(db, film: id, serie: null),
|
await GenresZu(db, film: id, serie: null),
|
||||||
await MitwirkendeZu(db, film: id, serie: null)));
|
await MitwirkendeZu(db, film: id, serie: null),
|
||||||
|
OriginalTitle: film.OriginalTitle,
|
||||||
|
TitlePinned: film.TitlePinned,
|
||||||
|
SortTitle: film.SortTitleValue,
|
||||||
|
SortTitlePinned: film.SortTitlePinned,
|
||||||
|
PublishAt: film.PublishAt));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/v1/series/{id:guid}", async (
|
app.MapGet("/api/v1/series/{id:guid}", async (
|
||||||
@@ -355,17 +652,70 @@ public static class LibraryContentEndpoints
|
|||||||
|
|
||||||
var folgen = await db.Episodes.AsNoTracking()
|
var folgen = await db.Episodes.AsNoTracking()
|
||||||
.Where(f => f.SeriesId == id)
|
.Where(f => f.SeriesId == id)
|
||||||
.Select(f => new { f.Id, f.SeasonNumber })
|
.Select(f => new { f.Id, f.SeasonNumber, f.PublishAt })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
|
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
|
||||||
|
|
||||||
|
var sprache = await SpracheVon(wer, db);
|
||||||
|
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.SeriesId == id && t.Language == sprache);
|
||||||
|
|
||||||
return Results.Ok(new SeriesResponse(
|
return Results.Ok(new SeriesResponse(
|
||||||
serie.Id, serie.Title, serie.Year, serie.Overview, serie.PosterImage, serie.BackdropImage,
|
serie.Id, uebersetzt?.Title ?? serie.Title, serie.Year,
|
||||||
|
uebersetzt?.Overview ?? serie.Overview, serie.PosterImage, serie.BackdropImage,
|
||||||
folgen.Count, folgen.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
|
folgen.Count, folgen.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
|
||||||
folgen.Count(f => !gesehen.Contains(f.Id)),
|
folgen.Count(f => !gesehen.Contains(f.Id)),
|
||||||
serie.LogoImage, serie.RuntimeMinutes, serie.Rating, serie.Tagline, serie.ContentRating,
|
serie.LogoImage, serie.RuntimeMinutes, serie.Rating,
|
||||||
|
uebersetzt?.Tagline ?? serie.Tagline, serie.ContentRating,
|
||||||
await GenresZu(db, film: null, serie: id),
|
await GenresZu(db, film: null, serie: id),
|
||||||
await MitwirkendeZu(db, film: null, serie: id)));
|
await MitwirkendeZu(db, film: null, serie: id),
|
||||||
|
OriginalTitle: serie.OriginalTitle,
|
||||||
|
State: (await ZuständeZu(db, wer, serien: [id])).GetValueOrDefault(id),
|
||||||
|
TitlePinned: serie.TitlePinned,
|
||||||
|
SortTitle: serie.SortTitleValue,
|
||||||
|
SortTitlePinned: serie.SortTitlePinned,
|
||||||
|
PublishAt: folgen.Min(f => f.PublishAt)));
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// **Eine Folge für sich.**
|
||||||
|
//
|
||||||
|
// Es gab sie nur im Rudel — als Liste einer Serie. Die Apps reichen
|
||||||
|
// aber eine blanke Kennung durch: sie haben sie aus „Weiterschauen"
|
||||||
|
// und wissen nicht, ob ein Film, eine Serie oder eine Folge
|
||||||
|
// dahintersteckt. Sie fragten der Reihe nach Film und Serie, fanden
|
||||||
|
// beides nicht und meldeten „Dieser Titel ist auf dem Server nicht
|
||||||
|
// mehr vorhanden" — über eine Folge, die sie selbst gerade zeigten.
|
||||||
|
app.MapGet("/api/v1/episodes/{id:guid}", async (
|
||||||
|
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var folge = await db.Episodes.AsNoTracking()
|
||||||
|
.Where(f => f.Id == id)
|
||||||
|
.Select(f => new
|
||||||
|
{
|
||||||
|
f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber,
|
||||||
|
f.Title, f.Overview, f.StillImage, f.PublishAt,
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (folge is null) return Results.NotFound();
|
||||||
|
|
||||||
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
|
.Where(d => d.EpisodeId == id)
|
||||||
|
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel })
|
||||||
|
.ToListAsync();
|
||||||
|
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
|
var stände = await ZuständeZu(db, wer, folgen: [id]);
|
||||||
|
|
||||||
|
var wurzel = await MediathekspfadFuer(db, wer,
|
||||||
|
await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId)
|
||||||
|
.Select(x => x.LibraryId).FirstAsync());
|
||||||
|
|
||||||
|
return Results.Ok(new EpisodeResponse(
|
||||||
|
folge.Id, folge.SeasonNumber, folge.EpisodeNumber,
|
||||||
|
folge.Title, folge.Overview, folge.StillImage,
|
||||||
|
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||||
|
stände.GetValueOrDefault(id),
|
||||||
|
folge.PublishAt,
|
||||||
|
SeriesId: folge.SeriesId));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
|
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
|
||||||
@@ -385,7 +735,7 @@ public static class LibraryContentEndpoints
|
|||||||
.Where(f => f.SeasonNumber > diese.SeasonNumber
|
.Where(f => f.SeasonNumber > diese.SeasonNumber
|
||||||
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
|
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
|
||||||
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
||||||
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
|
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
// Keine weitere Folge ist kein Fehler — es war die letzte.
|
// Keine weitere Folge ist kein Fehler — es war die letzte.
|
||||||
@@ -393,16 +743,26 @@ public static class LibraryContentEndpoints
|
|||||||
|
|
||||||
var dateien = await db.MediaFiles.AsNoTracking()
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
.Where(d => d.EpisodeId == naechste.Id)
|
.Where(d => d.EpisodeId == naechste.Id)
|
||||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration })
|
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]);
|
var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]);
|
||||||
|
|
||||||
|
// **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
|
||||||
|
// nichts; das Plakat der Serie sagt wenigstens, worum es geht.
|
||||||
|
// TMDB liefert Folgenbilder nur für einen Teil der Folgen, und
|
||||||
|
// bei kleineren Serien für gar keine.
|
||||||
|
var serienBild = await db.Series.AsNoTracking()
|
||||||
|
.Where(x => x.Id == diese.SeriesId)
|
||||||
|
.Select(x => x.PosterImage).FirstOrDefaultAsync();
|
||||||
|
|
||||||
return Results.Ok(new EpisodeResponse(
|
return Results.Ok(new EpisodeResponse(
|
||||||
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
|
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
|
||||||
naechste.Title, naechste.Overview, naechste.StillImage,
|
naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild,
|
||||||
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
|
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
|
||||||
stände.GetValueOrDefault(naechste.Id)));
|
stände.GetValueOrDefault(naechste.Id),
|
||||||
|
naechste.PublishAt,
|
||||||
|
SeriesId: diese.SeriesId));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
|
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
|
||||||
@@ -415,22 +775,33 @@ public static class LibraryContentEndpoints
|
|||||||
|
|
||||||
var folgen = await abfrage
|
var folgen = await abfrage
|
||||||
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
||||||
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
|
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
var wurzel = await MediathekspfadFuer(db, wer,
|
||||||
|
await db.Series.AsNoTracking().Where(x => x.Id == id)
|
||||||
|
.Select(x => x.LibraryId).FirstAsync());
|
||||||
|
|
||||||
var ids = folgen.Select(f => f.Id).ToList();
|
var ids = folgen.Select(f => f.Id).ToList();
|
||||||
var dateien = await db.MediaFiles.AsNoTracking()
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value))
|
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value))
|
||||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId })
|
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId, d.VersionLabel })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||||
var stände = await ZuständeZu(db, wer, folgen: ids);
|
var stände = await ZuständeZu(db, wer, folgen: ids);
|
||||||
|
|
||||||
|
var serienBild = await db.Series.AsNoTracking()
|
||||||
|
.Where(x => x.Id == id)
|
||||||
|
.Select(x => x.PosterImage).FirstOrDefaultAsync();
|
||||||
|
|
||||||
var items = folgen.Select(f => new EpisodeResponse(
|
var items = folgen.Select(f => new EpisodeResponse(
|
||||||
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage,
|
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview,
|
||||||
|
f.StillImage ?? serienBild,
|
||||||
dateien.Where(d => d.EpisodeId == f.Id)
|
dateien.Where(d => d.EpisodeId == f.Id)
|
||||||
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
|
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
|
||||||
stände.GetValueOrDefault(f.Id))).ToList();
|
stände.GetValueOrDefault(f.Id),
|
||||||
|
f.PublishAt,
|
||||||
|
SeriesId: id)).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using Flaro.Api.Auth;
|
using Flaro.Api.Auth;
|
||||||
using Flaro.Core.Libraries;
|
using Flaro.Core.Libraries;
|
||||||
|
using Flaro.Core.Library;
|
||||||
using Flaro.Core.Users;
|
using Flaro.Core.Users;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -8,6 +10,9 @@ namespace Flaro.Api.Endpoints;
|
|||||||
|
|
||||||
public static class ManagementEndpoints
|
public static class ManagementEndpoints
|
||||||
{
|
{
|
||||||
|
private static Guid? Kennung(ClaimsPrincipal wer) =>
|
||||||
|
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
||||||
|
|
||||||
public static void MapManagement(this IEndpointRouteBuilder app)
|
public static void MapManagement(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator);
|
var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
@@ -15,20 +20,87 @@ public static class ManagementEndpoints
|
|||||||
benutzer.MapGet("/", async (FlaroContext db) =>
|
benutzer.MapGet("/", async (FlaroContext db) =>
|
||||||
Results.Ok(await db.Users
|
Results.Ok(await db.Users
|
||||||
.OrderBy(u => u.UserName)
|
.OrderBy(u => u.UserName)
|
||||||
.Select(u => new AuthenticatedUser(u.Id, u.UserName, u.IsAdministrator))
|
.Select(u => new AuthenticatedUser(
|
||||||
|
u.Id, u.UserName, u.IsAdministrator, u.Language, u.AvatarImage, u.IsOwner))
|
||||||
.ToListAsync()));
|
.ToListAsync()));
|
||||||
|
|
||||||
|
/* ---------- Rechte, Besitz, Löschen ---------- */
|
||||||
|
|
||||||
|
// **Warum es einen Besitzer gibt.** Sobald Administratoren einander
|
||||||
|
// das Recht entziehen können, gibt es einen Zustand ohne jeden
|
||||||
|
// Administrator — und aus dem führt kein Weg zurück ausser dem an der
|
||||||
|
// Datenbank. Ein Konto, das ausgenommen ist, schliesst ihn aus.
|
||||||
|
benutzer.MapPut("/{id:guid}/administrator", async (
|
||||||
|
Guid id, RechtRequest anfrage, ClaimsPrincipal wer,
|
||||||
|
FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||||
|
if (ziel is null) return Einwand.Fehlt("Dieses Konto gibt es nicht mehr.");
|
||||||
|
|
||||||
|
if (ziel.IsOwner && !anfrage.IsAdministrator)
|
||||||
|
return Einwand.Bitte("Dem Besitzer lässt sich das Adminrecht nicht nehmen.");
|
||||||
|
|
||||||
|
// **Nicht an sich selbst.** Wer sich im Eifer das eigene Recht
|
||||||
|
// nimmt, steht vor einer Verwaltung, die er nicht mehr öffnen
|
||||||
|
// kann — und das Zurücknehmen braucht genau die.
|
||||||
|
if (Kennung(wer) == id && !anfrage.IsAdministrator)
|
||||||
|
return Einwand.Bitte("Das eigene Adminrecht kannst du dir nicht selbst nehmen.");
|
||||||
|
|
||||||
|
ziel.IsAdministrator = anfrage.IsAdministrator;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
// **Nur der Besitzer gibt den Besitz weiter**, und nur an jemanden,
|
||||||
|
// der schon Administrator ist: sonst wäre der Server einen Klick von
|
||||||
|
// einem Besitzer entfernt, der die Verwaltung gar nicht sieht.
|
||||||
|
benutzer.MapPost("/{id:guid}/owner", async (
|
||||||
|
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (Kennung(wer) is not { } ich) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var bisher = await db.Users.FirstOrDefaultAsync(u => u.Id == ich, ct);
|
||||||
|
if (bisher is not { IsOwner: true })
|
||||||
|
return Einwand.Bitte("Nur der Besitzer kann den Besitz weitergeben.");
|
||||||
|
|
||||||
|
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||||
|
if (ziel is null) return Einwand.Fehlt("Dieses Konto gibt es nicht mehr.");
|
||||||
|
if (!ziel.IsAdministrator)
|
||||||
|
return Einwand.Bitte("Der Besitz geht nur an einen Administrator.");
|
||||||
|
|
||||||
|
bisher.IsOwner = false;
|
||||||
|
ziel.IsOwner = true;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
benutzer.MapDelete("/{id:guid}", async (
|
||||||
|
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||||
|
if (ziel is null) return Results.NoContent();
|
||||||
|
|
||||||
|
if (ziel.IsOwner) return Einwand.Bitte("Den Besitzer kann man nicht löschen.");
|
||||||
|
if (Kennung(wer) == id)
|
||||||
|
return Einwand.Bitte("Das eigene Konto kannst du hier nicht löschen.");
|
||||||
|
|
||||||
|
// Geräte, Sehstände, Sehprotokoll, Meldungen und offene
|
||||||
|
// Einladungen hängen mit `Cascade` daran — siehe `FlaroContext`.
|
||||||
|
// Ein Konto zu löschen und seine Spuren zu lassen hiesse, Zahlen
|
||||||
|
// über jemanden zu führen, den es nicht mehr gibt.
|
||||||
|
db.Users.Remove(ziel);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
benutzer.MapPost("/", async (CreateUserRequest anfrage, FlaroContext db) =>
|
benutzer.MapPost("/", async (CreateUserRequest anfrage, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
var name = anfrage.UserName.Trim();
|
var name = anfrage.UserName.Trim();
|
||||||
if (name.Length == 0 || anfrage.Password.Length < 8)
|
if (name.Length == 0 || anfrage.Password.Length < 8)
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
return Einwand.Bitte("Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
|
||||||
{
|
|
||||||
["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (await db.Users.AnyAsync(u => u.UserName == name))
|
if (await db.Users.AnyAsync(u => u.UserName == name))
|
||||||
return Results.Problem($"„{name}“ gibt es schon.", statusCode: StatusCodes.Status409Conflict);
|
return Einwand.Belegt($"„{name}“ gibt es schon.");
|
||||||
|
|
||||||
var neu = new User
|
var neu = new User
|
||||||
{
|
{
|
||||||
@@ -51,9 +123,44 @@ public static class ManagementEndpoints
|
|||||||
// dem sich nach dem Klick nichts findet — da ist der Unterschied
|
// dem sich nach dem Klick nichts findet — da ist der Unterschied
|
||||||
// zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens
|
// zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens
|
||||||
// sichtbar. Zwei Abfragen für alle zusammen, nicht eine je Zeile.
|
// sichtbar. Zwei Abfragen für alle zusammen, nicht eine je Zeile.
|
||||||
bibliotheken.MapGet("/", async (FlaroContext db) =>
|
bibliotheken.MapGet("/", async (FlaroContext db, TimeProvider uhr) =>
|
||||||
{
|
{
|
||||||
var liste = await db.Libraries.AsNoTracking().OrderBy(l => l.Name).ToListAsync();
|
// **Ohne Rücksicht auf Gross- und Kleinschreibung.**
|
||||||
|
//
|
||||||
|
// SQLite vergleicht Text sonst zeichengenau, und dort stehen alle
|
||||||
|
// Grossbuchstaben vor allen kleinen: „Serien" landete über „film",
|
||||||
|
// obwohl f vor s kommt. Wer eine Mediathek umbenennt, sieht sie
|
||||||
|
// dann scheinbar grundlos springen — es sah aus, als stünde die
|
||||||
|
// zuletzt bearbeitete oben.
|
||||||
|
// **Erst die eigene Reihenfolge, dann der Name.** Wer nie
|
||||||
|
// umsortiert hat, bekommt das Alphabet; wer es getan hat, seine
|
||||||
|
// Reihenfolge. Ohne den zweiten Schlüssel stünden alle
|
||||||
|
// Nie-Sortierten in zufälliger Folge.
|
||||||
|
var liste = await db.Libraries.AsNoTracking()
|
||||||
|
.OrderBy(l => l.SortOrder)
|
||||||
|
.ThenBy(l => EF.Functions.Collate(l.Name, "NOCASE"))
|
||||||
|
.ToListAsync();
|
||||||
|
var jetzt = uhr.GetUtcNow();
|
||||||
|
|
||||||
|
// **Was noch wartet, je Mediathek.** Wer scannt, sieht das Ergebnis
|
||||||
|
// an der Mediathek — dass zwanzig Titel irgendwo weiter unten
|
||||||
|
// warten, erführe er sonst nur durch Weiterscrollen.
|
||||||
|
//
|
||||||
|
// Für einen gewöhnlichen Benutzer bleibt die Zahl null: der
|
||||||
|
// Freigabefilter lässt ihn Unveröffentlichtes gar nicht erst
|
||||||
|
// zählen, und das ist richtig so.
|
||||||
|
var wartendeFilme = await db.Movies.AsNoTracking()
|
||||||
|
.Where(m => m.PublishAt == null || m.PublishAt > jetzt)
|
||||||
|
.GroupBy(m => m.LibraryId)
|
||||||
|
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
|
||||||
|
|
||||||
|
var wartendeFolgen = await db.Episodes.AsNoTracking()
|
||||||
|
.Where(f => f.PublishAt == null || f.PublishAt > jetzt)
|
||||||
|
.GroupBy(f => db.Series.Where(s => s.Id == f.SeriesId)
|
||||||
|
.Select(s => s.LibraryId).First())
|
||||||
|
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
|
||||||
|
|
||||||
var filme = await db.Movies.AsNoTracking()
|
var filme = await db.Movies.AsNoTracking()
|
||||||
.GroupBy(m => m.LibraryId)
|
.GroupBy(m => m.LibraryId)
|
||||||
@@ -65,36 +172,286 @@ public static class ManagementEndpoints
|
|||||||
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
|
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
|
||||||
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
|
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
|
||||||
|
|
||||||
return Results.Ok(liste.Select(l => new LibraryResponse(
|
// **Drei Zahlen, die alle stimmen und Verschiedenes meinen.**
|
||||||
l.Id, l.Name, l.Kind.ToString(), l.Path,
|
//
|
||||||
l.Kind == LibraryKind.Shows
|
// Plex zählte 451 Filme, Flaro 449, und der Scan lief über 461
|
||||||
? serien.GetValueOrDefault(l.Id)
|
// Dateien. Keine davon ist falsch — es sind Titel, Dateien und
|
||||||
: filme.GetValueOrDefault(l.Id))).ToList());
|
// zweite Fassungen. Nebeneinander beantwortet sich die Frage von
|
||||||
|
// selbst; einzeln bleibt sie offen.
|
||||||
|
var folgen = await db.Episodes.AsNoTracking()
|
||||||
|
.GroupBy(f => db.Series.Where(x => x.Id == f.SeriesId)
|
||||||
|
.Select(x => x.LibraryId).First())
|
||||||
|
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
|
||||||
|
|
||||||
|
// Vermisste zählen nicht mit: sie liegen nicht mehr auf der Platte,
|
||||||
|
// und der Scan hat sie deshalb auch nicht gesehen.
|
||||||
|
//
|
||||||
|
// **`MitTitel` zählt Titel, nicht Dateien** — und zwar nur solche,
|
||||||
|
// zu denen wirklich noch eine Datei daliegt. Ohne das ginge die
|
||||||
|
// Rechnung schief, sobald etwas vermisst wird: ein erster Versuch
|
||||||
|
// verglich gegen alle Folgen und kam auf 1223 Titel bei 324
|
||||||
|
// Dateien.
|
||||||
|
var dateien = await db.MediaFiles.AsNoTracking()
|
||||||
|
.Where(f => f.MissingSince == null)
|
||||||
|
.GroupBy(f => f.LibraryId)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
Id = g.Key,
|
||||||
|
Alle = g.Count(),
|
||||||
|
Ohne = g.Count(f => f.MovieId == null && f.EpisodeId == null),
|
||||||
|
MitTitel = g.Where(f => f.MovieId != null).Select(f => f.MovieId).Distinct().Count()
|
||||||
|
+ g.Where(f => f.EpisodeId != null).Select(f => f.EpisodeId).Distinct().Count(),
|
||||||
|
})
|
||||||
|
.ToDictionaryAsync(x => x.Id, x => x);
|
||||||
|
|
||||||
|
return Results.Ok(liste.Select(l =>
|
||||||
|
{
|
||||||
|
var zaehler = dateien.GetValueOrDefault(l.Id);
|
||||||
|
var alleDateien = zaehler?.Alle ?? 0;
|
||||||
|
var ohneTitel = zaehler?.Ohne ?? 0;
|
||||||
|
|
||||||
|
// Jeder Titel, zu dem noch eine Datei daliegt, hat mindestens
|
||||||
|
// eine. Was darüber liegt, sind zweite Fassungen — Plex'
|
||||||
|
// „Optimized for Mobile" etwa.
|
||||||
|
var mitTitel = zaehler?.MitTitel ?? 0;
|
||||||
|
|
||||||
|
return new LibraryResponse(
|
||||||
|
l.Id, l.Name, l.Kind.ToString(), l.Path,
|
||||||
|
l.Kind == LibraryKind.Shows
|
||||||
|
? serien.GetValueOrDefault(l.Id)
|
||||||
|
: filme.GetValueOrDefault(l.Id),
|
||||||
|
l.ReviewNewItems,
|
||||||
|
l.Kind == LibraryKind.Shows
|
||||||
|
? wartendeFolgen.GetValueOrDefault(l.Id)
|
||||||
|
: wartendeFilme.GetValueOrDefault(l.Id),
|
||||||
|
FileCount: alleDateien,
|
||||||
|
EpisodeCount: l.Kind == LibraryKind.Shows ? folgen.GetValueOrDefault(l.Id) : null,
|
||||||
|
UnassignedCount: ohneTitel,
|
||||||
|
ExtraVersionCount: Math.Max(0, alleDateien - ohneTitel - mitTitel));
|
||||||
|
}).ToList());
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// **Was nicht mehr da ist, aber noch in der Mediathek steht.**
|
||||||
|
//
|
||||||
|
// Der Scan löscht nichts mehr von selbst — eine kurz nicht eingehängte
|
||||||
|
// Platte hätte sonst Titel samt Sehfortschritt gekostet. Stattdessen
|
||||||
|
// steht es hier, und das Löschen ist eine Entscheidung.
|
||||||
|
// **Die Reihenfolge der Seitenleiste.**
|
||||||
|
//
|
||||||
|
// Als ganze Liste und nicht als „eins hoch": zwei Benutzer, die
|
||||||
|
// gleichzeitig schieben, kämen sonst in einen Zustand, den keiner von
|
||||||
|
// beiden wollte. Die Liste beschreibt, wie es danach aussehen soll.
|
||||||
|
bibliotheken.MapPut("/order", async (
|
||||||
|
ReihenfolgeRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (anfrage.Ids is null or { Count: 0 })
|
||||||
|
return Einwand.Bitte("Ohne Reihenfolge gibt es nichts zu ordnen.");
|
||||||
|
|
||||||
|
// Ab 1 zählen: die 0 bleibt denen, die nie umsortiert wurden.
|
||||||
|
for (var i = 0; i < anfrage.Ids.Count; i++)
|
||||||
|
{
|
||||||
|
var platz = i + 1;
|
||||||
|
var kennung = anfrage.Ids[i];
|
||||||
|
await db.Libraries.Where(l => l.Id == kennung)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(l => l.SortOrder, platz), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.NoContent();
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// Die Prüfung je Mediathek umstellen.
|
||||||
|
bibliotheken.MapPut("/{id:guid}/review", async (
|
||||||
|
Guid id, PruefungRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var betroffen = await db.Libraries.Where(l => l.Id == id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(l => l.ReviewNewItems, anfrage.ReviewNewItems), ct);
|
||||||
|
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Was zu keinem Titel gehört.**
|
||||||
|
//
|
||||||
|
// Der Scan erfindet keine Serie mehr, wenn weder Ordner noch Dateiname
|
||||||
|
// eine nennen — nur verschwanden die Dateien damit lautlos. Bei 3559
|
||||||
|
// von 5884 war das die halbe Mediathek, und in der Übersicht stand
|
||||||
|
// eine Zahl, die niemand erklären konnte. Hier stehen sie, mit dem
|
||||||
|
// Grund dazu.
|
||||||
|
bibliotheken.MapGet("/{id:guid}/unassigned", async (
|
||||||
|
Guid id, FlaroContext db, int take = 300) =>
|
||||||
|
{
|
||||||
|
var bibliothek = await db.Libraries.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id);
|
||||||
|
if (bibliothek is null) return Results.NotFound();
|
||||||
|
|
||||||
|
var offen = db.MediaFiles.AsNoTracking()
|
||||||
|
.Where(f => f.LibraryId == id && f.MovieId == null && f.EpisodeId == null
|
||||||
|
&& f.MissingSince == null);
|
||||||
|
|
||||||
|
var gesamt = await offen.CountAsync();
|
||||||
|
var pfade = await offen.OrderBy(f => f.Path).Take(Math.Clamp(take, 1, 2000)).ToListAsync();
|
||||||
|
|
||||||
|
// Der Grund wird hier gerechnet, nicht gespeichert: er hängt allein
|
||||||
|
// am Pfad und an der Benennungsregel. Eine Spalte dafür wäre beim
|
||||||
|
// nächsten Regelwechsel veraltet, ohne dass es jemand merkt.
|
||||||
|
return Results.Ok(new Unzugeordnet(
|
||||||
|
gesamt,
|
||||||
|
pfade.Select(f => new UnzugeordneteDatei(
|
||||||
|
f.Id, f.Path, FileNames.WarumNicht(f.Path, bibliothek.Path))).ToList()));
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Was die Metadatenquelle nicht hergab.**
|
||||||
|
//
|
||||||
|
// `/unassigned` listet Dateien ganz ohne Titel — bei einer
|
||||||
|
// Filmbibliothek ist das immer leer, weil der Scan für jede Datei
|
||||||
|
// einen Eintrag anlegt. Sichtbar war damit nur die Hälfte: dass ein
|
||||||
|
// Film zwar dasteht, aber unter seinem Dateinamen und ohne Plakat,
|
||||||
|
// erfuhr niemand. In der Übersicht standen 448 Filme, in Plex 451 —
|
||||||
|
// und nirgends, welche drei fehlen.
|
||||||
|
bibliotheken.MapGet("/{id:guid}/unmatched", async (
|
||||||
|
Guid id, FlaroContext db, int take = 300) =>
|
||||||
|
{
|
||||||
|
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound();
|
||||||
|
|
||||||
|
var grenze = Math.Clamp(take, 1, 2000);
|
||||||
|
|
||||||
|
var filme = await db.Movies.AsNoTracking()
|
||||||
|
.Where(m => m.LibraryId == id && m.TmdbId == null)
|
||||||
|
.OrderBy(m => m.SortTitleValue)
|
||||||
|
.Take(grenze)
|
||||||
|
.Select(m => new
|
||||||
|
{
|
||||||
|
m.Id, m.Title, Jahr = m.ParsedYear ?? m.Year,
|
||||||
|
Dateien = db.MediaFiles.Count(f => f.MovieId == m.Id),
|
||||||
|
Beispiel = db.MediaFiles.Where(f => f.MovieId == m.Id)
|
||||||
|
.Select(f => f.Path).FirstOrDefault(),
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var serien = await db.Series.AsNoTracking()
|
||||||
|
.Where(x => x.LibraryId == id && x.TmdbId == null)
|
||||||
|
.OrderBy(x => x.SortTitleValue)
|
||||||
|
.Take(grenze)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id, x.Title, Jahr = x.ParsedYear ?? x.Year,
|
||||||
|
Dateien = db.Episodes.Count(f => f.SeriesId == x.Id),
|
||||||
|
Beispiel = db.Episodes.Where(f => f.SeriesId == x.Id)
|
||||||
|
.Join(db.MediaFiles, f => f.Id, d => d.EpisodeId, (_, d) => d.Path)
|
||||||
|
.FirstOrDefault(),
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var liste = filme
|
||||||
|
.Select(m => new OhneZuordnung(
|
||||||
|
m.Id, "movie", m.Title, m.Jahr, m.Dateien, Dateiname(m.Beispiel)))
|
||||||
|
.Concat(serien.Select(x => new OhneZuordnung(
|
||||||
|
x.Id, "series", x.Title, x.Jahr, x.Dateien, Dateiname(x.Beispiel))))
|
||||||
|
.OrderBy(x => x.Titel, StringComparer.CurrentCultureIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Results.Ok(liste);
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Titel mit mehr als einer Datei — dort versteckt sich Falsches.**
|
||||||
|
//
|
||||||
|
// Zwei Dateien an einem Film sind meist zwei Fassungen und völlig in
|
||||||
|
// Ordnung. Manchmal sind es aber zwei verschiedene Filme, die bei der
|
||||||
|
// Metadatenquelle auf denselben Treffer liefen — und dann ist einer
|
||||||
|
// davon lautlos verschwunden. Wer die Liste durchsieht, erkennt das
|
||||||
|
// an den Dateinamen in zwei Sekunden.
|
||||||
|
bibliotheken.MapGet("/{id:guid}/versions", async (
|
||||||
|
Guid id, FlaroContext db, int take = 300) =>
|
||||||
|
{
|
||||||
|
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound();
|
||||||
|
|
||||||
|
var mehrfach = await db.Movies.AsNoTracking()
|
||||||
|
.Where(m => m.LibraryId == id && db.MediaFiles.Count(f => f.MovieId == m.Id) > 1)
|
||||||
|
.OrderBy(m => m.SortTitleValue)
|
||||||
|
.Take(Math.Clamp(take, 1, 2000))
|
||||||
|
.Select(m => new
|
||||||
|
{
|
||||||
|
m.Id, m.Title, Jahr = m.ParsedYear ?? m.Year,
|
||||||
|
Dateien = db.MediaFiles.Where(f => f.MovieId == m.Id)
|
||||||
|
.Select(f => new { f.Id, f.Path }).ToList(),
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var liste = mehrfach
|
||||||
|
.Select(m => new Mehrfachtitel(
|
||||||
|
m.Id, "movie", m.Title, m.Jahr,
|
||||||
|
m.Dateien.Select(f => new Fassungsdatei(f.Id, Dateiname(f.Path) ?? f.Path)).ToList()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Results.Ok(new Mehrfachbericht(liste.Count, liste));
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
bibliotheken.MapGet("/missing", async (FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var liste = await db.MediaFiles.AsNoTracking()
|
||||||
|
.Where(f => f.MissingSince != null)
|
||||||
|
.OrderBy(f => f.Path)
|
||||||
|
.Select(f => new VermissteDatei(
|
||||||
|
f.Id, f.Path, f.VersionLabel, f.MissingSince!.Value,
|
||||||
|
db.Libraries.Where(l => l.Id == f.LibraryId).Select(l => l.Name).First(),
|
||||||
|
f.MovieId != null
|
||||||
|
? db.Movies.Where(m => m.Id == f.MovieId).Select(m => m.Title).FirstOrDefault()
|
||||||
|
: db.Episodes.Where(e => e.Id == f.EpisodeId)
|
||||||
|
.Select(e => e.Title ?? ("Folge " + e.EpisodeNumber)).FirstOrDefault(),
|
||||||
|
f.MovieId, f.EpisodeId))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Results.Ok(liste);
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// Endgültig weg — mitsamt dem Titel, wenn es seine letzte Datei war.
|
||||||
|
bibliotheken.MapDelete("/missing", async (Guid? fileId, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var betroffen = db.MediaFiles.Where(f => f.MissingSince != null);
|
||||||
|
if (fileId is { } eine) betroffen = betroffen.Where(f => f.Id == eine);
|
||||||
|
|
||||||
|
// Die Titel merken, bevor die Dateien weg sind — danach lässt sich
|
||||||
|
// nicht mehr feststellen, welche verwaist zurückbleiben.
|
||||||
|
var filme = await betroffen.Where(f => f.MovieId != null)
|
||||||
|
.Select(f => f.MovieId!.Value).Distinct().ToListAsync();
|
||||||
|
var folgen = await betroffen.Where(f => f.EpisodeId != null)
|
||||||
|
.Select(f => f.EpisodeId!.Value).Distinct().ToListAsync();
|
||||||
|
|
||||||
|
var weg = await betroffen.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
await db.Movies
|
||||||
|
.Where(m => filme.Contains(m.Id) && !db.MediaFiles.Any(f => f.MovieId == m.Id))
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
await db.Episodes
|
||||||
|
.Where(e => folgen.Contains(e.Id) && !db.MediaFiles.Any(f => f.EpisodeId == e.Id))
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
await db.Series
|
||||||
|
.Where(x => !db.Episodes.Any(e => e.SeriesId == x.Id))
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new { removed = weg });
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
|
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
if (!Enum.TryParse<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
|
if (!Enum.TryParse<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
return Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows.");
|
||||||
{
|
|
||||||
["kind"] = [$"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows."],
|
|
||||||
});
|
|
||||||
|
|
||||||
// **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad
|
// **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad
|
||||||
// führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und
|
// führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und
|
||||||
// die Ursache stünde allenfalls im Protokoll.
|
// die Ursache stünde allenfalls im Protokoll.
|
||||||
if (!Directory.Exists(anfrage.Path))
|
if (!Directory.Exists(anfrage.Path))
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
return Einwand.Bitte($"„{anfrage.Path}“ gibt es im Container nicht. " +
|
||||||
{
|
"Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt.");
|
||||||
["path"] = [$"„{anfrage.Path}“ gibt es im Container nicht. " +
|
|
||||||
"Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt."],
|
|
||||||
});
|
|
||||||
|
|
||||||
var neu = new Library { Name = anfrage.Name.Trim(), Kind = art, Path = anfrage.Path };
|
var neu = new Library
|
||||||
|
{
|
||||||
|
Name = anfrage.Name.Trim(), Kind = art, Path = anfrage.Path,
|
||||||
|
ReviewNewItems = anfrage.ReviewNewItems,
|
||||||
|
};
|
||||||
db.Libraries.Add(neu);
|
db.Libraries.Add(neu);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Created($"/api/v1/libraries/{neu.Id}",
|
return Results.Created($"/api/v1/libraries/{neu.Id}",
|
||||||
new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path, 0));
|
new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path, 0,
|
||||||
|
neu.ReviewNewItems));
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
// **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in
|
// **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in
|
||||||
@@ -107,10 +464,7 @@ public static class ManagementEndpoints
|
|||||||
{
|
{
|
||||||
var name = anfrage.Name.Trim();
|
var name = anfrage.Name.Trim();
|
||||||
if (name.Length == 0)
|
if (name.Length == 0)
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
return Einwand.Bitte("Ein Name darf nicht leer sein.");
|
||||||
{
|
|
||||||
["name"] = ["Ein Name darf nicht leer sein."],
|
|
||||||
});
|
|
||||||
|
|
||||||
var vorhanden = await db.Libraries.FindAsync(id);
|
var vorhanden = await db.Libraries.FindAsync(id);
|
||||||
if (vorhanden is null) return Results.NotFound();
|
if (vorhanden is null) return Results.NotFound();
|
||||||
@@ -125,16 +479,74 @@ public static class ManagementEndpoints
|
|||||||
// sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim
|
// sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim
|
||||||
// Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server,
|
// Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server,
|
||||||
// den man nicht noch einmal benutzt.
|
// den man nicht noch einmal benutzt.
|
||||||
bibliotheken.MapDelete("/{id:guid}", async (Guid id, FlaroContext db) =>
|
bibliotheken.MapDelete("/{id:guid}", async (
|
||||||
|
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
|
||||||
{
|
{
|
||||||
var vorhanden = await db.Libraries.FindAsync(id);
|
var vorhanden = await db.Libraries.FindAsync(id);
|
||||||
if (vorhanden is null) return Results.NotFound();
|
if (vorhanden is null) return Results.NotFound();
|
||||||
|
|
||||||
|
// Aus demselben Grund wie beim Zurücksetzen — nur dass hier die
|
||||||
|
// ganze Mediathek unter dem laufenden Scan verschwände.
|
||||||
|
if (koordinator.IsRunning(id))
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
"Für diese Mediathek läuft gerade ein Scan. "
|
||||||
|
+ "Entfernen geht erst, wenn er durch ist.",
|
||||||
|
statusCode: StatusCodes.Status409Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
db.Libraries.Remove(vorhanden);
|
db.Libraries.Remove(vorhanden);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Den Inhalt wegwerfen, die Mediathek behalten.**
|
||||||
|
//
|
||||||
|
// Wenn eine Zuordnungsregel falsch war, steht hinterher ein Bestand da,
|
||||||
|
// den kein Scan mehr geradezieht: die falschen Einträge haben ihre
|
||||||
|
// Dateien, gelten also nicht als verwaist, und bleiben. Die Mediathek
|
||||||
|
// zu löschen und neu anzulegen tut dasselbe — nur verliert man dabei
|
||||||
|
// Namen, Pfad und Einstellungen und tippt sie neu.
|
||||||
|
//
|
||||||
|
// Nicht gelöscht wird eine einzige Mediendatei. Entfernt wird allein,
|
||||||
|
// was Flaro über sie weiss.
|
||||||
|
bibliotheken.MapPost("/{id:guid}/reset", async (
|
||||||
|
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
|
||||||
|
{
|
||||||
|
var vorhanden = await db.Libraries.FindAsync(id);
|
||||||
|
if (vorhanden is null) return Results.NotFound();
|
||||||
|
|
||||||
|
// **Nicht während eines Laufs.**
|
||||||
|
//
|
||||||
|
// Die Oberfläche sperrt den Knopf, aber darauf darf sich der
|
||||||
|
// Server nicht verlassen: ein zweiter Administrator oder ein
|
||||||
|
// Aufruf von Hand genügt. Wer Zeilen löscht, während der Scan
|
||||||
|
// welche schreibt, bekommt einen Lauf, der mitten im Schreiben
|
||||||
|
// abbricht — und einen Bestand, den niemand mehr erklären kann.
|
||||||
|
if (koordinator.IsRunning(id))
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
"Für diese Mediathek läuft gerade ein Scan. "
|
||||||
|
+ "Zurücksetzen geht erst, wenn er durch ist.",
|
||||||
|
statusCode: StatusCodes.Status409Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In dieser Reihenfolge: erst die Dateien, dann die Titel. Die
|
||||||
|
// Fremdschlüssel zeigen von den Dateien auf Film und Folge.
|
||||||
|
await db.MediaFiles.Where(f => f.LibraryId == id).ExecuteDeleteAsync();
|
||||||
|
await db.Movies.Where(m => m.LibraryId == id).ExecuteDeleteAsync();
|
||||||
|
await db.Episodes
|
||||||
|
.Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == id))
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
await db.Series.Where(s => s.LibraryId == id).ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
return Results.NoContent();
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Nur der Dateiname — der volle Pfad geht niemanden etwas an.</summary>
|
||||||
|
private static string? Dateiname(string? pfad) =>
|
||||||
|
pfad is null ? null : Path.GetFileName(pfad);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class FlaroPolicies
|
public static class FlaroPolicies
|
||||||
|
|||||||
@@ -25,6 +25,224 @@ public static class MatchEndpoints
|
|||||||
{
|
{
|
||||||
public static void MapMatching(this IEndpointRouteBuilder app)
|
public static void MapMatching(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
|
// **Den Titel von Hand setzen — und festschreiben.**
|
||||||
|
//
|
||||||
|
// TMDB kennt nicht jeden Titel so, wie er im Haushalt heisst, und bei
|
||||||
|
// Mitschnitten gibt es gar keinen. Ohne diesen Weg bleibt nur der
|
||||||
|
// Dateiname oder ein falscher Treffer.
|
||||||
|
//
|
||||||
|
// Festgeschrieben wie Plakat und Hintergrund: sonst überschreibt das
|
||||||
|
// nächste Auffrischen die Korrektur, und man tippt sie nach jedem Lauf
|
||||||
|
// neu.
|
||||||
|
app.MapPut("/api/v1/{art:regex(^movies|series$)}/{id:guid}/title", async (
|
||||||
|
string art, Guid id, TitelRequest anfrage, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var titel = anfrage.Title?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(titel))
|
||||||
|
{
|
||||||
|
return Einwand.Bitte("Ein Titel darf nicht leer sein.");
|
||||||
|
}
|
||||||
|
if (titel.Length > 512)
|
||||||
|
return Einwand.Bitte("Höchstens 512 Zeichen.");
|
||||||
|
|
||||||
|
// **Der Sortiertitel bleibt, wenn er von Hand gesetzt wurde.**
|
||||||
|
// Sonst wäre „Harry Potter 3" nach jeder Titeländerung wieder weg.
|
||||||
|
var betroffen = art == "movies"
|
||||||
|
? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(m => m.Title, titel)
|
||||||
|
.SetProperty(m => m.SortTitleValue,
|
||||||
|
m => m.SortTitlePinned ? m.SortTitleValue : SortTitle.For(titel))
|
||||||
|
.SetProperty(m => m.TitlePinned, true))
|
||||||
|
: await db.Series.Where(x => x.Id == id).ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(x => x.Title, titel)
|
||||||
|
.SetProperty(x => x.SortTitleValue,
|
||||||
|
x => x.SortTitlePinned ? x.SortTitleValue : SortTitle.For(titel))
|
||||||
|
.SetProperty(x => x.TitlePinned, true));
|
||||||
|
|
||||||
|
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Der Sortiertitel — wonach einsortiert wird, wenn nicht nach dem Titel.**
|
||||||
|
//
|
||||||
|
// Eine Reihe steht alphabetisch durcheinander: „…Orden des Phönix" vor
|
||||||
|
// „…Stein der Weisen", weil O vor S liegt. Mit „Harry Potter 1",
|
||||||
|
// „Harry Potter 2" stehen sie so, wie man sie sieht.
|
||||||
|
app.MapPut("/api/v1/{art:regex(^movies|series$)}/{id:guid}/sorttitle", async (
|
||||||
|
string art, Guid id, TitelRequest anfrage, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var sortiert = anfrage.Title?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(sortiert))
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
"Ein Sortiertitel darf nicht leer sein. Zum Zurücksetzen gibt es „Wieder automatisch“.",
|
||||||
|
statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
if (sortiert.Length > 512)
|
||||||
|
return Einwand.Bitte("Höchstens 512 Zeichen.");
|
||||||
|
|
||||||
|
var betroffen = art == "movies"
|
||||||
|
? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(m => m.SortTitleValue, sortiert)
|
||||||
|
.SetProperty(m => m.SortTitlePinned, true))
|
||||||
|
: await db.Series.Where(x => x.Id == id).ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(x => x.SortTitleValue, sortiert)
|
||||||
|
.SetProperty(x => x.SortTitlePinned, true));
|
||||||
|
|
||||||
|
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Zurück zum errechneten Sortiertitel.**
|
||||||
|
//
|
||||||
|
// Anders als beim Titel wird er sofort neu gebildet: er steht nirgends
|
||||||
|
// sichtbar, und ihn bis zum nächsten Auffrischen falsch stehen zu
|
||||||
|
// lassen hiesse, dass die Liste solange in der falschen Reihenfolge
|
||||||
|
// steht.
|
||||||
|
//
|
||||||
|
// **Geladen und zurückgeschrieben, nicht in einem Rutsch.** Die Regel
|
||||||
|
// steht in `SortTitle.For` — einer C#-Methode, die EF Core nicht nach
|
||||||
|
// SQL übersetzen kann. Der Versuch endete in einer 500; dieselbe Regel
|
||||||
|
// ein zweites Mal als SQL zu schreiben wäre die Variante, bei der
|
||||||
|
// beide irgendwann auseinanderlaufen. Es geht um eine Zeile.
|
||||||
|
app.MapDelete("/api/v1/{art:regex(^movies|series$)}/{id:guid}/sorttitle", async (
|
||||||
|
string art, Guid id, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
if (art == "movies")
|
||||||
|
{
|
||||||
|
var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
|
||||||
|
if (film is null) return Results.NotFound();
|
||||||
|
|
||||||
|
film.SortTitleValue = SortTitle.For(film.Title);
|
||||||
|
film.SortTitlePinned = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var serie = await db.Series.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (serie is null) return Results.NotFound();
|
||||||
|
|
||||||
|
serie.SortTitleValue = SortTitle.For(serie.Title);
|
||||||
|
serie.SortTitlePinned = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.NoContent();
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Zurück zum automatischen Titel.**
|
||||||
|
//
|
||||||
|
// Ohne diesen Weg gäbe es keinen zurück, ausser den alten Namen zu
|
||||||
|
// erraten. Der Titel selbst bleibt stehen, bis das nächste Auffrischen
|
||||||
|
// ihn holt — sonst stünde dort bis dahin gar nichts.
|
||||||
|
app.MapDelete("/api/v1/{art:regex(^movies|series$)}/{id:guid}/title", async (
|
||||||
|
string art, Guid id, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var betroffen = art == "movies"
|
||||||
|
? await db.Movies.Where(m => m.Id == id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.TitlePinned, false))
|
||||||
|
: await db.Series.Where(x => x.Id == id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.TitlePinned, false));
|
||||||
|
|
||||||
|
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Eine Datei aus ihrem Titel herauslösen.**
|
||||||
|
//
|
||||||
|
// Zwei Dateien hängen an einem Eintrag, gehören aber zu zwei Filmen:
|
||||||
|
// „Hanni und Nanni" und „Hanni und Nanni 3" liefen bei der
|
||||||
|
// Metadatenquelle auf denselben Treffer, und der zweite Film war
|
||||||
|
// damit nicht mehr zu sehen. Die Riegel dagegen greifen erst beim
|
||||||
|
// nächsten Lauf — was schon zusammengelegt ist, bleibt es.
|
||||||
|
//
|
||||||
|
// Der neue Eintrag bekommt den Titel aus dem Dateinamen und keine
|
||||||
|
// Kennung: er steht danach unter „Ohne Zuordnung" und lässt sich dort
|
||||||
|
// in zwei Klicks richtigstellen. Eine Kennung zu raten hiesse, denselben
|
||||||
|
// Fehler noch einmal zu machen.
|
||||||
|
app.MapPost("/api/v1/files/{id:guid}/split", async (
|
||||||
|
Guid id, FlaroContext db, TimeProvider clock) =>
|
||||||
|
{
|
||||||
|
var datei = await db.MediaFiles.FirstOrDefaultAsync(f => f.Id == id);
|
||||||
|
if (datei is null) return Results.NotFound();
|
||||||
|
|
||||||
|
if (datei.MovieId is not { } alt)
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
"Nur eine Filmdatei lässt sich herauslösen. Bei Serien entscheidet "
|
||||||
|
+ "die Folgennummer, zu welcher Folge eine Datei gehört.",
|
||||||
|
statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
var geschwister = await db.MediaFiles.CountAsync(f => f.MovieId == alt);
|
||||||
|
if (geschwister < 2)
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
"An diesem Titel hängt nur diese eine Datei — da gibt es nichts zu trennen.",
|
||||||
|
statusCode: StatusCodes.Status409Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = FileNames.ParseMovie(datei.Path);
|
||||||
|
var bisher = await db.Movies.AsNoTracking().FirstAsync(m => m.Id == alt);
|
||||||
|
|
||||||
|
var neu = new Movie
|
||||||
|
{
|
||||||
|
LibraryId = datei.LibraryId,
|
||||||
|
ParsedTitle = name.Title,
|
||||||
|
ParsedYear = name.Year,
|
||||||
|
Title = name.Title,
|
||||||
|
SortTitleValue = SortTitle.For(name.Title),
|
||||||
|
Year = name.Year,
|
||||||
|
// **Sofort sichtbar.** Wer etwas herauslöst, will es sehen —
|
||||||
|
// nicht erst freigeben müssen.
|
||||||
|
PublishAt = bisher.PublishAt ?? clock.GetUtcNow(),
|
||||||
|
};
|
||||||
|
db.Movies.Add(neu);
|
||||||
|
|
||||||
|
datei.MovieId = neu.Id;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new MatchState(null, null, false, neu.Title, neu.Year, Id: neu.Id));
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Woher dieser Titel kommt.**
|
||||||
|
//
|
||||||
|
// Steht im Dialog über der Suche. Wenn eine Zuordnung völlig daneben
|
||||||
|
// liegt — „Dark Shadows" von 1966 statt „Dark" —, sagt der angezeigte
|
||||||
|
// Titel nichts mehr darüber, welche Datei da eigentlich gemeint ist.
|
||||||
|
// Der Ordner sagt es, und zwar sofort.
|
||||||
|
app.MapGet("/api/v1/{art:regex(^movies|series$)}/{id:guid}/herkunft", async (
|
||||||
|
string art, Guid id, FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var dateien = art == "movies"
|
||||||
|
? db.MediaFiles.AsNoTracking().Where(f => f.MovieId == id)
|
||||||
|
: db.MediaFiles.AsNoTracking().Where(f => f.EpisodeId != null
|
||||||
|
&& db.Episodes.Any(e => e.Id == f.EpisodeId && e.SeriesId == id));
|
||||||
|
|
||||||
|
// **Gezählt wird vollständig, gezeigt nur eine Handvoll.** Hier
|
||||||
|
// stand erst ein `Take(50)` vor dem Zählen — bei 1200 Folgen
|
||||||
|
// behauptete der Dialog dann „50 Dateien". Eine Zahl, die bei
|
||||||
|
// genau dem Titel falsch wird, bei dem man hinsieht.
|
||||||
|
//
|
||||||
|
// **Ein Zug, dann im Speicher.** Der Ordner aus einem Pfad zu
|
||||||
|
// schneiden lässt sich nicht nach SQL übersetzen — der Versuch
|
||||||
|
// endete in einem 500er. Die Pfade eines einzelnen Titels sind
|
||||||
|
// auch bei 1200 Folgen ein paar hundert Kilobyte; das ist
|
||||||
|
// billiger als eine Abfrage, die nicht läuft.
|
||||||
|
var pfade = await dateien.Select(f => f.Path).ToListAsync();
|
||||||
|
if (pfade.Count == 0) return Results.Ok(new Herkunft(null, 0, []));
|
||||||
|
|
||||||
|
var ordner = pfade
|
||||||
|
.Select(p => Path.GetDirectoryName(p) ?? "")
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Results.Ok(new Herkunft(
|
||||||
|
ordner.Count == 1 ? ordner[0] : GemeinsamerAnfang(ordner),
|
||||||
|
pfade.Count,
|
||||||
|
pfade.Order(StringComparer.Ordinal)
|
||||||
|
.Take(6)
|
||||||
|
.Select(Path.GetFileName)
|
||||||
|
.Where(n => n is not null)
|
||||||
|
.ToList()!));
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
app.MapGet("/api/v1/movies/{id:guid}/matches", async (
|
app.MapGet("/api/v1/movies/{id:guid}/matches", async (
|
||||||
Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) =>
|
Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) =>
|
||||||
{
|
{
|
||||||
@@ -34,7 +252,16 @@ public static class MatchEndpoints
|
|||||||
// Ohne eigene Suchworte das, was aus dem Dateinamen gelesen wurde
|
// Ohne eigene Suchworte das, was aus dem Dateinamen gelesen wurde
|
||||||
// — nicht der angezeigte Titel, der ja gerade der falsche ist.
|
// — nicht der angezeigte Titel, der ja gerade der falsche ist.
|
||||||
var vorschläge = await anbieter.SearchMoviesAsync(
|
var vorschläge = await anbieter.SearchMoviesAsync(
|
||||||
string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(), year ?? film.Year);
|
string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(),
|
||||||
|
// **Das Jahr aus dem Dateinamen, nicht das aus der Zuordnung.**
|
||||||
|
//
|
||||||
|
// Hier stand `film.Year` — und das kommt von genau der
|
||||||
|
// Zuordnung, die gerade korrigiert werden soll. „Doctor
|
||||||
|
// Strange" war fälschlich auf ein Making-of von 2022
|
||||||
|
// gelaufen; die Suche hängte daraufhin `year=2022` an, und der
|
||||||
|
// richtige Film von 2016 konnte gar nicht mehr auftauchen. Ein
|
||||||
|
// falscher Treffer verhinderte so seine eigene Korrektur.
|
||||||
|
year ?? film.ParsedYear);
|
||||||
|
|
||||||
return Results.Ok(vorschläge.Select(v =>
|
return Results.Ok(vorschläge.Select(v =>
|
||||||
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
||||||
@@ -42,18 +269,46 @@ public static class MatchEndpoints
|
|||||||
|
|
||||||
app.MapPut("/api/v1/movies/{id:guid}/match", async (
|
app.MapPut("/api/v1/movies/{id:guid}/match", async (
|
||||||
Guid id, ApplyMatchRequest anfrage, FlaroContext db,
|
Guid id, ApplyMatchRequest anfrage, FlaroContext db,
|
||||||
IMetadataProvider anbieter, MatchApplier anwender) =>
|
IMetadataProvider anbieter, MatchApplier anwender, Zusammenlegung zusammen) =>
|
||||||
{
|
{
|
||||||
var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
|
var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
|
||||||
if (film is null) return Results.NotFound();
|
if (film is null) return Results.NotFound();
|
||||||
|
|
||||||
var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId);
|
var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId);
|
||||||
if (gefunden is null)
|
if (gefunden is null)
|
||||||
return Results.Problem($"TMDB kennt den Film {anfrage.TmdbId} nicht.",
|
return Einwand.Fehlt($"TMDB kennt den Film {anfrage.TmdbId} nicht.");
|
||||||
statusCode: StatusCodes.Status404NotFound);
|
|
||||||
|
// **Hat die Kennung schon jemand, ist es derselbe Film.**
|
||||||
|
//
|
||||||
|
// Ein Film kommt je Mediathek genau einmal vor — das erzwingt ein
|
||||||
|
// Eindeutigkeitsindex. Die Kennung blind zu setzen liess ihn
|
||||||
|
// zuschlagen, und der Benutzer bekam „An error occurred while
|
||||||
|
// processing your request." zu sehen: eine englische
|
||||||
|
// Allgemeinmeldung für einen Fall, der gar kein Fehler ist.
|
||||||
|
//
|
||||||
|
// Zwei Dateien, die denselben Film meinen, gehören zusammengelegt
|
||||||
|
// — genau das tut der Scan am Ende jedes Laufs auch. Der
|
||||||
|
// Sehfortschritt wird dabei gefaltet, nichts geht verloren.
|
||||||
|
var schonDa = await db.Movies
|
||||||
|
.Where(m => m.LibraryId == film.LibraryId && m.TmdbId == anfrage.TmdbId && m.Id != id)
|
||||||
|
.Select(m => m.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (schonDa != Guid.Empty)
|
||||||
|
{
|
||||||
|
await anwender.ApplyAsync(
|
||||||
|
(await db.Movies.FirstAsync(m => m.Id == schonDa)), gefunden);
|
||||||
|
await zusammen.FilmVerschmelzenAsync(sieger: schonDa, verlierer: id);
|
||||||
|
|
||||||
|
var sieger = await db.Movies.AsNoTracking().FirstAsync(m => m.Id == schonDa);
|
||||||
|
return Results.Ok(new MatchState(
|
||||||
|
sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year,
|
||||||
|
Id: sieger.Id, Merged: true));
|
||||||
|
}
|
||||||
|
|
||||||
await anwender.ApplyAsync(film, gefunden);
|
await anwender.ApplyAsync(film, gefunden);
|
||||||
return Results.Ok(new MatchState(film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year));
|
return Results.Ok(new MatchState(
|
||||||
|
film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year, Id: film.Id));
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
app.MapGet("/api/v1/series/{id:guid}/matches", async (
|
app.MapGet("/api/v1/series/{id:guid}/matches", async (
|
||||||
@@ -63,7 +318,9 @@ public static class MatchEndpoints
|
|||||||
if (serie is null) return Results.NotFound();
|
if (serie is null) return Results.NotFound();
|
||||||
|
|
||||||
var vorschläge = await anbieter.SearchSeriesAsync(
|
var vorschläge = await anbieter.SearchSeriesAsync(
|
||||||
string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(), year ?? serie.Year);
|
string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(),
|
||||||
|
// Siehe oben: aus dem Dateinamen, nicht aus der Zuordnung.
|
||||||
|
year ?? serie.ParsedYear);
|
||||||
|
|
||||||
return Results.Ok(vorschläge.Select(v =>
|
return Results.Ok(vorschläge.Select(v =>
|
||||||
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
||||||
@@ -71,18 +328,68 @@ public static class MatchEndpoints
|
|||||||
|
|
||||||
app.MapPut("/api/v1/series/{id:guid}/match", async (
|
app.MapPut("/api/v1/series/{id:guid}/match", async (
|
||||||
Guid id, ApplyMatchRequest anfrage, FlaroContext db,
|
Guid id, ApplyMatchRequest anfrage, FlaroContext db,
|
||||||
IMetadataProvider anbieter, MatchApplier anwender) =>
|
IMetadataProvider anbieter, MatchApplier anwender, Zusammenlegung zusammen) =>
|
||||||
{
|
{
|
||||||
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id);
|
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id);
|
||||||
if (serie is null) return Results.NotFound();
|
if (serie is null) return Results.NotFound();
|
||||||
|
|
||||||
var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId);
|
var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId);
|
||||||
if (gefunden is null)
|
if (gefunden is null)
|
||||||
return Results.Problem($"TMDB kennt die Serie {anfrage.TmdbId} nicht.",
|
return Einwand.Fehlt($"TMDB kennt die Serie {anfrage.TmdbId} nicht.");
|
||||||
statusCode: StatusCodes.Status404NotFound);
|
|
||||||
|
// Siehe bei den Filmen: dieselbe Kennung heisst dieselbe Serie,
|
||||||
|
// und zwei Ordner derselben Serie gehören zusammengelegt.
|
||||||
|
var schonDa = await db.Series
|
||||||
|
.Where(x => x.LibraryId == serie.LibraryId && x.TmdbId == anfrage.TmdbId && x.Id != id)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (schonDa != Guid.Empty)
|
||||||
|
{
|
||||||
|
await anwender.ApplyAsync(await db.Series.FirstAsync(x => x.Id == schonDa), gefunden);
|
||||||
|
await zusammen.SerieVerschmelzenAsync(sieger: schonDa, verlierer: id);
|
||||||
|
|
||||||
|
var sieger = await db.Series.AsNoTracking().FirstAsync(x => x.Id == schonDa);
|
||||||
|
return Results.Ok(new MatchState(
|
||||||
|
sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year,
|
||||||
|
Id: sieger.Id, Merged: true));
|
||||||
|
}
|
||||||
|
|
||||||
await anwender.ApplyAsync(serie, gefunden);
|
await anwender.ApplyAsync(serie, gefunden);
|
||||||
return Results.Ok(new MatchState(serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year));
|
return Results.Ok(new MatchState(
|
||||||
|
serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year, Id: serie.Id));
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Der längste Pfad, mit dem alle anfangen — oder <c>null</c>.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Verglichen wird nach Ordnerbestandteilen, nicht nach Zeichen: sonst
|
||||||
|
/// ergäben <c>/media/Serien/Dark</c> und <c>/media/Serien/Darkwing</c> den
|
||||||
|
/// gemeinsamen Anfang <c>/media/Serien/Dark</c>, und der bezeichnet einen
|
||||||
|
/// Ordner, in dem nur eines von beidem liegt.
|
||||||
|
/// </remarks>
|
||||||
|
private static string? GemeinsamerAnfang(IReadOnlyList<string> pfade)
|
||||||
|
{
|
||||||
|
if (pfade.Count == 0) return null;
|
||||||
|
|
||||||
|
var teile = pfade.Select(p => p.Split('/', StringSplitOptions.RemoveEmptyEntries)).ToList();
|
||||||
|
var gemeinsam = new List<string>();
|
||||||
|
|
||||||
|
for (var i = 0; i < teile[0].Length; i++)
|
||||||
|
{
|
||||||
|
var stueck = teile[0][i];
|
||||||
|
if (teile.Any(t => i >= t.Length || t[i] != stueck)) break;
|
||||||
|
gemeinsam.Add(stueck);
|
||||||
|
}
|
||||||
|
|
||||||
|
return gemeinsam.Count == 0 ? null : "/" + string.Join('/', gemeinsam);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Woher ein Titel im Dateisystem stammt.</summary>
|
||||||
|
///
|
||||||
|
/// <param name="Ordner">Der gemeinsame Ordner aller Dateien; <c>null</c>, wenn es keinen gibt.</param>
|
||||||
|
/// <param name="Dateien">Wie viele Dateien an diesem Titel hängen.</param>
|
||||||
|
/// <param name="Beispiele">Ein paar Dateinamen — mehr braucht niemand zum Wiedererkennen.</param>
|
||||||
|
public sealed record Herkunft(string? Ordner, int Dateien, IReadOnlyList<string> Beispiele);
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Flaro.Core.Library;
|
||||||
|
using Flaro.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>Melden, was an einem Titel nicht stimmt — und es abarbeiten.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum es das gibt.</b> Wer einen Server für andere betreibt, erfährt von
|
||||||
|
/// Fehlern zuletzt: dass die deutsche Tonspur nach einer halben Stunde
|
||||||
|
/// aussetzt, merkt der, der den Film sieht, und nicht der, der ihn eingeräumt
|
||||||
|
/// hat. Bisher gab es dafür keinen Weg ausser dem Zuruf im Wohnzimmer.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Melden darf jeder, lesen nur die Verwaltung.</b> Die eine Ausnahme sind
|
||||||
|
/// die eigenen Meldungen: wer etwas gemeldet hat, soll nachsehen können, ob
|
||||||
|
/// etwas daraus geworden ist, ohne zu fragen. Das ist dieselbe Linie wie beim
|
||||||
|
/// Sehprotokoll — eigene Daten ja, fremde nein, auch als Administrator nicht
|
||||||
|
/// über diesen Weg.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class MeldungEndpoints
|
||||||
|
{
|
||||||
|
private const int HoechstensZeichen = 2000;
|
||||||
|
|
||||||
|
/// <summary>Wie viele Meldungen eine Liste höchstens zurückgibt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Die offene Liste ist eine Warteschlange. Wer fünfhundert offene
|
||||||
|
/// Meldungen hat, hat ein anderes Problem als das, das dieser Bildschirm
|
||||||
|
/// löst.
|
||||||
|
/// </remarks>
|
||||||
|
private const int HoechstensZeilen = 500;
|
||||||
|
|
||||||
|
public static void MapMeldungen(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
// **Am selben Präfix wie alles, was eine undurchsichtige Titelkennung
|
||||||
|
// annimmt.** Gesehen, Favorit, Weglegen und Fortschritt liegen auch
|
||||||
|
// dort; der Client muss dadurch nicht wissen, ob er einen Film, eine
|
||||||
|
// Serie oder eine Folge in der Hand hält.
|
||||||
|
var melden = app.MapGroup("/api/v1/items").RequireAuthorization();
|
||||||
|
|
||||||
|
melden.MapPost("/{id:guid}/reports", async (
|
||||||
|
Guid id, MeldungRequest anfrage, ClaimsPrincipal wer,
|
||||||
|
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (Kennung(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var kommentar = anfrage.Comment?.Trim() ?? "";
|
||||||
|
if (kommentar.Length == 0)
|
||||||
|
return Einwand.Bitte("Ohne Beschreibung lässt sich nichts nachsehen.");
|
||||||
|
if (kommentar.Length > HoechstensZeichen)
|
||||||
|
return Einwand.Bitte($"Höchstens {HoechstensZeichen} Zeichen.");
|
||||||
|
|
||||||
|
if (AlsArt(anfrage.Kind) is not { } art)
|
||||||
|
{
|
||||||
|
return Einwand.Bitte("Unbekannte Art. Möglich sind: bild, ton, untertitel, daten, sonstiges.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AlsStelle(anfrage.Position, out var stelle))
|
||||||
|
return Einwand.Bitte("Die Stelle muss wie 0:34:12 aussehen.");
|
||||||
|
|
||||||
|
// **Der Freigabefilter erledigt die Rechteprüfung.** Wer einen
|
||||||
|
// Titel nicht sehen darf, findet ihn hier nicht — ohne ein
|
||||||
|
// einziges `Where` und ohne eine Stelle, die man zu pflegen
|
||||||
|
// vergessen kann.
|
||||||
|
Guid? film = null, serie = null, folge = null;
|
||||||
|
if (await db.Movies.AnyAsync(m => m.Id == id, ct)) film = id;
|
||||||
|
else if (await db.Episodes.AnyAsync(f => f.Id == id, ct)) folge = id;
|
||||||
|
else if (await db.Series.AnyAsync(s => s.Id == id, ct)) serie = id;
|
||||||
|
else return Einwand.Bitte("Diesen Titel gibt es nicht.");
|
||||||
|
|
||||||
|
int? staffel = null;
|
||||||
|
if (anfrage.SeasonNumber is { } nummer)
|
||||||
|
{
|
||||||
|
if (serie is null)
|
||||||
|
return Einwand.Bitte("Eine Staffel gibt es nur zu einer Serie.");
|
||||||
|
if (!await db.Episodes.AnyAsync(f => f.SeriesId == serie && f.SeasonNumber == nummer, ct))
|
||||||
|
return Einwand.Bitte("Diese Staffel gibt es in der Serie nicht.");
|
||||||
|
staffel = nummer;
|
||||||
|
}
|
||||||
|
|
||||||
|
var meldung = new Meldung
|
||||||
|
{
|
||||||
|
UserId = benutzer,
|
||||||
|
MovieId = film,
|
||||||
|
SeriesId = serie,
|
||||||
|
EpisodeId = folge,
|
||||||
|
SeasonNumber = staffel,
|
||||||
|
Art = art,
|
||||||
|
Stelle = stelle,
|
||||||
|
Kommentar = kommentar,
|
||||||
|
GemeldetAm = uhr.GetUtcNow(),
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Meldungen.Add(meldung);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { id = meldung.Id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// **Die eigenen — jeder nur sich selbst, auch ein Administrator.**
|
||||||
|
// Steht vor der Verwaltungsgruppe, damit `mine` nicht als Kennung
|
||||||
|
// gelesen wird; die Verwaltungsrouten tragen zusätzlich `:guid`.
|
||||||
|
app.MapGroup("/api/v1/reports/mine").RequireAuthorization()
|
||||||
|
.MapGet("", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (Kennung(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var roh = await db.Meldungen.AsNoTracking()
|
||||||
|
.Where(m => m.UserId == benutzer)
|
||||||
|
.OrderByDescending(m => m.GemeldetAm)
|
||||||
|
.Take(HoechstensZeilen)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(await Anreichern(db, roh, ct));
|
||||||
|
});
|
||||||
|
|
||||||
|
var verwalten = app.MapGroup("/api/v1/reports")
|
||||||
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
verwalten.MapGet("", async (
|
||||||
|
FlaroContext db, CancellationToken ct, bool erledigt = false) =>
|
||||||
|
{
|
||||||
|
// **Offene aufsteigend, erledigte absteigend.** Das eine ist eine
|
||||||
|
// Warteschlange — dort gehört das Älteste nach oben. Das andere
|
||||||
|
// ist ein Archiv, und darin sucht man das Letzte.
|
||||||
|
var abfrage = db.Meldungen.AsNoTracking();
|
||||||
|
var roh = erledigt
|
||||||
|
? await abfrage.Where(m => m.ErledigtAm != null)
|
||||||
|
.OrderByDescending(m => m.ErledigtAm).Take(HoechstensZeilen).ToListAsync(ct)
|
||||||
|
: await abfrage.Where(m => m.ErledigtAm == null)
|
||||||
|
.OrderBy(m => m.GemeldetAm).Take(HoechstensZeilen).ToListAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(await Anreichern(db, roh, ct));
|
||||||
|
});
|
||||||
|
|
||||||
|
// **Abhaken, nicht löschen.** Wiederholt sich ein Problem, ist die
|
||||||
|
// Vorgeschichte die halbe Antwort. Zweimal abhaken ist kein Fehler,
|
||||||
|
// sondern zweimal dasselbe Ergebnis.
|
||||||
|
verwalten.MapPost("/{id:guid}/resolve", (Guid id, ClaimsPrincipal wer,
|
||||||
|
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||||
|
Umschalten(id, wer, db, ct, uhr.GetUtcNow()));
|
||||||
|
|
||||||
|
verwalten.MapPost("/{id:guid}/reopen", (Guid id, ClaimsPrincipal wer,
|
||||||
|
FlaroContext db, CancellationToken ct) =>
|
||||||
|
Umschalten(id, wer, db, ct, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Umschalten(
|
||||||
|
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct, DateTimeOffset? wann)
|
||||||
|
{
|
||||||
|
var meldung = await db.Meldungen.FirstOrDefaultAsync(m => m.Id == id, ct);
|
||||||
|
if (meldung is null)
|
||||||
|
return Einwand.Fehlt("Diese Meldung gibt es nicht mehr.");
|
||||||
|
|
||||||
|
meldung.ErledigtAm = wann;
|
||||||
|
meldung.ErledigtVon = wann is null ? null : Kennung(wer);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hängt Titel, Plakat und Melder an die nackten Zeilen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Nachgeschlagen, nicht verbunden.</b> Ein Join würde eine Meldung
|
||||||
|
/// verschwinden lassen, sobald ihr Titel für den Fragenden unsichtbar wird
|
||||||
|
/// — und das trifft ausgerechnet die eigene Liste des Melders, wenn ein
|
||||||
|
/// Administrator den Titel zurückzieht. Hier bleibt die Zeile stehen und
|
||||||
|
/// der Name fehlt.
|
||||||
|
///
|
||||||
|
/// <b>Und ohne <c>IgnoreQueryFilters</c>.</b> Das wäre die bequeme Lösung
|
||||||
|
/// und gäbe den Namen eines unveröffentlichten Titels über einen Weg
|
||||||
|
/// heraus, an den beim nächsten Mal niemand denkt.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<List<Meldungseintrag>> Anreichern(
|
||||||
|
FlaroContext db, List<Meldung> roh, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var filmIds = roh.Where(m => m.MovieId != null).Select(m => m.MovieId!.Value).Distinct().ToList();
|
||||||
|
var serienIds = roh.Where(m => m.SeriesId != null).Select(m => m.SeriesId!.Value).Distinct().ToList();
|
||||||
|
var folgenIds = roh.Where(m => m.EpisodeId != null).Select(m => m.EpisodeId!.Value).Distinct().ToList();
|
||||||
|
var leuteIds = roh.Select(m => m.UserId).Distinct().ToList();
|
||||||
|
|
||||||
|
var filme = await db.Movies.AsNoTracking().Where(m => filmIds.Contains(m.Id))
|
||||||
|
.Select(m => new { m.Id, m.Title, m.PosterImage }).ToDictionaryAsync(m => m.Id, ct);
|
||||||
|
|
||||||
|
var serien = await db.Series.AsNoTracking().Where(s => serienIds.Contains(s.Id))
|
||||||
|
.Select(s => new { s.Id, s.Title, s.PosterImage }).ToDictionaryAsync(s => s.Id, ct);
|
||||||
|
|
||||||
|
var folgen = await db.Episodes.AsNoTracking().Where(f => folgenIds.Contains(f.Id))
|
||||||
|
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
|
||||||
|
{
|
||||||
|
f.Id, Folgentitel = f.Title, f.SeasonNumber, f.EpisodeNumber,
|
||||||
|
Serientitel = s.Title, s.PosterImage, SerienId = s.Id,
|
||||||
|
})
|
||||||
|
.ToDictionaryAsync(f => f.Id, ct);
|
||||||
|
|
||||||
|
var leute = await db.Users.AsNoTracking().Where(u => leuteIds.Contains(u.Id))
|
||||||
|
.Select(u => new { u.Id, u.UserName }).ToDictionaryAsync(u => u.Id, ct);
|
||||||
|
|
||||||
|
var fertig = new List<Meldungseintrag>(roh.Count);
|
||||||
|
foreach (var m in roh)
|
||||||
|
{
|
||||||
|
var melder = leute.TryGetValue(m.UserId, out var u) ? u.UserName : "";
|
||||||
|
var gemeinsam = (
|
||||||
|
Kind: AlsWort(m.Art),
|
||||||
|
Stelle: m.Stelle?.TotalSeconds,
|
||||||
|
m.Kommentar, m.GemeldetAm, m.ErledigtAm);
|
||||||
|
|
||||||
|
if (m.MovieId is { } film)
|
||||||
|
{
|
||||||
|
filme.TryGetValue(film, out var f);
|
||||||
|
fertig.Add(new Meldungseintrag(
|
||||||
|
m.Id, "movie", film, f?.Title ?? "", null, f?.PosterImage,
|
||||||
|
gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar,
|
||||||
|
gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm));
|
||||||
|
}
|
||||||
|
else if (m.EpisodeId is { } folge)
|
||||||
|
{
|
||||||
|
folgen.TryGetValue(folge, out var f);
|
||||||
|
fertig.Add(new Meldungseintrag(
|
||||||
|
m.Id, "episode", f?.SerienId ?? folge, f?.Serientitel ?? "",
|
||||||
|
f is null ? null
|
||||||
|
: $"S{f.SeasonNumber} · F{f.EpisodeNumber}"
|
||||||
|
+ (f.Folgentitel is { Length: > 0 } t ? $" — {t}" : ""),
|
||||||
|
f?.PosterImage,
|
||||||
|
gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar,
|
||||||
|
gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm));
|
||||||
|
}
|
||||||
|
else if (m.SeriesId is { } serie)
|
||||||
|
{
|
||||||
|
serien.TryGetValue(serie, out var s);
|
||||||
|
fertig.Add(new Meldungseintrag(
|
||||||
|
m.Id, m.SeasonNumber is null ? "series" : "season", serie,
|
||||||
|
s?.Title ?? "",
|
||||||
|
m.SeasonNumber is { } nummer ? $"Staffel {nummer}" : null,
|
||||||
|
s?.PosterImage,
|
||||||
|
gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar,
|
||||||
|
gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fertig;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid? Kennung(ClaimsPrincipal wer) =>
|
||||||
|
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
||||||
|
|
||||||
|
private static Meldungsart? AlsArt(string? wert) => wert?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"bild" => Meldungsart.Bild,
|
||||||
|
"ton" => Meldungsart.Ton,
|
||||||
|
"untertitel" => Meldungsart.Untertitel,
|
||||||
|
"daten" => Meldungsart.Daten,
|
||||||
|
"sonstiges" => Meldungsart.Sonstiges,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Das Wort, das über die Leitung geht — nicht der Name im Code.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Von Hand und nicht über <c>ToString()</c>: sonst entschiede ein
|
||||||
|
/// Umbenennen im C# über das Format, das der Client liest.
|
||||||
|
/// </remarks>
|
||||||
|
private static string AlsWort(Meldungsart art) => art switch
|
||||||
|
{
|
||||||
|
Meldungsart.Bild => "bild",
|
||||||
|
Meldungsart.Ton => "ton",
|
||||||
|
Meldungsart.Untertitel => "untertitel",
|
||||||
|
Meldungsart.Daten => "daten",
|
||||||
|
_ => "sonstiges",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>„1:02:03", „2:03" oder „123" — genau die Formen, die der Client schreibt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Nicht <c>TimeSpan.Parse</c>: der liest „2:03" als zwei Stunden und drei
|
||||||
|
/// Minuten. Wer in ein Feld neben einem Film „2:03" schreibt, meint zwei
|
||||||
|
/// Minuten — so steht es auch unter jedem Fortschrittsbalken.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool AlsStelle(string? text, out TimeSpan? wert)
|
||||||
|
{
|
||||||
|
wert = null;
|
||||||
|
var roh = text?.Trim() ?? "";
|
||||||
|
if (roh.Length == 0) return true;
|
||||||
|
|
||||||
|
var teile = roh.Split(':');
|
||||||
|
if (teile.Length > 3) return false;
|
||||||
|
|
||||||
|
var zahlen = new List<int>();
|
||||||
|
foreach (var teil in teile)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(teil, out var zahl) || zahl < 0) return false;
|
||||||
|
zahlen.Add(zahl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alles ausser dem vordersten Glied zählt in Sechzigern.
|
||||||
|
if (zahlen.Skip(1).Any(z => z > 59)) return false;
|
||||||
|
|
||||||
|
var sekunden = zahlen.Aggregate(0L, (summe, zahl) => summe * 60 + zahl);
|
||||||
|
wert = TimeSpan.FromSeconds(sekunden);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,15 @@ public static class PlaybackEndpoints
|
|||||||
{
|
{
|
||||||
private const int HöchstensJeSeite = 200;
|
private const int HöchstensJeSeite = 200;
|
||||||
|
|
||||||
|
/// <summary>Wie viele Titel eine Sammelanfrage höchstens trägt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Grosszügig, aber nicht offen.</b> Eine Mediathek mit 500 Filmen
|
||||||
|
/// lässt sich damit in einem Zug abhaken; eine Anfrage mit 50 000
|
||||||
|
/// Kennungen ist keine Bedienung mehr, sondern ein Angriff.
|
||||||
|
/// </remarks>
|
||||||
|
private const int HöchstensJeZug = 1000;
|
||||||
|
|
||||||
public static void MapPlayback(this IEndpointRouteBuilder app)
|
public static void MapPlayback(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
MapZustand(app);
|
MapZustand(app);
|
||||||
@@ -39,9 +48,10 @@ public static class PlaybackEndpoints
|
|||||||
// Jede Meldung wird abgelegt — es gibt keine, die zu klein wäre.
|
// Jede Meldung wird abgelegt — es gibt keine, die zu klein wäre.
|
||||||
titel.MapPost("/progress", async (
|
titel.MapPost("/progress", async (
|
||||||
Guid id, ProgressRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
|
Guid id, ProgressRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
|
||||||
TimeProvider clock, PlaybackSessions laufende) =>
|
TimeProvider clock, PlaybackSessions laufende, Wiedergabeeinstellungen einstellungen) =>
|
||||||
{
|
{
|
||||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
var regeln = await einstellungen.RegelnAsync();
|
||||||
|
|
||||||
var zustand = await Zustand(db, benutzer, id);
|
var zustand = await Zustand(db, benutzer, id);
|
||||||
if (zustand is null) return Results.NotFound();
|
if (zustand is null) return Results.NotFound();
|
||||||
@@ -53,7 +63,12 @@ public static class PlaybackEndpoints
|
|||||||
? TimeSpan.FromSeconds(sekunden)
|
? TimeSpan.FromSeconds(sekunden)
|
||||||
: zustand.Runtime ?? await Dateilaufzeit(db, zustand);
|
: zustand.Runtime ?? await Dateilaufzeit(db, zustand);
|
||||||
|
|
||||||
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(anfrage.PositionSeconds), laenge);
|
var ergebnis = ProgressRule.Apply(
|
||||||
|
TimeSpan.FromSeconds(anfrage.PositionSeconds), laenge, regeln);
|
||||||
|
|
||||||
|
// **Erst zählen, dann überschreiben.** Der Zuwachs ergibt sich aus
|
||||||
|
// der alten Stelle; eine Zeile später gibt es die nicht mehr.
|
||||||
|
await Mitzaehlen(db, benutzer, zustand, ergebnis.Position, clock);
|
||||||
|
|
||||||
// **Das Häkchen wird gesetzt, nicht genommen.** Wer einen
|
// **Das Häkchen wird gesetzt, nicht genommen.** Wer einen
|
||||||
// gesehenen Film erneut anfängt, hat ihn trotzdem gesehen — und
|
// gesehenen Film erneut anfängt, hat ihn trotzdem gesehen — und
|
||||||
@@ -72,7 +87,7 @@ public static class PlaybackEndpoints
|
|||||||
// Wer weitersieht, hat es sich offenbar anders überlegt.
|
// Wer weitersieht, hat es sich offenbar anders überlegt.
|
||||||
if (ergebnis.Position > TimeSpan.Zero) zustand.Dismissed = false;
|
if (ergebnis.Position > TimeSpan.Zero) zustand.Dismissed = false;
|
||||||
|
|
||||||
Nachfuehren(zustand);
|
Nachfuehren(zustand, regeln);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
// **Die Meldung ist zugleich das Lebenszeichen.** Jeder Client
|
// **Die Meldung ist zugleich das Lebenszeichen.** Jeder Client
|
||||||
@@ -90,9 +105,11 @@ public static class PlaybackEndpoints
|
|||||||
});
|
});
|
||||||
|
|
||||||
titel.MapPut("/watched", async (
|
titel.MapPut("/watched", async (
|
||||||
Guid id, WatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
Guid id, WatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||||
|
Wiedergabeeinstellungen einstellungen) =>
|
||||||
{
|
{
|
||||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
var regeln = await einstellungen.RegelnAsync();
|
||||||
|
|
||||||
var zustand = await Zustand(db, benutzer, id);
|
var zustand = await Zustand(db, benutzer, id);
|
||||||
if (zustand is null) return Results.NotFound();
|
if (zustand is null) return Results.NotFound();
|
||||||
@@ -114,57 +131,118 @@ public static class PlaybackEndpoints
|
|||||||
zustand.Dismissed = false;
|
zustand.Dismissed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Eine Serie zieht ihre Folgen mit.** Sonst stünde das Häkchen an
|
||||||
|
// der Serie und daneben „noch 42 offen".
|
||||||
|
if (zustand.SeriesId is { } serie)
|
||||||
|
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock, regeln);
|
||||||
|
|
||||||
zustand.UpdatedAt = clock.GetUtcNow();
|
zustand.UpdatedAt = clock.GetUtcNow();
|
||||||
Nachfuehren(zustand);
|
Nachfuehren(zustand, regeln);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Ok(Abbild(zustand));
|
return Results.Ok(Abbild(zustand));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen.
|
// **Viele auf einmal.** Eigene Route, weil `/items/{id}` eine Kennung
|
||||||
titel.MapPost("/dismiss", async (
|
// im Pfad trägt und eine Liste keine hat.
|
||||||
Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
app.MapPut("/api/v1/items/watched", async (
|
||||||
|
BulkWatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||||
|
Wiedergabeeinstellungen einstellungen) =>
|
||||||
{
|
{
|
||||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
var regeln = await einstellungen.RegelnAsync();
|
||||||
|
if (anfrage.Ids.Count == 0)
|
||||||
|
return Results.Ok(new BulkWatchedResponse(0, []));
|
||||||
|
if (anfrage.Ids.Count > HöchstensJeZug)
|
||||||
|
return Results.Problem(
|
||||||
|
$"Mehr als {HöchstensJeZug} Titel auf einmal gehen nicht. "
|
||||||
|
+ "Nimm weniger und wiederhole es.",
|
||||||
|
statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
var jetzt = clock.GetUtcNow();
|
||||||
|
var unbekannt = new List<Guid>();
|
||||||
|
var geaendert = 0;
|
||||||
|
|
||||||
|
// **Nacheinander, nicht in einem Rutsch.** Eine Serie zieht ihre
|
||||||
|
// Folgen mit, und dafür gibt es keine Mengenoperation, die das
|
||||||
|
// ebenso richtig täte. Gespeichert wird einmal am Ende.
|
||||||
|
foreach (var id in anfrage.Ids.Distinct())
|
||||||
|
{
|
||||||
|
var zustand = await Zustand(db, benutzer, id);
|
||||||
|
if (zustand is null) { unbekannt.Add(id); continue; }
|
||||||
|
|
||||||
|
if (zustand.Watched == anfrage.Watched && zustand.Position == TimeSpan.Zero) continue;
|
||||||
|
|
||||||
|
if (anfrage.Watched && !zustand.Watched)
|
||||||
|
{
|
||||||
|
zustand.PlayCount++;
|
||||||
|
zustand.LastPlayedAt = jetzt;
|
||||||
|
}
|
||||||
|
zustand.Watched = anfrage.Watched;
|
||||||
|
zustand.Position = TimeSpan.Zero;
|
||||||
|
zustand.Dismissed = false;
|
||||||
|
zustand.UpdatedAt = jetzt;
|
||||||
|
Nachfuehren(zustand, regeln);
|
||||||
|
|
||||||
|
if (zustand.SeriesId is { } serie)
|
||||||
|
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock, regeln);
|
||||||
|
|
||||||
|
geaendert++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.Ok(new BulkWatchedResponse(geaendert, unbekannt));
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen.
|
||||||
|
titel.MapPost("/dismiss", async (
|
||||||
|
Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||||
|
Wiedergabeeinstellungen einstellungen) =>
|
||||||
|
{
|
||||||
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
var regeln = await einstellungen.RegelnAsync();
|
||||||
|
|
||||||
var zustand = await Zustand(db, benutzer, id);
|
var zustand = await Zustand(db, benutzer, id);
|
||||||
if (zustand is null) return Results.NotFound();
|
if (zustand is null) return Results.NotFound();
|
||||||
|
|
||||||
zustand.Dismissed = true;
|
zustand.Dismissed = true;
|
||||||
zustand.UpdatedAt = clock.GetUtcNow();
|
zustand.UpdatedAt = clock.GetUtcNow();
|
||||||
Nachfuehren(zustand);
|
Nachfuehren(zustand, regeln);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Ok(Abbild(zustand));
|
return Results.Ok(Abbild(zustand));
|
||||||
});
|
});
|
||||||
|
|
||||||
titel.MapPut("/favourite", async (
|
titel.MapPut("/favourite", async (
|
||||||
Guid id, FavouriteRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
Guid id, FavouriteRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||||
|
Wiedergabeeinstellungen einstellungen) =>
|
||||||
{
|
{
|
||||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
var regeln = await einstellungen.RegelnAsync();
|
||||||
|
|
||||||
var zustand = await Zustand(db, benutzer, id);
|
var zustand = await Zustand(db, benutzer, id);
|
||||||
if (zustand is null) return Results.NotFound();
|
if (zustand is null) return Results.NotFound();
|
||||||
|
|
||||||
zustand.Favourite = anfrage.Favourite;
|
zustand.Favourite = anfrage.Favourite;
|
||||||
zustand.UpdatedAt = clock.GetUtcNow();
|
zustand.UpdatedAt = clock.GetUtcNow();
|
||||||
Nachfuehren(zustand);
|
Nachfuehren(zustand, regeln);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Ok(Abbild(zustand));
|
return Results.Ok(Abbild(zustand));
|
||||||
});
|
});
|
||||||
|
|
||||||
titel.MapPut("/rating", async (
|
titel.MapPut("/rating", async (
|
||||||
Guid id, RatingRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
Guid id, RatingRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||||
|
Wiedergabeeinstellungen einstellungen) =>
|
||||||
{
|
{
|
||||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
var regeln = await einstellungen.RegelnAsync();
|
||||||
if (anfrage.Rating is { } wert && wert is < 1 or > 10)
|
if (anfrage.Rating is { } wert && wert is < 1 or > 10)
|
||||||
return Results.Problem("Eine Bewertung liegt zwischen 1 und 10.",
|
return Einwand.Bitte("Eine Bewertung liegt zwischen 1 und 10.");
|
||||||
statusCode: StatusCodes.Status400BadRequest);
|
|
||||||
|
|
||||||
var zustand = await Zustand(db, benutzer, id);
|
var zustand = await Zustand(db, benutzer, id);
|
||||||
if (zustand is null) return Results.NotFound();
|
if (zustand is null) return Results.NotFound();
|
||||||
|
|
||||||
zustand.Rating = anfrage.Rating;
|
zustand.Rating = anfrage.Rating;
|
||||||
zustand.UpdatedAt = clock.GetUtcNow();
|
zustand.UpdatedAt = clock.GetUtcNow();
|
||||||
Nachfuehren(zustand);
|
Nachfuehren(zustand, regeln);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Ok(Abbild(zustand));
|
return Results.Ok(Abbild(zustand));
|
||||||
});
|
});
|
||||||
@@ -223,7 +301,7 @@ public static class PlaybackEndpoints
|
|||||||
|
|
||||||
var folgen = await db.Episodes.AsNoTracking()
|
var folgen = await db.Episodes.AsNoTracking()
|
||||||
.Where(f => serienIds.Contains(f.SeriesId))
|
.Where(f => serienIds.Contains(f.SeriesId))
|
||||||
.Select(f => new { f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.Title, f.StillImage })
|
.Select(f => new { f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.Title, f.StillImage, f.PublishAt })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var folgenIds = folgen.Select(f => f.Id).ToList();
|
var folgenIds = folgen.Select(f => f.Id).ToList();
|
||||||
@@ -233,7 +311,7 @@ public static class PlaybackEndpoints
|
|||||||
|
|
||||||
var serien = await db.Series.AsNoTracking()
|
var serien = await db.Series.AsNoTracking()
|
||||||
.Where(x => serienIds.Contains(x.Id))
|
.Where(x => serienIds.Contains(x.Id))
|
||||||
.Select(x => new { x.Id, x.Title, x.PosterImage })
|
.Select(x => new { x.Id, x.Title, x.PosterImage, x.BackdropImage })
|
||||||
.ToDictionaryAsync(x => x.Id);
|
.ToDictionaryAsync(x => x.Id);
|
||||||
|
|
||||||
var liste = new List<ContinueEntry>();
|
var liste = new List<ContinueEntry>();
|
||||||
@@ -255,9 +333,21 @@ public static class PlaybackEndpoints
|
|||||||
naechste.Id, "episode", serie.Title, naechste.Title,
|
naechste.Id, "episode", serie.Title, naechste.Title,
|
||||||
naechste.SeasonNumber, naechste.EpisodeNumber, null,
|
naechste.SeasonNumber, naechste.EpisodeNumber, null,
|
||||||
serie.PosterImage, naechste.StillImage ?? serie.PosterImage,
|
serie.PosterImage, naechste.StillImage ?? serie.PosterImage,
|
||||||
zustand is null ? Leer() : Abbild(zustand)));
|
zustand is null ? Leer() : Abbild(zustand),
|
||||||
|
SeriesId: x.SeriesId,
|
||||||
|
PublishAt: naechste.PublishAt,
|
||||||
|
SeriesBackdropImage: serie.BackdropImage));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Die Dateien erst jetzt, für die ausgewählten Folgen — nicht für
|
||||||
|
// alle Folgen aller angefassten Serien.
|
||||||
|
var erste = await Erstdateien(db, [], liste.Select(e => e.Id).ToList());
|
||||||
|
liste = liste.Select(e => e with
|
||||||
|
{
|
||||||
|
FileId = erste.GetValueOrDefault(e.Id).Id,
|
||||||
|
DurationSeconds = erste.GetValueOrDefault(e.Id).Sekunden,
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<ContinueEntry>(liste, liste.Count));
|
return Results.Ok(new Page<ContinueEntry>(liste, liste.Count));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,9 +409,24 @@ public static class PlaybackEndpoints
|
|||||||
.Select(x => x.Film)
|
.Select(x => x.Film)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
// **Mit Datei und Termin.** Ein Vorschlag ohne Datei liesse den
|
||||||
|
// Aufmacher „Abspielen" anbieten und dann doch nur blättern; ein
|
||||||
|
// Vorschlag ohne Termin verschwiege, dass es ihn noch nicht gibt.
|
||||||
|
var vorschlagIds = treffer.Select(m => m.Id).ToList();
|
||||||
|
var vorschlagsdateien = await db.MediaFiles.AsNoTracking()
|
||||||
|
.Where(d => d.MovieId != null && vorschlagIds.Contains(d.MovieId!.Value))
|
||||||
|
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.MovieId, d.VersionLabel })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
var items = treffer.Select(m => new MovieResponse(
|
var items = treffer.Select(m => new MovieResponse(
|
||||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||||
[], null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).ToList();
|
vorschlagsdateien.Where(d => d.MovieId == m.Id)
|
||||||
|
.Select(d => new MediaFileResponse(
|
||||||
|
d.Id, Path.GetFileName(d.Path), d.SizeBytes, d.Container,
|
||||||
|
d.Duration?.TotalSeconds, [], d.VersionLabel))
|
||||||
|
.ToList(),
|
||||||
|
null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
|
||||||
|
PublishAt: m.PublishAt)).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<MovieResponse>(items, items.Count));
|
return Results.Ok(new Page<MovieResponse>(items, items.Count));
|
||||||
});
|
});
|
||||||
@@ -348,32 +453,69 @@ public static class PlaybackEndpoints
|
|||||||
var folgen = zustände.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
|
var folgen = zustände.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
|
||||||
|
|
||||||
var filmdaten = await db.Movies.AsNoTracking().Where(m => filme.Contains(m.Id))
|
var filmdaten = await db.Movies.AsNoTracking().Where(m => filme.Contains(m.Id))
|
||||||
.Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage })
|
.Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage, m.PublishAt })
|
||||||
.ToDictionaryAsync(m => m.Id);
|
.ToDictionaryAsync(m => m.Id);
|
||||||
|
|
||||||
var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.Id))
|
var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.Id))
|
||||||
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
|
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
|
||||||
{
|
{
|
||||||
f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage,
|
f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage,
|
||||||
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage,
|
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt,
|
||||||
|
SerienBild = s.BackdropImage,
|
||||||
})
|
})
|
||||||
.ToDictionaryAsync(f => f.Id);
|
.ToDictionaryAsync(f => f.Id);
|
||||||
|
|
||||||
|
var dateien = await Erstdateien(db, filme, folgen);
|
||||||
|
|
||||||
var liste = new List<ContinueEntry>(zustände.Count);
|
var liste = new List<ContinueEntry>(zustände.Count);
|
||||||
foreach (var z in zustände)
|
foreach (var z in zustände)
|
||||||
{
|
{
|
||||||
if (z.MovieId is { } film && filmdaten.TryGetValue(film, out var m))
|
if (z.MovieId is { } film && filmdaten.TryGetValue(film, out var m))
|
||||||
liste.Add(new ContinueEntry(
|
liste.Add(new ContinueEntry(
|
||||||
m.Id, "movie", m.Title, null, null, null, m.Year,
|
m.Id, "movie", m.Title, null, null, null, m.Year,
|
||||||
m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z)));
|
m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z),
|
||||||
|
FileId: dateien.GetValueOrDefault(m.Id).Id,
|
||||||
|
DurationSeconds: dateien.GetValueOrDefault(m.Id).Sekunden,
|
||||||
|
PublishAt: m.PublishAt));
|
||||||
else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f))
|
else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f))
|
||||||
liste.Add(new ContinueEntry(
|
liste.Add(new ContinueEntry(
|
||||||
f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null,
|
f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null,
|
||||||
f.PosterImage, f.StillImage ?? f.PosterImage, Abbild(z)));
|
f.PosterImage, f.StillImage ?? f.PosterImage, Abbild(z),
|
||||||
|
SeriesId: f.SerienId,
|
||||||
|
FileId: dateien.GetValueOrDefault(f.Id).Id,
|
||||||
|
DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden,
|
||||||
|
PublishAt: f.PublishAt,
|
||||||
|
SeriesBackdropImage: f.SerienBild));
|
||||||
}
|
}
|
||||||
return liste;
|
return liste;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Je Titel die erste Datei — die, die abgespielt würde.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>In einer Abfrage für alle.</b> Zwanzig Zeilen auf der Startseite
|
||||||
|
/// wären sonst zwanzig Abfragen, und SQLite führt die nacheinander aus.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<Dictionary<Guid, (Guid? Id, double? Sekunden)>> Erstdateien(
|
||||||
|
FlaroContext db, List<Guid> filme, List<Guid> folgen)
|
||||||
|
{
|
||||||
|
if (filme.Count == 0 && folgen.Count == 0) return [];
|
||||||
|
|
||||||
|
var roh = await db.MediaFiles.AsNoTracking()
|
||||||
|
.Where(d => (d.MovieId != null && filme.Contains(d.MovieId!.Value))
|
||||||
|
|| (d.EpisodeId != null && folgen.Contains(d.EpisodeId!.Value)))
|
||||||
|
.Select(d => new { d.Id, d.MovieId, d.EpisodeId, d.Duration })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return roh
|
||||||
|
.GroupBy(d => d.MovieId ?? d.EpisodeId!.Value)
|
||||||
|
.ToDictionary(g => g.Key, g =>
|
||||||
|
{
|
||||||
|
var erste = g.First();
|
||||||
|
return ((Guid?)erste.Id, erste.Duration?.TotalSeconds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Gemeinsames
|
// Gemeinsames
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -385,8 +527,104 @@ public static class PlaybackEndpoints
|
|||||||
/// <see cref="UserItemState.InProgress"/> schreibt — gäbe es eine zweite,
|
/// <see cref="UserItemState.InProgress"/> schreibt — gäbe es eine zweite,
|
||||||
/// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen.
|
/// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static void Nachfuehren(UserItemState zustand) =>
|
/// <summary>Schreibt die tatsächlich gesehene Zeit ins Protokoll.</summary>
|
||||||
zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand);
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Der Zuwachs, gedeckelt auf die verstrichene Zeit.</b> Zwischen zwei
|
||||||
|
/// Meldungen liegen zehn Sekunden; wer in dieser Zeit von Minute 5 auf
|
||||||
|
/// Minute 90 springt, hat nicht 85 Minuten gesehen. Ohne Deckel erfände
|
||||||
|
/// jedes Vorspulen Sehzeit — und ein Film, den man einmal durchgespult
|
||||||
|
/// hat, stünde in der Statistik wie zweimal gesehen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Das Doppelte als Toleranz</b>, nicht genau die verstrichene Zeit:
|
||||||
|
/// eine ausgefallene Meldung, ein kurzer Aussetzer im Netz, doppelte
|
||||||
|
/// Abspielgeschwindigkeit — all das ist echtes Schauen und soll zählen.
|
||||||
|
/// Erst darüber wird gekappt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Rückwärts zählt nichts.</b> Wer zurückspringt und dieselbe Stelle
|
||||||
|
/// noch einmal sieht, bekommt sie nicht abgezogen und auch nicht doppelt
|
||||||
|
/// gutgeschrieben: der Zuwachs ist dann null, bis er die alte Stelle
|
||||||
|
/// wieder erreicht. Das ist die freundlichere Ungenauigkeit von beiden.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task Mitzaehlen(
|
||||||
|
FlaroContext db, Guid benutzer, UserItemState zustand,
|
||||||
|
TimeSpan neueStelle, TimeProvider clock, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (zustand.MovieId is null && zustand.EpisodeId is null) return;
|
||||||
|
|
||||||
|
var jetzt = clock.GetUtcNow();
|
||||||
|
var verstrichen = jetzt - zustand.UpdatedAt;
|
||||||
|
var zuwachs = (neueStelle - zustand.Position).TotalSeconds;
|
||||||
|
|
||||||
|
// **Ohne verstrichene Zeit gibt es nichts gutzuschreiben.** Das ist
|
||||||
|
// die erste Meldung einer Zeile — sie legt den Nullpunkt, mehr nicht.
|
||||||
|
// Stünde hier stattdessen „kein Deckel", zählte eine frisch angelegte
|
||||||
|
// Zeile ihre Anfangsstelle voll mit: wer einen Film öffnet und sofort
|
||||||
|
// auf Minute fünf springt, hätte fünf Minuten gesehen.
|
||||||
|
if (zuwachs <= 0 || verstrichen <= TimeSpan.Zero) return;
|
||||||
|
|
||||||
|
zuwachs = Math.Min(zuwachs, verstrichen.TotalSeconds * 2);
|
||||||
|
if (zuwachs <= 0) return;
|
||||||
|
|
||||||
|
// Ortszeit des Servers: wer um halb eins zu Ende sieht, hat das am
|
||||||
|
// Abend getan.
|
||||||
|
var tag = DateOnly.FromDateTime(jetzt.ToLocalTime().DateTime);
|
||||||
|
|
||||||
|
// **Erhöhen, und nur wenn es die Zeile nicht gibt, anlegen.**
|
||||||
|
//
|
||||||
|
// Vorher stand hier „nachsehen, dann anlegen oder erhöhen" — und das
|
||||||
|
// ist ein Wettlauf: der Spieler meldet alle zehn Sekunden, ein Sprung
|
||||||
|
// meldet sofort, und zwei Meldungen können sich überholen. Dann fanden
|
||||||
|
// beide keine Zeile, beide legten eine an, und die zweite lief in
|
||||||
|
// „UNIQUE constraint failed: Sehtage.UserId, Sehtage.Tag,
|
||||||
|
// Sehtage.MovieId". Gemeldet wurde das als Fehler 500 auf einen
|
||||||
|
// Fortschrittsbericht — für den Zuschauer unsichtbar, im Protokoll ein
|
||||||
|
// Stapelabzug.
|
||||||
|
//
|
||||||
|
// `ExecuteUpdateAsync` rechnet in der Datenbank statt im Speicher und
|
||||||
|
// kann deshalb nicht danebengreifen. Trifft es nichts, wird angelegt;
|
||||||
|
// kommt dabei jemand zuvor, fängt der zweite Versuch es auf.
|
||||||
|
if (await Erhoehen(db, benutzer, tag, zustand, zuwachs, ct) > 0) return;
|
||||||
|
|
||||||
|
db.Sehtage.Add(new Sehtag
|
||||||
|
{
|
||||||
|
UserId = benutzer,
|
||||||
|
MovieId = zustand.MovieId,
|
||||||
|
EpisodeId = zustand.EpisodeId,
|
||||||
|
Tag = tag,
|
||||||
|
Sekunden = zuwachs,
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
// Jemand war schneller. Die angefangene Zeile wieder vergessen,
|
||||||
|
// sonst versucht der Aufrufer sie beim nächsten Speichern erneut.
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
await Erhoehen(db, benutzer, tag, zustand, zuwachs, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Schreibt die Sekunden an einer vorhandenen Zeile fort.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>Gibt zurück, wie viele Zeilen getroffen wurden — 0 oder 1.</remarks>
|
||||||
|
private static Task<int> Erhoehen(
|
||||||
|
FlaroContext db, Guid benutzer, DateOnly tag, UserItemState zustand,
|
||||||
|
double zuwachs, CancellationToken ct) =>
|
||||||
|
db.Sehtage
|
||||||
|
.Where(z => z.UserId == benutzer && z.Tag == tag
|
||||||
|
&& z.MovieId == zustand.MovieId && z.EpisodeId == zustand.EpisodeId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(z => z.Sekunden, z => z.Sekunden + zuwachs), ct);
|
||||||
|
|
||||||
|
private static void Nachfuehren(UserItemState zustand, Wiedergaberegeln regeln) =>
|
||||||
|
zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand, regeln);
|
||||||
|
|
||||||
/// <summary>Welches Gerät das ist — die Kennung seines Tokens.</summary>
|
/// <summary>Welches Gerät das ist — die Kennung seines Tokens.</summary>
|
||||||
///
|
///
|
||||||
@@ -455,14 +693,23 @@ public static class PlaybackEndpoints
|
|||||||
/// <summary>Holt den Stand oder legt ihn an — <c>null</c>, wenn es den Titel nicht gibt.</summary>
|
/// <summary>Holt den Stand oder legt ihn an — <c>null</c>, wenn es den Titel nicht gibt.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b>Erst Film, dann Folge.</b> Zwei Abfragen im schlechteren Fall. Eine
|
/// <para>
|
||||||
/// gemeinsame Tabelle für beides würde eine sparen und dafür an jeder
|
/// <b>Erst Film, dann Folge, dann Serie.</b> Drei Abfragen im schlechtesten
|
||||||
/// anderen Stelle im Modell kosten.
|
/// Fall. Eine gemeinsame Tabelle für alles würde zwei sparen und dafür an
|
||||||
|
/// jeder anderen Stelle im Modell kosten.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Dass die Serie fehlte, war ein stiller Fehler.</b> Jede Aktion auf
|
||||||
|
/// einer Serienkachel — als gesehen markieren, zu den Favoriten — bekam
|
||||||
|
/// eine 404, und weil das Kontextmenü die Ablehnung verschluckte, geschah
|
||||||
|
/// sichtbar gar nichts.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static async Task<UserItemState?> Zustand(FlaroContext db, Guid benutzer, Guid titel)
|
private static async Task<UserItemState?> Zustand(FlaroContext db, Guid benutzer, Guid titel)
|
||||||
{
|
{
|
||||||
var vorhanden = await db.UserItemStates
|
var vorhanden = await db.UserItemStates
|
||||||
.FirstOrDefaultAsync(z => z.UserId == benutzer && (z.MovieId == titel || z.EpisodeId == titel));
|
.FirstOrDefaultAsync(z => z.UserId == benutzer
|
||||||
|
&& (z.MovieId == titel || z.EpisodeId == titel || z.SeriesId == titel));
|
||||||
if (vorhanden is not null) return vorhanden;
|
if (vorhanden is not null) return vorhanden;
|
||||||
|
|
||||||
if (await db.Movies.AnyAsync(m => m.Id == titel))
|
if (await db.Movies.AnyAsync(m => m.Id == titel))
|
||||||
@@ -479,9 +726,68 @@ public static class PlaybackEndpoints
|
|||||||
return neu;
|
return neu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await db.Series.AnyAsync(s => s.Id == titel))
|
||||||
|
{
|
||||||
|
var neu = new UserItemState { UserId = benutzer, SeriesId = titel };
|
||||||
|
db.UserItemStates.Add(neu);
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Setzt das Häkchen an allen Folgen einer Serie — oder nimmt es weg.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>„Serie gesehen" heisst: jede Folge gesehen.</b> Nur die Zeile der
|
||||||
|
/// Serie zu setzen liesse die Folgenliste unverändert und den Zähler
|
||||||
|
/// „noch 42 offen" daneben stehen — zwei Angaben, die sich widersprechen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Die Stelle bleibt, wo sie ist.</b> Wer eine Serie von Hand abhakt,
|
||||||
|
/// will sie aus dem Weg haben; eine halb gesehene Folge soll dadurch nicht
|
||||||
|
/// ihren Fortschritt verlieren, falls er doch zurückkommt. Nur
|
||||||
|
/// „Weiterschauen" räumt sich auf, weil ein Abgehaktes dort nichts zu
|
||||||
|
/// suchen hat.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task FolgenSetzen(
|
||||||
|
FlaroContext db, Guid benutzer, Guid serie, bool gesehen, TimeProvider clock,
|
||||||
|
Wiedergaberegeln regeln)
|
||||||
|
{
|
||||||
|
var folgen = await db.Episodes.AsNoTracking()
|
||||||
|
.Where(f => f.SeriesId == serie)
|
||||||
|
.Select(f => f.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
if (folgen.Count == 0) return;
|
||||||
|
|
||||||
|
var staende = await db.UserItemStates
|
||||||
|
.Where(z => z.UserId == benutzer && z.EpisodeId != null && folgen.Contains(z.EpisodeId.Value))
|
||||||
|
.ToListAsync();
|
||||||
|
var bekannt = staende.ToDictionary(z => z.EpisodeId!.Value);
|
||||||
|
|
||||||
|
var jetzt = clock.GetUtcNow();
|
||||||
|
foreach (var folge in folgen)
|
||||||
|
{
|
||||||
|
if (!bekannt.TryGetValue(folge, out var zustand))
|
||||||
|
{
|
||||||
|
// Ungesehen ist der Normalfall — dafür braucht es keine Zeile.
|
||||||
|
if (!gesehen) continue;
|
||||||
|
zustand = new UserItemState { UserId = benutzer, EpisodeId = folge };
|
||||||
|
db.UserItemStates.Add(zustand);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesehen && !zustand.Watched) zustand.PlayCount++;
|
||||||
|
zustand.Watched = gesehen;
|
||||||
|
zustand.Position = TimeSpan.Zero;
|
||||||
|
zustand.Dismissed = false;
|
||||||
|
if (gesehen) zustand.LastPlayedAt = jetzt;
|
||||||
|
zustand.UpdatedAt = jetzt;
|
||||||
|
Nachfuehren(zustand, regeln);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Die Laufzeit aus der längsten Datei hinter dem Titel.</summary>
|
/// <summary>Die Laufzeit aus der längsten Datei hinter dem Titel.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -489,12 +795,19 @@ public static class PlaybackEndpoints
|
|||||||
/// eine beschnittene sein mag. Gegen die kürzere zu messen hiesse, einen
|
/// 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.
|
/// Film für gesehen zu halten, der noch zwanzig Minuten vor sich hat.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static async Task<TimeSpan?> Dateilaufzeit(FlaroContext db, UserItemState zustand) =>
|
private static async Task<TimeSpan?> Dateilaufzeit(FlaroContext db, UserItemState zustand)
|
||||||
await db.MediaFiles.AsNoTracking()
|
{
|
||||||
|
// Eine Serie hat keine eigene Datei. Ohne diese Zeile verglich die
|
||||||
|
// Abfrage `EpisodeId == null` und nahm die längste herrenlose Datei.
|
||||||
|
if (zustand.MovieId is null && zustand.EpisodeId is null) return null;
|
||||||
|
|
||||||
|
return await db.MediaFiles.AsNoTracking()
|
||||||
.Where(d => zustand.MovieId != null ? d.MovieId == zustand.MovieId : d.EpisodeId == zustand.EpisodeId)
|
.Where(d => zustand.MovieId != null ? d.MovieId == zustand.MovieId : d.EpisodeId == zustand.EpisodeId)
|
||||||
.MaxAsync(d => (TimeSpan?)d.Duration);
|
.MaxAsync(d => (TimeSpan?)d.Duration);
|
||||||
|
}
|
||||||
|
|
||||||
internal static ItemStateResponse Abbild(UserItemState z) => new(
|
internal static ItemStateResponse Abbild(UserItemState z) => new(
|
||||||
z.Position.TotalSeconds, z.Runtime?.TotalSeconds,
|
z.Position.TotalSeconds, z.Runtime?.TotalSeconds,
|
||||||
z.Watched, z.PlayCount, z.Favourite, z.Rating, z.Dismissed, z.LastPlayedAt);
|
z.Watched, z.PlayCount, z.Favourite, z.Rating, z.Dismissed, z.LastPlayedAt,
|
||||||
|
z.InProgress);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Flaro.Core.Metadata;
|
||||||
|
using Flaro.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>Das eigene Konto — für jeden, nicht nur für Administratoren.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Eine eigene Gruppe, weil die Rechte andere sind.</b> Alles unter
|
||||||
|
/// <c>/api/v1/users</c> war bisher Verwaltung und damit Administratoren
|
||||||
|
/// vorbehalten. Hier geht es um das eigene Konto: wer angemeldet ist, darf
|
||||||
|
/// sein Bild tauschen, und niemand darf das eines anderen anfassen. Die
|
||||||
|
/// Kennung kommt deshalb aus dem Token und nie aus der Adresse.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Es war bisher nicht erreichbar.</b> „Meine Sprache" stand mitten in der
|
||||||
|
/// Verwaltung, und die zeigt der Client nur Administratoren — der Endpunkt
|
||||||
|
/// erlaubte es längst jedem. Wer kein Admin war, konnte seine Sprache also
|
||||||
|
/// nirgends ändern.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ProfilEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Höchstens so gross darf ein Profilbild sein.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Fünf Megabyte sind für ein Bild von 200 Pixeln reichlich. Dieselbe
|
||||||
|
/// Grenze wie bei Plakaten wäre hier zu grosszügig: ein Plakat sieht man
|
||||||
|
/// gross, einen Kreis von 28 Pixeln nicht.
|
||||||
|
/// </remarks>
|
||||||
|
private const long HoechstensBytes = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
public static void MapProfil(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var gruppe = app.MapGroup("/api/v1/users/me").RequireAuthorization();
|
||||||
|
|
||||||
|
gruppe.MapGet("", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (Kennung(wer) is not { } id) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var benutzer = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||||
|
return benutzer is null
|
||||||
|
? Results.Unauthorized()
|
||||||
|
: Results.Ok(new AuthenticatedUser(
|
||||||
|
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
|
||||||
|
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
|
||||||
|
});
|
||||||
|
|
||||||
|
gruppe.MapPost("/avatar", async (
|
||||||
|
HttpRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
|
||||||
|
IImageStore ablage, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (Kennung(wer) is not { } id) return Results.Unauthorized();
|
||||||
|
if (!anfrage.HasFormContentType)
|
||||||
|
return Einwand.Bitte("Kein Formular.");
|
||||||
|
|
||||||
|
var form = await anfrage.ReadFormAsync(ct);
|
||||||
|
var datei = form.Files.FirstOrDefault();
|
||||||
|
if (datei is null || datei.Length == 0)
|
||||||
|
return Einwand.Bitte("Keine Datei dabei.");
|
||||||
|
if (datei.Length > HoechstensBytes)
|
||||||
|
return Einwand.Bitte("Höchstens 5 MB.");
|
||||||
|
|
||||||
|
await using var strom = datei.OpenReadStream();
|
||||||
|
var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct);
|
||||||
|
if (schluessel is null)
|
||||||
|
return Results.Problem("Das Bild liess sich nicht ablegen.", statusCode: 502);
|
||||||
|
|
||||||
|
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||||
|
if (benutzer is null) return Results.Unauthorized();
|
||||||
|
|
||||||
|
benutzer.AvatarImage = schluessel;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { image = schluessel });
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
// **Entfernen heisst vergessen, nicht löschen.** Die Datei bleibt in
|
||||||
|
// der Bildablage liegen — dort liegt sie unter dem Hash ihres Inhalts
|
||||||
|
// und könnte an einem anderen Konto hängen. Was dort aufräumt, ist
|
||||||
|
// „Bildspeicher leeren" in der Verwaltung, und das kennt alle Bezüge.
|
||||||
|
gruppe.MapDelete("/avatar", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (Kennung(wer) is not { } id) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||||
|
if (benutzer is null) return Results.Unauthorized();
|
||||||
|
|
||||||
|
benutzer.AvatarImage = null;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
MapStatistik(gruppe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Was jemand geschaut hat.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Jeder nur sich selbst, auch ein Administrator.</b> Das ist keine
|
||||||
|
/// Erfindung für diese Seite, sondern die Linie, die im Server schon
|
||||||
|
/// steht: wer wann welchen Film anschaut, ist eine Auskunft über Menschen
|
||||||
|
/// und nicht über den Server. Es gibt deshalb keinen Weg, ein fremdes
|
||||||
|
/// Konto zu meinen — die Kennung kommt aus dem Token.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Wochen beginnen am Montag.</b> Nicht am Sonntag und nicht am Tag der
|
||||||
|
/// Anfrage: eine Kurve, deren Balken je nach Abfragezeitpunkt anders
|
||||||
|
/// zusammenfallen, lässt sich mit sich selbst nicht vergleichen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void MapStatistik(RouteGroupBuilder gruppe)
|
||||||
|
{
|
||||||
|
gruppe.MapGet("/statistics", async (
|
||||||
|
ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||||
|
int wochen = 12, CancellationToken ct = default) =>
|
||||||
|
{
|
||||||
|
if (Kennung(wer) is not { } id) return Results.Unauthorized();
|
||||||
|
|
||||||
|
wochen = Math.Clamp(wochen, 1, 104);
|
||||||
|
var heute = DateOnly.FromDateTime(clock.GetUtcNow().ToLocalTime().DateTime);
|
||||||
|
var ersterMontag = Montag(heute).AddDays(-7 * (wochen - 1));
|
||||||
|
|
||||||
|
// ---- Das Protokoll ----
|
||||||
|
|
||||||
|
var tage = await db.Sehtage.AsNoTracking()
|
||||||
|
.Where(z => z.UserId == id)
|
||||||
|
.Select(z => new { z.Tag, z.Sekunden, z.MovieId, z.EpisodeId })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var gesamt = tage.Sum(z => z.Sekunden);
|
||||||
|
var seit = tage.Count == 0 ? (DateTimeOffset?)null
|
||||||
|
: new DateTimeOffset(tage.Min(z => z.Tag).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
|
||||||
|
|
||||||
|
var jeWoche = tage
|
||||||
|
.Where(z => z.Tag >= ersterMontag)
|
||||||
|
.GroupBy(z => Montag(z.Tag))
|
||||||
|
.ToDictionary(g => g.Key, g => g.Sum(x => x.Sekunden));
|
||||||
|
|
||||||
|
var kurve = Enumerable.Range(0, wochen)
|
||||||
|
.Select(n => ersterMontag.AddDays(7 * n))
|
||||||
|
.Select(montag => new Wochenwert(montag, jeWoche.GetValueOrDefault(montag)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// ---- Was ohne Protokoll vollständig ist ----
|
||||||
|
|
||||||
|
var staende = await db.UserItemStates.AsNoTracking()
|
||||||
|
.Where(z => z.UserId == id && (z.MovieId != null || z.EpisodeId != null))
|
||||||
|
.Select(z => new
|
||||||
|
{
|
||||||
|
z.MovieId, z.EpisodeId, z.Watched, z.LastPlayedAt,
|
||||||
|
Laufzeit = z.Runtime,
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var gesehene = staende.Where(z => z.Watched).ToList();
|
||||||
|
|
||||||
|
// ---- Zuletzt gesehen ----
|
||||||
|
|
||||||
|
var letzte = staende
|
||||||
|
.Where(z => z.LastPlayedAt != null)
|
||||||
|
.OrderByDescending(z => z.LastPlayedAt)
|
||||||
|
.Take(12)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var filmIds = letzte.Where(z => z.MovieId != null).Select(z => z.MovieId!.Value).ToList();
|
||||||
|
var folgenIds = letzte.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
|
||||||
|
|
||||||
|
var filme = await db.Movies.AsNoTracking().Where(m => filmIds.Contains(m.Id))
|
||||||
|
.Select(m => new { m.Id, m.Title, m.PosterImage }).ToDictionaryAsync(m => m.Id, ct);
|
||||||
|
|
||||||
|
var folgen = await db.Episodes.AsNoTracking().Where(f => folgenIds.Contains(f.Id))
|
||||||
|
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
|
||||||
|
{
|
||||||
|
f.Id, Folgentitel = f.Title, f.SeasonNumber, f.EpisodeNumber,
|
||||||
|
Serientitel = s.Title, s.PosterImage, SerienId = s.Id,
|
||||||
|
})
|
||||||
|
.ToDictionaryAsync(f => f.Id, ct);
|
||||||
|
|
||||||
|
var zuletzt = new List<Zuletztgesehen>();
|
||||||
|
foreach (var z in letzte)
|
||||||
|
{
|
||||||
|
if (z.MovieId is { } film && filme.TryGetValue(film, out var m))
|
||||||
|
{
|
||||||
|
zuletzt.Add(new Zuletztgesehen(
|
||||||
|
m.Id, "movie", m.Title, null, m.PosterImage, z.LastPlayedAt!.Value, z.Watched));
|
||||||
|
}
|
||||||
|
else if (z.EpisodeId is { } folge && folgen.TryGetValue(folge, out var f))
|
||||||
|
{
|
||||||
|
zuletzt.Add(new Zuletztgesehen(
|
||||||
|
f.SerienId, "episode", f.Serientitel,
|
||||||
|
$"S{f.SeasonNumber} · F{f.EpisodeNumber}" +
|
||||||
|
(f.Folgentitel is { Length: > 0 } t ? $" — {t}" : ""),
|
||||||
|
f.PosterImage, z.LastPlayedAt!.Value, z.Watched));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Genres, aus dem Protokoll ----
|
||||||
|
|
||||||
|
var genres = await GenresAus(db, tage.Select(t => (t.MovieId, t.EpisodeId, t.Sekunden)), ct);
|
||||||
|
|
||||||
|
return Results.Ok(new StatistikResponse(
|
||||||
|
gesamt,
|
||||||
|
gesehene.Count,
|
||||||
|
gesehene.Sum(z => z.Laufzeit?.TotalSeconds ?? 0),
|
||||||
|
seit,
|
||||||
|
kurve,
|
||||||
|
zuletzt,
|
||||||
|
genres));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Montag der Woche, in der dieser Tag liegt.</summary>
|
||||||
|
private static DateOnly Montag(DateOnly tag) =>
|
||||||
|
tag.AddDays(-((int)tag.DayOfWeek + 6) % 7);
|
||||||
|
|
||||||
|
/// <summary>Welche Genres die gesehene Zeit ausmachen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Eine Folge hat keine Genres — ihre Serie hat sie. Deshalb wird jede
|
||||||
|
/// Folge erst zu ihrer Serie aufgelöst; sonst stünden Serien gar nicht in
|
||||||
|
/// der Auswertung, obwohl sie den grösseren Teil der Zeit ausmachen.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<List<Genrewert>> GenresAus(
|
||||||
|
FlaroContext db,
|
||||||
|
IEnumerable<(Guid? Film, Guid? Folge, double Sekunden)> zeilen,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var liste = zeilen.ToList();
|
||||||
|
if (liste.Count == 0) return [];
|
||||||
|
|
||||||
|
var folgenIds = liste.Where(z => z.Folge != null).Select(z => z.Folge!.Value).Distinct().ToList();
|
||||||
|
var serieZuFolge = await db.Episodes.AsNoTracking()
|
||||||
|
.Where(f => folgenIds.Contains(f.Id))
|
||||||
|
.ToDictionaryAsync(f => f.Id, f => f.SeriesId, ct);
|
||||||
|
|
||||||
|
// Sekunden je Titel zusammenziehen, bevor die Genres dazukommen —
|
||||||
|
// sonst wird derselbe Tag so oft gezählt, wie der Titel Genres hat.
|
||||||
|
var jeFilm = new Dictionary<Guid, double>();
|
||||||
|
var jeSerie = new Dictionary<Guid, double>();
|
||||||
|
foreach (var (film, folge, sekunden) in liste)
|
||||||
|
{
|
||||||
|
if (film is { } f) jeFilm[f] = jeFilm.GetValueOrDefault(f) + sekunden;
|
||||||
|
else if (folge is { } e && serieZuFolge.TryGetValue(e, out var serie))
|
||||||
|
jeSerie[serie] = jeSerie.GetValueOrDefault(serie) + sekunden;
|
||||||
|
}
|
||||||
|
|
||||||
|
var filmIds = jeFilm.Keys.ToList();
|
||||||
|
var serienIds = jeSerie.Keys.ToList();
|
||||||
|
|
||||||
|
var verweise = await db.GenreLinks.AsNoTracking()
|
||||||
|
.Where(l => (l.MovieId != null && filmIds.Contains(l.MovieId!.Value))
|
||||||
|
|| (l.SeriesId != null && serienIds.Contains(l.SeriesId!.Value)))
|
||||||
|
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id,
|
||||||
|
(l, g) => new { l.MovieId, l.SeriesId, g.Name })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var summen = new Dictionary<string, double>();
|
||||||
|
foreach (var v in verweise)
|
||||||
|
{
|
||||||
|
var sekunden = v.MovieId is { } f ? jeFilm.GetValueOrDefault(f)
|
||||||
|
: v.SeriesId is { } s ? jeSerie.GetValueOrDefault(s)
|
||||||
|
: 0;
|
||||||
|
if (sekunden <= 0) continue;
|
||||||
|
summen[v.Name] = summen.GetValueOrDefault(v.Name) + sekunden;
|
||||||
|
}
|
||||||
|
|
||||||
|
return summen.OrderByDescending(p => p.Value).Take(6)
|
||||||
|
.Select(p => new Genrewert(p.Key, p.Value)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wer anfragt — aus dem Token, nie aus der Adresse.</summary>
|
||||||
|
private static Guid? Kennung(ClaimsPrincipal wer) =>
|
||||||
|
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
using Flaro.Api.Auth;
|
||||||
using Flaro.Core;
|
using Flaro.Core;
|
||||||
|
using Flaro.Core.Delivery;
|
||||||
|
using Flaro.Core.Playback;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
using Flaro.Metadata;
|
using Flaro.Metadata;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -30,6 +33,57 @@ public static class SettingsEndpoints
|
|||||||
UsingProjectKey: aufgelöst.UsingProjectKey));
|
UsingProjectKey: aufgelöst.UsingProjectKey));
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **In welcher Sprache der Bestand vorliegt.**
|
||||||
|
//
|
||||||
|
// Der eigentliche Fehler war nicht, dass Titel englisch blieben —
|
||||||
|
// sondern dass niemand feststellen konnte, ob sie es sind. „Alles ist
|
||||||
|
// noch auf Englisch, obwohl Deutsch eingestellt ist" liess sich nicht
|
||||||
|
// beantworten, nur bestreiten. Hier steht die Zahl.
|
||||||
|
app.MapGet("/api/v1/settings/metadata/languages", async (FlaroContext db) =>
|
||||||
|
{
|
||||||
|
var eingestellt = await db.Settings.AsNoTracking()
|
||||||
|
.Where(x => x.Key == DatabaseTmdbSettings.LanguageName)
|
||||||
|
.Select(x => x.Value).FirstOrDefaultAsync();
|
||||||
|
var serversprache = TmdbSettings.Resolve(null, eingestellt).Language;
|
||||||
|
|
||||||
|
// Nur zugeordnete Titel zählen: was keine Kennung hat, hat auch
|
||||||
|
// keine Sprache — das ist ein Zuordnungsproblem, kein Sprachproblem.
|
||||||
|
var filme = await db.Movies.AsNoTracking().Where(m => m.TmdbId != null)
|
||||||
|
.GroupBy(m => m.MetadataLanguage)
|
||||||
|
.Select(g => new { Sprache = g.Key, Anzahl = g.Count() }).ToListAsync();
|
||||||
|
var serien = await db.Series.AsNoTracking().Where(x => x.TmdbId != null)
|
||||||
|
.GroupBy(x => x.MetadataLanguage)
|
||||||
|
.Select(g => new { Sprache = g.Key, Anzahl = g.Count() }).ToListAsync();
|
||||||
|
|
||||||
|
var zusammen = filme.Concat(serien)
|
||||||
|
.GroupBy(x => x.Sprache)
|
||||||
|
.Select(g => new Sprachanteil(g.Key, g.Sum(x => x.Anzahl)))
|
||||||
|
.OrderByDescending(x => x.Anzahl)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Results.Ok(new Sprachverteilung(
|
||||||
|
serversprache,
|
||||||
|
zusammen,
|
||||||
|
Abweichend: zusammen.Where(x => x.Sprache != serversprache).Sum(x => x.Anzahl)));
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// Frischt genau die auf, die nicht zur Serversprache passen.
|
||||||
|
app.MapPost("/api/v1/settings/metadata/languages/refresh", async (
|
||||||
|
FlaroContext db, ScanCoordinator koordinator) =>
|
||||||
|
{
|
||||||
|
var eingestellt = await db.Settings.AsNoTracking()
|
||||||
|
.Where(x => x.Key == DatabaseTmdbSettings.LanguageName)
|
||||||
|
.Select(x => x.Value).FirstOrDefaultAsync();
|
||||||
|
var serversprache = TmdbSettings.Resolve(null, eingestellt).Language;
|
||||||
|
|
||||||
|
var mediatheken = await db.Libraries.AsNoTracking().Select(l => l.Id).ToListAsync();
|
||||||
|
var gestartet = mediatheken.Count(id => koordinator.TryStart(id, erneut: true, serversprache));
|
||||||
|
|
||||||
|
return gestartet > 0
|
||||||
|
? Results.Accepted(value: new { started = gestartet, language = serversprache })
|
||||||
|
: Einwand.Belegt("In jeder Mediathek läuft bereits etwas. Warte, bis es durch ist.");
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
app.MapPut("/api/v1/settings/metadata", async (
|
app.MapPut("/api/v1/settings/metadata", async (
|
||||||
MetadataSettingsRequest anfrage, FlaroContext db) =>
|
MetadataSettingsRequest anfrage, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
@@ -40,16 +94,99 @@ public static class SettingsEndpoints
|
|||||||
// Form wie „de-DE". Alles andere nähme TMDB stillschweigend als
|
// Form wie „de-DE". Alles andere nähme TMDB stillschweigend als
|
||||||
// Englisch an, und niemand wüsste, warum.
|
// Englisch an, und niemand wüsste, warum.
|
||||||
if (!System.Text.RegularExpressions.Regex.IsMatch(sprache, "^[a-z]{2}(-[A-Z]{2})?$"))
|
if (!System.Text.RegularExpressions.Regex.IsMatch(sprache, "^[a-z]{2}(-[A-Z]{2})?$"))
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
return Einwand.Bitte($"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“.");
|
||||||
{
|
|
||||||
["language"] = [$"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“."],
|
|
||||||
});
|
|
||||||
await Setzen(db, DatabaseTmdbSettings.LanguageName, sprache);
|
await Setzen(db, DatabaseTmdbSettings.LanguageName, sprache);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Wiedergabe
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.MapGet("/api/v1/settings/playback", async (Wiedergabeeinstellungen einstellungen) =>
|
||||||
|
{
|
||||||
|
var regeln = await einstellungen.RegelnAsync();
|
||||||
|
var stufe = await einstellungen.StufeAsync();
|
||||||
|
return Results.Ok(new PlaybackSettingsResponse(
|
||||||
|
regeln.FortsetzenAb.TotalSeconds, regeln.GesehenAnteil, stufe.AlsWort()));
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
app.MapPut("/api/v1/settings/playback", async (
|
||||||
|
PlaybackSettingsRequest anfrage, FlaroContext db, TimeProvider clock) =>
|
||||||
|
{
|
||||||
|
var vorher = Wiedergabeeinstellungen.Lesen(
|
||||||
|
(await db.Settings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Key == Wiedergabeeinstellungen.FortsetzenAbName))?.Value,
|
||||||
|
(await db.Settings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Key == Wiedergabeeinstellungen.GesehenAnteilName))?.Value);
|
||||||
|
|
||||||
|
var nachher = Wiedergabeeinstellungen.Lesen(
|
||||||
|
anfrage.ResumeAfterSeconds?.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
?? vorher.FortsetzenAb.TotalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
anfrage.WatchedFraction?.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
?? vorher.GesehenAnteil.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
await Setzen(db, Wiedergabeeinstellungen.FortsetzenAbName,
|
||||||
|
nachher.FortsetzenAb.TotalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
await Setzen(db, Wiedergabeeinstellungen.GesehenAnteilName,
|
||||||
|
nachher.GesehenAnteil.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
if (anfrage.Encoder is not null)
|
||||||
|
await Setzen(db, Wiedergabeeinstellungen.TranscoderName, Kodierstufen.Lesen(anfrage.Encoder).AlsWort());
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// **Was schon dasteht, muss neu bewertet werden.**
|
||||||
|
//
|
||||||
|
// `UserItemState.InProgress` ist eine gespeicherte Spalte — die
|
||||||
|
// Bruchrechnung lässt sich nicht nach SQL übersetzen. Eine neue
|
||||||
|
// Schwelle wirkte deshalb nur auf künftige Meldungen: wer gestern
|
||||||
|
// eine Sekunde hineingeschaut hat, stünde bis in alle Ewigkeit in
|
||||||
|
// „Weiterschauen". Also einmal durch, in Stapeln.
|
||||||
|
var geaendert = nachher == vorher ? 0 : await NeuBewerten(db, nachher, clock);
|
||||||
|
return Results.Ok(new PlaybackSettingsApplied(
|
||||||
|
nachher.FortsetzenAb.TotalSeconds, nachher.GesehenAnteil,
|
||||||
|
Kodierstufen.Lesen(anfrage.Encoder).AlsWort(), geaendert));
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rechnet „steht in Weiterschauen" für alle Zeilen neu.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// In Stapeln zu fünfhundert: ein Haushalt mit drei Leuten und
|
||||||
|
/// fünftausend Titeln hat höchstens ein paar tausend Zeilen, aber alle
|
||||||
|
/// auf einmal zu laden ist eine Zahl, die man nicht kennt.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<int> NeuBewerten(
|
||||||
|
FlaroContext db, Wiedergaberegeln regeln, TimeProvider clock)
|
||||||
|
{
|
||||||
|
const int Stapel = 500;
|
||||||
|
var geaendert = 0;
|
||||||
|
var jetzt = clock.GetUtcNow();
|
||||||
|
|
||||||
|
for (var sprung = 0; ; sprung += Stapel)
|
||||||
|
{
|
||||||
|
var zeilen = await db.UserItemStates
|
||||||
|
.OrderBy(z => z.Id).Skip(sprung).Take(Stapel).ToListAsync();
|
||||||
|
if (zeilen.Count == 0) break;
|
||||||
|
|
||||||
|
foreach (var z in zeilen)
|
||||||
|
{
|
||||||
|
var soll = ProgressRule.BelongsInContinueWatching(z, regeln);
|
||||||
|
if (soll == z.InProgress) continue;
|
||||||
|
z.InProgress = soll;
|
||||||
|
z.UpdatedAt = jetzt;
|
||||||
|
geaendert++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
if (zeilen.Count < Stapel) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return geaendert;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task Setzen(FlaroContext db, string schlüssel, string wert)
|
private static async Task Setzen(FlaroContext db, string schlüssel, string wert)
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using Flaro.Core.Delivery;
|
||||||
|
using Flaro.Core.Library;
|
||||||
|
using Flaro.Data;
|
||||||
|
using Flaro.Media;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>Wiedergabe, die sofort anfängt — statt auf eine fertige Datei zu warten.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum es diesen zweiten Weg gibt.</b> <see cref="DeliveryEndpoints"/>
|
||||||
|
/// baut eine fertige MP4 und antwortet solange mit 202. Für eine App, die den
|
||||||
|
/// Film mitnehmen will, ist das richtig. Im Browser heisst es: Minuten vor dem
|
||||||
|
/// ersten Bild — und bei einem Bildcodec, den der Browser nicht kennt, ein
|
||||||
|
/// Umpacken, das nichts nützt, weil es das Bild gar nicht anfasst.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Der Browser sagt, was er kann.</b> Vorher bekam er stillschweigend
|
||||||
|
/// Apples Geräteprofil, das HEVC, AC3 und EAC3 erlaubt — und stand dann vor
|
||||||
|
/// einer Datei, von der er nichts öffnen konnte.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class StreamEndpoints
|
||||||
|
{
|
||||||
|
public static void MapStream(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
// **Eine Sitzung anlegen.** Der Rumpf trägt, was das Gerät kann und
|
||||||
|
// was der Benutzer gewählt hat.
|
||||||
|
app.MapPost("/api/v1/files/{id:guid}/stream", async (
|
||||||
|
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
|
||||||
|
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr) =>
|
||||||
|
{
|
||||||
|
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
|
||||||
|
// **Geplant heisst sichtbar, nicht abspielbar.** Ohne diese
|
||||||
|
// Prüfung liesse sich ein Titel, der mit „Verfügbar ab …" dasteht,
|
||||||
|
// trotzdem abspielen — und die Angabe wäre eine Zierde.
|
||||||
|
var gewuenschte = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
|
||||||
|
if (gewuenschte is null) return Results.NotFound();
|
||||||
|
if (await DeliveryEndpoints.FreiAb(db, gewuenschte, uhr.GetUtcNow()) is { } nochNicht)
|
||||||
|
return DeliveryEndpoints.NochNicht(nochNicht);
|
||||||
|
|
||||||
|
var profil = Profil(anfrage);
|
||||||
|
var hoehe = anfrage.Height is { } gewuenscht
|
||||||
|
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// **Was der Browser ohnehin öffnet, geht unverändert hinaus.**
|
||||||
|
//
|
||||||
|
// Eine MP4 mit H.264 und AAC durch ffmpeg zu schicken wäre ein
|
||||||
|
// Prozess und ein Kratzplatzordner für nichts — und der
|
||||||
|
// Browser verlöre dabei das, was er von sich aus am besten
|
||||||
|
// kann: springen über Bereichsanfragen, ohne dass irgendwo
|
||||||
|
// etwas neu gerechnet wird.
|
||||||
|
//
|
||||||
|
// **Nur ohne Wünsche.** Wer eine Tonspur oder eine Stufe
|
||||||
|
// wählt, bekommt eine Sitzung: beides lässt sich an einer
|
||||||
|
// durchgereichten Datei nicht einstellen.
|
||||||
|
//
|
||||||
|
// **Und nicht, wenn der Spieler schon gescheitert ist.** Dann
|
||||||
|
// hat er die Datei gesehen und nicht geöffnet; sie ihm ein
|
||||||
|
// zweites Mal unverändert hinzulegen wäre derselbe Fehler mit
|
||||||
|
// mehr Geduld.
|
||||||
|
if (hoehe is null && anfrage.AudioStream is null && !anfrage.Transcode
|
||||||
|
&& anfrage.AudioOffsetMs == 0
|
||||||
|
&& await Durchreichbar(db, id, profil) is { } datei)
|
||||||
|
{
|
||||||
|
return Results.Ok(await Direktauskunft(db, datei, anfrage.StartSeconds ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
var sitzung = await koordinator.StartenAsync(
|
||||||
|
id, benutzer, profil, hoehe, anfrage.AudioStream, anfrage.StartSeconds ?? 0,
|
||||||
|
erzwingen: anfrage.Transcode,
|
||||||
|
tonversatz: Math.Clamp(anfrage.AudioOffsetMs, -5000, 5000) / 1000.0);
|
||||||
|
|
||||||
|
return Results.Ok(await Auskunft(db, sitzung));
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException fehler)
|
||||||
|
{
|
||||||
|
// Ein deutscher Satz, kein Fehlercode — hier wird gelesen,
|
||||||
|
// nicht gesucht. Die Einzelheiten stehen im Protokoll.
|
||||||
|
return Results.Problem(fehler.Message, statusCode: StatusCodes.Status409Conflict);
|
||||||
|
}
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// **Die Playlist.** Sie wächst, während ffmpeg schreibt.
|
||||||
|
app.MapGet("/api/v1/stream/{sitzung:guid}/index.m3u8", async (
|
||||||
|
Guid sitzung, HttpResponse antwort, Streamkoordinator koordinator, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
// **Diese Datei darf nie zwischengespeichert werden**, und das ist
|
||||||
|
// kein Vorsichtsmass, sondern aus einem Fehler gelernt: der Film
|
||||||
|
// blieb nach zwanzig Sekunden stehen, obwohl auf der Platte längst
|
||||||
|
// der ganze Rest lag. `Results.File` setzt `Last-Modified`, der
|
||||||
|
// Browser legte die Liste mit zwei Einträgen ab, und hls.js bekam
|
||||||
|
// bei jedem Nachfragen dieselben zwei zurück. Eine wachsende Liste
|
||||||
|
// ist das Gegenteil von zwischenspeicherbar.
|
||||||
|
antwort.Headers.CacheControl = "no-store, no-cache, must-revalidate";
|
||||||
|
antwort.Headers.Pragma = "no-cache";
|
||||||
|
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
|
||||||
|
koordinator.Lebenszeichen(sitzung);
|
||||||
|
|
||||||
|
// **Hier wird gewartet, nicht abgewiesen.**
|
||||||
|
//
|
||||||
|
// Zuerst stand hier eine 204, solange noch kein Segment da war —
|
||||||
|
// der Spieler sollte gleich noch einmal fragen. Gemessen: hls.js
|
||||||
|
// hält jede Antwort ohne Playlist für einen Fehler und gibt sofort
|
||||||
|
// auf. Eine Antwort, die ein paar Sekunden braucht, ist für ihn
|
||||||
|
// dagegen das Normale.
|
||||||
|
//
|
||||||
|
// Gemessen am Prüfbestand: beim Umpacken liegt das erste Segment
|
||||||
|
// nach deutlich unter einer Sekunde vor. Die Spanne hier ist für
|
||||||
|
// den anderen Fall gedacht — ein 4K-Film, der neu gerechnet wird.
|
||||||
|
for (var versuch = 0; versuch < 60; versuch++)
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) return Results.NoContent();
|
||||||
|
|
||||||
|
if (koordinator.Sitzung(sitzung) is not { } jetzt) return Results.NotFound();
|
||||||
|
|
||||||
|
if (jetzt.Fehler is { Length: > 0 })
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
"Die Wiedergabe liess sich nicht aufbereiten. "
|
||||||
|
+ "Die Einzelheiten stehen im Protokoll von heute.",
|
||||||
|
statusCode: StatusCodes.Status500InternalServerError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Als Inhalt, nicht als Datei.** `Results.File` hängt
|
||||||
|
// `Last-Modified` an und lädt damit zum Zwischenspeichern ein.
|
||||||
|
if (Streamkoordinator.Playlist(jetzt.Ordner) is { } liste)
|
||||||
|
return Results.Text(liste, "application/vnd.apple.mpegurl", Encoding.UTF8);
|
||||||
|
|
||||||
|
// **Hört ffmpeg auf, ohne ein Segment geschrieben zu haben**,
|
||||||
|
// kommt auch keines mehr. Weiterzuwarten hiesse, eine halbe
|
||||||
|
// Minute auf etwas zu warten, das es nicht geben wird.
|
||||||
|
if (jetzt.Beendet)
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
"Für diese Datei kam keine abspielbare Fassung zustande. "
|
||||||
|
+ "Die Einzelheiten stehen im Protokoll von heute.",
|
||||||
|
statusCode: StatusCodes.Status500InternalServerError);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(250, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Problem(
|
||||||
|
"Die Aufbereitung dauert ungewöhnlich lange. Versuche eine kleinere Auflösung.",
|
||||||
|
statusCode: StatusCodes.Status504GatewayTimeout);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// **Die Segmente.** Jedes Abholen ist zugleich das Lebenszeichen.
|
||||||
|
app.MapGet("/api/v1/stream/{sitzung:guid}/{datei}", (
|
||||||
|
Guid sitzung, string datei, HttpResponse antwort, Streamkoordinator koordinator) =>
|
||||||
|
{
|
||||||
|
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
|
||||||
|
|
||||||
|
// **Nur der blosse Name.** Ohne diese Prüfung käme man mit
|
||||||
|
// `../../config/flaro.db` an die Datenbank — ein Pfad aus der
|
||||||
|
// Anfrage darf nie ungeprüft an das Dateisystem.
|
||||||
|
if (datei != Path.GetFileName(datei) || datei.Contains("..", StringComparison.Ordinal))
|
||||||
|
return Results.BadRequest();
|
||||||
|
|
||||||
|
if (!datei.EndsWith(".m4s", StringComparison.Ordinal)
|
||||||
|
&& !datei.EndsWith(".mp4", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
koordinator.Lebenszeichen(sitzung);
|
||||||
|
|
||||||
|
var pfad = Path.Combine(laufend.Ordner, datei);
|
||||||
|
if (!File.Exists(pfad)) return Results.NotFound();
|
||||||
|
|
||||||
|
// **Segmente ändern sich nie.** Anders als die Liste: einmal
|
||||||
|
// geschrieben, bleibt eines, wie es ist — es darf also beliebig
|
||||||
|
// lange liegenbleiben. Das spart beim Zurückspringen den ganzen
|
||||||
|
// Weg über die Leitung.
|
||||||
|
antwort.Headers.CacheControl = "private, max-age=86400, immutable";
|
||||||
|
return Results.File(pfad, "video/mp4", enableRangeProcessing: true);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// **Schliessen heisst aufräumen.** Ohne das liefe ffmpeg weiter, bis
|
||||||
|
// die Geduld der Sitzung abgelaufen ist — und schriebe solange den
|
||||||
|
// ganzen Film auf den Kratzplatz.
|
||||||
|
app.MapDelete("/api/v1/stream/{sitzung:guid}", (Guid sitzung, Streamkoordinator koordinator) =>
|
||||||
|
{
|
||||||
|
koordinator.Beenden(sitzung);
|
||||||
|
return Results.NoContent();
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// **Dasselbe noch einmal als POST, für `sendBeacon`.**
|
||||||
|
//
|
||||||
|
// Beim Schliessen eines Reiters kommt eine gewöhnliche Anfrage oft
|
||||||
|
// nicht mehr durch — der Browser bricht sie mit der Seite ab.
|
||||||
|
// `sendBeacon` überlebt das, kann aber nur POST. Ohne diesen Weg
|
||||||
|
// bliebe nach jedem geschlossenen Reiter ein ffmpeg stehen, bis die
|
||||||
|
// Geduld der Sitzung abgelaufen ist.
|
||||||
|
app.MapPost("/api/v1/stream/{sitzung:guid}/schliessen", (
|
||||||
|
Guid sitzung, Streamkoordinator koordinator) =>
|
||||||
|
{
|
||||||
|
koordinator.Beenden(sitzung);
|
||||||
|
return Results.NoContent();
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// **Untertitel als eigene Datei, nicht im Strom.**
|
||||||
|
//
|
||||||
|
// So lassen sie sich während der Wiedergabe umschalten, ohne dass
|
||||||
|
// irgendetwas neu gerechnet wird. In einer fMP4-Segmentierung hätten
|
||||||
|
// sie ohnehin keinen Platz.
|
||||||
|
app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async (
|
||||||
|
Guid id, int spur, FlaroContext db, UntertitelLeser leser, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||||
|
if (datei is null || !File.Exists(datei.Path)) return Results.NotFound();
|
||||||
|
|
||||||
|
var vtt = await leser.AlsWebVttAsync(datei.Path, spur, ct);
|
||||||
|
return vtt is null
|
||||||
|
? Results.NotFound()
|
||||||
|
: Results.Text(vtt, "text/vtt", Encoding.UTF8);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Datei, wenn der Browser sie unverändert öffnen kann — sonst <c>null</c>.</summary>
|
||||||
|
private static async Task<Guid?> Durchreichbar(FlaroContext db, Guid id, DeviceProfile profil)
|
||||||
|
{
|
||||||
|
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
|
||||||
|
if (datei is null || !File.Exists(datei.Path)) return null;
|
||||||
|
|
||||||
|
var spuren = await db.MediaStreams.AsNoTracking()
|
||||||
|
.Where(s => s.MediaFileId == id).OrderBy(s => s.Index).ToListAsync();
|
||||||
|
|
||||||
|
var gelesen = spuren.Select(s => new ProbedStream(
|
||||||
|
s.Index, s.Kind, s.Codec, s.Language, s.Title,
|
||||||
|
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)).ToList();
|
||||||
|
|
||||||
|
return Stromentscheid.Fuer(datei.Container, gelesen, profil).Art == Stromart.Direkt
|
||||||
|
? datei.Id
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<StreamResponse> Direktauskunft(FlaroContext db, Guid datei, double ab)
|
||||||
|
{
|
||||||
|
var spuren = await db.MediaStreams.AsNoTracking()
|
||||||
|
.Where(s => s.MediaFileId == datei)
|
||||||
|
.OrderBy(s => s.Index)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return new StreamResponse(
|
||||||
|
// **Keine Sitzung, also keine Kennung.** Der Spieler merkt daran,
|
||||||
|
// dass es nichts zu beenden und nichts aufzuräumen gibt.
|
||||||
|
SessionId: Guid.Empty,
|
||||||
|
Url: $"/api/v1/files/{datei}/download",
|
||||||
|
Method: "direct",
|
||||||
|
|
||||||
|
// **Null, nicht `ab`.** Bei einer durchgereichten Datei springt
|
||||||
|
// der Spieler selbst; es gibt keinen Vorlauf, den er aufrechnen
|
||||||
|
// müsste. Ihn hier zu melden hiesse, die Stelle doppelt zu zählen.
|
||||||
|
StartSeconds: 0,
|
||||||
|
Height: null,
|
||||||
|
AudioStream: spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Audio)?.Index,
|
||||||
|
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio)
|
||||||
|
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
||||||
|
.ToList(),
|
||||||
|
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
|
||||||
|
.Where(s => s.Codec is null || !IstBild(s.Codec))
|
||||||
|
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
||||||
|
.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Das Geräteprofil aus der Anfrage — oder, wenn keines kam, Apples.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Die Rückfallebene ist nicht harmlos.</b> Sie erlaubt HEVC und EAC3;
|
||||||
|
/// ein Browser, der sein Profil nicht mitschickt, bekommt damit womöglich
|
||||||
|
/// etwas, das er nicht abspielen kann. Alte Clients sollen aber nicht
|
||||||
|
/// plötzlich gar nichts mehr bekommen — die Apps schicken ohnehin bald
|
||||||
|
/// ihr eigenes.
|
||||||
|
/// </remarks>
|
||||||
|
private static DeviceProfile Profil(StreamRequest anfrage)
|
||||||
|
{
|
||||||
|
if (anfrage.Containers is not { Count: > 0 }
|
||||||
|
|| anfrage.VideoCodecs is not { Count: > 0 }
|
||||||
|
|| anfrage.AudioCodecs is not { Count: > 0 })
|
||||||
|
{
|
||||||
|
return DeviceProfile.AppleDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DeviceProfile(
|
||||||
|
new HashSet<string>(anfrage.Containers, StringComparer.OrdinalIgnoreCase),
|
||||||
|
new HashSet<string>(anfrage.VideoCodecs, StringComparer.OrdinalIgnoreCase),
|
||||||
|
new HashSet<string>(anfrage.AudioCodecs, StringComparer.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<StreamResponse> Auskunft(FlaroContext db, Stromsitzung sitzung)
|
||||||
|
{
|
||||||
|
var spuren = await db.MediaStreams.AsNoTracking()
|
||||||
|
.Where(s => s.MediaFileId == sitzung.DateiId)
|
||||||
|
.OrderBy(s => s.Index)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return new StreamResponse(
|
||||||
|
SessionId: sitzung.Kennung,
|
||||||
|
Url: $"/api/v1/stream/{sitzung.Kennung}/index.m3u8",
|
||||||
|
Method: sitzung.Art switch
|
||||||
|
{
|
||||||
|
Stromart.Direkt => "direct",
|
||||||
|
Stromart.Umpacken => "remux",
|
||||||
|
_ => "transcode",
|
||||||
|
},
|
||||||
|
StartSeconds: sitzung.Ab,
|
||||||
|
Height: sitzung.Hoehe,
|
||||||
|
AudioStream: sitzung.Tonspur,
|
||||||
|
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio)
|
||||||
|
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
||||||
|
.ToList(),
|
||||||
|
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
|
||||||
|
.Where(s => s.Codec is null || !IstBild(s.Codec))
|
||||||
|
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
|
||||||
|
.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bildbasierte Untertitel lassen sich nicht nach WebVTT wandeln.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Sie anzubieten wäre eine Zusage, die nicht eingelöst werden kann.</b>
|
||||||
|
/// PGS ist auf jedem Blu-ray-Rip die Regel — ein Eintrag im Menü, der zu
|
||||||
|
/// nichts führt, ist schlechter als kein Eintrag.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool IstBild(string codec) =>
|
||||||
|
codec is "hdmv_pgs_subtitle" or "pgssub" or "dvd_subtitle" or "dvdsub" or "xsub";
|
||||||
|
|
||||||
|
/// <summary>Wie eine Spur im Menü heisst.</summary>
|
||||||
|
private static string Benennung(MediaStream spur)
|
||||||
|
{
|
||||||
|
if (spur.Title is { Length: > 0 } eigen) return eigen;
|
||||||
|
|
||||||
|
var sprache = Sprache(spur.Language);
|
||||||
|
var teile = new List<string> { sprache };
|
||||||
|
if (spur.Channels is { } k and > 2) teile.Add($"{k} Kanäle");
|
||||||
|
if (spur.IsForced) teile.Add("erzwungen");
|
||||||
|
return string.Join(" · ", teile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dreibuchstabige Kennungen in Worte — für die üblichen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Keine vollständige Tabelle.</b> „deu" steht auf fast jeder Datei
|
||||||
|
/// hier; die übrigen bleiben, wie sie sind, statt eine Liste zu pflegen,
|
||||||
|
/// die nie fertig wird.
|
||||||
|
/// </remarks>
|
||||||
|
private static string Sprache(string? kennung) => kennung?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
null or "" or "und" => "Unbenannt",
|
||||||
|
"deu" or "ger" or "de" => "Deutsch",
|
||||||
|
"eng" or "en" => "Englisch",
|
||||||
|
"fra" or "fre" or "fr" => "Französisch",
|
||||||
|
"ita" or "it" => "Italienisch",
|
||||||
|
"spa" or "es" => "Spanisch",
|
||||||
|
"jpn" or "ja" => "Japanisch",
|
||||||
|
_ => kennung!.ToUpperInvariant(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using Flaro.Core.Users;
|
using Flaro.Core.Users;
|
||||||
using Flaro.Core;
|
using Flaro.Core;
|
||||||
using Flaro.Metadata;
|
using Flaro.Metadata;
|
||||||
@@ -27,7 +28,11 @@ public static class SystemEndpoints
|
|||||||
{
|
{
|
||||||
app.MapGet("/api/v1/system/info", async (FlaroContext db) =>
|
app.MapGet("/api/v1/system/info", async (FlaroContext db) =>
|
||||||
Results.Ok(new SystemInfo(
|
Results.Ok(new SystemInfo(
|
||||||
Version: typeof(SystemEndpoints).Assembly.GetName().Version?.ToString(3) ?? "0.0.0",
|
// **Beides aus einer Hand.** Die Assembly-Version war hier
|
||||||
|
// immer „1.0.0" und sagte deshalb nichts darüber, welches
|
||||||
|
// Abbild läuft. Der Build daneben tut genau das.
|
||||||
|
Version: Buildkennung.Version,
|
||||||
|
Build: Buildkennung.Build,
|
||||||
// **Wie dieser Server heisst.** Ohne ihn zeigen die Clients
|
// **Wie dieser Server heisst.** Ohne ihn zeigen die Clients
|
||||||
// den Hostnamen — „localhost", „192.168.1.50" —, und wer zwei
|
// den Hostnamen — „localhost", „192.168.1.50" —, und wer zwei
|
||||||
// Server hat, unterscheidet sie an einer Zahl.
|
// Server hat, unterscheidet sie an einer Zahl.
|
||||||
@@ -43,7 +48,9 @@ public static class SystemEndpoints
|
|||||||
Attributions:
|
Attributions:
|
||||||
[
|
[
|
||||||
new AttributionResponse(Attribution.Tmdb, Attribution.TmdbLogoUrl, Attribution.TmdbUrl),
|
new AttributionResponse(Attribution.Tmdb, Attribution.TmdbLogoUrl, Attribution.TmdbUrl),
|
||||||
])))
|
],
|
||||||
|
HeroHeight: Aufmacherhoehe.Lesen((await db.Settings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(e => e.Key == Aufmacherhoehe.Schlüssel))?.Value))))
|
||||||
.AllowAnonymous();
|
.AllowAnonymous();
|
||||||
|
|
||||||
// **Wer schaut gerade was.**
|
// **Wer schaut gerade was.**
|
||||||
@@ -71,14 +78,111 @@ public static class SystemEndpoints
|
|||||||
Uptime: (DateTimeOffset.UtcNow - Gestartet).TotalSeconds)))
|
Uptime: (DateTimeOffset.UtcNow - Gestartet).TotalSeconds)))
|
||||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Ordner zum Durchklicken.** Der Pfad einer Mediathek ist der im
|
||||||
|
// Container, nicht der auf dem NAS — eine Unterscheidung, die nur
|
||||||
|
// kennt, wer die Compose-Datei selbst geschrieben hat. Ein Tippfehler
|
||||||
|
// darin ergibt eine leere Mediathek ohne erkennbaren Grund. Wer sich
|
||||||
|
// durchklickt, sieht stattdessen, was wirklich da ist.
|
||||||
|
//
|
||||||
|
// Das Textfeld daneben bleibt trotzdem: eintippen ist schneller, wenn
|
||||||
|
// man den Pfad kennt, und bei sehr tiefen Bäumen die einzige Geduld
|
||||||
|
// schonende Möglichkeit.
|
||||||
|
app.MapGet("/api/v1/system/folders", (string? path) =>
|
||||||
|
Ordnerbaum.Lesen(path) is { } baum
|
||||||
|
? Results.Ok(baum)
|
||||||
|
: Einwand.Fehlt($"Den Ordner {path} gibt es nicht, oder es ist keiner."))
|
||||||
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Dubletten von Hand zusammenlegen.** Nach einem Scan passiert das
|
||||||
|
// von selbst; hier steht es für den Fall, dass doch wieder welche
|
||||||
|
// auftauchen — etwa nachdem eine Zuordnung von Hand geändert wurde.
|
||||||
|
app.MapPost("/api/v1/system/merge-duplicates",
|
||||||
|
async (Zusammenlegung zusammenlegung, CancellationToken ct) =>
|
||||||
|
Results.Ok(await zusammenlegung.AlleAsync(null, ct)))
|
||||||
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Protokolle, eine Datei je Tag.** Das Containerprotokoll ist nach
|
||||||
|
// einem Neustart weg und nur erreichbar, wenn man sich auf das NAS
|
||||||
|
// verbindet — die Frage „warum wurde der Film gestern Nacht nicht
|
||||||
|
// erkannt?" lässt sich damit nicht beantworten.
|
||||||
|
app.MapGet("/api/v1/system/logs", (Tagebuch tagebuch) =>
|
||||||
|
Results.Ok(tagebuch.Tage()
|
||||||
|
.Select(t => new Protokolltag(
|
||||||
|
t.ToString("yyyy-MM-dd"),
|
||||||
|
new FileInfo(tagebuch.DateiFuer(t)) is { Exists: true } f ? f.Length : 0))
|
||||||
|
.ToList()))
|
||||||
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
app.MapGet("/api/v1/system/logs/{tag}", (string tag, Tagebuch tagebuch, int? tail) =>
|
||||||
|
{
|
||||||
|
// **Selbst zusammengesetzt, nicht aus der Anfrage übernommen.**
|
||||||
|
// Ein Datum, das durch `DateOnly` gegangen ist, kann kein
|
||||||
|
// `../../etc/passwd` mehr sein.
|
||||||
|
if (!DateOnly.TryParseExact(tag, "yyyy-MM-dd", null,
|
||||||
|
System.Globalization.DateTimeStyles.None, out var tagesdatum))
|
||||||
|
{
|
||||||
|
return Einwand.Bitte("Erwartet wird ein Datum als JJJJ-MM-TT.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var datei = tagebuch.DateiFuer(tagesdatum);
|
||||||
|
if (!File.Exists(datei)) return Results.NotFound();
|
||||||
|
|
||||||
|
// **Mit `tail` nur das Ende.** Die Ansicht braucht die letzten
|
||||||
|
// Zeilen; die ganze Datei dafür zu schicken endete bei 2 MB über
|
||||||
|
// eine wackelige Verbindung in „Failed to fetch". Ohne `tail`
|
||||||
|
// kommt weiterhin alles — das ist der Weg zum Herunterladen.
|
||||||
|
if (tail is > 0 and <= 5000)
|
||||||
|
{
|
||||||
|
return Results.Text(
|
||||||
|
tagebuch.Ende(tagesdatum, tail.Value), "text/plain", System.Text.Encoding.UTF8);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.File(datei, "text/plain; charset=utf-8", $"flaro-{tag}.log");
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Die eigene Sprache.** Metadaten liegen in der des Servers vor; was
|
||||||
|
// hier eingestellt wird, greift nur, soweit Übersetzungen da sind.
|
||||||
|
// Deshalb kommt die Abdeckung gleich mit — ohne sie wäre „Englisch"
|
||||||
|
// ein Versprechen, das der Server nicht hält.
|
||||||
|
app.MapPut("/api/v1/users/me/language", async (
|
||||||
|
SprachRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
|
||||||
|
Uebersetzer uebersetzer, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (PlaybackEndpoints.Benutzer(wer) is not { } kennung) return Results.Unauthorized();
|
||||||
|
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == kennung, ct);
|
||||||
|
if (benutzer is null) return Results.NotFound();
|
||||||
|
|
||||||
|
var sprache = anfrage.Language?.Trim();
|
||||||
|
benutzer.Language = string.IsNullOrEmpty(sprache) ? null : sprache;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
if (benutzer.Language is null) return Results.Ok(new SprachStand(null, 0, 0));
|
||||||
|
|
||||||
|
var abdeckung = await uebersetzer.AbdeckungAsync(benutzer.Language, ct);
|
||||||
|
return Results.Ok(new SprachStand(benutzer.Language, abdeckung.Vorhanden, abdeckung.Gesamt));
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// Die Sammelaktion dazu — Administratoren, weil sie je Titel eine
|
||||||
|
// Anfrage an TMDB kostet.
|
||||||
|
app.MapPost("/api/v1/system/translations", async (
|
||||||
|
SprachRequest anfrage, Uebersetzer uebersetzer, Tagebuch tagebuch, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var sprache = anfrage.Language?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(sprache))
|
||||||
|
return Einwand.Bitte("Ohne Sprache gibt es nichts zu holen.");
|
||||||
|
|
||||||
|
tagebuch.Schreiben($"Übersetzungen für {sprache} werden geholt.");
|
||||||
|
var bericht = await uebersetzer.HolenAsync(sprache, null, ct);
|
||||||
|
tagebuch.Schreiben(
|
||||||
|
$"Übersetzungen für {sprache}: {bericht.Gesamt} geholt, {bericht.Fehlend} ohne Treffer.");
|
||||||
|
return Results.Ok(bericht);
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) =>
|
app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
var name = anfrage.Name.Trim();
|
var name = anfrage.Name.Trim();
|
||||||
if (name.Length == 0)
|
if (name.Length == 0)
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
return Einwand.Bitte("Ein Name darf nicht leer sein.");
|
||||||
{
|
|
||||||
["name"] = ["Ein Name darf nicht leer sein."],
|
|
||||||
});
|
|
||||||
|
|
||||||
var vorhanden = await db.Settings.FirstOrDefaultAsync(e => e.Key == NameSchlüssel);
|
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 });
|
if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = NameSchlüssel, Value = name });
|
||||||
@@ -88,13 +192,30 @@ public static class SystemEndpoints
|
|||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Wie hoch der Aufmacher steht.** Gelesen wird er mit der
|
||||||
|
// Systemauskunft weiter oben — die holt der Client ohnehin als
|
||||||
|
// Erstes, und so steht die Höhe schon beim ersten Zeichnen fest.
|
||||||
|
app.MapPut("/api/v1/system/hero-height", async (
|
||||||
|
HoeheRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (!Aufmacherhoehe.Kennt(anfrage.Height))
|
||||||
|
return Einwand.Bitte("Unbekannte Stufe. Möglich sind: niedrig, mittel, hoch, sehrhoch.");
|
||||||
|
|
||||||
|
var wert = anfrage.Height.Trim().ToLowerInvariant();
|
||||||
|
var vorhanden = await db.Settings
|
||||||
|
.FirstOrDefaultAsync(e => e.Key == Aufmacherhoehe.Schlüssel, ct);
|
||||||
|
if (vorhanden is null)
|
||||||
|
db.Settings.Add(new ServerSetting { Key = Aufmacherhoehe.Schlüssel, Value = wert });
|
||||||
|
else vorhanden.Value = wert;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) =>
|
app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8)
|
if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8)
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
return Einwand.Bitte("Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
|
||||||
{
|
|
||||||
["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
|
|
||||||
});
|
|
||||||
|
|
||||||
await EinrichtungsSchloss.WaitAsync();
|
await EinrichtungsSchloss.WaitAsync();
|
||||||
try
|
try
|
||||||
@@ -102,14 +223,17 @@ public static class SystemEndpoints
|
|||||||
// Noch einmal prüfen, innerhalb des Schlosses. Draussen wäre die
|
// Noch einmal prüfen, innerhalb des Schlosses. Draussen wäre die
|
||||||
// Antwort schon veraltet, wenn sie ankommt.
|
// Antwort schon veraltet, wenn sie ankommt.
|
||||||
if (await db.Users.AnyAsync())
|
if (await db.Users.AnyAsync())
|
||||||
return Results.Problem(
|
return Einwand.Belegt("Dieser Server ist bereits eingerichtet.");
|
||||||
"Dieser Server ist bereits eingerichtet.", statusCode: StatusCodes.Status409Conflict);
|
|
||||||
|
|
||||||
var administrator = new User
|
var administrator = new User
|
||||||
{
|
{
|
||||||
UserName = anfrage.UserName.Trim(),
|
UserName = anfrage.UserName.Trim(),
|
||||||
PasswordHash = PasswordHasher.Hash(anfrage.Password),
|
PasswordHash = PasswordHasher.Hash(anfrage.Password),
|
||||||
IsAdministrator = true,
|
IsAdministrator = true,
|
||||||
|
// **Wer einrichtet, besitzt.** Ihm nimmt niemand das
|
||||||
|
// Adminrecht — sonst gäbe es einen Zustand ohne jeden
|
||||||
|
// Administrator, und aus dem führt kein Weg zurück.
|
||||||
|
IsOwner = true,
|
||||||
};
|
};
|
||||||
db.Users.Add(administrator);
|
db.Users.Add(administrator);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -121,6 +245,97 @@ public static class SystemEndpoints
|
|||||||
|
|
||||||
// Ohne Anmeldung, damit Docker und Reverse Proxies danach fragen können.
|
// Ohne Anmeldung, damit Docker und Reverse Proxies danach fragen können.
|
||||||
// Verrät nichts ausser der Tatsache, dass etwas antwortet.
|
// Verrät nichts ausser der Tatsache, dass etwas antwortet.
|
||||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" })).AllowAnonymous();
|
// **Wo was liegt, und wie viel davon.**
|
||||||
|
//
|
||||||
|
// Die drei Pfade standen nirgends in der Oberfläche; wer wissen wollte,
|
||||||
|
// wohin die Bilder wandern, musste die Compose-Datei lesen. Und wenn
|
||||||
|
// das `cache`-Volume vollläuft, ist das der erste Ort, an dem man
|
||||||
|
// nachsieht — nicht der letzte.
|
||||||
|
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade) =>
|
||||||
|
{
|
||||||
|
Speicherort Messen(string name, string variable, string pfad) =>
|
||||||
|
new(name, variable, pfad, Belegt(pfad), Frei(pfad),
|
||||||
|
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null);
|
||||||
|
|
||||||
|
return Results.Ok(new[]
|
||||||
|
{
|
||||||
|
Messen("Daten", "FLARO_DATA", pfade.Data),
|
||||||
|
Messen("Bilder", "FLARO_CACHE", pfade.Cache),
|
||||||
|
Messen("Umpacken", "FLARO_TRANSCODE", pfade.Transcode),
|
||||||
|
});
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Den Bildspeicher leeren.**
|
||||||
|
//
|
||||||
|
// Ungefährlich: jedes Bild lässt sich erneut holen, und hochgeladene
|
||||||
|
// liegen als Datei genauso wieder da, sobald jemand sie neu wählt.
|
||||||
|
// Darum ohne Umweg über eine Wartungsaktion — es ist ein Cache.
|
||||||
|
app.MapDelete("/api/v1/system/storage/images", (FlaroPaths pfade) =>
|
||||||
|
{
|
||||||
|
var ordner = Path.Combine(pfade.Cache, "images");
|
||||||
|
if (!Directory.Exists(ordner)) return Results.NoContent();
|
||||||
|
|
||||||
|
var befreit = Belegt(ordner);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(ordner, recursive: true);
|
||||||
|
Directory.CreateDirectory(ordner);
|
||||||
|
}
|
||||||
|
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return Results.Problem(Fehlertext.FuerMenschen(fehler));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new { freed = befreit });
|
||||||
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
|
// **Auch hier die Kennung.** Wer auf dem NAS nur „curl" hat, soll nicht
|
||||||
|
// erst die Oberfläche öffnen müssen, um sie zu sehen.
|
||||||
|
app.MapGet("/health", () => Results.Ok(new
|
||||||
|
{
|
||||||
|
status = "ok",
|
||||||
|
version = Buildkennung.Version,
|
||||||
|
build = Buildkennung.Build,
|
||||||
|
})).AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wie viel ein Ordner belegt — rekursiv, ohne zu werfen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Ein unlesbarer Unterordner darf die Auskunft nicht verhindern; dann
|
||||||
|
/// steht dort eben etwas zu wenig. Eine Zahl, die fast stimmt, ist besser
|
||||||
|
/// als eine Fehlerseite.
|
||||||
|
/// </remarks>
|
||||||
|
private static long Belegt(string pfad)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(pfad)) return 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new DirectoryInfo(pfad)
|
||||||
|
.EnumerateFiles("*", new EnumerationOptions
|
||||||
|
{
|
||||||
|
RecurseSubdirectories = true,
|
||||||
|
IgnoreInaccessible = true,
|
||||||
|
})
|
||||||
|
.Sum(d => d.Length);
|
||||||
|
}
|
||||||
|
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wie viel auf dem Volume noch frei ist — <c>null</c>, wenn unbekannt.</summary>
|
||||||
|
private static long? Frei(string pfad)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new DriveInfo(Path.GetPathRoot(Path.GetFullPath(pfad)) ?? "/").AvailableFreeSpace;
|
||||||
|
}
|
||||||
|
catch (Exception fehler) when (fehler is ArgumentException or IOException
|
||||||
|
or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Flaro.Data;
|
||||||
|
|
||||||
|
namespace Flaro.Api;
|
||||||
|
|
||||||
|
/// <summary>Macht aus einer Ausnahme einen Satz, den ein Mensch lesen kann.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum es das braucht.</b> In der Oberfläche stand tagelang „An error
|
||||||
|
/// occurred while saving the entity changes. See the inner exception for
|
||||||
|
/// details." — englisch, in einem deutschen Server, und mit dem Verweis auf
|
||||||
|
/// eine innere Ausnahme, die niemand zu sehen bekam. Das ist keine Meldung,
|
||||||
|
/// das ist eine Sackgasse: sie nennt weder die Ursache noch einen nächsten
|
||||||
|
/// Schritt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Zwei Texte, nicht einer.</b> <see cref="FuerMenschen"/> liefert, was in
|
||||||
|
/// der Oberfläche steht: deutsch, ohne Fehlercode, mit einem Hinweis, was zu
|
||||||
|
/// tun ist. <see cref="FuersProtokoll"/> liefert alles — Typ, Meldung, jede
|
||||||
|
/// innere Ausnahme — und landet in der Tagesdatei. Wer den Fehler sucht,
|
||||||
|
/// findet dort die Einzelheiten; wer den Server nur benutzt, wird nicht
|
||||||
|
/// damit behelligt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Erkannt wird am Text, nicht am Typ.</b> Ein Verweis auf
|
||||||
|
/// <c>SqliteException</c> hiesse, den Datenbanktreiber in dieses Projekt zu
|
||||||
|
/// ziehen, nur um eine Meldung zu formulieren. Die Zeichenkette
|
||||||
|
/// „UNIQUE constraint failed: Tabelle.Spalte" ist seit Jahren stabil und
|
||||||
|
/// kostet nichts.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static partial class Fehlertext
|
||||||
|
{
|
||||||
|
/// <summary>Was in der Oberfläche steht — deutsch, ohne Fehlercode.</summary>
|
||||||
|
public static string FuerMenschen(Exception fehler)
|
||||||
|
{
|
||||||
|
var regel = VerletzteRegel(fehler);
|
||||||
|
if (regel is not null) return regel;
|
||||||
|
|
||||||
|
// Fehler, die ihren eigenen Text schon für Menschen schreiben, dürfen
|
||||||
|
// ihn behalten: eine eigene Ausnahme sagt mehr als jede Verallgemeinerung.
|
||||||
|
if (fehler is BestandVerschwundenException or InvalidOperationException { Source: "Flaro.Data" })
|
||||||
|
return fehler.Message;
|
||||||
|
|
||||||
|
return fehler switch
|
||||||
|
{
|
||||||
|
UnauthorizedAccessException =>
|
||||||
|
"Auf einen Ordner oder eine Datei fehlt das Zugriffsrecht. "
|
||||||
|
+ "Prüfe die Einhängung und die Benutzerkennung des Containers.",
|
||||||
|
DirectoryNotFoundException or FileNotFoundException =>
|
||||||
|
"Ein Ordner oder eine Datei war nicht mehr da. "
|
||||||
|
+ "Ist das Laufwerk noch eingehängt?",
|
||||||
|
IOException =>
|
||||||
|
"Beim Lesen oder Schreiben ist etwas schiefgegangen. "
|
||||||
|
+ "Oft ist das ein volles Volume oder ein schlafendes Laufwerk.",
|
||||||
|
TaskCanceledException or OperationCanceledException =>
|
||||||
|
"Der Vorgang wurde abgebrochen.",
|
||||||
|
// Tritt auf, wenn jemand dieselben Zeilen gleichzeitig entfernt —
|
||||||
|
// etwa ein „Zurücksetzen" während eines laufenden Scans.
|
||||||
|
Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException =>
|
||||||
|
"Jemand hat dieselben Einträge gleichzeitig geändert. "
|
||||||
|
+ "Der Lauf wurde abgebrochen; starte ihn noch einmal.",
|
||||||
|
HttpRequestException =>
|
||||||
|
"Die Metadatenquelle war nicht erreichbar. "
|
||||||
|
+ "Beim nächsten Lauf wird es erneut versucht.",
|
||||||
|
_ =>
|
||||||
|
"Etwas ist schiefgegangen. Die Einzelheiten stehen im Protokoll "
|
||||||
|
+ "von heute, unter „Protokolle“.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Alles, was es über den Fehler zu sagen gibt — für die Tagesdatei.</summary>
|
||||||
|
public static string FuersProtokoll(Exception fehler)
|
||||||
|
{
|
||||||
|
var text = new StringBuilder();
|
||||||
|
var tiefe = 0;
|
||||||
|
Exception innerste = fehler;
|
||||||
|
|
||||||
|
for (Exception? e = fehler; e is not null; e = e.InnerException, tiefe++)
|
||||||
|
{
|
||||||
|
if (tiefe > 0) text.Append(" ← ");
|
||||||
|
text.Append(e.GetType().Name).Append(": ").Append(e.Message);
|
||||||
|
innerste = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Und wo es passiert ist.**
|
||||||
|
//
|
||||||
|
// Ohne diese Zeilen stand in der Tagesdatei „InvalidOperationException:
|
||||||
|
// Nullable object must have a value." und sonst nichts — eine Meldung,
|
||||||
|
// mit der weder der Betreiber noch der Entwickler etwas anfangen kann.
|
||||||
|
// Gerade die Fehler, die man nicht erwartet hat, sind die, bei denen
|
||||||
|
// die Stelle alles ist.
|
||||||
|
//
|
||||||
|
// Nur die innerste Ausnahme und nur die eigenen Bilder: der Rahmenwerk-
|
||||||
|
// Anteil einer Spur ist dreimal so lang und sagt nichts über Flaro.
|
||||||
|
var spur = Eigene(innerste.StackTrace);
|
||||||
|
if (spur.Length > 0) text.Append('\n').Append(spur);
|
||||||
|
|
||||||
|
return text.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Zeilen der Spur, die zu Flaro gehören.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Höchstens zwölf.</b> Was darunter liegt, ist ASP.NET und EF Core —
|
||||||
|
/// bei einem Fehler im eigenen Code steht die Antwort immer oben.
|
||||||
|
/// </remarks>
|
||||||
|
private static string Eigene(string? spur)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(spur)) return "";
|
||||||
|
|
||||||
|
var zeilen = spur.Split('\n')
|
||||||
|
.Select(z => z.TrimEnd('\r'))
|
||||||
|
.Where(z => z.Contains(" Flaro.", StringComparison.Ordinal))
|
||||||
|
.Take(12)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Steckt gar nichts Eigenes darin, ist die ganze Spur besser als keine.
|
||||||
|
if (zeilen.Count == 0)
|
||||||
|
{
|
||||||
|
zeilen = spur.Split('\n').Select(z => z.TrimEnd('\r')).Take(6).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join('\n', zeilen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Welche Eindeutigkeitsregel gebrochen wurde — in Worten.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Die Tabellen- und Spaltennamen werden übersetzt, nicht
|
||||||
|
/// durchgereicht.</b> „UNIQUE constraint failed: MediaFiles.LibraryId,
|
||||||
|
/// MediaFiles.Path" ist für den, der es gebaut hat, eine vollständige
|
||||||
|
/// Diagnose — und für alle anderen eine Zeichenkette. Was dem Benutzer
|
||||||
|
/// nützt, ist der Satz dahinter: was kollidiert ist und was er dagegen
|
||||||
|
/// tun kann.
|
||||||
|
/// </remarks>
|
||||||
|
private static string? VerletzteRegel(Exception fehler)
|
||||||
|
{
|
||||||
|
string? spalten = null;
|
||||||
|
|
||||||
|
for (Exception? e = fehler; e is not null && spalten is null; e = e.InnerException)
|
||||||
|
{
|
||||||
|
var treffer = Eindeutigkeit().Match(e.Message);
|
||||||
|
if (treffer.Success) spalten = treffer.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spalten is null) return null;
|
||||||
|
|
||||||
|
// Normalisiert auf „Tabelle.Spalte, Spalte", damit die Zuordnung unten
|
||||||
|
// nicht an einem Leerzeichen scheitert.
|
||||||
|
var schluessel = spalten.Replace(" ", "");
|
||||||
|
|
||||||
|
if (schluessel.Contains("MediaFiles.Path"))
|
||||||
|
{
|
||||||
|
return "Zwei Einträge beanspruchen denselben Dateipfad. "
|
||||||
|
+ "Das deutet auf einen Ordner hin, der doppelt eingehängt ist, "
|
||||||
|
+ "oder auf eine Verknüpfung, die zurück in die Mediathek zeigt. "
|
||||||
|
+ "Es wurde nichts verändert.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schluessel.Contains("Episodes.SeasonNumber") || schluessel.Contains("Episodes.EpisodeNumber"))
|
||||||
|
{
|
||||||
|
return "Zwei Dateien beanspruchen dieselbe Folge derselben Staffel. "
|
||||||
|
+ "Sind es zwei Fassungen, gehören sie an dieselbe Folge — "
|
||||||
|
+ "dann hilft „Zuordnung korrigieren“. Es wurde nichts verändert.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schluessel.Contains("TmdbId"))
|
||||||
|
{
|
||||||
|
return "Zwei Titel wurden derselben Vorlage zugeordnet. "
|
||||||
|
+ "Unter „Doppelte zusammenführen“ lässt sich das auflösen. "
|
||||||
|
+ "Es wurde nichts verändert.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schluessel.Contains("MediaStreams"))
|
||||||
|
{
|
||||||
|
return "Eine Datei meldet dieselbe Spur zweimal. "
|
||||||
|
+ "Beim nächsten Lauf wird sie erneut untersucht.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Ein Eintrag gibt es bereits ein zweites Mal. "
|
||||||
|
+ "Es wurde nichts verändert; die Einzelheiten stehen im Protokoll von heute.";
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"UNIQUE constraint failed:\s*([^'""\r\n]+)", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex Eindeutigkeit();
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Flaro.Api.Auth;
|
||||||
|
using Flaro.Data;
|
||||||
|
|
||||||
|
namespace Flaro.Api;
|
||||||
|
|
||||||
|
/// <summary>Setzt je Anfrage, wer Unveröffentlichtes sehen darf.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Als Middleware und nicht in den Endpunkten.</b> Zwanzig Endpunkte
|
||||||
|
/// müssten daran denken; diese eine Stelle läuft vor allen. Was sie setzt,
|
||||||
|
/// greift über den globalen Abfragefilter in <see cref="FlaroContext"/> auf
|
||||||
|
/// jede Abfrage — auch auf die, die erst nächstes Jahr geschrieben wird.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nach der Authentifizierung, vor den Endpunkten.</b> Vorher stünde im
|
||||||
|
/// <c>ClaimsPrincipal</c> noch nichts, und jeder gälte als gewöhnlicher
|
||||||
|
/// Benutzer — die Verwaltung bekäme ihre eigene Prüfliste nicht zu sehen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Freigabefilter
|
||||||
|
{
|
||||||
|
public static IApplicationBuilder UseFreigabefilter(this IApplicationBuilder app) =>
|
||||||
|
app.Use(async (zusammenhang, weiter) =>
|
||||||
|
{
|
||||||
|
var db = zusammenhang.RequestServices.GetService<FlaroContext>();
|
||||||
|
if (db is not null)
|
||||||
|
{
|
||||||
|
db.Jetzt = zusammenhang.RequestServices
|
||||||
|
.GetRequiredService<TimeProvider>().GetUtcNow();
|
||||||
|
db.AuchUnveroeffentlichtes =
|
||||||
|
zusammenhang.User.IsInRole(FlaroRoles.Administrator);
|
||||||
|
}
|
||||||
|
|
||||||
|
await weiter();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -26,7 +26,9 @@ public sealed class MatchApplier(
|
|||||||
film.TmdbId = gefunden.ProviderId;
|
film.TmdbId = gefunden.ProviderId;
|
||||||
film.ImdbId = gefunden.ImdbId;
|
film.ImdbId = gefunden.ImdbId;
|
||||||
film.Title = gefunden.Title;
|
film.Title = gefunden.Title;
|
||||||
film.SortTitleValue = SortTitle.For(gefunden.Title);
|
// Ein von Hand gesetzter Sortiertitel überlebt auch eine Korrektur der
|
||||||
|
// Zuordnung — sonst wäre die Reihenfolge nach jedem Geradeziehen hin.
|
||||||
|
if (!film.SortTitlePinned) film.SortTitleValue = SortTitle.For(gefunden.Title);
|
||||||
film.Year = gefunden.Year ?? film.Year;
|
film.Year = gefunden.Year ?? film.Year;
|
||||||
film.Overview = gefunden.Overview;
|
film.Overview = gefunden.Overview;
|
||||||
film.PosterImage = await Ablegen(gefunden.Poster, ct);
|
film.PosterImage = await Ablegen(gefunden.Poster, ct);
|
||||||
@@ -51,7 +53,7 @@ public sealed class MatchApplier(
|
|||||||
serie.TmdbId = gefunden.ProviderId;
|
serie.TmdbId = gefunden.ProviderId;
|
||||||
serie.ImdbId = gefunden.ImdbId;
|
serie.ImdbId = gefunden.ImdbId;
|
||||||
serie.Title = gefunden.Title;
|
serie.Title = gefunden.Title;
|
||||||
serie.SortTitleValue = SortTitle.For(gefunden.Title);
|
if (!serie.SortTitlePinned) serie.SortTitleValue = SortTitle.For(gefunden.Title);
|
||||||
serie.Year = gefunden.Year ?? serie.Year;
|
serie.Year = gefunden.Year ?? serie.Year;
|
||||||
serie.Overview = gefunden.Overview;
|
serie.Overview = gefunden.Overview;
|
||||||
serie.PosterImage = await Ablegen(gefunden.Poster, ct);
|
serie.PosterImage = await Ablegen(gefunden.Poster, ct);
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
namespace Flaro.Api;
|
||||||
|
|
||||||
|
/// <summary>Zeigt Ordner des Dateisystems, damit Pfade nicht getippt werden müssen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum das überhaupt nötig ist.</b> Ein Pfad in einer Mediathek ist der
|
||||||
|
/// Pfad <i>im Container</i>, nicht der auf dem NAS — also das, was in der
|
||||||
|
/// Compose-Datei hinter dem Doppelpunkt steht. Diese Unterscheidung kennt
|
||||||
|
/// niemand, der nicht gerade die Compose-Datei geschrieben hat, und ein
|
||||||
|
/// Tippfehler darin ergibt eine leere Mediathek ohne erkennbaren Grund.
|
||||||
|
/// Wer sich durchklicken kann, sieht stattdessen, was wirklich da ist.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nur für Administratoren, und das ist keine Förmlichkeit.</b> Diese
|
||||||
|
/// Auskunft legt den Aufbau des Dateisystems offen. Im Container ist das
|
||||||
|
/// wenig, aber eingehängte Ordner tragen die Namen echter Verzeichnisse.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Ordnerbaum
|
||||||
|
{
|
||||||
|
/// <summary>Wo die Suche beginnt, wenn niemand einen Pfad nennt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// In dieser Reihenfolge, weil die Anleitung Medien nach <c>/media</c>
|
||||||
|
/// einhängen lässt. Steht dort nichts, ist die Wurzel der ehrlichste
|
||||||
|
/// Anfang — raten hilft niemandem.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly string[] Anfänge = ["/media", "/mnt", "/data", "/volume1"];
|
||||||
|
|
||||||
|
/// <summary>Was an der Wurzel nicht angezeigt wird.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Keine Sicherheitsmassnahme, eine Aufräumarbeit.</b> Wer den Pfad
|
||||||
|
/// kennt, kann ihn weiterhin eintippen; verborgen wird nichts. Es geht
|
||||||
|
/// darum, dass eine Liste aus zwanzig Einträgen, von denen achtzehn
|
||||||
|
/// <c>/proc</c>, <c>/sys</c> und <c>/usr</c> heissen, unbrauchbar ist.
|
||||||
|
/// Medien liegen dort nie.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly HashSet<string> AnDerWurzelVerborgen = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"bin", "boot", "dev", "etc", "lib", "lib32", "lib64", "libx32",
|
||||||
|
"proc", "root", "run", "sbin", "sys", "tmp", "usr", "var",
|
||||||
|
};
|
||||||
|
|
||||||
|
public sealed record Eintrag(string Name, string Path);
|
||||||
|
|
||||||
|
public sealed record Antwort(
|
||||||
|
string Path,
|
||||||
|
string? Parent,
|
||||||
|
IReadOnlyList<Eintrag> Entries,
|
||||||
|
/// <summary>Warum die Liste leer ist — oder <c>null</c>, wenn sie es nicht ist.</summary>
|
||||||
|
string? Note);
|
||||||
|
|
||||||
|
/// <summary>Der Pfad, mit dem die Auswahl aufgeht.</summary>
|
||||||
|
public static string Anfang()
|
||||||
|
{
|
||||||
|
foreach (var kandidat in Anfänge)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(kandidat)) return kandidat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nicht hart „/": unter Windows und macOS gibt es das so nicht, und
|
||||||
|
// die Tests laufen dort.
|
||||||
|
return Path.GetPathRoot(Environment.CurrentDirectory) is { Length: > 0 } wurzel
|
||||||
|
? wurzel
|
||||||
|
: "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Listet die Unterordner eines Verzeichnisses.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Gibt auch dann eine Antwort, wenn das Lesen scheitert.</b> Ein
|
||||||
|
/// Ordner ohne Leserecht ist kein Fehler des Aufrufers, sondern eine
|
||||||
|
/// Tatsache über das Dateisystem — und eine, die in der Oberfläche
|
||||||
|
/// stehen soll, statt als 500 im Protokoll zu landen. <c>null</c> heisst
|
||||||
|
/// dagegen: diesen Pfad gibt es nicht.
|
||||||
|
/// </remarks>
|
||||||
|
public static Antwort? Lesen(string? angefragt)
|
||||||
|
{
|
||||||
|
var pfad = string.IsNullOrWhiteSpace(angefragt) ? Anfang() : angefragt.Trim();
|
||||||
|
|
||||||
|
// Macht aus „/media/../etc" wieder „/etc". Das ist keine Abwehr —
|
||||||
|
// Administratoren dürfen überallhin — sondern sorgt dafür, dass der
|
||||||
|
// angezeigte Pfad der ist, den man auch eintippen würde.
|
||||||
|
try { pfad = Path.GetFullPath(pfad); }
|
||||||
|
catch (Exception e) when (e is ArgumentException or NotSupportedException or PathTooLongException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(pfad)) return null;
|
||||||
|
|
||||||
|
var eltern = Path.GetDirectoryName(pfad.TrimEnd(Path.DirectorySeparatorChar));
|
||||||
|
var anDerWurzel = string.IsNullOrEmpty(eltern);
|
||||||
|
|
||||||
|
List<Eintrag> eintraege;
|
||||||
|
string? hinweis = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
eintraege = Directory.EnumerateDirectories(pfad)
|
||||||
|
.Select(p => new Eintrag(Path.GetFileName(p), p))
|
||||||
|
.Where(e => e.Name.Length > 0)
|
||||||
|
.Where(e => !anDerWurzel || !AnDerWurzelVerborgen.Contains(e.Name))
|
||||||
|
.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (eintraege.Count == 0) hinweis = "Keine Unterordner.";
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
eintraege = [];
|
||||||
|
hinweis = "Kein Leserecht für diesen Ordner.";
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
eintraege = [];
|
||||||
|
hinweis = "Der Ordner liess sich nicht lesen.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Antwort(pfad, anDerWurzel ? null : eltern, eintraege, hinweis);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,8 +118,10 @@ public sealed class PackageCoordinator(
|
|||||||
catch (Exception fehler)
|
catch (Exception fehler)
|
||||||
{
|
{
|
||||||
log.LogError(fehler, "Das Umpacken von {Kennung} ist gescheitert.", fileId);
|
log.LogError(fehler, "Das Umpacken von {Kennung} ist gescheitert.", fileId);
|
||||||
|
// Der rohe Ausnahmetext bleibt im Protokoll; angezeigt wird ein Satz.
|
||||||
_zustand[fileId] = new PackageState(
|
_zustand[fileId] = new PackageState(
|
||||||
"failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(), fehler.Message);
|
"failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(),
|
||||||
|
Fehlertext.FuerMenschen(fehler));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using Flaro.Api;
|
using Flaro.Api;
|
||||||
using Flaro.Api.Auth;
|
using Flaro.Api.Auth;
|
||||||
@@ -19,7 +20,15 @@ pfade.EnsureExists();
|
|||||||
builder.Services.AddSingleton(pfade);
|
builder.Services.AddSingleton(pfade);
|
||||||
builder.Services.AddSingleton(TimeProvider.System);
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
|
|
||||||
builder.Services.AddDbContext<FlaroContext>(o => o.UseSqlite($"Data Source={pfade.DatabaseFile}"));
|
// **Mit Wartezeit statt sofortigem Abbruch.**
|
||||||
|
//
|
||||||
|
// SQLite lässt genau einen Schreiber zu. Ohne `Default Timeout` scheitert
|
||||||
|
// jeder, der dazwischenkommt, auf der Stelle mit „database is locked" —
|
||||||
|
// während eines Scans über tausende Dateien ist das jede zweite Anfrage aus
|
||||||
|
// der Oberfläche. Dreissig Sekunden sind grosszügig; gebraucht werden
|
||||||
|
// Millisekunden.
|
||||||
|
builder.Services.AddDbContext<FlaroContext>(o =>
|
||||||
|
o.UseSqlite($"Data Source={pfade.DatabaseFile};Default Timeout=30"));
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddAuthentication(TokenAuthenticationHandler.SchemeName)
|
.AddAuthentication(TokenAuthenticationHandler.SchemeName)
|
||||||
@@ -66,6 +75,7 @@ builder.Services.AddRateLimiter(o =>
|
|||||||
// und lässt einen Absturz dort den Server nicht mitnehmen.
|
// und lässt einen Absturz dort den Server nicht mitnehmen.
|
||||||
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
|
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
|
||||||
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
|
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
|
||||||
|
builder.Services.AddSingleton<Schluesselbilder>();
|
||||||
builder.Services.AddSingleton<FfmpegRemuxer>();
|
builder.Services.AddSingleton<FfmpegRemuxer>();
|
||||||
|
|
||||||
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
|
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
|
||||||
@@ -74,10 +84,13 @@ builder.Services.AddSingleton(SidecarOptions.FromEnvironment());
|
|||||||
builder.Services.AddSingleton<ISidecarStore, SidecarWriter>();
|
builder.Services.AddSingleton<ISidecarStore, SidecarWriter>();
|
||||||
|
|
||||||
builder.Services.AddScoped<LibraryScanner>();
|
builder.Services.AddScoped<LibraryScanner>();
|
||||||
|
builder.Services.AddScoped<Zusammenlegung>();
|
||||||
|
builder.Services.AddScoped<Uebersetzer>();
|
||||||
|
|
||||||
// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was
|
// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was
|
||||||
// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann.
|
// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann.
|
||||||
builder.Services.AddScoped<ITmdbSettingsSource, DatabaseTmdbSettings>();
|
builder.Services.AddScoped<ITmdbSettingsSource, DatabaseTmdbSettings>();
|
||||||
|
builder.Services.AddScoped<Wiedergabeeinstellungen>();
|
||||||
builder.Services.AddHttpClient<TmdbClient>(c =>
|
builder.Services.AddHttpClient<TmdbClient>(c =>
|
||||||
{
|
{
|
||||||
c.BaseAddress = new Uri(TmdbClient.BaseAddress);
|
c.BaseAddress = new Uri(TmdbClient.BaseAddress);
|
||||||
@@ -93,9 +106,31 @@ builder.Services.AddScoped<MetadataEnricher>();
|
|||||||
builder.Services.AddScoped<MatchApplier>();
|
builder.Services.AddScoped<MatchApplier>();
|
||||||
builder.Services.AddSingleton<ScanCoordinator>();
|
builder.Services.AddSingleton<ScanCoordinator>();
|
||||||
builder.Services.AddSingleton<PackageCoordinator>();
|
builder.Services.AddSingleton<PackageCoordinator>();
|
||||||
|
|
||||||
|
// **Neben dem Umpacker, nicht an seiner Stelle.** Der baut eine fertige Datei
|
||||||
|
// zum Mitnehmen; dieser lässt sie laufen, während sie entsteht.
|
||||||
|
builder.Services.AddSingleton<Streamkoordinator>();
|
||||||
|
builder.Services.AddSingleton<UntertitelLeser>();
|
||||||
// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage
|
// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage
|
||||||
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
|
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
|
||||||
builder.Services.AddSingleton<PlaybackSessions>();
|
builder.Services.AddSingleton<PlaybackSessions>();
|
||||||
|
builder.Services.AddSingleton<Tagebuch>();
|
||||||
|
|
||||||
|
// **Die Schlüssel der Datenverschlüsselung gehören ins Volume.**
|
||||||
|
//
|
||||||
|
// Ohne diese Zeile legt ASP.NET sie unter `~/.aspnet/DataProtection-Keys` ab —
|
||||||
|
// also im Container, der beim nächsten Update weg ist. Heute fällt das nicht
|
||||||
|
// auf, weil Flaros Sitzungs-Cookie das Gerätetoken selbst trägt und nichts
|
||||||
|
// davon verschlüsselt wird. Es fällt erst auf, wenn irgendwann etwas doch
|
||||||
|
// darauf baut, und dann sieht es aus wie ein Fehler anderswo.
|
||||||
|
//
|
||||||
|
// Nebenbei verschwindet damit die Warnung, die bei jedem Start im Protokoll
|
||||||
|
// stand und nach einem Problem aussah, das keines war.
|
||||||
|
var schluesselOrdner = Path.Combine(pfade.Data, "keys");
|
||||||
|
Directory.CreateDirectory(schluesselOrdner);
|
||||||
|
builder.Services.AddDataProtection()
|
||||||
|
.PersistKeysToFileSystem(new DirectoryInfo(schluesselOrdner))
|
||||||
|
.SetApplicationName("Flaro");
|
||||||
builder.Services.AddProblemDetails();
|
builder.Services.AddProblemDetails();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
@@ -104,10 +139,57 @@ var app = builder.Build();
|
|||||||
// hochfährt, soll nicht erst eine Migration von Hand anstossen müssen.
|
// hochfährt, soll nicht erst eine Migration von Hand anstossen müssen.
|
||||||
using (var bereich = app.Services.CreateScope())
|
using (var bereich = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
await bereich.ServiceProvider.GetRequiredService<FlaroContext>().Database.MigrateAsync();
|
var db = bereich.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||||
|
|
||||||
|
// **WAL, und zwar vor der Migration.**
|
||||||
|
//
|
||||||
|
// In der Vorgabe („delete") sperrt ein Schreiber die ganze Datei: solange
|
||||||
|
// der Scan schreibt, kommt *keine* Leseanfrage durch. Genau so sah es in
|
||||||
|
// der Oberfläche aus — Bilddialog und Protokoll liefen während eines
|
||||||
|
// Laufs sporadisch ins Leere. Mit WAL lesen beliebig viele weiter,
|
||||||
|
// während einer schreibt.
|
||||||
|
//
|
||||||
|
// Die Einstellung hält sich in der Datei; einmal gesetzt bleibt sie. Sie
|
||||||
|
// trotzdem bei jedem Start zu setzen kostet nichts und deckt den Fall ab,
|
||||||
|
// dass jemand mit einer frischen Datei kommt.
|
||||||
|
await db.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;");
|
||||||
|
|
||||||
|
// Ohne das wartet SQLite nach jeder Schreibung auf die Platte. Mit WAL
|
||||||
|
// ist „normal" die übliche Wahl: ein Absturz des Prozesses kostet nichts,
|
||||||
|
// nur ein Stromausfall mitten im Schreiben könnte die letzte Transaktion
|
||||||
|
// kosten — und die ist bei einem Medienserver ein Scanfortschritt.
|
||||||
|
await db.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;");
|
||||||
|
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseExceptionHandler();
|
// **Auch das Letzte, was durchrutscht, spricht Deutsch.**
|
||||||
|
//
|
||||||
|
// Die Vorgabe von ASP.NET antwortet mit „An error occurred while processing
|
||||||
|
// your request." — englisch, ohne Grund, ohne nächsten Schritt. Genau das
|
||||||
|
// stand im Zuordnungsdialog, als eine Kennung doppelt vergeben werden
|
||||||
|
// sollte. Hier bekommt jede Ausnahme, die es bis nach oben schafft, einen
|
||||||
|
// deutschen Satz; die Einzelheiten gehen ins Tagesprotokoll, wo man sie
|
||||||
|
// sucht.
|
||||||
|
app.UseExceptionHandler(zweig => zweig.Run(async zusammenhang =>
|
||||||
|
{
|
||||||
|
var fehler = zusammenhang.Features
|
||||||
|
.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>()?.Error;
|
||||||
|
|
||||||
|
if (fehler is not null)
|
||||||
|
{
|
||||||
|
zusammenhang.RequestServices.GetRequiredService<Tagebuch>().Schreiben(
|
||||||
|
$"{zusammenhang.Request.Method} {zusammenhang.Request.Path}: "
|
||||||
|
+ Fehlertext.FuersProtokoll(fehler),
|
||||||
|
Tagebuch.Art.Fehler);
|
||||||
|
}
|
||||||
|
|
||||||
|
zusammenhang.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||||
|
await Results.Problem(
|
||||||
|
fehler is null ? "Etwas ist schiefgegangen." : Fehlertext.FuerMenschen(fehler),
|
||||||
|
statusCode: StatusCodes.Status500InternalServerError)
|
||||||
|
.ExecuteAsync(zusammenhang);
|
||||||
|
}));
|
||||||
app.UseStatusCodePages();
|
app.UseStatusCodePages();
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
|
|
||||||
@@ -173,6 +255,10 @@ if (Directory.Exists(oberfläche))
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// **Nach der Anmeldung, vor den Endpunkten.** Davor stünde im Principal noch
|
||||||
|
// nichts, und ein Administrator sähe seine eigene Prüfliste nicht.
|
||||||
|
app.UseFreigabefilter();
|
||||||
|
|
||||||
app.MapSystem();
|
app.MapSystem();
|
||||||
app.MapAuth();
|
app.MapAuth();
|
||||||
app.MapManagement();
|
app.MapManagement();
|
||||||
@@ -180,8 +266,14 @@ app.MapLibraryContent();
|
|||||||
app.MapSettings();
|
app.MapSettings();
|
||||||
app.MapImages();
|
app.MapImages();
|
||||||
app.MapDelivery();
|
app.MapDelivery();
|
||||||
|
app.MapStream();
|
||||||
app.MapPlayback();
|
app.MapPlayback();
|
||||||
app.MapMatching();
|
app.MapMatching();
|
||||||
|
app.MapArtwork();
|
||||||
|
app.MapProfil();
|
||||||
|
app.MapFreigabe();
|
||||||
|
app.MapMeldungen();
|
||||||
|
app.MapEinladungen();
|
||||||
|
|
||||||
// **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile
|
// **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
|
// fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die
|
||||||
@@ -208,6 +300,22 @@ if (Directory.Exists(oberfläche))
|
|||||||
}).AllowAnonymous();
|
}).AllowAnonymous();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Start und Ende gehören ins Protokoll.**
|
||||||
|
//
|
||||||
|
// Ohne das war nicht zu unterscheiden, ob der Server während eines langen
|
||||||
|
// Scans nur beschäftigt oder zwischendurch neu gestartet war — und genau das
|
||||||
|
// macht den Unterschied zwischen „dauert eben" und „stürzt ab". Steht mitten
|
||||||
|
// im Scan ein Start, weiss man es beim nächsten Blick ins Protokoll.
|
||||||
|
{
|
||||||
|
var tagebuch = app.Services.GetRequiredService<Tagebuch>();
|
||||||
|
var leben = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||||
|
|
||||||
|
leben.ApplicationStarted.Register(() =>
|
||||||
|
tagebuch.Schreiben($"Server gestartet — {Buildkennung.Beides}"));
|
||||||
|
leben.ApplicationStopping.Register(() =>
|
||||||
|
tagebuch.Schreiben("Server fährt herunter."));
|
||||||
|
}
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
/// <summary>Damit die Tests den Server hochfahren können.</summary>
|
/// <summary>Damit die Tests den Server hochfahren können.</summary>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Flaro.Core.Library;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
|
|
||||||
@@ -9,7 +10,15 @@ public sealed record ScanState(
|
|||||||
DateTimeOffset? FinishedAt,
|
DateTimeOffset? FinishedAt,
|
||||||
ScanReport? Report,
|
ScanReport? Report,
|
||||||
EnrichReport? Enrichment,
|
EnrichReport? Enrichment,
|
||||||
string? Error);
|
string? Error,
|
||||||
|
/// <summary>Woran der Lauf gerade ist — nur der letzte Schritt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Nur der letzte, nicht eine Liste.</b> Der Zustand liegt im
|
||||||
|
/// Arbeitsspeicher, und eine wachsende Liste über vierzigtausend Titel
|
||||||
|
/// wäre ein Leck. Wer die Vorgeschichte braucht, liest das Protokoll.
|
||||||
|
/// </remarks>
|
||||||
|
Scanschritt? Schritt = null);
|
||||||
|
|
||||||
/// <summary>Lässt Scans im Hintergrund laufen und weiss, wie weit sie sind.</summary>
|
/// <summary>Lässt Scans im Hintergrund laufen und weiss, wie weit sie sind.</summary>
|
||||||
///
|
///
|
||||||
@@ -47,7 +56,20 @@ public sealed class ScanCoordinator(
|
|||||||
public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running";
|
public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running";
|
||||||
|
|
||||||
/// <summary>Startet einen Lauf — oder sagt, dass schon einer läuft.</summary>
|
/// <summary>Startet einen Lauf — oder sagt, dass schon einer läuft.</summary>
|
||||||
public bool TryStart(Guid libraryId, bool erneut = false)
|
///
|
||||||
|
/// <param name="nurSprache">
|
||||||
|
/// Nur Titel anfassen, die nicht in dieser Sprache vorliegen. Dann wird
|
||||||
|
/// auch <b>nicht gescannt</b>: nach einer Sprachumstellung hat sich am
|
||||||
|
/// Dateisystem nichts geändert, und 5884 Dateien noch einmal abzulaufen
|
||||||
|
/// kostet Minuten für nichts.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="nurDiese">
|
||||||
|
/// <b>Nur diese Titel auffrischen</b> — für „Metadaten neu holen" an einer
|
||||||
|
/// Auswahl in der Mediathek. <c>null</c> heisst: der ganze Bestand.
|
||||||
|
/// </param>
|
||||||
|
public bool TryStart(
|
||||||
|
Guid libraryId, bool erneut = false, string? nurSprache = null,
|
||||||
|
IReadOnlySet<Guid>? nurDiese = null)
|
||||||
{
|
{
|
||||||
var start = new ScanState("running", clock.GetUtcNow(), null, null, null, null);
|
var start = new ScanState("running", clock.GetUtcNow(), null, null, null, null);
|
||||||
|
|
||||||
@@ -59,17 +81,47 @@ public sealed class ScanCoordinator(
|
|||||||
// Lief schon einer, steht hier dessen Zustand und nicht unserer.
|
// Lief schon einer, steht hier dessen Zustand und nicht unserer.
|
||||||
if (!ReferenceEquals(vorher, start)) return false;
|
if (!ReferenceEquals(vorher, start)) return false;
|
||||||
|
|
||||||
_ = Task.Run(() => Laufen(libraryId, erneut));
|
_ = Task.Run(() => Laufen(libraryId, erneut, nurSprache, nurDiese));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Laufen(Guid libraryId, bool erneut)
|
private async Task Laufen(
|
||||||
|
Guid libraryId, bool erneut, string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var bereich = scopes.CreateScope();
|
using var bereich = scopes.CreateScope();
|
||||||
var scanner = bereich.ServiceProvider.GetRequiredService<LibraryScanner>();
|
|
||||||
var bericht = await scanner.ScanAsync(libraryId);
|
// **Jeder Schritt landet im Zustand und im Protokoll.** Der
|
||||||
|
// Zustand ist für die Oberfläche, das Protokoll für die Frage
|
||||||
|
// „was war gestern Nacht?" — die ein Zustand im Arbeitsspeicher
|
||||||
|
// nach einem Neustart nicht mehr beantworten kann.
|
||||||
|
var tagebuch = bereich.ServiceProvider.GetRequiredService<Tagebuch>();
|
||||||
|
var melder = new Progress<Scanschritt>(schritt =>
|
||||||
|
{
|
||||||
|
tagebuch.Schreiben(schritt.Text());
|
||||||
|
|
||||||
|
// **Vermerke stehen nur in der Datei.** Sie erklären, warum
|
||||||
|
// etwas so zugeordnet wurde — in der Fortschrittszeile wäre das
|
||||||
|
// Flackern, in der Tagesdatei ist es die Antwort auf „wieso 737
|
||||||
|
// Serien?".
|
||||||
|
if (schritt.Phase == Scanphase.Vermerk) return;
|
||||||
|
|
||||||
|
_zustand.AddOrUpdate(libraryId,
|
||||||
|
_ => new ScanState("running", clock.GetUtcNow(), null, null, null, null, schritt),
|
||||||
|
(_, alt) => alt with { Schritt = schritt });
|
||||||
|
});
|
||||||
|
|
||||||
|
// **Nach einer Sprachumstellung wird nicht gescannt.** Am
|
||||||
|
// Dateisystem hat sich nichts geändert; 5884 Dateien noch einmal
|
||||||
|
// abzulaufen kostete Minuten für nichts. Dasselbe gilt, wenn
|
||||||
|
// jemand eine Handvoll Titel ausgewählt hat.
|
||||||
|
ScanReport? bericht = null;
|
||||||
|
if (nurSprache is null && nurDiese is null)
|
||||||
|
{
|
||||||
|
var scanner = bereich.ServiceProvider.GetRequiredService<LibraryScanner>();
|
||||||
|
bericht = await scanner.ScanAsync(libraryId, melder);
|
||||||
|
}
|
||||||
|
|
||||||
// Der Scan ist durch, die Dateien stehen in der Mediathek. Was
|
// Der Scan ist durch, die Dateien stehen in der Mediathek. Was
|
||||||
// jetzt noch kommt, kann scheitern, ohne dass davon etwas verloren
|
// jetzt noch kommt, kann scheitern, ohne dass davon etwas verloren
|
||||||
@@ -77,23 +129,54 @@ public sealed class ScanCoordinator(
|
|||||||
_zustand[libraryId] = _zustand[libraryId] with { Status = "enriching", Report = bericht };
|
_zustand[libraryId] = _zustand[libraryId] with { Status = "enriching", Report = bericht };
|
||||||
|
|
||||||
var metadaten = await bereich.ServiceProvider
|
var metadaten = await bereich.ServiceProvider
|
||||||
.GetRequiredService<MetadataEnricher>().EnrichAsync(libraryId, erneut);
|
.GetRequiredService<MetadataEnricher>()
|
||||||
|
.EnrichAsync(libraryId, erneut, melder, nurSprache: nurSprache, nurDiese: nurDiese);
|
||||||
|
|
||||||
|
// **Erst jetzt lassen sich Dubletten überhaupt erkennen.**
|
||||||
|
// Beim Scan heisst der eine Eintrag „Ich einfach unverbesserlich"
|
||||||
|
// und der andere „Despicable Me"; dass es derselbe Film ist,
|
||||||
|
// verrät allein die TMDB-Kennung, und die steht erst nach den
|
||||||
|
// Metadaten fest.
|
||||||
|
//
|
||||||
|
// **Nicht bei einer Auswahl.** Wer zehn Titel auffrischt, erwartet
|
||||||
|
// nicht, dass nebenbei anderswo zwei Einträge verschwinden — und
|
||||||
|
// genau das sähe aus wie ein verlorener Film.
|
||||||
|
var zusammengelegt = nurDiese is null
|
||||||
|
? await bereich.ServiceProvider.GetRequiredService<Zusammenlegung>().AlleAsync(libraryId)
|
||||||
|
: Zusammenlegungsbericht.Nichts;
|
||||||
|
|
||||||
|
if (zusammengelegt.Gesamt > 0)
|
||||||
|
{
|
||||||
|
log.LogInformation(
|
||||||
|
"Zusammengelegt: {Filme} Film(e), {Serien} Serie(n) der Bibliothek {Kennung}.",
|
||||||
|
zusammengelegt.Filme, zusammengelegt.Serien, libraryId);
|
||||||
|
}
|
||||||
|
|
||||||
_zustand[libraryId] = _zustand[libraryId] with
|
_zustand[libraryId] = _zustand[libraryId] with
|
||||||
{
|
{
|
||||||
Status = "finished",
|
Status = "finished",
|
||||||
FinishedAt = clock.GetUtcNow(),
|
FinishedAt = clock.GetUtcNow(),
|
||||||
Enrichment = metadaten,
|
Enrichment = metadaten,
|
||||||
|
Schritt = null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception fehler)
|
catch (Exception fehler)
|
||||||
{
|
{
|
||||||
|
// **Zwei Texte, und zwar absichtlich.** Ins Protokoll geht alles,
|
||||||
|
// bis zur innersten Ausnahme — dort wird gesucht. In die
|
||||||
|
// Oberfläche geht ein deutscher Satz ohne Fehlercode; dort wird
|
||||||
|
// nicht gesucht, dort wird gelesen.
|
||||||
log.LogError(fehler, "Scan der Bibliothek {Kennung} ist gescheitert.", libraryId);
|
log.LogError(fehler, "Scan der Bibliothek {Kennung} ist gescheitert.", libraryId);
|
||||||
|
using (var bereich = scopes.CreateScope())
|
||||||
|
{
|
||||||
|
bereich.ServiceProvider.GetRequiredService<Tagebuch>().Schreiben(
|
||||||
|
$"Scan gescheitert: {Fehlertext.FuersProtokoll(fehler)}", Tagebuch.Art.Fehler);
|
||||||
|
}
|
||||||
_zustand[libraryId] = _zustand[libraryId] with
|
_zustand[libraryId] = _zustand[libraryId] with
|
||||||
{
|
{
|
||||||
Status = "failed",
|
Status = "failed",
|
||||||
FinishedAt = clock.GetUtcNow(),
|
FinishedAt = clock.GetUtcNow(),
|
||||||
Error = fehler.Message,
|
Error = Fehlertext.FuerMenschen(fehler),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Flaro.Core.Delivery;
|
||||||
|
using Flaro.Core.Library;
|
||||||
|
using Flaro.Data;
|
||||||
|
using Flaro.Media;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Flaro.Api;
|
||||||
|
|
||||||
|
/// <summary>Eine laufende Wiedergabe mit ihrem ffmpeg dahinter.</summary>
|
||||||
|
///
|
||||||
|
/// <param name="Ab">
|
||||||
|
/// Ab welcher Sekunde des Films diese Sitzung beginnt. <b>Die Zeitachse des
|
||||||
|
/// Spielers fängt bei null an</b>, nicht beim Film — wer ab Minute 30
|
||||||
|
/// einsteigt, dessen Spieler steht bei 0, während der Film bei 1800 steht.
|
||||||
|
/// Ohne diesen Wert ginge jeder gemeldete Stand um eine halbe Stunde daneben.
|
||||||
|
/// </param>
|
||||||
|
public sealed record Stromsitzung(
|
||||||
|
Guid Kennung,
|
||||||
|
Guid DateiId,
|
||||||
|
Guid Benutzer,
|
||||||
|
string Ordner,
|
||||||
|
double Ab,
|
||||||
|
int? Hoehe,
|
||||||
|
int? Tonspur,
|
||||||
|
Stromart Art,
|
||||||
|
DateTimeOffset Begonnen)
|
||||||
|
{
|
||||||
|
/// <summary>Wann zuletzt ein Segment abgeholt wurde.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Daran hängt das Aufräumen.</b> Ein geschlossener Browser sagt
|
||||||
|
/// niemandem Bescheid; bliebe die Sitzung stehen, liefe ffmpeg weiter und
|
||||||
|
/// schriebe den ganzen Film auf den Kratzplatz.
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset Gesehen { get; set; } = Begonnen;
|
||||||
|
|
||||||
|
public string? Fehler { get; set; }
|
||||||
|
public bool Beendet { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verwaltet laufende Ströme: starten, bedienen, aufräumen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Neben <see cref="PackageCoordinator"/>, nicht an seiner Stelle.</b> Der
|
||||||
|
/// baut eine fertige Datei zum Mitnehmen — mit Länge, Bereichsanfragen und
|
||||||
|
/// Fortsetzen nach einem Abbruch; das bleibt für die Apps richtig. Hier geht
|
||||||
|
/// es um etwas anderes: der erste Ton soll nach Sekunden kommen, nicht nach
|
||||||
|
/// Minuten.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Eine Sitzung je Wiedergabe, nicht je Datei.</b> Zwei Leute können
|
||||||
|
/// denselben Film in verschiedenen Auflösungen und mit verschiedenen
|
||||||
|
/// Tonspuren sehen. Das nach Dateikennung zu führen hiesse, dass der zweite
|
||||||
|
/// bekommt, was der erste gewählt hat.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class Streamkoordinator(
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
FlaroPaths pfade,
|
||||||
|
FfmpegOptions ffmpeg,
|
||||||
|
Schluesselbilder schluesselbilder,
|
||||||
|
TimeProvider clock,
|
||||||
|
ILogger<Streamkoordinator> log) : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<Guid, Eintrag> _sitzungen = new();
|
||||||
|
|
||||||
|
private sealed record Eintrag(Stromsitzung Sitzung, Process Prozess, CancellationTokenSource Abbruch);
|
||||||
|
|
||||||
|
/// <summary>Nach dieser Stille gilt eine Sitzung als verlassen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Grosszügiger als ein Segment lang.</b> Wer pausiert, holt eine
|
||||||
|
/// Weile nichts ab; ihn nach zwanzig Sekunden abzuwürgen wäre
|
||||||
|
/// Sparsamkeit an der falschen Stelle. Zwei Minuten reichen für eine
|
||||||
|
/// Pause und sind kurz genug, dass ein geschlossener Browser nicht
|
||||||
|
/// stundenlang Platten füllt.
|
||||||
|
/// </remarks>
|
||||||
|
public static readonly TimeSpan Geduld = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
/// <summary>Wie viele Ströme gleichzeitig laufen dürfen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Weil Umkodieren einen ganzen Rechner beschäftigt.</b> Vier
|
||||||
|
/// gleichzeitige 4K-Läufe bringen jedes NAS zum Stehen — und dann
|
||||||
|
/// ruckelt nicht einer, sondern alle vier.
|
||||||
|
/// </remarks>
|
||||||
|
private const int HoechstensGleichzeitig = 6;
|
||||||
|
|
||||||
|
public int LaufendeAnzahl => _sitzungen.Count;
|
||||||
|
|
||||||
|
public Stromsitzung? Sitzung(Guid kennung) => _sitzungen.GetValueOrDefault(kennung)?.Sitzung;
|
||||||
|
|
||||||
|
/// <summary>Startet einen Strom und gibt die Sitzung zurück.</summary>
|
||||||
|
/// <param name="erzwingen">
|
||||||
|
/// Umkodieren, auch wenn das Profil etwas anderes sagt — der Spieler hat
|
||||||
|
/// es mit dem ersten Plan probiert und ist gescheitert.
|
||||||
|
/// </param>
|
||||||
|
public async Task<Stromsitzung> StartenAsync(
|
||||||
|
Guid dateiId, Guid benutzer, DeviceProfile profil,
|
||||||
|
int? hoehe, int? tonspur, double ab, CancellationToken ct = default,
|
||||||
|
bool erzwingen = false, double tonversatz = 0)
|
||||||
|
{
|
||||||
|
AbgelaufeneWegraeumen();
|
||||||
|
|
||||||
|
if (_sitzungen.Count >= HoechstensGleichzeitig)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Es laufen bereits {HoechstensGleichzeitig} Wiedergaben, die umgerechnet werden müssen. "
|
||||||
|
+ "Warte, bis eine davon zu Ende ist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var bereich = scopes.CreateScope();
|
||||||
|
var db = bereich.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||||
|
|
||||||
|
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == dateiId, ct)
|
||||||
|
?? throw new InvalidOperationException("Diese Datei steht nicht mehr in der Mediathek.");
|
||||||
|
|
||||||
|
if (!File.Exists(datei.Path))
|
||||||
|
throw new InvalidOperationException($"„{Path.GetFileName(datei.Path)}“ liegt nicht mehr da.");
|
||||||
|
|
||||||
|
var spuren = await db.MediaStreams.AsNoTracking()
|
||||||
|
.Where(s => s.MediaFileId == dateiId).OrderBy(s => s.Index)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var gelesen = spuren.Select(s => new ProbedStream(
|
||||||
|
s.Index, s.Kind, s.Codec, s.Language, s.Title,
|
||||||
|
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)).ToList();
|
||||||
|
|
||||||
|
var plan = Stromentscheid.Fuer(datei.Container, gelesen, profil, hoehe, erzwingen);
|
||||||
|
|
||||||
|
var kennung = Guid.CreateVersion7();
|
||||||
|
var ordner = Path.Combine(pfade.Transcode, "stroeme", kennung.ToString("N"));
|
||||||
|
Directory.CreateDirectory(ordner);
|
||||||
|
|
||||||
|
// Die eingestellte Stufe aus demselben Bereich — ein Lesevorgang, und
|
||||||
|
// er steht hier, weil nur hier feststeht, dass überhaupt kodiert wird.
|
||||||
|
var stufe = await bereich.ServiceProvider
|
||||||
|
.GetRequiredService<Wiedergabeeinstellungen>().StufeAsync(ct);
|
||||||
|
|
||||||
|
// **Beim Kopieren wird auf das Schlüsselbild gerundet.**
|
||||||
|
//
|
||||||
|
// Ein kopierter Bildstrom lässt sich nicht mitten in einer Bildgruppe
|
||||||
|
// aufschneiden; ffmpeg beginnt beim Schlüsselbild davor, während der
|
||||||
|
// Ton neu gerechnet wird und genau beim Wunsch anfängt. Gemessen an
|
||||||
|
// einer Quelle mit Schlüsselbildern alle fünf Sekunden, Sprung auf
|
||||||
|
// Sekunde 12: Bild ab 0, Ton erst ab 1,98 — zwei Sekunden stummes
|
||||||
|
// Bild, und die Uhr des Spielers zwei Sekunden daneben.
|
||||||
|
//
|
||||||
|
// Beim Umkodieren entfällt das: dort wird das Bild neu gerechnet und
|
||||||
|
// schneidet auf die Sekunde genau. Dann vorzurunden hiesse, ohne Not
|
||||||
|
// ein Stück zurückzuspringen.
|
||||||
|
// **Gesprungen wird auf den Wunsch, gemeldet wird das Schlüsselbild.**
|
||||||
|
//
|
||||||
|
// Beim Kopieren springt ffmpeg ungenau — siehe `HlsArgumente` — und
|
||||||
|
// landet damit auf dem Schlüsselbild vor dem Wunsch. Bild und Ton
|
||||||
|
// fangen dort gemeinsam an; das ist die Zusage, und sie hängt an
|
||||||
|
// keiner Rechnung hier.
|
||||||
|
//
|
||||||
|
// Wo genau das war, sagt die Suche. Sie ist dafür da, dem Spieler
|
||||||
|
// eine ehrliche Uhr zu geben — nicht dafür, die Ausrichtung
|
||||||
|
// herzustellen. Vergreift sie sich, geht die Uhr falsch; Bild und Ton
|
||||||
|
// bleiben zusammen. Umgekehrt wäre es ein schlechter Tausch: einen
|
||||||
|
// Sprungpunkt auf die Millisekunde zu treffen, ist zu viel verlangt.
|
||||||
|
var gewuenscht = Math.Max(0, ab);
|
||||||
|
var gemeldet = plan.BildKopieren
|
||||||
|
? await schluesselbilder.DavorAsync(datei.Path, gewuenscht, ct)
|
||||||
|
: gewuenscht;
|
||||||
|
|
||||||
|
var anfrage = new Stromanfrage(
|
||||||
|
datei.Path, ordner, gelesen, plan, tonspur, gewuenscht, Hardware(), stufe, tonversatz);
|
||||||
|
|
||||||
|
// **Die tatsächlich benutzte Spur, nicht die gewünschte.** Sonst
|
||||||
|
// stünde im Menü bei keiner ein Haken, obwohl eine davon läuft — und
|
||||||
|
// bei einer Spur, die es nicht mehr gibt, bei der falschen.
|
||||||
|
var sitzung = new Stromsitzung(
|
||||||
|
kennung, dateiId, benutzer, ordner, gemeldet,
|
||||||
|
plan.Hoehe, HlsArgumente.Tonspur(gelesen, tonspur)?.Index, plan.Art, clock.GetUtcNow());
|
||||||
|
|
||||||
|
var prozess = Starten(anfrage, sitzung);
|
||||||
|
var abbruch = new CancellationTokenSource();
|
||||||
|
_sitzungen[kennung] = new Eintrag(sitzung, prozess, abbruch);
|
||||||
|
|
||||||
|
log.LogInformation(
|
||||||
|
"Strom {Kennung}: {Art} von {Datei} ab {Ab}s{Hoehe}.",
|
||||||
|
kennung, plan.Art, Path.GetFileName(datei.Path), sitzung.Ab,
|
||||||
|
plan.Hoehe is { } h ? $", auf {h}p" : "");
|
||||||
|
|
||||||
|
return sitzung;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hält eine Sitzung am Leben — bei jedem abgeholten Segment.</summary>
|
||||||
|
public void Lebenszeichen(Guid kennung)
|
||||||
|
{
|
||||||
|
if (_sitzungen.TryGetValue(kennung, out var eintrag))
|
||||||
|
eintrag.Sitzung.Gesehen = clock.GetUtcNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Beendet eine Sitzung und räumt ihren Ordner weg.</summary>
|
||||||
|
public void Beenden(Guid kennung)
|
||||||
|
{
|
||||||
|
if (!_sitzungen.TryRemove(kennung, out var eintrag)) return;
|
||||||
|
Abraeumen(eintrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Beendet, was niemand mehr abholt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Beim Starten einer neuen Sitzung, nicht in einem eigenen Takt.</b>
|
||||||
|
/// Ein Hintergrunddienst für einen Handgriff, der ohnehin selten
|
||||||
|
/// geschieht, wäre ein Faden mehr, der schiefgehen kann.
|
||||||
|
/// </remarks>
|
||||||
|
public void AbgelaufeneWegraeumen()
|
||||||
|
{
|
||||||
|
var grenze = clock.GetUtcNow() - Geduld;
|
||||||
|
foreach (var (kennung, eintrag) in _sitzungen)
|
||||||
|
{
|
||||||
|
if (eintrag.Sitzung.Gesehen > grenze) continue;
|
||||||
|
if (!_sitzungen.TryRemove(kennung, out var weg)) continue;
|
||||||
|
|
||||||
|
log.LogInformation("Strom {Kennung} wurde seit {Geduld} nicht abgeholt — beendet.",
|
||||||
|
kennung, Geduld);
|
||||||
|
Abraeumen(weg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ob die Playlist schon etwas enthält, das sich abspielen lässt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Die Datei allein genügt nicht.</b> ffmpeg legt sie an, bevor das
|
||||||
|
/// erste Segment fertig ist; ein Spieler, der sie dann bekommt, sieht
|
||||||
|
/// eine leere Liste und gibt auf.
|
||||||
|
/// </remarks>
|
||||||
|
public static string? Playlist(string ordner)
|
||||||
|
{
|
||||||
|
var pfad = Path.Combine(ordner, HlsArgumente.Playlist);
|
||||||
|
if (!File.Exists(pfad)) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// `ReadAllText` auf einer Datei, in die ffmpeg gerade schreibt:
|
||||||
|
// deshalb ausdrücklich teilend geöffnet, sonst gäbe es hier je
|
||||||
|
// nach Zeitpunkt eine Ausnahme statt einer Antwort.
|
||||||
|
using var strom = new FileStream(
|
||||||
|
pfad, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||||
|
using var leser = new StreamReader(strom);
|
||||||
|
var text = leser.ReadToEnd();
|
||||||
|
|
||||||
|
// Ohne Segment ist die Liste wertlos — der Spieler sähe eine
|
||||||
|
// leere Playlist und gäbe auf.
|
||||||
|
return text.Contains(".m4s", StringComparison.Ordinal) ? text : null;
|
||||||
|
}
|
||||||
|
catch (IOException) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ob die Playlist schon etwas enthält, das sich abspielen lässt.</summary>
|
||||||
|
public static bool PlaylistBereit(string ordner) => Playlist(ordner) is not null;
|
||||||
|
|
||||||
|
/// <summary>Welche Beschleunigung benutzt wird.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b><c>auto</c> prüft, statt zu behaupten.</b> Liegt kein
|
||||||
|
/// <c>/dev/dri/renderD128</c> vor — auf einem Mac, in einem Container ohne
|
||||||
|
/// durchgereichtes Gerät —, läuft es in Software weiter. Langsamer, aber
|
||||||
|
/// es läuft; ein ffmpeg, das mit „No VA display found" abbricht, wäre für
|
||||||
|
/// den Zuschauer dasselbe wie ein kaputter Server.
|
||||||
|
/// </remarks>
|
||||||
|
private Beschleunigung Hardware()
|
||||||
|
{
|
||||||
|
var gewuenscht = Environment.GetEnvironmentVariable("FLARO_HWACCEL")?.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
return gewuenscht switch
|
||||||
|
{
|
||||||
|
"none" or "software" => Beschleunigung.Software,
|
||||||
|
"vaapi" => Beschleunigung.Vaapi,
|
||||||
|
_ => File.Exists("/dev/dri/renderD128") ? Beschleunigung.Vaapi : Beschleunigung.Software,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Process Starten(Stromanfrage anfrage, Stromsitzung sitzung)
|
||||||
|
{
|
||||||
|
var argumente = HlsArgumente.Fuer(anfrage);
|
||||||
|
|
||||||
|
var start = new ProcessStartInfo(ffmpeg.Ffmpeg)
|
||||||
|
{
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
foreach (var a in argumente) start.ArgumentList.Add(a);
|
||||||
|
|
||||||
|
var prozess = Process.Start(start)
|
||||||
|
?? throw new InvalidOperationException($"{ffmpeg.Ffmpeg} liess sich nicht starten.");
|
||||||
|
|
||||||
|
// **Der Fehlerkanal wird gelesen, nicht nur umgeleitet.** Läuft die
|
||||||
|
// Röhre voll, bleibt ffmpeg beim Schreiben stehen — und der Strom
|
||||||
|
// hört mitten im Film auf, ohne dass irgendwo etwas stünde.
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var text = await prozess.StandardError.ReadToEndAsync();
|
||||||
|
await prozess.WaitForExitAsync();
|
||||||
|
|
||||||
|
sitzung.Beendet = true;
|
||||||
|
if (prozess.ExitCode == 0)
|
||||||
|
{
|
||||||
|
// **Auch ein geglückter Lauf darf etwas zu sagen haben.**
|
||||||
|
// Vorher wurde der Text weggeworfen, sobald der Rückgabewert 0
|
||||||
|
// war — und genau die Meldungen, die einen Tonversatz
|
||||||
|
// ankündigen, sind Warnungen, keine Fehler. Ein Film, der
|
||||||
|
// schief klingt, aber durchläuft, hinterliess damit nirgends
|
||||||
|
// eine Spur.
|
||||||
|
if (text.Trim() is { Length: > 0 } warnungen)
|
||||||
|
{
|
||||||
|
log.LogWarning("Strom {Kennung}: ffmpeg meldet {Warnungen}",
|
||||||
|
sitzung.Kennung, warnungen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sitzung.Fehler = text.Trim();
|
||||||
|
log.LogWarning("Strom {Kennung}: ffmpeg endete mit {Code}: {Fehler}",
|
||||||
|
sitzung.Kennung, prozess.ExitCode, sitzung.Fehler);
|
||||||
|
});
|
||||||
|
|
||||||
|
_ = Task.Run(() => prozess.StandardOutput.ReadToEndAsync());
|
||||||
|
return prozess;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Abraeumen(Eintrag eintrag)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!eintrag.Prozess.HasExited) eintrag.Prozess.Kill(entireProcessTree: true);
|
||||||
|
}
|
||||||
|
catch (Exception fehler) when (fehler is InvalidOperationException or NotSupportedException)
|
||||||
|
{
|
||||||
|
log.LogDebug(fehler, "ffmpeg zu {Kennung} liess sich nicht beenden.", eintrag.Sitzung.Kennung);
|
||||||
|
}
|
||||||
|
|
||||||
|
eintrag.Prozess.Dispose();
|
||||||
|
eintrag.Abbruch.Dispose();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(eintrag.Sitzung.Ordner))
|
||||||
|
Directory.Delete(eintrag.Sitzung.Ordner, recursive: true);
|
||||||
|
}
|
||||||
|
catch (IOException fehler)
|
||||||
|
{
|
||||||
|
log.LogWarning(fehler, "Der Ordner zu Strom {Kennung} blieb liegen.", eintrag.Sitzung.Kennung);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Beim Herunterfahren bleibt nichts laufen und nichts liegen.</summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var kennung in _sitzungen.Keys) Beenden(kennung);
|
||||||
|
|
||||||
|
// **Auch, was ein Absturz hinterlassen hat.** Nach einem harten Ende
|
||||||
|
// steht der Ordner noch da; ihn beim nächsten Herunterfahren zu
|
||||||
|
// übersehen hiesse, dass der Kratzplatz nur wächst.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var wurzel = Path.Combine(pfade.Transcode, "stroeme");
|
||||||
|
if (Directory.Exists(wurzel)) Directory.Delete(wurzel, recursive: true);
|
||||||
|
}
|
||||||
|
catch (IOException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Flaro.Api;
|
||||||
|
|
||||||
|
/// <summary>Schreibt mit, was der Server tut — eine Datei je Tag.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Wofür, wenn es doch ein Containerprotokoll gibt.</b> Das ist nach einem
|
||||||
|
/// Neustart weg, und um es überhaupt zu sehen, muss man sich auf das NAS
|
||||||
|
/// verbinden. Die Frage „warum wurde der Film gestern Nacht nicht erkannt?"
|
||||||
|
/// lässt sich damit nicht beantworten. Diese Dateien liegen im
|
||||||
|
/// <c>config</c>-Volume, überleben jeden Neustart und sind aus dem Browser
|
||||||
|
/// lesbar.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Eine Datei je Tag, nach lokaler Zeit.</b> Nicht nach UTC: wer um
|
||||||
|
/// 23 Uhr einen Scan anstösst, sucht ihn am heutigen Datum, nicht am
|
||||||
|
/// morgigen. Die Zeitzone kommt aus <c>TZ</c>, so wie überall sonst im
|
||||||
|
/// Container auch.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Mit Aufbewahrungsgrenze.</b> Ohne sie läuft die <c>config</c>-Partition
|
||||||
|
/// eines NAS irgendwann voll, und zwar lautlos.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class Tagebuch
|
||||||
|
{
|
||||||
|
public enum Art { Info, Warnung, Fehler }
|
||||||
|
|
||||||
|
private readonly string _ordner;
|
||||||
|
private readonly TimeProvider _uhr;
|
||||||
|
private readonly int _tageAufbewahren;
|
||||||
|
|
||||||
|
/// <summary>Ein Schreiber je Prozess — zwei gleichzeitige Zeilen vermischen sich sonst.</summary>
|
||||||
|
private readonly Lock _schloss = new();
|
||||||
|
|
||||||
|
private DateOnly _zuletztAufgeraeumt = DateOnly.MinValue;
|
||||||
|
|
||||||
|
public Tagebuch(FlaroPaths pfade, TimeProvider uhr)
|
||||||
|
{
|
||||||
|
_ordner = Path.Combine(pfade.Data, "logs");
|
||||||
|
_uhr = uhr;
|
||||||
|
_tageAufbewahren = int.TryParse(
|
||||||
|
Environment.GetEnvironmentVariable("FLARO_LOG_TAGE"), out var tage) && tage > 0
|
||||||
|
? tage
|
||||||
|
: 30;
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_ordner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Datei für einen Tag — auch dann, wenn es sie nicht gibt.</summary>
|
||||||
|
public string DateiFuer(DateOnly tag) =>
|
||||||
|
Path.Combine(_ordner, $"flaro-{tag:yyyy-MM-dd}.log");
|
||||||
|
|
||||||
|
/// <summary>Welche Tage protokolliert sind, neueste zuerst.</summary>
|
||||||
|
public IReadOnlyList<DateOnly> Tage()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_ordner)) return [];
|
||||||
|
|
||||||
|
return Directory.EnumerateFiles(_ordner, "flaro-*.log")
|
||||||
|
.Select(p => Path.GetFileNameWithoutExtension(p)["flaro-".Length..])
|
||||||
|
.Select(t => DateOnly.TryParseExact(t, "yyyy-MM-dd", CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.None, out var tag) ? tag : (DateOnly?)null)
|
||||||
|
.Where(t => t is not null)
|
||||||
|
.Select(t => t!.Value)
|
||||||
|
.OrderByDescending(t => t)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die letzten Zeilen eines Tages — ohne die ganze Datei zu lesen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Weil das Ende interessiert, nicht der Anfang.</b> Wer ins Protokoll
|
||||||
|
/// sieht, sucht fast immer das Letzte. Die Oberfläche holte bisher die
|
||||||
|
/// ganze Datei und warf davon 95 % weg — bei 2 MB über eine wackelige
|
||||||
|
/// Verbindung endete das in „Failed to fetch", und nach einem langen Scan
|
||||||
|
/// wären es Dutzende Megabyte.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Rückwärts in Blöcken gelesen, nicht Zeile für Zeile von vorn: sonst
|
||||||
|
/// kostet die Ansicht dasselbe wie das Herunterladen, nur ohne den Nutzen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public string Ende(DateOnly tag, int zeilen)
|
||||||
|
{
|
||||||
|
var datei = DateiFuer(tag);
|
||||||
|
if (!File.Exists(datei)) return "";
|
||||||
|
|
||||||
|
const int block = 64 * 1024;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var strom = new FileStream(
|
||||||
|
datei, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||||
|
|
||||||
|
var gefunden = 0;
|
||||||
|
var position = strom.Length;
|
||||||
|
var puffer = new byte[block];
|
||||||
|
|
||||||
|
while (position > 0 && gefunden <= zeilen)
|
||||||
|
{
|
||||||
|
var lesen = (int)Math.Min(block, position);
|
||||||
|
position -= lesen;
|
||||||
|
strom.Position = position;
|
||||||
|
strom.ReadExactly(puffer, 0, lesen);
|
||||||
|
|
||||||
|
for (var i = lesen - 1; i >= 0 && gefunden <= zeilen; i--)
|
||||||
|
{
|
||||||
|
if (puffer[i] != (byte)'\n') continue;
|
||||||
|
gefunden++;
|
||||||
|
// Eine Zeile zu viel zählen und danach die angebrochene
|
||||||
|
// erste wegwerfen — sonst beginnt die Ausgabe mitten im
|
||||||
|
// Satz.
|
||||||
|
if (gefunden > zeilen) position += i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
strom.Position = position;
|
||||||
|
using var leser = new StreamReader(strom, Encoding.UTF8);
|
||||||
|
return leser.ReadToEnd();
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hängt eine Zeile an das Protokoll des heutigen Tages.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Wirft nie.</b> Ein volles Dateisystem oder ein schreibgeschütztes
|
||||||
|
/// Volume darf einen Scan nicht abbrechen — das Protokoll ist Beiwerk, und
|
||||||
|
/// Beiwerk, das den Betrieb anhält, ist schlechter als keines.
|
||||||
|
/// </remarks>
|
||||||
|
public void Schreiben(string text, Art art = Art.Info)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var jetzt = _uhr.GetLocalNow();
|
||||||
|
var heute = DateOnly.FromDateTime(jetzt.DateTime);
|
||||||
|
var stufe = art switch
|
||||||
|
{
|
||||||
|
Art.Fehler => "FEHLER ",
|
||||||
|
Art.Warnung => "WARNUNG",
|
||||||
|
_ => "INFO ",
|
||||||
|
};
|
||||||
|
|
||||||
|
lock (_schloss)
|
||||||
|
{
|
||||||
|
File.AppendAllText(
|
||||||
|
DateiFuer(heute),
|
||||||
|
$"{jetzt:HH:mm:ss} {stufe} {text}{Environment.NewLine}",
|
||||||
|
Encoding.UTF8);
|
||||||
|
|
||||||
|
Aufraeumen(heute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IOException) { }
|
||||||
|
catch (UnauthorizedAccessException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Löscht, was älter ist als die Aufbewahrungsfrist — höchstens einmal je Tag.</summary>
|
||||||
|
private void Aufraeumen(DateOnly heute)
|
||||||
|
{
|
||||||
|
if (_zuletztAufgeraeumt == heute) return;
|
||||||
|
_zuletztAufgeraeumt = heute;
|
||||||
|
|
||||||
|
var grenze = heute.AddDays(-_tageAufbewahren);
|
||||||
|
foreach (var tag in Tage().Where(t => t < grenze))
|
||||||
|
{
|
||||||
|
try { File.Delete(DateiFuer(tag)); } catch (IOException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using Flaro.Core.Delivery;
|
||||||
|
using Flaro.Core.Playback;
|
||||||
|
using Flaro.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Flaro.Api;
|
||||||
|
|
||||||
|
/// <summary>Holt die Wiedergabe-Einstellungen aus der Datenbank.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Dasselbe Muster wie <see cref="DatabaseTmdbSettings"/>:</b> je Anfrage
|
||||||
|
/// einmal gelesen und dann gemerkt. Eine Fortschrittsmeldung kommt alle zehn
|
||||||
|
/// Sekunden und von jedem Gerät; dreimal dieselbe Zeile je Meldung zu lesen
|
||||||
|
/// wäre Arbeit für nichts.
|
||||||
|
///
|
||||||
|
/// <b>Serverweit, nicht je Nutzer.</b> Der Transcoder gehört der Maschine —
|
||||||
|
/// es gibt eine CPU und eine Entscheidung. Die beiden Schwellen könnten je
|
||||||
|
/// Nutzer gelten; sie tun es nicht, weil zwei Wahrheiten darüber, was
|
||||||
|
/// „angefangen" heisst, mehr kosten als sie einbringen.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class Wiedergabeeinstellungen(FlaroContext db)
|
||||||
|
{
|
||||||
|
public const string FortsetzenAbName = "wiedergabe.fortsetzen.abSekunden";
|
||||||
|
public const string GesehenAnteilName = "wiedergabe.gesehen.anteil";
|
||||||
|
public const string TranscoderName = "wiedergabe.transcoder.stufe";
|
||||||
|
|
||||||
|
private Wiedergaberegeln? _regeln;
|
||||||
|
private Kodierstufe? _stufe;
|
||||||
|
|
||||||
|
/// <summary>Wann ein Titel angefangen ist und wann er gesehen ist.</summary>
|
||||||
|
public async Task<Wiedergaberegeln> RegelnAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (_regeln is { } fertig) return fertig;
|
||||||
|
await LesenAsync(ct);
|
||||||
|
return _regeln!.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wie der Transcoder zwischen Bild und Tempo abwägt.</summary>
|
||||||
|
public async Task<Kodierstufe> StufeAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (_stufe is { } fertig) return fertig;
|
||||||
|
await LesenAsync(ct);
|
||||||
|
return _stufe!.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LesenAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var zeilen = await db.Settings
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(s => s.Key == FortsetzenAbName
|
||||||
|
|| s.Key == GesehenAnteilName
|
||||||
|
|| s.Key == TranscoderName)
|
||||||
|
.ToDictionaryAsync(s => s.Key, s => s.Value, ct);
|
||||||
|
|
||||||
|
_regeln = Lesen(zeilen.GetValueOrDefault(FortsetzenAbName),
|
||||||
|
zeilen.GetValueOrDefault(GesehenAnteilName));
|
||||||
|
_stufe = Kodierstufen.Lesen(zeilen.GetValueOrDefault(TranscoderName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Macht aus zwei gespeicherten Zeichenketten geprüfte Regeln.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Ohne Ortsangabe gerechnet</b> — <see cref="double.TryParse(string?,
|
||||||
|
/// IFormatProvider?, out double)"/> mit <see
|
||||||
|
/// cref="System.Globalization.CultureInfo.InvariantCulture"/>: in der
|
||||||
|
/// Datenbank steht ein Punkt, und auf einem Server mit deutscher
|
||||||
|
/// Spracheinstellung läse ein Komma-Parser aus „0.9" eine Neun.
|
||||||
|
/// </remarks>
|
||||||
|
public static Wiedergaberegeln Lesen(string? abSekunden, string? anteil)
|
||||||
|
{
|
||||||
|
var regeln = Wiedergaberegeln.Vorgabe;
|
||||||
|
|
||||||
|
if (double.TryParse(abSekunden, System.Globalization.NumberStyles.Float,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out var sekunden))
|
||||||
|
regeln = regeln with { FortsetzenAb = TimeSpan.FromSeconds(sekunden) };
|
||||||
|
|
||||||
|
if (double.TryParse(anteil, System.Globalization.NumberStyles.Float,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out var wert))
|
||||||
|
regeln = regeln with { GesehenAnteil = wert };
|
||||||
|
|
||||||
|
return regeln.Geprueft();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
namespace Flaro.Core.Auth;
|
||||||
|
|
||||||
|
/// <summary>Ein Link, mit dem sich jemand selbst ein Konto anlegt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum nicht der vorhandene <see cref="AuthCode"/>.</b> Der ist ein
|
||||||
|
/// Gerätecode: fünf Minuten Leben, ein Gerätename daneben, und sein
|
||||||
|
/// <c>ApprovedByUserId</c> zeigt auf ein Konto, das es schon gibt — jemand,
|
||||||
|
/// der angemeldet ist, winkt ein zweites Gerät durch. Eine Einladung ist das
|
||||||
|
/// Gegenteil: erlaubt wird vorab, und wer sie einlöst, hat noch gar kein
|
||||||
|
/// Konto. Dieselbe Tabelle für beides hiesse, die Hälfte ihrer Spalten
|
||||||
|
/// wechselweise leer zu lassen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Übernommen ist die Geheimnisdisziplin.</b> Wie bei
|
||||||
|
/// <see cref="DeviceToken"/> liegt hier nur der Fingerabdruck; den Link
|
||||||
|
/// selbst bekommt man genau einmal zu sehen, beim Erstellen. Wer die
|
||||||
|
/// Datenbank liest, kann daraus keine Einladung bauen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Keine Rolle.</b> Wer eingeladen wird, ist gewöhnlicher Benutzer. Ein
|
||||||
|
/// Feld, über das ein Link Adminrechte verschenken könnte, gibt es nicht —
|
||||||
|
/// und damit auch keinen Weg, es versehentlich zu setzen. Administratoren
|
||||||
|
/// legt man von Hand an, und das ist selten genug.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class Einladung
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
/// <summary>Siehe <see cref="Secrets.Fingerprint"/> — nie der Link selbst.</summary>
|
||||||
|
public required string TokenFingerprint { get; init; }
|
||||||
|
|
||||||
|
public required Guid CreatedByUserId { get; init; }
|
||||||
|
|
||||||
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
|
||||||
|
public required DateTimeOffset ExpiresAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Wie oft er gilt. <c>null</c> heisst: unbegrenzt.</summary>
|
||||||
|
public int? MaxUses { get; init; }
|
||||||
|
|
||||||
|
public int UsedCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Von Hand zurückgezogen — <c>null</c> heisst: gilt noch.</summary>
|
||||||
|
public DateTimeOffset? RevokedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ob sich damit jetzt noch ein Konto anlegen lässt.</summary>
|
||||||
|
public bool Gilt(DateTimeOffset jetzt) =>
|
||||||
|
RevokedAt is null
|
||||||
|
&& jetzt < ExpiresAt
|
||||||
|
&& (MaxUses is not { } hoechstens || UsedCount < hoechstens);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
namespace Flaro.Core.Delivery;
|
||||||
|
|
||||||
|
/// <summary>Wie der Transcoder zwischen Bild und Tempo abwägt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Drei Stufen, keine freie Zahl</b> — wie bei
|
||||||
|
/// <see cref="Stromentscheid.Stufen"/>. „crf 19" sagt niemandem etwas, der
|
||||||
|
/// nicht ffmpeg kennt; „Qualität" und „Tempo" sagen genau das, worum es geht.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Und es ist eine echte Abwägung.</b> Was in Echtzeit laufen muss, darf
|
||||||
|
/// nicht langsamer rechnen als es abspielt. Auf einer schwachen Maschine kann
|
||||||
|
/// „Qualität" dazu führen, dass das Bild stockt — das ist kein Fehler, das ist
|
||||||
|
/// die Stufe. Deshalb steht der Satz auch in der Oberfläche.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public enum Kodierstufe
|
||||||
|
{
|
||||||
|
/// <summary>Schöneres Bild, langsamere Rechnung.</summary>
|
||||||
|
Qualitaet,
|
||||||
|
|
||||||
|
/// <summary>Was Flaro immer getan hat.</summary>
|
||||||
|
Ausgewogen,
|
||||||
|
|
||||||
|
/// <summary>Startet schneller, kostet Bild.</summary>
|
||||||
|
Tempo,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hin und zurück zwischen Stufe und gespeichertem Wort.</summary>
|
||||||
|
public static class Kodierstufen
|
||||||
|
{
|
||||||
|
/// <summary>Unbekanntes wird ausgewogen — das war schon immer das Verhalten.</summary>
|
||||||
|
public static Kodierstufe Lesen(string? wert) => wert?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"qualitaet" or "qualität" => Kodierstufe.Qualitaet,
|
||||||
|
"tempo" => Kodierstufe.Tempo,
|
||||||
|
_ => Kodierstufe.Ausgewogen,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Wie die Stufe in der Datenbank steht.</summary>
|
||||||
|
public static string AlsWort(this Kodierstufe stufe) => stufe switch
|
||||||
|
{
|
||||||
|
Kodierstufe.Qualitaet => "qualitaet",
|
||||||
|
Kodierstufe.Tempo => "tempo",
|
||||||
|
_ => "ausgewogen",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using Flaro.Core.Library;
|
||||||
|
|
||||||
|
namespace Flaro.Core.Delivery;
|
||||||
|
|
||||||
|
/// <summary>Wie eine Datei in einen laufenden Spieler kommt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Nicht zu verwechseln mit <see cref="DeliveryMethod"/>.</b> Dort geht es
|
||||||
|
/// um eine fertige Datei zum Mitnehmen; hier um einen Strom, der sofort
|
||||||
|
/// anfängt. Beides hat dieselbe Frage — passt das Gerät zur Datei? — und ganz
|
||||||
|
/// verschiedene Antworten: eine Datei darf Minuten brauchen, ein Spieler nicht.
|
||||||
|
/// </remarks>
|
||||||
|
public enum Stromart
|
||||||
|
{
|
||||||
|
/// <summary>Die Datei geht unverändert hinaus, der Browser spielt sie direkt.</summary>
|
||||||
|
Direkt,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Umgepackt, aber nicht neu gerechnet — mkv nach fMP4, Bild Byte für Byte.
|
||||||
|
/// </summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Das ist der häufigste Fall und der billigste.</b> Eine MKV mit H.264
|
||||||
|
/// und AAC kann jeder Browser abspielen; nur die Schachtel kennt er nicht.
|
||||||
|
/// Sie zu wechseln kostet den Server fast nichts.
|
||||||
|
/// </remarks>
|
||||||
|
Umpacken,
|
||||||
|
|
||||||
|
/// <summary>Das Bild wird neu gerechnet — anderer Codec oder kleinere Auflösung.</summary>
|
||||||
|
Umkodieren,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Warum es nicht beim Durchreichen bleibt.</summary>
|
||||||
|
public enum Stromgrund
|
||||||
|
{
|
||||||
|
PasstSchon,
|
||||||
|
SchachtelUnbekannt,
|
||||||
|
BildcodecUnbekannt,
|
||||||
|
TonUnbekannt,
|
||||||
|
KleinereAufloesungGewuenscht,
|
||||||
|
|
||||||
|
/// <summary>Der Spieler hat es versucht und kam nicht zurecht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Das Urteil des Browsers schlägt seine eigene Auskunft.</b>
|
||||||
|
/// <c>canPlayType</c> ist eine Behauptung — „kenne ich" heisst nicht
|
||||||
|
/// „kann ich diese Datei". Wer es ausprobiert hat und gescheitert ist,
|
||||||
|
/// weiss mehr als jede Abfrage vorher.
|
||||||
|
/// </remarks>
|
||||||
|
SpielerKamNichtZurecht,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Was für diesen Strom zu tun ist.</summary>
|
||||||
|
///
|
||||||
|
/// <param name="Hoehe">
|
||||||
|
/// Auf wie viele Bildzeilen herunterskaliert wird — <c>null</c> heisst:
|
||||||
|
/// Originalauflösung.
|
||||||
|
/// </param>
|
||||||
|
public sealed record Streamplan(
|
||||||
|
Stromart Art,
|
||||||
|
IReadOnlyList<Stromgrund> Gruende,
|
||||||
|
int? Hoehe)
|
||||||
|
{
|
||||||
|
public bool BildKopieren => Art != Stromart.Umkodieren;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Entscheidet, was ein Strom braucht, damit der Spieler ihn öffnet.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Getrennt von <see cref="DeliveryDecider"/>, und das ist Absicht.</b> Der
|
||||||
|
/// entscheidet über eine Datei zum Herunterladen und kennt nur zwei Ausgänge:
|
||||||
|
/// durchreichen oder umpacken. Ein unbekannter Bildcodec führte dort ebenfalls
|
||||||
|
/// zum Umpacken — und das half nichts, weil Umpacken das Bild nicht anfasst.
|
||||||
|
/// Für den Browser war das der Fehler, an dem jede Wiedergabe mit HEVC
|
||||||
|
/// scheiterte: er bekam eine tadellose MP4, in der ein Codec steckte, den er
|
||||||
|
/// nicht kennt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Das Bild wird nur angefasst, wenn es sein muss.</b> Neu zu rechnen
|
||||||
|
/// kostet ein Vielfaches des Umpackens und verliert Bildqualität. Wer eine
|
||||||
|
/// kleinere Auflösung will, bekommt sie; wer nicht, bekommt das Original.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Stromentscheid
|
||||||
|
{
|
||||||
|
/// <summary>Die Stufen, die ein Spieler anbieten darf.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Keine freie Zahl.</b> Vier Stufen decken ab, wofür es Gründe gibt:
|
||||||
|
/// das Original, zwei Zwischenstufen und eine für die langsame Leitung.
|
||||||
|
/// Eine beliebige Höhe zuzulassen hiesse, für jede Zahl eine eigene
|
||||||
|
/// Sitzung zu rechnen.
|
||||||
|
/// </remarks>
|
||||||
|
public static readonly IReadOnlyList<int> Stufen = [1080, 720, 480];
|
||||||
|
|
||||||
|
/// <param name="hoeheGewuenscht">
|
||||||
|
/// Die vom Spieler gewählte Stufe, oder <c>null</c> für das Original.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="erzwingen">
|
||||||
|
/// Umkodieren, egal was das Profil sagt. <b>Für den zweiten Versuch:</b>
|
||||||
|
/// der Spieler hat es mit dem ersten Plan probiert und ist gescheitert.
|
||||||
|
/// </param>
|
||||||
|
public static Streamplan Fuer(
|
||||||
|
string? schachtel,
|
||||||
|
IReadOnlyList<ProbedStream> spuren,
|
||||||
|
DeviceProfile profil,
|
||||||
|
int? hoeheGewuenscht = null,
|
||||||
|
bool erzwingen = false)
|
||||||
|
{
|
||||||
|
var gruende = new List<Stromgrund>();
|
||||||
|
|
||||||
|
var bild = spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Video);
|
||||||
|
var bildPasst = Passt(bild?.Codec, profil.VideoCodecs);
|
||||||
|
if (!bildPasst) gruende.Add(Stromgrund.BildcodecUnbekannt);
|
||||||
|
|
||||||
|
// **Eine Stufe über der Quelle ist keine Stufe.** Ein 720p-Film in
|
||||||
|
// „1080p" abzuspielen hiesse, ihn hochzurechnen: mehr Last, mehr
|
||||||
|
// Daten, kein besseres Bild.
|
||||||
|
var hoehe = hoeheGewuenscht is { } gewuenscht
|
||||||
|
&& (bild?.Height is not { } vorhanden || gewuenscht < vorhanden)
|
||||||
|
? gewuenscht
|
||||||
|
: (int?)null;
|
||||||
|
if (hoehe is not null) gruende.Add(Stromgrund.KleinereAufloesungGewuenscht);
|
||||||
|
|
||||||
|
if (erzwingen) gruende.Add(Stromgrund.SpielerKamNichtZurecht);
|
||||||
|
|
||||||
|
if (erzwingen || !bildPasst || hoehe is not null)
|
||||||
|
return new Streamplan(Stromart.Umkodieren, gruende, hoehe);
|
||||||
|
|
||||||
|
var schachtelPasst = Passt(schachtel, profil.Containers);
|
||||||
|
if (!schachtelPasst) gruende.Add(Stromgrund.SchachtelUnbekannt);
|
||||||
|
|
||||||
|
// **Beim Ton genügt eine passende Spur.** Welche gespielt wird,
|
||||||
|
// entscheidet der Benutzer; dass eine siebenkanalige DTS-Spur daneben
|
||||||
|
// liegt, ist kein Grund, den ganzen Film anzufassen.
|
||||||
|
var tonspuren = spuren.Where(s => s.Kind == MediaStreamKind.Audio).ToList();
|
||||||
|
var tonPasst = tonspuren.Count == 0 || tonspuren.Any(s => Passt(s.Codec, profil.AudioCodecs));
|
||||||
|
if (!tonPasst) gruende.Add(Stromgrund.TonUnbekannt);
|
||||||
|
|
||||||
|
if (!schachtelPasst || !tonPasst)
|
||||||
|
return new Streamplan(Stromart.Umpacken, gruende, null);
|
||||||
|
|
||||||
|
return new Streamplan(Stromart.Direkt, [Stromgrund.PasstSchon], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Was fehlt, zählt als passend — eine Lücke ist kein Urteil.</summary>
|
||||||
|
private static bool Passt(string? wert, IReadOnlySet<string> erlaubt) =>
|
||||||
|
string.IsNullOrWhiteSpace(wert) || erlaubt.Contains(wert);
|
||||||
|
}
|
||||||
@@ -17,4 +17,27 @@ public sealed class Library
|
|||||||
public required LibraryKind Kind { get; set; }
|
public required LibraryKind Kind { get; set; }
|
||||||
public required string Path { get; set; }
|
public required string Path { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Ob neue Funde erst geprüft werden, bevor sie erscheinen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Vorgabe: aus.</b> Wer eine Mediathek anlegt, will sie sehen. Die
|
||||||
|
/// Prüfung ist für den Fall gedacht, dass Zuordnung und Bilder stimmen
|
||||||
|
/// sollen, bevor jemand im Haushalt den Film findet — oder dass etwas
|
||||||
|
/// erst zu einem bestimmten Zeitpunkt erscheinen soll.
|
||||||
|
/// </remarks>
|
||||||
|
public bool ReviewNewItems { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Wo sie in der Seitenleiste steht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Weil alphabetisch nicht immer richtig ist.</b> Wer „Filme" und
|
||||||
|
/// „Kinderfilme" und „Dokumentationen" hat, will nicht nach dem Alphabet
|
||||||
|
/// sortiert sein, sondern danach, was er am häufigsten öffnet.
|
||||||
|
///
|
||||||
|
/// <c>0</c> für alles, was nie umsortiert wurde; dann entscheidet der
|
||||||
|
/// Name. Damit bleibt eine frisch angelegte Mediathek da, wo man sie
|
||||||
|
/// erwartet, statt am Ende zu landen.
|
||||||
|
/// </remarks>
|
||||||
|
public int SortOrder { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ namespace Flaro.Core.Library;
|
|||||||
|
|
||||||
public sealed record MovieName(string Title, int? Year);
|
public sealed record MovieName(string Title, int? Year);
|
||||||
|
|
||||||
public sealed record EpisodeName(string SeriesTitle, int Season, int Episode, string? EpisodeTitle);
|
/// <param name="AusOrdner">
|
||||||
|
/// <b>Ob der Serientitel vom Ordner kommt oder aus dem Dateinamen.</b> Steht
|
||||||
|
/// nur im Protokoll — aber genau diese Angabe fehlte, als aus 95 Serien 737
|
||||||
|
/// wurden: niemand konnte sagen, welcher der beiden Wege sie angelegt hatte.
|
||||||
|
/// </param>
|
||||||
|
public sealed record EpisodeName(
|
||||||
|
string SeriesTitle, int Season, int Episode, string? EpisodeTitle, bool AusOrdner = false);
|
||||||
|
|
||||||
/// <summary>Macht aus einem Dateinamen wieder Titel, Jahr, Staffel und Folge.</summary>
|
/// <summary>Macht aus einem Dateinamen wieder Titel, Jahr, Staffel und Folge.</summary>
|
||||||
///
|
///
|
||||||
@@ -57,8 +63,27 @@ public static partial class FileNames
|
|||||||
[GeneratedRegex(@"^\d{3,4}[pi]$|^[48]k$", RegexOptions.IgnoreCase)]
|
[GeneratedRegex(@"^\d{3,4}[pi]$|^[48]k$", RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex Auflösung();
|
private static partial Regex Auflösung();
|
||||||
|
|
||||||
/// <summary>S01E02, s1e2, S01.E02, S01E02E03 — die erste Folge zählt.</summary>
|
/// <summary>S01E02, s1e2, S01.E02, Serie_S02E18, S2024E04 — die erste Folge zählt.</summary>
|
||||||
[GeneratedRegex(@"\bS(?<staffel>\d{1,3})[\s._-]*E(?<folge>\d{1,4})", RegexOptions.IgnoreCase)]
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Kein <c>\b</c> vor dem S, und das hat 3445 Dateien gekostet.</b> Eine
|
||||||
|
/// Wortgrenze sitzt zwischen einem Wortzeichen und einem Nicht-Wortzeichen
|
||||||
|
/// — der Unterstrich ist aber selbst ein Wortzeichen. In
|
||||||
|
/// <c>The Good Doctor_S02E18_Trampolin.mkv</c> steht zwischen <c>r</c> und
|
||||||
|
/// <c>S</c> damit <i>keine</i> Grenze, und der ganze Bestand aus einem
|
||||||
|
/// Mediathek-Download fiel durch. Die Rückschau verlangt stattdessen
|
||||||
|
/// ausdrücklich: kein Buchstabe, keine Ziffer davor — ein Unterstrich
|
||||||
|
/// schon.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Vier Ziffern für die Staffel.</b> Sendungen aus Mediatheken
|
||||||
|
/// nummerieren nach Jahr: <c>Die Anstalt_S2024E04</c>. Mit drei Ziffern
|
||||||
|
/// fiel jede davon durch.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[GeneratedRegex(@"(?<![A-Za-z0-9])S(?<staffel>\d{1,4})[\s._-]*E(?<folge>\d{1,4})",
|
||||||
|
RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex StaffelUndFolge();
|
private static partial Regex StaffelUndFolge();
|
||||||
|
|
||||||
/// <summary>1x02, 01x02 — die ältere Schreibweise.</summary>
|
/// <summary>1x02, 01x02 — die ältere Schreibweise.</summary>
|
||||||
@@ -80,6 +105,115 @@ public static partial class FileNames
|
|||||||
return new MovieName(titel.Length > 0 ? titel : Rohname(dateiname), jahrAusKlammern ?? jahr);
|
return new MovieName(titel.Length > 0 ? titel : Rohname(dateiname), jahrAusKlammern ?? jahr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Name der Fassung — oder <c>null</c>, wenn der Name keine nennt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Eine Fassung ist kein eigener Film.</b> Die mobile Fassung, die
|
||||||
|
/// 4K-Fassung und das Original gehören zu einem Eintrag mit drei Dateien.
|
||||||
|
/// <see cref="ParseMovie"/> wirft den Zusatz deshalb weg — hier wird er
|
||||||
|
/// gelesen, damit die Dateien in der Auswahl Namen tragen statt Pfade.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Drei Schreibweisen, in dieser Reihenfolge: die Plex-Form
|
||||||
|
/// <c>{edition-Mobil}</c>, ein Zusatz hinter dem letzten Bindestrich
|
||||||
|
/// (<c>Film (2021) - Mobil.mp4</c>), und ein Elternordner, der nur die
|
||||||
|
/// Fassung benennt (<c>Mobil/Film (2021).mp4</c>).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Findet sich nichts, bleibt es bei <c>null</c> — dann bildet der Aufrufer
|
||||||
|
/// den Namen aus der gemessenen Auflösung. Zu raten wäre hier schlecht:
|
||||||
|
/// „Directors Cut" ist eine Fassung, „GRP" ist eine Veröffentlichungsgruppe.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static string? Fassung(string pfad)
|
||||||
|
{
|
||||||
|
var roh = Rohname(pfad);
|
||||||
|
|
||||||
|
var klammer = EditionInKlammern().Match(roh);
|
||||||
|
if (klammer.Success) return Saeubern(klammer.Groups["name"].Value);
|
||||||
|
|
||||||
|
var strich = roh.LastIndexOf(" - ", StringComparison.Ordinal);
|
||||||
|
if (strich > 0)
|
||||||
|
{
|
||||||
|
var hinten = Saeubern(roh[(strich + 3)..]);
|
||||||
|
if (hinten.Length > 0 && IstFassungsname(hinten)) return Vereinheitlichen(hinten);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Drei Ebenen hoch, nicht nur eine.** Plex legt seine zweite Fassung
|
||||||
|
// als `Plex Versions/Optimized for Mobile/<Serie>/<Staffel>/…` ab —
|
||||||
|
// der Ordner, der die Fassung benennt, steht dort also weit über der
|
||||||
|
// Datei. Drei Ebenen decken das ab, ohne irgendwann die Mediathek
|
||||||
|
// selbst für eine Fassung zu halten.
|
||||||
|
var ordner = Path.GetDirectoryName(pfad);
|
||||||
|
for (var ebene = 0; ebene < 3 && !string.IsNullOrEmpty(ordner); ebene++)
|
||||||
|
{
|
||||||
|
var name = Path.GetFileName(ordner);
|
||||||
|
if (name.Length > 0 && IstFassungsname(name)) return Vereinheitlichen(Saeubern(name));
|
||||||
|
ordner = Path.GetDirectoryName(ordner);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ordner, die nur Fassungen einsammeln und keine Serie benennen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>Plex Versions</c> ist kein Titel, sondern eine Schublade. Ohne diese
|
||||||
|
/// Prüfung hiesse die Serie so, und darunter läge der halbe Bestand ein
|
||||||
|
/// zweites Mal.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool IstFassungsordner(string name) =>
|
||||||
|
IstFassungsname(name) || Schlank(name).Equals("plexversions", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool IstFassungsname(string wort)
|
||||||
|
{
|
||||||
|
var blank = wort.Trim();
|
||||||
|
return Auflösung().IsMatch(blank) || Fassungsnamen.ContainsKey(Schlank(blank));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Schreibweisen, die dasselbe meinen, bekommen denselben Namen.</summary>
|
||||||
|
private static string Vereinheitlichen(string wort) =>
|
||||||
|
Fassungsnamen.GetValueOrDefault(Schlank(wort), wort.Trim());
|
||||||
|
|
||||||
|
private static string Schlank(string wort) =>
|
||||||
|
wort.Trim().Replace(" ", "").Replace("'", "").Replace("-", "");
|
||||||
|
|
||||||
|
private static string Saeubern(string wort) =>
|
||||||
|
Leerraum().Replace(wort.Replace('_', ' ').Replace('.', ' '), " ")
|
||||||
|
.Trim(' ', '-', '–', ',', '[', ']', '(', ')', '{', '}');
|
||||||
|
|
||||||
|
/// <summary>Was als Fassung durchgeht — knapp gehalten, mit Absicht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Eine geschlossene Liste.</b> Nähme man jedes Wort hinter dem letzten
|
||||||
|
/// Bindestrich, hiesse eine Fassung irgendwann „YIFY" oder „GRP" — das
|
||||||
|
/// sind Veröffentlichungsgruppen, keine Fassungen. Lieber kein Name als
|
||||||
|
/// ein falscher: ohne Treffer rechnet die Antwort sie aus der gemessenen
|
||||||
|
/// Bildgrösse — <c>Benennung.Aufloesungswort</c>.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly Dictionary<string, string> Fassungsnamen =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["mobil"] = "Mobil", ["mobile"] = "Mobil",
|
||||||
|
["optimiert"] = "Mobil", ["optimized"] = "Mobil",
|
||||||
|
// Wie Plex seine zweite Fassung nennt. Nicht um Plex zu bedienen,
|
||||||
|
// sondern weil die Dateien nun einmal so auf der Platte liegen:
|
||||||
|
// ohne diese Zeilen steht jede Folge zweimal untereinander, mit
|
||||||
|
// zwei gleich aussehenden Knopfreihen und ohne Hinweis, warum.
|
||||||
|
["optimizedformobile"] = "Mobil",
|
||||||
|
["optimiertfürmobilgeräte"] = "Mobil",
|
||||||
|
["optimiertfuermobilgeraete"] = "Mobil",
|
||||||
|
["directorscut"] = "Director's Cut", ["directorcut"] = "Director's Cut",
|
||||||
|
["extended"] = "Extended", ["extendedcut"] = "Extended",
|
||||||
|
["theatrical"] = "Kinofassung", ["kinofassung"] = "Kinofassung",
|
||||||
|
["uncut"] = "Uncut", ["remastered"] = "Remastered", ["imax"] = "IMAX",
|
||||||
|
["4k"] = "4K", ["8k"] = "8K", ["sd"] = "SD",
|
||||||
|
};
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\{edition-(?<name>[^}]+)\}", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex EditionInKlammern();
|
||||||
|
|
||||||
/// <summary>Liest Serie, Staffel, Folge und Folgentitel — oder <c>null</c>.</summary>
|
/// <summary>Liest Serie, Staffel, Folge und Folgentitel — oder <c>null</c>.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -87,16 +221,40 @@ public static partial class FileNames
|
|||||||
/// ein Film oder etwas, das niemand benennen kann — in beiden Fällen wäre
|
/// ein Film oder etwas, das niemand benennen kann — in beiden Fällen wäre
|
||||||
/// eine geratene Staffel 1 schlimmer als gar keine.
|
/// eine geratene Staffel 1 schlimmer als gar keine.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static EpisodeName? ParseEpisode(string dateiname)
|
/// <param name="wurzel">
|
||||||
|
/// Der Wurzelpfad der Mediathek. Ist er angegeben, bestimmt der erste
|
||||||
|
/// Ordner darunter die Serie — und zwar <b>allein</b>.
|
||||||
|
/// </param>
|
||||||
|
public static EpisodeName? ParseEpisode(string dateiname, string? wurzel = null)
|
||||||
{
|
{
|
||||||
var roh = Rohname(dateiname);
|
var roh = Rohname(dateiname);
|
||||||
|
|
||||||
var treffer = StaffelUndFolge().Match(roh);
|
var treffer = StaffelUndFolge().Match(roh);
|
||||||
if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh);
|
if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh);
|
||||||
if (!treffer.Success) return null;
|
|
||||||
|
|
||||||
if (!int.TryParse(treffer.Groups["staffel"].Value, out var staffel) ||
|
int staffel, folge;
|
||||||
!int.TryParse(treffer.Groups["folge"].Value, out var folge)) return null;
|
|
||||||
|
if (treffer.Success)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(treffer.Groups["staffel"].Value, out staffel) ||
|
||||||
|
!int.TryParse(treffer.Groups["folge"].Value, out folge)) return null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// **„Folge 12" ist eine Folgennummer, nur auf Deutsch.**
|
||||||
|
//
|
||||||
|
// Mediatheken und Mitschnitte benennen so:
|
||||||
|
// „Weihnachtsmann & Co. KG # Folge 12 # Strafe muss sein".
|
||||||
|
// Eine Staffel steht dort nie — die kommt, wenn überhaupt, vom
|
||||||
|
// Ordner darüber. Fehlt auch der, gilt Staffel 1: das ist die
|
||||||
|
// Übereinkunft für eine Serie ohne Staffeln, nicht geraten.
|
||||||
|
var nurFolge = NurFolgennummer().Match(roh);
|
||||||
|
if (!nurFolge.Success || !int.TryParse(nurFolge.Groups["folge"].Value, out folge))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
staffel = StaffelAusOrdner(dateiname) ?? 1;
|
||||||
|
treffer = nurFolge;
|
||||||
|
}
|
||||||
|
|
||||||
var davor = roh[..treffer.Index];
|
var davor = roh[..treffer.Index];
|
||||||
var danach = roh[(treffer.Index + treffer.Length)..];
|
var danach = roh[(treffer.Index + treffer.Length)..];
|
||||||
@@ -107,13 +265,185 @@ public static partial class FileNames
|
|||||||
var (worteDanach, _) = Zerlegen(danach, istDateiname: false);
|
var (worteDanach, _) = Zerlegen(danach, istDateiname: false);
|
||||||
var (folgenTitel, _) = TitelBis(worteDanach, worteDanach.Length);
|
var (folgenTitel, _) = TitelBis(worteDanach, worteDanach.Length);
|
||||||
|
|
||||||
|
// **Der Ordner entscheidet, nicht der Dateiname.**
|
||||||
|
//
|
||||||
|
// Was unter „Serien/Pingu/" liegt, gehört zu Pingu — ohne Rücksicht
|
||||||
|
// darauf, wie die Datei heisst. Vorher entschied der Dateiname, und
|
||||||
|
// das ging reihenweise schief: Dateien namens „S01E001_Aang.mkv"
|
||||||
|
// tragen vor der Folgennummer nichts, also wurde der ganze Dateiname
|
||||||
|
// zum Serientitel. Aus einer Serie mit zwanzig Folgen wurden zwanzig
|
||||||
|
// Serien.
|
||||||
|
//
|
||||||
|
// Umgekehrt ebenso wichtig: „Doctor Who (2005) - S12E04.mkv" im Ordner
|
||||||
|
// „Doctor Who" ergab zwei Serien, weil die Jahresangabe im Dateinamen
|
||||||
|
// stand und im Ordner nicht. Der Ordner ist die verlässlichere Angabe,
|
||||||
|
// weil ihn ein Mensch angelegt hat.
|
||||||
|
//
|
||||||
|
// So macht es Plex seit je, und aus demselben Grund.
|
||||||
|
// **Mit Wurzel entscheidet der Ordner, ohne Wurzel nur im Notfall.**
|
||||||
|
//
|
||||||
|
// Steht die Wurzel fest, weiss man, welcher Ordner die Serie benennt —
|
||||||
|
// der erste darunter, von einem Menschen angelegt. Fehlt sie, könnte
|
||||||
|
// über dem Staffelordner irgendetwas stehen; dann ist der Dateiname
|
||||||
|
// die bessere Angabe, und der Ordner greift nur, wenn er gar nichts
|
||||||
|
// hergibt.
|
||||||
|
var ausOrdner = wurzel is { Length: > 0 }
|
||||||
|
? SerieAusOrdner(dateiname, wurzel)
|
||||||
|
: serie.Length > 0 ? null : SerieAusOrdner(dateiname, null);
|
||||||
|
|
||||||
|
// **Der Rohname ist keine Serie.**
|
||||||
|
//
|
||||||
|
// Hier stand als letzter Rückfall `Rohname(dateiname)`. Das war der
|
||||||
|
// teuerste Fehler des Projekts: liegt eine Datei direkt in der Wurzel
|
||||||
|
// und trägt vor der Folgennummer nichts Brauchbares, wurde der
|
||||||
|
// komplette Dateiname — samt Folgentitel — zum Serientitel. Aus 95
|
||||||
|
// Serien wurden 737, jede mit genau einer Folge, und jede frisch bei
|
||||||
|
// jedem Lauf.
|
||||||
|
//
|
||||||
|
// Jetzt gilt: entweder der Ordner weiss es, oder der Teil vor der
|
||||||
|
// Folgennummer weiss es — oder niemand weiss es, und dann wird nichts
|
||||||
|
// erfunden. Was hier `null` ergibt, zählt im Scan als unerkannt und
|
||||||
|
// erscheint unter „Nicht zugeordnet"; eine Geisterserie mit einer
|
||||||
|
// Folge ist schlechter als ein ehrliches „weiss ich nicht".
|
||||||
|
var serientitel = ausOrdner ?? (serie.Length > 0 ? serie : null);
|
||||||
|
if (serientitel is null) return null;
|
||||||
|
|
||||||
return new EpisodeName(
|
return new EpisodeName(
|
||||||
SeriesTitle: serie.Length > 0 ? serie : Rohname(dateiname),
|
SeriesTitle: serientitel,
|
||||||
Season: staffel,
|
Season: staffel,
|
||||||
Episode: folge,
|
Episode: folge,
|
||||||
EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null);
|
EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null,
|
||||||
|
AusOrdner: ausOrdner is not null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Warum sich eine Datei keiner Serie zuordnen liess.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Zwei Gründe, und sie verlangen Verschiedenes.</b> Fehlt die
|
||||||
|
/// Folgennummer, muss die Datei umbenannt werden. Fehlt der Serienname,
|
||||||
|
/// genügt ein Ordner darum herum. „Nicht zugeordnet" allein sagt beides
|
||||||
|
/// nicht — und bei 3559 Dateien ist der Unterschied die ganze Arbeit.
|
||||||
|
/// </remarks>
|
||||||
|
public static string WarumNicht(string dateiname, string? wurzel = null)
|
||||||
|
{
|
||||||
|
var roh = Rohname(dateiname);
|
||||||
|
var treffer = StaffelUndFolge().Match(roh);
|
||||||
|
if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh);
|
||||||
|
|
||||||
|
if (!treffer.Success)
|
||||||
|
{
|
||||||
|
return "keine Folgennummer im Dateinamen (erwartet wird etwa S01E02 oder 1x02).";
|
||||||
|
}
|
||||||
|
|
||||||
|
return wurzel is { Length: > 0 } && SerieAusOrdner(dateiname, wurzel) is null
|
||||||
|
? "liegt ohne Ordner direkt in der Mediathek, und vor der Folgennummer "
|
||||||
|
+ "steht kein Serienname."
|
||||||
|
: "vor der Folgennummer steht kein Serienname.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Serienname aus dem Pfad.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Mit Wurzel: der erste Ordner darunter.</b> Alles, was in
|
||||||
|
/// <c>Serien/Pingu/</c> liegt, gehört zu Pingu — egal wie tief und egal
|
||||||
|
/// wie die Datei heisst. Das ist die Regel, die ein Mensch beim Anlegen
|
||||||
|
/// der Ordner im Kopf hatte.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ohne Wurzel: höchstens zwei Ebenen hoch</b>, Staffelordner
|
||||||
|
/// übersprungen. Diesen Weg gehen nur die Tests, die mit relativen Pfaden
|
||||||
|
/// arbeiten.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static string? SerieAusOrdner(string pfad, string? wurzel)
|
||||||
|
{
|
||||||
|
if (wurzel is { Length: > 0 })
|
||||||
|
{
|
||||||
|
var unterhalb = UnterWurzel(pfad, wurzel);
|
||||||
|
// Liegt die Datei direkt in der Wurzel, gibt es keinen Ordner, der
|
||||||
|
// sie benennt — dann bleibt nur der Dateiname.
|
||||||
|
if (unterhalb is null || !unterhalb.Contains('/')) return null;
|
||||||
|
|
||||||
|
// **Schubladen überspringen.** Unter `Plex Versions/Optimized for
|
||||||
|
// Mobile/` steht die Serie erst darunter; wer den ersten Ordner
|
||||||
|
// nimmt, legt eine Serie namens „Plex Versions" an.
|
||||||
|
var teile = unterhalb.Split('/');
|
||||||
|
foreach (var teil in teile[..^1])
|
||||||
|
{
|
||||||
|
if (IstFassungsordner(teil) || IstStaffelordner(teil)) continue;
|
||||||
|
return Saubermachen(teil);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ordner = Path.GetDirectoryName(pfad);
|
||||||
|
|
||||||
|
for (var ebene = 0; ebene < 2 && !string.IsNullOrEmpty(ordner); ebene++)
|
||||||
|
{
|
||||||
|
var name = Path.GetFileName(ordner);
|
||||||
|
if (name.Length == 0) return null;
|
||||||
|
|
||||||
|
if (!IstStaffelordner(name)) return Saubermachen(name);
|
||||||
|
|
||||||
|
ordner = Path.GetDirectoryName(ordner);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Pfad unterhalb der Wurzel, mit „/" getrennt.</summary>
|
||||||
|
private static string? UnterWurzel(string pfad, string wurzel)
|
||||||
|
{
|
||||||
|
var p = pfad.Replace('\\', '/');
|
||||||
|
var w = wurzel.Replace('\\', '/').TrimEnd('/');
|
||||||
|
|
||||||
|
if (!p.StartsWith(w + "/", StringComparison.OrdinalIgnoreCase)) return null;
|
||||||
|
return p[(w.Length + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Saubermachen(string ordnername)
|
||||||
|
{
|
||||||
|
var (worte, _) = Zerlegen(ordnername, istDateiname: false);
|
||||||
|
var (titel, _) = TitelBis(worte, worte.Length);
|
||||||
|
return titel.Length > 0 ? titel : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>„Folge 12", „Episode 3", „Teil 7" — eine Nummer ohne Staffel.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Mit Wortgrenze dahinter, damit „Folge 1" nicht aus „Folge 12" wird, und
|
||||||
|
/// nur bis drei Ziffern: „Folge 2024" wäre eher ein Datum.
|
||||||
|
/// </remarks>
|
||||||
|
[GeneratedRegex(@"(?<![A-Za-z])(?:folge|episode|teil|ep)[\s._#-]*(?<folge>\d{1,3})(?!\d)",
|
||||||
|
RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex NurFolgennummer();
|
||||||
|
|
||||||
|
/// <summary>Die Staffelnummer aus einem Ordner „Season 4" über der Datei.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Nur aus dem unmittelbaren Ordner.</b> Steht dort keine Zahl, wird
|
||||||
|
/// keine erfunden — der Aufrufer entscheidet, was dann gilt.
|
||||||
|
/// </remarks>
|
||||||
|
private static int? StaffelAusOrdner(string pfad)
|
||||||
|
{
|
||||||
|
var ordner = Path.GetFileName(Path.GetDirectoryName(pfad.Replace('\\', '/')) ?? "");
|
||||||
|
var treffer = StaffelZahl().Match(ordner.Trim());
|
||||||
|
return treffer.Success && int.TryParse(treffer.Groups["nr"].Value, out var nr) ? nr : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^(?:staffel|season|series|s)[\s._-]*(?<nr>\d{1,3})$", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex StaffelZahl();
|
||||||
|
|
||||||
|
/// <summary>„Staffel 1", „Season 02", „S3" — Ordner, die keine Serie benennen.</summary>
|
||||||
|
private static bool IstStaffelordner(string name) =>
|
||||||
|
StaffelOrdnerName().IsMatch(name.Trim());
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^(staffel|season|series|s)[\s._-]*\d{1,3}$|^(specials?|extras?)$",
|
||||||
|
RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex StaffelOrdnerName();
|
||||||
|
|
||||||
/// <summary>Nimmt Worte, bis das erste Beiwerk oder eine Jahreszahl kommt.</summary>
|
/// <summary>Nimmt Worte, bis das erste Beiwerk oder eine Jahreszahl kommt.</summary>
|
||||||
private static (string Titel, int? Jahr) TitelBis(string[] worte, int grenze)
|
private static (string Titel, int? Jahr) TitelBis(string[] worte, int grenze)
|
||||||
{
|
{
|
||||||
@@ -144,6 +474,14 @@ public static partial class FileNames
|
|||||||
{
|
{
|
||||||
var blank = wort.Trim('[', ']', '(', ')', '{', '}', '-', '_');
|
var blank = wort.Trim('[', ']', '(', ')', '{', '}', '-', '_');
|
||||||
if (blank.Length == 0) return false;
|
if (blank.Length == 0) return false;
|
||||||
|
|
||||||
|
// **Fassungsnamen beenden den Titel wie jedes andere Beiwerk.**
|
||||||
|
// Vorher tat das nur die Auflösung: aus
|
||||||
|
// „The Matrix (1999) - Mobil.mp4" wurde der Film „The Matrix - Mobil",
|
||||||
|
// also ein zweiter Eintrag neben „The Matrix" — und die mobile
|
||||||
|
// Fassung stand getrennt vom Original in der Mediathek.
|
||||||
|
if (Fassungsnamen.ContainsKey(Schlank(blank))) return true;
|
||||||
|
|
||||||
if (Beiwerk.Contains(blank) || Auflösung().IsMatch(blank)) return true;
|
if (Beiwerk.Contains(blank) || Auflösung().IsMatch(blank)) return true;
|
||||||
|
|
||||||
// „x264-GRP" und „DD5.1" hängen am Beiwerk fest.
|
// „x264-GRP" und „DD5.1" hängen am Beiwerk fest.
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace Flaro.Core.Library;
|
||||||
|
|
||||||
|
/// <summary>Ein Titel in einer anderen Sprache als der des Servers.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum nicht einfach alles in allen Sprachen holen.</b> Das hiesse, den
|
||||||
|
/// ganzen Bestand je Sprache abzurufen und zu speichern — für einen Haushalt
|
||||||
|
/// mit zwei Sprachen ein Vielfaches an Abrufen und Speicher für etwas, das
|
||||||
|
/// fast niemand ansieht.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Also auf Abruf.</b> Öffnet jemand mit abweichender Sprache einen Titel,
|
||||||
|
/// wird die Übersetzung einmal geholt und bleibt liegen. Was fehlt, erscheint
|
||||||
|
/// in der Sprache des Servers — nicht leer.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ItemTranslation
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
public Guid? MovieId { get; init; }
|
||||||
|
public Guid? SeriesId { get; init; }
|
||||||
|
public Guid? EpisodeId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Das Sprachkürzel, so wie es beim Abruf verwendet wurde.</summary>
|
||||||
|
public required string Language { get; init; }
|
||||||
|
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Overview { get; set; }
|
||||||
|
public string? Tagline { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Wann sie geholt wurde — für ein späteres Auffrischen.</summary>
|
||||||
|
public DateTimeOffset FetchedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -46,11 +46,44 @@ public static class LibraryWalker
|
|||||||
return new WalkResult(gefunden, probleme);
|
return new WalkResult(gefunden, probleme);
|
||||||
}
|
}
|
||||||
|
|
||||||
Absteigen(wurzel, 0, gefunden, probleme);
|
// **Jedes echte Verzeichnis genau einmal.** Siehe <see cref="Echtpfad"/>.
|
||||||
|
var besucht = new HashSet<string>(StringComparer.Ordinal) { Echtpfad(wurzel) };
|
||||||
|
|
||||||
|
Absteigen(wurzel, 0, gefunden, probleme, besucht);
|
||||||
return new WalkResult(gefunden, probleme);
|
return new WalkResult(gefunden, probleme);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Absteigen(string ordner, int tiefe, List<ScannedFile> gefunden, List<WalkProblem> probleme)
|
/// <summary>Wohin ein Verzeichnis wirklich zeigt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Ohne das wird aus einem Ordner zwei.</b> Ein Verweis — ein Symlink,
|
||||||
|
/// ein zweiter Bind Mount derselben Freigabe — macht denselben Inhalt unter
|
||||||
|
/// einem zweiten Namen erreichbar. Der Scan lief beide Wege ab und legte
|
||||||
|
/// „Pingu" und „Pingu-nochmal" als zwei Serien an; dieselbe Datei bekam
|
||||||
|
/// zwei Pfade und damit zwei Zeilen, was am Eindeutigkeitsindex den ganzen
|
||||||
|
/// Speichervorgang zerriss.
|
||||||
|
///
|
||||||
|
/// Lässt sich das Ziel nicht auflösen, gilt der Pfad selbst. Lieber einmal
|
||||||
|
/// zu viel absteigen als wegen eines Rechtefehlers einen echten Ordner
|
||||||
|
/// auslassen.
|
||||||
|
/// </remarks>
|
||||||
|
private static string Echtpfad(string ordner)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ziel = Directory.ResolveLinkTarget(ordner, returnFinalTarget: true);
|
||||||
|
return Path.GetFullPath(ziel?.FullName ?? ordner).TrimEnd(Path.DirectorySeparatorChar);
|
||||||
|
}
|
||||||
|
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException
|
||||||
|
or ArgumentException or NotSupportedException)
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(ordner).TrimEnd(Path.DirectorySeparatorChar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Absteigen(
|
||||||
|
string ordner, int tiefe, List<ScannedFile> gefunden, List<WalkProblem> probleme,
|
||||||
|
HashSet<string> besucht)
|
||||||
{
|
{
|
||||||
if (tiefe > MaximaleTiefe)
|
if (tiefe > MaximaleTiefe)
|
||||||
{
|
{
|
||||||
@@ -87,7 +120,15 @@ public static class LibraryWalker
|
|||||||
foreach (var unter in unterordner)
|
foreach (var unter in unterordner)
|
||||||
{
|
{
|
||||||
if (MediaExtensions.IsSkippedDirectory(Path.GetFileName(unter))) continue;
|
if (MediaExtensions.IsSkippedDirectory(Path.GetFileName(unter))) continue;
|
||||||
Absteigen(unter, tiefe + 1, gefunden, probleme);
|
|
||||||
|
if (!besucht.Add(Echtpfad(unter)))
|
||||||
|
{
|
||||||
|
probleme.Add(new WalkProblem(
|
||||||
|
unter, "Zeigt auf einen Ordner, der in diesem Lauf schon erfasst wurde."));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Absteigen(unter, tiefe + 1, gefunden, probleme, besucht);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,32 @@ public sealed class MediaFile
|
|||||||
/// erneut aufbringen.
|
/// erneut aufbringen.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public DateTimeOffset? ProbedAt { get; set; }
|
public DateTimeOffset? ProbedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Seit wann die Datei nicht mehr gefunden wird — sonst <c>null</c>.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Markiert statt gelöscht.</b> Vorher räumte der Scan Dateien weg,
|
||||||
|
/// die er nicht mehr sah, und mit der letzten Datei fiel der Titel samt
|
||||||
|
/// Sehfortschritt aller Benutzer. Ein kurz nicht eingehängtes Volume
|
||||||
|
/// genügte dafür. Jetzt bleibt der Eintrag stehen und wird dem
|
||||||
|
/// Administrator gemeldet; gelöscht wird nur auf Ansage.
|
||||||
|
///
|
||||||
|
/// Taucht die Datei wieder auf, verschwindet die Markierung von selbst.
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset? MissingSince { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Wie diese Fassung heisst — „4K", „Mobil", „Director's Cut".</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Eine Fassung ist kein eigener Film.</b> Mehrere Dateien gehören zu
|
||||||
|
/// einem Eintrag, und ohne Namen stünden in der Auswahl mehrmals
|
||||||
|
/// dieselben Angaben. Kommt aus dem Dateinamen, sonst aus der gemessenen
|
||||||
|
/// Bildhöhe; bleibt leer, wenn es nur eine Fassung gibt.
|
||||||
|
/// </remarks>
|
||||||
|
public string? VersionLabel { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class Movie
|
public sealed class Movie : Flaro.Core.Metadata.IBebildert
|
||||||
{
|
{
|
||||||
public Guid Id { get; init; } = Guid.CreateVersion7();
|
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||||
public required Guid LibraryId { get; init; }
|
public required Guid LibraryId { get; init; }
|
||||||
@@ -87,6 +110,111 @@ public sealed class Movie
|
|||||||
/// <summary>Ohne führenden Artikel — siehe <see cref="SortTitle"/>.</summary>
|
/// <summary>Ohne führenden Artikel — siehe <see cref="SortTitle"/>.</summary>
|
||||||
public required string SortTitleValue { get; set; }
|
public required string SortTitleValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ab wann dieser Titel für alle sichtbar ist.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <c>null</c> heisst: wartet auf Prüfung. Ein Zeitpunkt in der
|
||||||
|
/// Vergangenheit heisst veröffentlicht, einer in der Zukunft geplant.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ein Zeitpunkt, kein Zustandsfeld, und das ist der Kern.</b> Die
|
||||||
|
/// Sichtbarkeit wird bei der Abfrage gerechnet statt von einem Dienst
|
||||||
|
/// umgeschaltet. Damit stimmt sie nach einem Neustart, nach einer
|
||||||
|
/// Zeitumstellung und auch dann, wenn der Server zum geplanten Zeitpunkt
|
||||||
|
/// gar nicht lief — ein Ticker hätte in jedem dieser Fälle das Falsche
|
||||||
|
/// getan oder gar nichts.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset? PublishAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Von Hand gewählt — ein Auffrischen überschreibt es nicht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Getrennt je Bild, nicht als ein Schalter.</b> Man kann ein eigenes
|
||||||
|
/// Plakat wollen und trotzdem automatische Hintergründe; ein gemeinsamer
|
||||||
|
/// Merker zwänge zu einer Entscheidung für alle drei.
|
||||||
|
///
|
||||||
|
/// Ohne das überschreibt der nächste Lauf von
|
||||||
|
/// <c>EnrichAsync(…, erneut: true)</c> jede Handauswahl wieder.
|
||||||
|
/// </remarks>
|
||||||
|
public bool PosterPinned { get; set; }
|
||||||
|
public bool BackdropPinned { get; set; }
|
||||||
|
public bool LogoPinned { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Der Titel, wie ihn jemand von Hand gesetzt hat.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Aus demselben Grund wie <see cref="PosterPinned"/>: ohne diesen Merker
|
||||||
|
/// überschreibt das nächste Auffrischen die Korrektur, und man tippt sie
|
||||||
|
/// nach jedem Lauf neu.
|
||||||
|
/// </remarks>
|
||||||
|
public bool TitlePinned { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Der Sortiertitel, wie ihn jemand von Hand gesetzt hat.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Wofür man ihn braucht:</b> eine Reihe steht alphabetisch
|
||||||
|
/// durcheinander. „Harry Potter und der Orden des Phönix" kommt vor „Harry
|
||||||
|
/// Potter und der Stein der Weisen", weil O vor S liegt — obwohl der
|
||||||
|
/// Stein der erste Teil ist. Mit „Harry Potter 1", „Harry Potter 2" stehen
|
||||||
|
/// sie in der Reihenfolge, in der man sie sieht.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Leer heisst: wie bisher.</b> Ohne Eintrag bleibt es bei dem Wert,
|
||||||
|
/// den <see cref="SortTitle"/> aus dem Titel errechnet — Artikel vorn
|
||||||
|
/// abgezogen. Nur wer etwas einträgt, weicht davon ab.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public bool SortTitlePinned { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Seit wann eine Änderung auf ihre Kenntnisnahme wartet.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nicht dasselbe wie <see cref="PublishAt"/>.</b> Der Titel bleibt
|
||||||
|
/// sichtbar — es geht nicht darum, ihn zurückzuhalten, sondern darum, dass
|
||||||
|
/// jemand hinsieht. Ein Auffrischen kann einen Titel umbenennen, ein
|
||||||
|
/// Plakat tauschen oder die Zuordnung wechseln; wer eine Mediathek
|
||||||
|
/// betreut, will das mitbekommen und nicht zufällig entdecken.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>null</c> heisst: nichts offen. Gesetzt wird es nur, wenn die
|
||||||
|
/// Mediathek auf Prüfung steht — wer keine Prüfung will, soll auch keine
|
||||||
|
/// Liste bekommen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset? ChangedSince { get; set; }
|
||||||
|
|
||||||
|
/// <summary>In welcher Sprache die Angaben geholt wurden.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Weil der Server es sonst selbst nicht weiss.</b> „Alles ist noch auf
|
||||||
|
/// Englisch, obwohl der Server auf Deutsch steht" liess sich nicht
|
||||||
|
/// beantworten, sondern nur bestreiten: nirgends stand, in welcher Sprache
|
||||||
|
/// ein Titel eigentlich vorliegt. Mit dieser Spalte wird aus der Frage
|
||||||
|
/// eine Zahl — und aus „frisch mal auf" ein Knopf, der genau die
|
||||||
|
/// abweichenden anfasst statt aller viertausend.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>null</c> heisst: vor dieser Änderung geholt, Sprache unbekannt.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public string? MetadataLanguage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Der Titel in der Sprache des Originals.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit die Mediathek nicht zweisprachig aussieht.</b> Vorher stand
|
||||||
|
/// nur ein Titel da, und welcher es wurde, entschied die Sprache zum
|
||||||
|
/// Zeitpunkt des Scans — „Despicable Me" neben „Ich einfach
|
||||||
|
/// unverbesserlich". Mit beiden lässt sich zeigen, was passt, und über
|
||||||
|
/// beide suchen.
|
||||||
|
/// </remarks>
|
||||||
|
public string? OriginalTitle { get; set; }
|
||||||
|
|
||||||
public int? Year { get; set; }
|
public int? Year { get; set; }
|
||||||
public string? Overview { get; set; }
|
public string? Overview { get; set; }
|
||||||
|
|
||||||
@@ -151,7 +279,7 @@ public sealed class Movie
|
|||||||
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class Series
|
public sealed class Series : Flaro.Core.Metadata.IBebildert
|
||||||
{
|
{
|
||||||
public Guid Id { get; init; } = Guid.CreateVersion7();
|
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||||
public required Guid LibraryId { get; init; }
|
public required Guid LibraryId { get; init; }
|
||||||
@@ -180,6 +308,111 @@ public sealed class Series
|
|||||||
|
|
||||||
public required string Title { get; set; }
|
public required string Title { get; set; }
|
||||||
public required string SortTitleValue { get; set; }
|
public required string SortTitleValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ab wann dieser Titel für alle sichtbar ist.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <c>null</c> heisst: wartet auf Prüfung. Ein Zeitpunkt in der
|
||||||
|
/// Vergangenheit heisst veröffentlicht, einer in der Zukunft geplant.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ein Zeitpunkt, kein Zustandsfeld, und das ist der Kern.</b> Die
|
||||||
|
/// Sichtbarkeit wird bei der Abfrage gerechnet statt von einem Dienst
|
||||||
|
/// umgeschaltet. Damit stimmt sie nach einem Neustart, nach einer
|
||||||
|
/// Zeitumstellung und auch dann, wenn der Server zum geplanten Zeitpunkt
|
||||||
|
/// gar nicht lief — ein Ticker hätte in jedem dieser Fälle das Falsche
|
||||||
|
/// getan oder gar nichts.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset? PublishAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Von Hand gewählt — ein Auffrischen überschreibt es nicht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Getrennt je Bild, nicht als ein Schalter.</b> Man kann ein eigenes
|
||||||
|
/// Plakat wollen und trotzdem automatische Hintergründe; ein gemeinsamer
|
||||||
|
/// Merker zwänge zu einer Entscheidung für alle drei.
|
||||||
|
///
|
||||||
|
/// Ohne das überschreibt der nächste Lauf von
|
||||||
|
/// <c>EnrichAsync(…, erneut: true)</c> jede Handauswahl wieder.
|
||||||
|
/// </remarks>
|
||||||
|
public bool PosterPinned { get; set; }
|
||||||
|
public bool BackdropPinned { get; set; }
|
||||||
|
public bool LogoPinned { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Der Titel, wie ihn jemand von Hand gesetzt hat.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Aus demselben Grund wie <see cref="PosterPinned"/>: ohne diesen Merker
|
||||||
|
/// überschreibt das nächste Auffrischen die Korrektur, und man tippt sie
|
||||||
|
/// nach jedem Lauf neu.
|
||||||
|
/// </remarks>
|
||||||
|
public bool TitlePinned { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Der Sortiertitel, wie ihn jemand von Hand gesetzt hat.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Wofür man ihn braucht:</b> eine Reihe steht alphabetisch
|
||||||
|
/// durcheinander. „Harry Potter und der Orden des Phönix" kommt vor „Harry
|
||||||
|
/// Potter und der Stein der Weisen", weil O vor S liegt — obwohl der
|
||||||
|
/// Stein der erste Teil ist. Mit „Harry Potter 1", „Harry Potter 2" stehen
|
||||||
|
/// sie in der Reihenfolge, in der man sie sieht.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Leer heisst: wie bisher.</b> Ohne Eintrag bleibt es bei dem Wert,
|
||||||
|
/// den <see cref="SortTitle"/> aus dem Titel errechnet — Artikel vorn
|
||||||
|
/// abgezogen. Nur wer etwas einträgt, weicht davon ab.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public bool SortTitlePinned { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Seit wann eine Änderung auf ihre Kenntnisnahme wartet.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nicht dasselbe wie <see cref="PublishAt"/>.</b> Der Titel bleibt
|
||||||
|
/// sichtbar — es geht nicht darum, ihn zurückzuhalten, sondern darum, dass
|
||||||
|
/// jemand hinsieht. Ein Auffrischen kann einen Titel umbenennen, ein
|
||||||
|
/// Plakat tauschen oder die Zuordnung wechseln; wer eine Mediathek
|
||||||
|
/// betreut, will das mitbekommen und nicht zufällig entdecken.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>null</c> heisst: nichts offen. Gesetzt wird es nur, wenn die
|
||||||
|
/// Mediathek auf Prüfung steht — wer keine Prüfung will, soll auch keine
|
||||||
|
/// Liste bekommen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset? ChangedSince { get; set; }
|
||||||
|
|
||||||
|
/// <summary>In welcher Sprache die Angaben geholt wurden.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Weil der Server es sonst selbst nicht weiss.</b> „Alles ist noch auf
|
||||||
|
/// Englisch, obwohl der Server auf Deutsch steht" liess sich nicht
|
||||||
|
/// beantworten, sondern nur bestreiten: nirgends stand, in welcher Sprache
|
||||||
|
/// ein Titel eigentlich vorliegt. Mit dieser Spalte wird aus der Frage
|
||||||
|
/// eine Zahl — und aus „frisch mal auf" ein Knopf, der genau die
|
||||||
|
/// abweichenden anfasst statt aller viertausend.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>null</c> heisst: vor dieser Änderung geholt, Sprache unbekannt.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public string? MetadataLanguage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Der Titel in der Sprache des Originals.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit die Mediathek nicht zweisprachig aussieht.</b> Vorher stand
|
||||||
|
/// nur ein Titel da, und welcher es wurde, entschied die Sprache zum
|
||||||
|
/// Zeitpunkt des Scans — „Despicable Me" neben „Ich einfach
|
||||||
|
/// unverbesserlich". Mit beiden lässt sich zeigen, was passt, und über
|
||||||
|
/// beide suchen.
|
||||||
|
/// </remarks>
|
||||||
|
public string? OriginalTitle { get; set; }
|
||||||
public int? Year { get; set; }
|
public int? Year { get; set; }
|
||||||
public string? Overview { get; set; }
|
public string? Overview { get; set; }
|
||||||
public int? TmdbId { get; set; }
|
public int? TmdbId { get; set; }
|
||||||
@@ -253,5 +486,18 @@ public sealed class Episode
|
|||||||
/// <summary>Das Standbild der Folge.</summary>
|
/// <summary>Das Standbild der Folge.</summary>
|
||||||
public string? StillImage { get; set; }
|
public string? StillImage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>In welcher Sprache die Angaben geholt wurden — siehe <see cref="Movie.MetadataLanguage"/>.</summary>
|
||||||
|
public string? MetadataLanguage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ab wann diese Folge für alle sichtbar ist — siehe <see cref="Movie.PublishAt"/>.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>An der Folge, nicht an der Serie.</b> Eine neue Staffel einer
|
||||||
|
/// laufenden Serie soll sich einzeln planen lassen; eine Serie ist
|
||||||
|
/// sichtbar, sobald eine ihrer Folgen es ist, und braucht damit kein
|
||||||
|
/// eigenes Feld.
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset? PublishAt { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
namespace Flaro.Core.Library;
|
||||||
|
|
||||||
|
/// <summary>Worum es bei einer Meldung geht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Fünf Fächer, nicht fünfzig.</b> Sie sind nicht dazu da, das Problem zu
|
||||||
|
/// beschreiben — das tut der Kommentar —, sondern dazu, eine Liste sortierbar
|
||||||
|
/// zu machen. „Dreimal Ton an derselben Serie" ist eine Auskunft, die man
|
||||||
|
/// sieht, ohne zu lesen.
|
||||||
|
/// </remarks>
|
||||||
|
public enum Meldungsart
|
||||||
|
{
|
||||||
|
Bild,
|
||||||
|
Ton,
|
||||||
|
Untertitel,
|
||||||
|
|
||||||
|
/// <summary>Titel, Jahr, Plakat, Zuordnung — alles, was nicht im Bild ist.</summary>
|
||||||
|
Daten,
|
||||||
|
|
||||||
|
Sonstiges,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Was jemandem an einem Titel aufgefallen ist.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Vier Ziele, drei Spalten.</b> Film, Serie, Staffel, Folge — und eine
|
||||||
|
/// Staffel gibt es als Zeile nicht. Sie ist <c>SeriesId</c> plus
|
||||||
|
/// <c>SeasonNumber</c>, so wie sie überall sonst in diesem Server adressiert
|
||||||
|
/// wird. Eine Tabelle dafür anzulegen, nur damit eine Meldung irgendwohin
|
||||||
|
/// zeigen kann, wäre ein Modellumbau für einen Fremdschlüssel.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Keine Zeile je Benutzer und Titel.</b> Anders als bei
|
||||||
|
/// <see cref="Playback.UserItemState"/> und <see cref="Playback.Sehtag"/> steht
|
||||||
|
/// hier kein eindeutiger Index: zwei Meldungen zu demselben Film sind zwei
|
||||||
|
/// Meldungen. „Der Ton setzt bei 0:30 aus" und „das Plakat gehört zu einem
|
||||||
|
/// anderen Film" haben miteinander nichts zu tun.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ein Zeitpunkt statt eines Hakens.</b> Wie bei <c>PublishAt</c> und
|
||||||
|
/// <c>ChangedSince</c>: ein <c>bool Erledigt</c> sagt, dass es vorbei ist,
|
||||||
|
/// aber nicht wann — und „seit wann liegt das hier" ist die erste Frage an
|
||||||
|
/// eine Liste von Aufgaben.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class Meldung
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
/// <summary>Wer gemeldet hat — aus dem Token, nie aus dem Rumpf.</summary>
|
||||||
|
public required Guid UserId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Entweder ein Film …</summary>
|
||||||
|
public Guid? MovieId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>… oder eine Serie, ganz oder mit <see cref="SeasonNumber"/> …</summary>
|
||||||
|
public Guid? SeriesId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>… oder eine einzelne Folge. Genau eines von dreien.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Ohne die Serie daneben.</b> Welche Serie das ist, weiss die Folge
|
||||||
|
/// selbst; es hier ein zweites Mal abzulegen hiesse, zwei Wahrheiten zu
|
||||||
|
/// pflegen, von denen eine irgendwann die ältere ist.
|
||||||
|
/// </remarks>
|
||||||
|
public Guid? EpisodeId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Eine einzelne Staffel — nur zusammen mit <see cref="SeriesId"/>.</summary>
|
||||||
|
public int? SeasonNumber { get; init; }
|
||||||
|
|
||||||
|
public required Meldungsart Art { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Wo im Titel. <c>null</c> heisst: nicht an einer Stelle.</summary>
|
||||||
|
public TimeSpan? Stelle { get; init; }
|
||||||
|
|
||||||
|
public required string Kommentar { get; init; }
|
||||||
|
|
||||||
|
public required DateTimeOffset GemeldetAm { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Wann abgehakt — <c>null</c> heisst: offen.</summary>
|
||||||
|
public DateTimeOffset? ErledigtAm { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Wer abgehakt hat.</summary>
|
||||||
|
public Guid? ErledigtVon { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
namespace Flaro.Core.Library;
|
||||||
|
|
||||||
|
/// <summary>Woran ein Lauf gerade ist.</summary>
|
||||||
|
public enum Scanphase
|
||||||
|
{
|
||||||
|
/// <summary>Das Dateisystem wird durchgegangen.</summary>
|
||||||
|
Suchen,
|
||||||
|
|
||||||
|
/// <summary>Eine Datei wird gelesen und vermessen.</summary>
|
||||||
|
Pruefen,
|
||||||
|
|
||||||
|
/// <summary>Ein Titel wird bei der Metadatenquelle gesucht.</summary>
|
||||||
|
Nachschlagen,
|
||||||
|
|
||||||
|
/// <summary>Die Angaben zu einem gefundenen Titel werden geholt.</summary>
|
||||||
|
Holen,
|
||||||
|
|
||||||
|
/// <summary>Plakate und Hintergründe werden geladen.</summary>
|
||||||
|
Bilder,
|
||||||
|
|
||||||
|
/// <summary>Verwaistes wird entfernt, Doppeltes zusammengelegt.</summary>
|
||||||
|
Aufraeumen,
|
||||||
|
|
||||||
|
/// <summary>Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Warum getrennt.</b> „Serie ‚Pingu' neu angelegt, aus dem Ordner" ist
|
||||||
|
/// beim Suchen eines Fehlers Gold wert und in einer Zeile, die im
|
||||||
|
/// Sekundentakt wechselt, nur Flackern. Die Oberfläche überspringt diese
|
||||||
|
/// Schritte, die Tagesdatei nimmt sie auf.
|
||||||
|
/// </remarks>
|
||||||
|
Vermerk,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein Zwischenstand: was gerade passiert, und wie weit.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Mit Namen, nicht nur mit Zahlen.</b> Vorher stand in der Verwaltung
|
||||||
|
/// minutenlang „Wird durchsucht…" — ohne Hinweis, ob überhaupt etwas
|
||||||
|
/// passiert. Wer bei vierzigtausend Dateien zusieht, braucht zu sehen, dass
|
||||||
|
/// sich etwas bewegt, und woran es gerade hängt, wenn es hängt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Ein <c>record struct</c>, weil davon im Sekundentakt viele entstehen und
|
||||||
|
/// keiner davon überlebt.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Phase">Welcher Abschnitt des Laufs.</param>
|
||||||
|
/// <param name="Was">Woran genau — ein Titel oder ein Pfad.</param>
|
||||||
|
/// <param name="Erledigt">Wie viele Stücke schon durch sind.</param>
|
||||||
|
/// <param name="Gesamt">Wie viele es insgesamt sind; <c>0</c>, wenn unbekannt.</param>
|
||||||
|
public readonly record struct Scanschritt(Scanphase Phase, string Was, int Erledigt = 0, int Gesamt = 0)
|
||||||
|
{
|
||||||
|
/// <summary>Der Satz, der in der Oberfläche steht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Hier und nicht in der Oberfläche.</b> Die Apps brauchen denselben
|
||||||
|
/// Satz, und zweimal formuliert driftet er auseinander.
|
||||||
|
/// </remarks>
|
||||||
|
public string Text()
|
||||||
|
{
|
||||||
|
var zaehler = Gesamt > 0 ? $" — {Erledigt} von {Gesamt}" : "";
|
||||||
|
|
||||||
|
return Phase switch
|
||||||
|
{
|
||||||
|
Scanphase.Suchen => $"Suche Medien in {Kurz(Was)}",
|
||||||
|
Scanphase.Pruefen => $"Prüfe {Kurz(Was)}{zaehler}",
|
||||||
|
Scanphase.Nachschlagen => $"Suche „{Was}“ bei der Metadatenquelle{zaehler}",
|
||||||
|
Scanphase.Holen => $"Hole Metadaten zu {Was}{zaehler}",
|
||||||
|
Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}",
|
||||||
|
Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen",
|
||||||
|
Scanphase.Vermerk => Was,
|
||||||
|
_ => Was,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die letzten zwei Pfadbestandteile — ein ganzer Pfad sprengt die Zeile.</summary>
|
||||||
|
private static string Kurz(string pfad)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(pfad)) return pfad;
|
||||||
|
|
||||||
|
var teile = pfad.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
return teile.Length <= 2 ? pfad : string.Join('/', teile[^2..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Flaro.Core.Library;
|
||||||
|
|
||||||
|
/// <summary>Führt Schreibvarianten desselben Titels zusammen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Wofür das nötig wurde.</b> Der Scan suchte einen vorhandenen Film über
|
||||||
|
/// den Titel aus dem Dateinamen, zeichengenau verglichen. Zwei Dateien mit
|
||||||
|
/// <c>Dr. Dolittle 2</c> und <c>Dr Dolittle 2</c> ergaben deshalb zwei
|
||||||
|
/// Einträge — die danach beide demselben TMDB-Eintrag zugeordnet wurden und
|
||||||
|
/// ununterscheidbar nebeneinander standen. Dasselbe bei
|
||||||
|
/// <c>WALL·E</c> gegen <c>WALL-E</c> oder <c>Spider-Man</c> gegen
|
||||||
|
/// <c>Spider Man</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nur zum Vergleichen, nie zum Anzeigen.</b> Was hier herauskommt, ist
|
||||||
|
/// eine Zeichenkette ohne Satzzeichen und ohne Grossschreibung — brauchbar
|
||||||
|
/// als Schlüssel, unbrauchbar als Titel.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Bewusst nicht aggressiver.</b> Artikel zu streichen oder Zahlwörter
|
||||||
|
/// umzuschreiben würfe irgendwann zwei verschiedene Filme zusammen, und das
|
||||||
|
/// wäre schlimmer als zwei Einträge für einen: ein zusammengelegter Film
|
||||||
|
/// lässt sich nicht mehr trennen, ohne zu wissen, welche Datei wohin gehörte.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Titelschluessel
|
||||||
|
{
|
||||||
|
/// <summary>Der Vergleichsschlüssel zu einem Titel.</summary>
|
||||||
|
public static string Von(string? titel)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(titel)) return "";
|
||||||
|
|
||||||
|
// Zerlegt „é" in „e" + Akzent, damit der Akzent gleich mit herausfällt.
|
||||||
|
// Ohne das wären „Amelie" und „Amélie" zwei Filme.
|
||||||
|
var zerlegt = titel.Normalize(NormalizationForm.FormD);
|
||||||
|
|
||||||
|
var bau = new StringBuilder(zerlegt.Length);
|
||||||
|
var letzterWarLeer = true;
|
||||||
|
|
||||||
|
foreach (var z in zerlegt)
|
||||||
|
{
|
||||||
|
if (CharUnicodeInfo.GetUnicodeCategory(z) == UnicodeCategory.NonSpacingMark) continue;
|
||||||
|
|
||||||
|
if (char.IsLetterOrDigit(z))
|
||||||
|
{
|
||||||
|
bau.Append(char.ToLowerInvariant(z));
|
||||||
|
letzterWarLeer = false;
|
||||||
|
}
|
||||||
|
else if (!letzterWarLeer)
|
||||||
|
{
|
||||||
|
// Jedes Satz- und Sonderzeichen wird zu einem Leerzeichen —
|
||||||
|
// damit „Spider-Man" und „Spider Man" denselben Schlüssel
|
||||||
|
// haben, „Spider-Man" und „Spiderman" aber nicht. Letzteres
|
||||||
|
// wäre ein Raten, das irgendwann danebenläge.
|
||||||
|
bau.Append(' ');
|
||||||
|
letzterWarLeer = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return OhneBindewort(bau.ToString().TrimEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Wörter, die nur zwei Namen verbinden.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Weil „&" verschwindet und „und" stehenbleibt.</b> TMDB führt
|
||||||
|
/// den Film als „Hanni & Nanni", auf der Platte heisst er „Hanni und
|
||||||
|
/// Nanni" — das Kaufmanns-Und fällt als Sonderzeichen heraus, das Wort
|
||||||
|
/// nicht, und die beiden Schlüssel gingen auseinander. Die Namensgleichheit
|
||||||
|
/// griff deshalb nicht, und gewählt wurde stattdessen der erstbeste
|
||||||
|
/// Treffer: „Hanni & Nanni 3". Gemessen am echten Dienst — der richtige
|
||||||
|
/// Film stand auf Platz zwei.
|
||||||
|
///
|
||||||
|
/// <b>Gestrichen statt umgeschrieben.</b> „&" nach „und" zu übersetzen
|
||||||
|
/// hiesse, sich für eine Sprache zu entscheiden; „Batman and Robin" bliebe
|
||||||
|
/// dann aussen vor. Ohne das Bindewort passt beides.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly string[] Bindewoerter = ["und", "and", "et", "e", "y"];
|
||||||
|
|
||||||
|
private static string OhneBindewort(string schluessel)
|
||||||
|
{
|
||||||
|
if (schluessel.Length == 0) return schluessel;
|
||||||
|
|
||||||
|
var worte = schluessel.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (worte.Length < 3) return schluessel; // „Ich und" ist kein Bindewort-Fall
|
||||||
|
|
||||||
|
// **Nur zwischen zwei Wörtern, nie am Rand.** „Und dann kam Polly"
|
||||||
|
// fängt mit einem Bindewort an, und das gehört zum Titel.
|
||||||
|
var behalten = worte
|
||||||
|
.Where((w, i) => i == 0 || i == worte.Length - 1
|
||||||
|
|| !Bindewoerter.Contains(w, StringComparer.Ordinal))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return string.Join(' ', behalten);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ob zwei Fundstücke derselbe Titel sind.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Ein fehlendes Jahr passt auf jedes.</b> Eine Datei heisst
|
||||||
|
/// <c>Descendants 2 (2017).mkv</c>, die zweite nur <c>Descendants 2.mkv</c>
|
||||||
|
/// — das ist der häufigste Grund für eine Dublette gewesen. Stehen
|
||||||
|
/// dagegen <b>beide</b> Jahre da und sind verschieden, sind es zwei
|
||||||
|
/// Filme: die beiden „Dune" von 1984 und 2021 dürfen nie verschmelzen.
|
||||||
|
/// </remarks>
|
||||||
|
public static bool Gleich(string? titelA, int? jahrA, string? titelB, int? jahrB)
|
||||||
|
{
|
||||||
|
var a = Von(titelA);
|
||||||
|
if (a.Length == 0 || a != Von(titelB)) return false;
|
||||||
|
return jahrA is null || jahrB is null || jahrA == jahrB;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
namespace Flaro.Core.Media;
|
||||||
|
|
||||||
|
/// <summary>Macht aus ffprobe-Rohwerten etwas, das man vorlesen kann.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Was vorher in der Oberfläche stand.</b> „DEU EAC3 6.0" und
|
||||||
|
/// „DEU MOV_TEXT" — beides ist richtig und beides sagt niemandem etwas, der
|
||||||
|
/// nicht weiss, dass EAC3 Dolby Digital Plus heisst und dass sechs Kanäle
|
||||||
|
/// 5.1 ergeben und nicht 6.0.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Hier und nicht in der Oberfläche.</b> Die Apps brauchen dieselben
|
||||||
|
/// Namen. Zweimal formuliert driften sie auseinander, und dann heisst
|
||||||
|
/// dieselbe Tonspur am Fernseher anders als im Browser.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Unbekanntes bleibt stehen.</b> Ein Codec, den diese Liste nicht kennt,
|
||||||
|
/// erscheint in Grossbuchstaben — nicht als „Unbekannt". Die rohe Angabe ist
|
||||||
|
/// immer noch eine Auskunft; ein Platzhalter ist keine.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Benennung
|
||||||
|
{
|
||||||
|
/// <summary>Der Klarname einer Sprache: „deu" wird „Deutsch".</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Aus einer eigenen Liste, nicht aus <see cref="CultureInfo"/>.</b>
|
||||||
|
/// Zwei Gründe, beide gemessen: dessen <c>NativeName</c> liefert den Namen
|
||||||
|
/// in der Sprache selbst — „English", „français" —, und für ein erfundenes
|
||||||
|
/// Kürzel wie „xyz" erfindet es stillschweigend eine Kultur, statt zu
|
||||||
|
/// scheitern. Beides taugt hier nicht.
|
||||||
|
/// </remarks>
|
||||||
|
public static string? Sprache(string? kuerzel, string? anzeigeSprache = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(kuerzel)) return null;
|
||||||
|
|
||||||
|
// Nur der Sprachteil: „de-DE" und „de" meinen dasselbe.
|
||||||
|
var blank = kuerzel.Trim().Split('-', '_')[0];
|
||||||
|
|
||||||
|
// ffmpeg schreibt „und" für unbekannt. Das als Sprache anzuzeigen wäre
|
||||||
|
// schlimmer als gar nichts — es sieht aus wie eine Angabe.
|
||||||
|
if (blank.Equals("und", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| blank.Equals("unk", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| blank.Equals("mul", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbekanntes bleibt stehen. Die rohe Angabe ist immer noch eine
|
||||||
|
// Auskunft; „Unbekannt" ist keine.
|
||||||
|
return Sprachnamen.GetValueOrDefault(blank, blank.ToUpperInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die übliche Schreibweise für eine Kanalzahl.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Sechs Kanäle sind 5.1, nicht 6.0.</b> Der letzte ist der
|
||||||
|
/// Tiefton-Kanal, und der wird hinter dem Punkt gezählt. Vorher stand in
|
||||||
|
/// der Oberfläche „6.0" — eine Schreibweise, die es nicht gibt.
|
||||||
|
/// </remarks>
|
||||||
|
public static string? Kanaele(int? anzahl) => anzahl switch
|
||||||
|
{
|
||||||
|
null or <= 0 => null,
|
||||||
|
1 => "Mono",
|
||||||
|
2 => "Stereo",
|
||||||
|
3 => "2.1",
|
||||||
|
4 => "4.0",
|
||||||
|
5 => "5.0",
|
||||||
|
6 => "5.1",
|
||||||
|
7 => "6.1",
|
||||||
|
8 => "7.1",
|
||||||
|
_ => $"{anzahl} Kanäle",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Der geläufige Name eines Codecs.</summary>
|
||||||
|
public static string? Codec(string? kuerzel)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(kuerzel)) return null;
|
||||||
|
var blank = kuerzel.Trim();
|
||||||
|
return Codecs.GetValueOrDefault(blank.ToLowerInvariant(), blank.ToUpperInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Auflösungsstufe zu einer gemessenen Bildhöhe.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>In Stufen, nicht roh.</b> Ein beschnittenes Breitbild misst 1040
|
||||||
|
/// Zeilen statt 1080; genau das stand in der Oberfläche und sah aus wie
|
||||||
|
/// ein Fehler. Der genaue Wert gehört in den Tooltip, nicht in die Zeile.
|
||||||
|
/// </remarks>
|
||||||
|
public static string? Aufloesung(int? hoehe) => AusHoehe(hoehe);
|
||||||
|
|
||||||
|
/// <summary>Die Stufe aus Breite <i>und</i> Höhe — die grössere gewinnt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Die Höhe allein lügt bei Breitbild.</b> „Spider-Man" liegt als
|
||||||
|
/// 1920×800 vor — ein Kinoformat von 2.39:1, also Full HD. Nach der Höhe
|
||||||
|
/// gerechnet kam „720p" heraus, und das stand so in der Oberfläche: eine
|
||||||
|
/// Angabe, die jeder sofort als falsch erkennt, der die Datei kennt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Die grössere der beiden Stufen, nicht die Breite allein.</b> Sonst
|
||||||
|
/// kippt es beim Hochformat: ein 1080×1920 gedrehtes Bild wäre nach der
|
||||||
|
/// Breite „720p". So stimmt beides, und keine Richtung ist bevorzugt.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static string? Aufloesung(int? breite, int? hoehe)
|
||||||
|
{
|
||||||
|
var a = Stufe(hoehe);
|
||||||
|
var b = Stufe(breite * 9 / 16); // Breite auf eine 16:9-Höhe umgerechnet
|
||||||
|
var groessere = Math.Max(a ?? 0, b ?? 0);
|
||||||
|
return groessere > 0 ? AusHoehe(groessere) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Zeilenzahl, auf die eine Stufe lautet — zum Vergleichen.</summary>
|
||||||
|
private static int? Stufe(int? wert) => wert switch
|
||||||
|
{
|
||||||
|
null or <= 0 => null,
|
||||||
|
>= 2000 => 2160,
|
||||||
|
>= 1400 => 1440,
|
||||||
|
>= 1000 => 1080,
|
||||||
|
>= 700 => 720,
|
||||||
|
>= 500 => 576,
|
||||||
|
>= 300 => 480,
|
||||||
|
_ => wert,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string? AusHoehe(int? hoehe) => hoehe switch
|
||||||
|
{
|
||||||
|
null or <= 0 => null,
|
||||||
|
>= 2000 => "2160p",
|
||||||
|
>= 1400 => "1440p",
|
||||||
|
>= 1000 => "1080p",
|
||||||
|
>= 700 => "720p",
|
||||||
|
>= 500 => "576p",
|
||||||
|
>= 300 => "480p",
|
||||||
|
_ => $"{hoehe}p",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Dieselbe Messung als Wort: „4K", „Full HD", „HD", „SD".</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Wer schauen will, wählt keine Zeilenzahl.</b> „1080p" ist eine Angabe
|
||||||
|
/// für den, der eine Stufe einstellt; „Full HD" ist eine für den, der sich
|
||||||
|
/// fragt, ob es sich lohnt, dafür den Fernseher anzumachen. Der genaue Wert
|
||||||
|
/// geht nicht verloren — <see cref="Aufloesung(int?, int?)"/> liefert ihn
|
||||||
|
/// weiter, und die Oberfläche zeigt ihn beim Überfahren.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>1440p heisst hier Full HD.</b> „QHD" kennt kaum jemand, und ein
|
||||||
|
/// fünftes Wort für eine Auflösung, die zwischen zwei bekannten liegt,
|
||||||
|
/// hilft niemandem beim Entscheiden. „8K" steht nur da, damit eine
|
||||||
|
/// 8K-Datei nicht als 4K gemeldet wird.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Gerechnet wird mit Breite und Höhe</b>, aus demselben Grund wie oben:
|
||||||
|
/// ein Film im Kinoformat hat 1920×800 und ist Full HD, nicht HD.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static string? Aufloesungswort(int? breite, int? hoehe)
|
||||||
|
{
|
||||||
|
var a = Stufe(hoehe);
|
||||||
|
var b = Stufe(breite * 9 / 16);
|
||||||
|
return Math.Max(a ?? 0, b ?? 0) switch
|
||||||
|
{
|
||||||
|
<= 0 => null,
|
||||||
|
>= 3500 => "8K",
|
||||||
|
>= 2000 => "4K",
|
||||||
|
>= 1000 => "Full HD",
|
||||||
|
>= 700 => "HD",
|
||||||
|
_ => "SD",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die ganze Zeile zu einer Tonspur.</summary>
|
||||||
|
public static string Tonspur(string? sprache, string? codec, int? kanaele, string? titel)
|
||||||
|
{
|
||||||
|
var teile = new List<string>();
|
||||||
|
if (Sprache(sprache) is { } s) teile.Add(s);
|
||||||
|
if (Codec(codec) is { } c) teile.Add(c);
|
||||||
|
if (Kanaele(kanaele) is { } k) teile.Add(k);
|
||||||
|
|
||||||
|
// **Der Titel nur, wenn er etwas hinzufügt.** „SoundHandler" ist kein
|
||||||
|
// Name, sondern ein Rest aus dem Umpacken. Und ein Titel, der bloss
|
||||||
|
// die Sprache wiederholt, ergibt „Deutsch · AAC · Mono · ‚Deutsch'".
|
||||||
|
var blank = titel?.Trim();
|
||||||
|
if (blank is { Length: > 0 }
|
||||||
|
&& !Nichtssagend.Contains(blank)
|
||||||
|
&& !string.Equals(blank, Sprache(sprache), StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(blank, sprache?.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
teile.Add($"„{blank}“");
|
||||||
|
}
|
||||||
|
|
||||||
|
return teile.Count > 0 ? string.Join(" · ", teile) : "Tonspur";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die ganze Zeile zu einer Untertitelspur.</summary>
|
||||||
|
public static string Untertitelspur(string? sprache, bool erzwungen, bool fuerHoergeschaedigte)
|
||||||
|
{
|
||||||
|
var name = Sprache(sprache) ?? "Untertitel";
|
||||||
|
if (erzwungen) name += " (erzwungen)";
|
||||||
|
else if (fuerHoergeschaedigte) name += " (für Hörgeschädigte)";
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die ganze Zeile zu einer Bildspur.</summary>
|
||||||
|
public static string Bildspur(string? codec, int? hoehe) => Bildspur(codec, null, hoehe);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Bildspur(string?, int?)"/>
|
||||||
|
public static string Bildspur(string? codec, int? breite, int? hoehe)
|
||||||
|
{
|
||||||
|
var teile = new List<string>();
|
||||||
|
// Das Wort, nicht die Zahl — siehe `Aufloesungswort`.
|
||||||
|
if (Aufloesungswort(breite, hoehe) is { } a) teile.Add(a);
|
||||||
|
if (Codec(codec) is { } c) teile.Add(c);
|
||||||
|
return teile.Count > 0 ? string.Join(" · ", teile) : "Bild";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>„Deutsch (Deutschland)" wird „Deutsch".</summary>
|
||||||
|
|
||||||
|
|
||||||
|
private static readonly HashSet<string> Nichtssagend = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"SoundHandler", "VideoHandler", "SubtitleHandler", "Stereo", "Audio", "Video",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> Codecs = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
// Ton
|
||||||
|
["eac3"] = "Dolby Digital Plus",
|
||||||
|
["ac3"] = "Dolby Digital",
|
||||||
|
["truehd"] = "Dolby TrueHD",
|
||||||
|
["dts"] = "DTS",
|
||||||
|
["aac"] = "AAC",
|
||||||
|
["opus"] = "Opus",
|
||||||
|
["vorbis"] = "Vorbis",
|
||||||
|
["flac"] = "FLAC",
|
||||||
|
["mp3"] = "MP3",
|
||||||
|
["pcm_s16le"] = "PCM",
|
||||||
|
// Bild
|
||||||
|
["h264"] = "H.264",
|
||||||
|
["avc"] = "H.264",
|
||||||
|
["hevc"] = "H.265",
|
||||||
|
["h265"] = "H.265",
|
||||||
|
["av1"] = "AV1",
|
||||||
|
["vp9"] = "VP9",
|
||||||
|
["mpeg2video"] = "MPEG-2",
|
||||||
|
["mpeg4"] = "MPEG-4",
|
||||||
|
// Untertitel
|
||||||
|
["mov_text"] = "Text",
|
||||||
|
["subrip"] = "SRT",
|
||||||
|
["ass"] = "ASS",
|
||||||
|
["ssa"] = "SSA",
|
||||||
|
["pgs"] = "PGS",
|
||||||
|
["hdmv_pgs_subtitle"] = "PGS",
|
||||||
|
["dvd_subtitle"] = "DVD",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Sprachnamen auf Deutsch, über das zwei- und das dreibuchstabige Kürzel.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Beide Schreibweisen kommen in Mediendateien vor, oft in derselben.
|
||||||
|
/// ISO 639-2 kennt für manche Sprachen zwei Kürzel — „ger" und „deu" —,
|
||||||
|
/// und welches eine Datei trägt, hängt am Werkzeug, das sie erzeugt hat.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly Dictionary<string, string> Sprachnamen = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["de"] = "Deutsch", ["ger"] = "Deutsch", ["deu"] = "Deutsch",
|
||||||
|
["en"] = "Englisch", ["eng"] = "Englisch",
|
||||||
|
["fr"] = "Französisch", ["fre"] = "Französisch", ["fra"] = "Französisch",
|
||||||
|
["es"] = "Spanisch", ["spa"] = "Spanisch",
|
||||||
|
["it"] = "Italienisch", ["ita"] = "Italienisch",
|
||||||
|
["nl"] = "Niederländisch", ["dut"] = "Niederländisch", ["nld"] = "Niederländisch",
|
||||||
|
["pl"] = "Polnisch", ["pol"] = "Polnisch",
|
||||||
|
["pt"] = "Portugiesisch", ["por"] = "Portugiesisch",
|
||||||
|
["ru"] = "Russisch", ["rus"] = "Russisch",
|
||||||
|
["tr"] = "Türkisch", ["tur"] = "Türkisch",
|
||||||
|
["ja"] = "Japanisch", ["jpn"] = "Japanisch",
|
||||||
|
["ko"] = "Koreanisch", ["kor"] = "Koreanisch",
|
||||||
|
["zh"] = "Chinesisch", ["chi"] = "Chinesisch", ["zho"] = "Chinesisch",
|
||||||
|
["sv"] = "Schwedisch", ["swe"] = "Schwedisch",
|
||||||
|
["no"] = "Norwegisch", ["nor"] = "Norwegisch",
|
||||||
|
["da"] = "Dänisch", ["dan"] = "Dänisch",
|
||||||
|
["fi"] = "Finnisch", ["fin"] = "Finnisch",
|
||||||
|
["cs"] = "Tschechisch", ["cze"] = "Tschechisch", ["ces"] = "Tschechisch",
|
||||||
|
["hu"] = "Ungarisch", ["hun"] = "Ungarisch",
|
||||||
|
["el"] = "Griechisch", ["gre"] = "Griechisch", ["ell"] = "Griechisch",
|
||||||
|
["ar"] = "Arabisch", ["ara"] = "Arabisch",
|
||||||
|
["he"] = "Hebräisch", ["heb"] = "Hebräisch",
|
||||||
|
["hi"] = "Hindi", ["hin"] = "Hindi",
|
||||||
|
["ro"] = "Rumänisch", ["rum"] = "Rumänisch", ["ron"] = "Rumänisch",
|
||||||
|
["uk"] = "Ukrainisch", ["ukr"] = "Ukrainisch",
|
||||||
|
["th"] = "Thailändisch", ["tha"] = "Thailändisch",
|
||||||
|
["la"] = "Latein", ["lat"] = "Latein",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -53,7 +53,21 @@ public sealed record MovieMetadata(
|
|||||||
///
|
///
|
||||||
/// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da.
|
/// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
RemoteImage? Logo = null);
|
RemoteImage? Logo = null,
|
||||||
|
|
||||||
|
/// <summary>Der Titel in der Sprache des Originals.</summary>
|
||||||
|
string? OriginalTitle = null,
|
||||||
|
|
||||||
|
/// <summary>In welcher Sprache diese Angaben geholt wurden.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit der Server es hinterher noch weiss.</b> Die Sprache steht in
|
||||||
|
/// den Einstellungen und ändert sich; was einmal geholt wurde, trägt sie
|
||||||
|
/// danach nirgends. „Alles ist noch auf Englisch, obwohl Deutsch
|
||||||
|
/// eingestellt ist" liess sich deshalb nicht beantworten, sondern nur
|
||||||
|
/// bestreiten. Die Quelle weiss es — also gibt sie es mit.
|
||||||
|
/// </remarks>
|
||||||
|
string? Language = null);
|
||||||
|
|
||||||
public sealed record SeriesMetadata(
|
public sealed record SeriesMetadata(
|
||||||
int ProviderId, string Title, int? Year, string? Overview,
|
int ProviderId, string Title, int? Year, string? Overview,
|
||||||
@@ -74,9 +88,24 @@ public sealed record SeriesMetadata(
|
|||||||
///
|
///
|
||||||
/// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da.
|
/// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
RemoteImage? Logo = null);
|
RemoteImage? Logo = null,
|
||||||
|
|
||||||
public sealed record EpisodeMetadata(string? Title, string? Overview, RemoteImage? Still);
|
/// <summary>Der Titel in der Sprache des Originals.</summary>
|
||||||
|
string? OriginalTitle = null,
|
||||||
|
|
||||||
|
/// <summary>In welcher Sprache diese Angaben geholt wurden.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit der Server es hinterher noch weiss.</b> Die Sprache steht in
|
||||||
|
/// den Einstellungen und ändert sich; was einmal geholt wurde, trägt sie
|
||||||
|
/// danach nirgends. „Alles ist noch auf Englisch, obwohl Deutsch
|
||||||
|
/// eingestellt ist" liess sich deshalb nicht beantworten, sondern nur
|
||||||
|
/// bestreiten. Die Quelle weiss es — also gibt sie es mit.
|
||||||
|
/// </remarks>
|
||||||
|
string? Language = null);
|
||||||
|
|
||||||
|
public sealed record EpisodeMetadata(
|
||||||
|
string? Title, string? Overview, RemoteImage? Still, string? Language = null);
|
||||||
|
|
||||||
/// <summary>Woher Titel, Beschreibungen und Bilder kommen.</summary>
|
/// <summary>Woher Titel, Beschreibungen und Bilder kommen.</summary>
|
||||||
///
|
///
|
||||||
@@ -119,8 +148,28 @@ public interface IMetadataProvider
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
Task<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default);
|
Task<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Holt einen Titel in einer bestimmten Sprache.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Für die Übersetzungstabelle, nicht für den Scan.</b> Der Scan nimmt
|
||||||
|
/// die Sprache des Servers; dies hier füllt nach, was ein Benutzer mit
|
||||||
|
/// abweichender Sprache sehen will.
|
||||||
|
/// </remarks>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Mit Vorgabe.</b> Ein Anbieter, der keine Sprachen kennt, liefert
|
||||||
|
/// schlicht die des Servers — das ist ehrlicher als eine leere Antwort und
|
||||||
|
/// erspart jedem Test-Doppelgänger eine Pflichtmethode, die er nicht
|
||||||
|
/// braucht.
|
||||||
|
/// </remarks>
|
||||||
|
Task<MovieMetadata?> GetMovieAsync(int providerId, string sprache, CancellationToken ct = default) =>
|
||||||
|
GetMovieAsync(providerId, ct);
|
||||||
|
|
||||||
Task<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default);
|
Task<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Holt eine Serie in einer bestimmten Sprache.</summary>
|
||||||
|
Task<SeriesMetadata?> GetSeriesAsync(int providerId, string sprache, CancellationToken ct = default) =>
|
||||||
|
GetSeriesAsync(providerId, ct);
|
||||||
|
|
||||||
/// <summary>Mehrere Vorschläge zur Auswahl — für das Korrigieren von Hand.</summary>
|
/// <summary>Mehrere Vorschläge zur Auswahl — für das Korrigieren von Hand.</summary>
|
||||||
Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string title, int? year, CancellationToken ct = default);
|
Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string title, int? year, CancellationToken ct = default);
|
||||||
|
|
||||||
@@ -148,6 +197,38 @@ public interface IImageStore
|
|||||||
{
|
{
|
||||||
/// <summary>Holt das Bild und gibt seinen Schlüssel zurück — oder <c>null</c>.</summary>
|
/// <summary>Holt das Bild und gibt seinen Schlüssel zurück — oder <c>null</c>.</summary>
|
||||||
Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default);
|
Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Legt ein hochgeladenes Bild ab.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Für eigene Plakate. Dieselbe Ablage wie für geholte — damit ein Bild
|
||||||
|
/// unter derselben Adresse liegt, egal woher es stammt.
|
||||||
|
/// </remarks>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Mit Vorgabe.</b> Eine Ablage, die nur Geholtes kennt, lehnt das
|
||||||
|
/// Hochladen ab — das ist ehrlicher als eine Pflichtmethode, die jeder
|
||||||
|
/// Test-Doppelgänger nachbauen müsste, obwohl er sie nie braucht.
|
||||||
|
/// </remarks>
|
||||||
|
Task<string?> StoreStreamAsync(Stream inhalt, string? medientyp, CancellationToken ct = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Was Bilder trägt — Film oder Serie.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit das Setzen eines Plakats nur einmal geschrieben werden muss.</b>
|
||||||
|
/// Film und Serie tragen dieselben sechs Eigenschaften; ohne gemeinsame
|
||||||
|
/// Schnittstelle stünde derselbe Code zweimal da und liefe irgendwann
|
||||||
|
/// auseinander.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IBebildert
|
||||||
|
{
|
||||||
|
string? PosterImage { get; set; }
|
||||||
|
string? BackdropImage { get; set; }
|
||||||
|
string? LogoImage { get; set; }
|
||||||
|
bool PosterPinned { get; set; }
|
||||||
|
bool BackdropPinned { get; set; }
|
||||||
|
bool LogoPinned { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ein Bild aus der Ablage wieder herausgeben.</summary>
|
/// <summary>Ein Bild aus der Ablage wieder herausgeben.</summary>
|
||||||
@@ -160,3 +241,12 @@ public interface IImageReader
|
|||||||
{
|
{
|
||||||
Task<Stream?> OpenAsync(string schlüssel, CancellationToken ct = default);
|
Task<Stream?> OpenAsync(string schlüssel, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein Bild zur Auswahl — Plakat, Hintergrund oder Schriftzug.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Mit Vorschau und Sprache.</b> Wer ein Plakat aussucht, sucht meist eins
|
||||||
|
/// in seiner Sprache, und er will es sehen, bevor er es nimmt — nicht danach.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record Bildkandidat(
|
||||||
|
string Kind, string Url, string ThumbUrl, string? Language, double? Vote);
|
||||||
|
|||||||
@@ -25,23 +25,15 @@ namespace Flaro.Core.Playback;
|
|||||||
/// eine Stelle, an der man weitermachen kann, schliessen einander nicht aus —
|
/// eine Stelle, an der man weitermachen kann, schliessen einander nicht aus —
|
||||||
/// wer einen Film ein zweites Mal anfängt, soll beides haben.
|
/// wer einen Film ein zweites Mal anfängt, soll beides haben.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Was einstellbar ist, steht in <see cref="Wiedergaberegeln"/>.</b> Es
|
||||||
|
/// gibt inzwischen eine Untergrenze fürs Weiterschauen — aber sie entscheidet
|
||||||
|
/// nur, ob der Titel in der Reihe steht, nicht ob die Stelle überlebt. Das
|
||||||
|
/// bleibt der Unterschied.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class ProgressRule
|
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>
|
/// <summary>Was aus einer Meldung folgt.</summary>
|
||||||
/// <param name="Position">Die Stelle, die abgelegt wird — nie null gesetzt.</param>
|
/// <param name="Position">Die Stelle, die abgelegt wird — nie null gesetzt.</param>
|
||||||
/// <param name="Watched">Ob der Titel damit als gesehen gilt.</param>
|
/// <param name="Watched">Ob der Titel damit als gesehen gilt.</param>
|
||||||
@@ -57,7 +49,8 @@ public static class ProgressRule
|
|||||||
/// Laufzeit fehlt, ist der Fehler, den man nicht zurücknehmen kann, ohne
|
/// Laufzeit fehlt, ist der Fehler, den man nicht zurücknehmen kann, ohne
|
||||||
/// es zu merken.
|
/// es zu merken.
|
||||||
/// </param>
|
/// </param>
|
||||||
public static Outcome Apply(TimeSpan position, TimeSpan? runtime)
|
/// <param name="regeln">Ab welchem Anteil ein Titel als gesehen gilt.</param>
|
||||||
|
public static Outcome Apply(TimeSpan position, TimeSpan? runtime, Wiedergaberegeln regeln)
|
||||||
{
|
{
|
||||||
// Eine negative Stelle gibt es nicht, und eine jenseits des Endes
|
// Eine negative Stelle gibt es nicht, und eine jenseits des Endes
|
||||||
// auch nicht. Beides kommt vor: der eine Player meldet beim Spulen
|
// auch nicht. Beides kommt vor: der eine Player meldet beim Spulen
|
||||||
@@ -69,7 +62,7 @@ public static class ProgressRule
|
|||||||
|
|
||||||
if (stelle > laenge) stelle = laenge;
|
if (stelle > laenge) stelle = laenge;
|
||||||
|
|
||||||
return new Outcome(stelle, Watched: stelle >= laenge * WatchedFraction);
|
return new Outcome(stelle, Watched: stelle >= laenge * regeln.GesehenAnteil);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ob ein Zustand in „Weiterschauen" gehört.</summary>
|
/// <summary>Ob ein Zustand in „Weiterschauen" gehört.</summary>
|
||||||
@@ -80,9 +73,15 @@ public static class ProgressRule
|
|||||||
/// aber die Prüfung kann beides gegeneinanderhalten. Läuft das auseinander,
|
/// aber die Prüfung kann beides gegeneinanderhalten. Läuft das auseinander,
|
||||||
/// fällt es hier auf und nicht beim Benutzer.
|
/// fällt es hier auf und nicht beim Benutzer.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static bool BelongsInContinueWatching(UserItemState zustand) =>
|
public static bool BelongsInContinueWatching(UserItemState zustand, Wiedergaberegeln regeln) =>
|
||||||
zustand is { Dismissed: false, Position.Ticks: > 0 }
|
zustand is { Dismissed: false, Position.Ticks: > 0 }
|
||||||
|
// **Die Untergrenze ist einstellbar und war es nicht.** Ein Tick
|
||||||
|
// genügte: wer im Aufmacher eine Sekunde hineinschaute, fand den
|
||||||
|
// Titel danach in „Weiterschauen". Was oben über Jellyfin steht,
|
||||||
|
// gilt weiter — die Stelle wird nicht weggeworfen, der Titel steht
|
||||||
|
// nur nicht in der Reihe.
|
||||||
|
&& zustand.Position >= regeln.FortsetzenAb
|
||||||
&& (zustand.Runtime is not { } laenge
|
&& (zustand.Runtime is not { } laenge
|
||||||
|| laenge <= TimeSpan.Zero
|
|| laenge <= TimeSpan.Zero
|
||||||
|| zustand.Position < laenge * WatchedFraction);
|
|| zustand.Position < laenge * regeln.GesehenAnteil);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
namespace Flaro.Core.Playback;
|
||||||
|
|
||||||
|
/// <summary>Wie lange an einem Tag an einem Titel gesehen wurde.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum es das überhaupt gibt.</b> <see cref="UserItemState"/> ist eine
|
||||||
|
/// Zeile je Titel und Nutzer — der <i>letzte</i> Stand. Wer einen Film zweimal
|
||||||
|
/// sieht, überschreibt den ersten Durchgang; wann etwas lief, weiß der Server
|
||||||
|
/// nur für das jeweils letzte Mal. Für „wie viel habe ich letzte Woche
|
||||||
|
/// geschaut" reicht das nicht, und raten kann man es nicht.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum Tagesbündel und keine Sitzungen.</b> Eine Sitzungstabelle braucht
|
||||||
|
/// Anfang <i>und</i> Ende, und das Ende kommt oft nie an: der Browser wird
|
||||||
|
/// zugemacht, das Telefon schläft ein. Dann stehen offene Sitzungen herum, die
|
||||||
|
/// niemand schliesst, und jede Auswertung muss raten, was davon zählt.
|
||||||
|
/// Tagesbündel haben diesen Lebenslauf nicht — es wird nur addiert. Für die
|
||||||
|
/// Fragen, um die es geht (wie viel insgesamt, wie viel je Woche, was
|
||||||
|
/// zuletzt), reicht das vollständig.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Es fängt bei null an.</b> Was vor dem Einbau lief, steht nicht darin —
|
||||||
|
/// die Daten gibt es nicht und lassen sich nicht nachträglich erfinden. Was
|
||||||
|
/// sofort vollständig ist, steht weiter in <see cref="UserItemState"/>:
|
||||||
|
/// gesehen ja/nein, wie oft, wann zuletzt.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class Sehtag
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
public required Guid UserId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Entweder ein Film …</summary>
|
||||||
|
public Guid? MovieId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>… oder eine Folge. Nie beides, nie keines.</summary>
|
||||||
|
public Guid? EpisodeId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Der Tag in der Zeitzone des Servers.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Ortszeit, nicht UTC.</b> Wer um halb eins nachts einen Film zu Ende
|
||||||
|
/// sieht, hat das am Abend getan und nicht am nächsten Morgen — eine
|
||||||
|
/// Wochenkurve nach UTC verschöbe jeden späten Abend auf den Folgetag.
|
||||||
|
/// Der Server hat eine Zeitzone, und die ist die des Haushalts.
|
||||||
|
/// </remarks>
|
||||||
|
public required DateOnly Tag { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Wie viele Sekunden an diesem Tag tatsächlich liefen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Tatsächlich, nicht übersprungen.</b> Gezählt wird der Zuwachs
|
||||||
|
/// zwischen zwei Meldungen, gedeckelt auf die Zeit, die dazwischen
|
||||||
|
/// verstrichen ist. Ein Sprung von Minute 5 auf Minute 90 ergäbe sonst
|
||||||
|
/// 85 Minuten Sehzeit in zehn Sekunden.
|
||||||
|
/// </remarks>
|
||||||
|
public double Sekunden { get; set; }
|
||||||
|
}
|
||||||
@@ -23,6 +23,25 @@ public sealed class UserItemState
|
|||||||
public Guid? MovieId { get; init; }
|
public Guid? MovieId { get; init; }
|
||||||
public Guid? EpisodeId { get; init; }
|
public Guid? EpisodeId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Die Serie als Ganzes — für Favorit, Bewertung und das Häkchen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Eine Serie wird nicht angeschaut, ihre Folgen werden es</b> — das
|
||||||
|
/// bleibt richtig, und deshalb steht hier kein Fortschritt. Was hier steht,
|
||||||
|
/// ist, was sich an einer Folge nicht festmachen lässt: ob die Serie ein
|
||||||
|
/// Favorit ist, wie der Benutzer sie bewertet, und ob er sie für erledigt
|
||||||
|
/// erklärt hat.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum überhaupt eine Zeile und nicht nur gezählte Folgen.</b> Ohne
|
||||||
|
/// sie lief jede Aktion auf einer Serienkachel — Häkchen, Favorit — in eine
|
||||||
|
/// 404: der Server kannte zu dieser Kennung keinen Zustand. Sichtbar war
|
||||||
|
/// davon nichts; der Klick tat einfach nichts.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public Guid? SeriesId { get; init; }
|
||||||
|
|
||||||
/// <summary>Wo der Benutzer steht.</summary>
|
/// <summary>Wo der Benutzer steht.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace Flaro.Core.Playback;
|
||||||
|
|
||||||
|
/// <summary>Wann ein Titel angefangen ist und wann er als gesehen gilt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Zwei Zahlen, die niemand raten kann.</b> Wie lange man hineinschauen
|
||||||
|
/// muss, damit es zählt, und ab welchem Anteil ein Film durch ist — das hängt
|
||||||
|
/// davon ab, wie jemand schaut, nicht davon, was technisch richtig wäre. Beide
|
||||||
|
/// standen als Konstante im Quelltext; jetzt stehen sie in den Einstellungen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Als Wert, nicht als Abfrage.</b> <see cref="ProgressRule"/> bleibt damit
|
||||||
|
/// ein reiner Typ: er rechnet, er liest nichts. Prüfbar ohne Datenbank, und
|
||||||
|
/// derselbe Satz Regeln gilt für eine Meldung und für die Neubewertung aller
|
||||||
|
/// vorhandenen Zeilen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
///
|
||||||
|
/// <param name="FortsetzenAb">
|
||||||
|
/// Wie weit man gekommen sein muss, damit ein Titel in „Weiterschauen" steht.
|
||||||
|
/// <see cref="TimeSpan.Zero"/> heisst: jede angefangene Sekunde zählt.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="GesehenAnteil">Ab welchem Anteil der Laufzeit ein Titel gesehen ist.</param>
|
||||||
|
public readonly record struct Wiedergaberegeln(TimeSpan FortsetzenAb, double GesehenAnteil)
|
||||||
|
{
|
||||||
|
/// <summary>Was gilt, solange niemand etwas anderes eingestellt hat.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Neunzig Sekunden, nicht null.</b> Ohne Untergrenze genügte ein
|
||||||
|
/// einziger Tick: wer im Aufmacher eine Sekunde hineinschaute, fand den
|
||||||
|
/// Titel danach in „Weiterschauen" wieder. Eineinhalb Minuten sind lang
|
||||||
|
/// genug, dass niemand aus Versehen hineinfällt, und kurz genug, dass eine
|
||||||
|
/// abgebrochene Folge noch zählt.
|
||||||
|
/// </remarks>
|
||||||
|
public static readonly Wiedergaberegeln Vorgabe = new(TimeSpan.FromSeconds(90), 0.90);
|
||||||
|
|
||||||
|
/// <summary>Dieselben Regeln mit geprüften Werten.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Was aus der Datenbank kommt, hat dort irgendwann jemand hineingeschrieben.
|
||||||
|
/// Ein Anteil von 0 hiesse „alles ist gesehen", einer von 2 „nichts".
|
||||||
|
/// </remarks>
|
||||||
|
public Wiedergaberegeln Geprueft() => new(
|
||||||
|
FortsetzenAb < TimeSpan.Zero ? TimeSpan.Zero : FortsetzenAb,
|
||||||
|
Math.Clamp(GesehenAnteil, 0.5, 1.0));
|
||||||
|
}
|
||||||
@@ -19,5 +19,48 @@ public sealed class User
|
|||||||
|
|
||||||
public bool IsAdministrator { get; set; }
|
public bool IsAdministrator { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Dem Besitzer kann niemand das Adminrecht nehmen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Damit sich ein Server nicht selbst aussperren kann.</b> Sobald
|
||||||
|
/// Administratoren einander das Recht entziehen können, gibt es einen
|
||||||
|
/// Zustand ohne jeden Administrator — und aus dem führt kein Weg zurück
|
||||||
|
/// ausser dem an der Datenbank. Ein Konto, das ausgenommen ist, schliesst
|
||||||
|
/// diesen Zustand aus.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Es ist das erste Konto</b>, also das, mit dem der Server
|
||||||
|
/// eingerichtet wurde. Übertragen lässt es sich — an einen anderen
|
||||||
|
/// Administrator, und nur vom Besitzer selbst.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public bool IsOwner { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Die bevorzugte Sprache — <c>null</c> heisst: die des Servers.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Und sie ist nicht kostenlos.</b> Metadaten werden in der Sprache des
|
||||||
|
/// Servers geholt und gespeichert. Wer eine andere wählt, bekommt, was in
|
||||||
|
/// der Übersetzungstabelle liegt — und für den Rest die Serversprache.
|
||||||
|
/// Darauf wird beim Umstellen deutlich hingewiesen; es stillschweigend
|
||||||
|
/// halb zu übersetzen wäre schlechter als gar nicht.
|
||||||
|
/// </remarks>
|
||||||
|
public string? Language { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Das eigene Bild — der Schlüssel aus derselben Ablage wie Plakate.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Kein eigener Speicher für Profilbilder.</b> Es ist ein Bild wie
|
||||||
|
/// jedes andere: derselbe Ordner, dieselbe Adresse, derselbe
|
||||||
|
/// Zwischenspeicher im Browser. Ein zweiter Weg hiesse ein zweites Mal
|
||||||
|
/// Pfadprüfung, Grössengrenze und Aufräumen — dreimal dieselbe Arbeit für
|
||||||
|
/// ein Bild von 200 Pixeln.
|
||||||
|
///
|
||||||
|
/// <c>null</c> heisst: der Buchstabenkreis. Der ist keine Notlösung,
|
||||||
|
/// sondern eine gültige Antwort — niemand muss ein Bild haben.
|
||||||
|
/// </remarks>
|
||||||
|
public string? AvatarImage { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,51 @@ namespace Flaro.Data;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbContext(options)
|
public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
|
/// <summary>Ob diese Anfrage auch Unveröffentlichtes sehen darf.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Hier und nicht in zwanzig Abfragen.</b> Auf Filme und Folgen wird an
|
||||||
|
/// rund zwanzig Stellen in vier Dateien zugegriffen. Schriebe jede ihren
|
||||||
|
/// eigenen Filter, reichte eine vergessene — und auffallen würde es nicht
|
||||||
|
/// beim Programmieren, sondern wenn jemand im Haushalt einen Titel sieht,
|
||||||
|
/// den er nicht sehen sollte. Ein globaler Abfragefilter lässt sich nicht
|
||||||
|
/// vergessen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Vorgabe <c>true</c>, gesetzt wird je Anfrage.</b> Der Scanner und
|
||||||
|
/// die Metadaten laufen ohne Benutzer und müssen alles sehen — sonst legt
|
||||||
|
/// der nächste Lauf einen wartenden Titel ein zweites Mal an. Für jede
|
||||||
|
/// HTTP-Anfrage setzt die Middleware den Wert, bevor irgendein Endpunkt
|
||||||
|
/// läuft; dass sie das tut, hält ein Test fest.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public bool AuchUnveroeffentlichtes { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Der Zeitpunkt, gegen den geplante Titel geprüft werden.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Am Kontext und nicht als <c>DateTimeOffset.UtcNow</c> im Filter: ein
|
||||||
|
/// Aufruf mitten im Ausdruck liesse sich nicht nach SQL übersetzen, und
|
||||||
|
/// in Tests wäre die Zeit nicht zu stellen.
|
||||||
|
/// </remarks>
|
||||||
|
public DateTimeOffset Jetzt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
||||||
public DbSet<AuthCode> AuthCodes => Set<AuthCode>();
|
public DbSet<AuthCode> AuthCodes => Set<AuthCode>();
|
||||||
|
|
||||||
|
/// <summary>Links, mit denen sich jemand selbst ein Konto anlegt.</summary>
|
||||||
|
public DbSet<Einladung> Einladungen => Set<Einladung>();
|
||||||
public DbSet<Library> Libraries => Set<Library>();
|
public DbSet<Library> Libraries => Set<Library>();
|
||||||
public DbSet<ServerSetting> Settings => Set<ServerSetting>();
|
public DbSet<ServerSetting> Settings => Set<ServerSetting>();
|
||||||
|
|
||||||
|
/// <summary>Wie lange an welchem Tag gesehen wurde — siehe <see cref="Sehtag"/>.</summary>
|
||||||
|
public DbSet<Sehtag> Sehtage => Set<Sehtag>();
|
||||||
|
|
||||||
|
/// <summary>Was Benutzer an Titeln gemeldet haben — siehe <see cref="Meldung"/>.</summary>
|
||||||
|
public DbSet<Meldung> Meldungen => Set<Meldung>();
|
||||||
|
|
||||||
public DbSet<MediaFile> MediaFiles => Set<MediaFile>();
|
public DbSet<MediaFile> MediaFiles => Set<MediaFile>();
|
||||||
public DbSet<Movie> Movies => Set<Movie>();
|
public DbSet<Movie> Movies => Set<Movie>();
|
||||||
public DbSet<Series> Series => Set<Series>();
|
public DbSet<Series> Series => Set<Series>();
|
||||||
@@ -38,6 +77,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
|
public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
|
||||||
public DbSet<Person> People => Set<Person>();
|
public DbSet<Person> People => Set<Person>();
|
||||||
public DbSet<Credit> Credits => Set<Credit>();
|
public DbSet<Credit> Credits => Set<Credit>();
|
||||||
|
public DbSet<ItemTranslation> Translations => Set<ItemTranslation>();
|
||||||
|
|
||||||
/// <summary>Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.</summary>
|
/// <summary>Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.</summary>
|
||||||
///
|
///
|
||||||
@@ -62,7 +102,10 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
// Zwei Konten „Guido" und „guido" wären eine Falle, keine Funktion.
|
// Zwei Konten „Guido" und „guido" wären eine Falle, keine Funktion.
|
||||||
e.HasIndex(x => x.UserName).IsUnique();
|
e.HasIndex(x => x.UserName).IsUnique();
|
||||||
e.Property(x => x.UserName).HasMaxLength(64).UseCollation("NOCASE");
|
e.Property(x => x.UserName).HasMaxLength(64).UseCollation("NOCASE");
|
||||||
|
e.Property(x => x.Language).HasMaxLength(16);
|
||||||
e.Property(x => x.PasswordHash).HasMaxLength(256);
|
e.Property(x => x.PasswordHash).HasMaxLength(256);
|
||||||
|
// Ein Hash aus 64 Hexzeichen, wie jeder Bildschlüssel.
|
||||||
|
e.Property(x => x.AvatarImage).HasMaxLength(64);
|
||||||
});
|
});
|
||||||
|
|
||||||
b.Entity<DeviceToken>(e =>
|
b.Entity<DeviceToken>(e =>
|
||||||
@@ -89,6 +132,24 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
e.Property(x => x.DeviceId).HasMaxLength(128);
|
e.Property(x => x.DeviceId).HasMaxLength(128);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
b.Entity<Einladung>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
// Gesucht wird nur über den Fingerabdruck — und eindeutig, damit
|
||||||
|
// zwei Links niemals dasselbe Konto füttern können.
|
||||||
|
e.HasIndex(x => x.TokenFingerprint).IsUnique();
|
||||||
|
e.Property(x => x.TokenFingerprint).HasMaxLength(64);
|
||||||
|
|
||||||
|
// Die Liste in der Verwaltung: die neuesten zuerst.
|
||||||
|
e.HasIndex(x => x.CreatedAt);
|
||||||
|
|
||||||
|
// Wer einlädt und später gelöscht wird, nimmt seine offenen
|
||||||
|
// Einladungen mit. Ein Link ohne Absender ist keiner.
|
||||||
|
e.HasOne<User>().WithMany().HasForeignKey(x => x.CreatedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
b.Entity<Library>(e =>
|
b.Entity<Library>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
@@ -113,6 +174,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
// ein zweimal angestossener Scan jede Datei zweimal an, und die
|
// ein zweimal angestossener Scan jede Datei zweimal an, und die
|
||||||
// Mediathek wächst bei jedem Lauf.
|
// Mediathek wächst bei jedem Lauf.
|
||||||
e.HasIndex(x => new { x.LibraryId, x.Path }).IsUnique();
|
e.HasIndex(x => new { x.LibraryId, x.Path }).IsUnique();
|
||||||
|
e.Property(x => x.VersionLabel).HasMaxLength(64);
|
||||||
e.HasIndex(x => x.MovieId);
|
e.HasIndex(x => x.MovieId);
|
||||||
e.HasIndex(x => x.EpisodeId);
|
e.HasIndex(x => x.EpisodeId);
|
||||||
e.Property(x => x.Path).HasMaxLength(1024);
|
e.Property(x => x.Path).HasMaxLength(1024);
|
||||||
@@ -125,11 +187,31 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
e.HasIndex(x => new { x.LibraryId, x.SortTitleValue });
|
e.HasIndex(x => new { x.LibraryId, x.SortTitleValue });
|
||||||
|
|
||||||
|
// **Ein Film kommt je Mediathek genau einmal vor.**
|
||||||
|
//
|
||||||
|
// Der Scan ordnet über den Dateinamen zu, lange bevor jemand
|
||||||
|
// weiss, welcher Film das ist — weichen zwei Dateien darin ab,
|
||||||
|
// entstehen zwei Einträge, die anschliessend beide demselben
|
||||||
|
// TMDB-Eintrag zugeordnet werden. Hier steht die Regel, die das
|
||||||
|
// unmöglich macht, statt nur Code, der sie einhalten soll.
|
||||||
|
//
|
||||||
|
// Gefiltert, weil ohne Metadaten alle Einträge `null` tragen und
|
||||||
|
// ein ungefilterter Index dann nur einen einzigen zuliesse.
|
||||||
|
e.HasIndex(x => new { x.LibraryId, x.TmdbId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
e.Property(x => x.Title).HasMaxLength(512);
|
e.Property(x => x.Title).HasMaxLength(512);
|
||||||
|
e.Property(x => x.OriginalTitle).HasMaxLength(512);
|
||||||
e.Property(x => x.SortTitleValue).HasMaxLength(512);
|
e.Property(x => x.SortTitleValue).HasMaxLength(512);
|
||||||
e.Property(x => x.ImdbId).HasMaxLength(32);
|
e.Property(x => x.ImdbId).HasMaxLength(32);
|
||||||
e.Property(x => x.Tagline).HasMaxLength(512);
|
e.Property(x => x.Tagline).HasMaxLength(512);
|
||||||
e.Property(x => x.ContentRating).HasMaxLength(16);
|
e.Property(x => x.ContentRating).HasMaxLength(16);
|
||||||
|
e.Property(x => x.MetadataLanguage).HasMaxLength(16);
|
||||||
|
// Gefragt wird danach genau einmal je Ansicht — aber über den
|
||||||
|
// ganzen Bestand. Ohne Index liest SQLite dafür jede Zeile.
|
||||||
|
e.HasIndex(x => new { x.LibraryId, x.MetadataLanguage });
|
||||||
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
|
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
@@ -138,11 +220,31 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
e.HasIndex(x => new { x.LibraryId, x.SortTitleValue });
|
e.HasIndex(x => new { x.LibraryId, x.SortTitleValue });
|
||||||
|
|
||||||
|
// **Eine Serie kommt je Mediathek genau einmal vor.**
|
||||||
|
//
|
||||||
|
// Der Scan ordnet über den Dateinamen zu, lange bevor jemand
|
||||||
|
// weiss, welcher Film das ist — weichen zwei Dateien darin ab,
|
||||||
|
// entstehen zwei Einträge, die anschliessend beide demselben
|
||||||
|
// TMDB-Eintrag zugeordnet werden. Hier steht die Regel, die das
|
||||||
|
// unmöglich macht, statt nur Code, der sie einhalten soll.
|
||||||
|
//
|
||||||
|
// Gefiltert, weil ohne Metadaten alle Einträge `null` tragen und
|
||||||
|
// ein ungefilterter Index dann nur einen einzigen zuliesse.
|
||||||
|
e.HasIndex(x => new { x.LibraryId, x.TmdbId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
e.Property(x => x.Title).HasMaxLength(512);
|
e.Property(x => x.Title).HasMaxLength(512);
|
||||||
|
e.Property(x => x.OriginalTitle).HasMaxLength(512);
|
||||||
e.Property(x => x.SortTitleValue).HasMaxLength(512);
|
e.Property(x => x.SortTitleValue).HasMaxLength(512);
|
||||||
e.Property(x => x.ImdbId).HasMaxLength(32);
|
e.Property(x => x.ImdbId).HasMaxLength(32);
|
||||||
e.Property(x => x.Tagline).HasMaxLength(512);
|
e.Property(x => x.Tagline).HasMaxLength(512);
|
||||||
e.Property(x => x.ContentRating).HasMaxLength(16);
|
e.Property(x => x.ContentRating).HasMaxLength(16);
|
||||||
|
e.Property(x => x.MetadataLanguage).HasMaxLength(16);
|
||||||
|
// Gefragt wird danach genau einmal je Ansicht — aber über den
|
||||||
|
// ganzen Bestand. Ohne Index liest SQLite dafür jede Zeile.
|
||||||
|
e.HasIndex(x => new { x.LibraryId, x.MetadataLanguage });
|
||||||
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
|
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
@@ -203,6 +305,54 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// **Die Freigaberegel, einmal und für jede Abfrage.**
|
||||||
|
//
|
||||||
|
// <b>Zwei Zustände, nicht einer.</b> Ohne Zeitpunkt wartet ein Titel
|
||||||
|
// auf eine Entscheidung — den soll niemand sehen, er ist noch nicht
|
||||||
|
// einmal geprüft. Mit einem Zeitpunkt in der Zukunft ist die
|
||||||
|
// Entscheidung gefallen: er kommt, nur noch nicht. Den zu verstecken
|
||||||
|
// hiesse, eine Vorfreude zu unterschlagen, die der Sinn des Planens
|
||||||
|
// ist — bei Netflix steht „Ab 14. März" auf der Kachel, und genau das
|
||||||
|
// soll hier auch stehen.
|
||||||
|
//
|
||||||
|
// <b>Sichtbar heisst nicht abspielbar.</b> Dass die Datei vor dem
|
||||||
|
// Zeitpunkt nicht herausgeht, steht nicht hier, sondern bei der
|
||||||
|
// Auslieferung — ein Filter, der etwas zeigt, darf nicht zugleich der
|
||||||
|
// sein, der es herausgibt.
|
||||||
|
//
|
||||||
|
// Gerechnet statt gespeichert: damit stimmt es nach einem Neustart,
|
||||||
|
// nach einer Zeitumstellung und auch dann, wenn der Server zum
|
||||||
|
// geplanten Zeitpunkt gar nicht lief.
|
||||||
|
b.Entity<Movie>().HasQueryFilter(m =>
|
||||||
|
AuchUnveroeffentlichtes || m.PublishAt != null);
|
||||||
|
|
||||||
|
b.Entity<Episode>().HasQueryFilter(f =>
|
||||||
|
AuchUnveroeffentlichtes || f.PublishAt != null);
|
||||||
|
|
||||||
|
// **Die Serie hängt an ihren Folgen.** Ein eigenes Feld gäbe zwei
|
||||||
|
// Wahrheiten — eine veröffentlichte Serie, deren Folgen alle noch
|
||||||
|
// warten, wäre ein leerer Eintrag in der Mediathek.
|
||||||
|
b.Entity<Series>().HasQueryFilter(s =>
|
||||||
|
AuchUnveroeffentlichtes
|
||||||
|
|| Episodes.Any(f => f.SeriesId == s.Id && f.PublishAt != null));
|
||||||
|
|
||||||
|
b.Entity<ItemTranslation>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.Property(x => x.Language).HasMaxLength(16);
|
||||||
|
e.Property(x => x.Title).HasMaxLength(512);
|
||||||
|
e.Property(x => x.Tagline).HasMaxLength(512);
|
||||||
|
|
||||||
|
// Je Titel und Sprache genau eine Zeile — sonst sammeln sich
|
||||||
|
// Übersetzungen an, und welche gezeigt wird, entscheidet der Zufall.
|
||||||
|
e.HasIndex(x => new { x.MovieId, x.Language }).IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
e.HasIndex(x => new { x.SeriesId, x.Language }).IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
e.HasIndex(x => new { x.EpisodeId, x.Language }).IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
});
|
||||||
|
|
||||||
b.Entity<UserItemState>(e =>
|
b.Entity<UserItemState>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
@@ -220,6 +370,8 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
.HasFilter("\"MovieId\" IS NOT NULL");
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
e.HasIndex(x => new { x.UserId, x.EpisodeId }).IsUnique()
|
e.HasIndex(x => new { x.UserId, x.EpisodeId }).IsUnique()
|
||||||
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
e.HasIndex(x => new { x.UserId, x.SeriesId }).IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
// Der Weg von „Weiterschauen": die offenen Zeilen eines
|
// Der Weg von „Weiterschauen": die offenen Zeilen eines
|
||||||
// Benutzers, neueste zuerst. Ohne Index ein vollständiger
|
// Benutzers, neueste zuerst. Ohne Index ein vollständiger
|
||||||
@@ -232,6 +384,69 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
// gibt, ist keine Erinnerung, sondern Datenmüll.
|
// gibt, ist keine Erinnerung, sondern Datenmüll.
|
||||||
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
|
||||||
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Entity<Sehtag>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
// Je Benutzer, Tag und Titel eine Zeile — sonst addierte sich
|
||||||
|
// derselbe Nachmittag in zwei Zeilen, und keine davon wäre falsch.
|
||||||
|
e.HasIndex(x => new { x.UserId, x.Tag, x.MovieId }).IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
e.HasIndex(x => new { x.UserId, x.Tag, x.EpisodeId }).IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
// Der Weg jeder Auswertung: die Tage eines Benutzers in einem
|
||||||
|
// Zeitraum. Ohne Index ein vollständiger Durchlauf je Aufruf.
|
||||||
|
e.HasIndex(x => new { x.UserId, x.Tag });
|
||||||
|
|
||||||
|
// **Als Zahl, nicht als Text.** Aus demselben Grund wie bei den
|
||||||
|
// Zeitpunkten: „2026-10-6" sortierte sich als Text vor
|
||||||
|
// „2026-10-31", und eine Wochenkurve stünde durcheinander.
|
||||||
|
e.Property(x => x.Tag).HasConversion(
|
||||||
|
tag => tag.DayNumber, zahl => DateOnly.FromDayNumber(zahl));
|
||||||
|
|
||||||
|
e.HasOne<User>().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
// Verschwindet der Titel, verschwindet seine Sehzeit mit. Eine
|
||||||
|
// Statistik über Filme, die es nicht mehr gibt, zählt Stunden auf
|
||||||
|
// nichts.
|
||||||
|
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Entity<Meldung>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
// **Als Text, nicht als Zahl.** Eine 2 in der Datenbank sagt keinem
|
||||||
|
// Menschen etwas, und eine später eingeschobene Art machte aus
|
||||||
|
// jedem gespeicherten „Ton" stillschweigend „Untertitel".
|
||||||
|
e.Property(x => x.Art).HasConversion<string>().HasMaxLength(32);
|
||||||
|
|
||||||
|
// Zweitausend Zeichen: Platz für eine Schilderung mit Zeiten und
|
||||||
|
// Fassungen, zu wenig für einen Aufsatz. Zum Vergleich: „Der
|
||||||
|
// deutsche Ton setzt nach einer halben Minute aus, Englisch läuft
|
||||||
|
// weiter" sind 72.
|
||||||
|
e.Property(x => x.Kommentar).HasMaxLength(2000);
|
||||||
|
|
||||||
|
// Der Weg der Verwaltung: die offenen, älteste zuerst.
|
||||||
|
e.HasIndex(x => new { x.ErledigtAm, x.GemeldetAm });
|
||||||
|
// Der Weg des Melders: seine eigenen, neueste zuerst.
|
||||||
|
e.HasIndex(x => new { x.UserId, x.GemeldetAm });
|
||||||
|
|
||||||
|
// **Kein eindeutiger Index.** Zwei Meldungen zu demselben Film sind
|
||||||
|
// zwei Meldungen; sie zusammenzulegen hiesse, die zweite zu
|
||||||
|
// verschlucken.
|
||||||
|
|
||||||
|
e.HasOne<User>().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
// Verschwindet der Titel, verschwindet die Meldung dazu. Eine
|
||||||
|
// Aufgabe zu einem Film, den es nicht mehr gibt, ist keine mehr.
|
||||||
|
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
b.Entity<Episode>(e =>
|
b.Entity<Episode>(e =>
|
||||||
@@ -242,6 +457,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
|||||||
// und die hängen beide an diesem einen Eintrag.
|
// und die hängen beide an diesem einen Eintrag.
|
||||||
e.HasIndex(x => new { x.SeriesId, x.SeasonNumber, x.EpisodeNumber }).IsUnique();
|
e.HasIndex(x => new { x.SeriesId, x.SeasonNumber, x.EpisodeNumber }).IsUnique();
|
||||||
e.Property(x => x.Title).HasMaxLength(512);
|
e.Property(x => x.Title).HasMaxLength(512);
|
||||||
|
e.Property(x => x.MetadataLanguage).HasMaxLength(16);
|
||||||
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ public sealed record ScanReport(
|
|||||||
int Removed,
|
int Removed,
|
||||||
int Unrecognised,
|
int Unrecognised,
|
||||||
int Probed,
|
int Probed,
|
||||||
IReadOnlyList<WalkProblem> Problems);
|
IReadOnlyList<WalkProblem> Problems,
|
||||||
|
/// <summary>Wie viele Dateien dieser Lauf als verschwunden markiert hat.</summary>
|
||||||
|
int Missing = 0);
|
||||||
|
|
||||||
|
/// <summary>Der Scan hat abgebrochen, statt den Bestand für verschwunden zu erklären.</summary>
|
||||||
|
public sealed class BestandVerschwundenException(string nachricht) : Exception(nachricht);
|
||||||
|
|
||||||
/// <summary>Gleicht ab, was auf der Platte liegt, mit dem, was in der Datenbank steht.</summary>
|
/// <summary>Gleicht ab, was auf der Platte liegt, mit dem, was in der Datenbank steht.</summary>
|
||||||
///
|
///
|
||||||
@@ -33,14 +38,42 @@ public sealed record ScanReport(
|
|||||||
public sealed class LibraryScanner(
|
public sealed class LibraryScanner(
|
||||||
FlaroContext db, IMediaProbe sonde, TimeProvider clock, ILogger<LibraryScanner> log)
|
FlaroContext db, IMediaProbe sonde, TimeProvider clock, ILogger<LibraryScanner> log)
|
||||||
{
|
{
|
||||||
public async Task<ScanReport> ScanAsync(Guid libraryId, CancellationToken ct = default)
|
public async Task<ScanReport> ScanAsync(
|
||||||
|
Guid libraryId, IProgress<Scanschritt>? fortschritt = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var bibliothek = await db.Libraries.FindAsync([libraryId], ct)
|
var bibliothek = await db.Libraries.FindAsync([libraryId], ct)
|
||||||
?? throw new InvalidOperationException($"Keine Bibliothek mit der Kennung {libraryId}.");
|
?? throw new InvalidOperationException($"Keine Bibliothek mit der Kennung {libraryId}.");
|
||||||
|
|
||||||
var laufZeit = clock.GetUtcNow();
|
var laufZeit = clock.GetUtcNow();
|
||||||
|
_laufZeit = laufZeit;
|
||||||
|
_pruefungNoetig = bibliothek.ReviewNewItems;
|
||||||
|
|
||||||
|
// **Die Notbremse, und sie steht vor allem anderen.**
|
||||||
|
//
|
||||||
|
// Ist das Volume beim Start des NAS noch nicht eingehängt, liegt der
|
||||||
|
// Ordner leer oder gar nicht da. Ohne diese Prüfung erklärte der Lauf
|
||||||
|
// den gesamten Bestand für verschwunden — bei 458 Titeln samt
|
||||||
|
// Sehfortschritt aller Benutzer. Lieber gar kein Scan als der.
|
||||||
|
var warVorherDa = await db.MediaFiles.CountAsync(f => f.LibraryId == libraryId, ct);
|
||||||
|
|
||||||
|
if (!Directory.Exists(bibliothek.Path))
|
||||||
|
{
|
||||||
|
throw new BestandVerschwundenException(
|
||||||
|
$"Den Ordner {bibliothek.Path} gibt es nicht. Ist das Volume eingehängt? " +
|
||||||
|
"Es wurde nichts verändert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fortschritt?.Report(new Scanschritt(Scanphase.Suchen, bibliothek.Path));
|
||||||
var ergebnis = LibraryWalker.Walk(bibliothek.Path);
|
var ergebnis = LibraryWalker.Walk(bibliothek.Path);
|
||||||
|
|
||||||
|
if (ergebnis.Files.Count == 0 && warVorherDa > 0)
|
||||||
|
{
|
||||||
|
throw new BestandVerschwundenException(
|
||||||
|
$"In {bibliothek.Path} liegt keine einzige Mediendatei mehr, vorher waren es " +
|
||||||
|
$"{warVorherDa}. Der Lauf wurde abgebrochen, damit nicht der ganze Bestand " +
|
||||||
|
"als verschwunden gilt. Es wurde nichts verändert.");
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var problem in ergebnis.Problems)
|
foreach (var problem in ergebnis.Problems)
|
||||||
log.LogWarning("Scan: {Pfad} übersprungen — {Grund}", problem.Path, problem.Reason);
|
log.LogWarning("Scan: {Pfad} übersprungen — {Grund}", problem.Path, problem.Reason);
|
||||||
|
|
||||||
@@ -61,12 +94,59 @@ public sealed class LibraryScanner(
|
|||||||
? await db.Episodes.Where(f => serien.Select(s => s.Id).Contains(f.SeriesId)).ToListAsync(ct)
|
? await db.Episodes.Where(f => serien.Select(s => s.Id).Contains(f.SeriesId)).ToListAsync(ct)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
int neu = 0, geändert = 0, unerkannt = 0, untersucht = 0;
|
int neu = 0, geändert = 0, unerkannt = 0, untersucht = 0, verschoben = 0;
|
||||||
|
var seitLetztemSchreiben = 0;
|
||||||
|
|
||||||
|
// **Dateien nach Grösse und Änderungszeit, für das Erkennen von
|
||||||
|
// Verschiebungen.**
|
||||||
|
//
|
||||||
|
// Seit verschwundene Dateien markiert statt gelöscht werden, hinterlässt
|
||||||
|
// jede Umbenennung eine Leiche: die alte Zeile bleibt als „vermisst"
|
||||||
|
// stehen, die alte Folge gilt damit nicht als verwaist, und die alte
|
||||||
|
// Serie bleibt in der Mediathek. Nach dem Umbenennen eines
|
||||||
|
// Serienordners stand alles doppelt da.
|
||||||
|
//
|
||||||
|
// Grösse **und** Zeitstempel müssen stimmen. Grösse allein reichte
|
||||||
|
// nicht: zwei gleich grosse Dateien gibt es, und die zu vertauschen
|
||||||
|
// wäre schlimmer als eine Leiche.
|
||||||
|
// Auf Sekunden gerundet: der Zeitstempel geht durch die Datenbank und
|
||||||
|
// kommt nicht auf den Tick genau zurück. Ein Vergleich auf Gleichheit
|
||||||
|
// schlug deshalb immer fehl, und keine Verschiebung wurde je erkannt.
|
||||||
|
static (long, long) Merkmal(long bytes, DateTimeOffset geaendert) =>
|
||||||
|
(bytes, geaendert.ToUnixTimeSeconds());
|
||||||
|
|
||||||
|
var nachMerkmal = bekannteDateien.Values
|
||||||
|
.GroupBy(f => Merkmal(f.SizeBytes, f.ModifiedAt))
|
||||||
|
.Where(g => g.Count() == 1) // Mehrdeutiges bleibt liegen.
|
||||||
|
.ToDictionary(g => g.Key, g => g.Single());
|
||||||
|
|
||||||
|
var durch = 0;
|
||||||
|
|
||||||
|
// **Was dieser Lauf schon in der Hand hatte.**
|
||||||
|
//
|
||||||
|
// Ein Pfad darf im selben Lauf nur einmal vorkommen. Kommt er zweimal
|
||||||
|
// — ein Ordner, der doppelt eingehängt ist, oder eine Verknüpfung, die
|
||||||
|
// in die Mediathek zurückzeigt —, entstand bisher eine zweite Zeile
|
||||||
|
// mit demselben `(LibraryId, Path)`. Der Eindeutigkeitsindex liess
|
||||||
|
// daraufhin den **ganzen** Speichervorgang scheitern: der Scan brach
|
||||||
|
// ab, die Zähler blieben stehen, und in der Oberfläche stand eine
|
||||||
|
// englische Meldung über „entity changes".
|
||||||
|
var gesehen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
foreach (var datei in ergebnis.Files)
|
foreach (var datei in ergebnis.Files)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!gesehen.Add(datei.Path))
|
||||||
|
{
|
||||||
|
log.LogInformation(
|
||||||
|
"Scan: {Pfad} kam ein zweites Mal vorbei und wurde übersprungen.", datei.Path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fortschritt?.Report(new Scanschritt(
|
||||||
|
Scanphase.Pruefen, datei.Path, ++durch, ergebnis.Files.Count));
|
||||||
|
|
||||||
Guid? filmId = null, folgeId = null;
|
Guid? filmId = null, folgeId = null;
|
||||||
|
|
||||||
if (bibliothek.Kind == LibraryKind.Movies)
|
if (bibliothek.Kind == LibraryKind.Movies)
|
||||||
@@ -76,18 +156,48 @@ public sealed class LibraryScanner(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var name = FileNames.ParseEpisode(datei.Path);
|
// **Mit der Wurzel der Mediathek.** Erst damit kann der
|
||||||
|
// Scanner wissen, welcher Ordner die Serie benennt — und ohne
|
||||||
|
// das wurde aus jeder Folgendatei eine eigene Serie.
|
||||||
|
var name = FileNames.ParseEpisode(datei.Path, bibliothek.Path);
|
||||||
if (name is null)
|
if (name is null)
|
||||||
{
|
{
|
||||||
// **Nicht raten.** Eine erfundene Staffel 1 landete unter
|
// **Nicht raten — aber auch nicht verschweigen.**
|
||||||
// einer Serie, zu der die Datei nicht gehört, und dort
|
//
|
||||||
// fände sie niemand je wieder.
|
// Eine erfundene Staffel 1 landete unter einer Serie, zu
|
||||||
|
// der die Datei nicht gehört, und dort fände sie niemand je
|
||||||
|
// wieder. Die Datei einfach zu überspringen war aber die
|
||||||
|
// andere Hälfte des Fehlers: 3559 von 5884 Dateien
|
||||||
|
// verschwanden lautlos, und in der Mediathek stand eine
|
||||||
|
// Zahl, die keiner erklären konnte.
|
||||||
|
//
|
||||||
|
// Jetzt bekommt sie eine Zeile ohne Zuordnung. Damit ist
|
||||||
|
// sie zählbar, auflistbar und beim nächsten Lauf — oder
|
||||||
|
// nach einer besseren Regel — nachträglich zuzuordnen.
|
||||||
unerkannt++;
|
unerkannt++;
|
||||||
log.LogInformation("Scan: {Pfad} trägt keine Folgennummer und bleibt liegen.", datei.Path);
|
log.LogInformation("Scan: {Pfad} liess sich keiner Serie zuordnen.", datei.Path);
|
||||||
continue;
|
fortschritt?.Report(new Scanschritt(Scanphase.Vermerk,
|
||||||
|
$"Nicht zugeordnet: {datei.Path} — {FileNames.WarumNicht(datei.Path, bibliothek.Path)}"));
|
||||||
}
|
}
|
||||||
|
if (name is not null)
|
||||||
|
{
|
||||||
|
var vorher = serien.Count;
|
||||||
var serie = SerieFinden(serien, libraryId, name.SeriesTitle);
|
var serie = SerieFinden(serien, libraryId, name.SeriesTitle);
|
||||||
|
|
||||||
|
// **Jede neue Serie wird begründet.** Ohne diese Zeile liess
|
||||||
|
// sich nicht feststellen, welcher der beiden Wege — Ordner oder
|
||||||
|
// Dateiname — die Geisterserien angelegt hatte, und jede
|
||||||
|
// Fehlersuche blieb Vermutung.
|
||||||
|
if (serien.Count > vorher)
|
||||||
|
{
|
||||||
|
fortschritt?.Report(new Scanschritt(Scanphase.Vermerk,
|
||||||
|
$"Serie „{serie.Title}“ neu angelegt — "
|
||||||
|
+ (name.AusOrdner ? "aus dem Ordner" : "aus dem Dateinamen")
|
||||||
|
+ $", wegen {datei.Path}"));
|
||||||
|
}
|
||||||
|
|
||||||
folgeId = FolgeFinden(folgen, serie.Id, name).Id;
|
folgeId = FolgeFinden(folgen, serie.Id, name).Id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaFile eintrag;
|
MediaFile eintrag;
|
||||||
@@ -108,9 +218,40 @@ public sealed class LibraryScanner(
|
|||||||
bekannt.MovieId = filmId;
|
bekannt.MovieId = filmId;
|
||||||
bekannt.EpisodeId = folgeId;
|
bekannt.EpisodeId = folgeId;
|
||||||
bekannt.SeenAt = laufZeit;
|
bekannt.SeenAt = laufZeit;
|
||||||
|
// Wieder da — die Markierung fällt von selbst weg.
|
||||||
|
bekannt.MissingSince = null;
|
||||||
|
bekannt.VersionLabel = FileNames.Fassung(datei.Path) ?? bekannt.VersionLabel;
|
||||||
if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++;
|
if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++;
|
||||||
eintrag = bekannt;
|
eintrag = bekannt;
|
||||||
}
|
}
|
||||||
|
else if (nachMerkmal.TryGetValue(Merkmal(datei.SizeBytes, datei.ModifiedAt), out var umgezogen)
|
||||||
|
&& umgezogen.SeenAt < laufZeit)
|
||||||
|
{
|
||||||
|
// **Dieselbe Datei, neuer Pfad.** Der Eintrag zieht um, statt
|
||||||
|
// dass ein neuer entsteht und der alte als vermisst
|
||||||
|
// zurückbleibt. Sonst stünde nach dem Umbenennen eines Ordners
|
||||||
|
// alles doppelt da — einmal richtig, einmal als Leiche.
|
||||||
|
nachMerkmal.Remove(Merkmal(datei.SizeBytes, datei.ModifiedAt));
|
||||||
|
bekannteDateien.Remove(umgezogen.Path);
|
||||||
|
|
||||||
|
umgezogen.Path = datei.Path;
|
||||||
|
// **Unter dem neuen Pfad wieder eintragen.** Der alte Schlüssel
|
||||||
|
// ist weg, der neue fehlte — und damit war der Eintrag für den
|
||||||
|
// Rest des Laufs unauffindbar. Trifft derselbe Pfad später noch
|
||||||
|
// einmal ein, entstünde eine zweite Zeile dafür.
|
||||||
|
bekannteDateien[datei.Path] = umgezogen;
|
||||||
|
umgezogen.MovieId = filmId;
|
||||||
|
umgezogen.EpisodeId = folgeId;
|
||||||
|
umgezogen.SeenAt = laufZeit;
|
||||||
|
umgezogen.MissingSince = null;
|
||||||
|
umgezogen.VersionLabel = FileNames.Fassung(datei.Path) ?? umgezogen.VersionLabel;
|
||||||
|
|
||||||
|
eintrag = umgezogen;
|
||||||
|
// Nicht erneut untersuchen: der Inhalt ist derselbe, nur der
|
||||||
|
// Name ist anders.
|
||||||
|
mussUntersucht = umgezogen.ProbedAt is null;
|
||||||
|
verschoben++;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
eintrag = new MediaFile
|
eintrag = new MediaFile
|
||||||
@@ -122,31 +263,70 @@ public sealed class LibraryScanner(
|
|||||||
SeenAt = laufZeit,
|
SeenAt = laufZeit,
|
||||||
MovieId = filmId,
|
MovieId = filmId,
|
||||||
EpisodeId = folgeId,
|
EpisodeId = folgeId,
|
||||||
|
VersionLabel = FileNames.Fassung(datei.Path),
|
||||||
};
|
};
|
||||||
db.MediaFiles.Add(eintrag);
|
db.MediaFiles.Add(eintrag);
|
||||||
|
// **Auch Neuzugänge gehören in die Liste.** Sie standen dort
|
||||||
|
// nie — ein Pfad, der im selben Lauf ein zweites Mal auftauchte,
|
||||||
|
// wurde deshalb ein zweites Mal angelegt.
|
||||||
|
bekannteDateien[datei.Path] = eintrag;
|
||||||
neu++;
|
neu++;
|
||||||
mussUntersucht = true;
|
mussUntersucht = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mussUntersucht && await UntersuchenAsync(eintrag, laufZeit, ct)) untersucht++;
|
// **Was keiner Serie gehört, wird nicht untersucht.** Ein
|
||||||
|
// ffprobe-Durchgang kostet rund fünfzig Millisekunden; bei 3559
|
||||||
|
// unzugeordneten Dateien ist das eine halbe Stunde für Angaben,
|
||||||
|
// die niemand zu sehen bekommt. Sobald die Zuordnung steht, holt
|
||||||
|
// der nächste Lauf es nach — `ProbedAt` ist dann noch leer.
|
||||||
|
var gehoertDazu = filmId is not null || folgeId is not null;
|
||||||
|
if (gehoertDazu && mussUntersucht && await UntersuchenAsync(eintrag, laufZeit, ct)) untersucht++;
|
||||||
|
|
||||||
|
// **Zwischendurch schreiben, nicht erst am Ende.**
|
||||||
|
//
|
||||||
|
// Gemessen an achtzig Dateien erscheinen die Titel ohnehin
|
||||||
|
// laufend — denn <see cref="UntersuchenAsync"/> speichert je
|
||||||
|
// Datei. Nur gilt das eben *nur* dort: scheitert die
|
||||||
|
// Untersuchung, kehrt sie vor dem Speichern um. Ein Bestand aus
|
||||||
|
// Dateien, die ffprobe nicht lesen kann, bliebe bis zum Schluss
|
||||||
|
// unsichtbar — und wer dabei zusieht, hält den Scan für hängend.
|
||||||
|
//
|
||||||
|
// Fünfzig, nicht jede: ein Schreibvorgang je Datei kostet bei
|
||||||
|
// vierzigtausend mehr, als die Sichtbarkeit wert ist.
|
||||||
|
//
|
||||||
|
// Für das Aufräumen weiter unten ist das unbedenklich. Es
|
||||||
|
// vergleicht gegen `laufZeit`, und die steht vor der Schleife
|
||||||
|
// fest; jede hier geschriebene Datei trägt genau diesen Wert.
|
||||||
|
if (++seitLetztemSchreiben >= 50)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
seitLetztemSchreiben = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
// Was dieser Lauf nicht gesehen hat, liegt nicht mehr da.
|
// **Was dieser Lauf nicht gesehen hat, wird markiert — nicht gelöscht.**
|
||||||
var entfernt = await db.MediaFiles
|
// Eine umbenannte Festplatte, ein Tippfehler im Pfad, ein Laufwerk im
|
||||||
.Where(f => f.LibraryId == libraryId && f.SeenAt < laufZeit)
|
// Ruhezustand: all das sah vorher aus wie „der Film ist weg", und mit
|
||||||
.ExecuteDeleteAsync(ct);
|
// ihm ging der Sehfortschritt aller Benutzer.
|
||||||
|
var verschwunden = await db.MediaFiles
|
||||||
|
.Where(f => f.LibraryId == libraryId && f.SeenAt < laufZeit && f.MissingSince == null)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(f => f.MissingSince, laufZeit), ct);
|
||||||
|
|
||||||
|
fortschritt?.Report(new Scanschritt(Scanphase.Aufraeumen, ""));
|
||||||
await VerwaisteAufraeumen(libraryId, ct);
|
await VerwaisteAufraeumen(libraryId, ct);
|
||||||
|
|
||||||
log.LogInformation(
|
log.LogInformation(
|
||||||
"Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Geändert} geändert, " +
|
"Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Verschoben} verschoben, " +
|
||||||
"{Entfernt} entfernt, {Unerkannt} unerkannt, {Untersucht} untersucht.",
|
"{Geändert} geändert, {Verschwunden} nicht mehr gefunden, {Unerkannt} unerkannt, " +
|
||||||
bibliothek.Name, ergebnis.Files.Count, neu, geändert, entfernt, unerkannt, untersucht);
|
"{Untersucht} untersucht.",
|
||||||
|
bibliothek.Name, ergebnis.Files.Count, neu, verschoben, geändert, verschwunden,
|
||||||
|
unerkannt, untersucht);
|
||||||
|
|
||||||
return new ScanReport(
|
return new ScanReport(
|
||||||
ergebnis.Files.Count, neu, geändert, entfernt, unerkannt, untersucht, ergebnis.Problems);
|
ergebnis.Files.Count, neu, geändert, 0, unerkannt, untersucht, ergebnis.Problems,
|
||||||
|
Missing: verschwunden);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Liest die Spuren einer Datei und schreibt sie fort.</summary>
|
/// <summary>Liest die Spuren einer Datei und schreibt sie fort.</summary>
|
||||||
@@ -171,6 +351,13 @@ public sealed class LibraryScanner(
|
|||||||
datei.BitRate = gelesen.BitRate;
|
datei.BitRate = gelesen.BitRate;
|
||||||
datei.ProbedAt = laufZeit;
|
datei.ProbedAt = laufZeit;
|
||||||
|
|
||||||
|
// **Der Rückfall aus der Bildgrösse wird nicht mehr gespeichert.**
|
||||||
|
// Er stand in der Spalte und war damit das, was beim letzten Scan
|
||||||
|
// gegolten hat: als aus „576p" ein „SD" wurde, blieb in jeder
|
||||||
|
// bestehenden Zeile das alte Wort stehen. Jetzt rechnet die Antwort
|
||||||
|
// ihn aus den Spuren — siehe `LibraryContentEndpoints.Datei`. In der
|
||||||
|
// Spalte steht nur noch, was im Dateinamen stand.
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
// Die alten Spuren weg, bevor die neuen kommen: eine umkodierte Datei
|
// Die alten Spuren weg, bevor die neuen kommen: eine umkodierte Datei
|
||||||
@@ -221,18 +408,48 @@ public sealed class LibraryScanner(
|
|||||||
/// <summary>Sucht den Film — oder legt ihn an.</summary>
|
/// <summary>Sucht den Film — oder legt ihn an.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Der Titel **aus dem Dateinamen** und das Jahr entscheiden — nicht der
|
/// <para>
|
||||||
/// angezeigte Titel, der nach dem ersten Lauf von der Metadatenquelle
|
/// Der Titel <b>aus dem Dateinamen</b> und das Jahr entscheiden — nicht
|
||||||
|
/// der angezeigte Titel, der nach dem ersten Lauf von der Metadatenquelle
|
||||||
/// kommt und sich dabei ändert. Nur über den Titel ohne Jahr zu gehen
|
/// kommt und sich dabei ändert. Nur über den Titel ohne Jahr zu gehen
|
||||||
/// würfe die beiden „Dune" von 1984 und 2021 zusammen; über die Datei zu
|
/// würfe die beiden „Dune" von 1984 und 2021 zusammen; über die Datei zu
|
||||||
/// gehen machte aus 4K- und 1080p-Fassung zwei Filme.
|
/// gehen machte aus 4K- und 1080p-Fassung zwei Filme.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Verglichen wird über <see cref="Titelschluessel"/>, nicht Zeichen
|
||||||
|
/// für Zeichen.</b> Vorher ergaben <c>Dr. Dolittle 2</c> und
|
||||||
|
/// <c>Dr Dolittle 2</c> zwei Einträge, die anschliessend beide demselben
|
||||||
|
/// TMDB-Eintrag zugeordnet wurden und ununterscheidbar nebeneinander
|
||||||
|
/// standen. Ebenso eine Datei mit Jahresangabe neben einer ohne.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <summary>Ob neue Funde dieses Laufs erst geprüft werden sollen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Als Feld und nicht als Parameter durch vier Methoden gereicht: es gilt
|
||||||
|
/// für den ganzen Lauf und ändert sich darin nicht.
|
||||||
|
/// </remarks>
|
||||||
|
private bool _pruefungNoetig;
|
||||||
|
|
||||||
|
private DateTimeOffset _laufZeit;
|
||||||
|
|
||||||
private Movie FilmFinden(List<Movie> bekannt, Guid libraryId, MovieName name)
|
private Movie FilmFinden(List<Movie> bekannt, Guid libraryId, MovieName name)
|
||||||
{
|
{
|
||||||
var treffer = bekannt.FirstOrDefault(m =>
|
var treffer = bekannt.FirstOrDefault(m =>
|
||||||
string.Equals(m.ParsedTitle, name.Title, StringComparison.OrdinalIgnoreCase)
|
Titelschluessel.Gleich(m.ParsedTitle, m.ParsedYear, name.Title, name.Year));
|
||||||
&& m.ParsedYear == name.Year);
|
if (treffer is not null)
|
||||||
if (treffer is not null) return treffer;
|
{
|
||||||
|
// **Das Anzeigejahr nachtragen, wenn es bisher fehlte.** Trug die
|
||||||
|
// erste Datei keines im Namen und die zweite schon, stünde sonst
|
||||||
|
// ein Film ohne Jahr da, obwohl es bekannt ist.
|
||||||
|
//
|
||||||
|
// `ParsedYear` bleibt unangetastet — es ist der Vergleichsschlüssel
|
||||||
|
// und deshalb `init`. Das ist kein Mangel: ein fehlendes Jahr passt
|
||||||
|
// in `Titelschluessel.Gleich` ohnehin auf jedes, der Eintrag findet
|
||||||
|
// also weiterhin beide Schreibweisen.
|
||||||
|
treffer.Year ??= name.Year;
|
||||||
|
return treffer;
|
||||||
|
}
|
||||||
|
|
||||||
var neu = new Movie
|
var neu = new Movie
|
||||||
{
|
{
|
||||||
@@ -242,16 +459,27 @@ public sealed class LibraryScanner(
|
|||||||
Title = name.Title,
|
Title = name.Title,
|
||||||
SortTitleValue = SortTitle.For(name.Title),
|
SortTitleValue = SortTitle.For(name.Title),
|
||||||
Year = name.Year,
|
Year = name.Year,
|
||||||
|
// **Sofort sichtbar, ausser die Mediathek verlangt eine Prüfung.**
|
||||||
|
// Die Vorgabe ist „erscheinen": wer eine Mediathek anlegt, will
|
||||||
|
// sie sehen.
|
||||||
|
PublishAt = _pruefungNoetig ? null : _laufZeit,
|
||||||
};
|
};
|
||||||
db.Movies.Add(neu);
|
db.Movies.Add(neu);
|
||||||
bekannt.Add(neu);
|
bekannt.Add(neu);
|
||||||
return neu;
|
return neu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sucht die Serie — oder legt sie an.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Über denselben Schlüssel wie beim Film: <c>Game of Thrones</c> und
|
||||||
|
/// <c>Game.of.Thrones</c> sind eine Serie, nicht zwei. Ein Jahr steht am
|
||||||
|
/// Serienordner selten, deshalb gibt es hier keines zu vergleichen.
|
||||||
|
/// </remarks>
|
||||||
private Series SerieFinden(List<Series> bekannt, Guid libraryId, string titel)
|
private Series SerieFinden(List<Series> bekannt, Guid libraryId, string titel)
|
||||||
{
|
{
|
||||||
var treffer = bekannt.FirstOrDefault(s =>
|
var treffer = bekannt.FirstOrDefault(s =>
|
||||||
string.Equals(s.ParsedTitle, titel, StringComparison.OrdinalIgnoreCase));
|
Titelschluessel.Gleich(s.ParsedTitle, null, titel, null));
|
||||||
if (treffer is not null) return treffer;
|
if (treffer is not null) return treffer;
|
||||||
|
|
||||||
var neu = new Series
|
var neu = new Series
|
||||||
@@ -285,6 +513,7 @@ public sealed class LibraryScanner(
|
|||||||
SeasonNumber = name.Season,
|
SeasonNumber = name.Season,
|
||||||
EpisodeNumber = name.Episode,
|
EpisodeNumber = name.Episode,
|
||||||
Title = name.EpisodeTitle,
|
Title = name.EpisodeTitle,
|
||||||
|
PublishAt = _pruefungNoetig ? null : _laufZeit,
|
||||||
};
|
};
|
||||||
db.Episodes.Add(neu);
|
db.Episodes.Add(neu);
|
||||||
bekannt.Add(neu);
|
bekannt.Add(neu);
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public sealed class MetadataEnricher(
|
|||||||
IImageStore bilder,
|
IImageStore bilder,
|
||||||
IImageReader bildLeser,
|
IImageReader bildLeser,
|
||||||
ISidecarStore beiwerk,
|
ISidecarStore beiwerk,
|
||||||
|
Zusammenlegung zusammenlegung,
|
||||||
|
TimeProvider uhr,
|
||||||
ILogger<MetadataEnricher> log)
|
ILogger<MetadataEnricher> log)
|
||||||
{
|
{
|
||||||
/// <param name="erneut">
|
/// <param name="erneut">
|
||||||
@@ -42,8 +44,22 @@ public sealed class MetadataEnricher(
|
|||||||
/// <b>Nachgeholt wird über die bekannte Kennung, nicht über eine neue
|
/// <b>Nachgeholt wird über die bekannte Kennung, nicht über eine neue
|
||||||
/// Suche.</b> Eine Zuordnung von Hand bleibt damit stehen.
|
/// Suche.</b> Eine Zuordnung von Hand bleibt damit stehen.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="nurSprache">
|
||||||
|
/// <b>Nur Titel anfassen, die nicht in dieser Sprache vorliegen.</b> Für
|
||||||
|
/// den Fall nach einer Sprachumstellung: zwölf abweichende Titel
|
||||||
|
/// aufzufrischen kostet zwölf Anfragen, der ganze Bestand viertausend.
|
||||||
|
/// <c>null</c> heisst: keine Einschränkung.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="nurDiese">
|
||||||
|
/// <b>Nur diese Filme und Serien anfassen.</b> Für „Metadaten neu holen"
|
||||||
|
/// an einer Auswahl in der Mediathek: zehn ausgewählte Titel kosten zehn
|
||||||
|
/// Anfragen, der ganze Bestand viertausend. Folgen gehören zu ihrer Serie
|
||||||
|
/// — ist die ausgewählt, kommen sie mit. <c>null</c> heisst: alles.
|
||||||
|
/// </param>
|
||||||
public async Task<EnrichReport> EnrichAsync(
|
public async Task<EnrichReport> EnrichAsync(
|
||||||
Guid libraryId, bool erneut = false, CancellationToken ct = default)
|
Guid libraryId, bool erneut = false,
|
||||||
|
IProgress<Scanschritt>? fortschritt = null, CancellationToken ct = default,
|
||||||
|
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null)
|
||||||
{
|
{
|
||||||
if (!await anbieter.IsConfiguredAsync(ct))
|
if (!await anbieter.IsConfiguredAsync(ct))
|
||||||
{
|
{
|
||||||
@@ -51,12 +67,19 @@ public sealed class MetadataEnricher(
|
|||||||
return new EnrichReport(0, 0, 0, Skipped: true, null);
|
return new EnrichReport(0, 0, 0, Skipped: true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Ob die Mediathek auf Prüfung steht.** Nur dann wird eine Änderung
|
||||||
|
// vorgemerkt — wer keine Prüfung will, soll auch keine Liste bekommen.
|
||||||
|
var pruefungNoetig = await db.Libraries.AsNoTracking()
|
||||||
|
.Where(l => l.Id == libraryId)
|
||||||
|
.Select(l => l.ReviewNewItems)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
int filme = 0, serien = 0, folgen = 0;
|
int filme = 0, serien = 0, folgen = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
filme = await FilmeZuordnen(libraryId, erneut, ct);
|
filme = await FilmeZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese, pruefungNoetig);
|
||||||
serien = await SerienZuordnen(libraryId, erneut, ct);
|
serien = await SerienZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese, pruefungNoetig);
|
||||||
folgen = await FolgenZuordnen(libraryId, erneut, ct);
|
folgen = await FolgenZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese);
|
||||||
}
|
}
|
||||||
catch (MetadataCredentialsException fehler)
|
catch (MetadataCredentialsException fehler)
|
||||||
{
|
{
|
||||||
@@ -71,16 +94,43 @@ public sealed class MetadataEnricher(
|
|||||||
return new EnrichReport(filme, serien, folgen, Skipped: false, null);
|
return new EnrichReport(filme, serien, folgen, Skipped: false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<int> FilmeZuordnen(Guid libraryId, bool erneut, CancellationToken ct)
|
private async Task<int> FilmeZuordnen(
|
||||||
|
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
|
||||||
|
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null, bool pruefungNoetig = false)
|
||||||
{
|
{
|
||||||
var offen = await db.Movies
|
var offen = await db.Movies
|
||||||
.Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null))
|
.Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null)
|
||||||
|
// **Beim Sprachlauf nur Zugeordnetes.** Ein Titel ohne
|
||||||
|
// Kennung hat keine Sprache, weil er keine Metadaten hat —
|
||||||
|
// das ist ein Zuordnungsproblem. Ihn hier mitzunehmen hiesse,
|
||||||
|
// bei jedem Sprachlauf dieselben dreihundert vergeblichen
|
||||||
|
// Suchen erneut zu starten.
|
||||||
|
&& (nurSprache == null
|
||||||
|
|| (m.TmdbId != null && m.MetadataLanguage != nurSprache)))
|
||||||
|
.Where(m => nurDiese == null || nurDiese.Contains(m.Id))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
var zugeordnet = 0;
|
var zugeordnet = 0;
|
||||||
|
var durch = 0;
|
||||||
|
|
||||||
|
// Wer welche Kennung schon hat — auch die, die dieser Lauf gerade
|
||||||
|
// vergeben hat und die deshalb noch nicht in der Datenbank stehen.
|
||||||
|
var vergeben = await db.Movies
|
||||||
|
.Where(m => m.LibraryId == libraryId && m.TmdbId != null)
|
||||||
|
.ToDictionaryAsync(m => m.TmdbId!.Value, m => m.Id, ct);
|
||||||
|
|
||||||
|
// Welches Jahr im Dateinamen des schon zugeordneten stand — daran
|
||||||
|
// entscheidet sich, ob ein zweiter Treffer wirklich derselbe Film ist.
|
||||||
|
var jahreNachId = await db.Movies
|
||||||
|
.Where(m => m.LibraryId == libraryId && m.TmdbId != null)
|
||||||
|
.ToDictionaryAsync(m => m.Id, m => m.ParsedYear, ct);
|
||||||
|
|
||||||
|
var zusammenzulegen = new List<(Guid Sieger, Guid Verlierer)>();
|
||||||
|
|
||||||
foreach (var film in offen)
|
foreach (var film in offen)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
fortschritt?.Report(new Scanschritt(
|
||||||
|
Scanphase.Nachschlagen, film.Title, ++durch, offen.Count));
|
||||||
|
|
||||||
var datei = await db.MediaFiles.FirstOrDefaultAsync(f => f.MovieId == film.Id, ct);
|
var datei = await db.MediaFiles.FirstOrDefaultAsync(f => f.MovieId == film.Id, ct);
|
||||||
var (vorgegeben, festgelegt) = await VorhandeneZuordnung(datei, SidecarNames.MovieNfo, ct);
|
var (vorgegeben, festgelegt) = await VorhandeneZuordnung(datei, SidecarNames.MovieNfo, ct);
|
||||||
@@ -96,15 +146,92 @@ public sealed class MetadataEnricher(
|
|||||||
|
|
||||||
if (gefunden is null) continue;
|
if (gefunden is null) continue;
|
||||||
|
|
||||||
|
// **Zwei Einträge, derselbe Film — und das fällt erst hier auf.**
|
||||||
|
//
|
||||||
|
// Beim Scan heisst der eine „Ich einfach unverbesserlich" und der
|
||||||
|
// andere „Despicable Me"; dass es derselbe ist, verrät allein die
|
||||||
|
// TMDB-Kennung. Die hier blind zu setzen verletzt den
|
||||||
|
// Eindeutigkeitsindex, und EF bricht die ganze Speicherung ab —
|
||||||
|
// mit „An error occurred while saving the entity changes", einer
|
||||||
|
// Meldung, die den Grund nicht nennt und den ganzen Lauf mitnimmt.
|
||||||
|
//
|
||||||
|
// Also nicht setzen, sondern vormerken: zusammengelegt wird nach
|
||||||
|
// der Schleife, wenn nichts mehr dazwischenkommt.
|
||||||
|
// **Sich selbst ist keine Dublette, und das war der teure Teil.**
|
||||||
|
//
|
||||||
|
// `vergeben` enthält jeden Film, der schon eine Kennung hat — beim
|
||||||
|
// Auffrischen also auch diesen hier. Ohne den Vergleich auf
|
||||||
|
// Gleichheit fand sich jeder zugeordnete Film als „Dublette seiner
|
||||||
|
// selbst", wurde übersprungen und nie fortgeschrieben. „Metadaten
|
||||||
|
// auffrischen" tat damit an allem, was schon zugeordnet war,
|
||||||
|
// schlicht nichts: Titel, Beschreibung und Sprache blieben für
|
||||||
|
// immer so, wie sie beim ersten Lauf hereinkamen. Genau das sah
|
||||||
|
// aus wie „die Spracheinstellung wirkt nicht".
|
||||||
|
if (vergeben.TryGetValue(gefunden.ProviderId, out var schonDa) && schonDa != film.Id)
|
||||||
|
{
|
||||||
|
// **Verschiedene Jahre heissen: verschiedene Filme.**
|
||||||
|
//
|
||||||
|
// „…Heiligtümer des Todes Teil 1 (2010)" und „Teil 2 (2011)"
|
||||||
|
// landen bei TMDB auf demselben Treffer, wenn ohne Jahr
|
||||||
|
// gesucht werden musste — die Suche liefert für beide Titel
|
||||||
|
// Teil 2 zuerst. Zusammengelegt verschwand damit ein ganzer
|
||||||
|
// Film als „zweite Fassung" des anderen, und niemand erfuhr
|
||||||
|
// davon. Gemessen am echten Bestand: 448 Filme und 13 zweite
|
||||||
|
// Fassungen bei 461 Dateien.
|
||||||
|
//
|
||||||
|
// Das Jahr aus dem Dateinamen ändert sich nie und ist deshalb
|
||||||
|
// der verlässlichste Unterschied, den es hier gibt. Kennt nur
|
||||||
|
// einer von beiden eines, sagt das nichts — dann bleibt es
|
||||||
|
// beim Zusammenlegen, denn „Despicable Me" und „Ich einfach
|
||||||
|
// unverbesserlich" sind wirklich derselbe Film.
|
||||||
|
if (JahreWidersprechen(film.ParsedYear, jahreNachId.GetValueOrDefault(schonDa)))
|
||||||
|
{
|
||||||
|
fortschritt?.Report(new Scanschritt(Scanphase.Vermerk,
|
||||||
|
$"„{film.ParsedTitle ?? film.Title}“ ({film.ParsedYear}) und ein anderer Titel "
|
||||||
|
+ $"führen beide auf {gefunden.ProviderId} — verschiedene Jahre, also nicht "
|
||||||
|
+ "zusammengelegt. Dieser bleibt ohne Zuordnung."));
|
||||||
|
durch++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
zusammenzulegen.Add((Sieger: schonDa, Verlierer: film.Id));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Was vorher dastand** — nur daran lässt sich sagen, ob ein
|
||||||
|
// Auffrischen wirklich etwas geändert hat. Ein Lauf, der denselben
|
||||||
|
// Titel noch einmal holt, ist nichts zum Abnicken.
|
||||||
|
var vorher = (film.TmdbId, film.Title, film.PosterImage, film.Year);
|
||||||
|
|
||||||
|
vergeben[gefunden.ProviderId] = film.Id;
|
||||||
|
// **Auch das Jahr, und zwar sofort.** Die beiden Tabellen müssen
|
||||||
|
// im Gleichschritt wachsen: wird nur `vergeben` fortgeschrieben,
|
||||||
|
// findet der zweite Film zwar den ersten, kennt aber dessen Jahr
|
||||||
|
// nicht — und legt wieder zusammen, was nicht zusammengehört.
|
||||||
|
jahreNachId[film.Id] = film.ParsedYear;
|
||||||
film.TmdbId = gefunden.ProviderId;
|
film.TmdbId = gefunden.ProviderId;
|
||||||
film.ImdbId = gefunden.ImdbId;
|
film.ImdbId = gefunden.ImdbId;
|
||||||
film.Title = gefunden.Title;
|
// **Ein Titel von Hand bleibt stehen.** Wie bei den Bildern: ohne
|
||||||
film.SortTitleValue = SortTitle.For(gefunden.Title);
|
// diese Prüfung tippt man ihn nach jedem Auffrischen neu.
|
||||||
|
if (!film.TitlePinned)
|
||||||
|
{
|
||||||
|
film.Title = gefunden.Title;
|
||||||
|
// **Der Sortiertitel hat seinen eigenen Merker.** Wer „Harry
|
||||||
|
// Potter 3" eingetragen hat, will den Titel trotzdem weiter
|
||||||
|
// automatisch bekommen — beides an einem Schalter hiesse, für
|
||||||
|
// die Reihenfolge auf die Pflege des Titels zu verzichten.
|
||||||
|
if (!film.SortTitlePinned) film.SortTitleValue = SortTitle.For(gefunden.Title);
|
||||||
|
}
|
||||||
|
film.OriginalTitle = gefunden.OriginalTitle;
|
||||||
|
film.MetadataLanguage = gefunden.Language;
|
||||||
film.Year = gefunden.Year ?? film.Year;
|
film.Year = gefunden.Year ?? film.Year;
|
||||||
film.Overview = gefunden.Overview;
|
film.Overview = gefunden.Overview;
|
||||||
film.PosterImage = await BildAblegen(gefunden.Poster, ct);
|
// **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung
|
||||||
film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
|
// überschreibt jedes Auffrischen die Auswahl wieder — und wer
|
||||||
film.LogoImage = await BildAblegen(gefunden.Logo, ct);
|
// einmal ein besseres Plakat gesucht hat, sucht es jedes Mal neu.
|
||||||
|
if (!film.PosterPinned) film.PosterImage = await BildAblegen(gefunden.Poster, ct);
|
||||||
|
if (!film.BackdropPinned) film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
|
||||||
|
if (!film.LogoPinned) film.LogoImage = await BildAblegen(gefunden.Logo, ct);
|
||||||
film.RuntimeMinutes = gefunden.RuntimeMinutes;
|
film.RuntimeMinutes = gefunden.RuntimeMinutes;
|
||||||
film.Rating = gefunden.Rating;
|
film.Rating = gefunden.Rating;
|
||||||
film.Tagline = gefunden.Tagline;
|
film.Tagline = gefunden.Tagline;
|
||||||
@@ -115,10 +242,36 @@ public sealed class MetadataEnricher(
|
|||||||
await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct);
|
await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct);
|
||||||
zugeordnet++;
|
zugeordnet++;
|
||||||
|
|
||||||
|
// **Eine Änderung an einem Titel, den es schon gab, wird vorgemerkt.**
|
||||||
|
// Bei einem neuen Fund nicht: der steht ohnehin schon in der
|
||||||
|
// Prüfliste, weil er auf Freigabe wartet.
|
||||||
|
if (pruefungNoetig && vorher.TmdbId is not null
|
||||||
|
&& vorher != (film.TmdbId, film.Title, film.PosterImage, film.Year))
|
||||||
|
{
|
||||||
|
film.ChangedSince ??= uhr.GetUtcNow();
|
||||||
|
}
|
||||||
|
|
||||||
if (datei is not null) await FilmBeiwerk(film, datei, ct);
|
if (datei is not null) await FilmBeiwerk(film, datei, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (zugeordnet > 0) await db.SaveChangesAsync(ct);
|
if (zugeordnet > 0) await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// **Erst speichern, dann zusammenlegen.** Das Verschmelzen löscht
|
||||||
|
// Zeilen am Änderungsverfolger vorbei; stünde es davor, schriebe das
|
||||||
|
// folgende Speichern sie wieder hin.
|
||||||
|
foreach (var (sieger, verlierer) in zusammenzulegen)
|
||||||
|
{
|
||||||
|
await zusammenlegung.FilmVerschmelzenAsync(sieger, verlierer, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zusammenzulegen.Count > 0)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
log.LogInformation(
|
||||||
|
"{Anzahl} Film(e) waren derselbe Titel unter anderem Namen und wurden zusammengelegt.",
|
||||||
|
zusammenzulegen.Count);
|
||||||
|
}
|
||||||
|
|
||||||
return zugeordnet;
|
return zugeordnet;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,16 +295,54 @@ public sealed class MetadataEnricher(
|
|||||||
return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo));
|
return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<int> SerienZuordnen(Guid libraryId, bool erneut, CancellationToken ct)
|
/// <summary>Ob zwei Jahre einander widersprechen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nur wenn beide bekannt sind.</b> Eine Lücke ist keine Aussage: wer
|
||||||
|
/// seine Dateien ohne Jahr benennt, bekäme sonst nie eine Dublette
|
||||||
|
/// erkannt — und „Despicable Me" und „Ich einfach unverbesserlich"
|
||||||
|
/// gehören wirklich zusammen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Dafür schon ab einem Jahr Abstand, und das ist gemessen.</b> Erst
|
||||||
|
/// stand hier eine Toleranz von einem Jahr — für den Fall, dass eine
|
||||||
|
/// Erstaufführung im Dezember hier im Januar läuft. Genau die Toleranz
|
||||||
|
/// verschluckte den Fall, um den es geht: „Heiligtümer des Todes Teil 1"
|
||||||
|
/// ist von 2010, „Teil 2" von 2011. Zwei Dateien, deren Namen
|
||||||
|
/// verschiedene Jahre nennen, sind verschiedene Filme — und wenn doch
|
||||||
|
/// nicht, steht der zweite in der Liste der nicht zugeordneten Titel und
|
||||||
|
/// lässt sich in zwei Klicks geradeziehen. Das ist allemal besser, als
|
||||||
|
/// ihn lautlos verschwinden zu lassen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static bool JahreWidersprechen(int? eines, int? anderes) =>
|
||||||
|
eines is { } a && anderes is { } b && a != b;
|
||||||
|
|
||||||
|
private async Task<int> SerienZuordnen(
|
||||||
|
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
|
||||||
|
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null, bool pruefungNoetig = false)
|
||||||
{
|
{
|
||||||
var offen = await db.Series
|
var offen = await db.Series
|
||||||
.Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null))
|
.Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null)
|
||||||
|
&& (nurSprache == null
|
||||||
|
|| (s.TmdbId != null && s.MetadataLanguage != nurSprache)))
|
||||||
|
.Where(s => nurDiese == null || nurDiese.Contains(s.Id))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
var zugeordnet = 0;
|
var zugeordnet = 0;
|
||||||
|
var durch = 0;
|
||||||
|
|
||||||
|
var vergeben = await db.Series
|
||||||
|
.Where(x => x.LibraryId == libraryId && x.TmdbId != null)
|
||||||
|
.ToDictionaryAsync(x => x.TmdbId!.Value, x => x.Id, ct);
|
||||||
|
|
||||||
|
var zusammenzulegen = new List<(Guid Sieger, Guid Verlierer)>();
|
||||||
|
|
||||||
foreach (var serie in offen)
|
foreach (var serie in offen)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
fortschritt?.Report(new Scanschritt(
|
||||||
|
Scanphase.Nachschlagen, serie.Title, ++durch, offen.Count));
|
||||||
|
|
||||||
// Bei einer Serie liegt die NFO neben der Folge, also im
|
// Bei einer Serie liegt die NFO neben der Folge, also im
|
||||||
// Staffelordner — gesucht wird deshalb ab der ersten Folgendatei.
|
// Staffelordner — gesucht wird deshalb ab der ersten Folgendatei.
|
||||||
@@ -168,15 +359,37 @@ public sealed class MetadataEnricher(
|
|||||||
|
|
||||||
if (gefunden is null) continue;
|
if (gefunden is null) continue;
|
||||||
|
|
||||||
|
// Wie beim Film: zwei Einträge, dieselbe Serie. Bei Serien ist das
|
||||||
|
// noch häufiger — derselbe Ordner unter zwei Schreibweisen, oder
|
||||||
|
// ein Staffelordner, der versehentlich als eigene Serie gelesen
|
||||||
|
// wurde.
|
||||||
|
// Siehe FilmeZuordnen: sich selbst ist keine Dublette.
|
||||||
|
if (vergeben.TryGetValue(gefunden.ProviderId, out var schonDa) && schonDa != serie.Id)
|
||||||
|
{
|
||||||
|
zusammenzulegen.Add((Sieger: schonDa, Verlierer: serie.Id));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
vergeben[gefunden.ProviderId] = serie.Id;
|
||||||
|
var vorher = (serie.TmdbId, serie.Title, serie.PosterImage, serie.Year);
|
||||||
|
|
||||||
serie.TmdbId = gefunden.ProviderId;
|
serie.TmdbId = gefunden.ProviderId;
|
||||||
serie.ImdbId = gefunden.ImdbId;
|
serie.ImdbId = gefunden.ImdbId;
|
||||||
serie.Title = gefunden.Title;
|
if (!serie.TitlePinned)
|
||||||
serie.SortTitleValue = SortTitle.For(gefunden.Title);
|
{
|
||||||
|
serie.Title = gefunden.Title;
|
||||||
|
if (!serie.SortTitlePinned) serie.SortTitleValue = SortTitle.For(gefunden.Title);
|
||||||
|
}
|
||||||
|
serie.OriginalTitle = gefunden.OriginalTitle;
|
||||||
|
serie.MetadataLanguage = gefunden.Language;
|
||||||
serie.Year = gefunden.Year ?? serie.Year;
|
serie.Year = gefunden.Year ?? serie.Year;
|
||||||
serie.Overview = gefunden.Overview;
|
serie.Overview = gefunden.Overview;
|
||||||
serie.PosterImage = await BildAblegen(gefunden.Poster, ct);
|
// **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung
|
||||||
serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
|
// überschreibt jedes Auffrischen die Auswahl wieder — und wer
|
||||||
serie.LogoImage = await BildAblegen(gefunden.Logo, ct);
|
// einmal ein besseres Plakat gesucht hat, sucht es jedes Mal neu.
|
||||||
|
if (!serie.PosterPinned) serie.PosterImage = await BildAblegen(gefunden.Poster, ct);
|
||||||
|
if (!serie.BackdropPinned) serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
|
||||||
|
if (!serie.LogoPinned) serie.LogoImage = await BildAblegen(gefunden.Logo, ct);
|
||||||
serie.RuntimeMinutes = gefunden.RuntimeMinutes;
|
serie.RuntimeMinutes = gefunden.RuntimeMinutes;
|
||||||
serie.Rating = gefunden.Rating;
|
serie.Rating = gefunden.Rating;
|
||||||
serie.Tagline = gefunden.Tagline;
|
serie.Tagline = gefunden.Tagline;
|
||||||
@@ -184,6 +397,12 @@ public sealed class MetadataEnricher(
|
|||||||
|
|
||||||
await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct);
|
await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct);
|
||||||
await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct);
|
await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct);
|
||||||
|
|
||||||
|
if (pruefungNoetig && vorher.TmdbId is not null
|
||||||
|
&& vorher != (serie.TmdbId, serie.Title, serie.PosterImage, serie.Year))
|
||||||
|
{
|
||||||
|
serie.ChangedSince ??= uhr.GetUtcNow();
|
||||||
|
}
|
||||||
serie.MatchLocked = festgelegt;
|
serie.MatchLocked = festgelegt;
|
||||||
zugeordnet++;
|
zugeordnet++;
|
||||||
|
|
||||||
@@ -191,6 +410,20 @@ public sealed class MetadataEnricher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (zugeordnet > 0) await db.SaveChangesAsync(ct);
|
if (zugeordnet > 0) await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
foreach (var (sieger, verlierer) in zusammenzulegen)
|
||||||
|
{
|
||||||
|
await zusammenlegung.SerieVerschmelzenAsync(sieger, verlierer, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zusammenzulegen.Count > 0)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
log.LogInformation(
|
||||||
|
"{Anzahl} Serie(n) waren dieselbe unter anderem Namen und wurden zusammengelegt.",
|
||||||
|
zusammenzulegen.Count);
|
||||||
|
}
|
||||||
|
|
||||||
return zugeordnet;
|
return zugeordnet;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,24 +463,46 @@ public sealed class MetadataEnricher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind.</summary>
|
/// <summary>Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind.</summary>
|
||||||
private async Task<int> FolgenZuordnen(Guid libraryId, bool erneut, CancellationToken ct)
|
private async Task<int> FolgenZuordnen(
|
||||||
|
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
|
||||||
|
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null)
|
||||||
{
|
{
|
||||||
|
// **Mit Titel, nicht nur mit Kennung.** In der Fortschrittszeile stand
|
||||||
|
// „Hole Metadaten zu Folge 2×13" — und bei 3815 Folgen aus achtzig
|
||||||
|
// Serien sagt eine Folgennummer ohne Serie überhaupt nichts.
|
||||||
|
//
|
||||||
|
// **Eine Auswahl greift über die Serie, nicht über die Folge.** In der
|
||||||
|
// Mediathek wählt man Serienkacheln aus; dass damit deren Folgen
|
||||||
|
// gemeint sind, ist der ganze Sinn der Sache.
|
||||||
var serien = await db.Series
|
var serien = await db.Series
|
||||||
.Where(s => s.LibraryId == libraryId && s.TmdbId != null)
|
.Where(s => s.LibraryId == libraryId && s.TmdbId != null)
|
||||||
.Select(s => new { s.Id, TmdbId = s.TmdbId!.Value })
|
.Where(s => nurDiese == null || nurDiese.Contains(s.Id))
|
||||||
|
.Select(s => new { s.Id, TmdbId = s.TmdbId!.Value, s.Title })
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
if (serien.Count == 0) return 0;
|
if (serien.Count == 0) return 0;
|
||||||
|
|
||||||
var kennungen = serien.Select(s => s.Id).ToList();
|
var kennungen = serien.Select(s => s.Id).ToList();
|
||||||
var offen = await db.Episodes
|
var offen = await db.Episodes
|
||||||
.Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null))
|
.Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null)
|
||||||
|
&& (nurSprache == null
|
||||||
|
|| (f.Overview != null && f.MetadataLanguage != nurSprache)))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Einmal nachschlagbar machen: `First` je Folge wäre bei 3815 Folgen
|
||||||
|
// und achtzig Serien eine Viertelmillion Vergleiche.
|
||||||
|
var nachKennung = serien.ToDictionary(s => s.Id);
|
||||||
|
|
||||||
var zugeordnet = 0;
|
var zugeordnet = 0;
|
||||||
|
var durch = 0;
|
||||||
foreach (var folge in offen)
|
foreach (var folge in offen)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
var serie = serien.First(s => s.Id == folge.SeriesId);
|
if (!nachKennung.TryGetValue(folge.SeriesId, out var serie)) continue;
|
||||||
|
|
||||||
|
fortschritt?.Report(new Scanschritt(
|
||||||
|
Scanphase.Holen,
|
||||||
|
$"{serie.Title} {folge.SeasonNumber}x{folge.EpisodeNumber:00}",
|
||||||
|
++durch, offen.Count));
|
||||||
|
|
||||||
var gefunden = await anbieter.FindEpisodeAsync(serie.TmdbId, folge.SeasonNumber, folge.EpisodeNumber, ct);
|
var gefunden = await anbieter.FindEpisodeAsync(serie.TmdbId, folge.SeasonNumber, folge.EpisodeNumber, ct);
|
||||||
if (gefunden is null) continue;
|
if (gefunden is null) continue;
|
||||||
@@ -255,6 +510,7 @@ public sealed class MetadataEnricher(
|
|||||||
// Ein Titel aus dem Dateinamen wird ersetzt: TMDB kennt ihn besser.
|
// Ein Titel aus dem Dateinamen wird ersetzt: TMDB kennt ihn besser.
|
||||||
// Ein leerer Fund überschreibt dagegen nichts.
|
// Ein leerer Fund überschreibt dagegen nichts.
|
||||||
folge.Title = gefunden.Title ?? folge.Title;
|
folge.Title = gefunden.Title ?? folge.Title;
|
||||||
|
folge.MetadataLanguage = gefunden.Language;
|
||||||
folge.Overview = gefunden.Overview;
|
folge.Overview = gefunden.Overview;
|
||||||
folge.StillImage = await BildAblegen(gefunden.Still, ct);
|
folge.StillImage = await BildAblegen(gefunden.Still, ct);
|
||||||
zugeordnet++;
|
zugeordnet++;
|
||||||
|
|||||||
@@ -0,0 +1,786 @@
|
|||||||
|
// <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("20261004093243_Dubletten")]
|
||||||
|
partial class Dubletten
|
||||||
|
{
|
||||||
|
/// <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.Property<string>("VersionLabel")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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,190 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Dubletten : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "VersionLabel",
|
||||||
|
table: "MediaFiles",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// **Erst aufräumen, dann die Regel aufstellen.** Andersherum
|
||||||
|
// scheitert diese Migration auf jeder Datenbank, in der schon
|
||||||
|
// Dubletten stehen — und zwar beim Start im Container, wo die
|
||||||
|
// Meldung niemand sieht und der Server einfach nicht hochkommt.
|
||||||
|
//
|
||||||
|
// In SQL und nicht über die Anwendung, weil Migrationen vor allem
|
||||||
|
// anderen laufen: zu diesem Zeitpunkt gibt es noch keine Dienste.
|
||||||
|
//
|
||||||
|
// Der älteste Eintrag gewinnt. Die Kennungen sind Guid v7 und
|
||||||
|
// damit zeitlich geordnet, `MIN` liefert also den zuerst
|
||||||
|
// angelegten — den, auf den vorhandene Verläufe schon zeigen.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE TEMP TABLE FilmDubletten AS
|
||||||
|
SELECT m.Id AS Verlierer,
|
||||||
|
(SELECT MIN(s.Id) FROM Movies s
|
||||||
|
WHERE s.LibraryId = m.LibraryId AND s.TmdbId = m.TmdbId) AS Sieger
|
||||||
|
FROM Movies m
|
||||||
|
WHERE m.TmdbId IS NOT NULL
|
||||||
|
AND m.Id <> (SELECT MIN(s.Id) FROM Movies s
|
||||||
|
WHERE s.LibraryId = m.LibraryId AND s.TmdbId = m.TmdbId);
|
||||||
|
""");
|
||||||
|
|
||||||
|
// Die Dateien zuerst — sie sind das Einzige, was sich nicht
|
||||||
|
// wiederherstellen lässt.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE MediaFiles
|
||||||
|
SET MovieId = (SELECT Sieger FROM FilmDubletten WHERE Verlierer = MediaFiles.MovieId)
|
||||||
|
WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten);
|
||||||
|
""");
|
||||||
|
|
||||||
|
// **Sehfortschritt: erst die konfliktfreien umhängen.** Hat
|
||||||
|
// derselbe Benutzer beide Einträge angefangen, ginge das nicht —
|
||||||
|
// die Eindeutigkeitsregel auf UserItemStates verböte die zweite
|
||||||
|
// Zeile.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE UserItemStates
|
||||||
|
SET MovieId = (SELECT Sieger FROM FilmDubletten WHERE Verlierer = UserItemStates.MovieId)
|
||||||
|
WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM UserItemStates z
|
||||||
|
WHERE z.UserId = UserItemStates.UserId
|
||||||
|
AND z.MovieId = (SELECT Sieger FROM FilmDubletten
|
||||||
|
WHERE Verlierer = UserItemStates.MovieId));
|
||||||
|
""");
|
||||||
|
|
||||||
|
// Für die übrigen gewinnt der weitere Stand — niemand will
|
||||||
|
// Sehfortschritt verlieren, nur weil zwei Einträge verschmelzen.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE UserItemStates
|
||||||
|
SET Position = MAX(Position, COALESCE((
|
||||||
|
SELECT a.Position FROM UserItemStates a
|
||||||
|
JOIN FilmDubletten d ON a.MovieId = d.Verlierer
|
||||||
|
WHERE a.UserId = UserItemStates.UserId AND d.Sieger = UserItemStates.MovieId
|
||||||
|
LIMIT 1), 0)),
|
||||||
|
Watched = MAX(Watched, COALESCE((
|
||||||
|
SELECT a.Watched FROM UserItemStates a
|
||||||
|
JOIN FilmDubletten d ON a.MovieId = d.Verlierer
|
||||||
|
WHERE a.UserId = UserItemStates.UserId AND d.Sieger = UserItemStates.MovieId
|
||||||
|
LIMIT 1), 0)),
|
||||||
|
PlayCount = MAX(PlayCount, COALESCE((
|
||||||
|
SELECT a.PlayCount FROM UserItemStates a
|
||||||
|
JOIN FilmDubletten d ON a.MovieId = d.Verlierer
|
||||||
|
WHERE a.UserId = UserItemStates.UserId AND d.Sieger = UserItemStates.MovieId
|
||||||
|
LIMIT 1), 0))
|
||||||
|
WHERE MovieId IN (SELECT Sieger FROM FilmDubletten);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"DELETE FROM UserItemStates WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten);");
|
||||||
|
|
||||||
|
// Genres und Mitwirkende sind abgeleitet — sie entstehen beim
|
||||||
|
// nächsten Auffrischen neu. Umhängen wäre mehr Aufwand für
|
||||||
|
// dasselbe Ergebnis.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"DELETE FROM GenreLinks WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten);");
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"DELETE FROM Credits WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten);");
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"DELETE FROM Movies WHERE Id IN (SELECT Verlierer FROM FilmDubletten);");
|
||||||
|
migrationBuilder.Sql("DROP TABLE FilmDubletten;");
|
||||||
|
|
||||||
|
// --- Dasselbe für Serien ---------------------------------------
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE TEMP TABLE SerienDubletten AS
|
||||||
|
SELECT x.Id AS Verlierer,
|
||||||
|
(SELECT MIN(s.Id) FROM Series s
|
||||||
|
WHERE s.LibraryId = x.LibraryId AND s.TmdbId = x.TmdbId) AS Sieger
|
||||||
|
FROM Series x
|
||||||
|
WHERE x.TmdbId IS NOT NULL
|
||||||
|
AND x.Id <> (SELECT MIN(s.Id) FROM Series s
|
||||||
|
WHERE s.LibraryId = x.LibraryId AND s.TmdbId = x.TmdbId);
|
||||||
|
""");
|
||||||
|
|
||||||
|
// Folgen, deren Platz beim Sieger frei ist, wandern mit.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE Episodes
|
||||||
|
SET SeriesId = (SELECT Sieger FROM SerienDubletten WHERE Verlierer = Episodes.SeriesId)
|
||||||
|
WHERE SeriesId IN (SELECT Verlierer FROM SerienDubletten)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM Episodes e
|
||||||
|
WHERE e.SeriesId = (SELECT Sieger FROM SerienDubletten
|
||||||
|
WHERE Verlierer = Episodes.SeriesId)
|
||||||
|
AND e.SeasonNumber = Episodes.SeasonNumber
|
||||||
|
AND e.EpisodeNumber = Episodes.EpisodeNumber);
|
||||||
|
""");
|
||||||
|
|
||||||
|
// Die übrigen sind doppelt belegt: die Datei wandert zur Folge des
|
||||||
|
// Siegers, die doppelte Folge fällt weg. Sonst stünde S01E01
|
||||||
|
// zweimal in der Staffelliste.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE MediaFiles
|
||||||
|
SET EpisodeId = (
|
||||||
|
SELECT e.Id FROM Episodes e
|
||||||
|
JOIN Episodes alt ON alt.Id = MediaFiles.EpisodeId
|
||||||
|
JOIN SerienDubletten d ON d.Verlierer = alt.SeriesId
|
||||||
|
WHERE e.SeriesId = d.Sieger
|
||||||
|
AND e.SeasonNumber = alt.SeasonNumber
|
||||||
|
AND e.EpisodeNumber = alt.EpisodeNumber)
|
||||||
|
WHERE EpisodeId IN (
|
||||||
|
SELECT alt.Id FROM Episodes alt
|
||||||
|
JOIN SerienDubletten d ON d.Verlierer = alt.SeriesId);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
DELETE FROM UserItemStates WHERE EpisodeId IN (
|
||||||
|
SELECT alt.Id FROM Episodes alt
|
||||||
|
JOIN SerienDubletten d ON d.Verlierer = alt.SeriesId);
|
||||||
|
""");
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
DELETE FROM Episodes WHERE SeriesId IN (SELECT Verlierer FROM SerienDubletten);
|
||||||
|
""");
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"DELETE FROM GenreLinks WHERE SeriesId IN (SELECT Verlierer FROM SerienDubletten);");
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"DELETE FROM Credits WHERE SeriesId IN (SELECT Verlierer FROM SerienDubletten);");
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"DELETE FROM Series WHERE Id IN (SELECT Verlierer FROM SerienDubletten);");
|
||||||
|
migrationBuilder.Sql("DROP TABLE SerienDubletten;");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Series_LibraryId_TmdbId",
|
||||||
|
table: "Series",
|
||||||
|
columns: new[] { "LibraryId", "TmdbId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Movies_LibraryId_TmdbId",
|
||||||
|
table: "Movies",
|
||||||
|
columns: new[] { "LibraryId", "TmdbId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "\"TmdbId\" IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Series_LibraryId_TmdbId",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Movies_LibraryId_TmdbId",
|
||||||
|
table: "Movies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "VersionLabel",
|
||||||
|
table: "MediaFiles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+789
@@ -0,0 +1,789 @@
|
|||||||
|
// <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("20261004094257_VerschwundeneDateien")]
|
||||||
|
partial class VerschwundeneDateien
|
||||||
|
{
|
||||||
|
/// <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?>("MissingSince")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.Property<string>("VersionLabel")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class VerschwundeneDateien : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "MissingSince",
|
||||||
|
table: "MediaFiles",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MissingSince",
|
||||||
|
table: "MediaFiles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+852
@@ -0,0 +1,852 @@
|
|||||||
|
// <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("20261004102022_SpracheUndTitel")]
|
||||||
|
partial class SpracheUndTitel
|
||||||
|
{
|
||||||
|
/// <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.ItemTranslation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FetchedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Translations");
|
||||||
|
});
|
||||||
|
|
||||||
|
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?>("MissingSince")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.Property<string>("VersionLabel")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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>("Language")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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,95 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SpracheUndTitel : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Language",
|
||||||
|
table: "Users",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 16,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "OriginalTitle",
|
||||||
|
table: "Series",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "OriginalTitle",
|
||||||
|
table: "Movies",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Translations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
SeriesId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
EpisodeId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
Language = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
|
||||||
|
Title = table.Column<string>(type: "TEXT", maxLength: 512, nullable: true),
|
||||||
|
Overview = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Tagline = table.Column<string>(type: "TEXT", maxLength: 512, nullable: true),
|
||||||
|
FetchedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Translations", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Translations_EpisodeId_Language",
|
||||||
|
table: "Translations",
|
||||||
|
columns: new[] { "EpisodeId", "Language" },
|
||||||
|
unique: true,
|
||||||
|
filter: "\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Translations_MovieId_Language",
|
||||||
|
table: "Translations",
|
||||||
|
columns: new[] { "MovieId", "Language" },
|
||||||
|
unique: true,
|
||||||
|
filter: "\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Translations_SeriesId_Language",
|
||||||
|
table: "Translations",
|
||||||
|
columns: new[] { "SeriesId", "Language" },
|
||||||
|
unique: true,
|
||||||
|
filter: "\"SeriesId\" IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Translations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Language",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OriginalTitle",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OriginalTitle",
|
||||||
|
table: "Movies");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
// <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("20261004103857_Bildauswahl")]
|
||||||
|
partial class Bildauswahl
|
||||||
|
{
|
||||||
|
/// <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.ItemTranslation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FetchedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Translations");
|
||||||
|
});
|
||||||
|
|
||||||
|
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?>("MissingSince")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.Property<string>("VersionLabel")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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>("Language")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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,84 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Bildauswahl : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "BackdropPinned",
|
||||||
|
table: "Series",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "LogoPinned",
|
||||||
|
table: "Series",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "PosterPinned",
|
||||||
|
table: "Series",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "BackdropPinned",
|
||||||
|
table: "Movies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "LogoPinned",
|
||||||
|
table: "Movies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "PosterPinned",
|
||||||
|
table: "Movies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BackdropPinned",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LogoPinned",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PosterPinned",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BackdropPinned",
|
||||||
|
table: "Movies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LogoPinned",
|
||||||
|
table: "Movies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PosterPinned",
|
||||||
|
table: "Movies");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,882 @@
|
|||||||
|
// <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("20261004105814_Freigabe")]
|
||||||
|
partial class Freigabe
|
||||||
|
{
|
||||||
|
/// <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.Property<bool>("ReviewNewItems")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.ItemTranslation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FetchedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Translations");
|
||||||
|
});
|
||||||
|
|
||||||
|
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?>("MissingSince")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.Property<string>("VersionLabel")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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>("Language")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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,77 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Freigabe : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "PublishAt",
|
||||||
|
table: "Series",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "PublishAt",
|
||||||
|
table: "Movies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "ReviewNewItems",
|
||||||
|
table: "Libraries",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "PublishAt",
|
||||||
|
table: "Episodes",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
// **Alles Vorhandene gilt als veröffentlicht.**
|
||||||
|
//
|
||||||
|
// Ohne diese zwei Zeilen stünde nach der Migration jeder Titel auf
|
||||||
|
// `null` — und `null` heisst „wartet auf Prüfung". Der gesamte
|
||||||
|
// Bestand wäre für alle ausser Administratoren verschwunden, und
|
||||||
|
// zwar sofort beim Start im Container, ohne Meldung.
|
||||||
|
//
|
||||||
|
// Das Hinzufügedatum und nicht `now`: so bleibt „zuletzt
|
||||||
|
// hinzugefügt" eine sinnvolle Reihenfolge, statt dass vierhundert
|
||||||
|
// Filme auf dieselbe Sekunde fallen.
|
||||||
|
migrationBuilder.Sql("UPDATE Movies SET PublishAt = AddedAt WHERE PublishAt IS NULL;");
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE Episodes SET PublishAt = (
|
||||||
|
SELECT COALESCE(MIN(f.SeenAt), '2000-01-01 00:00:00')
|
||||||
|
FROM MediaFiles f WHERE f.EpisodeId = Episodes.Id)
|
||||||
|
WHERE PublishAt IS NULL;
|
||||||
|
""");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PublishAt",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PublishAt",
|
||||||
|
table: "Movies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ReviewNewItems",
|
||||||
|
table: "Libraries");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PublishAt",
|
||||||
|
table: "Episodes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,885 @@
|
|||||||
|
// <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("20261004121630_Reihenfolge")]
|
||||||
|
partial class Reihenfolge
|
||||||
|
{
|
||||||
|
/// <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.Property<bool>("ReviewNewItems")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.ItemTranslation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FetchedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Translations");
|
||||||
|
});
|
||||||
|
|
||||||
|
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?>("MissingSince")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.Property<string>("VersionLabel")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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>("Language")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Reihenfolge : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SortOrder",
|
||||||
|
table: "Libraries",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SortOrder",
|
||||||
|
table: "Libraries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+907
@@ -0,0 +1,907 @@
|
|||||||
|
// <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("20261004183828_Metadatensprache")]
|
||||||
|
partial class Metadatensprache
|
||||||
|
{
|
||||||
|
/// <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.Property<bool>("ReviewNewItems")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("MetadataLanguage")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.ItemTranslation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FetchedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Translations");
|
||||||
|
});
|
||||||
|
|
||||||
|
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?>("MissingSince")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.Property<string>("VersionLabel")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("MetadataLanguage")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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<bool>("TitlePinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "MetadataLanguage");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "SortTitleValue");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("MetadataLanguage")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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<bool>("TitlePinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "MetadataLanguage");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "SortTitleValue");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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>("Language")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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,91 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Metadatensprache : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "MetadataLanguage",
|
||||||
|
table: "Series",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 16,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "TitlePinned",
|
||||||
|
table: "Series",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "MetadataLanguage",
|
||||||
|
table: "Movies",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 16,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "TitlePinned",
|
||||||
|
table: "Movies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "MetadataLanguage",
|
||||||
|
table: "Episodes",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 16,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Series_LibraryId_MetadataLanguage",
|
||||||
|
table: "Series",
|
||||||
|
columns: new[] { "LibraryId", "MetadataLanguage" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Movies_LibraryId_MetadataLanguage",
|
||||||
|
table: "Movies",
|
||||||
|
columns: new[] { "LibraryId", "MetadataLanguage" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Series_LibraryId_MetadataLanguage",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Movies_LibraryId_MetadataLanguage",
|
||||||
|
table: "Movies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MetadataLanguage",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TitlePinned",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MetadataLanguage",
|
||||||
|
table: "Movies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TitlePinned",
|
||||||
|
table: "Movies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MetadataLanguage",
|
||||||
|
table: "Episodes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,921 @@
|
|||||||
|
// <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("20261005160301_Serienzustand")]
|
||||||
|
partial class Serienzustand
|
||||||
|
{
|
||||||
|
/// <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.Property<bool>("ReviewNewItems")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("MetadataLanguage")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.ItemTranslation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EpisodeId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FetchedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MovieId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EpisodeId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("MovieId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId", "Language")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Translations");
|
||||||
|
});
|
||||||
|
|
||||||
|
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?>("MissingSince")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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.Property<string>("VersionLabel")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("MetadataLanguage")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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<bool>("TitlePinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "MetadataLanguage");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "SortTitleValue");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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<bool>("BackdropPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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>("LogoPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("MatchLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("MetadataLanguage")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalTitle")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<bool>("PosterPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PublishAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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<bool>("TitlePinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("TmdbId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "MetadataLanguage");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "SortTitleValue");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryId", "TmdbId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"TmdbId\" IS NOT NULL");
|
||||||
|
|
||||||
|
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<Guid?>("SeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "EpisodeId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"EpisodeId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "MovieId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MovieId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "SeriesId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SeriesId\" 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>("Language")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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.Library.Series", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("Flaro.Core.Users.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user