chore(release): first stable release #45

Merged
devitq merged 134 commits from develop into main 2026-05-23 07:28:37 +00:00
191 changed files with 14644 additions and 356 deletions
+54
View File
@@ -0,0 +1,54 @@
name: Build & Test
on:
workflow_call:
outputs:
artifact-name:
description: "Uploaded artifact name for downstream jobs"
value: ${{ jobs.build.outputs.artifact-name }}
jobs:
build:
name: Build & Test
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
outputs:
artifact-name: ${{ steps.meta.outputs.artifact-name }}
steps:
- name: Checkout source
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
cache: gradle
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Set artifact name
id: meta
run: echo "artifact-name=build-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT"
- name: Run CI quality gate
run: ./gradlew clean check bootJar --stacktrace --no-daemon
- name: Upload build artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: ${{ steps.meta.outputs.artifact-name }}
path: |
build/libs/*.jar
build/reports/detekt/**
build/reports/ktlint/**
build/reports/jacoco/**
build/reports/**
build/test-results/**
build/jacoco/**
retention-days: 7
if-no-files-found: error
+86
View File
@@ -0,0 +1,86 @@
name: CI
run-name: "${{ github.ref_name }} - ${{ github.event_name }} by @${{ github.actor }}"
on:
push:
branches: [develop, main]
tags: ["v*"]
pull_request:
branches: [develop, main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
trufflehog:
name: TruffleHog Secret Scan
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout source
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run TruffleHog
uses: trufflesecurity/trufflehog@main
with:
extra_args: --results=verified,unknown
build:
name: Build & Test
needs: [trufflehog]
uses: ./.github/workflows/build.yaml
docker:
name: Docker
needs: build
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
uses: ./.github/workflows/docker.yaml
permissions:
contents: read
packages: write
with:
push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'develop' }}
secrets: inherit
notify-main:
name: Notify Main Build
needs: docker
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Send Telegram notification
env:
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
DIGEST: ${{ needs.docker.outputs.image-digest }}
run: |
COMMIT_URL="https://github.com/${REPO}/commit/${SHA}"
MSG="*${REPO}* - main branch CI succeeded"
MSG="${MSG}%0Ađź”— [Commit](${COMMIT_URL})"
if [ -n "${DIGEST}" ]; then
MSG="${MSG}%0AImage: \`ghcr.io/${REPO}@${DIGEST}\`"
fi
curl -sf -X POST \
"https://api.telegram.org/bot${TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
-d "parse_mode=Markdown" \
-d "text=${MSG}"
release:
name: Release
needs: docker
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
uses: ./.github/workflows/release.yaml
with:
version: ${{ github.ref_name }}
image-digest: ${{ needs.docker.outputs.image-digest }}
secrets: inherit
+77
View File
@@ -0,0 +1,77 @@
name: Docker Build & Push
on:
workflow_call:
inputs:
push:
description: "Push image to GHCR"
type: boolean
required: true
outputs:
image-digest:
description: "Pushed image digest (sha256:…)"
value: ${{ jobs.docker.outputs.image-digest }}
image-tags:
description: "Comma-separated list of applied tags"
value: ${{ jobs.docker.outputs.image-tags }}
jobs:
docker:
name: Docker Build & Push
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
image-digest: ${{ steps.build-push.outputs.digest }}
image-tags: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout source
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build image (no push)
if: inputs.push == false
uses: docker/build-push-action@v7
with:
context: .
file: ./Containerfile
push: false
provenance: false
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Log in to GHCR
if: inputs.push == true
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
if: inputs.push == true
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=sha-
- name: Build and push image
if: inputs.push == true
id: build-push
uses: docker/build-push-action@v7
with:
context: .
file: ./Containerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha
cache-to: type=gha,mode=max
+54
View File
@@ -0,0 +1,54 @@
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/MovieNight
- name: Upload plugin artifact
uses: actions/upload-artifact@v7
with:
name: MovieNight
path: artifacts/MovieNight/*
retention-days: 7
if-no-files-found: error
+58
View File
@@ -0,0 +1,58 @@
name: Release & Notify
on:
workflow_call:
inputs:
version:
description: "Tag name, e.g. v1.2.3"
type: string
required: true
image-digest:
description: "Docker image digest from docker job"
type: string
required: false
default: ""
secrets:
TELEGRAM_BOT_TOKEN:
required: true
TELEGRAM_CHAT_ID:
required: true
jobs:
release:
name: GitHub Release
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
if gh release view "${{ inputs.version }}" >/dev/null 2>&1; then
echo "Release ${{ inputs.version }} already exists. Skipping creation."
else
gh release create "${{ inputs.version }}" \
--generate-notes \
--title "${{ inputs.version }}"
fi
- name: Send Telegram notification
env:
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
VERSION: ${{ inputs.version }}
REPO: ${{ github.repository }}
DIGEST: ${{ inputs.image-digest }}
run: |
RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}"
MSG="*${REPO}* - released *${VERSION}*"
MSG="${MSG}%0Ađź”— [Release notes](${RELEASE_URL})"
if [ -n "${DIGEST}" ]; then
MSG="${MSG}%0AImage: \`ghcr.io/${REPO}@${DIGEST}\`"
fi
curl -sf -X POST \
"https://api.telegram.org/bot${TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
-d "parse_mode=Markdown" \
-d "text=${MSG}"
+3
View File
@@ -42,6 +42,9 @@ gradle-app.setting
*.tar.gz
*.rar
# Gradle wrapper
!gradle/wrapper/gradle-wrapper.jar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
+1 -1
View File
@@ -2,6 +2,6 @@
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.1.20" />
<option name="version" value="2.0.21" />
</component>
</project>
+3 -8
View File
@@ -26,13 +26,7 @@ RUN --mount=type=cache,target=${GRADLE_USER_HOME} \
COPY src src
RUN --mount=type=cache,target=${GRADLE_USER_HOME} \
./gradlew --no-daemon build \
-x test \
-x detekt \
-x ktlintCheck \
-x ktlintKotlinScriptCheck \
-x ktlintMainSourceSetCheck \
-x ktlintTestSourceSetCheck
./gradlew --no-daemon bootJar
RUN mkdir -p ${APP_HOME}/dist \
&& cp ${APP_HOME}/build/libs/*.jar ${APP_HOME}/dist/${JAR_NAME} \
@@ -60,6 +54,7 @@ COPY --from=builder --chown=app:app /workspace/dist/app.jar /app/app.jar
USER app
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC" \
SPRING_AOT_ENABLED=true \
SERVER_PORT=8080 \
PATH="/app:$PATH"
@@ -70,4 +65,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
STOPSIGNAL SIGTERM
ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} -Dserver.port=${SERVER_PORT} -jar /app/app.jar"]
ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} -Dspring.aot.enabled=${SPRING_AOT_ENABLED} -Dserver.port=${SERVER_PORT} -jar /app/app.jar"]
+8 -5
View File
@@ -15,6 +15,8 @@ plugins {
jacoco
}
apply(plugin = "org.springframework.boot.aot")
apply(from = "$rootDir/gradle/docker.gradle.kts")
group = "com.project"
@@ -28,11 +30,11 @@ java {
dependencies {
implementation(platform(libs.sentry.bom))
implementation(platform(libs.spring.grpc.bom))
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.actuator)
// implementation(libs.spring.boot.starter.security)
implementation(libs.spring.boot.starter.aop)
implementation(libs.spring.boot.starter.security)
implementation(libs.spring.boot.starter.cache)
implementation(libs.spring.boot.starter.data.jdbc)
implementation(libs.spring.boot.starter.validation)
@@ -40,12 +42,13 @@ dependencies {
implementation(libs.flyway.database.postgresql)
implementation(libs.kotlin.reflect)
implementation("net.logstash.logback:logstash-logback-encoder:8.0")
implementation(libs.micrometer.tracing.bridge.otel)
implementation(libs.opentelemetry.exporter.otlp)
implementation(libs.sentry.spring.boot.starter)
implementation(libs.spring.grpc.starter)
implementation(libs.grpc.services)
implementation(libs.spring.boot.starter.oauth2.client)
runtimeOnly(libs.micrometer.registry.prometheus)
runtimeOnly(libs.h2)
@@ -55,7 +58,7 @@ dependencies {
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.kotlin.test.junit5)
testImplementation(libs.spring.grpc.test)
testImplementation(libs.mockk)
testRuntimeOnly(libs.junit.platform.launcher)
}
+9
View File
@@ -7,3 +7,12 @@ comments:
active: false
UndocumentedPublicProperty:
active: false
style:
MagicNumber:
active: false
ReturnCount:
max: 3
complexity:
active: false
+26
View File
@@ -0,0 +1,26 @@
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cloudnative-pg
namespace: argocd
spec:
project: default
source:
repoURL: https://cloudnative-pg.github.io/charts
targetRevision: 0.27.1
chart: cloudnative-pg
destination:
server: https://kubernetes.default.svc
namespace: cloudnative-pg
syncPolicy:
automated:
prune: true
selfHeal: true
enabled: true
syncOptions:
- CreateNamespace=true
- ApplyOutOfSyncOnly=true
- ServerSideApply=true
@@ -0,0 +1,32 @@
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: external-secrets-operator
namespace: argocd
spec:
project: default
source:
repoURL: https://charts.external-secrets.io
targetRevision: 2.5.0
chart: external-secrets
helm:
valuesObject:
webhook:
create: false
certController:
create: false
destination:
server: https://kubernetes.default.svc
namespace: external-secrets
syncPolicy:
automated:
prune: true
selfHeal: true
enabled: true
syncOptions:
- CreateNamespace=true
- ApplyOutOfSyncOnly=true
- ServerSideApply=true
+79
View File
@@ -0,0 +1,79 @@
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: grafana
namespace: argocd
spec:
project: default
source:
repoURL: oci://ghcr.io/grafana-community/helm-charts/grafana
path: .
targetRevision: 12.3.0
helm:
valuesObject:
envValueFrom:
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET:
secretKeyRef:
name: grafana-secrets
key: client-secret
grafana.ini:
server:
root_url: https://grafana.internal.itqdev.xyz
auth:
disable_login_form: false
oauth_auto_login: false
auth.generic_oauth:
enabled: true
name: Keycloak
allow_sign_up: true
client_id: grafana-private
client_secret: ${GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET}
scopes: openid profile email
use_pkce: true
auth_url: https://id.itqdev.xyz/realms/master/protocol/openid-connect/auth
token_url: https://id.itqdev.xyz/realms/master/protocol/openid-connect/token
api_url: https://id.itqdev.xyz/realms/master/protocol/openid-connect/userinfo
role_attribute_path: >
contains(groups[*], 'admin') && 'Admin' ||
contains(groups[*], 'editor') && 'Editor' ||
'Viewer'
role_attribute_strict: false
use_refresh_token: true
id_token_attribute_name: preferred_username
sidecar:
dashboards:
enabled: true
label: grafana_dashboard
labelValue: "1"
searchNamespace: ALL
folderAnnotation: grafana_folder
provider:
folder: MovieNight
allowUiUpdates: false
datasources:
enabled: true
label: grafana_datasource
labelValue: "1"
searchNamespace: ALL
destination:
server: https://kubernetes.default.svc
namespace: grafana
syncPolicy:
automated:
prune: true
selfHeal: true
enabled: true
syncOptions:
- CreateNamespace=true
- ApplyOutOfSyncOnly=true
- ServerSideApply=true
+36
View File
@@ -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
+58
View File
@@ -0,0 +1,58 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: victoriametrics-agent
namespace: argocd
spec:
project: default
source:
repoURL: https://victoriametrics.github.io/helm-charts/
chart: victoria-metrics-agent
targetRevision: 0.11.0
helm:
releaseName: vmagent
valuesObject:
remoteWriteUrls:
- http://vmsingle-victoria-metrics-single-server.observability.svc:8428/api/v1/write
config:
global:
scrape_interval: 10s
scrape_configs:
- job_name: 'movienight-backend'
metrics_path: /actuator/prometheus
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_component]
action: keep
regex: backend
- source_labels: [__meta_kubernetes_namespace]
action: keep
regex: movienight
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_instance]
action: keep
regex: movienight
- source_labels: [__meta_kubernetes_pod_container_port_name]
action: keep
regex: management
- action: labelmap
regex: __meta_kubernetes_pod_label_(.+)
- source_labels: [__meta_kubernetes_namespace]
action: replace
target_label: namespace
- source_labels: [__meta_kubernetes_pod_name]
action: replace
target_label: pod
destination:
server: https://kubernetes.default.svc
namespace: observability
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ApplyOutOfSyncOnly=true
- ServerSideApply=true
+67
View File
@@ -0,0 +1,67 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: victoriametrics-alert
namespace: argocd
spec:
project: default
source:
repoURL: https://victoriametrics.github.io/helm-charts/
chart: victoria-metrics-alert
targetRevision: 0.12.0
helm:
releaseName: vmalert
valuesObject:
server:
datasource:
url: http://vmsingle-victoria-metrics-single-server.observability.svc:8428
config:
alerts:
groups:
- name: movienight.rules
rules:
- alert: MovieNightBackendDown
expr: avg(up{job="movienight-backend"}) < 1
for: 2m
labels:
severity: critical
annotations:
summary: MovieNight backend is not fully available
description: vmagent is scraping fewer healthy MovieNight backend targets than expected.
- alert: MovieNightHigh5xxRatio
expr: sum(rate(http_server_requests_seconds_count{job="movienight-backend",status=~"5..",uri!~"/actuator.*"}[5m])) / clamp_min(sum(rate(http_server_requests_seconds_count{job="movienight-backend",uri!~"/actuator.*"}[5m])), 0.001) > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: MovieNight backend 5xx ratio is high
description: More than 5 percent of non-actuator HTTP requests are returning 5xx responses.
- alert: MovieNightNoScrapeData
expr: absent(up{job="movienight-backend"})
for: 5m
labels:
severity: warning
annotations:
summary: MovieNight backend scrape data is missing
description: VictoriaMetrics has no up metric for the movienight-backend scrape job.
alertmanager:
enabled: true
config:
route:
group_by: ['alertname']
receiver: blackhole
receivers:
- name: blackhole
destination:
server: https://kubernetes.default.svc
namespace: observability
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ApplyOutOfSyncOnly=true
- ServerSideApply=true
@@ -0,0 +1,34 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: victoriametrics-operator
namespace: argocd
spec:
project: default
source:
repoURL: https://victoriametrics.github.io/helm-charts/
chart: victoria-metrics-operator
targetRevision: 0.38.0
helm:
releaseName: victoriametrics-operator
valuesObject:
admissionWebhooks:
enabled: false
createCRD: true
operator:
# This enables the operator to watch for CRs in all namespaces
# or we can specify namespaces.
disableNamespaceRestriction: true
destination:
server: https://kubernetes.default.svc
namespace: observability
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ApplyOutOfSyncOnly=true
- ServerSideApply=true
+32
View File
@@ -0,0 +1,32 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: victoriametrics-single
namespace: argocd
spec:
project: default
source:
repoURL: https://victoriametrics.github.io/helm-charts/
chart: victoria-metrics-single
targetRevision: 0.20.0
helm:
releaseName: vmsingle
valuesObject:
server:
retentionPeriod: 30d
persistentVolume:
enabled: true
size: 4Gi
destination:
server: https://kubernetes.default.svc
namespace: observability
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ApplyOutOfSyncOnly=true
- ServerSideApply=true
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: movienight
description: MovieNight backend
type: application
version: 0.1.0
appVersion: "0.0.1"
@@ -0,0 +1,25 @@
MovieNight backend has been deployed.
Backend:
Service: {{ include "movienight.fullname" . }}-backend
Port: {{ .Values.backend.service.port }}
Postgres:
{{- if .Values.postgres.url }}
Using explicit SPRING_DATASOURCE_URL.
{{- else if .Values.postgres.existingSecret.name }}
Using secret {{ .Values.postgres.existingSecret.name }}.
{{- else if .Values.postgres.cluster.enabled }}
CNPG Cluster: {{ include "movienight.postgresClusterName" . }}
JDBC URL: {{ include "movienight.postgresJdbcUrl" . }}
{{- else }}
No Postgres values provided. The app will fall back to its embedded H2 defaults.
{{- end }}
Gateway:
{{- if .Values.gateway.enabled }}
Gateway: {{ include "movienight.gatewayName" . }}
GatewayClass: {{ .Values.gateway.className }}
{{- else }}
Disabled.
{{- end }}
@@ -0,0 +1,141 @@
{{- 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 -}}
{{- define "movienight.victoriaMetricsName" -}}
{{- printf "%s-victoriametrics" (include "movienight.fullname" .) | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "movienight.vmagentName" -}}
{{- printf "%s-vmagent" (include "movienight.fullname" .) | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "movienight.victoriaMetricsURL" -}}
{{- if .Values.observability.grafana.datasource.url -}}
{{- .Values.observability.grafana.datasource.url -}}
{{- else -}}
{{- .Values.observability.victoriaMetrics.url -}}
{{- end -}}
{{- end -}}
@@ -0,0 +1,107 @@
{{- 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 .Values.backend.management.enabled }}
- name: management
containerPort: {{ .Values.backend.management.port }}
protocol: TCP
{{- end }}
{{- if or $postgresEnv .Values.backend.env .Values.backend.management.enabled .Values.observability.enabled }}
env:
{{- if .Values.backend.management.enabled }}
- name: MANAGEMENT_SERVER_PORT
value: {{ .Values.backend.management.port | quote }}
{{- end }}
{{- if .Values.observability.enabled }}
- name: MANAGEMENT_METRICS_TAGS_APPLICATION
value: {{ .Values.observability.metrics.applicationTag | quote }}
- name: HTTP_SERVER_REQUESTS_HISTOGRAM_ENABLED
value: {{ .Values.observability.metrics.httpServerRequestsHistogram | quote }}
{{- end }}
{{- if $postgresEnv }}
{{- $postgresEnv | nindent 12 }}
{{- end }}
{{- with .Values.backend.env }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
{{- with .Values.backend.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.backend.startupProbe }}
startupProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.backend.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.backend.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.backend.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.backend.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.backend.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.backend.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
@@ -0,0 +1,27 @@
{{- 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
{{- if .Values.backend.management.enabled }}
- name: management
port: {{ .Values.backend.management.port }}
targetPort: management
protocol: TCP
{{- end }}
selector:
{{- include "movienight.componentSelectorLabels" (dict "root" . "component" "backend") | nindent 4 }}
{{- end }}
@@ -0,0 +1,50 @@
{{- if .Values.gateway.enabled }}
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: {{ include "movienight.gatewayName" . }}
labels:
{{- include "movienight.componentLabels" (dict "root" . "component" "gateway") | nindent 4 }}
{{- with .Values.gateway.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if or .Values.global.annotations .Values.gateway.annotations }}
annotations:
{{- with .Values.global.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.gateway.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
spec:
gatewayClassName: {{ required "gateway.className is required when gateway.enabled=true" .Values.gateway.className | quote }}
listeners:
{{- if .Values.gateway.http.enabled }}
- name: http
protocol: HTTP
port: {{ .Values.gateway.http.port }}
{{- if .Values.gateway.listenerHostname }}
hostname: {{ .Values.gateway.listenerHostname | quote }}
{{- end }}
allowedRoutes:
namespaces:
from: Same
{{- end }}
{{- if .Values.gateway.https.enabled }}
- name: https
protocol: HTTPS
port: {{ .Values.gateway.https.port }}
{{- if .Values.gateway.listenerHostname }}
hostname: {{ .Values.gateway.listenerHostname | quote }}
{{- end }}
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: {{ required "gateway.https.secretName is required when gateway.https.enabled=true" .Values.gateway.https.secretName }}
allowedRoutes:
namespaces:
from: Same
{{- end }}
{{- end }}
@@ -0,0 +1,29 @@
{{- if and .Values.routes.enabled .Values.gateway.enabled }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "movienight.fullname" . }}
labels:
{{- include "movienight.componentLabels" (dict "root" . "component" "route") | nindent 4 }}
{{- with .Values.global.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
parentRefs:
- name: {{ include "movienight.gatewayName" . }}
{{- if .Values.gateway.hostnames }}
hostnames:
{{- toYaml .Values.gateway.hostnames | nindent 4 }}
{{- end }}
rules:
{{- if and .Values.routes.backend.enabled .Values.backend.enabled }}
- matches:
- path:
type: PathPrefix
value: {{ .Values.routes.backend.pathPrefix | quote }}
backendRefs:
- name: {{ include "movienight.fullname" . }}-backend
port: {{ .Values.backend.service.port }}
{{- end }}
{{- end }}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,828 @@
{{- if and .Values.observability.enabled .Values.observability.grafana.dashboards.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "movienight.fullname" . }}-grafana-red-dashboard
labels:
{{- include "movienight.componentLabels" (dict "root" . "component" "grafana-dashboard") | nindent 4 }}
{{- with .Values.observability.grafana.dashboards.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if or .Values.global.annotations .Values.observability.grafana.dashboards.annotations }}
annotations:
{{- with .Values.global.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.observability.grafana.dashboards.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
data:
movienight-red.json: |
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"target": {
"limit": 100,
"matchAny": false,
"tags": [],
"type": "dashboard"
},
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
},
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "avg(up{job=~\"$job\"})",
"instant": false,
"legendFormat": "up",
"range": true,
"refId": "A"
}
],
"title": "Availability",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 0
},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval]))",
"instant": false,
"legendFormat": "requests",
"range": true,
"refId": "A"
}
],
"title": "Request Rate",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 0.01
},
{
"color": "red",
"value": 0.05
}
]
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 12,
"y": 0
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",status=~\"5..\",uri!~\"/actuator.*\"}[$__rate_interval])) / clamp_min(sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval])), 0.001)",
"instant": false,
"legendFormat": "5xx ratio",
"range": true,
"refId": "A"
}
],
"title": "Error Ratio",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 0.5
},
{
"color": "red",
"value": 1
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 18,
"y": 0
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum(rate(http_server_requests_seconds_sum{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval])) / clamp_min(sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval])), 0.001)",
"instant": false,
"legendFormat": "mean",
"range": true,
"refId": "A"
}
],
"title": "Mean Duration",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 4
},
"id": 5,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum by (method, uri, status) (rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval]))",
"instant": false,
"range": true,
"refId": "A"
}
],
"title": "Request Rate by Route",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 4
},
"id": 6,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum by (le, uri) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval])))",
"instant": false,
"legendFormat": "p95",
"range": true,
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum by (uri) (rate(http_server_requests_seconds_sum{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval])) / clamp_min(sum by (uri) (rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval])), 0.001)",
"instant": false,
"legendFormat": "mean",
"range": true,
"refId": "B"
}
],
"title": "Duration by Route",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 12
},
"id": 7,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",status=~\"2..\",uri!~\"/actuator.*\"}[$__rate_interval]))",
"instant": false,
"legendFormat": "2xx",
"range": true,
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",status=~\"4..\",uri!~\"/actuator.*\"}[$__rate_interval]))",
"instant": false,
"legendFormat": "4xx",
"range": true,
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",status=~\"5..\",uri!~\"/actuator.*\"}[$__rate_interval]))",
"instant": false,
"legendFormat": "5xx",
"range": true,
"refId": "C"
}
],
"title": "Requests by Status Class",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 12
},
"id": 8,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum(process_cpu_usage{job=~\"$job\"})",
"instant": false,
"legendFormat": "process CPU",
"range": true,
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum(jvm_memory_used_bytes{job=~\"$job\",area=\"heap\"})",
"instant": false,
"legendFormat": "heap used",
"range": true,
"refId": "B"
}
],
"title": "Runtime Signals",
"type": "timeseries"
}
],
"preload": false,
"refresh": "30s",
"schemaVersion": 42,
"tags": [
"movienight",
"red"
],
"templating": {
"list": [
{
"current": {
"selected": false,
"text": {{ .Values.observability.grafana.datasource.name | quote }},
"value": {{ .Values.observability.grafana.datasource.uid | quote }}
},
"hide": 0,
"includeAll": false,
"label": "Datasource",
"multi": false,
"name": "datasource",
"options": [],
"query": "prometheus",
"refresh": 1,
"regex": "",
"type": "datasource"
},
{
"current": {
"selected": false,
"text": {{ .Values.observability.vmagent.backendJobName | quote }},
"value": {{ .Values.observability.vmagent.backendJobName | quote }}
},
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"definition": "label_values(http_server_requests_seconds_count, job)",
"hide": 0,
"includeAll": false,
"label": "Job",
"multi": false,
"name": "job",
"options": [],
"query": {
"query": "label_values(http_server_requests_seconds_count, job)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"refresh": 1,
"regex": "",
"sort": 1,
"type": "query"
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "MovieNight RED",
"uid": "movienight-red",
"version": 1,
"weekStart": ""
}
{{- end }}
@@ -0,0 +1,39 @@
{{- if and .Values.observability.enabled .Values.observability.grafana.datasource.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "movienight.fullname" . }}-grafana-datasource
labels:
{{- include "movienight.componentLabels" (dict "root" . "component" "grafana-datasource") | nindent 4 }}
{{- with .Values.observability.grafana.datasource.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if or .Values.global.annotations .Values.observability.grafana.datasource.annotations }}
annotations:
{{- with .Values.global.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.observability.grafana.datasource.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
data:
victoriametrics.yaml: |
apiVersion: 1
prune: true
datasources:
- name: {{ .Values.observability.grafana.datasource.name | quote }}
type: prometheus
access: proxy
orgId: 1
uid: {{ .Values.observability.grafana.datasource.uid | quote }}
url: {{ include "movienight.victoriaMetricsURL" . | quote }}
basicAuth: false
isDefault: {{ .Values.observability.grafana.datasource.isDefault }}
editable: false
jsonData:
httpMethod: POST
queryTimeout: 10s
timeInterval: {{ .Values.observability.vmagent.scrapeInterval | quote }}
{{- end }}
@@ -0,0 +1,36 @@
{{- if .Values.postgres.cluster.enabled }}
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: {{ include "movienight.postgresClusterName" . }}
labels:
{{- include "movienight.componentLabels" (dict "root" . "component" "postgres") | nindent 4 }}
{{- with .Values.postgres.cluster.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if or .Values.global.annotations .Values.postgres.cluster.annotations }}
annotations:
{{- with .Values.global.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.postgres.cluster.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
spec:
instances: {{ .Values.postgres.cluster.instances }}
storage:
size: {{ .Values.postgres.cluster.storage.size | quote }}
{{- if .Values.postgres.cluster.storage.storageClass }}
storageClass: {{ .Values.postgres.cluster.storage.storageClass | quote }}
{{- end }}
bootstrap:
initdb:
database: {{ .Values.postgres.cluster.database | quote }}
owner: {{ .Values.postgres.cluster.owner | quote }}
secret:
name: {{ required "postgres.cluster.bootstrapSecretName is required when postgres.cluster.enabled=true" .Values.postgres.cluster.bootstrapSecretName }}
{{- with .Values.postgres.cluster.extraSpec }}
{{- toYaml . | nindent 2 }}
{{- end }}
{{- end }}
@@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "movienight.serviceAccountName" . }}
labels:
{{- include "movienight.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
+283
View File
@@ -0,0 +1,283 @@
{
"$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
},
"management": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"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
},
"observability": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"metrics": {
"type": "object",
"additionalProperties": true
},
"victoriaMetrics": {
"type": "object",
"additionalProperties": true
},
"vmagent": {
"type": "object",
"additionalProperties": true
},
"grafana": {
"type": "object",
"additionalProperties": true
}
},
"additionalProperties": true
}
}
}
+150
View File
@@ -0,0 +1,150 @@
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
management:
enabled: true
port: 8081
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: management
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: management
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
startupProbe:
httpGet:
path: /actuator/health
port: management
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 12
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: /
observability:
enabled: true
metrics:
applicationTag: movienight
httpServerRequestsHistogram: "true"
vmagent:
scrapeInterval: 10s
backendJobName: movienight-backend
victoriaMetrics:
url: "http://vmsingle-victoria-metrics-single-server.observability.svc:8428"
operator:
enabled: true
grafana:
datasource:
enabled: true
name: VictoriaMetrics
uid: victoriametrics
labels:
grafana_datasource: "1"
annotations: {}
url: ""
isDefault: true
dashboards:
enabled: true
labels:
grafana_dashboard: "1"
annotations:
grafana_folder: MovieNight
+62
View File
@@ -0,0 +1,62 @@
---
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_BASE_URL: "{{ .jellyfinBaseUrl }}"
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: jellyfinBaseUrl
remoteRef:
key: /movienight/MOVIENIGHT_JELLYFIN_BASE_URL
- 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
+30
View File
@@ -0,0 +1,30 @@
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: movienight-cnpg-bootstrap
namespace: movienight
spec:
secretStoreRef:
name: infisical
kind: ClusterSecretStore
target:
name: movienight-cnpg-bootstrap
creationPolicy: Owner
template:
engineVersion: v2
type: kubernetes.io/basic-auth
data:
username: "{{ .dbUsername }}"
password: "{{ .dbPassword }}"
data:
- secretKey: dbUsername
remoteRef:
key: /movienight/MOVIENIGHT_DB_USERNAME
- secretKey: dbPassword
remoteRef:
key: /movienight/MOVIENIGHT_DB_PASSWORD
refreshInterval: 1h
@@ -0,0 +1,23 @@
---
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: infisical
spec:
provider:
infisical:
hostAPI: https://vault.itqdev.xyz
auth:
universalAuthCredentials:
clientId:
name: infisical-secret
key: clientId
namespace: external-secrets
clientSecret:
name: infisical-secret
key: clientSecret
namespace: external-secrets
secretsScope:
projectSlug: default-c-nay
environmentSlug: prod
secretsPath: /
+11
View File
@@ -0,0 +1,11 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: jellyfin
---
apiVersion: v1
kind: Namespace
metadata:
name: movienight
+4
View File
@@ -11,10 +11,13 @@ spring-grpc = "1.0.1"
protoc = "3.25.1"
grpc-java = "1.60.0"
springdoc = "2.8.6"
mockk = "1.13.13"
[libraries]
spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" }
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" }
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" }
spring-boot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop" }
spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" }
spring-boot-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache" }
spring-boot-starter-data-jdbc = { module = "org.springframework.boot:spring-boot-starter-data-jdbc" }
@@ -33,6 +36,7 @@ flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" }
kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
sentry-bom = { module = "io.sentry:sentry-bom", version.ref = "sentry" }
sentry-spring-boot-starter = { module = "io.sentry:sentry-spring-boot-starter-jakarta" }
Binary file not shown.
+63
View File
@@ -0,0 +1,63 @@
---
name: karpathy-guidelines
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
license: MIT
---
# Karpathy Guidelines
Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
+2
View File
@@ -0,0 +1,2 @@
**/bin/
**/obj/
@@ -0,0 +1,50 @@
using System.Collections.Generic;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.MovieNight.Configuration;
/// <summary>
/// MovieNight plugin settings persisted by Jellyfin.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether integration calls are enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the MovieNight backend base URL.
/// </summary>
public string BackendBaseUrl { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the backend plugin token.
/// </summary>
public string ApiToken { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the periodic sync interval in minutes.
/// </summary>
public int SyncIntervalMinutes { get; set; } = 30;
/// <summary>
/// Gets or sets a value indicating whether playback stop events are pushed to MovieNight.
/// </summary>
public bool EnablePlaybackEvents { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether periodic backend sync is enabled.
/// </summary>
public bool EnablePeriodicSync { get; set; } = true;
/// <summary>
/// Gets or sets enabled Jellyfin library ids. Empty means all libraries.
/// </summary>
public List<string> EnabledLibraryIds { get; set; } = new();
/// <summary>
/// Gets or sets the path where .strm files will be created.
/// </summary>
public string StrmOutputPath { get; set; } = string.Empty;
}
@@ -0,0 +1,94 @@
const movieNightConfigPage = {
pluginId: "42c72919-d6ff-4f62-bb8c-0fac39efafdb",
loadConfiguration(view) {
Dashboard.showLoadingMsg();
return ApiClient.getPluginConfiguration(this.pluginId)
.then((config) => {
view.querySelector("#BackendBaseUrl").value =
config.BackendBaseUrl || "";
view.querySelector("#ApiToken").value = config.ApiToken || "";
view.querySelector("#SyncIntervalMinutes").value =
config.SyncIntervalMinutes || 30;
view.querySelector("#StrmOutputPath").value =
config.StrmOutputPath || "";
view.querySelector("#Enabled").checked = config.Enabled || false;
view.querySelector("#EnablePeriodicSync").checked =
config.EnablePeriodicSync !== false;
view.querySelector("#EnablePlaybackEvents").checked =
config.EnablePlaybackEvents !== false;
const uiScriptUrl = ApiClient.getUrl("web/ConfigurationPage", {
name: "MovieNight.ui.js",
});
view.querySelector("#UIScriptUrl").innerText = uiScriptUrl;
})
.finally(() => {
Dashboard.hideLoadingMsg();
});
},
saveConfiguration(view) {
const form = view.querySelector("#MovieNightConfigForm");
Dashboard.showLoadingMsg();
return ApiClient.getPluginConfiguration(this.pluginId)
.then((config) => {
config.BackendBaseUrl = form.querySelector("#BackendBaseUrl").value;
config.ApiToken = form.querySelector("#ApiToken").value;
config.SyncIntervalMinutes = parseInt(
form.querySelector("#SyncIntervalMinutes").value || "30",
10,
);
config.StrmOutputPath = form.querySelector("#StrmOutputPath").value;
config.Enabled = form.querySelector("#Enabled").checked;
config.EnablePeriodicSync =
form.querySelector("#EnablePeriodicSync").checked;
config.EnablePlaybackEvents =
form.querySelector("#EnablePlaybackEvents").checked;
return ApiClient.updatePluginConfiguration(this.pluginId, config);
})
.then((result) => {
Dashboard.processPluginConfigurationUpdateResult(result);
})
.finally(() => {
Dashboard.hideLoadingMsg();
});
},
testConnection() {
Dashboard.showLoadingMsg();
return ApiClient.ajax({
type: "POST",
url: ApiClient.getUrl("MovieNight/TestConnection"),
})
.then((result) => {
Dashboard.alert((result && result.message) || "OK");
})
.catch(() => {
Dashboard.alert("MovieNight connection test failed");
})
.finally(() => {
Dashboard.hideLoadingMsg();
});
},
};
export default function (view) {
movieNightConfigPage.loadConfiguration(view);
view
.querySelector("#MovieNightConfigForm")
.addEventListener("submit", (event) => {
event.preventDefault();
movieNightConfigPage.saveConfiguration(view);
});
view.querySelector("#TestConnection").addEventListener("click", (event) => {
event.preventDefault();
movieNightConfigPage.testConnection();
});
}
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<title>MovieNight</title>
</head>
<body>
<div
id="MovieNightConfigPage"
data-role="page"
class="page type-interior pluginConfigurationPage"
data-controller="__plugin/MovieNight.js">
<div data-role="content">
<div class="content-primary">
<form id="MovieNightConfigForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="BackendBaseUrl">Backend URL</label>
<input is="emby-input" id="BackendBaseUrl" name="BackendBaseUrl" type="url" placeholder="http://localhost:8080" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ApiToken">Plugin token</label>
<input is="emby-input" id="ApiToken" name="ApiToken" type="password" autocomplete="new-password" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SyncIntervalMinutes">Sync interval minutes</label>
<input is="emby-input" id="SyncIntervalMinutes" name="SyncIntervalMinutes" type="number" min="1" max="1440" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="StrmOutputPath">STRM output path</label>
<input is="emby-input" id="StrmOutputPath" name="StrmOutputPath" type="text" placeholder="/data/movies/movienight" />
<div class="fieldDescription">Directory where .strm files will be created for new films.</div>
</div>
<label class="checkboxContainer">
<input id="Enabled" name="Enabled" type="checkbox" is="emby-checkbox" />
<span>Enable MovieNight integration</span>
</label>
<label class="checkboxContainer">
<input id="EnablePeriodicSync" name="EnablePeriodicSync" type="checkbox" is="emby-checkbox" />
<span>Enable periodic backend sync</span>
</label>
<label class="checkboxContainer">
<input id="EnablePlaybackEvents" name="EnablePlaybackEvents" type="checkbox" is="emby-checkbox" />
<span>Send playback stop events</span>
</label>
<div>
<button is="emby-button" type="submit" class="raised button-submit block">
<span>Save</span>
</button>
</div>
<div>
<button is="emby-button" type="button" id="TestConnection" class="raised block">
<span>Test connection</span>
</button>
</div>
<div style="margin-top: 2em; padding: 1em; background: #333; border-radius: 4px;">
<h3>UI Integration</h3>
<p>To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin <strong>Custom JavaScript</strong> setting (Dashboard > General):</p>
<code id="UIScriptUrl" style="display: block; padding: 0.5em; background: #000; word-break: break-all;"></code>
</div>
</form>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,936 @@
(function () {
if (typeof window.movieNightUiCleanup === 'function') {
window.movieNightUiCleanup();
}
const PLUGIN_ID = "42c72919-d6ff-4f62-bb8c-0fac39efafdb";
const ROUTE_RETRY_DELAYS_MS = [0, 100, 300, 700, 1500, 3000];
function getAlert() {
if (typeof Dashboard !== 'undefined' && Dashboard.alert) {
return (options) => Dashboard.alert(formatAlertMessage(options));
}
return (options) => {
alert(formatAlertMessage(options));
};
}
const showMsg = getAlert();
function formatAlertMessage(options) {
if (typeof options === 'string') return options;
if (!options) return '';
return [options.title, options.text || options.message].filter(Boolean).join('\n\n');
}
function ensureMovieNightStyles() {
if (document.getElementById('movieNightUiStyles')) return;
const style = document.createElement('style');
style.id = 'movieNightUiStyles';
style.textContent = `
.movieNightActionButton {
align-items: center;
border: var(--defaultLighterBorder, 1px solid rgba(255,255,255,.16));
border-radius: var(--smallRadius, 8px);
display: inline-flex;
gap: .55em;
min-height: 2.65em;
padding: .55em .9em;
transition: background-color .16s ease, border-color .16s ease, color .16s ease;
}
.movieNightActionButton:hover,
.movieNightActionButton:focus {
border-color: var(--dimTextColor, rgba(255,255,255,.45));
color: #fff;
}
.movieNightActionButton .material-icons {
font-size: 1.35em;
}
.movieNightDetailButton {
color: var(--textColor, #fff);
}
.movieNightDetailButton .detailButton-content {
border-radius: 999px;
outline: 1px solid rgba(255,255,255,.16);
outline-offset: -1px;
}
.movieNightDetailButton:focus .detailButton-content,
.movieNightDetailButton:hover .detailButton-content {
background: rgba(255,255,255,.18);
}
.movieNightHomeButtons {
margin-bottom: 1.25em;
}
.movieNightPanel {
background: color-mix(in srgb, var(--headerColor, #202020) 78%, transparent);
border: var(--defaultBorder, 1px solid rgba(255,255,255,.12));
border-radius: var(--smallRadius, 8px);
box-sizing: border-box;
padding: 1em;
}
.movieNightPanelHeader {
align-items: center;
display: flex;
gap: 1em;
justify-content: space-between;
margin-bottom: .75em;
}
.movieNightPanelHeader .sectionTitle {
margin: 0;
}
.movieNightSyncStatus {
color: var(--dimTextColor, rgba(255,255,255,.65));
font-size: .86em;
text-align: right;
}
.movieNightBtnContainer {
display: flex;
flex-wrap: wrap;
gap: .65em;
}
.movieNightDialog {
background: color-mix(in srgb, var(--drawerColor, #1f1f1f) 92%, transparent) !important;
border: var(--defaultBorder, 1px solid rgba(255,255,255,.15)) !important;
border-radius: var(--smallRadius, 8px) !important;
box-shadow: var(--shadow, 0 18px 55px rgba(0,0,0,.55)) !important;
box-sizing: border-box;
color: var(--textColor, #fff) !important;
max-width: calc(100vw - 2em);
}
.movieNightDialogTitle {
align-items: center;
display: flex;
gap: .55em;
margin: 0;
font-size: 1.35em;
font-weight: 500;
}
.movieNightDialogTitle .material-icons {
color: var(--uiAccentColor, #00a4dc);
font-size: 1.25em;
}
.movieNightDialog .dialog-content {
color: var(--textColor, #fff);
}
.movieNightDialog .dialog-footer {
justify-content: flex-end;
}
.movieNightRecommendationList {
display: grid;
gap: .8em;
}
.movieNightRecommendation {
background: rgba(255,255,255,.055);
border: var(--defaultLighterBorder, 1px solid rgba(255,255,255,.14));
border-radius: var(--smallRadius, 8px);
display: grid;
gap: .9em;
grid-template-columns: 76px minmax(0, 1fr);
padding: .75em;
}
.movieNightRecommendationPoster {
align-self: start;
aspect-ratio: 2 / 3;
background: rgba(255,255,255,.08);
border-radius: var(--smallerRadius, 6px);
object-fit: cover;
overflow: hidden;
width: 76px;
}
.movieNightRecommendationBody {
min-width: 0;
}
.movieNightRecommendationHeader {
align-items: start;
display: flex;
gap: 1em;
justify-content: space-between;
}
.movieNightRecommendationTitle {
color: #fff;
font-size: 1.08em;
font-weight: 600;
line-height: 1.25;
overflow-wrap: anywhere;
}
.movieNightRecommendationMeta,
.movieNightRecommendationReason {
color: var(--dimTextColor, rgba(255,255,255,.68));
font-size: .9em;
margin-top: .25em;
}
.movieNightRecommendationScore {
background: rgba(255,255,255,.1);
border-radius: 999px;
color: #fff;
flex: 0 0 auto;
font-size: .82em;
padding: .28em .65em;
white-space: nowrap;
}
.movieNightRecommendationDescription {
color: rgba(255,255,255,.84);
display: -webkit-box;
line-height: 1.35;
margin-top: .65em;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.movieNightRecommendationActions {
display: flex;
flex-wrap: wrap;
gap: .5em;
margin-top: .75em;
}
.movieNightRecommendationActions .emby-button {
min-height: 2.35em;
}
.movieNightField {
margin-bottom: 1em;
}
.movieNightField label {
color: var(--dimTextColor, rgba(255,255,255,.72));
display: block;
font-size: .9em;
margin-bottom: .35em;
}
.movieNightFieldRow {
display: grid;
gap: 1em;
grid-template-columns: minmax(6em, .7fr) minmax(0, 1.3fr);
}
.movieNightDialog .emby-input {
box-sizing: border-box;
width: 100%;
}
@media (max-width: 42em) {
.movieNightRecommendation {
grid-template-columns: 56px minmax(0, 1fr);
}
.movieNightRecommendationPoster {
width: 56px;
}
.movieNightRecommendationHeader {
display: block;
}
.movieNightRecommendationScore {
display: inline-flex;
margin-top: .45em;
}
.movieNightPanelHeader {
align-items: flex-start;
flex-direction: column;
gap: .35em;
}
.movieNightSyncStatus {
text-align: left;
}
.movieNightFieldRow {
grid-template-columns: 1fr;
gap: 0;
}
}
`;
document.head.appendChild(style);
}
function createTextButton(text, className, onClick, icon) {
const btn = document.createElement('button');
btn.type = 'button';
btn.is = 'emby-button';
btn.className = `emby-button raised movieNightActionButton ${className}`;
btn.innerHTML = icon
? `<span class="material-icons ${icon}" aria-hidden="true"></span><span>${text}</span>`
: `<span>${text}</span>`;
btn.onclick = onClick;
return btn;
}
function createIconButton(icon, title, className, onClick) {
const btn = document.createElement('button');
btn.type = 'button';
btn.is = 'emby-button';
btn.className = `button-flat detailButton emby-button movieNightDetailButton ${className}`;
btn.title = title;
btn.innerHTML = `
<div class="detailButton-content">
<span class="material-icons detailButton-icon ${icon}" aria-hidden="true"></span>
</div>
`;
btn.onclick = onClick;
return btn;
}
async function injectUI() {
ensureMovieNightStyles();
await checkOnboarding();
// Item Detail Page
const itemId = getItemIdFromUrl();
document.querySelectorAll('.mainDetailButtons').forEach((detailButtons) => {
if (!itemId) return;
// MovieNight Rating
const existingRateBtn = detailButtons.querySelector('.btnMovieNightRate');
if (!existingRateBtn || existingRateBtn.dataset.movieNightItemId !== itemId) {
existingRateBtn?.remove();
const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => {
e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId);
});
rateBtn.dataset.movieNightItemId = itemId;
insertInDetailRow(detailButtons, rateBtn);
}
// Mark Viewed in MovieNight
const existingViewedBtn = detailButtons.querySelector('.btnMovieNightMarkViewed');
if (!existingViewedBtn || existingViewedBtn.dataset.movieNightItemId !== itemId) {
existingViewedBtn?.remove();
const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => {
e.preventDefault(); e.stopPropagation(); submitViewed(itemId);
});
viewedBtn.dataset.movieNightItemId = itemId;
insertInDetailRow(detailButtons, viewedBtn);
}
});
// Library Pages
document
.querySelectorAll('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x')
.forEach((toolBar) => {
if (!toolBar.querySelector('.btnMovieNightRecommend')) {
toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => {
e.preventDefault(); showRecommendation();
}, 'auto_awesome'));
}
if (!toolBar.querySelector('.btnMovieNightAddMovie')) {
toolBar.appendChild(createTextButton('Add Movie', 'btnMovieNightAddMovie', (e) => {
e.preventDefault(); showAddMovieDialog();
}, 'add'));
}
});
// Home Page
document.querySelectorAll('.sections.homeSectionsContainer').forEach((homeSections) => {
if (homeSections.querySelector('.movieNightHomeButtons')) return;
const section = document.createElement('div');
section.className = 'verticalSection movieNightHomeButtons';
section.style.padding = '0 var(--sidePadding)';
section.innerHTML = `
<div class="movieNightPanel">
<div class="movieNightPanelHeader">
<h2 class="sectionTitle">MovieNight</h2>
<span class="movieNightSyncStatus"></span>
</div>
<div class="movieNightBtnContainer"></div>
</div>
`;
const btnContainer = section.querySelector('.movieNightBtnContainer');
btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation, 'auto_awesome'));
btnContainer.appendChild(createTextButton('Add Movie', 'btnMovieNightAddMovie', showAddMovieDialog, 'add'));
btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync, 'sync'));
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 movieNightDialog';
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 = '1.25em';
dialog.style.minWidth = '350px';
dialog.style.opacity = '1';
dialog.innerHTML = `
<h2 class="movieNightDialogTitle">
<span class="material-icons auto_awesome" aria-hidden="true"></span>
<span>${title}</span>
</h2>
<div class="dialog-content" style="margin:1em 0; opacity:1;"></div>
<div class="dialog-footer" style="display:flex; gap:1em; opacity:1;">
<button is="emby-button" class="emby-button button-flat btnCancel" style="flex:1; color: white !important; opacity:1;">Cancel</button>
</div>
`;
return dialog;
}
async function showRatingDialog(itemId) {
const overlay = createOverlay();
const dialog = createDialogBase('Rate on MovieNight');
const content = dialog.querySelector('.dialog-content');
content.innerHTML = `<div class="rating-grid" style="display:grid; grid-template-columns:repeat(5, 1fr); gap:0.8em;"></div>`;
const grid = content.querySelector('.rating-grid');
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
for (let i = 1; i <= 10; i++) {
const btn = document.createElement('button');
btn.type = 'button'; btn.is = 'emby-button';
btn.className = 'emby-button raised';
btn.innerText = i;
btn.style.padding = '0.8em 0';
btn.style.textAlign = 'center';
btn.style.display = 'flex';
btn.style.alignItems = 'center';
btn.style.justifyContent = 'center';
btn.style.fontSize = '1.2em';
btn.onclick = async () => { cleanup(); await submitRating(itemId, i); };
grid.appendChild(btn);
}
dialog.querySelector('.btnCancel').onclick = cleanup;
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
overlay.appendChild(dialog);
document.body.appendChild(overlay);
}
async function showAddMovieDialog() {
const overlay = createOverlay();
const dialog = createDialogBase('Add Movie');
const content = dialog.querySelector('.dialog-content');
const footer = dialog.querySelector('.dialog-footer');
content.innerHTML = `
<div class="movieNightField">
<label>Movie Title</label>
<input type="text" class="emby-input txtTitle" placeholder="e.g. Inception">
</div>
<div class="movieNightFieldRow">
<div class="movieNightField">
<label>Year</label>
<input type="number" class="emby-input txtYear" placeholder="2010">
</div>
<div class="movieNightField">
<label>IMDb ID</label>
<input type="text" class="emby-input txtImdb" placeholder="tt1375666">
</div>
</div>
<div class="movieNightField">
<label>Stream URL</label>
<input type="text" class="emby-input txtUrl" placeholder="http://...">
</div>
`;
const btnAdd = document.createElement('button');
btnAdd.className = 'emby-button raised button-submit';
btnAdd.style.flex = '2';
btnAdd.style.backgroundColor = '#0064d2';
btnAdd.innerHTML = '<span>Add Film</span>';
footer.insertBefore(btnAdd, footer.firstChild);
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
btnAdd.onclick = async () => {
const title = dialog.querySelector('.txtTitle').value;
const year = dialog.querySelector('.txtYear').value;
const imdbId = dialog.querySelector('.txtImdb').value;
const url = dialog.querySelector('.txtUrl').value;
if (!title) return;
cleanup();
await addMovie(title, url, year, imdbId);
};
dialog.querySelector('.btnCancel').onclick = cleanup;
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
overlay.appendChild(dialog);
document.body.appendChild(overlay);
dialog.querySelector('.txtTitle').focus();
}
async function showOnboardingDialog() {
const overlay = createOverlay();
const dialog = createDialogBase('Welcome to MovieNight!');
dialog.style.minWidth = '450px';
const content = dialog.querySelector('.dialog-content');
const footer = dialog.querySelector('.dialog-footer');
content.innerHTML = `
<p style="margin-bottom:1.5em; opacity:0.8; text-align:center;">Pick your preferences to get better recommendations.</p>
<div style="margin-bottom:1.5em;">
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Favorite Genres</label>
<div class="genre-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
</div>
<div style="margin-bottom:1.5em;">
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Preferred Eras</label>
<div class="era-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
</div>
<div>
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Content Types</label>
<div class="type-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
</div>
`;
const genres = ["Action", "Comedy", "Drama", "Sci-Fi", "Horror", "Thriller", "Animation", "Documentary"];
const eras = ["1980s", "1990s", "2000s", "2010s", "2020s"];
const types = ["FILM", "SERIES"];
const selections = { genres: new Set(), eras: new Set(), types: new Set() };
const createChip = (text, container, type) => {
const chip = document.createElement('div');
chip.innerText = text;
chip.style.cssText = 'padding:0.4em 1em; border-radius:2em; border:1px solid #444; cursor:pointer; font-size:0.9em; transition:all 0.2s;';
chip.onclick = () => {
if (selections[type].has(text)) {
selections[type].delete(text);
chip.style.backgroundColor = 'transparent';
chip.style.borderColor = '#444';
} else {
selections[type].add(text);
chip.style.backgroundColor = '#0064d2';
chip.style.borderColor = '#0064d2';
}
};
container.appendChild(chip);
};
genres.forEach(g => createChip(g, content.querySelector('.genre-chips'), 'genres'));
eras.forEach(e => createChip(e, content.querySelector('.era-chips'), 'eras'));
types.forEach(t => createChip(t, content.querySelector('.type-chips'), 'types'));
const btnSave = document.createElement('button');
btnSave.className = 'emby-button raised button-submit';
btnSave.style.flex = '2';
btnSave.style.backgroundColor = '#0064d2';
btnSave.innerHTML = '<span>Save & Start</span>';
footer.insertBefore(btnSave, footer.firstChild);
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
btnSave.onclick = async () => {
const payload = {
weightedGenres: Object.fromEntries([...selections.genres].map(g => [g, 5])),
eras: [...selections.eras],
contentTypes: [...selections.types]
};
cleanup();
await completeOnboarding(payload);
};
dialog.querySelector('.btnCancel').onclick = cleanup;
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
overlay.appendChild(dialog);
document.body.appendChild(overlay);
}
async function checkOnboarding() {
if (window.movieNightOnboardingChecked) return;
const userId = ApiClient.getCurrentUserId();
if (!userId) return;
window.movieNightOnboardingChecked = true;
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 = normalizeRecommendations(response);
if (recommendations && recommendations.length > 0) {
showRecommendationsDialog(recommendations);
} 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.');
}
}
function normalizeRecommendations(response) {
if (typeof response === 'string') {
return JSON.parse(response);
}
if (Array.isArray(response)) {
return response;
}
return response?.items || response?.recommendations || [];
}
function getRecommendationFilm(recommendation) {
return recommendation?.film || recommendation || {};
}
function getRecommendationTitle(recommendation) {
const film = getRecommendationFilm(recommendation);
return film.title || recommendation?.title || 'Untitled';
}
function getRecommendationItemId(recommendation) {
const film = getRecommendationFilm(recommendation);
return recommendation?.jellyfinItemId || film.jellyfinItemId;
}
function getRecommendationWatchUrl(recommendation) {
const itemId = getRecommendationItemId(recommendation);
if (recommendation?.watchUrl) return recommendation.watchUrl;
return itemId ? `${window.location.origin}/web/#/details?id=${encodeURIComponent(itemId)}` : null;
}
function getRecommendationPosterUrl(recommendation) {
const itemId = getRecommendationItemId(recommendation);
if (!itemId) return null;
if (typeof ApiClient !== 'undefined' && ApiClient.getUrl) {
return ApiClient.getUrl(`Items/${itemId}/Images/Primary`, {
fillHeight: 330,
fillWidth: 220,
quality: 90
});
}
return `/Items/${encodeURIComponent(itemId)}/Images/Primary?fillHeight=330&fillWidth=220&quality=90`;
}
function formatRecommendationScore(score) {
return typeof score === 'number' ? `${Math.round(score * 100)}% match` : '';
}
function showRecommendationsDialog(recommendations) {
const overlay = createOverlay();
const dialog = createDialogBase('MovieNight Recommendations');
dialog.style.width = 'min(760px, calc(100vw - 2em))';
dialog.style.maxHeight = 'min(760px, calc(100vh - 2em))';
dialog.style.display = 'flex';
dialog.style.flexDirection = 'column';
const content = dialog.querySelector('.dialog-content');
const footer = dialog.querySelector('.dialog-footer');
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
content.style.overflowY = 'auto';
content.style.paddingRight = '.25em';
content.style.marginBottom = '1em';
footer.querySelector('.btnCancel').textContent = 'Close';
const list = document.createElement('div');
list.className = 'movieNightRecommendationList';
content.replaceChildren(list);
recommendations.forEach((recommendation) => {
list.appendChild(createRecommendationRow(recommendation, cleanup));
});
dialog.querySelector('.btnCancel').onclick = cleanup;
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
overlay.appendChild(dialog);
document.body.appendChild(overlay);
}
function createRecommendationRow(recommendation, cleanup) {
const film = getRecommendationFilm(recommendation);
const itemId = getRecommendationItemId(recommendation);
const watchUrl = getRecommendationWatchUrl(recommendation);
const posterUrl = getRecommendationPosterUrl(recommendation);
const row = document.createElement('div');
row.className = 'movieNightRecommendation';
const poster = document.createElement('img');
poster.className = 'movieNightRecommendationPoster';
poster.alt = '';
if (posterUrl) {
poster.src = posterUrl;
}
poster.onerror = () => {
poster.removeAttribute('src');
};
const body = document.createElement('div');
body.className = 'movieNightRecommendationBody';
const header = document.createElement('div');
header.className = 'movieNightRecommendationHeader';
const titleBlock = document.createElement('div');
titleBlock.style.minWidth = '0';
const title = document.createElement('div');
title.className = 'movieNightRecommendationTitle';
title.textContent = getRecommendationTitle(recommendation);
titleBlock.appendChild(title);
const meta = [film.releaseYear, ...(film.genres || [])].filter(Boolean).join(' · ');
if (meta) {
const metaEl = document.createElement('div');
metaEl.className = 'movieNightRecommendationMeta';
metaEl.textContent = meta;
titleBlock.appendChild(metaEl);
}
const score = document.createElement('div');
score.className = 'movieNightRecommendationScore';
score.textContent = formatRecommendationScore(recommendation?.score);
header.appendChild(titleBlock);
if (score.textContent) header.appendChild(score);
body.appendChild(header);
if (film.description) {
const description = document.createElement('div');
description.className = 'movieNightRecommendationDescription';
description.textContent = film.description;
body.appendChild(description);
}
const reasons = recommendation?.reasons || [];
if (reasons.length) {
const reason = document.createElement('div');
reason.className = 'movieNightRecommendationReason';
reason.textContent = reasons.join(', ');
body.appendChild(reason);
}
const actions = document.createElement('div');
actions.className = 'movieNightRecommendationActions';
if (watchUrl) {
actions.appendChild(createRecommendationAction('Open', 'play_arrow', () => {
cleanup();
window.location.href = watchUrl;
}));
}
if (itemId) {
actions.appendChild(createRecommendationAction('Rate', 'star_rate', () => {
cleanup();
showRatingDialog(itemId);
}));
actions.appendChild(createRecommendationAction('Viewed', 'visibility', () => {
cleanup();
submitViewed(itemId);
}));
}
body.appendChild(actions);
row.appendChild(poster);
row.appendChild(body);
return row;
}
function createRecommendationAction(text, icon, onClick) {
return createTextButton(text, 'movieNightRecommendationAction', (e) => {
e.preventDefault();
e.stopPropagation();
onClick();
}, icon);
}
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 injectTimeout;
let injectInFlight = false;
let rerunAfterInject = false;
let routeRetryTimeouts = [];
const routeEventListeners = [];
const patchedHistoryMethods = [];
async function runInject() {
if (injectInFlight) {
rerunAfterInject = true;
return;
}
injectInFlight = true;
try {
await injectUI();
} catch (err) {
console.error('MovieNight UI injection failed', err);
} finally {
injectInFlight = false;
if (rerunAfterInject) {
rerunAfterInject = false;
scheduleInject();
}
}
}
function scheduleInject(delay = 100) {
if (injectTimeout) return;
injectTimeout = setTimeout(() => {
injectTimeout = null;
runInject();
}, delay);
}
function scheduleRouteInject() {
routeRetryTimeouts.forEach(clearTimeout);
routeRetryTimeouts = ROUTE_RETRY_DELAYS_MS.map((delay) => {
return setTimeout(() => runInject(), delay);
});
}
function patchHistoryMethod(name) {
const current = history[name];
const original = current?._movieNightOriginal || current;
if (typeof original !== 'function') return;
history[name] = function () {
const result = original.apply(this, arguments);
scheduleRouteInject();
return result;
};
history[name]._movieNightOriginal = original;
patchedHistoryMethods.push(name);
}
function addRouteEventListener(name) {
window.addEventListener(name, scheduleRouteInject);
routeEventListeners.push(name);
}
patchHistoryMethod('pushState');
patchHistoryMethod('replaceState');
addRouteEventListener('hashchange');
addRouteEventListener('popstate');
addRouteEventListener('pageshow');
const observer = new MutationObserver(() => scheduleInject());
observer.observe(document.body, { childList: true, subtree: true });
window.movieNightUiCleanup = () => {
observer.disconnect();
if (injectTimeout) clearTimeout(injectTimeout);
routeRetryTimeouts.forEach(clearTimeout);
routeEventListeners.forEach((name) => window.removeEventListener(name, scheduleRouteInject));
patchedHistoryMethods.forEach((name) => {
const original = history[name]?._movieNightOriginal;
if (original) history[name] = original;
});
window.movieNightUiCleanup = null;
};
scheduleRouteInject();
})();
@@ -0,0 +1,258 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.MovieNight.Services;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.MovieNight.Controllers;
/// <summary>
/// Admin endpoints for the MovieNight plugin.
/// </summary>
[ApiController]
[Route("MovieNight")]
public class MovieNightController : ControllerBase
{
private readonly MovieNightBackendClient _backendClient;
private readonly MovieNightSyncService _syncService;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightController"/> class.
/// </summary>
public MovieNightController(
MovieNightBackendClient backendClient,
MovieNightSyncService syncService)
{
_backendClient = backendClient;
_syncService = syncService;
}
/// <summary>
/// Ping endpoint for connectivity checks.
/// </summary>
[HttpGet("Ping")]
public ActionResult Ping() => Ok("Pong");
/// <summary>
/// Returns plugin status.
/// </summary>
/// <returns>Status response.</returns>
[HttpGet("Status")]
[Authorize]
public ActionResult<MovieNightPluginStatus> GetStatus()
{
var configuration = Plugin.Instance?.Configuration;
return new MovieNightPluginStatus(
Enabled: configuration?.Enabled ?? false,
BackendBaseUrl: configuration?.BackendBaseUrl ?? string.Empty,
PeriodicSyncEnabled: configuration?.EnablePeriodicSync ?? false,
PlaybackEventsEnabled: configuration?.EnablePlaybackEvents ?? false,
SyncIntervalMinutes: configuration?.SyncIntervalMinutes ?? 30);
}
/// <summary>
/// Tests backend connectivity.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Connection result.</returns>
[HttpPost("TestConnection")]
[Authorize]
public async Task<ActionResult<MovieNightConnectionResult>> TestConnection(CancellationToken cancellationToken)
{
return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Triggers backend sync.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response.</returns>
[HttpPost("Sync")]
[Authorize]
public async Task<ActionResult<string>> Sync(CancellationToken cancellationToken)
{
await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false);
return Ok("Sync triggered");
}
/// <summary>
/// Gets backend sync state.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response.</returns>
[HttpGet("SyncState")]
[Authorize]
public async Task<IActionResult> SyncState(CancellationToken cancellationToken)
{
var body = await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false);
return Content(body, "application/json");
}
/// <summary>
/// Gets recommendations for the current user.
/// </summary>
[HttpGet("Users/{userId}/Recommendations")]
[Authorize]
public async Task<IActionResult> GetRecommendations(
[FromRoute] string userId,
[FromQuery] string? contentType,
[FromQuery] string? mood,
[FromQuery] int limit = 10,
CancellationToken cancellationToken = default)
{
var body = await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false);
return Content(body, "application/json");
}
/// <summary>
/// Posts a rating for a film.
/// </summary>
[HttpPost("Users/{userId}/Ratings/Films/{filmId}")]
[Authorize]
public async Task<ActionResult> PostRating(
[FromRoute] string userId,
[FromRoute] string filmId,
[FromBody] RatingRequest request,
CancellationToken cancellationToken)
{
await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false);
return Ok();
}
/// <summary>
/// Marks a film as viewed.
/// </summary>
[HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")]
[Authorize]
public async Task<ActionResult> MarkViewed(
[FromRoute] string userId,
[FromRoute] string filmId,
[FromBody] ViewedRequest request,
CancellationToken cancellationToken)
{
await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false);
return Ok();
}
/// <summary>
/// Gets user preferences.
/// </summary>
[HttpGet("Users/{userId}/Preferences")]
[Authorize]
public async Task<IActionResult> GetPreferences(
[FromRoute] string userId,
CancellationToken cancellationToken)
{
var body = await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false);
return body is null ? NotFound() : Content(body, "application/json");
}
/// <summary>
/// Completes onboarding for a user.
/// </summary>
[HttpPost("Users/{userId}/Onboarding")]
[Authorize]
public async Task<ActionResult> CompleteOnboarding(
[FromRoute] string userId,
[FromBody] object payload,
CancellationToken cancellationToken)
{
await _backendClient.CompleteOnboardingAsync(userId, payload, cancellationToken).ConfigureAwait(false);
return Ok();
}
/// <summary>
/// Creates a new film by generating a .strm file in a folder-per-movie structure.
/// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm
/// </summary>
[HttpPost("Films")]
[Authorize]
public async Task<ActionResult> CreateFilm([FromBody] CreateFilmRequest request)
{
var config = Plugin.Instance?.Configuration;
if (config == null || string.IsNullOrWhiteSpace(config.StrmOutputPath))
{
return BadRequest("STRM output path is not configured.");
}
if (string.IsNullOrWhiteSpace(request.Title))
{
return BadRequest("Movie title is required.");
}
try
{
// Construct name: "Movie Name (Year) [imdbid-ttXXXXXXX]"
var folderName = request.Title.Trim();
if (request.Year.HasValue)
{
folderName += $" ({request.Year})";
}
if (!string.IsNullOrWhiteSpace(request.ImdbId))
{
var ttId = request.ImdbId.Trim().ToLowerInvariant();
if (!ttId.StartsWith("tt")) ttId = "tt" + ttId;
folderName += $" [imdbid-{ttId}]";
}
// Sanitize for file system
var invalidChars = Path.GetInvalidFileNameChars();
var safeFolderName = new string(folderName.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray());
var movieDirectory = Path.Combine(config.StrmOutputPath, safeFolderName);
if (!Directory.Exists(movieDirectory))
{
Directory.CreateDirectory(movieDirectory);
}
var filePath = Path.Combine(movieDirectory, $"{safeFolderName}.strm");
var strmContent = string.IsNullOrWhiteSpace(request.Url)
? "http://placeholder.url/upload_me_later"
: request.Url.Trim();
await System.IO.File.WriteAllTextAsync(filePath, strmContent).ConfigureAwait(false);
return Ok(new { FilePath = filePath, FolderName = safeFolderName });
}
catch (Exception ex)
{
return StatusCode(500, $"Failed to create film: {ex.Message}");
}
}
}
/// <summary>
/// Create film request.
/// </summary>
public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId);
/// <summary>
/// Rating request.
/// </summary>
public sealed record RatingRequest(int Score, string? Note);
/// <summary>
/// Viewed request.
/// </summary>
public sealed record ViewedRequest(DateTimeOffset? WatchedAt);
/// <summary>
/// MovieNight plugin status response.
/// </summary>
/// <param name="Enabled">Whether integration is enabled.</param>
/// <param name="BackendBaseUrl">Backend base URL.</param>
/// <param name="PeriodicSyncEnabled">Whether periodic sync is enabled.</param>
/// <param name="PlaybackEventsEnabled">Whether playback events are enabled.</param>
/// <param name="SyncIntervalMinutes">Sync interval in minutes.</param>
public sealed record MovieNightPluginStatus(
bool Enabled,
string BackendBaseUrl,
bool PeriodicSyncEnabled,
bool PlaybackEventsEnabled,
int SyncIntervalMinutes);
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.MovieNight</RootNamespace>
<AssemblyName>Jellyfin.Plugin.MovieNight</AssemblyName>
<Version>1.0.0.1</Version>
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Jellyfin.Common" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Controller" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<None Remove="Configuration\config.js" />
<None Remove="Configuration\ui.js" />
<EmbeddedResource Include="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\config.js" />
<EmbeddedResource Include="Configuration\ui.js" />
</ItemGroup>
</Project>
@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.MovieNight.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.MovieNight;
/// <summary>
/// MovieNight Jellyfin plugin.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Application paths.</param>
/// <param name="xmlSerializer">XML serializer.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <inheritdoc />
public override string Name => "MovieNight";
/// <inheritdoc />
public override Guid Id => Guid.Parse("42c72919-d6ff-4f62-bb8c-0fac39efafdb");
/// <inheritdoc />
public override string Description => "Bridges Jellyfin playback and sync signals to the MovieNight backend.";
/// <summary>
/// Gets the current plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
return
[
new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.configPage.html",
GetType().Namespace)
},
new PluginPageInfo
{
Name = Name + ".js",
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.config.js",
GetType().Namespace)
},
new PluginPageInfo
{
Name = Name + ".ui.js",
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.ui.js",
GetType().Namespace)
}
];
}
}
@@ -0,0 +1,21 @@
using Jellyfin.Plugin.MovieNight.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.MovieNight;
/// <summary>
/// Registers MovieNight services with Jellyfin.
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<MovieNightBackendClient>();
serviceCollection.AddSingleton<MovieNightSyncService>();
serviceCollection.AddHostedService<MovieNightPeriodicSyncService>();
serviceCollection.AddHostedService<MovieNightPlaybackEventService>();
}
}
@@ -0,0 +1,323 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MovieNight.Services;
/// <summary>
/// Thin HTTP client for the MovieNight backend.
/// </summary>
public class MovieNightBackendClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly ILogger<MovieNightBackendClient> _logger;
private readonly HttpClient _httpClient;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightBackendClient"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public MovieNightBackendClient(ILogger<MovieNightBackendClient> logger)
{
_logger = logger;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
}
/// <summary>
/// Calls backend health.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Connection result.</returns>
public async Task<MovieNightConnectionResult> TestConnectionAsync(CancellationToken cancellationToken)
{
var payload = new MovieNightEventPayload(
EventId: $"plugin-test:{Guid.NewGuid():N}",
EventType: "plugin.test",
OccurredAt: DateTimeOffset.UtcNow,
JellyfinUserId: "movienight-plugin-test-user",
ItemId: "movienight-plugin-test-item",
PayloadVersion: 1,
Payload: new Dictionary<string, object?>
{
["source"] = "config-test"
});
var request = CreateEventRequest(payload);
if (request is null)
{
return MovieNightConnectionResult.Failed("Plugin is not configured.");
}
try
{
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode
? MovieNightConnectionResult.Ok()
: MovieNightConnectionResult.Failed($"Backend event endpoint returned {(int)response.StatusCode}.");
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
_logger.LogWarning(ex, "MovieNight connection test failed");
return MovieNightConnectionResult.Failed(ex.Message);
}
}
/// <summary>
/// Pushes library sync data to the backend.
/// </summary>
/// <param name="payload">Sync payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response body.</returns>
public async Task<string> SyncAsync(object payload, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/sync");
if (request is null)
{
return "Plugin is not configured.";
}
request.Content = JsonContent.Create(payload, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return body;
}
/// <summary>
/// Gets recommendations for a user.
/// </summary>
public async Task<string> GetRecommendationsAsync(string userId, string? contentType, string? mood, int limit, CancellationToken cancellationToken)
{
userId = NormalizeJellyfinId(userId);
var query = $"?limit={limit}";
if (!string.IsNullOrEmpty(contentType)) query += $"&contentType={Uri.EscapeDataString(contentType)}";
if (!string.IsNullOrEmpty(mood)) query += $"&mood={Uri.EscapeDataString(mood)}";
var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/recommendations{query}");
if (request is null) return "Plugin is not configured.";
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return body;
}
/// <summary>
/// Posts a rating for a film.
/// </summary>
public async Task PostRatingAsync(string userId, string filmId, int score, string? note, CancellationToken cancellationToken)
{
userId = NormalizeJellyfinId(userId);
filmId = NormalizeJellyfinId(filmId);
var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/ratings/items/{filmId}");
if (request is null) return;
request.Content = JsonContent.Create(new { score, note }, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Gets ratings for a user.
/// </summary>
public async Task<string> GetRatingsAsync(string userId, CancellationToken cancellationToken)
{
userId = NormalizeJellyfinId(userId);
var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/ratings");
if (request is null) return "[]";
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return body;
}
/// <summary>
/// Marks a film as viewed.
/// </summary>
public async Task MarkViewedAsync(string userId, string filmId, DateTimeOffset? watchedAt, CancellationToken cancellationToken)
{
userId = NormalizeJellyfinId(userId);
filmId = NormalizeJellyfinId(filmId);
var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/library/items/{filmId}/viewed");
if (request is null) return;
request.Content = JsonContent.Create(new { watchedAt }, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Reads backend sync state.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response body.</returns>
public async Task<string> GetSyncStateAsync(CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Get, "/api/integrations/jellyfin/sync-state");
if (request is null)
{
return "Plugin is not configured.";
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return body;
}
/// <summary>
/// Gets user preferences.
/// </summary>
public async Task<string?> GetPreferencesAsync(string userId, CancellationToken cancellationToken)
{
userId = NormalizeJellyfinId(userId);
var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/preferences");
if (request is null) return null;
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return body;
}
/// <summary>
/// Completes onboarding for a user.
/// </summary>
public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken)
{
userId = NormalizeJellyfinId(userId);
var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/recommendation-onboarding");
if (request is null) return;
request.Content = JsonContent.Create(payload, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Pushes an event payload to the backend event endpoint.
/// </summary>
/// <param name="payload">Event payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task.</returns>
public async Task PushEventAsync(MovieNightEventPayload payload, CancellationToken cancellationToken)
{
for (var attempt = 1; attempt <= 3; attempt++)
{
var request = CreateEventRequest(payload);
if (request is null)
{
return;
}
try
{
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return;
}
if ((int)response.StatusCode == 401)
{
_logger.LogWarning("MovieNight event push was rejected with 401 Unauthorized");
return;
}
_logger.LogDebug("MovieNight event push returned status {StatusCode}", response.StatusCode);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
_logger.LogDebug(ex, "MovieNight event push attempt {Attempt} failed", attempt);
}
if (attempt < 3)
{
await Task.Delay(TimeSpan.FromSeconds(attempt * 2), cancellationToken).ConfigureAwait(false);
}
}
}
private static HttpRequestMessage? CreateEventRequest(MovieNightEventPayload payload)
{
var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/events");
if (request is null)
{
return null;
}
request.Content = JsonContent.Create(payload, options: JsonOptions);
return request;
}
private static string? GetBaseUrl()
{
var value = Plugin.Instance?.Configuration.BackendBaseUrl?.Trim();
return string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/');
}
private static string NormalizeJellyfinId(string value)
{
return Guid.TryParse(value, out var guid) ? guid.ToString("N") : value;
}
private static bool IsEnabled()
{
var configuration = Plugin.Instance?.Configuration;
return configuration is { Enabled: true } && !string.IsNullOrWhiteSpace(configuration.BackendBaseUrl);
}
private static HttpRequestMessage? CreateRequest(HttpMethod method, string path)
{
if (!IsEnabled())
{
return null;
}
var baseUrl = GetBaseUrl();
if (baseUrl is null)
{
return null;
}
var request = new HttpRequestMessage(method, new Uri(baseUrl + path));
var token = Plugin.Instance?.Configuration.ApiToken;
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Add("X-MovieNight-Plugin-Token", token);
}
return request;
}
}
/// <summary>
/// Backend connection result.
/// </summary>
/// <param name="Success">Whether the call succeeded.</param>
/// <param name="Message">Result message.</param>
public sealed record MovieNightConnectionResult(bool Success, string Message)
{
/// <summary>
/// Creates a successful result.
/// </summary>
/// <returns>Connection result.</returns>
public static MovieNightConnectionResult Ok() => new(true, "OK");
/// <summary>
/// Creates a failed result.
/// </summary>
/// <param name="message">Failure message.</param>
/// <returns>Connection result.</returns>
public static MovieNightConnectionResult Failed(string message) => new(false, message);
}
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.MovieNight.Services;
/// <summary>
/// Event payload sent to MovieNight.
/// </summary>
/// <param name="EventId">Idempotency key.</param>
/// <param name="EventType">Event type.</param>
/// <param name="OccurredAt">Event timestamp.</param>
/// <param name="JellyfinUserId">Jellyfin user id.</param>
/// <param name="ItemId">Jellyfin item id.</param>
/// <param name="PayloadVersion">Payload version.</param>
/// <param name="Payload">Extra event data.</param>
public sealed record MovieNightEventPayload(
[property: JsonPropertyName("event_id")]
string EventId,
[property: JsonPropertyName("event_type")]
string EventType,
[property: JsonPropertyName("occurred_at")]
DateTimeOffset OccurredAt,
[property: JsonPropertyName("jellyfin_user_id")]
string JellyfinUserId,
[property: JsonPropertyName("item_id")]
string ItemId,
[property: JsonPropertyName("payload_version")]
int PayloadVersion,
[property: JsonPropertyName("payload")]
IReadOnlyDictionary<string, object?> Payload);
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Jellyfin.Data.Enums;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MovieNight.Services;
/// <summary>
/// Periodically asks MovieNight to run its current Jellyfin sync.
/// </summary>
public sealed class MovieNightPeriodicSyncService : BackgroundService
{
private readonly MovieNightSyncService _syncService;
private readonly ILogger<MovieNightPeriodicSyncService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightPeriodicSyncService"/> class.
/// </summary>
public MovieNightPeriodicSyncService(
MovieNightSyncService syncService,
ILogger<MovieNightPeriodicSyncService> logger)
{
_syncService = syncService;
_logger = logger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var delay = GetDelay();
try
{
await Task.Delay(delay, stoppingToken).ConfigureAwait(false);
if (!ShouldRun())
{
continue;
}
await _syncService.PerformSyncAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MovieNight periodic sync failed");
}
}
}
private static bool ShouldRun()
{
var configuration = Plugin.Instance?.Configuration;
return configuration is { Enabled: true, EnablePeriodicSync: true };
}
private static TimeSpan GetDelay()
{
var minutes = Plugin.Instance?.Configuration.SyncIntervalMinutes ?? 30;
return TimeSpan.FromMinutes(Math.Clamp(minutes, 1, 1440));
}
}
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MovieNight.Services;
/// <summary>
/// Subscribes to Jellyfin playback events and forwards thin payloads.
/// </summary>
public sealed class MovieNightPlaybackEventService : IHostedService
{
private readonly ISessionManager _sessionManager;
private readonly MovieNightBackendClient _backendClient;
private readonly ILogger<MovieNightPlaybackEventService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightPlaybackEventService"/> class.
/// </summary>
/// <param name="sessionManager">Jellyfin session manager.</param>
/// <param name="backendClient">Backend client.</param>
/// <param name="logger">Logger.</param>
public MovieNightPlaybackEventService(
ISessionManager sessionManager,
MovieNightBackendClient backendClient,
ILogger<MovieNightPlaybackEventService> logger)
{
_sessionManager = sessionManager;
_backendClient = backendClient;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_sessionManager.PlaybackStopped += OnPlaybackStopped;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
return Task.CompletedTask;
}
private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e)
{
if (Plugin.Instance?.Configuration is not { Enabled: true, EnablePlaybackEvents: true })
{
return;
}
if (!e.PlayedToCompletion)
{
return;
}
var userId = e.Users?.FirstOrDefault()?.Id.ToString("N");
var itemId = e.Item?.Id.ToString("N");
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(itemId))
{
return;
}
var occurredAt = DateTimeOffset.UtcNow;
var eventId = string.IsNullOrWhiteSpace(e.PlaySessionId)
? $"playback-stopped:{userId}:{itemId}:{occurredAt.ToUnixTimeMilliseconds()}"
: $"playback-stopped:{userId}:{itemId}:{e.PlaySessionId}";
var payload = new MovieNightEventPayload(
EventId: eventId,
EventType: "playback.stopped",
OccurredAt: occurredAt,
JellyfinUserId: userId,
ItemId: itemId,
PayloadVersion: 1,
Payload: new Dictionary<string, object?>
{
["itemName"] = e.Item?.Name,
["playSessionId"] = e.PlaySessionId,
["positionTicks"] = e.PlaybackPositionTicks,
["playedToCompletion"] = e.PlayedToCompletion
});
_ = Task.Run(
async () =>
{
try
{
await _backendClient.PushEventAsync(payload, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "MovieNight playback event push failed");
}
});
}
}
@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Jellyfin.Data.Enums;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MovieNight.Services;
/// <summary>
/// Service for synchronizing the Jellyfin library with MovieNight.
/// </summary>
public class MovieNightSyncService
{
private readonly MovieNightBackendClient _backendClient;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IUserDataManager _userDataManager;
private readonly ILogger<MovieNightSyncService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightSyncService"/> class.
/// </summary>
public MovieNightSyncService(
MovieNightBackendClient backendClient,
ILibraryManager libraryManager,
IUserManager userManager,
IUserDataManager userDataManager,
ILogger<MovieNightSyncService> logger)
{
_backendClient = backendClient;
_libraryManager = libraryManager;
_userManager = userManager;
_userDataManager = userDataManager;
_logger = logger;
}
/// <summary>
/// Performs a full library sync.
/// </summary>
public async Task PerformSyncAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting MovieNight library sync");
var config = Plugin.Instance?.Configuration;
var enabledLibraryIds = config?.EnabledLibraryIds ?? new List<string>();
var query = new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Movie },
Recursive = true
};
if (enabledLibraryIds.Count > 0)
{
query.AncestorIds = enabledLibraryIds.Select(Guid.Parse).ToArray();
}
var items = _libraryManager.GetItemList(query);
var users = _userManager.Users;
var syncUsers = users.Select(u => new
{
jellyfinUserId = u.Id.ToString("N"),
name = u.Username
}).ToList();
var syncItems = new List<object>();
foreach (var item in items)
{
if (item is not Movie movie) continue;
var jellyfinItemId = movie.Id.ToString("N");
var title = string.IsNullOrWhiteSpace(movie.Name) ? jellyfinItemId : movie.Name;
var itemData = new Dictionary<string, object?>
{
["jellyfinItemId"] = jellyfinItemId,
["title"] = title,
["originalTitle"] = movie.OriginalTitle,
["description"] = movie.Overview,
["year"] = movie.ProductionYear,
["duration"] = movie.RunTimeTicks,
["genres"] = movie.Genres,
["posterUrl"] = $"/Items/{jellyfinItemId}/Images/Primary",
["imdbId"] = movie.GetProviderId(MetadataProvider.Imdb),
["tmdbId"] = movie.GetProviderId(MetadataProvider.Tmdb),
["userStates"] = users.Select(u => {
var userData = _userDataManager.GetUserData(u, movie);
return new {
jellyfinUserId = u.Id.ToString("N"),
isViewed = userData?.Played ?? false,
playCount = userData?.PlayCount ?? 0,
lastPlayedAt = userData?.LastPlayedDate,
userRating = userData?.Rating
};
}).ToList()
};
syncItems.Add(itemData);
}
await _backendClient.SyncAsync(new { users = syncUsers, items = syncItems }, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("MovieNight library sync completed");
}
}
+64
View File
@@ -0,0 +1,64 @@
# MovieNight Jellyfin Plugin
Thin Jellyfin server plugin for bridging Jellyfin playback/sync signals to the MovieNight backend.
## Build
```bash
cd plugins/jellyfin/Jellyfin.Plugin.MovieNight
dotnet publish -c Release
```
Install the published `net9.0` plugin files into the Jellyfin data directory under `plugins/MovieNight/`, then restart Jellyfin. This build targets Jellyfin `10.11.x`.
## Backend Contract Used
Current implemented calls:
- `POST /api/integrations/jellyfin/sync`
- `GET /api/integrations/jellyfin/sync-state`
- `POST /api/integrations/jellyfin/events`
- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendations`
- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/ratings/items/{jellyfin_item_id}`
- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/library/items/{jellyfin_item_id}/viewed`
- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/preferences`
- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendation-onboarding`
Configure the backend with:
- `JELLYFIN_INTEGRATION_ENABLED=true`
- `JELLYFIN_PLUGIN_TOKEN=<same token configured in the plugin>`
- `JELLYFIN_WEB_URL=<browser URL of Jellyfin, used for recommendation watch links>`
`JELLYFIN_SYNC_ENABLED=true` is still accepted as a legacy alias for `JELLYFIN_INTEGRATION_ENABLED=true`.
Optional backend-pull sync values:
- `JELLYFIN_BASE_URL=<backend-reachable Jellyfin server URL>`
- `JELLYFIN_API_KEY=<Jellyfin API key>`
The Jellyfin API key is only for backend-to-Jellyfin calls. The plugin token is a MovieNight shared secret for plugin-to-backend calls.
Configure the plugin with:
- Backend URL: MovieNight backend URL reachable from the Jellyfin server, for example `http://movienight-backend:8080`
- Plugin token: the exact `JELLYFIN_PLUGIN_TOKEN` value
- Enable MovieNight integration: checked
- Enable periodic backend sync: checked if the plugin should push library state on an interval
- Send playback stop events: checked if completed playback should mark films viewed in MovieNight
Event requests use JSON with:
- `event_id`
- `event_type`
- `occurred_at`
- `jellyfin_user_id`
- `item_id`
- `payload_version`
- `payload`
The plugin sends `X-MovieNight-Plugin-Token` when configured. Completed Jellyfin playback stop events are sent as `playback.stopped`. Transient event push failures are retried three times with the same `event_id`.
Sync requests push Jellyfin users, items, and per-user watched states to the backend. The backend creates MovieNight users for new Jellyfin users using their Jellyfin id as the stable mapping key, upserts films by `jellyfinItemId`, and uses the Jellyfin-facing endpoints above for UI actions so Jellyfin ids do not have to match MovieNight UUIDs. Run "Sync Library" once after installing/configuring the plugin so recommendations, rating, and viewed actions can resolve Jellyfin items.
The config page test action posts a small `plugin.test` event to `/api/integrations/jellyfin/events` and treats `200` as success and `401` as token/config failure.
+14
View File
@@ -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.
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
import os
import sys
import urllib.request
import argparse
import logging
import re
import random
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
DEFAULT_DATASET_URL = "https://raw.githubusercontent.com/sidooms/MovieTweetings/master/latest/movies.dat"
DEFAULT_OUTPUT_DIR = "./Jellyfin_Movies"
DEFAULT_COUNT = 1000
def download_dataset(url):
logger.info(f"Downloading dataset from {url}...")
try:
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req) as response:
data = response.read().decode('utf-8')
logger.info("Dataset downloaded successfully.")
return data.splitlines()
except Exception as e:
logger.error(f"Failed to download dataset: {e}")
sys.exit(1)
def parse_movies(lines):
"""
Parse the movies.dat file.
Format: IMDbID::Title (Year)::Genres
Example: 0000008::Edison Kinetoscopic Record of a Sneeze (1894)::Documentary|Short
"""
movies = []
# Regex to extract Title and Year from "Title (Year)"
title_year_pattern = re.compile(r'(.*)\s+\((\d{4})\)$')
for line in lines:
line = line.strip()
if not line:
continue
parts = line.split('::')
if len(parts) >= 2:
imdb_id_raw = parts[0]
title_year_raw = parts[1]
# Format IMDb ID to ttXXXXXXX
if imdb_id_raw.isdigit():
imdb_id = f"tt{imdb_id_raw.zfill(7)}"
else:
continue
match = title_year_pattern.match(title_year_raw)
if match:
title = match.group(1).strip()
year = match.group(2)
# Clean title for filesystem (remove invalid characters)
safe_title = re.sub(r'[\\/*?:"<>|]', "", title)
safe_title = safe_title.strip()
if safe_title:
movies.append({
'imdb_id': imdb_id,
'title': safe_title,
'year': year
})
logger.info(f"Parsed {len(movies)} valid movies from dataset.")
return movies
def create_dummy_video(filepath):
"""Create a minimal valid dummy video file (mp4)."""
try:
mp4_header = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom\x00\x00\x00\x00moov\x00\x00\x00\x08mvhd"
with open(filepath, 'wb') as f:
f.write(mp4_header)
return True
except Exception as e:
logger.error(f"Failed to create dummy video {filepath}: {e}")
return False
def generate_library(movies, output_dir, count):
"""Generate the folder structure and dummy files."""
if not os.path.exists(output_dir):
os.makedirs(output_dir)
logger.info(f"Created output directory: {output_dir}")
generated_imdb_ids = set()
created_count = 0
skipped_count = 0
failed_count = 0
logger.info(f"Starting generation of up to {count} movies...")
# Shuffle to get diverse movies
random.shuffle(movies)
for movie in movies:
if created_count >= count:
break
if movie['imdb_id'] in generated_imdb_ids:
skipped_count += 1
continue
# Jellyfin naming convention: Movie Name (year) [imdbid-tt1234567]
folder_name = f"{movie['title']} ({movie['year']}) [imdbid-{movie['imdb_id']}]"
folder_path = os.path.join(output_dir, folder_name)
file_name = f"{folder_name}.mp4"
file_path = os.path.join(folder_path, file_name)
if os.path.exists(file_path):
skipped_count += 1
generated_imdb_ids.add(movie['imdb_id'])
continue
try:
os.makedirs(folder_path, exist_ok=True)
if create_dummy_video(file_path):
created_count += 1
generated_imdb_ids.add(movie['imdb_id'])
else:
failed_count += 1
except Exception as e:
logger.error(f"Error processing {folder_name}: {e}")
failed_count += 1
logger.info("--- Generation Summary ---")
logger.info(f"Target count: {count}")
logger.info(f"Successfully created: {created_count}")
logger.info(f"Skipped (already exists or duplicate): {skipped_count}")
logger.info(f"Failed: {failed_count}")
return created_count > 0
def main():
parser = argparse.ArgumentParser(description="Generate a dummy Jellyfin movie library.")
parser.add_argument("--output-dir", type=str, default=DEFAULT_OUTPUT_DIR,
help=f"Directory to create the library in (default: {DEFAULT_OUTPUT_DIR})")
parser.add_argument("--count", type=int, default=DEFAULT_COUNT,
help=f"Number of movies to generate (default: {DEFAULT_COUNT})")
parser.add_argument("--dataset-url", type=str, default=DEFAULT_DATASET_URL,
help="URL to the movies.dat file")
args = parser.parse_args()
lines = download_dataset(args.dataset_url)
if not lines:
logger.error("No dataset lines to process.")
sys.exit(1)
movies = parse_movies(lines)
if not movies:
logger.error("No movies parsed from the dataset.")
sys.exit(1)
if len(movies) < args.count:
logger.warning(f"Requested {args.count} movies, but only {len(movies)} available.")
args.count = len(movies)
success = generate_library(movies, args.output_dir, args.count)
if success:
logger.info(f"Library generation complete. You can now mount '{os.path.abspath(args.output_dir)}' into Jellyfin.")
else:
logger.error("Library generation failed.")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -3,8 +3,10 @@ package com.project.movienight
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan("com.project.movienight.config")
class MovieNightApplication
@@ -0,0 +1,125 @@
package com.project.movienight.adapters.jellyfin
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.project.movienight.application.ports.output.JellyfinCatalogPort
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
import com.project.movienight.application.ports.output.JellyfinRemoteUser
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType
import org.springframework.stereotype.Service
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
@Service
class JellyfinApiClient(
private val properties: JellyfinIntegrationProperties,
private val objectMapper: ObjectMapper,
) : JellyfinCatalogPort {
private val httpClient: HttpClient =
HttpClient
.newBuilder()
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
.build()
override fun fetchUsers(): List<JellyfinRemoteUser> =
request("Users")
.asItems()
.mapNotNull { node ->
val id = node.fieldText("Id") ?: return@mapNotNull null
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
}
override fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
@Suppress("MaxLineLength")
request(
"Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData",
).asItems().mapNotNull { node ->
val itemId = node.fieldText("Id") ?: return@mapNotNull null
val providerIds = node["ProviderIds"]
val imdbId = providerIds?.fieldText("Imdb")
val people = node["People"]
val cast = people?.peopleByType("Actor", "GuestStar") ?: emptyList()
val directors = people?.peopleByType("Director") ?: emptyList()
JellyfinLibraryItemSnapshot(
jellyfinItemId = itemId,
title = node.fieldText("Name") ?: itemId,
description = node.fieldText("Overview") ?: "",
contentType = mapContentType(node.fieldText("Type")),
releaseYear = node["ProductionYear"]?.takeUnless { it.isNull }?.asInt(),
genres = node["Genres"]?.textList() ?: emptyList(),
cast = cast,
directors = directors,
platformRating = node["CommunityRating"]?.takeUnless { it.isNull }?.asDouble(),
imdbRating = null,
externalUrl = imdbId?.let { "https://www.imdb.com/title/$it/" },
jellyfinLibraryId = node.fieldText("ParentId"),
isPlayed =
node["UserData"]?.booleanField("Played") ?: node["UserData"]?.booleanField("IsPlayed") ?: false,
)
}
private fun request(path: String): JsonNode {
val uri = URI.create("${properties.baseUrl.trimEnd('/')}/$path")
val request =
HttpRequest
.newBuilder(uri)
.timeout(Duration.ofMillis(properties.requestTimeoutMs))
.header("Accept", "application/json")
.header("X-Emby-Token", properties.apiKey)
.GET()
.build()
val response =
try {
httpClient.send(request, HttpResponse.BodyHandlers.ofString())
} catch (
@Suppress("TooGenericExceptionCaught") exception: Exception,
) {
throw IllegalStateException("Failed to call Jellyfin at $uri", exception)
}
check(response.statusCode() in 200..299) {
"Jellyfin request failed with status ${response.statusCode()} for $uri"
}
return objectMapper.readTree(response.body())
}
private fun JsonNode.asItems(): List<JsonNode> =
when {
isArray -> map { it }
has("Items") && this["Items"].isArray -> this["Items"].map { it }
else -> emptyList()
}
private fun JsonNode.fieldText(name: String): String? =
get(name)?.takeUnless { it.isNull }?.asText()?.takeIf { it.isNotBlank() }
private fun JsonNode.booleanField(name: String): Boolean? = get(name)?.takeUnless { it.isNull }?.asBoolean()
private fun JsonNode.textList(): List<String> =
takeIf { it.isArray }?.mapNotNull { item ->
item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() }
}
?: emptyList()
private fun JsonNode.peopleByType(vararg types: String): List<String> {
if (!isArray) return emptyList()
return mapNotNull { person ->
val type = person.fieldText("Type") ?: return@mapNotNull null
if (types.any { it.equals(type, ignoreCase = true) }) person.fieldText("Name") else null
}
}
private fun mapContentType(value: String?): ContentType =
when (value?.lowercase()) {
"movie" -> ContentType.FILM
"series" -> ContentType.SERIES
"episode" -> ContentType.EPISODE
else -> ContentType.OTHER
}
}
@@ -0,0 +1,96 @@
package com.project.movienight.adapters.metrics
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.domain.model.JellyfinSyncSummary
import com.project.movienight.domain.model.RecommendationEventType
import io.micrometer.core.instrument.Counter
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Timer
import org.springframework.stereotype.Service
import java.util.concurrent.atomic.AtomicInteger
@Service
class BusinessMetricsService(
private val meterRegistry: MeterRegistry,
) : BusinessMetricsPort {
private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total")
private val filmsCreated: Counter = meterRegistry.counter("business_films_created_total")
private val filmsEdited: Counter = meterRegistry.counter("business_films_edited_total")
private val filmsDeleted: Counter = meterRegistry.counter("business_films_deleted_total")
private val filmsBlocked: Counter = meterRegistry.counter("business_films_blocked_total")
private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total")
private val libraryEvents: Counter = meterRegistry.counter("business_library_events_total")
private val jellyfinSyncRuns: Counter = meterRegistry.counter("business_jellyfin_sync_runs_total")
private val jellyfinSyncedUsers: Counter = meterRegistry.counter("business_jellyfin_synced_users_total")
private val jellyfinSkippedUsers: Counter = meterRegistry.counter("business_jellyfin_skipped_users_total")
private val jellyfinSyncedItems: Counter = meterRegistry.counter("business_jellyfin_synced_items_total")
private val jellyfinSyncDuration: Timer =
Timer
.builder("business_jellyfin_sync_duration")
.publishPercentileHistogram()
.register(meterRegistry)
private val jellyfinSyncFailures: Counter = meterRegistry.counter("business_jellyfin_sync_failures_total")
private val jellyfinUnmappedUsersGaugeValue = AtomicInteger(0)
init {
meterRegistry.gauge("business_jellyfin_unmapped_users", jellyfinUnmappedUsersGaugeValue)
}
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
override fun recordFilmCreated() {
filmsCreated.increment()
}
override fun recordFilmEdited() {
filmsEdited.increment()
}
override fun recordFilmDeleted() {
filmsDeleted.increment()
}
override fun recordFilmBlocked() {
filmsBlocked.increment()
}
override fun recordRecommendationRequest() {
recommendationRequests.increment()
}
override fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
Counter
.builder("recommendation_weights_updated_total")
.tag("eventType", eventType.name)
.register(meterRegistry)
.increment()
}
override fun recordRatingSubmitted() {
ratingsSubmitted.increment()
}
override fun recordLibraryEvent() {
libraryEvents.increment()
}
override fun recordJellyfinSync(summary: JellyfinSyncSummary) {
jellyfinSyncRuns.increment()
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
jellyfinSyncedItems.increment(summary.syncedItems.toDouble())
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
}
override fun recordJellyfinSyncFailure() {
jellyfinSyncFailures.increment()
}
override fun recordJellyfinUnmappedUser() {
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
}
override fun recordBackendWriteFailure() {
backendWriteFailures.increment()
}
}
@@ -0,0 +1,37 @@
package com.project.movienight.adapters.persistence.entity
import com.project.movienight.domain.model.FilmRating
import java.time.LocalDateTime
import java.util.UUID
data class FilmRatingEntity(
val id: UUID,
val userId: UUID,
val filmId: UUID,
val score: Int,
val note: String?,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
)
fun FilmRatingEntity.toDomain(): FilmRating =
FilmRating(
id = id,
userId = userId,
filmId = filmId,
score = score,
note = note,
createdAt = createdAt,
updatedAt = updatedAt,
)
fun FilmRating.toEntity(): FilmRatingEntity =
FilmRatingEntity(
id = id,
userId = userId,
filmId = filmId,
score = score,
note = note,
createdAt = createdAt,
updatedAt = updatedAt,
)
@@ -0,0 +1,31 @@
package com.project.movienight.adapters.persistence.entity
import com.project.movienight.domain.model.JellyfinSyncState
import java.time.LocalDateTime
import java.util.UUID
data class JellyfinSyncStateEntity(
val userId: UUID,
val lastSyncedAt: LocalDateTime?,
val lastSuccessfulSyncAt: LocalDateTime?,
val lastError: String?,
val syncedItemCount: Int,
)
fun JellyfinSyncStateEntity.toDomain(): JellyfinSyncState =
JellyfinSyncState(
userId = userId,
lastSyncedAt = lastSyncedAt,
lastSuccessfulSyncAt = lastSuccessfulSyncAt,
lastError = lastError,
syncedItemCount = syncedItemCount,
)
fun JellyfinSyncState.toEntity(): JellyfinSyncStateEntity =
JellyfinSyncStateEntity(
userId = userId,
lastSyncedAt = lastSyncedAt,
lastSuccessfulSyncAt = lastSuccessfulSyncAt,
lastError = lastError,
syncedItemCount = syncedItemCount,
)
@@ -0,0 +1,40 @@
package com.project.movienight.adapters.persistence.entity
import com.project.movienight.domain.model.AuthProvider
import com.project.movienight.domain.model.User
import java.time.LocalDateTime
import java.util.UUID
data class UserEntity(
val id: UUID,
val name: String,
val email: String,
val provider: String?,
val providerId: String?,
val jellyfinUserId: String?,
val createdAt: LocalDateTime,
)
fun UserEntity.toDomain(): User =
User(
id = id,
name = name,
email = email,
preferences = null,
jellyfinUserId = jellyfinUserId,
)
fun User.toEntity(
provider: AuthProvider? = null,
providerId: String? = null,
createdAt: LocalDateTime = LocalDateTime.now(),
): UserEntity =
UserEntity(
id = id,
name = name,
email = email,
provider = provider?.name,
providerId = providerId,
jellyfinUserId = jellyfinUserId,
createdAt = createdAt,
)
@@ -0,0 +1,41 @@
package com.project.movienight.adapters.persistence.entity
import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.UserPreferences
import java.util.UUID
data class UserPreferencesEntity(
val userId: UUID,
val weightedGenres: String,
val plotTypes: String,
val eras: String,
val castAndDirectors: String,
val moods: String,
val contentTypes: String,
)
fun UserPreferencesEntity.toDomain(): UserPreferences =
UserPreferences(
userId = userId,
weightedGenres = DelimitedValueCodec.decodeWeightedMap(weightedGenres),
plotTypes = DelimitedValueCodec.decodeList(plotTypes),
eras = DelimitedValueCodec.decodeList(eras),
castAndDirectors = DelimitedValueCodec.decodeList(castAndDirectors),
moods = DelimitedValueCodec.decodeList(moods),
contentTypes =
DelimitedValueCodec.decodeList(contentTypes).mapNotNull { value ->
runCatching { ContentType.valueOf(value) }.getOrNull()
},
)
fun UserPreferences.toEntity(): UserPreferencesEntity =
UserPreferencesEntity(
userId = userId,
weightedGenres = DelimitedValueCodec.encodeWeightedMap(weightedGenres),
plotTypes = DelimitedValueCodec.encodeList(plotTypes),
eras = DelimitedValueCodec.encodeList(eras),
castAndDirectors = DelimitedValueCodec.encodeList(castAndDirectors),
moods = DelimitedValueCodec.encodeList(moods),
contentTypes = DelimitedValueCodec.encodeList(contentTypes.map { it.name }),
)
@@ -0,0 +1,96 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
import com.project.movienight.domain.model.FilmLibraryEntry
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.util.UUID
@Repository
class FilmLibraryEntryRepository(
private val jdbc: JdbcTemplate,
) : FilmLibraryEntryRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
FilmLibraryEntry(
id = UUID.fromString(rs.getString("id")),
userId = UUID.fromString(rs.getString("user_id")),
filmId = UUID.fromString(rs.getString("film_id")),
comment = rs.getString("comment"),
isViewed = rs.getBoolean("is_viewed"),
watchedAt = rs.getTimestamp("watched_at")?.toLocalDateTime(),
)
}
override fun save(entry: FilmLibraryEntry): FilmLibraryEntry {
val updatedRows =
jdbc.update(
"""
UPDATE favorites
SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?, watched_at = ?
WHERE id = ?
""".trimIndent(),
entry.userId,
entry.filmId,
entry.comment,
entry.isViewed,
entry.watchedAt,
entry.id,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO favorites (id, user_id, film_id, comment, is_viewed, watched_at)
VALUES (?, ?, ?, ?, ?, ?)
""".trimIndent(),
entry.id,
entry.userId,
entry.filmId,
entry.comment,
entry.isViewed,
entry.watchedAt,
)
}
return entry
}
override fun findById(id: UUID): FilmLibraryEntry? =
jdbc
.query(
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE id = ?",
rowMapper,
id,
).firstOrNull()
override fun findByUserId(userId: UUID): List<FilmLibraryEntry> =
jdbc.query(
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ?",
rowMapper,
userId,
)
override fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmLibraryEntry? =
jdbc
.query(
"""
SELECT id, user_id, film_id, comment, is_viewed, watched_at
FROM favorites WHERE user_id = ? AND film_id = ?
""".trimIndent(),
rowMapper,
userId,
filmId,
).firstOrNull()
override fun findAll(): List<FilmLibraryEntry> =
jdbc.query(
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites",
rowMapper,
)
override fun deleteById(id: UUID) {
jdbc.update("DELETE FROM favorites WHERE id = ?", id)
}
}
@@ -1,73 +0,0 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.domain.model.FilmLibrary
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.util.UUID
@Repository
class FilmLibraryRepository(
private val jdbc: JdbcTemplate,
) : FilmLibraryRepositoryPort {
private val filmLibraryRowMapper = { rs: ResultSet, _: Int ->
FilmLibrary(
id = UUID.fromString(rs.getString("id")),
userId = UUID.fromString(rs.getString("user_id")),
filmId = UUID.fromString(rs.getString("film_id")),
comment = rs.getString("comment"),
isViewed = rs.getBoolean("is_viewed"),
)
}
override fun save(filmLibrary: FilmLibrary): FilmLibrary {
val updatedRows =
jdbc.update(
"""
UPDATE favorites
SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?
WHERE id = ?
""".trimIndent(),
filmLibrary.userId,
filmLibrary.filmId,
filmLibrary.comment,
filmLibrary.isViewed,
filmLibrary.id,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO favorites (id, user_id, film_id, comment, is_viewed)
VALUES (?, ?, ?, ?, ?)
""".trimIndent(),
filmLibrary.id,
filmLibrary.userId,
filmLibrary.filmId,
filmLibrary.comment,
filmLibrary.isViewed,
)
}
return filmLibrary
}
override fun findById(id: UUID): FilmLibrary? {
val entries =
jdbc.query(
"SELECT id, user_id, film_id, comment, is_viewed FROM favorites WHERE id = ?",
filmLibraryRowMapper,
id,
)
return entries.firstOrNull()
}
override fun findAll(): List<FilmLibrary> =
jdbc.query(
"SELECT id, user_id, film_id, comment, is_viewed FROM favorites",
filmLibraryRowMapper,
)
override fun deleteById(id: UUID) {
jdbc.update("DELETE FROM favorites WHERE id = ?", id)
}
}
@@ -0,0 +1,117 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.adapters.persistence.entity.FilmRatingEntity
import com.project.movienight.adapters.persistence.entity.toDomain
import com.project.movienight.adapters.persistence.entity.toEntity
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
import com.project.movienight.domain.model.FilmRating
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.time.LocalDateTime
import java.util.UUID
@Repository
class FilmRatingRepository(
private val jdbc: JdbcTemplate,
) : FilmRatingRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
FilmRatingEntity(
id = UUID.fromString(rs.getString("id")),
userId = UUID.fromString(rs.getString("user_id")),
filmId = UUID.fromString(rs.getString("film_id")),
score = rs.getInt("score"),
note = rs.getString("note"),
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(),
)
}
override fun save(rating: FilmRating): FilmRating {
val entity = rating.toEntity()
val updatedRows =
jdbc.update(
"""
UPDATE film_ratings
SET score = ?,
note = ?,
updated_at = ?
WHERE user_id = ?
AND film_id = ?
""".trimIndent(),
entity.score,
entity.note,
LocalDateTime.now(),
entity.userId,
entity.filmId,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO film_ratings (
id,
user_id,
film_id,
score,
note,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
entity.id,
entity.userId,
entity.filmId,
entity.score,
entity.note,
entity.createdAt,
entity.updatedAt,
)
}
return rating
}
override fun findByUserId(userId: UUID): List<FilmRating> =
jdbc
.query(
"""
SELECT id,
user_id,
film_id,
score,
note,
created_at,
updated_at
FROM film_ratings
WHERE user_id = ?
""".trimIndent(),
rowMapper,
userId,
).map { it.toDomain() }
override fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmRating? =
jdbc
.query(
"""
SELECT id,
user_id,
film_id,
score,
note,
created_at,
updated_at
FROM film_ratings
WHERE user_id = ?
AND film_id = ?
""".trimIndent(),
rowMapper,
userId,
filmId,
).firstOrNull()
?.toDomain()
}
@@ -1,6 +1,8 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.Film
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
@@ -16,6 +18,21 @@ class FilmRepository(
id = UUID.fromString(rs.getString("id")),
title = rs.getString("title"),
description = rs.getString("description"),
contentType =
runCatching {
ContentType.valueOf(
rs.getString("content_type"),
)
}.getOrDefault(ContentType.FILM),
releaseYear = rs.getObject("release_year")?.let { (it as Number).toInt() },
genres = DelimitedValueCodec.decodeList(rs.getString("genres")),
cast = DelimitedValueCodec.decodeList(rs.getString("cast_members")),
directors = DelimitedValueCodec.decodeList(rs.getString("directors")),
imdbRating = rs.getObject("imdb_rating")?.let { (it as Number).toDouble() },
platformRating = rs.getObject("platform_rating")?.let { (it as Number).toDouble() },
externalUrl = rs.getString("external_url"),
jellyfinItemId = rs.getString("jellyfin_item_id"),
jellyfinLibraryId = rs.getString("jellyfin_library_id"),
)
}
@@ -24,22 +41,67 @@ class FilmRepository(
jdbc.update(
"""
UPDATE films
SET title = ?, description = ?
SET title = ?,
description = ?,
content_type = ?,
release_year = ?,
genres = ?,
cast_members = ?,
directors = ?,
imdb_rating = ?,
platform_rating = ?,
external_url = ?,
jellyfin_item_id = ?,
jellyfin_library_id = ?
WHERE id = ?
""".trimIndent(),
film.title,
film.description,
film.contentType.name,
film.releaseYear,
DelimitedValueCodec.encodeList(film.genres),
DelimitedValueCodec.encodeList(film.cast),
DelimitedValueCodec.encodeList(film.directors),
film.imdbRating,
film.platformRating,
film.externalUrl,
film.jellyfinItemId,
film.jellyfinLibraryId,
film.id,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO films (id, title, description)
VALUES (?, ?, ?)
INSERT INTO films (
id,
title,
description,
content_type,
release_year,
genres,
cast_members,
directors,
imdb_rating,
platform_rating,
external_url,
jellyfin_item_id,
jellyfin_library_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
film.id,
film.title,
film.description,
film.contentType.name,
film.releaseYear,
DelimitedValueCodec.encodeList(film.genres),
DelimitedValueCodec.encodeList(film.cast),
DelimitedValueCodec.encodeList(film.directors),
film.imdbRating,
film.platformRating,
film.externalUrl,
film.jellyfinItemId,
film.jellyfinLibraryId,
)
}
return film
@@ -48,20 +110,111 @@ class FilmRepository(
override fun findById(id: UUID): Film? {
val films =
jdbc.query(
"SELECT id, title, description FROM films WHERE id = ?",
"""
SELECT id,
title,
description,
content_type,
release_year,
genres,
cast_members,
directors,
imdb_rating,
platform_rating,
external_url,
jellyfin_item_id,
jellyfin_library_id
FROM films
WHERE id = ?
""".trimIndent(),
filmRowMapper,
id,
)
return films.firstOrNull()
}
override fun findByJellyfinItemId(jellyfinItemId: String): Film? {
val films =
jdbc.query(
"""
SELECT id,
title,
description,
content_type,
release_year,
genres,
cast_members,
directors,
imdb_rating,
platform_rating,
external_url,
jellyfin_item_id,
jellyfin_library_id
FROM films
WHERE jellyfin_item_id = ?
""".trimIndent(),
filmRowMapper,
jellyfinItemId,
)
return films.firstOrNull()
}
override fun findAll(): List<Film> =
jdbc.query(
"SELECT id, title, description FROM films",
"""
SELECT id,
title,
description,
content_type,
release_year,
genres,
cast_members,
directors,
imdb_rating,
platform_rating,
external_url,
jellyfin_item_id,
jellyfin_library_id
FROM films
""".trimIndent(),
filmRowMapper,
)
override fun findByTitle(title: String): Film? {
val films =
jdbc.query(
"""
SELECT id,
title,
description,
content_type,
release_year,
genres,
cast_members,
directors,
imdb_rating,
platform_rating,
external_url,
jellyfin_item_id,
jellyfin_library_id
FROM films
WHERE title = ?
ORDER BY id
LIMIT 1
""".trimIndent(),
filmRowMapper,
title,
)
return films.firstOrNull()
}
override fun deleteById(id: UUID) {
jdbc.update("DELETE FROM films WHERE id = ?", id)
jdbc.update(
"""
DELETE FROM films
WHERE id = ?
""".trimIndent(),
id,
)
}
}
@@ -0,0 +1,33 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.JellyfinEventRecord
import com.project.movienight.application.ports.output.JellyfinEventStorePort
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
@Repository
class JellyfinEventRepository(
private val jdbc: NamedParameterJdbcTemplate,
) : JellyfinEventStorePort {
override fun save(event: JellyfinEventRecord): Boolean {
val sql =
"""
INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload)
VALUES (:eventId, :serverId, :eventType, :occurredAt, :jellyfinUserId, :jellyfinItemId, cast(:payload as jsonb))
ON CONFLICT (event_id) DO NOTHING
""".trimIndent()
val params =
MapSqlParameterSource()
.addValue("eventId", event.eventId)
.addValue("serverId", event.serverId)
.addValue("eventType", event.eventType)
.addValue("occurredAt", event.occurredAt)
.addValue("jellyfinUserId", event.jellyfinUserId)
.addValue("jellyfinItemId", event.jellyfinItemId)
.addValue("payload", event.payload)
return jdbc.update(sql, params) == 1
}
}
@@ -0,0 +1,100 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.adapters.persistence.entity.JellyfinSyncStateEntity
import com.project.movienight.adapters.persistence.entity.toDomain
import com.project.movienight.adapters.persistence.entity.toEntity
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
import com.project.movienight.domain.model.JellyfinSyncState
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.util.UUID
@Repository
class JellyfinSyncStateRepository(
private val jdbc: JdbcTemplate,
) : JellyfinSyncStateRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
JellyfinSyncStateEntity(
userId = UUID.fromString(rs.getString("user_id")),
lastSyncedAt = rs.getTimestamp("last_synced_at")?.toLocalDateTime(),
lastSuccessfulSyncAt = rs.getTimestamp("last_successful_sync_at")?.toLocalDateTime(),
lastError = rs.getString("last_error"),
syncedItemCount = rs.getInt("synced_item_count"),
)
}
override fun save(state: JellyfinSyncState): JellyfinSyncState {
val entity = state.toEntity()
val updatedRows =
jdbc.update(
"""
UPDATE jellyfin_sync_state
SET last_synced_at = ?,
last_successful_sync_at = ?,
last_error = ?,
synced_item_count = ?,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = ?
""".trimIndent(),
entity.lastSyncedAt,
entity.lastSuccessfulSyncAt,
entity.lastError,
entity.syncedItemCount,
entity.userId,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO jellyfin_sync_state (
user_id,
last_synced_at,
last_successful_sync_at,
last_error,
synced_item_count
)
VALUES (?, ?, ?, ?, ?)
""".trimIndent(),
entity.userId,
entity.lastSyncedAt,
entity.lastSuccessfulSyncAt,
entity.lastError,
entity.syncedItemCount,
)
}
return state
}
override fun findByUserId(userId: UUID): JellyfinSyncState? =
jdbc
.query(
"""
SELECT user_id,
last_synced_at,
last_successful_sync_at,
last_error,
synced_item_count
FROM jellyfin_sync_state
WHERE user_id = ?
""".trimIndent(),
rowMapper,
userId,
).firstOrNull()
?.toDomain()
override fun findAll(): List<JellyfinSyncState> =
jdbc
.query(
"""
SELECT user_id,
last_synced_at,
last_successful_sync_at,
last_error,
synced_item_count
FROM jellyfin_sync_state
""".trimIndent(),
rowMapper,
).map { it.toDomain() }
}
@@ -0,0 +1,116 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort
import com.project.movienight.domain.model.RecommendationEvent
import com.project.movienight.domain.model.RecommendationEventType
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.util.UUID
@Repository
class RecommendationEventRepository(
private val jdbc: JdbcTemplate,
) : RecommendationEventRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
RecommendationEvent(
id = UUID.fromString(rs.getString("id")),
userId = UUID.fromString(rs.getString("user_id")),
filmId = UUID.fromString(rs.getString("film_id")),
eventType = RecommendationEventType.valueOf(rs.getString("event_type")),
score = rs.getObject("score")?.let { (it as Number).toDouble() },
relevanceScore = rs.getObject("relevance_score")?.let { (it as Number).toDouble() },
qualityScore = rs.getObject("quality_score")?.let { (it as Number).toDouble() },
contextScore = rs.getObject("context_score")?.let { (it as Number).toDouble() },
noveltyScore = rs.getObject("novelty_score")?.let { (it as Number).toDouble() },
diversityScore = rs.getObject("diversity_score")?.let { (it as Number).toDouble() },
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
)
}
override fun save(event: RecommendationEvent): RecommendationEvent {
jdbc.update(
"""
INSERT INTO recommendation_events (
id,
user_id,
film_id,
event_type,
score,
relevance_score,
quality_score,
context_score,
novelty_score,
diversity_score,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
event.id,
event.userId,
event.filmId,
event.eventType.name,
event.score,
event.relevanceScore,
event.qualityScore,
event.contextScore,
event.noveltyScore,
event.diversityScore,
event.createdAt,
)
return event
}
override fun findByUserId(userId: UUID): List<RecommendationEvent> =
jdbc.query(
"""
SELECT id,
user_id,
film_id,
event_type,
score,
relevance_score,
quality_score,
context_score,
novelty_score,
diversity_score,
created_at
FROM recommendation_events
WHERE user_id = ?
ORDER BY created_at DESC
""".trimIndent(),
rowMapper,
userId,
)
override fun findLatestRecommended(
userId: UUID,
filmId: UUID,
): RecommendationEvent? =
jdbc
.query(
"""
SELECT id,
user_id,
film_id,
event_type,
score,
relevance_score,
quality_score,
context_score,
novelty_score,
diversity_score,
created_at
FROM recommendation_events
WHERE user_id = ?
AND film_id = ?
AND event_type = ?
ORDER BY created_at DESC
LIMIT 1
""".trimIndent(),
rowMapper,
userId,
filmId,
RecommendationEventType.RECOMMENDED.name,
).firstOrNull()
}
@@ -0,0 +1,97 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.adapters.persistence.entity.UserPreferencesEntity
import com.project.movienight.adapters.persistence.entity.toDomain
import com.project.movienight.adapters.persistence.entity.toEntity
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
import com.project.movienight.domain.model.UserPreferences
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.util.UUID
@Repository
class UserPreferencesRepository(
private val jdbc: JdbcTemplate,
) : UserPreferencesRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
UserPreferencesEntity(
userId = UUID.fromString(rs.getString("user_id")),
weightedGenres = rs.getString("weighted_genres"),
plotTypes = rs.getString("plot_types"),
eras = rs.getString("eras"),
castAndDirectors = rs.getString("cast_and_directors"),
moods = rs.getString("moods"),
contentTypes = rs.getString("content_types"),
)
}
override fun save(preferences: UserPreferences): UserPreferences {
val entity = preferences.toEntity()
val updatedRows =
jdbc.update(
"""
UPDATE user_preferences
SET weighted_genres = ?,
plot_types = ?,
eras = ?,
cast_and_directors = ?,
moods = ?,
content_types = ?
WHERE user_id = ?
""".trimIndent(),
entity.weightedGenres,
entity.plotTypes,
entity.eras,
entity.castAndDirectors,
entity.moods,
entity.contentTypes,
entity.userId,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO user_preferences (
user_id,
weighted_genres,
plot_types,
eras,
cast_and_directors,
moods,
content_types
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
entity.userId,
entity.weightedGenres,
entity.plotTypes,
entity.eras,
entity.castAndDirectors,
entity.moods,
entity.contentTypes,
)
}
return preferences
}
override fun findByUserId(userId: UUID): UserPreferences? =
jdbc
.query(
"""
SELECT user_id,
weighted_genres,
plot_types,
eras,
cast_and_directors,
moods,
content_types
FROM user_preferences
WHERE user_id = ?
""".trimIndent(),
rowMapper,
userId,
).firstOrNull()
?.toDomain()
}
@@ -0,0 +1,130 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
import com.project.movienight.domain.model.UserRecommendationWeights
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.time.LocalDateTime
import java.util.UUID
@Repository
class UserRecommendationWeightsRepository(
private val jdbc: JdbcTemplate,
) : UserRecommendationWeightsRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
UserRecommendationWeights(
userId = UUID.fromString(rs.getString("user_id")),
relevanceWeight = rs.getDouble("relevance_weight"),
qualityWeight = rs.getDouble("quality_weight"),
contextWeight = rs.getDouble("context_weight"),
noveltyWeight = rs.getDouble("novelty_weight"),
diversityWeight = rs.getDouble("diversity_weight"),
genreVectorWeight = rs.getDouble("genre_vector_weight"),
plotVectorWeight = rs.getDouble("plot_vector_weight"),
moodVectorWeight = rs.getDouble("mood_vector_weight"),
eraVectorWeight = rs.getDouble("era_vector_weight"),
peopleVectorWeight = rs.getDouble("people_vector_weight"),
contentTypeVectorWeight = rs.getDouble("content_type_vector_weight"),
updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(),
)
}
override fun findByUserId(userId: UUID): UserRecommendationWeights? =
jdbc
.query(
"""
SELECT user_id,
relevance_weight,
quality_weight,
context_weight,
novelty_weight,
diversity_weight,
genre_vector_weight,
plot_vector_weight,
mood_vector_weight,
era_vector_weight,
people_vector_weight,
content_type_vector_weight,
updated_at
FROM user_recommendation_weights
WHERE user_id = ?
""".trimIndent(),
rowMapper,
userId,
).firstOrNull()
override fun save(weights: UserRecommendationWeights): UserRecommendationWeights {
val normalized = weights.normalized(updatedAt = LocalDateTime.now())
val updatedRows =
jdbc.update(
"""
UPDATE user_recommendation_weights
SET relevance_weight = ?,
quality_weight = ?,
context_weight = ?,
novelty_weight = ?,
diversity_weight = ?,
genre_vector_weight = ?,
plot_vector_weight = ?,
mood_vector_weight = ?,
era_vector_weight = ?,
people_vector_weight = ?,
content_type_vector_weight = ?,
updated_at = ?
WHERE user_id = ?
""".trimIndent(),
normalized.relevanceWeight,
normalized.qualityWeight,
normalized.contextWeight,
normalized.noveltyWeight,
normalized.diversityWeight,
normalized.genreVectorWeight,
normalized.plotVectorWeight,
normalized.moodVectorWeight,
normalized.eraVectorWeight,
normalized.peopleVectorWeight,
normalized.contentTypeVectorWeight,
normalized.updatedAt,
normalized.userId,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO user_recommendation_weights (
user_id,
relevance_weight,
quality_weight,
context_weight,
novelty_weight,
diversity_weight,
genre_vector_weight,
plot_vector_weight,
mood_vector_weight,
era_vector_weight,
people_vector_weight,
content_type_vector_weight,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
normalized.userId,
normalized.relevanceWeight,
normalized.qualityWeight,
normalized.contextWeight,
normalized.noveltyWeight,
normalized.diversityWeight,
normalized.genreVectorWeight,
normalized.plotVectorWeight,
normalized.moodVectorWeight,
normalized.eraVectorWeight,
normalized.peopleVectorWeight,
normalized.contentTypeVectorWeight,
normalized.updatedAt,
)
}
return normalized
}
}
@@ -1,6 +1,11 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.adapters.persistence.entity.UserEntity
import com.project.movienight.adapters.persistence.entity.toDomain
import com.project.movienight.adapters.persistence.entity.toEntity
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.AuthProvider
import com.project.movienight.domain.model.User
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
@@ -11,58 +16,208 @@ import java.util.UUID
class UserRepository(
private val jdbc: JdbcTemplate,
) : UserRepositoryPort {
private val userRowMapper = { rs: ResultSet, _: Int ->
User(
private val userEntityRowMapper = { rs: ResultSet, _: Int ->
UserEntity(
id = UUID.fromString(rs.getString("id")),
name = rs.getString("name"),
email = rs.getString("email"),
library = null,
provider = rs.getString("provider"),
providerId = rs.getString("provider_id"),
jellyfinUserId = rs.getString("jellyfin_user_id"),
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
)
}
override fun save(user: User): User {
val existingUser = findById(user.id)
val entity =
if (existingUser != null) {
user.toEntity(
provider = findProviderById(user.id),
providerId = findProviderIdById(user.id),
)
} else {
user.toEntity()
}
val updatedRows =
jdbc.update(
"""
UPDATE users
SET name = ?, email = ?
SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ?
WHERE id = ?
""".trimIndent(),
user.name,
user.email,
user.id,
entity.name,
entity.email,
entity.provider,
entity.providerId,
entity.jellyfinUserId,
entity.id,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO users (id, name, email)
VALUES (?, ?, ?)
INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
user.id,
user.name,
user.email,
entity.id,
entity.name,
entity.email,
entity.provider,
entity.providerId,
entity.jellyfinUserId,
entity.createdAt,
)
}
return user
}
override fun createOAuthUser(
user: User,
provider: AuthProvider,
providerId: String,
): User {
val entity = user.toEntity(provider = provider, providerId = providerId)
val updatedRows =
jdbc.update(
"""
UPDATE users
SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ?
WHERE id = ?
""".trimIndent(),
entity.name,
entity.email,
entity.provider,
entity.providerId,
entity.jellyfinUserId,
entity.id,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
entity.id,
entity.name,
entity.email,
entity.provider,
entity.providerId,
entity.jellyfinUserId,
entity.createdAt,
)
}
return findById(user.id) ?: user
}
override fun linkOAuthAccount(
userId: UUID,
provider: AuthProvider,
providerId: String,
): User {
val updatedRows =
jdbc.update(
"""
UPDATE users
SET provider = ?, provider_id = ?
WHERE id = ?
""".trimIndent(),
provider.name,
providerId,
userId,
)
if (updatedRows == 0) {
throw EntityNotFoundException(entity = "User", id = userId.toString())
}
return findById(userId) ?: throw EntityNotFoundException(entity = "User", id = userId.toString())
}
override fun findById(id: UUID): User? {
val users =
val entities =
jdbc.query(
"SELECT id, name, email FROM users WHERE id = ?",
userRowMapper,
"SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users WHERE id = ?",
userEntityRowMapper,
id,
)
return users.firstOrNull()
return entities.firstOrNull()?.toDomain()
}
override fun findByEmail(email: String): User? {
val entities =
jdbc.query(
"""
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at
FROM users
WHERE email = ?
""".trimIndent(),
userEntityRowMapper,
email,
)
return entities.firstOrNull()?.toDomain()
}
override fun findByJellyfinUserId(jellyfinUserId: String): User? {
val entities =
jdbc.query(
"""
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at
FROM users
WHERE jellyfin_user_id = ?
""".trimIndent(),
userEntityRowMapper,
jellyfinUserId,
)
return entities.firstOrNull()?.toDomain()
}
override fun findAll(): List<User> =
jdbc.query(
"SELECT id, name, email FROM users",
userRowMapper,
)
jdbc
.query(
"SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users",
userEntityRowMapper,
).map { it.toDomain() }
override fun deleteById(id: UUID) {
jdbc.update("DELETE FROM users WHERE id = ?", id)
}
override fun findByProviderAndProviderId(
provider: AuthProvider,
providerId: String,
): User? {
val entities =
jdbc.query(
"""
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users
WHERE provider = ? AND provider_id = ?
""".trimIndent(),
userEntityRowMapper,
provider.name,
providerId,
)
return entities.firstOrNull()?.toDomain()
}
private fun findProviderById(id: UUID): AuthProvider? =
jdbc
.query(
"SELECT provider FROM users WHERE id = ?",
{ rs: ResultSet, _: Int -> rs.getString("provider") },
id,
).firstOrNull()
?.let { AuthProvider.valueOf(it) }
private fun findProviderIdById(id: UUID): String? =
jdbc
.query(
"SELECT provider_id FROM users WHERE id = ?",
{ rs: ResultSet, _: Int -> rs.getString("provider_id") },
id,
).firstOrNull()
}
@@ -0,0 +1,38 @@
package com.project.movienight.adapters.persistence.jdbc.support
import java.net.URLDecoder
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
object DelimitedValueCodec {
fun encodeList(values: List<String>): String = values.joinToString("|") { encode(it) }
fun decodeList(value: String?): List<String> =
value
?.takeIf { it.isNotBlank() }
?.split("|")
?.map { decode(it) }
?: emptyList()
fun encodeWeightedMap(values: Map<String, Int>): String =
values.entries.joinToString("|") { entry -> "${encode(entry.key)}:${entry.value}" }
fun decodeWeightedMap(value: String?): Map<String, Int> {
if (value.isNullOrBlank()) return emptyMap()
return value
.split("|")
.mapNotNull { pair ->
val parts = pair.split(":", limit = 2)
if (parts.size != 2) return@mapNotNull null
val key = decode(parts[0])
val weight = parts[1].toIntOrNull() ?: return@mapNotNull null
key to weight
}.toMap()
}
private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
private fun decode(value: String): String = URLDecoder.decode(value, StandardCharsets.UTF_8)
}
@@ -0,0 +1,81 @@
package com.project.movienight.adapters.security
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.domain.model.AuthProvider
import com.project.movienight.domain.model.User
import org.slf4j.LoggerFactory
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.stereotype.Service
@Service
class CustomOAuth2UserService(
private val userRepository: UserRepositoryPort,
private val idGenerator: IdGenerator,
) : DefaultOAuth2UserService() {
companion object {
private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java)
}
override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User {
val oAuth2User = super.loadUser(userRequest)
val registrationId = userRequest.clientRegistration.registrationId
log.debug("Processing OAuth2 login for provider: {}", registrationId)
return try {
val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User)
val user = findOrCreateUser(userInfo)
UserPrincipal.create(user, oAuth2User.attributes)
} catch (e: IllegalArgumentException) {
log.error("OAuth2 authentication failed: ${e.message}", e)
throw OAuth2AuthenticationException("Failed to process OAuth2 user data")
} catch (e: OAuth2AuthenticationException) {
log.error("OAuth2 authentication failed: ${e.message}", e)
throw e
}
}
private fun findOrCreateUser(userInfo: OAuth2UserInfo): User {
val provider = AuthProvider.valueOf(userInfo.getProvider().uppercase())
val existingUser =
userRepository.findByProviderAndProviderId(
provider,
userInfo.getProviderId(),
)
return if (existingUser != null) {
log.debug("User found by provider: {}", userInfo.getProvider())
existingUser
} else {
val userByEmail = userRepository.findByEmail(userInfo.getEmail())
if (userByEmail != null) {
log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail())
userRepository.linkOAuthAccount(
userId = userByEmail.id,
provider = provider,
providerId = userInfo.getProviderId(),
)
} else {
log.debug("Creating new user for provider: {}", userInfo.getProvider())
val newUser =
User(
id = idGenerator.generateId(),
name = userInfo.getName(),
email = userInfo.getEmail(),
)
userRepository.createOAuthUser(
user = newUser,
provider = provider,
providerId = userInfo.getProviderId(),
)
}
}
}
}
@@ -0,0 +1,17 @@
package com.project.movienight.adapters.security
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
class GoogleOAuth2UserInfo(
private val attributes: Map<String, Any>,
) : OAuth2UserInfo {
override fun getProviderId(): String = attributes["sub"] as String
override fun getEmail(): String = attributes["email"] as String
override fun getName(): String = attributes["name"] as String
override fun getProvider(): String = "google"
override fun getAttributes(): Map<String, Any> = attributes
}
@@ -0,0 +1,21 @@
package com.project.movienight.adapters.security
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
import org.springframework.security.oauth2.core.user.OAuth2User
object OAuth2UserInfoFactory {
fun getOAuth2UserInfo(
registrationId: String,
user: OAuth2User,
): OAuth2UserInfo {
val attributes = user.attributes
return when (registrationId.lowercase()) {
"google" -> GoogleOAuth2UserInfo(attributes)
"yandex" -> YandexOAuth2UserInfo(attributes)
"vk" -> VkOAuth2UserInfo(attributes)
else -> throw OAuth2AuthenticationException("Unknown provider: $registrationId")
}
}
}
@@ -0,0 +1,54 @@
package com.project.movienight.adapters.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
class SecurityConfiguration(
private val customOAuth2UserService: CustomOAuth2UserService,
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.oauth2Login { oauth2 ->
oauth2
.userInfoEndpoint { userInfo ->
userInfo.userService(customOAuth2UserService)
}.defaultSuccessUrl("/api/users/me", true)
}.authorizeHttpRequests { auth ->
auth
.requestMatchers(
"/",
"/login/**",
"/oauth2/**",
"/h2-console/**",
"/actuator/health",
"/actuator/health/**",
"/actuator/prometheus",
).permitAll()
.requestMatchers("/api/v1/docs/**", "/api/v1/swagger-ui/**", "/swagger-ui/**")
.permitAll()
.requestMatchers(
"/api/integrations/jellyfin/**",
).permitAll()
.requestMatchers("/api/users/me")
.authenticated()
.requestMatchers("/api/**")
.authenticated()
.anyRequest()
.authenticated()
}.headers { headers ->
headers.frameOptions { frameOptions ->
frameOptions.sameOrigin()
}
}.csrf { csrf ->
csrf.disable()
}
return http.build()
}
}
@@ -0,0 +1,44 @@
package com.project.movienight.adapters.security
import com.project.movienight.domain.model.User
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.oauth2.core.user.OAuth2User
import java.util.UUID
class UserPrincipal(
private val user: User,
private val attributes: Map<String, Any>? = null,
) : OAuth2User,
UserDetails {
fun getId(): UUID = user.id
override fun getName(): String = user.name
override fun getAttributes(): Map<String, Any> = attributes ?: emptyMap()
override fun getAuthorities(): Collection<GrantedAuthority> =
listOf(
SimpleGrantedAuthority("ROLE_USER"),
)
override fun getPassword(): String = ""
override fun getUsername(): String = user.email
override fun isAccountNonExpired(): Boolean = true
override fun isAccountNonLocked(): Boolean = true
override fun isCredentialsNonExpired(): Boolean = true
override fun isEnabled(): Boolean = true
companion object {
fun create(
user: User,
attributes: Map<String, Any>? = null,
): UserPrincipal = UserPrincipal(user, attributes)
}
}
@@ -0,0 +1,28 @@
package com.project.movienight.adapters.security
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
class VkOAuth2UserInfo(
private val attributes: Map<String, Any>,
) : OAuth2UserInfo {
override fun getProviderId(): String =
(attributes["response"] as? List<*>)
?.firstOrNull()
?.let { it as? Map<*, *> }
?.get("id")
?.toString() ?: ""
override fun getEmail(): String = attributes["email"]?.toString() ?: ""
override fun getName(): String {
val response = attributes["response"] as? List<*>
val first = response?.firstOrNull() as? Map<*, *>
val firstName = first?.get("first_name")?.toString() ?: ""
val lastName = first?.get("last_name")?.toString() ?: ""
return "$firstName $lastName".trim()
}
override fun getProvider(): String = "vk"
override fun getAttributes(): Map<String, Any> = attributes
}
@@ -0,0 +1,22 @@
package com.project.movienight.adapters.security
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
class YandexOAuth2UserInfo(
private val attributes: Map<String, Any>,
) : OAuth2UserInfo {
override fun getProviderId(): String = attributes["id"]?.toString() ?: ""
override fun getEmail(): String =
(attributes["emails"] as? List<*>)
?.firstOrNull()
?.let { it as? Map<*, *> }
?.get("value")
?.toString() ?: ""
override fun getName(): String = attributes["display_name"]?.toString() ?: ""
override fun getProvider(): String = "yandex"
override fun getAttributes(): Map<String, Any> = attributes
}
@@ -3,29 +3,111 @@ package com.project.movienight.adapters.web
import com.project.movienight.domain.exception.BlockedValueException
import com.project.movienight.domain.exception.DomainException
import com.project.movienight.domain.exception.EntityNotFoundException
import org.slf4j.LoggerFactory
import org.slf4j.MDC
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.server.ResponseStatusException
import org.springframework.web.servlet.resource.NoResourceFoundException
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(EntityNotFoundException::class)
private val log = LoggerFactory.getLogger(javaClass)
@ExceptionHandler(EntityNotFoundException::class, NoResourceFoundException::class)
@ResponseStatus(HttpStatus.NOT_FOUND)
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse =
ErrorResponse(message = exception.message ?: "Entity not found")
fun handleNotFound(exception: Exception): ErrorResponse {
val traceId = currentTraceId()
log.warn("Resource not found: traceId='{}', message='{}'", traceId, exception.message)
return ErrorResponse(
message = exception.message ?: "Resource not found",
traceId = traceId,
)
}
@ExceptionHandler(BlockedValueException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse =
ErrorResponse(message = exception.message ?: "Blocked value")
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse {
val traceId = currentTraceId()
log.warn("Blocked value: traceId='{}', message='{}'", traceId, exception.message)
return ErrorResponse(
message = exception.message ?: "Blocked value",
traceId = traceId,
)
}
@ExceptionHandler(DomainException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleDomainException(exception: DomainException): ErrorResponse =
ErrorResponse(message = exception.message ?: "Domain error")
fun handleDomainException(exception: DomainException): ErrorResponse {
val traceId = currentTraceId()
log.warn("Domain error: traceId='{}', message='{}'", traceId, exception.message)
return ErrorResponse(
message = exception.message ?: "Domain error",
traceId = traceId,
)
}
@ExceptionHandler(MethodArgumentNotValidException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleValidationException(exception: MethodArgumentNotValidException): ErrorResponse {
val traceId = currentTraceId()
val details =
exception
.bindingResult
.fieldErrors
.joinToString("; ") { error -> "${error.field}: ${error.defaultMessage}" }
.ifBlank { "Invalid request" }
log.warn("Validation error: traceId='{}', message='{}'", traceId, details)
return ErrorResponse(
message = details,
traceId = traceId,
)
}
@ExceptionHandler(ResponseStatusException::class)
fun handleResponseStatusException(exception: ResponseStatusException): ResponseEntity<ErrorResponse> {
val traceId = currentTraceId()
log.warn(
"HTTP error: traceId='{}', status='{}', message='{}'",
traceId,
exception.statusCode,
exception.reason,
)
return ResponseEntity
.status(exception.statusCode)
.body(
ErrorResponse(
message = exception.reason ?: exception.message,
traceId = traceId,
),
)
}
@ExceptionHandler(Exception::class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
fun handleUnexpectedException(exception: Exception): ErrorResponse {
val traceId = currentTraceId()
log.error("Unexpected error: traceId='{}'", traceId, exception)
return ErrorResponse(
message = "Internal server error",
traceId = traceId,
)
}
private fun currentTraceId(): String = MDC.get("traceId") ?: "unknown"
}
data class ErrorResponse(
val message: String,
val traceId: String,
)
@@ -0,0 +1,10 @@
package com.project.movienight.adapters.web
import com.project.movienight.domain.exception.DomainException
import com.project.movienight.domain.model.ContentType
fun parseContentType(value: String): ContentType =
runCatching { ContentType.valueOf(value.uppercase()) }
.getOrElse { throw DomainException("Unsupported content type: $value") }
fun parseOptionalContentType(value: String?): ContentType? = value?.let { parseContentType(it) }
@@ -4,17 +4,19 @@ import com.project.movienight.adapters.web.dto.request.CreateFilmRequest
import com.project.movienight.adapters.web.dto.request.EditFilmRequest
import com.project.movienight.adapters.web.dto.response.FilmResponse
import com.project.movienight.application.ports.input.CreateFilmCommand
import com.project.movienight.application.ports.input.CreateFilmUseCase
import com.project.movienight.application.ports.input.DeleteFilmUseCase
import com.project.movienight.application.ports.input.EditFilmCommand
import com.project.movienight.application.ports.input.EditFilmUseCase
import com.project.movienight.application.ports.input.FilmUseCase
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@@ -22,20 +24,28 @@ import java.util.UUID
@RestController
@RequestMapping("/api/films")
class FilmController(
private val createFilmUseCase: CreateFilmUseCase,
private val editFilmUseCase: EditFilmUseCase,
private val deleteFilmUseCase: DeleteFilmUseCase,
private val filmUseCase: FilmUseCase,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@RequestBody request: CreateFilmRequest,
@Valid @RequestBody request: CreateFilmRequest,
): FilmResponse =
FilmResponse.fromDomain(
createFilmUseCase.create(
filmUseCase.create(
CreateFilmCommand(
title = request.title,
description = request.description,
contentType = parseContentType(request.contentType),
releaseYear = request.releaseYear,
genres = request.genres,
cast = request.cast,
directors = request.directors,
imdbRating = request.imdbRating,
platformRating = request.platformRating,
externalUrl = request.externalUrl,
jellyfinItemId = request.jellyfinItemId,
jellyfinLibraryId = request.jellyfinLibraryId,
),
),
)
@@ -43,15 +53,25 @@ class FilmController(
@PatchMapping("/{id}")
fun edit(
@PathVariable id: UUID,
@RequestBody request: EditFilmRequest,
@Valid @RequestBody request: EditFilmRequest,
): FilmResponse =
FilmResponse.fromDomain(
editFilmUseCase.edit(
filmUseCase.edit(
id = id,
command =
EditFilmCommand(
title = request.title,
description = request.description,
contentType = parseContentType(request.contentType),
releaseYear = request.releaseYear,
genres = request.genres,
cast = request.cast,
directors = request.directors,
imdbRating = request.imdbRating,
platformRating = request.platformRating,
externalUrl = request.externalUrl,
jellyfinItemId = request.jellyfinItemId,
jellyfinLibraryId = request.jellyfinLibraryId,
),
),
)
@@ -60,5 +80,25 @@ class FilmController(
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(
@PathVariable id: UUID,
) = deleteFilmUseCase.delete(id)
) = filmUseCase.delete(id)
@GetMapping("/{id}")
fun getById(
@PathVariable id: UUID,
): FilmResponse = FilmResponse.fromDomain(filmUseCase.getById(id))
@GetMapping
fun getAll(): List<FilmResponse> = filmUseCase.getAll().map { FilmResponse.fromDomain(it) }
@GetMapping("/search")
fun searchByTitle(
@RequestParam title: String,
): ResponseEntity<FilmResponse> {
val film = filmUseCase.searchByTitle(title)
return if (film != null) {
ResponseEntity.ok(FilmResponse.fromDomain(film))
} else {
ResponseEntity.notFound().build()
}
}
}
@@ -1,21 +1,16 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.CreateFilmLibraryRequest
import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse
import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse
import com.project.movienight.adapters.web.dto.response.FilmResponse
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
import com.project.movienight.application.ports.input.FilmLibraryUseCase
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.DeleteMapping
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.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
@@ -24,44 +19,29 @@ import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/library")
class FilmLibraryController(
private val createFilmLibraryUseCase: CreateFilmLibraryUseCase,
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
private val filmLibraryUseCase: FilmLibraryUseCase,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@PathVariable userId: UUID,
@RequestBody request: CreateFilmLibraryRequest,
): FilmLibraryResponse =
FilmLibraryResponse.fromDomain(
createFilmLibraryUseCase.create(
CreateFilmLibraryCommand(
userId = userId,
name = request.name,
),
),
)
@GetMapping
fun get(
fun list(
@PathVariable userId: UUID,
): FilmLibraryResponse =
FilmLibraryResponse.fromDomain(
getFilmLibraryUseCase.getLibrary(
GetFilmLibraryQuery(userId = userId),
),
)
): List<FilmLibraryEntryResponse> =
filmLibraryUseCase
.list(userId)
.map { entry -> FilmLibraryEntryResponse.fromDomain(entry) }
@GetMapping("/entries")
fun listEntries(
@PathVariable userId: UUID,
): List<FilmLibraryEntryResponse> = list(userId)
@PostMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.CREATED)
fun addFilm(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
): FilmLibraryResponse =
FilmLibraryResponse.fromDomain(
addFilmToLibraryUseCase.addFilm(
): FilmLibraryEntryResponse =
FilmLibraryEntryResponse.fromDomain(
filmLibraryUseCase.addFilm(
AddFilmToLibraryCommand(
userId = userId,
filmId = filmId,
@@ -69,15 +49,36 @@ class FilmLibraryController(
),
)
@PostMapping("/films/{filmId}/viewed")
fun markViewed(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
): FilmLibraryEntryResponse =
FilmLibraryEntryResponse.fromDomain(
filmLibraryUseCase.markViewed(
MarkFilmViewedCommand(
userId = userId,
filmId = filmId,
),
),
)
@DeleteMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun removeFilm(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
) = removeFilmFromLibraryUseCase.removeFilm(
RemoveFilmFromLibraryCommand(
userId = userId,
filmId = filmId,
),
)
) {
filmLibraryUseCase.removeFilm(
RemoveFilmFromLibraryCommand(
userId = userId,
filmId = filmId,
),
)
}
@GetMapping("/available-films")
fun getAvailableFilms(
@PathVariable userId: UUID,
): List<FilmResponse> = filmLibraryUseCase.listAvailableFilms(userId).map { FilmResponse.fromDomain(it) }
}
@@ -0,0 +1,45 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
import com.project.movienight.application.ports.input.FilmRatingUseCase
import com.project.movienight.application.ports.input.RateFilmCommand
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.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/ratings")
class FilmRatingController(
private val filmRatingUseCase: FilmRatingUseCase,
) {
@PostMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.CREATED)
fun rate(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
@Valid @RequestBody request: RateFilmRequest,
): FilmRatingResponse =
FilmRatingResponse.fromDomain(
filmRatingUseCase.rate(
RateFilmCommand(
userId = userId,
filmId = filmId,
score = request.score,
note = request.note,
),
),
)
@GetMapping
fun list(
@PathVariable userId: UUID,
): List<FilmRatingResponse> = filmRatingUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
}
@@ -0,0 +1,50 @@
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 jakarta.validation.Valid
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
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.ResponseStatus
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/integrations/jellyfin")
class JellyfinEventsController(
private val jellyfinEventUseCase: JellyfinEventUseCase,
private val authenticator: JellyfinPluginAuthenticator,
) {
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
@PostMapping("/events")
@ResponseStatus(HttpStatus.OK)
fun receiveEvent(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@Valid @RequestBody request: JellyfinEventRequest,
) {
authenticator.authenticate(token)
log.debug(
"Received Jellyfin event {} for user {} item {}",
request.eventId,
request.jellyfinUserId,
request.itemId,
)
jellyfinEventUseCase.handle(
HandleJellyfinEventCommand(
eventId = request.eventId,
serverId = null,
eventType = request.eventType,
occurredAt = request.occurredAt,
jellyfinUserId = request.jellyfinUserId,
itemId = request.itemId,
payload = request.payload,
),
)
}
}
@@ -0,0 +1,21 @@
package com.project.movienight.adapters.web
import com.project.movienight.config.JellyfinIntegrationProperties
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException
@Component
class JellyfinPluginAuthenticator(
private val properties: JellyfinIntegrationProperties,
) {
fun authenticate(token: String?) {
if (!properties.enabled) {
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
}
if (properties.pluginToken.isNotBlank() && token != properties.pluginToken) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
}
}
}
@@ -0,0 +1,227 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse
import com.project.movienight.adapters.web.dto.response.RecommendationResponse
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
import com.project.movienight.application.ports.input.FilmLibraryUseCase
import com.project.movienight.application.ports.input.FilmRatingUseCase
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.RateFilmCommand
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.input.UserPreferencesUseCase
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.RecommendationStyle
import com.project.movienight.domain.model.User
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.time.OffsetDateTime
import java.util.Locale
import java.util.UUID
@RestController
@RequestMapping("/api/integrations/jellyfin")
class JellyfinPluginController(
private val authenticator: JellyfinPluginAuthenticator,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val idGenerator: IdGenerator,
private val getRecommendationsUseCase: GetRecommendationsUseCase,
private val filmRatingUseCase: FilmRatingUseCase,
private val filmLibraryUseCase: FilmLibraryUseCase,
private val userPreferencesUseCase: UserPreferencesUseCase,
private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase,
private val jellyfinProperties: JellyfinIntegrationProperties,
) {
@GetMapping("/users/{jellyfinUserId}/recommendations")
fun recommend(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
@RequestParam(required = false) contentType: String?,
@RequestParam(required = false) mood: String?,
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
@RequestParam(required = false, defaultValue = "10") limit: Int,
): List<RecommendationResponse> {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
return getRecommendationsUseCase
.recommend(
RecommendationQuery(
userId = user.id,
contentType = parseOptionalContentType(contentType),
mood = mood,
libraryOnly = libraryOnly,
limit = limit,
),
).map { recommendation ->
RecommendationResponse.fromDomain(
recommendation = recommendation,
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
)
}
}
@PostMapping("/users/{jellyfinUserId}/ratings/items/{jellyfinItemId}")
fun rate(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
@PathVariable jellyfinItemId: String,
@Valid @RequestBody request: RateFilmRequest,
): FilmRatingResponse {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
val film = resolveFilm(jellyfinItemId)
return FilmRatingResponse.fromDomain(
filmRatingUseCase.rate(
RateFilmCommand(
userId = user.id,
filmId = film.id,
score = request.score,
note = request.note,
),
),
)
}
@GetMapping("/users/{jellyfinUserId}/ratings")
fun ratings(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
): List<FilmRatingResponse> {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
return filmRatingUseCase.getRatings(user.id).map { FilmRatingResponse.fromDomain(it) }
}
@PostMapping("/users/{jellyfinUserId}/library/items/{jellyfinItemId}/viewed")
fun markViewed(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
@PathVariable jellyfinItemId: String,
@RequestBody(required = false) request: JellyfinViewedRequest?,
): FilmLibraryEntryResponse {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
val film = resolveFilm(jellyfinItemId)
return FilmLibraryEntryResponse.fromDomain(
filmLibraryUseCase.markViewed(
MarkFilmViewedCommand(
userId = user.id,
filmId = film.id,
watchedAt = request?.watchedAt?.toLocalDateTime(),
),
),
)
}
@GetMapping("/users/{jellyfinUserId}/preferences")
fun preferences(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
): UserPreferencesResponse {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
return userPreferencesUseCase.get(user.id)?.let { UserPreferencesResponse.fromDomain(it) }
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User preferences not found")
}
@PostMapping("/users/{jellyfinUserId}/recommendation-onboarding")
fun completeOnboarding(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
@RequestBody request: RecommendationOnboardingRequest,
): RecommendationOnboardingResponse {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
return RecommendationOnboardingResponse.fromApplication(
completeRecommendationOnboardingUseCase.complete(
CompleteRecommendationOnboardingCommand(
userId = user.id,
weightedGenres = request.weightedGenres,
plotTypes = request.plotTypes,
eras = request.eras,
castAndDirectors = request.castAndDirectors,
moods = request.moods,
contentTypes = request.contentTypes.mapNotNull { runCatching { parseContentType(it) }.getOrNull() },
likedFilmIds = request.likedFilmIds,
dislikedFilmIds = request.dislikedFilmIds,
libraryFilmIds = request.libraryFilmIds,
watchedFilmIds = request.watchedFilmIds,
recommendationStyle = parseRecommendationStyle(request.recommendationStyle),
),
),
)
}
private fun resolveOrCreateUser(jellyfinUserId: String): User =
normalizeJellyfinId(jellyfinUserId).let { normalizedId ->
userRepository.findByJellyfinUserId(normalizedId)
?: userRepository.save(
User(
id = idGenerator.generateId(),
name = "Jellyfin User",
email = syntheticJellyfinEmail(normalizedId),
jellyfinUserId = normalizedId,
),
)
}
private fun resolveFilm(jellyfinItemId: String): Film =
normalizeJellyfinId(jellyfinItemId).let { normalizedId ->
filmRepository.findByJellyfinItemId(normalizedId)
?: throw EntityNotFoundException(entity = "Jellyfin item", id = jellyfinItemId)
}
private fun buildWatchUrl(jellyfinItemId: String?): String? {
if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) {
return null
}
val baseUrl = jellyfinProperties.webUrl.trimEnd('/')
val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8)
return "$baseUrl/web/#/details?id=$encodedItemId"
}
private fun parseRecommendationStyle(value: String): RecommendationStyle =
runCatching { RecommendationStyle.valueOf(value.uppercase()) }
.getOrDefault(RecommendationStyle.BALANCED)
private fun normalizeJellyfinId(value: String): String =
runCatching { UUID.fromString(value).toString().replace("-", "") }
.getOrDefault(value)
private fun syntheticJellyfinEmail(jellyfinUserId: String): String {
val safeId =
jellyfinUserId
.lowercase(Locale.getDefault())
.replace(Regex("[^a-z0-9._%+-]"), "-")
.take(240)
return "jellyfin-$safeId@movienight.local"
}
}
data class JellyfinViewedRequest(
val watchedAt: OffsetDateTime? = null,
)
@@ -0,0 +1,80 @@
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
@RestController
@RequestMapping("/api/integrations/jellyfin")
class JellyfinSyncController(
private val jellyfinSyncUseCase: JellyfinSyncUseCase,
private val authenticator: JellyfinPluginAuthenticator,
) {
@PostMapping("/sync")
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(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
): List<JellyfinSyncState> {
authenticator.authenticate(token)
return jellyfinSyncUseCase.getSyncStates()
}
private fun JellyfinSyncRequest.toCommand(): IngestJellyfinSyncCommand =
IngestJellyfinSyncCommand(
users =
users.map { user ->
JellyfinSyncUserCommand(
jellyfinUserId = user.jellyfinUserId,
name = user.name,
)
},
items =
items.map { item ->
JellyfinSyncItemCommand(
jellyfinItemId = item.jellyfinItemId,
title = item.title,
originalTitle = item.originalTitle,
description = item.description,
year = item.year,
genres = item.genres,
imdbId = item.imdbId,
tmdbId = item.tmdbId,
jellyfinLibraryId = item.jellyfinLibraryId,
userStates =
item.userStates.map { state ->
JellyfinSyncUserStateCommand(
jellyfinUserId = state.jellyfinUserId,
isViewed = state.isViewed,
playCount = state.playCount,
lastPlayedAt = state.lastPlayedAt,
userRating = state.userRating,
)
},
)
},
)
}
@@ -0,0 +1,91 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.response.RecommendationEventResponse
import com.project.movienight.adapters.web.dto.response.RecommendationResponse
import com.project.movienight.application.ports.input.AcceptRecommendationCommand
import com.project.movienight.application.ports.input.AcceptRecommendationUseCase
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.input.RejectRecommendationCommand
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
import com.project.movienight.config.JellyfinIntegrationProperties
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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/recommendations")
class RecommendationController(
private val getRecommendationsUseCase: GetRecommendationsUseCase,
private val acceptRecommendationUseCase: AcceptRecommendationUseCase,
private val rejectRecommendationUseCase: RejectRecommendationUseCase,
private val jellyfinProperties: JellyfinIntegrationProperties,
) {
@GetMapping
fun recommend(
@PathVariable userId: UUID,
@RequestParam(required = false) contentType: String?,
@RequestParam(required = false) mood: String?,
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
@RequestParam(required = false, defaultValue = "10") limit: Int,
): List<RecommendationResponse> =
getRecommendationsUseCase
.recommend(
RecommendationQuery(
userId = userId,
contentType = parseOptionalContentType(contentType),
mood = mood,
libraryOnly = libraryOnly,
limit = limit,
),
).map { recommendation ->
RecommendationResponse.fromDomain(
recommendation = recommendation,
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
)
}
@PostMapping("/{filmId}/accept")
fun accept(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
): RecommendationEventResponse =
RecommendationEventResponse.fromDomain(
acceptRecommendationUseCase.accept(
AcceptRecommendationCommand(
userId = userId,
filmId = filmId,
),
),
)
@PostMapping("/{filmId}/reject")
fun reject(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
): RecommendationEventResponse =
RecommendationEventResponse.fromDomain(
rejectRecommendationUseCase.reject(
RejectRecommendationCommand(
userId = userId,
filmId = filmId,
),
),
)
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"
}
}
@@ -0,0 +1,52 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.RecommendationStyle
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.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.Locale
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/recommendation-onboarding")
class RecommendationOnboardingController(
private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase,
) {
@PostMapping
fun complete(
@PathVariable userId: UUID,
@RequestBody request: RecommendationOnboardingRequest,
): RecommendationOnboardingResponse =
RecommendationOnboardingResponse.fromApplication(
completeRecommendationOnboardingUseCase.complete(
CompleteRecommendationOnboardingCommand(
userId = userId,
weightedGenres = request.weightedGenres,
plotTypes = request.plotTypes,
eras = request.eras,
castAndDirectors = request.castAndDirectors,
moods = request.moods,
contentTypes = request.contentTypes.mapNotNull(::parseContentType),
likedFilmIds = request.likedFilmIds,
dislikedFilmIds = request.dislikedFilmIds,
libraryFilmIds = request.libraryFilmIds,
watchedFilmIds = request.watchedFilmIds,
recommendationStyle = parseRecommendationStyle(request.recommendationStyle),
),
),
)
private fun parseContentType(value: String): ContentType? =
runCatching { ContentType.valueOf(value.uppercase(Locale.getDefault())) }.getOrNull()
private fun parseRecommendationStyle(value: String): RecommendationStyle =
runCatching { RecommendationStyle.valueOf(value.uppercase(Locale.getDefault())) }
.getOrDefault(RecommendationStyle.BALANCED)
}
@@ -0,0 +1,27 @@
package com.project.movienight.adapters.web
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.MDC
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import java.util.UUID
@Component
class TraceIdFilter : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val traceId = UUID.randomUUID().toString()
MDC.put("traceId", traceId)
try {
filterChain.doFilter(request, response)
} finally {
MDC.remove("traceId")
}
}
}
@@ -1,15 +1,17 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.security.UserPrincipal
import com.project.movienight.adapters.web.dto.request.CreateUserRequest
import com.project.movienight.adapters.web.dto.request.EditUserRequest
import com.project.movienight.adapters.web.dto.response.UserResponse
import com.project.movienight.application.ports.input.CreateUserCommand
import com.project.movienight.application.ports.input.CreateUserUseCase
import com.project.movienight.application.ports.input.DeleteUserUseCase
import com.project.movienight.application.ports.input.EditUserCommand
import com.project.movienight.application.ports.input.EditUserUseCase
import com.project.movienight.application.ports.input.UserUseCase
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
@@ -22,17 +24,15 @@ import java.util.UUID
@RestController
@RequestMapping("/api/users")
class UserController(
private val createUserUseCase: CreateUserUseCase,
private val editUserUseCase: EditUserUseCase,
private val deleteUserUseCase: DeleteUserUseCase,
private val userUseCase: UserUseCase,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@RequestBody request: CreateUserRequest,
@Valid @RequestBody request: CreateUserRequest,
): UserResponse =
UserResponse.fromDomain(
createUserUseCase.create(
userUseCase.create(
CreateUserCommand(
name = request.name,
email = request.email,
@@ -40,17 +40,31 @@ class UserController(
),
)
@GetMapping
fun getAll(): List<UserResponse> = userUseCase.getAll().map { UserResponse.fromDomain(it) }
@GetMapping("/me")
fun getMe(
@AuthenticationPrincipal principal: UserPrincipal,
): UserResponse = UserResponse.fromDomain(userUseCase.getById(principal.getId()))
@GetMapping("/{id}")
fun getById(
@PathVariable id: UUID,
): UserResponse = UserResponse.fromDomain(userUseCase.getById(id))
@PatchMapping("/{id}")
fun edit(
@PathVariable id: UUID,
@RequestBody request: EditUserRequest,
@Valid @RequestBody request: EditUserRequest,
): UserResponse =
UserResponse.fromDomain(
editUserUseCase.edit(
userUseCase.edit(
id = id,
command =
EditUserCommand(
name = request.name,
jellyfinUserId = request.jellyfinUserId,
),
),
)
@@ -59,5 +73,5 @@ class UserController(
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(
@PathVariable id: UUID,
) = deleteUserUseCase.delete(id)
) = userUseCase.delete(id)
}
@@ -0,0 +1,45 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
import com.project.movienight.application.ports.input.UserPreferencesUseCase
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/preferences")
class UserPreferencesController(
private val userPreferencesUseCase: UserPreferencesUseCase,
) {
@PutMapping
fun upsert(
@PathVariable userId: UUID,
@Valid @RequestBody request: UpsertUserPreferencesRequest,
): UserPreferencesResponse =
UserPreferencesResponse.fromDomain(
userPreferencesUseCase.upsert(
UpsertUserPreferencesCommand(
userId = userId,
weightedGenres = request.weightedGenres,
plotTypes = request.plotTypes,
eras = request.eras,
castAndDirectors = request.castAndDirectors,
moods = request.moods,
contentTypes =
request.contentTypes.map { parseContentType(it) },
),
),
)
@GetMapping
fun get(
@PathVariable userId: UUID,
): UserPreferencesResponse? = userPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) }
}
@@ -0,0 +1,53 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.UpdateUserRecommendationWeightsRequest
import com.project.movienight.adapters.web.dto.response.UserRecommendationWeightsResponse
import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/recommendation-weights")
class UserRecommendationWeightsController(
private val getUserRecommendationWeightsUseCase: GetUserRecommendationWeightsUseCase,
private val updateUserRecommendationWeightsUseCase: UpdateUserRecommendationWeightsUseCase,
) {
@GetMapping
fun get(
@PathVariable userId: UUID,
): UserRecommendationWeightsResponse =
UserRecommendationWeightsResponse.fromDomain(
getUserRecommendationWeightsUseCase.get(userId),
)
@PutMapping
fun update(
@PathVariable userId: UUID,
@RequestBody request: UpdateUserRecommendationWeightsRequest,
): UserRecommendationWeightsResponse =
UserRecommendationWeightsResponse.fromDomain(
updateUserRecommendationWeightsUseCase.update(
UpdateUserRecommendationWeightsCommand(
userId = userId,
relevanceWeight = request.relevanceWeight,
qualityWeight = request.qualityWeight,
contextWeight = request.contextWeight,
noveltyWeight = request.noveltyWeight,
diversityWeight = request.diversityWeight,
genreVectorWeight = request.genreVectorWeight,
plotVectorWeight = request.plotVectorWeight,
moodVectorWeight = request.moodVectorWeight,
eraVectorWeight = request.eraVectorWeight,
peopleVectorWeight = request.peopleVectorWeight,
contentTypeVectorWeight = request.contentTypeVectorWeight,
),
),
)
}
@@ -1,5 +0,0 @@
package com.project.movienight.adapters.web.dto.request
data class CreateFilmLibraryRequest(
val name: String = "My films",
)
@@ -1,6 +1,31 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class CreateFilmRequest(
@field:NotBlank
@field:Size(max = 255)
val title: String,
@field:NotBlank
val description: String,
@field:NotBlank
val contentType: String = "FILM",
@field:Min(1888)
@field:Max(3000)
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
@field:Min(0)
@field:Max(10)
val imdbRating: Double? = null,
@field:Min(0)
@field:Max(10)
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
val jellyfinLibraryId: String? = null,
)
@@ -1,6 +1,15 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class CreateUserRequest(
@field:NotBlank
@field:Size(max = 255)
val name: String,
@field:Email
@field:NotBlank
@field:Size(max = 320)
val email: String,
)
@@ -1,6 +1,31 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class EditFilmRequest(
@field:NotBlank
@field:Size(max = 255)
val title: String,
@field:NotBlank
val description: String,
@field:NotBlank
val contentType: String = "FILM",
@field:Min(1888)
@field:Max(3000)
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
@field:Min(0)
@field:Max(10)
val imdbRating: Double? = null,
@field:Min(0)
@field:Max(10)
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
val jellyfinLibraryId: String? = null,
)

Some files were not shown because too many files have changed in this diff Show More