diff --git a/.github/workflows/jellyfin-plugin.yaml b/.github/workflows/jellyfin-plugin.yaml new file mode 100644 index 0000000..a809b4a --- /dev/null +++ b/.github/workflows/jellyfin-plugin.yaml @@ -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 diff --git a/deploy/argocd/cloudnative-pg.yaml b/deploy/argocd/cloudnative-pg.yaml new file mode 100644 index 0000000..8aae1e4 --- /dev/null +++ b/deploy/argocd/cloudnative-pg.yaml @@ -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 diff --git a/deploy/argocd/external-secrets-operator.yaml b/deploy/argocd/external-secrets-operator.yaml new file mode 100644 index 0000000..9d0f430 --- /dev/null +++ b/deploy/argocd/external-secrets-operator.yaml @@ -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 diff --git a/deploy/argocd/jellyfin.yaml b/deploy/argocd/jellyfin.yaml new file mode 100644 index 0000000..898df16 --- /dev/null +++ b/deploy/argocd/jellyfin.yaml @@ -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 diff --git a/deploy/helm/movienight/Chart.yaml b/deploy/helm/movienight/Chart.yaml new file mode 100644 index 0000000..6280bbb --- /dev/null +++ b/deploy/helm/movienight/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: movienight +description: MovieNight backend +type: application +version: 0.1.0 +appVersion: "0.0.1" diff --git a/deploy/helm/movienight/templates/NOTES.txt b/deploy/helm/movienight/templates/NOTES.txt new file mode 100644 index 0000000..9eb3212 --- /dev/null +++ b/deploy/helm/movienight/templates/NOTES.txt @@ -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 }} diff --git a/deploy/helm/movienight/templates/_helpers.tpl b/deploy/helm/movienight/templates/_helpers.tpl new file mode 100644 index 0000000..5479b62 --- /dev/null +++ b/deploy/helm/movienight/templates/_helpers.tpl @@ -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 -}} diff --git a/deploy/helm/movienight/templates/backend/deployment.yaml b/deploy/helm/movienight/templates/backend/deployment.yaml new file mode 100644 index 0000000..2a03d9d --- /dev/null +++ b/deploy/helm/movienight/templates/backend/deployment.yaml @@ -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 }} diff --git a/deploy/helm/movienight/templates/backend/service.yaml b/deploy/helm/movienight/templates/backend/service.yaml new file mode 100644 index 0000000..1a4ca83 --- /dev/null +++ b/deploy/helm/movienight/templates/backend/service.yaml @@ -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 }} diff --git a/deploy/helm/movienight/templates/gateway/gateway.yaml b/deploy/helm/movienight/templates/gateway/gateway.yaml new file mode 100644 index 0000000..f2a2f4c --- /dev/null +++ b/deploy/helm/movienight/templates/gateway/gateway.yaml @@ -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 }} diff --git a/deploy/helm/movienight/templates/gateway/httproute.yaml b/deploy/helm/movienight/templates/gateway/httproute.yaml new file mode 100644 index 0000000..5449768 --- /dev/null +++ b/deploy/helm/movienight/templates/gateway/httproute.yaml @@ -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 }} diff --git a/deploy/helm/movienight/templates/postgres/cluster.yaml b/deploy/helm/movienight/templates/postgres/cluster.yaml new file mode 100644 index 0000000..57212c1 --- /dev/null +++ b/deploy/helm/movienight/templates/postgres/cluster.yaml @@ -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 }} diff --git a/deploy/helm/movienight/templates/rbac/serviceaccount.yaml b/deploy/helm/movienight/templates/rbac/serviceaccount.yaml new file mode 100644 index 0000000..9930827 --- /dev/null +++ b/deploy/helm/movienight/templates/rbac/serviceaccount.yaml @@ -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 }} diff --git a/deploy/helm/movienight/values.schema.json b/deploy/helm/movienight/values.schema.json new file mode 100644 index 0000000..395fdcc --- /dev/null +++ b/deploy/helm/movienight/values.schema.json @@ -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 + } + } +} diff --git a/deploy/helm/movienight/values.yaml b/deploy/helm/movienight/values.yaml new file mode 100644 index 0000000..b787c63 --- /dev/null +++ b/deploy/helm/movienight/values.yaml @@ -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: / diff --git a/deploy/manifests/backend-secret.yaml b/deploy/manifests/backend-secret.yaml new file mode 100644 index 0000000..cd0d074 --- /dev/null +++ b/deploy/manifests/backend-secret.yaml @@ -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 diff --git a/deploy/manifests/bootstrap-secret.yaml b/deploy/manifests/bootstrap-secret.yaml new file mode 100644 index 0000000..34d42b9 --- /dev/null +++ b/deploy/manifests/bootstrap-secret.yaml @@ -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 diff --git a/deploy/manifests/cluster-secret-store.yaml b/deploy/manifests/cluster-secret-store.yaml new file mode 100644 index 0000000..177cf89 --- /dev/null +++ b/deploy/manifests/cluster-secret-store.yaml @@ -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: / diff --git a/deploy/manifests/ns.yaml b/deploy/manifests/ns.yaml new file mode 100644 index 0000000..b395506 --- /dev/null +++ b/deploy/manifests/ns.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: jellyfin + +--- +apiVersion: v1 +kind: Namespace +metadata: + name: movienight diff --git a/plugins/jellyfin/.gitignore b/plugins/jellyfin/.gitignore new file mode 100644 index 0000000..5967294 --- /dev/null +++ b/plugins/jellyfin/.gitignore @@ -0,0 +1,2 @@ +**/bin/ +**/obj/ diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..2e97960 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.MovieNight.Configuration; + +/// +/// MovieNight plugin settings persisted by Jellyfin. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// Gets or sets a value indicating whether integration calls are enabled. + /// + public bool Enabled { get; set; } + + /// + /// Gets or sets the MovieNight backend base URL. + /// + public string BackendBaseUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the backend plugin token. + /// + public string ApiToken { get; set; } = string.Empty; + + /// + /// Gets or sets the periodic sync interval in minutes. + /// + public int SyncIntervalMinutes { get; set; } = 30; + + /// + /// Gets or sets a value indicating whether playback stop events are pushed to MovieNight. + /// + public bool EnablePlaybackEvents { get; set; } = true; + + /// + /// Gets or sets a value indicating whether periodic backend sync is enabled. + /// + public bool EnablePeriodicSync { get; set; } = true; + + /// + /// Gets or sets enabled Jellyfin library ids. Empty means all libraries. + /// + public List EnabledLibraryIds { get; set; } = new(); + + /// + /// Gets or sets the path where .strm files will be created. + /// + public string StrmOutputPath { get; set; } = string.Empty; +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js new file mode 100644 index 0000000..7e38881 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js @@ -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(); + }); +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html new file mode 100644 index 0000000..adb20db --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html @@ -0,0 +1,74 @@ + + + + MovieNight + + + + + + + + Backend URL + + + + + Plugin token + + + + + Sync interval minutes + + + + + STRM output path + + Directory where .strm files will be created for new films. + + + + + Enable MovieNight integration + + + + + Enable periodic backend sync + + + + + Send playback stop events + + + + + Save + + + + + + Test connection + + + + + UI Integration + To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin Custom JavaScript setting (Dashboard > General): + + + + + + + + + diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js new file mode 100644 index 0000000..022ad33 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js @@ -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 = `${text}`; + 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 = ` + + + + `; + 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 = ` + + MovieNight + + + + `; + 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 = ` + ${title} + + + `; + return dialog; + } + + async function showRatingDialog(itemId) { + const overlay = createOverlay(); + const dialog = createDialogBase('Rate on MovieNight'); + const content = dialog.querySelector('.dialog-content'); + + content.innerHTML = ``; + 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 = ` + + Movie Title (Required) + + + + + Year + + + + IMDb ID + + + + + Stream URL (Optional) + + + `; + + const btnAdd = document.createElement('button'); + btnAdd.className = 'emby-button raised button-submit'; + btnAdd.style.flex = '2'; + btnAdd.style.backgroundColor = '#0064d2'; + btnAdd.innerHTML = 'Add Film'; + 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 = ` + Pick your preferences to get better recommendations. + + Favorite Genres + + + + Preferred Eras + + + + Content Types + + + `; + + 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 = 'Save & Start'; + 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(); +})(); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs new file mode 100644 index 0000000..7d94e7c --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs @@ -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; + +/// +/// Admin endpoints for the MovieNight plugin. +/// +[ApiController] +[Route("MovieNight")] +public class MovieNightController : ControllerBase +{ + private readonly MovieNightBackendClient _backendClient; + private readonly MovieNightSyncService _syncService; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightController( + MovieNightBackendClient backendClient, + MovieNightSyncService syncService) + { + _backendClient = backendClient; + _syncService = syncService; + } + + /// + /// Ping endpoint for connectivity checks. + /// + [HttpGet("Ping")] + public ActionResult Ping() => Ok("Pong"); + + /// + /// Returns plugin status. + /// + /// Status response. + [HttpGet("Status")] + [Authorize] + public ActionResult 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); + } + + /// + /// Tests backend connectivity. + /// + /// Cancellation token. + /// Connection result. + [HttpPost("TestConnection")] + [Authorize] + public async Task> TestConnection(CancellationToken cancellationToken) + { + return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Triggers backend sync. + /// + /// Cancellation token. + /// Backend response. + [HttpPost("Sync")] + [Authorize] + public async Task> Sync(CancellationToken cancellationToken) + { + await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false); + return Ok("Sync triggered"); + } + + /// + /// Gets backend sync state. + /// + /// Cancellation token. + /// Backend response. + [HttpGet("SyncState")] + [Authorize] + public async Task SyncState(CancellationToken cancellationToken) + { + var body = await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false); + return Content(body, "application/json"); + } + + /// + /// Gets recommendations for the current user. + /// + [HttpGet("Users/{userId}/Recommendations")] + [Authorize] + public async Task 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"); + } + + /// + /// Posts a rating for a film. + /// + [HttpPost("Users/{userId}/Ratings/Films/{filmId}")] + [Authorize] + public async Task 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(); + } + + /// + /// Marks a film as viewed. + /// + [HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")] + [Authorize] + public async Task MarkViewed( + [FromRoute] string userId, + [FromRoute] string filmId, + [FromBody] ViewedRequest request, + CancellationToken cancellationToken) + { + await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false); + return Ok(); + } + + /// + /// Gets user preferences. + /// + [HttpGet("Users/{userId}/Preferences")] + [Authorize] + public async Task GetPreferences( + [FromRoute] string userId, + CancellationToken cancellationToken) + { + var body = await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false); + return body is null ? NotFound() : Content(body, "application/json"); + } + + /// + /// Completes onboarding for a user. + /// + [HttpPost("Users/{userId}/Onboarding")] + [Authorize] + public async Task CompleteOnboarding( + [FromRoute] string userId, + [FromBody] object payload, + CancellationToken cancellationToken) + { + await _backendClient.CompleteOnboardingAsync(userId, payload, cancellationToken).ConfigureAwait(false); + return Ok(); + } + + /// + /// 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 + /// + [HttpPost("Films")] + [Authorize] + public async Task 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}"); + } + } +} + +/// +/// Create film request. +/// +public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId); + +/// +/// Rating request. +/// +public sealed record RatingRequest(int Score, string? Note); + +/// +/// Viewed request. +/// +public sealed record ViewedRequest(DateTimeOffset? WatchedAt); + +/// +/// MovieNight plugin status response. +/// +/// Whether integration is enabled. +/// Backend base URL. +/// Whether periodic sync is enabled. +/// Whether playback events are enabled. +/// Sync interval in minutes. +public sealed record MovieNightPluginStatus( + bool Enabled, + string BackendBaseUrl, + bool PeriodicSyncEnabled, + bool PlaybackEventsEnabled, + int SyncIntervalMinutes); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj new file mode 100644 index 0000000..a8e6ec0 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj @@ -0,0 +1,36 @@ + + + + net9.0 + Jellyfin.Plugin.MovieNight + Jellyfin.Plugin.MovieNight + 1.0.0.1 + GPL-3.0-or-later + enable + true + false + + + + + + runtime + + + runtime + + + runtime + + + + + + + + + + + + + diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs new file mode 100644 index 0000000..73a5718 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs @@ -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; + +/// +/// MovieNight Jellyfin plugin. +/// +public class Plugin : BasePlugin, IHasWebPages +{ + /// + /// Initializes a new instance of the class. + /// + /// Application paths. + /// XML serializer. + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + public override string Name => "MovieNight"; + + /// + public override Guid Id => Guid.Parse("42c72919-d6ff-4f62-bb8c-0fac39efafdb"); + + /// + public override string Description => "Bridges Jellyfin playback and sync signals to the MovieNight backend."; + + /// + /// Gets the current plugin instance. + /// + public static Plugin? Instance { get; private set; } + + /// + public IEnumerable 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) + } + ]; + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs new file mode 100644 index 0000000..b4fc366 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs @@ -0,0 +1,21 @@ +using Jellyfin.Plugin.MovieNight.Services; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.Plugin.MovieNight; + +/// +/// Registers MovieNight services with Jellyfin. +/// +public class PluginServiceRegistrator : IPluginServiceRegistrator +{ + /// + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddHostedService(); + serviceCollection.AddHostedService(); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs new file mode 100644 index 0000000..90381b9 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs @@ -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; + +/// +/// Thin HTTP client for the MovieNight backend. +/// +public class MovieNightBackendClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + /// + /// Initializes a new instance of the class. + /// + /// Logger. + public MovieNightBackendClient(ILogger logger) + { + _logger = logger; + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(20) + }; + } + + /// + /// Calls backend health. + /// + /// Cancellation token. + /// Connection result. + public async Task 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 + { + ["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); + } + } + + /// + /// Pushes library sync data to the backend. + /// + /// Sync payload. + /// Cancellation token. + /// Backend response body. + public async Task 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; + } + + /// + /// Gets recommendations for a user. + /// + public async Task 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; + } + + /// + /// Posts a rating for a film. + /// + 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(); + } + + /// + /// Gets ratings for a user. + /// + public async Task 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; + } + + /// + /// Marks a film as viewed. + /// + 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(); + } + + /// + /// Reads backend sync state. + /// + /// Cancellation token. + /// Backend response body. + public async Task 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; + } + + /// + /// Gets user preferences. + /// + public async Task 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; + } + + /// + /// Completes onboarding for a user. + /// + 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(); + } + + /// + /// Pushes an event payload to the backend event endpoint. + /// + /// Event payload. + /// Cancellation token. + /// A task. + 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; + } +} + +/// +/// Backend connection result. +/// +/// Whether the call succeeded. +/// Result message. +public sealed record MovieNightConnectionResult(bool Success, string Message) +{ + /// + /// Creates a successful result. + /// + /// Connection result. + public static MovieNightConnectionResult Ok() => new(true, "OK"); + + /// + /// Creates a failed result. + /// + /// Failure message. + /// Connection result. + public static MovieNightConnectionResult Failed(string message) => new(false, message); +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs new file mode 100644 index 0000000..926199b --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Event payload sent to MovieNight. +/// +/// Idempotency key. +/// Event type. +/// Event timestamp. +/// Jellyfin user id. +/// Jellyfin item id. +/// Payload version. +/// Extra event data. +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 Payload); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs new file mode 100644 index 0000000..bd72c5e --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs @@ -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; + +/// +/// Periodically asks MovieNight to run its current Jellyfin sync. +/// +public sealed class MovieNightPeriodicSyncService : BackgroundService +{ + private readonly MovieNightSyncService _syncService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightPeriodicSyncService( + MovieNightSyncService syncService, + ILogger logger) + { + _syncService = syncService; + _logger = logger; + } + + /// + 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)); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs new file mode 100644 index 0000000..89a5f84 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs @@ -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; + +/// +/// Subscribes to Jellyfin playback events and forwards thin payloads. +/// +public sealed class MovieNightPlaybackEventService : IHostedService +{ + private readonly ISessionManager _sessionManager; + private readonly MovieNightBackendClient _backendClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Jellyfin session manager. + /// Backend client. + /// Logger. + public MovieNightPlaybackEventService( + ISessionManager sessionManager, + MovieNightBackendClient backendClient, + ILogger logger) + { + _sessionManager = sessionManager; + _backendClient = backendClient; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _sessionManager.PlaybackStopped += OnPlaybackStopped; + return Task.CompletedTask; + } + + /// + 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 + { + ["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"); + } + }); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs new file mode 100644 index 0000000..93da778 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs @@ -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; + +/// +/// Service for synchronizing the Jellyfin library with MovieNight. +/// +public class MovieNightSyncService +{ + private readonly MovieNightBackendClient _backendClient; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightSyncService( + MovieNightBackendClient backendClient, + ILibraryManager libraryManager, + IUserManager userManager, + IUserDataManager userDataManager, + ILogger logger) + { + _backendClient = backendClient; + _libraryManager = libraryManager; + _userManager = userManager; + _userDataManager = userDataManager; + _logger = logger; + } + + /// + /// Performs a full library sync. + /// + public async Task PerformSyncAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting MovieNight library sync"); + + var config = Plugin.Instance?.Configuration; + var enabledLibraryIds = config?.EnabledLibraryIds ?? new List(); + + 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(); + + 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 + { + ["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"); + } +} diff --git a/plugins/jellyfin/README.md b/plugins/jellyfin/README.md new file mode 100644 index 0000000..2c3b06a --- /dev/null +++ b/plugins/jellyfin/README.md @@ -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=` +- `JELLYFIN_WEB_URL=` + +`JELLYFIN_SYNC_ENABLED=true` is still accepted as a legacy alias for `JELLYFIN_INTEGRATION_ENABLED=true`. + +Optional backend-pull sync values: + +- `JELLYFIN_BASE_URL=` +- `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. diff --git a/plugins/jellyfin/build.yaml b/plugins/jellyfin/build.yaml new file mode 100644 index 0000000..3c846f3 --- /dev/null +++ b/plugins/jellyfin/build.yaml @@ -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. diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt index 92fb02c..4caacdd 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -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() diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt index 0d7a294..2d33a64 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -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 {}", diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt new file mode 100644 index 0000000..c2bbb1b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt @@ -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") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt new file mode 100644 index 0000000..10bda37 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt @@ -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 { + 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 { + 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, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt index aa107b7..29d5585 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -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 = jellyfinSyncUseCase.getSyncStates() + fun syncState( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + ): List { + 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, + ) + }, + ) + }, + ) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt new file mode 100644 index 0000000..d916f71 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt @@ -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 = emptyList(), + @field:Valid + val items: List = 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 = emptyList(), + val imdbId: String? = null, + val tmdbId: String? = null, + val jellyfinLibraryId: String? = null, + @field:Valid + val userStates: List = 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, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt index b28e68d..6be11a2 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt @@ -21,5 +21,38 @@ data class HandleJellyfinEventCommand( interface JellyfinSyncUseCase { fun syncNow(): JellyfinSyncSummary + fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary + fun getSyncStates(): List } + +data class IngestJellyfinSyncCommand( + val users: List = emptyList(), + val items: List = 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, + val imdbId: String?, + val tmdbId: String?, + val jellyfinLibraryId: String?, + val userStates: List, +) + +data class JellyfinSyncUserStateCommand( + val jellyfinUserId: String, + val isViewed: Boolean, + val playCount: Int, + val lastPlayedAt: OffsetDateTime?, + val userRating: Double?, +) diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt index b35c94c..2747229 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -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) } diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt index cc852d4..56fe3fe 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -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 = 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() + val syncedCountsByUserId = mutableMapOf() + + 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) } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e277aa3..c4248b1 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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: diff --git a/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt b/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt new file mode 100644 index 0000000..d041f0c --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt @@ -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 = + 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, + ), + ), + ), + ), + ) +}
To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin Custom JavaScript setting (Dashboard > General):
Pick your preferences to get better recommendations.