feat: setup k8s deploy #64
@@ -0,0 +1,58 @@
|
||||
name: Jellyfin Plugin
|
||||
run-name: "${{ github.ref_name }} - ${{ github.event_name }} by @${{ github.actor }}"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop, main]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- ".github/workflows/jellyfin-plugin.yaml"
|
||||
- "plugins/jellyfin/**"
|
||||
pull_request:
|
||||
branches: [develop, main]
|
||||
paths:
|
||||
- ".github/workflows/jellyfin-plugin.yaml"
|
||||
- "plugins/jellyfin/**"
|
||||
|
||||
concurrency:
|
||||
group: jellyfin-plugin-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Jellyfin Plugin
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up .NET 9
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: "9.0.x"
|
||||
|
||||
- name: Restore plugin dependencies
|
||||
run: dotnet restore plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj
|
||||
|
||||
- name: Publish plugin
|
||||
run: |
|
||||
dotnet publish \
|
||||
plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj \
|
||||
-c Release \
|
||||
--no-restore \
|
||||
-o artifacts/jellyfin-plugin/MovieNight
|
||||
|
||||
- name: Set artifact name
|
||||
id: meta
|
||||
run: echo "artifact-name=jellyfin-plugin-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload plugin artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.meta.outputs.artifact-name }}
|
||||
path: artifacts/jellyfin-plugin/**
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: cloudnative-pg
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://cloudnative-pg.github.io/charts
|
||||
targetRevision: 0.27.1
|
||||
chart: cloudnative-pg
|
||||
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: cloudnative-pg
|
||||
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
enabled: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- ApplyOutOfSyncOnly=true
|
||||
- ServerSideApply=true
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: external-secrets-operator
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://charts.external-secrets.io
|
||||
targetRevision: 2.5.0
|
||||
chart: external-secrets
|
||||
helm:
|
||||
valuesObject:
|
||||
webhook:
|
||||
create: false
|
||||
certController:
|
||||
create: false
|
||||
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: external-secrets
|
||||
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
enabled: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- ApplyOutOfSyncOnly=true
|
||||
- ServerSideApply=true
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: jellyfin
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://jellyfin.github.io/jellyfin-helm
|
||||
targetRevision: 2.7.0
|
||||
chart: jellyfin
|
||||
helm:
|
||||
valuesObject:
|
||||
replicaCount: 1
|
||||
persistence:
|
||||
config:
|
||||
size: 4Gi
|
||||
media:
|
||||
size: 20Gi
|
||||
metrics:
|
||||
enabled: true
|
||||
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: jellyfin
|
||||
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
enabled: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- ApplyOutOfSyncOnly=true
|
||||
- ServerSideApply=true
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: movienight
|
||||
description: MovieNight backend
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.0.1"
|
||||
@@ -0,0 +1,25 @@
|
||||
MovieNight backend has been deployed.
|
||||
|
||||
Backend:
|
||||
Service: {{ include "movienight.fullname" . }}-backend
|
||||
Port: {{ .Values.backend.service.port }}
|
||||
|
||||
Postgres:
|
||||
{{- if .Values.postgres.url }}
|
||||
Using explicit SPRING_DATASOURCE_URL.
|
||||
{{- else if .Values.postgres.existingSecret.name }}
|
||||
Using secret {{ .Values.postgres.existingSecret.name }}.
|
||||
{{- else if .Values.postgres.cluster.enabled }}
|
||||
CNPG Cluster: {{ include "movienight.postgresClusterName" . }}
|
||||
JDBC URL: {{ include "movienight.postgresJdbcUrl" . }}
|
||||
{{- else }}
|
||||
No Postgres values provided. The app will fall back to its embedded H2 defaults.
|
||||
{{- end }}
|
||||
|
||||
Gateway:
|
||||
{{- if .Values.gateway.enabled }}
|
||||
Gateway: {{ include "movienight.gatewayName" . }}
|
||||
GatewayClass: {{ .Values.gateway.className }}
|
||||
{{- else }}
|
||||
Disabled.
|
||||
{{- end }}
|
||||
@@ -0,0 +1,125 @@
|
||||
{{- define "movienight.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "movienight.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := include "movienight.name" . -}}
|
||||
{{- if contains $name .Release.Name -}}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "movienight.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "movienight.labels" -}}
|
||||
helm.sh/chart: {{ include "movienight.chart" . }}
|
||||
{{ include "movienight.selectorLabels" . }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- with .Values.global.labels }}
|
||||
{{ toYaml . }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "movienight.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "movienight.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "movienight.componentLabels" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- $component := .component -}}
|
||||
{{ include "movienight.labels" $root }}
|
||||
app.kubernetes.io/component: {{ $component }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "movienight.componentSelectorLabels" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- $component := .component -}}
|
||||
{{ include "movienight.selectorLabels" $root }}
|
||||
app.kubernetes.io/component: {{ $component }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "movienight.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
{{- default (include "movienight.fullname" .) .Values.serviceAccount.name -}}
|
||||
{{- else -}}
|
||||
{{- default "default" .Values.serviceAccount.name -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "movienight.gatewayName" -}}
|
||||
{{- if .Values.gateway.name -}}
|
||||
{{- .Values.gateway.name -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-gateway" (include "movienight.fullname" .) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "movienight.postgresClusterName" -}}
|
||||
{{- if .Values.postgres.cluster.name -}}
|
||||
{{- .Values.postgres.cluster.name -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-postgres" (include "movienight.fullname" .) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "movienight.postgresHost" -}}
|
||||
{{- default (printf "%s-rw" (include "movienight.postgresClusterName" .)) .Values.postgres.cluster.host -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "movienight.postgresJdbcUrl" -}}
|
||||
{{- printf "jdbc:postgresql://%s:%v/%s" (include "movienight.postgresHost" .) (default 5432 .Values.postgres.cluster.port) .Values.postgres.cluster.database -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "movienight.postgresEnv" -}}
|
||||
{{- if .Values.postgres.url }}
|
||||
- name: SPRING_DATASOURCE_URL
|
||||
value: {{ .Values.postgres.url | quote }}
|
||||
{{- if .Values.postgres.username }}
|
||||
- name: SPRING_DATASOURCE_USERNAME
|
||||
value: {{ .Values.postgres.username | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.postgres.password }}
|
||||
- name: SPRING_DATASOURCE_PASSWORD
|
||||
value: {{ .Values.postgres.password | quote }}
|
||||
{{- end }}
|
||||
{{- else if .Values.postgres.existingSecret.name }}
|
||||
- name: SPRING_DATASOURCE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.postgres.existingSecret.name }}
|
||||
key: {{ .Values.postgres.existingSecret.urlKey }}
|
||||
- name: SPRING_DATASOURCE_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.postgres.existingSecret.name }}
|
||||
key: {{ .Values.postgres.existingSecret.usernameKey }}
|
||||
- name: SPRING_DATASOURCE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.postgres.existingSecret.name }}
|
||||
key: {{ .Values.postgres.existingSecret.passwordKey }}
|
||||
{{- else if .Values.postgres.cluster.enabled }}
|
||||
- name: SPRING_DATASOURCE_URL
|
||||
value: {{ include "movienight.postgresJdbcUrl" . | quote }}
|
||||
- name: SPRING_DATASOURCE_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ required "postgres.cluster.bootstrapSecretName is required when postgres.cluster.enabled=true" .Values.postgres.cluster.bootstrapSecretName }}
|
||||
key: username
|
||||
- name: SPRING_DATASOURCE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ required "postgres.cluster.bootstrapSecretName is required when postgres.cluster.enabled=true" .Values.postgres.cluster.bootstrapSecretName }}
|
||||
key: password
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,92 @@
|
||||
{{- if .Values.backend.enabled }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "movienight.fullname" . }}-backend
|
||||
labels:
|
||||
{{- include "movienight.componentLabels" (dict "root" . "component" "backend") | nindent 4 }}
|
||||
{{- with .Values.global.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
replicas: {{ .Values.backend.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "movienight.componentSelectorLabels" (dict "root" . "component" "backend") | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "movienight.componentSelectorLabels" (dict "root" . "component" "backend") | nindent 8 }}
|
||||
{{- with .Values.backend.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "movienight.serviceAccountName" . }}
|
||||
{{- with .Values.global.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.podSecurityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- $postgresEnv := include "movienight.postgresEnv" . | trim }}
|
||||
containers:
|
||||
- name: backend
|
||||
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
|
||||
{{- with .Values.backend.securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.backend.service.port }}
|
||||
protocol: TCP
|
||||
{{- if or $postgresEnv .Values.backend.env }}
|
||||
env:
|
||||
{{- if $postgresEnv }}
|
||||
{{- $postgresEnv | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.env }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.startupProbe }}
|
||||
startupProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.livenessProbe }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.readinessProbe }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{- if .Values.backend.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "movienight.fullname" . }}-backend
|
||||
labels:
|
||||
{{- include "movienight.componentLabels" (dict "root" . "component" "backend") | nindent 4 }}
|
||||
{{- with .Values.global.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.backend.service.type }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.backend.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
selector:
|
||||
{{- include "movienight.componentSelectorLabels" (dict "root" . "component" "backend") | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,50 @@
|
||||
{{- if .Values.gateway.enabled }}
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: Gateway
|
||||
metadata:
|
||||
name: {{ include "movienight.gatewayName" . }}
|
||||
labels:
|
||||
{{- include "movienight.componentLabels" (dict "root" . "component" "gateway") | nindent 4 }}
|
||||
{{- with .Values.gateway.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if or .Values.global.annotations .Values.gateway.annotations }}
|
||||
annotations:
|
||||
{{- with .Values.global.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.gateway.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
gatewayClassName: {{ required "gateway.className is required when gateway.enabled=true" .Values.gateway.className | quote }}
|
||||
listeners:
|
||||
{{- if .Values.gateway.http.enabled }}
|
||||
- name: http
|
||||
protocol: HTTP
|
||||
port: {{ .Values.gateway.http.port }}
|
||||
{{- if .Values.gateway.listenerHostname }}
|
||||
hostname: {{ .Values.gateway.listenerHostname | quote }}
|
||||
{{- end }}
|
||||
allowedRoutes:
|
||||
namespaces:
|
||||
from: Same
|
||||
{{- end }}
|
||||
{{- if .Values.gateway.https.enabled }}
|
||||
- name: https
|
||||
protocol: HTTPS
|
||||
port: {{ .Values.gateway.https.port }}
|
||||
{{- if .Values.gateway.listenerHostname }}
|
||||
hostname: {{ .Values.gateway.listenerHostname | quote }}
|
||||
{{- end }}
|
||||
tls:
|
||||
mode: Terminate
|
||||
certificateRefs:
|
||||
- kind: Secret
|
||||
name: {{ required "gateway.https.secretName is required when gateway.https.enabled=true" .Values.gateway.https.secretName }}
|
||||
allowedRoutes:
|
||||
namespaces:
|
||||
from: Same
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,29 @@
|
||||
{{- if and .Values.routes.enabled .Values.gateway.enabled }}
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ include "movienight.fullname" . }}
|
||||
labels:
|
||||
{{- include "movienight.componentLabels" (dict "root" . "component" "route") | nindent 4 }}
|
||||
{{- with .Values.global.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
parentRefs:
|
||||
- name: {{ include "movienight.gatewayName" . }}
|
||||
{{- if .Values.gateway.hostnames }}
|
||||
hostnames:
|
||||
{{- toYaml .Values.gateway.hostnames | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- if and .Values.routes.backend.enabled .Values.backend.enabled }}
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: {{ .Values.routes.backend.pathPrefix | quote }}
|
||||
backendRefs:
|
||||
- name: {{ include "movienight.fullname" . }}-backend
|
||||
port: {{ .Values.backend.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,36 @@
|
||||
{{- if .Values.postgres.cluster.enabled }}
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
metadata:
|
||||
name: {{ include "movienight.postgresClusterName" . }}
|
||||
labels:
|
||||
{{- include "movienight.componentLabels" (dict "root" . "component" "postgres") | nindent 4 }}
|
||||
{{- with .Values.postgres.cluster.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if or .Values.global.annotations .Values.postgres.cluster.annotations }}
|
||||
annotations:
|
||||
{{- with .Values.global.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.postgres.cluster.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
instances: {{ .Values.postgres.cluster.instances }}
|
||||
storage:
|
||||
size: {{ .Values.postgres.cluster.storage.size | quote }}
|
||||
{{- if .Values.postgres.cluster.storage.storageClass }}
|
||||
storageClass: {{ .Values.postgres.cluster.storage.storageClass | quote }}
|
||||
{{- end }}
|
||||
bootstrap:
|
||||
initdb:
|
||||
database: {{ .Values.postgres.cluster.database | quote }}
|
||||
owner: {{ .Values.postgres.cluster.owner | quote }}
|
||||
secret:
|
||||
name: {{ required "postgres.cluster.bootstrapSecretName is required when postgres.cluster.enabled=true" .Values.postgres.cluster.bootstrapSecretName }}
|
||||
{{- with .Values.postgres.cluster.extraSpec }}
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,12 @@
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "movienight.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "movienight.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,246 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"definitions": {
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"annotations": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"envVar": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"valueFrom": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": true
|
||||
},
|
||||
"image": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repository": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"pullPolicy": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"probe": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"nameOverride": {
|
||||
"type": "string"
|
||||
},
|
||||
"fullnameOverride": {
|
||||
"type": "string"
|
||||
},
|
||||
"global": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"imagePullSecrets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"$ref": "#/definitions/labels"
|
||||
},
|
||||
"annotations": {
|
||||
"$ref": "#/definitions/annotations"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"serviceAccount": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"create": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"annotations": {
|
||||
"$ref": "#/definitions/annotations"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"postgres": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"existingSecret": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"cluster": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"instances": {
|
||||
"type": "integer"
|
||||
},
|
||||
"database": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner": {
|
||||
"type": "string"
|
||||
},
|
||||
"bootstrapSecretName": {
|
||||
"type": "string"
|
||||
},
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer"
|
||||
},
|
||||
"storage": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"labels": {
|
||||
"$ref": "#/definitions/labels"
|
||||
},
|
||||
"annotations": {
|
||||
"$ref": "#/definitions/annotations"
|
||||
},
|
||||
"extraSpec": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"backend": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"replicaCount": {
|
||||
"type": "integer"
|
||||
},
|
||||
"image": {
|
||||
"$ref": "#/definitions/image"
|
||||
},
|
||||
"service": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"env": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/envVar"
|
||||
}
|
||||
},
|
||||
"envFrom": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"podAnnotations": {
|
||||
"$ref": "#/definitions/annotations"
|
||||
},
|
||||
"podLabels": {
|
||||
"$ref": "#/definitions/labels"
|
||||
},
|
||||
"resources": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"securityContext": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"podSecurityContext": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"nodeSelector": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"tolerations": {
|
||||
"type": "array"
|
||||
},
|
||||
"affinity": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"readinessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"startupProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"gateway": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"routes": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
global:
|
||||
imagePullSecrets: []
|
||||
labels: {}
|
||||
annotations: {}
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
name: ""
|
||||
annotations: {}
|
||||
|
||||
postgres:
|
||||
# Set url/username/password for a fixed database, or use existingSecret.
|
||||
url: ""
|
||||
username: ""
|
||||
password: ""
|
||||
existingSecret:
|
||||
name: ""
|
||||
urlKey: url
|
||||
usernameKey: username
|
||||
passwordKey: password
|
||||
cluster:
|
||||
enabled: false
|
||||
name: ""
|
||||
instances: 1
|
||||
database: postgres
|
||||
owner: postgres
|
||||
# Secret containing CNPG initdb owner credentials (username/password).
|
||||
bootstrapSecretName: ""
|
||||
host: ""
|
||||
port: 5432
|
||||
storage:
|
||||
size: 10Gi
|
||||
storageClass: ""
|
||||
labels: {}
|
||||
annotations: {}
|
||||
extraSpec: {}
|
||||
|
||||
backend:
|
||||
enabled: true
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: ghcr.io/devitq/movienight-backend
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
env:
|
||||
- name: SERVER_PORT
|
||||
value: "8080"
|
||||
- name: SPRING_DATASOURCE_DRIVER_CLASS_NAME
|
||||
value: org.postgresql.Driver
|
||||
- name: SPRING_FLYWAY_ENABLED
|
||||
value: "true"
|
||||
- name: SPRING_FLYWAY_LOCATIONS
|
||||
value: classpath:db/migration
|
||||
- name: SPRING_FLYWAY_BASELINE_ON_MIGRATE
|
||||
value: "true"
|
||||
- name: SPRING_H2_CONSOLE_ENABLED
|
||||
value: "false"
|
||||
envFrom: []
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
resources: {}
|
||||
securityContext: {}
|
||||
podSecurityContext: {}
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/liveness
|
||||
port: http
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /actuator/health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 24
|
||||
|
||||
gateway:
|
||||
enabled: false
|
||||
name: ""
|
||||
className: ""
|
||||
labels: {}
|
||||
annotations: {}
|
||||
listenerHostname: ""
|
||||
hostnames: []
|
||||
http:
|
||||
enabled: true
|
||||
port: 80
|
||||
https:
|
||||
enabled: false
|
||||
port: 443
|
||||
secretName: ""
|
||||
|
||||
routes:
|
||||
enabled: true
|
||||
backend:
|
||||
enabled: true
|
||||
pathPrefix: /
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: movienight-backend
|
||||
namespace: movienight
|
||||
spec:
|
||||
secretStoreRef:
|
||||
name: infisical
|
||||
kind: ClusterSecretStore
|
||||
|
||||
target:
|
||||
name: movienight-backend
|
||||
creationPolicy: Owner
|
||||
template:
|
||||
engineVersion: v2
|
||||
type: Opaque
|
||||
data:
|
||||
JELLYFIN_INTEGRATION_ENABLED: "true"
|
||||
JELLYFIN_BASE_URL: "http://jellyfin.jellyfin.svc.cluster.local:8096"
|
||||
JELLYFIN_WEB_URL: "{{ .jellyfinWebUrl }}"
|
||||
JELLYFIN_PLUGIN_TOKEN: "{{ .jellyfinPluginToken }}"
|
||||
JELLYFIN_API_KEY: "{{ .jellyfinApiKey }}"
|
||||
OAUTH2_GOOGLE_CLIENT_ID: "{{ .googleClientId }}"
|
||||
OAUTH2_GOOGLE_CLIENT_SECRET: "{{ .googleClientSecret }}"
|
||||
OAUTH2_YANDEX_CLIENT_ID: "{{ .yandexClientId }}"
|
||||
OAUTH2_YANDEX_CLIENT_SECRET: "{{ .yandexClientSecret }}"
|
||||
OAUTH2_VK_CLIENT_ID: "{{ .vkClientId }}"
|
||||
OAUTH2_VK_CLIENT_SECRET: "{{ .vkClientSecret }}"
|
||||
|
||||
data:
|
||||
- secretKey: jellyfinWebUrl
|
||||
remoteRef:
|
||||
key: /movienight/MOVIENIGHT_JELLYFIN_WEB_URL
|
||||
- secretKey: jellyfinPluginToken
|
||||
remoteRef:
|
||||
key: /movienight/MOVIENIGHT_JELLYFIN_PLUGIN_TOKEN
|
||||
- secretKey: jellyfinApiKey
|
||||
remoteRef:
|
||||
key: /movienight/MOVIENIGHT_JELLYFIN_API_KEY
|
||||
- secretKey: googleClientId
|
||||
remoteRef:
|
||||
key: /movienight/MOVIENIGHT_OAUTH2_GOOGLE_CLIENT_ID
|
||||
- secretKey: googleClientSecret
|
||||
remoteRef:
|
||||
key: /movienight/MOVIENIGHT_OAUTH2_GOOGLE_CLIENT_SECRET
|
||||
- secretKey: yandexClientId
|
||||
remoteRef:
|
||||
key: /movienight/MOVIENIGHT_OAUTH2_YANDEX_CLIENT_ID
|
||||
- secretKey: yandexClientSecret
|
||||
remoteRef:
|
||||
key: /movienight/MOVIENIGHT_OAUTH2_YANDEX_CLIENT_SECRET
|
||||
- secretKey: vkClientId
|
||||
remoteRef:
|
||||
key: /movienight/MOVIENIGHT_OAUTH2_VK_CLIENT_ID
|
||||
- secretKey: vkClientSecret
|
||||
remoteRef:
|
||||
key: /movienight/MOVIENIGHT_OAUTH2_VK_CLIENT_SECRET
|
||||
|
||||
refreshInterval: 1h
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: movienight-cnpg-bootstrap
|
||||
namespace: movienight
|
||||
spec:
|
||||
secretStoreRef:
|
||||
name: infisical
|
||||
kind: ClusterSecretStore
|
||||
|
||||
target:
|
||||
name: movienight-cnpg-bootstrap
|
||||
creationPolicy: Owner
|
||||
template:
|
||||
engineVersion: v2
|
||||
type: kubernetes.io/basic-auth
|
||||
data:
|
||||
username: "{{ .dbUsername }}"
|
||||
password: "{{ .dbPassword }}"
|
||||
|
||||
data:
|
||||
- secretKey: dbUsername
|
||||
remoteRef:
|
||||
key: /movienight/MOVIENIGHT_DB_USERNAME
|
||||
- secretKey: dbPassword
|
||||
remoteRef:
|
||||
key: /movienight/MOVIENIGHT_DB_PASSWORD
|
||||
|
||||
refreshInterval: 1h
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ClusterSecretStore
|
||||
metadata:
|
||||
name: infisical
|
||||
spec:
|
||||
provider:
|
||||
infisical:
|
||||
hostAPI: https://vault.itqdev.xyz
|
||||
auth:
|
||||
universalAuthCredentials:
|
||||
clientId:
|
||||
name: infisical-secret
|
||||
key: clientId
|
||||
namespace: external-secrets
|
||||
clientSecret:
|
||||
name: infisical-secret
|
||||
key: clientSecret
|
||||
namespace: external-secrets
|
||||
secretsScope:
|
||||
projectSlug: default-c-nay
|
||||
environmentSlug: prod
|
||||
secretsPath: /
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: jellyfin
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: movienight
|
||||
@@ -0,0 +1,2 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// MovieNight plugin settings persisted by Jellyfin.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether integration calls are enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MovieNight backend base URL.
|
||||
/// </summary>
|
||||
public string BackendBaseUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the backend plugin token.
|
||||
/// </summary>
|
||||
public string ApiToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the periodic sync interval in minutes.
|
||||
/// </summary>
|
||||
public int SyncIntervalMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether playback stop events are pushed to MovieNight.
|
||||
/// </summary>
|
||||
public bool EnablePlaybackEvents { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether periodic backend sync is enabled.
|
||||
/// </summary>
|
||||
public bool EnablePeriodicSync { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets enabled Jellyfin library ids. Empty means all libraries.
|
||||
/// </summary>
|
||||
public List<string> EnabledLibraryIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path where .strm files will be created.
|
||||
/// </summary>
|
||||
public string StrmOutputPath { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
const movieNightConfigPage = {
|
||||
pluginId: "42c72919-d6ff-4f62-bb8c-0fac39efafdb",
|
||||
|
||||
loadConfiguration(view) {
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
return ApiClient.getPluginConfiguration(this.pluginId)
|
||||
.then((config) => {
|
||||
view.querySelector("#BackendBaseUrl").value =
|
||||
config.BackendBaseUrl || "";
|
||||
view.querySelector("#ApiToken").value = config.ApiToken || "";
|
||||
view.querySelector("#SyncIntervalMinutes").value =
|
||||
config.SyncIntervalMinutes || 30;
|
||||
view.querySelector("#StrmOutputPath").value =
|
||||
config.StrmOutputPath || "";
|
||||
view.querySelector("#Enabled").checked = config.Enabled || false;
|
||||
view.querySelector("#EnablePeriodicSync").checked =
|
||||
config.EnablePeriodicSync !== false;
|
||||
view.querySelector("#EnablePlaybackEvents").checked =
|
||||
config.EnablePlaybackEvents !== false;
|
||||
|
||||
const uiScriptUrl = ApiClient.getUrl("web/ConfigurationPage", {
|
||||
name: "MovieNight.ui.js",
|
||||
});
|
||||
view.querySelector("#UIScriptUrl").innerText = uiScriptUrl;
|
||||
})
|
||||
.finally(() => {
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
},
|
||||
|
||||
saveConfiguration(view) {
|
||||
const form = view.querySelector("#MovieNightConfigForm");
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
return ApiClient.getPluginConfiguration(this.pluginId)
|
||||
.then((config) => {
|
||||
config.BackendBaseUrl = form.querySelector("#BackendBaseUrl").value;
|
||||
config.ApiToken = form.querySelector("#ApiToken").value;
|
||||
config.SyncIntervalMinutes = parseInt(
|
||||
form.querySelector("#SyncIntervalMinutes").value || "30",
|
||||
10,
|
||||
);
|
||||
config.StrmOutputPath = form.querySelector("#StrmOutputPath").value;
|
||||
config.Enabled = form.querySelector("#Enabled").checked;
|
||||
config.EnablePeriodicSync =
|
||||
form.querySelector("#EnablePeriodicSync").checked;
|
||||
config.EnablePlaybackEvents =
|
||||
form.querySelector("#EnablePlaybackEvents").checked;
|
||||
|
||||
return ApiClient.updatePluginConfiguration(this.pluginId, config);
|
||||
})
|
||||
.then((result) => {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
})
|
||||
.finally(() => {
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
},
|
||||
|
||||
testConnection() {
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
return ApiClient.ajax({
|
||||
type: "POST",
|
||||
url: ApiClient.getUrl("MovieNight/TestConnection"),
|
||||
})
|
||||
.then((result) => {
|
||||
Dashboard.alert((result && result.message) || "OK");
|
||||
})
|
||||
.catch(() => {
|
||||
Dashboard.alert("MovieNight connection test failed");
|
||||
})
|
||||
.finally(() => {
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default function (view) {
|
||||
movieNightConfigPage.loadConfiguration(view);
|
||||
|
||||
view
|
||||
.querySelector("#MovieNightConfigForm")
|
||||
.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
movieNightConfigPage.saveConfiguration(view);
|
||||
});
|
||||
|
||||
view.querySelector("#TestConnection").addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
movieNightConfigPage.testConnection();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MovieNight</title>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="MovieNightConfigPage"
|
||||
data-role="page"
|
||||
class="page type-interior pluginConfigurationPage"
|
||||
data-controller="__plugin/MovieNight.js">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="MovieNightConfigForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="BackendBaseUrl">Backend URL</label>
|
||||
<input is="emby-input" id="BackendBaseUrl" name="BackendBaseUrl" type="url" placeholder="http://localhost:8080" />
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ApiToken">Plugin token</label>
|
||||
<input is="emby-input" id="ApiToken" name="ApiToken" type="password" autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="SyncIntervalMinutes">Sync interval minutes</label>
|
||||
<input is="emby-input" id="SyncIntervalMinutes" name="SyncIntervalMinutes" type="number" min="1" max="1440" />
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="StrmOutputPath">STRM output path</label>
|
||||
<input is="emby-input" id="StrmOutputPath" name="StrmOutputPath" type="text" placeholder="/data/movies/movienight" />
|
||||
<div class="fieldDescription">Directory where .strm files will be created for new films.</div>
|
||||
</div>
|
||||
|
||||
<label class="checkboxContainer">
|
||||
<input id="Enabled" name="Enabled" type="checkbox" is="emby-checkbox" />
|
||||
<span>Enable MovieNight integration</span>
|
||||
</label>
|
||||
|
||||
<label class="checkboxContainer">
|
||||
<input id="EnablePeriodicSync" name="EnablePeriodicSync" type="checkbox" is="emby-checkbox" />
|
||||
<span>Enable periodic backend sync</span>
|
||||
</label>
|
||||
|
||||
<label class="checkboxContainer">
|
||||
<input id="EnablePlaybackEvents" name="EnablePlaybackEvents" type="checkbox" is="emby-checkbox" />
|
||||
<span>Send playback stop events</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="button" id="TestConnection" class="raised block">
|
||||
<span>Test connection</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2em; padding: 1em; background: #333; border-radius: 4px;">
|
||||
<h3>UI Integration</h3>
|
||||
<p>To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin <strong>Custom JavaScript</strong> setting (Dashboard > General):</p>
|
||||
<code id="UIScriptUrl" style="display: block; padding: 0.5em; background: #000; word-break: break-all;"></code>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,456 @@
|
||||
(function () {
|
||||
const PLUGIN_ID = "42c72919-d6ff-4f62-bb8c-0fac39efafdb";
|
||||
|
||||
function getAlert() {
|
||||
if (typeof Dashboard !== 'undefined' && Dashboard.alert) {
|
||||
return (options) => Dashboard.alert(options);
|
||||
}
|
||||
return (options) => {
|
||||
const msg = typeof options === 'string' ? options : (options.text || options.title);
|
||||
alert(msg);
|
||||
};
|
||||
}
|
||||
|
||||
const showMsg = getAlert();
|
||||
|
||||
function createTextButton(text, className, onClick) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.is = 'emby-button';
|
||||
btn.className = `emby-button raised ${className}`;
|
||||
btn.style.margin = '0.5em';
|
||||
btn.style.padding = '0.4em 1em';
|
||||
btn.innerHTML = `<span>${text}</span>`;
|
||||
btn.onclick = onClick;
|
||||
return btn;
|
||||
}
|
||||
|
||||
function createIconButton(icon, title, className, onClick) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.is = 'emby-button';
|
||||
btn.className = `button-flat detailButton emby-button ${className}`;
|
||||
btn.title = title;
|
||||
btn.innerHTML = `
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon ${icon}" aria-hidden="true"></span>
|
||||
</div>
|
||||
`;
|
||||
btn.onclick = onClick;
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function injectUI() {
|
||||
// Check for onboarding
|
||||
await checkOnboarding();
|
||||
|
||||
// 1. Item Detail Page
|
||||
const detailButtons = document.querySelector('.mainDetailButtons');
|
||||
if (detailButtons) {
|
||||
const itemId = getItemIdFromUrl();
|
||||
if (itemId) {
|
||||
// MovieNight Rating
|
||||
if (!document.querySelector('.btnMovieNightRate')) {
|
||||
const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId);
|
||||
});
|
||||
insertInDetailRow(detailButtons, rateBtn);
|
||||
}
|
||||
// Mark Viewed in MovieNight
|
||||
if (!document.querySelector('.btnMovieNightMarkViewed')) {
|
||||
const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); submitViewed(itemId);
|
||||
});
|
||||
insertInDetailRow(detailButtons, viewedBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Library Pages - Add text buttons to toolbar
|
||||
const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x');
|
||||
if (toolBar && !document.querySelector('.btnMovieNightRecommend')) {
|
||||
toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => {
|
||||
e.preventDefault(); showRecommendation();
|
||||
}));
|
||||
toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => {
|
||||
e.preventDefault(); showAddMovieDialog();
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. Home Page - Prepend a MovieNight section
|
||||
const homeSections = document.querySelector('.sections.homeSectionsContainer');
|
||||
if (homeSections && !document.querySelector('.movieNightHomeButtons')) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'verticalSection movieNightHomeButtons';
|
||||
section.style.padding = '0 var(--sidePadding)';
|
||||
section.innerHTML = `
|
||||
<div class="sectionTitleContainer" style="display:flex; align-items:center; justify-content:space-between;">
|
||||
<h2 class="sectionTitle">MovieNight</h2>
|
||||
<span class="movieNightSyncStatus" style="font-size:0.8em; opacity:0.7;"></span>
|
||||
</div>
|
||||
<div class="movieNightBtnContainer" style="display:flex; flex-wrap:wrap; margin-top:0.5em;"></div>
|
||||
`;
|
||||
const btnContainer = section.querySelector('.movieNightBtnContainer');
|
||||
btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation));
|
||||
btnContainer.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', showAddMovieDialog));
|
||||
btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync));
|
||||
|
||||
homeSections.insertBefore(section, homeSections.firstChild);
|
||||
updateSyncStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function insertInDetailRow(container, btn) {
|
||||
const moreBtn = container.querySelector('.btnMoreCommands');
|
||||
if (moreBtn) container.insertBefore(btn, moreBtn);
|
||||
else container.appendChild(btn);
|
||||
}
|
||||
|
||||
function getItemIdFromUrl() {
|
||||
const queryString = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : window.location.search;
|
||||
const params = new URLSearchParams(queryString);
|
||||
return params.get('id') || params.get('itemId');
|
||||
}
|
||||
|
||||
function createOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'dialogBackdrop dialogBackdropOpened';
|
||||
overlay.style.zIndex = '99998';
|
||||
overlay.style.backgroundColor = 'rgba(0,0,0,0.7)';
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.right = '0'; overlay.style.bottom = '0';
|
||||
overlay.style.backdropFilter = 'blur(8px)';
|
||||
overlay.style.opacity = '1';
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function createDialogBase(title) {
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'dialog';
|
||||
dialog.style.position = 'fixed';
|
||||
dialog.style.top = '50%'; dialog.style.left = '50%';
|
||||
dialog.style.transform = 'translate(-50%, -50%)';
|
||||
dialog.style.zIndex = '99999';
|
||||
dialog.style.padding = '2.5em';
|
||||
dialog.style.minWidth = '350px';
|
||||
dialog.style.backgroundColor = '#1a1a1a';
|
||||
dialog.style.borderRadius = '1.5em';
|
||||
dialog.style.color = 'white';
|
||||
dialog.style.boxShadow = '0 20px 50px rgba(0,0,0,0.8)';
|
||||
dialog.style.border = '1px solid #444';
|
||||
dialog.style.opacity = '1';
|
||||
|
||||
dialog.innerHTML = `
|
||||
<h2 style="margin-top:0; text-align:center; font-weight:400; color:white; opacity:1;">${title}</h2>
|
||||
<div class="dialog-content" style="margin:1.5em 0; opacity:1;"></div>
|
||||
<div class="dialog-footer" style="display:flex; gap:1em; opacity:1;">
|
||||
<button is="emby-button" class="emby-button button-flat btnCancel" style="flex:1; color: white !important; opacity:1;">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function showRatingDialog(itemId) {
|
||||
const overlay = createOverlay();
|
||||
const dialog = createDialogBase('Rate on MovieNight');
|
||||
const content = dialog.querySelector('.dialog-content');
|
||||
|
||||
content.innerHTML = `<div class="rating-grid" style="display:grid; grid-template-columns:repeat(5, 1fr); gap:0.8em;"></div>`;
|
||||
const grid = content.querySelector('.rating-grid');
|
||||
|
||||
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button'; btn.is = 'emby-button';
|
||||
btn.className = 'emby-button raised';
|
||||
btn.innerText = i;
|
||||
btn.style.padding = '0.8em 0';
|
||||
btn.style.textAlign = 'center';
|
||||
btn.style.display = 'flex';
|
||||
btn.style.alignItems = 'center';
|
||||
btn.style.justifyContent = 'center';
|
||||
btn.style.fontSize = '1.2em';
|
||||
btn.onclick = async () => { cleanup(); await submitRating(itemId, i); };
|
||||
grid.appendChild(btn);
|
||||
}
|
||||
|
||||
dialog.querySelector('.btnCancel').onclick = cleanup;
|
||||
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
async function showAddMovieDialog() {
|
||||
const overlay = createOverlay();
|
||||
const dialog = createDialogBase('Add Movie (STRM)');
|
||||
const content = dialog.querySelector('.dialog-content');
|
||||
const footer = dialog.querySelector('.dialog-footer');
|
||||
|
||||
content.innerHTML = `
|
||||
<div style="margin-bottom:1em;">
|
||||
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Movie Title (Required)</label>
|
||||
<input type="text" class="emby-input txtTitle" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="e.g. Inception">
|
||||
</div>
|
||||
<div style="display:flex; gap:1em; margin-bottom:1em;">
|
||||
<div style="flex:1;">
|
||||
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Year</label>
|
||||
<input type="number" class="emby-input txtYear" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="2010">
|
||||
</div>
|
||||
<div style="flex:2;">
|
||||
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">IMDb ID</label>
|
||||
<input type="text" class="emby-input txtImdb" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="tt1375666">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Stream URL (Optional)</label>
|
||||
<input type="text" class="emby-input txtUrl" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="http://...">
|
||||
</div>
|
||||
`;
|
||||
|
||||
const btnAdd = document.createElement('button');
|
||||
btnAdd.className = 'emby-button raised button-submit';
|
||||
btnAdd.style.flex = '2';
|
||||
btnAdd.style.backgroundColor = '#0064d2';
|
||||
btnAdd.innerHTML = '<span>Add Film</span>';
|
||||
footer.insertBefore(btnAdd, footer.firstChild);
|
||||
|
||||
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
|
||||
|
||||
btnAdd.onclick = async () => {
|
||||
const title = dialog.querySelector('.txtTitle').value;
|
||||
const year = dialog.querySelector('.txtYear').value;
|
||||
const imdbId = dialog.querySelector('.txtImdb').value;
|
||||
const url = dialog.querySelector('.txtUrl').value;
|
||||
if (!title) return;
|
||||
cleanup();
|
||||
await addMovie(title, url, year, imdbId);
|
||||
};
|
||||
|
||||
dialog.querySelector('.btnCancel').onclick = cleanup;
|
||||
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
dialog.querySelector('.txtTitle').focus();
|
||||
}
|
||||
|
||||
async function showOnboardingDialog() {
|
||||
const overlay = createOverlay();
|
||||
const dialog = createDialogBase('Welcome to MovieNight!');
|
||||
dialog.style.minWidth = '450px';
|
||||
const content = dialog.querySelector('.dialog-content');
|
||||
const footer = dialog.querySelector('.dialog-footer');
|
||||
|
||||
content.innerHTML = `
|
||||
<p style="margin-bottom:1.5em; opacity:0.8; text-align:center;">Pick your preferences to get better recommendations.</p>
|
||||
<div style="margin-bottom:1.5em;">
|
||||
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Favorite Genres</label>
|
||||
<div class="genre-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
|
||||
</div>
|
||||
<div style="margin-bottom:1.5em;">
|
||||
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Preferred Eras</label>
|
||||
<div class="era-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Content Types</label>
|
||||
<div class="type-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const genres = ["Action", "Comedy", "Drama", "Sci-Fi", "Horror", "Thriller", "Animation", "Documentary"];
|
||||
const eras = ["1980s", "1990s", "2000s", "2010s", "2020s"];
|
||||
const types = ["FILM", "SERIES"];
|
||||
|
||||
const selections = { genres: new Set(), eras: new Set(), types: new Set() };
|
||||
|
||||
const createChip = (text, container, type) => {
|
||||
const chip = document.createElement('div');
|
||||
chip.innerText = text;
|
||||
chip.style.cssText = 'padding:0.4em 1em; border-radius:2em; border:1px solid #444; cursor:pointer; font-size:0.9em; transition:all 0.2s;';
|
||||
chip.onclick = () => {
|
||||
if (selections[type].has(text)) {
|
||||
selections[type].delete(text);
|
||||
chip.style.backgroundColor = 'transparent';
|
||||
chip.style.borderColor = '#444';
|
||||
} else {
|
||||
selections[type].add(text);
|
||||
chip.style.backgroundColor = '#0064d2';
|
||||
chip.style.borderColor = '#0064d2';
|
||||
}
|
||||
};
|
||||
container.appendChild(chip);
|
||||
};
|
||||
|
||||
genres.forEach(g => createChip(g, content.querySelector('.genre-chips'), 'genres'));
|
||||
eras.forEach(e => createChip(e, content.querySelector('.era-chips'), 'eras'));
|
||||
types.forEach(t => createChip(t, content.querySelector('.type-chips'), 'types'));
|
||||
|
||||
const btnSave = document.createElement('button');
|
||||
btnSave.className = 'emby-button raised button-submit';
|
||||
btnSave.style.flex = '2';
|
||||
btnSave.style.backgroundColor = '#0064d2';
|
||||
btnSave.innerHTML = '<span>Save & Start</span>';
|
||||
footer.insertBefore(btnSave, footer.firstChild);
|
||||
|
||||
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
|
||||
|
||||
btnSave.onclick = async () => {
|
||||
const payload = {
|
||||
weightedGenres: Object.fromEntries([...selections.genres].map(g => [g, 5])),
|
||||
eras: [...selections.eras],
|
||||
contentTypes: [...selections.types]
|
||||
};
|
||||
cleanup();
|
||||
await completeOnboarding(payload);
|
||||
};
|
||||
|
||||
dialog.querySelector('.btnCancel').onclick = cleanup;
|
||||
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
async function checkOnboarding() {
|
||||
if (window.movieNightOnboardingChecked) return;
|
||||
window.movieNightOnboardingChecked = true;
|
||||
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const prefs = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Preferences`));
|
||||
if (!prefs || (!Object.keys(prefs.weightedGenres || {}).length && !prefs.eras?.length)) {
|
||||
showOnboardingDialog();
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.status === 404) showOnboardingDialog();
|
||||
}
|
||||
}
|
||||
|
||||
async function completeOnboarding(payload) {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
await ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Onboarding`),
|
||||
data: JSON.stringify(payload),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
showMsg('Welcome! Your preferences have been saved.');
|
||||
} catch (err) {
|
||||
showMsg('Failed to save onboarding preferences.');
|
||||
}
|
||||
}
|
||||
|
||||
async function showRecommendation() {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Recommendations`));
|
||||
const recommendations = typeof response === 'string' ? JSON.parse(response) : response;
|
||||
|
||||
if (recommendations && recommendations.length > 0) {
|
||||
const rec = recommendations[0];
|
||||
const film = rec.film || rec;
|
||||
showMsg({
|
||||
title: 'MovieNight Recommendation',
|
||||
text: `How about watching: ${film.title}?\n\nReason: ${rec.reasons?.join(', ') || 'Based on your preferences'}`
|
||||
});
|
||||
} else {
|
||||
showMsg('No recommendations found at the moment.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get recommendations', err);
|
||||
showMsg('Failed to get recommendations. Check your API token and MovieNight status.');
|
||||
}
|
||||
}
|
||||
|
||||
async function addMovie(title, url, year, imdbId) {
|
||||
try {
|
||||
const data = { title, url };
|
||||
if (year) data.year = parseInt(year);
|
||||
if (imdbId) data.imdbId = imdbId;
|
||||
|
||||
await ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl(`MovieNight/Films`),
|
||||
data: JSON.stringify(data),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
showMsg(`STRM file created for "${title}". Refresh your library to see it.`);
|
||||
} catch (err) {
|
||||
console.error('Failed to create movie', err);
|
||||
showMsg('Failed to create movie. Ensure STRM output path is configured.');
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerSync() {
|
||||
try {
|
||||
await ApiClient.ajax({ type: 'POST', url: ApiClient.getUrl(`MovieNight/Sync`) });
|
||||
showMsg('Library sync triggered!');
|
||||
setTimeout(updateSyncStatus, 2000);
|
||||
} catch (err) {
|
||||
showMsg('Failed to trigger sync.');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSyncStatus() {
|
||||
const statusEl = document.querySelector('.movieNightSyncStatus');
|
||||
if (!statusEl) return;
|
||||
try {
|
||||
const state = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/SyncState`));
|
||||
const states = Array.isArray(state) ? state : [];
|
||||
const latest = states
|
||||
.map(s => s.lastSuccessfulSyncAt || s.lastSyncedAt)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.pop();
|
||||
if (latest) {
|
||||
statusEl.innerText = `Last sync: ${new Date(latest).toLocaleString()}`;
|
||||
}
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function submitRating(itemId, score) {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
await ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Ratings/Films/${itemId}`),
|
||||
data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
showMsg('Rating submitted to MovieNight!');
|
||||
} catch (err) {
|
||||
showMsg('Failed to submit rating.');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitViewed(itemId) {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
await ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Library/Films/${itemId}/Viewed`),
|
||||
data: JSON.stringify({ watchedAt: new Date().toISOString() }),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
showMsg('Marked as viewed in MovieNight!');
|
||||
} catch (err) {
|
||||
showMsg('Failed to mark as viewed.');
|
||||
}
|
||||
}
|
||||
|
||||
let timeout;
|
||||
const throttledInject = () => {
|
||||
if (timeout) return;
|
||||
timeout = setTimeout(() => {
|
||||
injectUI();
|
||||
timeout = null;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(throttledInject);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
injectUI();
|
||||
})();
|
||||
@@ -0,0 +1,258 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.MovieNight.Services;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for the MovieNight plugin.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("MovieNight")]
|
||||
public class MovieNightController : ControllerBase
|
||||
{
|
||||
private readonly MovieNightBackendClient _backendClient;
|
||||
private readonly MovieNightSyncService _syncService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieNightController"/> class.
|
||||
/// </summary>
|
||||
public MovieNightController(
|
||||
MovieNightBackendClient backendClient,
|
||||
MovieNightSyncService syncService)
|
||||
{
|
||||
_backendClient = backendClient;
|
||||
_syncService = syncService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ping endpoint for connectivity checks.
|
||||
/// </summary>
|
||||
[HttpGet("Ping")]
|
||||
public ActionResult Ping() => Ok("Pong");
|
||||
|
||||
/// <summary>
|
||||
/// Returns plugin status.
|
||||
/// </summary>
|
||||
/// <returns>Status response.</returns>
|
||||
[HttpGet("Status")]
|
||||
[Authorize]
|
||||
public ActionResult<MovieNightPluginStatus> GetStatus()
|
||||
{
|
||||
var configuration = Plugin.Instance?.Configuration;
|
||||
return new MovieNightPluginStatus(
|
||||
Enabled: configuration?.Enabled ?? false,
|
||||
BackendBaseUrl: configuration?.BackendBaseUrl ?? string.Empty,
|
||||
PeriodicSyncEnabled: configuration?.EnablePeriodicSync ?? false,
|
||||
PlaybackEventsEnabled: configuration?.EnablePlaybackEvents ?? false,
|
||||
SyncIntervalMinutes: configuration?.SyncIntervalMinutes ?? 30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests backend connectivity.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Connection result.</returns>
|
||||
[HttpPost("TestConnection")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<MovieNightConnectionResult>> TestConnection(CancellationToken cancellationToken)
|
||||
{
|
||||
return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers backend sync.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Backend response.</returns>
|
||||
[HttpPost("Sync")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<string>> Sync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Ok("Sync triggered");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets backend sync state.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Backend response.</returns>
|
||||
[HttpGet("SyncState")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SyncState(CancellationToken cancellationToken)
|
||||
{
|
||||
var body = await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Content(body, "application/json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recommendations for the current user.
|
||||
/// </summary>
|
||||
[HttpGet("Users/{userId}/Recommendations")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetRecommendations(
|
||||
[FromRoute] string userId,
|
||||
[FromQuery] string? contentType,
|
||||
[FromQuery] string? mood,
|
||||
[FromQuery] int limit = 10,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var body = await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false);
|
||||
return Content(body, "application/json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts a rating for a film.
|
||||
/// </summary>
|
||||
[HttpPost("Users/{userId}/Ratings/Films/{filmId}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> PostRating(
|
||||
[FromRoute] string userId,
|
||||
[FromRoute] string filmId,
|
||||
[FromBody] RatingRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a film as viewed.
|
||||
/// </summary>
|
||||
[HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> MarkViewed(
|
||||
[FromRoute] string userId,
|
||||
[FromRoute] string filmId,
|
||||
[FromBody] ViewedRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets user preferences.
|
||||
/// </summary>
|
||||
[HttpGet("Users/{userId}/Preferences")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetPreferences(
|
||||
[FromRoute] string userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var body = await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false);
|
||||
return body is null ? NotFound() : Content(body, "application/json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes onboarding for a user.
|
||||
/// </summary>
|
||||
[HttpPost("Users/{userId}/Onboarding")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> CompleteOnboarding(
|
||||
[FromRoute] string userId,
|
||||
[FromBody] object payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _backendClient.CompleteOnboardingAsync(userId, payload, cancellationToken).ConfigureAwait(false);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new film by generating a .strm file in a folder-per-movie structure.
|
||||
/// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm
|
||||
/// </summary>
|
||||
[HttpPost("Films")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> CreateFilm([FromBody] CreateFilmRequest request)
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.StrmOutputPath))
|
||||
{
|
||||
return BadRequest("STRM output path is not configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
return BadRequest("Movie title is required.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Construct name: "Movie Name (Year) [imdbid-ttXXXXXXX]"
|
||||
var folderName = request.Title.Trim();
|
||||
if (request.Year.HasValue)
|
||||
{
|
||||
folderName += $" ({request.Year})";
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ImdbId))
|
||||
{
|
||||
var ttId = request.ImdbId.Trim().ToLowerInvariant();
|
||||
if (!ttId.StartsWith("tt")) ttId = "tt" + ttId;
|
||||
folderName += $" [imdbid-{ttId}]";
|
||||
}
|
||||
|
||||
// Sanitize for file system
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var safeFolderName = new string(folderName.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray());
|
||||
|
||||
var movieDirectory = Path.Combine(config.StrmOutputPath, safeFolderName);
|
||||
if (!Directory.Exists(movieDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(movieDirectory);
|
||||
}
|
||||
|
||||
var filePath = Path.Combine(movieDirectory, $"{safeFolderName}.strm");
|
||||
|
||||
var strmContent = string.IsNullOrWhiteSpace(request.Url)
|
||||
? "http://placeholder.url/upload_me_later"
|
||||
: request.Url.Trim();
|
||||
|
||||
await System.IO.File.WriteAllTextAsync(filePath, strmContent).ConfigureAwait(false);
|
||||
|
||||
return Ok(new { FilePath = filePath, FolderName = safeFolderName });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, $"Failed to create film: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create film request.
|
||||
/// </summary>
|
||||
public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId);
|
||||
|
||||
/// <summary>
|
||||
/// Rating request.
|
||||
/// </summary>
|
||||
public sealed record RatingRequest(int Score, string? Note);
|
||||
|
||||
/// <summary>
|
||||
/// Viewed request.
|
||||
/// </summary>
|
||||
public sealed record ViewedRequest(DateTimeOffset? WatchedAt);
|
||||
|
||||
/// <summary>
|
||||
/// MovieNight plugin status response.
|
||||
/// </summary>
|
||||
/// <param name="Enabled">Whether integration is enabled.</param>
|
||||
/// <param name="BackendBaseUrl">Backend base URL.</param>
|
||||
/// <param name="PeriodicSyncEnabled">Whether periodic sync is enabled.</param>
|
||||
/// <param name="PlaybackEventsEnabled">Whether playback events are enabled.</param>
|
||||
/// <param name="SyncIntervalMinutes">Sync interval in minutes.</param>
|
||||
public sealed record MovieNightPluginStatus(
|
||||
bool Enabled,
|
||||
string BackendBaseUrl,
|
||||
bool PeriodicSyncEnabled,
|
||||
bool PlaybackEventsEnabled,
|
||||
int SyncIntervalMinutes);
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.MovieNight</RootNamespace>
|
||||
<AssemblyName>Jellyfin.Plugin.MovieNight</AssemblyName>
|
||||
<Version>1.0.0.1</Version>
|
||||
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Jellyfin.Common" Version="10.11.5">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.5">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.11.5">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<None Remove="Configuration\config.js" />
|
||||
<None Remove="Configuration\ui.js" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\config.js" />
|
||||
<EmbeddedResource Include="Configuration\ui.js" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.MovieNight.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight;
|
||||
|
||||
/// <summary>
|
||||
/// MovieNight Jellyfin plugin.
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">Application paths.</param>
|
||||
/// <param name="xmlSerializer">XML serializer.</param>
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "MovieNight";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => Guid.Parse("42c72919-d6ff-4f62-bb8c-0fac39efafdb");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Bridges Jellyfin playback and sync signals to the MovieNight backend.";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current plugin instance.
|
||||
/// </summary>
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return
|
||||
[
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}.Configuration.configPage.html",
|
||||
GetType().Namespace)
|
||||
},
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name + ".js",
|
||||
EmbeddedResourcePath = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}.Configuration.config.js",
|
||||
GetType().Namespace)
|
||||
},
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name + ".ui.js",
|
||||
EmbeddedResourcePath = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}.Configuration.ui.js",
|
||||
GetType().Namespace)
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Jellyfin.Plugin.MovieNight.Services;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight;
|
||||
|
||||
/// <summary>
|
||||
/// Registers MovieNight services with Jellyfin.
|
||||
/// </summary>
|
||||
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
serviceCollection.AddSingleton<MovieNightBackendClient>();
|
||||
serviceCollection.AddSingleton<MovieNightSyncService>();
|
||||
serviceCollection.AddHostedService<MovieNightPeriodicSyncService>();
|
||||
serviceCollection.AddHostedService<MovieNightPlaybackEventService>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin HTTP client for the MovieNight backend.
|
||||
/// </summary>
|
||||
public class MovieNightBackendClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly ILogger<MovieNightBackendClient> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieNightBackendClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public MovieNightBackendClient(ILogger<MovieNightBackendClient> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls backend health.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Connection result.</returns>
|
||||
public async Task<MovieNightConnectionResult> TestConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new MovieNightEventPayload(
|
||||
EventId: $"plugin-test:{Guid.NewGuid():N}",
|
||||
EventType: "plugin.test",
|
||||
OccurredAt: DateTimeOffset.UtcNow,
|
||||
JellyfinUserId: "movienight-plugin-test-user",
|
||||
ItemId: "movienight-plugin-test-item",
|
||||
PayloadVersion: 1,
|
||||
Payload: new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = "config-test"
|
||||
});
|
||||
var request = CreateEventRequest(payload);
|
||||
if (request is null)
|
||||
{
|
||||
return MovieNightConnectionResult.Failed("Plugin is not configured.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return response.IsSuccessStatusCode
|
||||
? MovieNightConnectionResult.Ok()
|
||||
: MovieNightConnectionResult.Failed($"Backend event endpoint returned {(int)response.StatusCode}.");
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "MovieNight connection test failed");
|
||||
return MovieNightConnectionResult.Failed(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes library sync data to the backend.
|
||||
/// </summary>
|
||||
/// <param name="payload">Sync payload.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Backend response body.</returns>
|
||||
public async Task<string> SyncAsync(object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/sync");
|
||||
if (request is null)
|
||||
{
|
||||
return "Plugin is not configured.";
|
||||
}
|
||||
|
||||
request.Content = JsonContent.Create(payload, options: JsonOptions);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recommendations for a user.
|
||||
/// </summary>
|
||||
public async Task<string> GetRecommendationsAsync(string userId, string? contentType, string? mood, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
userId = NormalizeJellyfinId(userId);
|
||||
var query = $"?limit={limit}";
|
||||
if (!string.IsNullOrEmpty(contentType)) query += $"&contentType={Uri.EscapeDataString(contentType)}";
|
||||
if (!string.IsNullOrEmpty(mood)) query += $"&mood={Uri.EscapeDataString(mood)}";
|
||||
|
||||
var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/recommendations{query}");
|
||||
if (request is null) return "Plugin is not configured.";
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts a rating for a film.
|
||||
/// </summary>
|
||||
public async Task PostRatingAsync(string userId, string filmId, int score, string? note, CancellationToken cancellationToken)
|
||||
{
|
||||
userId = NormalizeJellyfinId(userId);
|
||||
filmId = NormalizeJellyfinId(filmId);
|
||||
var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/ratings/items/{filmId}");
|
||||
if (request is null) return;
|
||||
|
||||
request.Content = JsonContent.Create(new { score, note }, options: JsonOptions);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets ratings for a user.
|
||||
/// </summary>
|
||||
public async Task<string> GetRatingsAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
userId = NormalizeJellyfinId(userId);
|
||||
var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/ratings");
|
||||
if (request is null) return "[]";
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a film as viewed.
|
||||
/// </summary>
|
||||
public async Task MarkViewedAsync(string userId, string filmId, DateTimeOffset? watchedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
userId = NormalizeJellyfinId(userId);
|
||||
filmId = NormalizeJellyfinId(filmId);
|
||||
var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/library/items/{filmId}/viewed");
|
||||
if (request is null) return;
|
||||
|
||||
request.Content = JsonContent.Create(new { watchedAt }, options: JsonOptions);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads backend sync state.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Backend response body.</returns>
|
||||
public async Task<string> GetSyncStateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var request = CreateRequest(HttpMethod.Get, "/api/integrations/jellyfin/sync-state");
|
||||
if (request is null)
|
||||
{
|
||||
return "Plugin is not configured.";
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets user preferences.
|
||||
/// </summary>
|
||||
public async Task<string?> GetPreferencesAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
userId = NormalizeJellyfinId(userId);
|
||||
var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/preferences");
|
||||
if (request is null) return null;
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes onboarding for a user.
|
||||
/// </summary>
|
||||
public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
userId = NormalizeJellyfinId(userId);
|
||||
var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/recommendation-onboarding");
|
||||
if (request is null) return;
|
||||
|
||||
request.Content = JsonContent.Create(payload, options: JsonOptions);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes an event payload to the backend event endpoint.
|
||||
/// </summary>
|
||||
/// <param name="payload">Event payload.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task.</returns>
|
||||
public async Task PushEventAsync(MovieNightEventPayload payload, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 1; attempt <= 3; attempt++)
|
||||
{
|
||||
var request = CreateEventRequest(payload);
|
||||
if (request is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int)response.StatusCode == 401)
|
||||
{
|
||||
_logger.LogWarning("MovieNight event push was rejected with 401 Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("MovieNight event push returned status {StatusCode}", response.StatusCode);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
_logger.LogDebug(ex, "MovieNight event push attempt {Attempt} failed", attempt);
|
||||
}
|
||||
|
||||
if (attempt < 3)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(attempt * 2), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpRequestMessage? CreateEventRequest(MovieNightEventPayload payload)
|
||||
{
|
||||
var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/events");
|
||||
if (request is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
request.Content = JsonContent.Create(payload, options: JsonOptions);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static string? GetBaseUrl()
|
||||
{
|
||||
var value = Plugin.Instance?.Configuration.BackendBaseUrl?.Trim();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/');
|
||||
}
|
||||
|
||||
private static string NormalizeJellyfinId(string value)
|
||||
{
|
||||
return Guid.TryParse(value, out var guid) ? guid.ToString("N") : value;
|
||||
}
|
||||
|
||||
private static bool IsEnabled()
|
||||
{
|
||||
var configuration = Plugin.Instance?.Configuration;
|
||||
return configuration is { Enabled: true } && !string.IsNullOrWhiteSpace(configuration.BackendBaseUrl);
|
||||
}
|
||||
|
||||
private static HttpRequestMessage? CreateRequest(HttpMethod method, string path)
|
||||
{
|
||||
if (!IsEnabled())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var baseUrl = GetBaseUrl();
|
||||
if (baseUrl is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(method, new Uri(baseUrl + path));
|
||||
var token = Plugin.Instance?.Configuration.ApiToken;
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Add("X-MovieNight-Plugin-Token", token);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backend connection result.
|
||||
/// </summary>
|
||||
/// <param name="Success">Whether the call succeeded.</param>
|
||||
/// <param name="Message">Result message.</param>
|
||||
public sealed record MovieNightConnectionResult(bool Success, string Message)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
/// <returns>Connection result.</returns>
|
||||
public static MovieNightConnectionResult Ok() => new(true, "OK");
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
/// <param name="message">Failure message.</param>
|
||||
/// <returns>Connection result.</returns>
|
||||
public static MovieNightConnectionResult Failed(string message) => new(false, message);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Event payload sent to MovieNight.
|
||||
/// </summary>
|
||||
/// <param name="EventId">Idempotency key.</param>
|
||||
/// <param name="EventType">Event type.</param>
|
||||
/// <param name="OccurredAt">Event timestamp.</param>
|
||||
/// <param name="JellyfinUserId">Jellyfin user id.</param>
|
||||
/// <param name="ItemId">Jellyfin item id.</param>
|
||||
/// <param name="PayloadVersion">Payload version.</param>
|
||||
/// <param name="Payload">Extra event data.</param>
|
||||
public sealed record MovieNightEventPayload(
|
||||
[property: JsonPropertyName("event_id")]
|
||||
string EventId,
|
||||
[property: JsonPropertyName("event_type")]
|
||||
string EventType,
|
||||
[property: JsonPropertyName("occurred_at")]
|
||||
DateTimeOffset OccurredAt,
|
||||
[property: JsonPropertyName("jellyfin_user_id")]
|
||||
string JellyfinUserId,
|
||||
[property: JsonPropertyName("item_id")]
|
||||
string ItemId,
|
||||
[property: JsonPropertyName("payload_version")]
|
||||
int PayloadVersion,
|
||||
[property: JsonPropertyName("payload")]
|
||||
IReadOnlyDictionary<string, object?> Payload);
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Periodically asks MovieNight to run its current Jellyfin sync.
|
||||
/// </summary>
|
||||
public sealed class MovieNightPeriodicSyncService : BackgroundService
|
||||
{
|
||||
private readonly MovieNightSyncService _syncService;
|
||||
private readonly ILogger<MovieNightPeriodicSyncService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieNightPeriodicSyncService"/> class.
|
||||
/// </summary>
|
||||
public MovieNightPeriodicSyncService(
|
||||
MovieNightSyncService syncService,
|
||||
ILogger<MovieNightPeriodicSyncService> logger)
|
||||
{
|
||||
_syncService = syncService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var delay = GetDelay();
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, stoppingToken).ConfigureAwait(false);
|
||||
if (!ShouldRun())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await _syncService.PerformSyncAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "MovieNight periodic sync failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldRun()
|
||||
{
|
||||
var configuration = Plugin.Instance?.Configuration;
|
||||
return configuration is { Enabled: true, EnablePeriodicSync: true };
|
||||
}
|
||||
|
||||
private static TimeSpan GetDelay()
|
||||
{
|
||||
var minutes = Plugin.Instance?.Configuration.SyncIntervalMinutes ?? 30;
|
||||
return TimeSpan.FromMinutes(Math.Clamp(minutes, 1, 1440));
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to Jellyfin playback events and forwards thin payloads.
|
||||
/// </summary>
|
||||
public sealed class MovieNightPlaybackEventService : IHostedService
|
||||
{
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly MovieNightBackendClient _backendClient;
|
||||
private readonly ILogger<MovieNightPlaybackEventService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieNightPlaybackEventService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="sessionManager">Jellyfin session manager.</param>
|
||||
/// <param name="backendClient">Backend client.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public MovieNightPlaybackEventService(
|
||||
ISessionManager sessionManager,
|
||||
MovieNightBackendClient backendClient,
|
||||
ILogger<MovieNightPlaybackEventService> logger)
|
||||
{
|
||||
_sessionManager = sessionManager;
|
||||
_backendClient = backendClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_sessionManager.PlaybackStopped += OnPlaybackStopped;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e)
|
||||
{
|
||||
if (Plugin.Instance?.Configuration is not { Enabled: true, EnablePlaybackEvents: true })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.PlayedToCompletion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = e.Users?.FirstOrDefault()?.Id.ToString("N");
|
||||
var itemId = e.Item?.Id.ToString("N");
|
||||
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(itemId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var occurredAt = DateTimeOffset.UtcNow;
|
||||
var eventId = string.IsNullOrWhiteSpace(e.PlaySessionId)
|
||||
? $"playback-stopped:{userId}:{itemId}:{occurredAt.ToUnixTimeMilliseconds()}"
|
||||
: $"playback-stopped:{userId}:{itemId}:{e.PlaySessionId}";
|
||||
|
||||
var payload = new MovieNightEventPayload(
|
||||
EventId: eventId,
|
||||
EventType: "playback.stopped",
|
||||
OccurredAt: occurredAt,
|
||||
JellyfinUserId: userId,
|
||||
ItemId: itemId,
|
||||
PayloadVersion: 1,
|
||||
Payload: new Dictionary<string, object?>
|
||||
{
|
||||
["itemName"] = e.Item?.Name,
|
||||
["playSessionId"] = e.PlaySessionId,
|
||||
["positionTicks"] = e.PlaybackPositionTicks,
|
||||
["playedToCompletion"] = e.PlayedToCompletion
|
||||
});
|
||||
|
||||
_ = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _backendClient.PushEventAsync(payload, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "MovieNight playback event push failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for synchronizing the Jellyfin library with MovieNight.
|
||||
/// </summary>
|
||||
public class MovieNightSyncService
|
||||
{
|
||||
private readonly MovieNightBackendClient _backendClient;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly ILogger<MovieNightSyncService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieNightSyncService"/> class.
|
||||
/// </summary>
|
||||
public MovieNightSyncService(
|
||||
MovieNightBackendClient backendClient,
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IUserDataManager userDataManager,
|
||||
ILogger<MovieNightSyncService> logger)
|
||||
{
|
||||
_backendClient = backendClient;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_userDataManager = userDataManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs a full library sync.
|
||||
/// </summary>
|
||||
public async Task PerformSyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting MovieNight library sync");
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
var enabledLibraryIds = config?.EnabledLibraryIds ?? new List<string>();
|
||||
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = new[] { BaseItemKind.Movie },
|
||||
Recursive = true
|
||||
};
|
||||
|
||||
if (enabledLibraryIds.Count > 0)
|
||||
{
|
||||
query.AncestorIds = enabledLibraryIds.Select(Guid.Parse).ToArray();
|
||||
}
|
||||
|
||||
var items = _libraryManager.GetItemList(query);
|
||||
var users = _userManager.Users;
|
||||
var syncUsers = users.Select(u => new
|
||||
{
|
||||
jellyfinUserId = u.Id.ToString("N"),
|
||||
name = u.Username
|
||||
}).ToList();
|
||||
var syncItems = new List<object>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item is not Movie movie) continue;
|
||||
|
||||
var jellyfinItemId = movie.Id.ToString("N");
|
||||
var title = string.IsNullOrWhiteSpace(movie.Name) ? jellyfinItemId : movie.Name;
|
||||
|
||||
var itemData = new Dictionary<string, object?>
|
||||
{
|
||||
["jellyfinItemId"] = jellyfinItemId,
|
||||
["title"] = title,
|
||||
["originalTitle"] = movie.OriginalTitle,
|
||||
["description"] = movie.Overview,
|
||||
["year"] = movie.ProductionYear,
|
||||
["duration"] = movie.RunTimeTicks,
|
||||
["genres"] = movie.Genres,
|
||||
["posterUrl"] = $"/Items/{jellyfinItemId}/Images/Primary",
|
||||
["imdbId"] = movie.GetProviderId(MetadataProvider.Imdb),
|
||||
["tmdbId"] = movie.GetProviderId(MetadataProvider.Tmdb),
|
||||
["userStates"] = users.Select(u => {
|
||||
var userData = _userDataManager.GetUserData(u, movie);
|
||||
return new {
|
||||
jellyfinUserId = u.Id.ToString("N"),
|
||||
isViewed = userData?.Played ?? false,
|
||||
playCount = userData?.PlayCount ?? 0,
|
||||
lastPlayedAt = userData?.LastPlayedDate,
|
||||
userRating = userData?.Rating
|
||||
};
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
syncItems.Add(itemData);
|
||||
}
|
||||
|
||||
await _backendClient.SyncAsync(new { users = syncUsers, items = syncItems }, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("MovieNight library sync completed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# MovieNight Jellyfin Plugin
|
||||
|
||||
Thin Jellyfin server plugin for bridging Jellyfin playback/sync signals to the MovieNight backend.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cd plugins/jellyfin/Jellyfin.Plugin.MovieNight
|
||||
dotnet publish -c Release
|
||||
```
|
||||
|
||||
Install the published `net9.0` plugin files into the Jellyfin data directory under `plugins/MovieNight/`, then restart Jellyfin. This build targets Jellyfin `10.11.x`.
|
||||
|
||||
## Backend Contract Used
|
||||
|
||||
Current implemented calls:
|
||||
|
||||
- `POST /api/integrations/jellyfin/sync`
|
||||
- `GET /api/integrations/jellyfin/sync-state`
|
||||
- `POST /api/integrations/jellyfin/events`
|
||||
- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendations`
|
||||
- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/ratings/items/{jellyfin_item_id}`
|
||||
- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/library/items/{jellyfin_item_id}/viewed`
|
||||
- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/preferences`
|
||||
- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendation-onboarding`
|
||||
|
||||
Configure the backend with:
|
||||
|
||||
- `JELLYFIN_INTEGRATION_ENABLED=true`
|
||||
- `JELLYFIN_PLUGIN_TOKEN=<same token configured in the plugin>`
|
||||
- `JELLYFIN_WEB_URL=<browser URL of Jellyfin, used for recommendation watch links>`
|
||||
|
||||
`JELLYFIN_SYNC_ENABLED=true` is still accepted as a legacy alias for `JELLYFIN_INTEGRATION_ENABLED=true`.
|
||||
|
||||
Optional backend-pull sync values:
|
||||
|
||||
- `JELLYFIN_BASE_URL=<backend-reachable Jellyfin server URL>`
|
||||
- `JELLYFIN_API_KEY=<Jellyfin API key>`
|
||||
|
||||
The Jellyfin API key is only for backend-to-Jellyfin calls. The plugin token is a MovieNight shared secret for plugin-to-backend calls.
|
||||
|
||||
Configure the plugin with:
|
||||
|
||||
- Backend URL: MovieNight backend URL reachable from the Jellyfin server, for example `http://movienight-backend:8080`
|
||||
- Plugin token: the exact `JELLYFIN_PLUGIN_TOKEN` value
|
||||
- Enable MovieNight integration: checked
|
||||
- Enable periodic backend sync: checked if the plugin should push library state on an interval
|
||||
- Send playback stop events: checked if completed playback should mark films viewed in MovieNight
|
||||
|
||||
Event requests use JSON with:
|
||||
|
||||
- `event_id`
|
||||
- `event_type`
|
||||
- `occurred_at`
|
||||
- `jellyfin_user_id`
|
||||
- `item_id`
|
||||
- `payload_version`
|
||||
- `payload`
|
||||
|
||||
The plugin sends `X-MovieNight-Plugin-Token` when configured. Completed Jellyfin playback stop events are sent as `playback.stopped`. Transient event push failures are retried three times with the same `event_id`.
|
||||
|
||||
Sync requests push Jellyfin users, items, and per-user watched states to the backend. The backend creates MovieNight users for new Jellyfin users using their Jellyfin id as the stable mapping key, upserts films by `jellyfinItemId`, and uses the Jellyfin-facing endpoints above for UI actions so Jellyfin ids do not have to match MovieNight UUIDs. Run "Sync Library" once after installing/configuring the plugin so recommendations, rating, and viewed actions can resolve Jellyfin items.
|
||||
|
||||
The config page test action posts a small `plugin.test` event to `/api/integrations/jellyfin/events` and treats `200` as success and `401` as token/config failure.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: "MovieNight"
|
||||
guid: "42c72919-d6ff-4f62-bb8c-0fac39efafdb"
|
||||
version: 2
|
||||
targetAbi: "10.11.0.0"
|
||||
framework: net9.0
|
||||
owner: "movienight"
|
||||
overview: "Bridge Jellyfin events and sync triggers to MovieNight"
|
||||
description: "Thin Jellyfin plugin for MovieNight backend integration"
|
||||
category: "General"
|
||||
artifacts:
|
||||
- "Jellyfin.Plugin.MovieNight.dll"
|
||||
changelog: |-
|
||||
- Initial plugin implementation.
|
||||
@@ -24,9 +24,7 @@ class SecurityConfiguration(
|
||||
.requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health")
|
||||
.permitAll()
|
||||
.requestMatchers(
|
||||
"/api/integrations/jellyfin/events",
|
||||
"/api/integrations/jellyfin/sync",
|
||||
"/api/integrations/jellyfin/sync-state",
|
||||
"/api/integrations/jellyfin/**",
|
||||
).permitAll()
|
||||
.requestMatchers("/api/users/me")
|
||||
.authenticated()
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.project.movienight.adapters.web
|
||||
import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest
|
||||
import com.project.movienight.application.ports.input.HandleJellyfinEventCommand
|
||||
import com.project.movienight.application.ports.input.JellyfinEventUseCase
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import jakarta.validation.Valid
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.HttpStatus
|
||||
@@ -13,13 +12,12 @@ import org.springframework.web.bind.annotation.RequestHeader
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/integrations/jellyfin")
|
||||
class JellyfinEventsController(
|
||||
private val jellyfinEventUseCase: JellyfinEventUseCase,
|
||||
private val properties: JellyfinIntegrationProperties,
|
||||
private val authenticator: JellyfinPluginAuthenticator,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
|
||||
|
||||
@@ -29,15 +27,7 @@ class JellyfinEventsController(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@Valid @RequestBody request: JellyfinEventRequest,
|
||||
) {
|
||||
if (!properties.enabled) {
|
||||
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
|
||||
}
|
||||
|
||||
if (properties.pluginToken.isNotBlank()) {
|
||||
if (token == null || token != properties.pluginToken) {
|
||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
|
||||
}
|
||||
}
|
||||
authenticator.authenticate(token)
|
||||
|
||||
log.debug(
|
||||
"Received Jellyfin event {} for user {} item {}",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
|
||||
@Component
|
||||
class JellyfinPluginAuthenticator(
|
||||
private val properties: JellyfinIntegrationProperties,
|
||||
) {
|
||||
fun authenticate(token: String?) {
|
||||
if (!properties.enabled) {
|
||||
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
|
||||
}
|
||||
|
||||
if (properties.pluginToken.isNotBlank() && token != properties.pluginToken) {
|
||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
|
||||
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
|
||||
import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse
|
||||
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
|
||||
import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse
|
||||
import com.project.movienight.adapters.web.dto.response.RecommendationResponse
|
||||
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
|
||||
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
|
||||
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
|
||||
import com.project.movienight.application.ports.input.FilmLibraryUseCase
|
||||
import com.project.movienight.application.ports.input.FilmRatingUseCase
|
||||
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
|
||||
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
||||
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||
import com.project.movienight.application.ports.input.RecommendationQuery
|
||||
import com.project.movienight.application.ports.input.UserPreferencesUseCase
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import com.project.movienight.domain.model.Film
|
||||
import com.project.movienight.domain.model.RecommendationStyle
|
||||
import com.project.movienight.domain.model.User
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestHeader
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/integrations/jellyfin")
|
||||
class JellyfinPluginController(
|
||||
private val authenticator: JellyfinPluginAuthenticator,
|
||||
private val userRepository: UserRepositoryPort,
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
private val getRecommendationsUseCase: GetRecommendationsUseCase,
|
||||
private val filmRatingUseCase: FilmRatingUseCase,
|
||||
private val filmLibraryUseCase: FilmLibraryUseCase,
|
||||
private val userPreferencesUseCase: UserPreferencesUseCase,
|
||||
private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase,
|
||||
private val jellyfinProperties: JellyfinIntegrationProperties,
|
||||
) {
|
||||
@GetMapping("/users/{jellyfinUserId}/recommendations")
|
||||
fun recommend(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@PathVariable jellyfinUserId: String,
|
||||
@RequestParam(required = false) contentType: String?,
|
||||
@RequestParam(required = false) mood: String?,
|
||||
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
|
||||
@RequestParam(required = false, defaultValue = "10") limit: Int,
|
||||
): List<RecommendationResponse> {
|
||||
authenticator.authenticate(token)
|
||||
val user = resolveOrCreateUser(jellyfinUserId)
|
||||
return getRecommendationsUseCase
|
||||
.recommend(
|
||||
RecommendationQuery(
|
||||
userId = user.id,
|
||||
contentType = parseOptionalContentType(contentType),
|
||||
mood = mood,
|
||||
libraryOnly = libraryOnly,
|
||||
limit = limit,
|
||||
),
|
||||
).map { recommendation ->
|
||||
RecommendationResponse.fromDomain(
|
||||
recommendation = recommendation,
|
||||
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/users/{jellyfinUserId}/ratings/items/{jellyfinItemId}")
|
||||
fun rate(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@PathVariable jellyfinUserId: String,
|
||||
@PathVariable jellyfinItemId: String,
|
||||
@Valid @RequestBody request: RateFilmRequest,
|
||||
): FilmRatingResponse {
|
||||
authenticator.authenticate(token)
|
||||
val user = resolveOrCreateUser(jellyfinUserId)
|
||||
val film = resolveFilm(jellyfinItemId)
|
||||
return FilmRatingResponse.fromDomain(
|
||||
filmRatingUseCase.rate(
|
||||
RateFilmCommand(
|
||||
userId = user.id,
|
||||
filmId = film.id,
|
||||
score = request.score,
|
||||
note = request.note,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/users/{jellyfinUserId}/ratings")
|
||||
fun ratings(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@PathVariable jellyfinUserId: String,
|
||||
): List<FilmRatingResponse> {
|
||||
authenticator.authenticate(token)
|
||||
val user = resolveOrCreateUser(jellyfinUserId)
|
||||
return filmRatingUseCase.getRatings(user.id).map { FilmRatingResponse.fromDomain(it) }
|
||||
}
|
||||
|
||||
@PostMapping("/users/{jellyfinUserId}/library/items/{jellyfinItemId}/viewed")
|
||||
fun markViewed(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@PathVariable jellyfinUserId: String,
|
||||
@PathVariable jellyfinItemId: String,
|
||||
@RequestBody(required = false) request: JellyfinViewedRequest?,
|
||||
): FilmLibraryEntryResponse {
|
||||
authenticator.authenticate(token)
|
||||
val user = resolveOrCreateUser(jellyfinUserId)
|
||||
val film = resolveFilm(jellyfinItemId)
|
||||
return FilmLibraryEntryResponse.fromDomain(
|
||||
filmLibraryUseCase.markViewed(
|
||||
MarkFilmViewedCommand(
|
||||
userId = user.id,
|
||||
filmId = film.id,
|
||||
watchedAt = request?.watchedAt?.toLocalDateTime(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/users/{jellyfinUserId}/preferences")
|
||||
fun preferences(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@PathVariable jellyfinUserId: String,
|
||||
): UserPreferencesResponse {
|
||||
authenticator.authenticate(token)
|
||||
val user = resolveOrCreateUser(jellyfinUserId)
|
||||
return userPreferencesUseCase.get(user.id)?.let { UserPreferencesResponse.fromDomain(it) }
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User preferences not found")
|
||||
}
|
||||
|
||||
@PostMapping("/users/{jellyfinUserId}/recommendation-onboarding")
|
||||
fun completeOnboarding(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@PathVariable jellyfinUserId: String,
|
||||
@RequestBody request: RecommendationOnboardingRequest,
|
||||
): RecommendationOnboardingResponse {
|
||||
authenticator.authenticate(token)
|
||||
val user = resolveOrCreateUser(jellyfinUserId)
|
||||
return RecommendationOnboardingResponse.fromApplication(
|
||||
completeRecommendationOnboardingUseCase.complete(
|
||||
CompleteRecommendationOnboardingCommand(
|
||||
userId = user.id,
|
||||
weightedGenres = request.weightedGenres,
|
||||
plotTypes = request.plotTypes,
|
||||
eras = request.eras,
|
||||
castAndDirectors = request.castAndDirectors,
|
||||
moods = request.moods,
|
||||
contentTypes = request.contentTypes.mapNotNull { runCatching { parseContentType(it) }.getOrNull() },
|
||||
likedFilmIds = request.likedFilmIds,
|
||||
dislikedFilmIds = request.dislikedFilmIds,
|
||||
libraryFilmIds = request.libraryFilmIds,
|
||||
watchedFilmIds = request.watchedFilmIds,
|
||||
recommendationStyle = parseRecommendationStyle(request.recommendationStyle),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveOrCreateUser(jellyfinUserId: String): User =
|
||||
normalizeJellyfinId(jellyfinUserId).let { normalizedId ->
|
||||
userRepository.findByJellyfinUserId(normalizedId)
|
||||
?: userRepository.save(
|
||||
User(
|
||||
id = idGenerator.generateId(),
|
||||
name = "Jellyfin User",
|
||||
email = syntheticJellyfinEmail(normalizedId),
|
||||
jellyfinUserId = normalizedId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveFilm(jellyfinItemId: String): Film =
|
||||
normalizeJellyfinId(jellyfinItemId).let { normalizedId ->
|
||||
filmRepository.findByJellyfinItemId(normalizedId)
|
||||
?: throw EntityNotFoundException(entity = "Jellyfin item", id = jellyfinItemId)
|
||||
}
|
||||
|
||||
private fun buildWatchUrl(jellyfinItemId: String?): String? {
|
||||
if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val baseUrl = jellyfinProperties.webUrl.trimEnd('/')
|
||||
val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8)
|
||||
return "$baseUrl/web/#/details?id=$encodedItemId"
|
||||
}
|
||||
|
||||
private fun parseRecommendationStyle(value: String): RecommendationStyle =
|
||||
runCatching { RecommendationStyle.valueOf(value.uppercase()) }
|
||||
.getOrDefault(RecommendationStyle.BALANCED)
|
||||
|
||||
private fun normalizeJellyfinId(value: String): String =
|
||||
runCatching { UUID.fromString(value).toString().replace("-", "") }
|
||||
.getOrDefault(value)
|
||||
|
||||
private fun syntheticJellyfinEmail(jellyfinUserId: String): String {
|
||||
val safeId =
|
||||
jellyfinUserId
|
||||
.lowercase(Locale.getDefault())
|
||||
.replace(Regex("[^a-z0-9._%+-]"), "-")
|
||||
.take(240)
|
||||
return "jellyfin-$safeId@movienight.local"
|
||||
}
|
||||
}
|
||||
|
||||
data class JellyfinViewedRequest(
|
||||
val watchedAt: OffsetDateTime? = null,
|
||||
)
|
||||
@@ -1,10 +1,18 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.JellyfinSyncRequest
|
||||
import com.project.movienight.application.ports.input.IngestJellyfinSyncCommand
|
||||
import com.project.movienight.application.ports.input.JellyfinSyncItemCommand
|
||||
import com.project.movienight.application.ports.input.JellyfinSyncUseCase
|
||||
import com.project.movienight.application.ports.input.JellyfinSyncUserCommand
|
||||
import com.project.movienight.application.ports.input.JellyfinSyncUserStateCommand
|
||||
import com.project.movienight.domain.model.JellyfinSyncState
|
||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestHeader
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@@ -12,10 +20,61 @@ import org.springframework.web.bind.annotation.RestController
|
||||
@RequestMapping("/api/integrations/jellyfin")
|
||||
class JellyfinSyncController(
|
||||
private val jellyfinSyncUseCase: JellyfinSyncUseCase,
|
||||
private val authenticator: JellyfinPluginAuthenticator,
|
||||
) {
|
||||
@PostMapping("/sync")
|
||||
fun syncNow(): JellyfinSyncSummary = jellyfinSyncUseCase.syncNow()
|
||||
fun syncNow(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@Valid @RequestBody(required = false) request: JellyfinSyncRequest?,
|
||||
): JellyfinSyncSummary {
|
||||
authenticator.authenticate(token)
|
||||
return if (request == null) {
|
||||
jellyfinSyncUseCase.syncNow()
|
||||
} else {
|
||||
jellyfinSyncUseCase.ingest(request.toCommand())
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/sync-state")
|
||||
fun syncState(): List<JellyfinSyncState> = jellyfinSyncUseCase.getSyncStates()
|
||||
fun syncState(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
): List<JellyfinSyncState> {
|
||||
authenticator.authenticate(token)
|
||||
return jellyfinSyncUseCase.getSyncStates()
|
||||
}
|
||||
|
||||
private fun JellyfinSyncRequest.toCommand(): IngestJellyfinSyncCommand =
|
||||
IngestJellyfinSyncCommand(
|
||||
users =
|
||||
users.map { user ->
|
||||
JellyfinSyncUserCommand(
|
||||
jellyfinUserId = user.jellyfinUserId,
|
||||
name = user.name,
|
||||
)
|
||||
},
|
||||
items =
|
||||
items.map { item ->
|
||||
JellyfinSyncItemCommand(
|
||||
jellyfinItemId = item.jellyfinItemId,
|
||||
title = item.title,
|
||||
originalTitle = item.originalTitle,
|
||||
description = item.description,
|
||||
year = item.year,
|
||||
genres = item.genres,
|
||||
imdbId = item.imdbId,
|
||||
tmdbId = item.tmdbId,
|
||||
jellyfinLibraryId = item.jellyfinLibraryId,
|
||||
userStates =
|
||||
item.userStates.map { state ->
|
||||
JellyfinSyncUserStateCommand(
|
||||
jellyfinUserId = state.jellyfinUserId,
|
||||
isViewed = state.isViewed,
|
||||
playCount = state.playCount,
|
||||
lastPlayedAt = state.lastPlayedAt,
|
||||
userRating = state.userRating,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class JellyfinSyncRequest(
|
||||
@field:Valid
|
||||
val users: List<JellyfinSyncUserRequest> = emptyList(),
|
||||
@field:Valid
|
||||
val items: List<JellyfinSyncItemRequest> = emptyList(),
|
||||
)
|
||||
|
||||
data class JellyfinSyncUserRequest(
|
||||
@field:NotBlank
|
||||
val jellyfinUserId: String,
|
||||
val name: String? = null,
|
||||
)
|
||||
|
||||
data class JellyfinSyncItemRequest(
|
||||
@field:NotBlank
|
||||
val jellyfinItemId: String,
|
||||
@field:NotBlank
|
||||
val title: String,
|
||||
val originalTitle: String? = null,
|
||||
val description: String? = null,
|
||||
val year: Int? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val imdbId: String? = null,
|
||||
val tmdbId: String? = null,
|
||||
val jellyfinLibraryId: String? = null,
|
||||
@field:Valid
|
||||
val userStates: List<JellyfinSyncUserStateRequest> = emptyList(),
|
||||
)
|
||||
|
||||
data class JellyfinSyncUserStateRequest(
|
||||
@field:NotBlank
|
||||
val jellyfinUserId: String,
|
||||
val isViewed: Boolean = false,
|
||||
val playCount: Int = 0,
|
||||
val lastPlayedAt: OffsetDateTime? = null,
|
||||
val userRating: Double? = null,
|
||||
)
|
||||
@@ -21,5 +21,38 @@ data class HandleJellyfinEventCommand(
|
||||
interface JellyfinSyncUseCase {
|
||||
fun syncNow(): JellyfinSyncSummary
|
||||
|
||||
fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary
|
||||
|
||||
fun getSyncStates(): List<JellyfinSyncState>
|
||||
}
|
||||
|
||||
data class IngestJellyfinSyncCommand(
|
||||
val users: List<JellyfinSyncUserCommand> = emptyList(),
|
||||
val items: List<JellyfinSyncItemCommand> = emptyList(),
|
||||
)
|
||||
|
||||
data class JellyfinSyncUserCommand(
|
||||
val jellyfinUserId: String,
|
||||
val name: String?,
|
||||
)
|
||||
|
||||
data class JellyfinSyncItemCommand(
|
||||
val jellyfinItemId: String,
|
||||
val title: String,
|
||||
val originalTitle: String?,
|
||||
val description: String?,
|
||||
val year: Int?,
|
||||
val genres: List<String>,
|
||||
val imdbId: String?,
|
||||
val tmdbId: String?,
|
||||
val jellyfinLibraryId: String?,
|
||||
val userStates: List<JellyfinSyncUserStateCommand>,
|
||||
)
|
||||
|
||||
data class JellyfinSyncUserStateCommand(
|
||||
val jellyfinUserId: String,
|
||||
val isViewed: Boolean,
|
||||
val playCount: Int,
|
||||
val lastPlayedAt: OffsetDateTime?,
|
||||
val userRating: Double?,
|
||||
)
|
||||
|
||||
+11
-4
@@ -12,6 +12,7 @@ import com.project.movienight.application.ports.output.JellyfinEventStorePort
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class JellyfinEventService(
|
||||
@@ -26,6 +27,8 @@ class JellyfinEventService(
|
||||
|
||||
@Transactional
|
||||
override fun handle(command: HandleJellyfinEventCommand) {
|
||||
val jellyfinUserId = normalizeJellyfinId(command.jellyfinUserId)
|
||||
val jellyfinItemId = normalizeJellyfinId(command.itemId)
|
||||
val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) }
|
||||
val inserted =
|
||||
jellyfinEventStore.save(
|
||||
@@ -34,8 +37,8 @@ class JellyfinEventService(
|
||||
serverId = command.serverId,
|
||||
eventType = command.eventType,
|
||||
occurredAt = command.occurredAt,
|
||||
jellyfinUserId = command.jellyfinUserId,
|
||||
jellyfinItemId = command.itemId,
|
||||
jellyfinUserId = jellyfinUserId,
|
||||
jellyfinItemId = jellyfinItemId,
|
||||
payload = payloadJson,
|
||||
),
|
||||
)
|
||||
@@ -45,13 +48,13 @@ class JellyfinEventService(
|
||||
|
||||
try {
|
||||
if (playbackEventTypes.contains(command.eventType)) {
|
||||
val localUser = userRepository.findByJellyfinUserId(command.jellyfinUserId)
|
||||
val localUser = userRepository.findByJellyfinUserId(jellyfinUserId)
|
||||
if (localUser == null) {
|
||||
businessMetricsService.recordJellyfinUnmappedUser()
|
||||
return
|
||||
}
|
||||
|
||||
val film = filmRepository.findByJellyfinItemId(command.itemId)
|
||||
val film = filmRepository.findByJellyfinItemId(jellyfinItemId)
|
||||
if (film == null) {
|
||||
businessMetricsService.recordBackendWriteFailure()
|
||||
return
|
||||
@@ -72,4 +75,8 @@ class JellyfinEventService(
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeJellyfinId(value: String): String =
|
||||
runCatching { UUID.fromString(value).toString().replace("-", "") }
|
||||
.getOrDefault(value)
|
||||
}
|
||||
|
||||
+161
-27
@@ -1,5 +1,6 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.application.ports.input.IngestJellyfinSyncCommand
|
||||
import com.project.movienight.application.ports.input.JellyfinSyncUseCase
|
||||
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
|
||||
@@ -10,15 +11,18 @@ import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapsh
|
||||
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.Film
|
||||
import com.project.movienight.domain.model.FilmLibraryEntry
|
||||
import com.project.movienight.domain.model.JellyfinSyncState
|
||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||
import com.project.movienight.domain.model.User
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
@@ -56,6 +60,21 @@ class JellyfinSyncService(
|
||||
|
||||
override fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
|
||||
|
||||
override fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary {
|
||||
if (!properties.enabled) {
|
||||
return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0)
|
||||
}
|
||||
|
||||
return try {
|
||||
ingestPluginSync(command)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
|
||||
) {
|
||||
businessMetricsService.recordJellyfinSyncFailure()
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
private fun runSync(): JellyfinSyncSummary {
|
||||
val startedAt = Instant.now()
|
||||
val remoteUsers = jellyfinCatalog.fetchUsers()
|
||||
@@ -63,7 +82,7 @@ class JellyfinSyncService(
|
||||
userRepository
|
||||
.findAll()
|
||||
.mapNotNull { user ->
|
||||
user.jellyfinUserId?.let { it to user }
|
||||
user.jellyfinUserId?.let { normalizeJellyfinId(it) to user }
|
||||
}.toMap()
|
||||
|
||||
var syncedUsers = 0
|
||||
@@ -71,7 +90,7 @@ class JellyfinSyncService(
|
||||
var syncedItems = 0
|
||||
|
||||
remoteUsers.forEach { remoteUser ->
|
||||
val localUser = localUsersByJellyfinId[remoteUser.id]
|
||||
val localUser = localUsersByJellyfinId[normalizeJellyfinId(remoteUser.id)]
|
||||
if (localUser == null) {
|
||||
skippedUsers += 1
|
||||
return@forEach
|
||||
@@ -123,34 +142,39 @@ class JellyfinSyncService(
|
||||
}
|
||||
|
||||
private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film {
|
||||
val normalizedItem =
|
||||
item.copy(
|
||||
jellyfinItemId = normalizeJellyfinId(item.jellyfinItemId),
|
||||
jellyfinLibraryId = item.jellyfinLibraryId?.let(::normalizeJellyfinId),
|
||||
)
|
||||
val film =
|
||||
filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy(
|
||||
title = item.title,
|
||||
description = item.description,
|
||||
contentType = item.contentType,
|
||||
releaseYear = item.releaseYear,
|
||||
genres = item.genres,
|
||||
cast = item.cast,
|
||||
directors = item.directors,
|
||||
imdbRating = item.imdbRating,
|
||||
platformRating = item.platformRating,
|
||||
externalUrl = item.externalUrl,
|
||||
jellyfinItemId = item.jellyfinItemId,
|
||||
jellyfinLibraryId = item.jellyfinLibraryId,
|
||||
filmRepository.findByJellyfinItemId(normalizedItem.jellyfinItemId)?.copy(
|
||||
title = normalizedItem.title,
|
||||
description = normalizedItem.description,
|
||||
contentType = normalizedItem.contentType,
|
||||
releaseYear = normalizedItem.releaseYear,
|
||||
genres = normalizedItem.genres,
|
||||
cast = normalizedItem.cast,
|
||||
directors = normalizedItem.directors,
|
||||
imdbRating = normalizedItem.imdbRating,
|
||||
platformRating = normalizedItem.platformRating,
|
||||
externalUrl = normalizedItem.externalUrl,
|
||||
jellyfinItemId = normalizedItem.jellyfinItemId,
|
||||
jellyfinLibraryId = normalizedItem.jellyfinLibraryId,
|
||||
) ?: Film(
|
||||
id = idGenerator.generateId(),
|
||||
title = item.title,
|
||||
description = item.description,
|
||||
contentType = item.contentType,
|
||||
releaseYear = item.releaseYear,
|
||||
genres = item.genres,
|
||||
cast = item.cast,
|
||||
directors = item.directors,
|
||||
imdbRating = item.imdbRating,
|
||||
platformRating = item.platformRating,
|
||||
externalUrl = item.externalUrl,
|
||||
jellyfinItemId = item.jellyfinItemId,
|
||||
jellyfinLibraryId = item.jellyfinLibraryId,
|
||||
title = normalizedItem.title,
|
||||
description = normalizedItem.description,
|
||||
contentType = normalizedItem.contentType,
|
||||
releaseYear = normalizedItem.releaseYear,
|
||||
genres = normalizedItem.genres,
|
||||
cast = normalizedItem.cast,
|
||||
directors = normalizedItem.directors,
|
||||
imdbRating = normalizedItem.imdbRating,
|
||||
platformRating = normalizedItem.platformRating,
|
||||
externalUrl = normalizedItem.externalUrl,
|
||||
jellyfinItemId = normalizedItem.jellyfinItemId,
|
||||
jellyfinLibraryId = normalizedItem.jellyfinLibraryId,
|
||||
)
|
||||
|
||||
return filmRepository.save(film)
|
||||
@@ -176,4 +200,114 @@ class JellyfinSyncService(
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ingestPluginSync(command: IngestJellyfinSyncCommand): JellyfinSyncSummary {
|
||||
val startedAt = Instant.now()
|
||||
upsertPluginUsers(command)
|
||||
val localUsersByJellyfinId =
|
||||
userRepository
|
||||
.findAll()
|
||||
.mapNotNull { user -> user.jellyfinUserId?.let { normalizeJellyfinId(it) to user } }
|
||||
.toMap()
|
||||
|
||||
val skippedUserIds = mutableSetOf<String>()
|
||||
val syncedCountsByUserId = mutableMapOf<UUID, Int>()
|
||||
|
||||
command.items.forEach { item ->
|
||||
val savedFilm =
|
||||
upsertFilm(
|
||||
JellyfinLibraryItemSnapshot(
|
||||
jellyfinItemId = item.jellyfinItemId,
|
||||
title = item.title,
|
||||
description = item.description ?: item.originalTitle ?: "",
|
||||
contentType = ContentType.FILM,
|
||||
releaseYear = item.year,
|
||||
genres = item.genres,
|
||||
cast = emptyList(),
|
||||
directors = emptyList(),
|
||||
platformRating = null,
|
||||
imdbRating = null,
|
||||
externalUrl = item.imdbId?.let { "https://www.imdb.com/title/$it/" },
|
||||
jellyfinLibraryId = item.jellyfinLibraryId,
|
||||
isPlayed = false,
|
||||
),
|
||||
)
|
||||
|
||||
item.userStates.forEach { state ->
|
||||
val stateUserId = normalizeJellyfinId(state.jellyfinUserId)
|
||||
val localUser = localUsersByJellyfinId[stateUserId]
|
||||
if (localUser == null) {
|
||||
skippedUserIds += stateUserId
|
||||
return@forEach
|
||||
}
|
||||
|
||||
syncedCountsByUserId[localUser.id] = syncedCountsByUserId.getOrDefault(localUser.id, 0) + 1
|
||||
if (state.isViewed || state.playCount > 0) {
|
||||
markFilmViewed(
|
||||
userId = localUser.id,
|
||||
filmId = savedFilm.id,
|
||||
watchedAt = state.lastPlayedAt?.toLocalDateTime() ?: LocalDateTime.now(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val now = LocalDateTime.now()
|
||||
syncedCountsByUserId.forEach { (userId, itemCount) ->
|
||||
syncStateRepository.save(
|
||||
JellyfinSyncState(
|
||||
userId = userId,
|
||||
lastSyncedAt = now,
|
||||
lastSuccessfulSyncAt = now,
|
||||
lastError = null,
|
||||
syncedItemCount = itemCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val summary =
|
||||
JellyfinSyncSummary(
|
||||
syncedUsers = syncedCountsByUserId.size,
|
||||
skippedUsers = skippedUserIds.size,
|
||||
syncedItems = command.items.size,
|
||||
durationMs = Duration.between(startedAt, Instant.now()).toMillis(),
|
||||
)
|
||||
businessMetricsService.recordJellyfinSync(summary)
|
||||
return summary
|
||||
}
|
||||
|
||||
private fun upsertPluginUsers(command: IngestJellyfinSyncCommand) {
|
||||
command.users.forEach { remoteUser ->
|
||||
val jellyfinUserId =
|
||||
remoteUser.jellyfinUserId
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let(::normalizeJellyfinId)
|
||||
?: return@forEach
|
||||
if (userRepository.findByJellyfinUserId(jellyfinUserId) != null) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
userRepository.save(
|
||||
User(
|
||||
id = idGenerator.generateId(),
|
||||
name = remoteUser.name?.takeIf { it.isNotBlank() } ?: "Jellyfin User",
|
||||
email = syntheticJellyfinEmail(jellyfinUserId),
|
||||
jellyfinUserId = jellyfinUserId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun syntheticJellyfinEmail(jellyfinUserId: String): String {
|
||||
val safeId =
|
||||
jellyfinUserId
|
||||
.lowercase(Locale.getDefault())
|
||||
.replace(Regex("[^a-z0-9._%+-]"), "-")
|
||||
.take(240)
|
||||
return "jellyfin-$safeId@movienight.local"
|
||||
}
|
||||
|
||||
private fun normalizeJellyfinId(value: String): String =
|
||||
runCatching { UUID.fromString(value).toString().replace("-", "") }
|
||||
.getOrDefault(value)
|
||||
}
|
||||
|
||||
@@ -97,12 +97,13 @@ info:
|
||||
|
||||
integrations:
|
||||
jellyfin:
|
||||
enabled: ${JELLYFIN_SYNC_ENABLED:false}
|
||||
enabled: ${JELLYFIN_INTEGRATION_ENABLED:false}
|
||||
base-url: ${JELLYFIN_BASE_URL:}
|
||||
web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}}
|
||||
api-key: ${JELLYFIN_API_KEY:}
|
||||
sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000}
|
||||
request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000}
|
||||
plugin-token: ${JELLYFIN_PLUGIN_TOKEN:}
|
||||
|
||||
services:
|
||||
user:
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.project.movienight.controllers
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@SpringBootTest(
|
||||
properties = [
|
||||
"integrations.jellyfin.enabled=true",
|
||||
"integrations.jellyfin.plugin-token=test-token",
|
||||
"integrations.jellyfin.web-url=https://jellyfin.example.test",
|
||||
],
|
||||
)
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
@Transactional
|
||||
class JellyfinPluginContractTest {
|
||||
private val jellyfinUserId = "11111111111111111111111111111111"
|
||||
private val dashedJellyfinUserId = "11111111-1111-1111-1111-111111111111"
|
||||
private val jellyfinItemId = "22222222222222222222222222222222"
|
||||
private val dashedJellyfinItemId = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
@Autowired
|
||||
private lateinit var mockMvc: MockMvc
|
||||
|
||||
@Autowired
|
||||
private lateinit var objectMapper: ObjectMapper
|
||||
|
||||
@Test
|
||||
fun `plugin sync payload creates mapped user and film`() {
|
||||
postSyncPayload()
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/integrations/jellyfin/users/$dashedJellyfinUserId/recommendations")
|
||||
.header("X-MovieNight-Plugin-Token", "test-token"),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$[0].title").value("Jellyfin Contract Film"))
|
||||
.andExpect(jsonPath("$[0].jellyfinItemId").value(jellyfinItemId))
|
||||
.andExpect(
|
||||
jsonPath("$[0].watchUrl")
|
||||
.value("https://jellyfin.example.test/web/#/details?id=$jellyfinItemId"),
|
||||
)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/integrations/jellyfin/users/$dashedJellyfinUserId/ratings/items/$dashedJellyfinItemId")
|
||||
.header("X-MovieNight-Plugin-Token", "test-token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"score":8,"note":"From Jellyfin UI"}"""),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.score").value(8))
|
||||
|
||||
val viewedPath =
|
||||
"/api/integrations/jellyfin/users/$dashedJellyfinUserId/library/items/" +
|
||||
"$dashedJellyfinItemId/viewed"
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post(viewedPath)
|
||||
.header("X-MovieNight-Plugin-Token", "test-token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"watchedAt":"2026-05-22T10:15:30Z"}"""),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.viewed").value(true))
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/integrations/jellyfin/sync-state")
|
||||
.header("X-MovieNight-Plugin-Token", "test-token"),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$[0].syncedItemCount").value(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin token is required when configured`() {
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/integrations/jellyfin/sync")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(syncPayload())),
|
||||
).andExpect(status().isUnauthorized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin onboarding can create user before first sync`() {
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/integrations/jellyfin/users/$dashedJellyfinUserId/recommendation-onboarding")
|
||||
.header("X-MovieNight-Plugin-Token", "test-token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"weightedGenres":{"Drama":5},"contentTypes":["FILM"]}"""),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.userId").exists())
|
||||
}
|
||||
|
||||
private fun postSyncPayload() {
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/integrations/jellyfin/sync")
|
||||
.header("X-MovieNight-Plugin-Token", "test-token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(syncPayload())),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.syncedUsers").value(1))
|
||||
.andExpect(jsonPath("$.syncedItems").value(1))
|
||||
}
|
||||
|
||||
private fun syncPayload(): Map<String, Any?> =
|
||||
mapOf(
|
||||
"users" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"jellyfinUserId" to jellyfinUserId,
|
||||
"name" to "Jellyfin User",
|
||||
),
|
||||
),
|
||||
"items" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"jellyfinItemId" to jellyfinItemId,
|
||||
"title" to "Jellyfin Contract Film",
|
||||
"description" to "Synced from plugin payload",
|
||||
"year" to 2026,
|
||||
"genres" to listOf("Drama"),
|
||||
"imdbId" to "tt1234567",
|
||||
"userStates" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"jellyfinUserId" to jellyfinUserId,
|
||||
"isViewed" to false,
|
||||
"playCount" to 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user