Merge pull request #45 from devitq/develop

chore(release): first stable release
This commit was merged in pull request #45.
This commit is contained in:
ITQ
2026-05-23 10:28:36 +03:00
committed by GitHub
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 *.tar.gz
*.rar *.rar
# Gradle wrapper
!gradle/wrapper/gradle-wrapper.jar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid* hs_err_pid*
replay_pid* replay_pid*
+1 -1
View File
@@ -2,6 +2,6 @@
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" /> <option name="externalSystemId" value="Gradle" />
<option name="version" value="2.1.20" /> <option name="version" value="2.0.21" />
</component> </component>
</project> </project>
+3 -8
View File
@@ -26,13 +26,7 @@ RUN --mount=type=cache,target=${GRADLE_USER_HOME} \
COPY src src COPY src src
RUN --mount=type=cache,target=${GRADLE_USER_HOME} \ RUN --mount=type=cache,target=${GRADLE_USER_HOME} \
./gradlew --no-daemon build \ ./gradlew --no-daemon bootJar
-x test \
-x detekt \
-x ktlintCheck \
-x ktlintKotlinScriptCheck \
-x ktlintMainSourceSetCheck \
-x ktlintTestSourceSetCheck
RUN mkdir -p ${APP_HOME}/dist \ RUN mkdir -p ${APP_HOME}/dist \
&& cp ${APP_HOME}/build/libs/*.jar ${APP_HOME}/dist/${JAR_NAME} \ && 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 USER app
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC" \ ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC" \
SPRING_AOT_ENABLED=true \
SERVER_PORT=8080 \ SERVER_PORT=8080 \
PATH="/app:$PATH" PATH="/app:$PATH"
@@ -70,4 +65,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
STOPSIGNAL SIGTERM 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 jacoco
} }
apply(plugin = "org.springframework.boot.aot")
apply(from = "$rootDir/gradle/docker.gradle.kts") apply(from = "$rootDir/gradle/docker.gradle.kts")
group = "com.project" group = "com.project"
@@ -28,11 +30,11 @@ java {
dependencies { dependencies {
implementation(platform(libs.sentry.bom)) implementation(platform(libs.sentry.bom))
implementation(platform(libs.spring.grpc.bom))
implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.actuator) 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.cache)
implementation(libs.spring.boot.starter.data.jdbc) implementation(libs.spring.boot.starter.data.jdbc)
implementation(libs.spring.boot.starter.validation) implementation(libs.spring.boot.starter.validation)
@@ -40,12 +42,13 @@ dependencies {
implementation(libs.flyway.database.postgresql) implementation(libs.flyway.database.postgresql)
implementation(libs.kotlin.reflect) implementation(libs.kotlin.reflect)
implementation("net.logstash.logback:logstash-logback-encoder:8.0")
implementation(libs.micrometer.tracing.bridge.otel) implementation(libs.micrometer.tracing.bridge.otel)
implementation(libs.opentelemetry.exporter.otlp) implementation(libs.opentelemetry.exporter.otlp)
implementation(libs.sentry.spring.boot.starter) implementation(libs.sentry.spring.boot.starter)
implementation(libs.spring.grpc.starter) implementation(libs.spring.boot.starter.oauth2.client)
implementation(libs.grpc.services)
runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.micrometer.registry.prometheus)
runtimeOnly(libs.h2) runtimeOnly(libs.h2)
@@ -55,7 +58,7 @@ dependencies {
testImplementation(libs.spring.boot.starter.test) testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.kotlin.test.junit5) testImplementation(libs.kotlin.test.junit5)
testImplementation(libs.spring.grpc.test) testImplementation(libs.mockk)
testRuntimeOnly(libs.junit.platform.launcher) testRuntimeOnly(libs.junit.platform.launcher)
} }
+9
View File
@@ -7,3 +7,12 @@ comments:
active: false active: false
UndocumentedPublicProperty: UndocumentedPublicProperty:
active: false 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" protoc = "3.25.1"
grpc-java = "1.60.0" grpc-java = "1.60.0"
springdoc = "2.8.6" springdoc = "2.8.6"
mockk = "1.13.13"
[libraries] [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-web = { module = "org.springframework.boot:spring-boot-starter-web" }
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" } 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-security = { module = "org.springframework.boot:spring-boot-starter-security" }
spring-boot-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache" } 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" } 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-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" }
kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5" } kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } 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-bom = { module = "io.sentry:sentry-bom", version.ref = "sentry" }
sentry-spring-boot-starter = { module = "io.sentry:sentry-spring-boot-starter-jakarta" } 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.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication @SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan("com.project.movienight.config") @ConfigurationPropertiesScan("com.project.movienight.config")
class MovieNightApplication 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 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.application.ports.output.FilmRepositoryPort
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.Film import com.project.movienight.domain.model.Film
import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@@ -16,6 +18,21 @@ class FilmRepository(
id = UUID.fromString(rs.getString("id")), id = UUID.fromString(rs.getString("id")),
title = rs.getString("title"), title = rs.getString("title"),
description = rs.getString("description"), 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( jdbc.update(
""" """
UPDATE films 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 = ? WHERE id = ?
""".trimIndent(), """.trimIndent(),
film.title, film.title,
film.description, 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, film.id,
) )
if (updatedRows == 0) { if (updatedRows == 0) {
jdbc.update( jdbc.update(
""" """
INSERT INTO films (id, title, description) INSERT INTO films (
VALUES (?, ?, ?) 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(), """.trimIndent(),
film.id, film.id,
film.title, film.title,
film.description, 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 return film
@@ -48,20 +110,111 @@ class FilmRepository(
override fun findById(id: UUID): Film? { override fun findById(id: UUID): Film? {
val films = val films =
jdbc.query( 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, filmRowMapper,
id, id,
) )
return films.firstOrNull() 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> = override fun findAll(): List<Film> =
jdbc.query( 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, 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) { 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 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.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 com.project.movienight.domain.model.User
import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@@ -11,58 +16,208 @@ import java.util.UUID
class UserRepository( class UserRepository(
private val jdbc: JdbcTemplate, private val jdbc: JdbcTemplate,
) : UserRepositoryPort { ) : UserRepositoryPort {
private val userRowMapper = { rs: ResultSet, _: Int -> private val userEntityRowMapper = { rs: ResultSet, _: Int ->
User( UserEntity(
id = UUID.fromString(rs.getString("id")), id = UUID.fromString(rs.getString("id")),
name = rs.getString("name"), name = rs.getString("name"),
email = rs.getString("email"), 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 { 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 = val updatedRows =
jdbc.update( jdbc.update(
""" """
UPDATE users UPDATE users
SET name = ?, email = ? SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ?
WHERE id = ? WHERE id = ?
""".trimIndent(), """.trimIndent(),
user.name, entity.name,
user.email, entity.email,
user.id, entity.provider,
entity.providerId,
entity.jellyfinUserId,
entity.id,
) )
if (updatedRows == 0) { if (updatedRows == 0) {
jdbc.update( jdbc.update(
""" """
INSERT INTO users (id, name, email) INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(), """.trimIndent(),
user.id, entity.id,
user.name, entity.name,
user.email, entity.email,
entity.provider,
entity.providerId,
entity.jellyfinUserId,
entity.createdAt,
) )
} }
return user 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? { override fun findById(id: UUID): User? {
val users = val entities =
jdbc.query( jdbc.query(
"SELECT id, name, email FROM users WHERE id = ?", "SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users WHERE id = ?",
userRowMapper, userEntityRowMapper,
id, 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> = override fun findAll(): List<User> =
jdbc.query( jdbc
"SELECT id, name, email FROM users", .query(
userRowMapper, "SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users",
) userEntityRowMapper,
).map { it.toDomain() }
override fun deleteById(id: UUID) { override fun deleteById(id: UUID) {
jdbc.update("DELETE FROM users WHERE id = ?", id) 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.BlockedValueException
import com.project.movienight.domain.exception.DomainException import com.project.movienight.domain.exception.DomainException
import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.exception.EntityNotFoundException
import org.slf4j.LoggerFactory
import org.slf4j.MDC
import org.springframework.http.HttpStatus 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.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.server.ResponseStatusException
import org.springframework.web.servlet.resource.NoResourceFoundException
@RestControllerAdvice @RestControllerAdvice
class ApiExceptionHandler { class ApiExceptionHandler {
@ExceptionHandler(EntityNotFoundException::class) private val log = LoggerFactory.getLogger(javaClass)
@ExceptionHandler(EntityNotFoundException::class, NoResourceFoundException::class)
@ResponseStatus(HttpStatus.NOT_FOUND) @ResponseStatus(HttpStatus.NOT_FOUND)
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse = fun handleNotFound(exception: Exception): ErrorResponse {
ErrorResponse(message = exception.message ?: "Entity not found") 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) @ExceptionHandler(BlockedValueException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse = fun handleBlockedValue(exception: BlockedValueException): ErrorResponse {
ErrorResponse(message = exception.message ?: "Blocked value") val traceId = currentTraceId()
log.warn("Blocked value: traceId='{}', message='{}'", traceId, exception.message)
return ErrorResponse(
message = exception.message ?: "Blocked value",
traceId = traceId,
)
}
@ExceptionHandler(DomainException::class) @ExceptionHandler(DomainException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleDomainException(exception: DomainException): ErrorResponse = fun handleDomainException(exception: DomainException): ErrorResponse {
ErrorResponse(message = exception.message ?: "Domain error") 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( data class ErrorResponse(
val message: String, 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.request.EditFilmRequest
import com.project.movienight.adapters.web.dto.response.FilmResponse import com.project.movienight.adapters.web.dto.response.FilmResponse
import com.project.movienight.application.ports.input.CreateFilmCommand 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.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.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping 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.PatchMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping 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.ResponseStatus
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import java.util.UUID import java.util.UUID
@@ -22,20 +24,28 @@ import java.util.UUID
@RestController @RestController
@RequestMapping("/api/films") @RequestMapping("/api/films")
class FilmController( class FilmController(
private val createFilmUseCase: CreateFilmUseCase, private val filmUseCase: FilmUseCase,
private val editFilmUseCase: EditFilmUseCase,
private val deleteFilmUseCase: DeleteFilmUseCase,
) { ) {
@PostMapping @PostMapping
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
fun create( fun create(
@RequestBody request: CreateFilmRequest, @Valid @RequestBody request: CreateFilmRequest,
): FilmResponse = ): FilmResponse =
FilmResponse.fromDomain( FilmResponse.fromDomain(
createFilmUseCase.create( filmUseCase.create(
CreateFilmCommand( CreateFilmCommand(
title = request.title, title = request.title,
description = request.description, 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}") @PatchMapping("/{id}")
fun edit( fun edit(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody request: EditFilmRequest, @Valid @RequestBody request: EditFilmRequest,
): FilmResponse = ): FilmResponse =
FilmResponse.fromDomain( FilmResponse.fromDomain(
editFilmUseCase.edit( filmUseCase.edit(
id = id, id = id,
command = command =
EditFilmCommand( EditFilmCommand(
title = request.title, title = request.title,
description = request.description, 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) @ResponseStatus(HttpStatus.NO_CONTENT)
fun delete( fun delete(
@PathVariable id: UUID, @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 package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.CreateFilmLibraryRequest import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse
import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse import com.project.movienight.adapters.web.dto.response.FilmResponse
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase import com.project.movienight.application.ports.input.FilmLibraryUseCase
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand import com.project.movienight.application.ports.input.MarkFilmViewedCommand
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.RemoveFilmFromLibraryCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping 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.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@@ -24,44 +19,29 @@ import java.util.UUID
@RestController @RestController
@RequestMapping("/api/users/{userId}/library") @RequestMapping("/api/users/{userId}/library")
class FilmLibraryController( class FilmLibraryController(
private val createFilmLibraryUseCase: CreateFilmLibraryUseCase, private val filmLibraryUseCase: FilmLibraryUseCase,
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
) { ) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@PathVariable userId: UUID,
@RequestBody request: CreateFilmLibraryRequest,
): FilmLibraryResponse =
FilmLibraryResponse.fromDomain(
createFilmLibraryUseCase.create(
CreateFilmLibraryCommand(
userId = userId,
name = request.name,
),
),
)
@GetMapping @GetMapping
fun get( fun list(
@PathVariable userId: UUID, @PathVariable userId: UUID,
): FilmLibraryResponse = ): List<FilmLibraryEntryResponse> =
FilmLibraryResponse.fromDomain( filmLibraryUseCase
getFilmLibraryUseCase.getLibrary( .list(userId)
GetFilmLibraryQuery(userId = userId), .map { entry -> FilmLibraryEntryResponse.fromDomain(entry) }
),
) @GetMapping("/entries")
fun listEntries(
@PathVariable userId: UUID,
): List<FilmLibraryEntryResponse> = list(userId)
@PostMapping("/films/{filmId}") @PostMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
fun addFilm( fun addFilm(
@PathVariable userId: UUID, @PathVariable userId: UUID,
@PathVariable filmId: UUID, @PathVariable filmId: UUID,
): FilmLibraryResponse = ): FilmLibraryEntryResponse =
FilmLibraryResponse.fromDomain( FilmLibraryEntryResponse.fromDomain(
addFilmToLibraryUseCase.addFilm( filmLibraryUseCase.addFilm(
AddFilmToLibraryCommand( AddFilmToLibraryCommand(
userId = userId, userId = userId,
filmId = filmId, 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}") @DeleteMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
fun removeFilm( fun removeFilm(
@PathVariable userId: UUID, @PathVariable userId: UUID,
@PathVariable filmId: UUID, @PathVariable filmId: UUID,
) = removeFilmFromLibraryUseCase.removeFilm( ) {
RemoveFilmFromLibraryCommand( filmLibraryUseCase.removeFilm(
userId = userId, RemoveFilmFromLibraryCommand(
filmId = filmId, 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 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.CreateUserRequest
import com.project.movienight.adapters.web.dto.request.EditUserRequest import com.project.movienight.adapters.web.dto.request.EditUserRequest
import com.project.movienight.adapters.web.dto.response.UserResponse import com.project.movienight.adapters.web.dto.response.UserResponse
import com.project.movienight.application.ports.input.CreateUserCommand 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.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.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping 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.PatchMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
@@ -22,17 +24,15 @@ import java.util.UUID
@RestController @RestController
@RequestMapping("/api/users") @RequestMapping("/api/users")
class UserController( class UserController(
private val createUserUseCase: CreateUserUseCase, private val userUseCase: UserUseCase,
private val editUserUseCase: EditUserUseCase,
private val deleteUserUseCase: DeleteUserUseCase,
) { ) {
@PostMapping @PostMapping
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
fun create( fun create(
@RequestBody request: CreateUserRequest, @Valid @RequestBody request: CreateUserRequest,
): UserResponse = ): UserResponse =
UserResponse.fromDomain( UserResponse.fromDomain(
createUserUseCase.create( userUseCase.create(
CreateUserCommand( CreateUserCommand(
name = request.name, name = request.name,
email = request.email, 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}") @PatchMapping("/{id}")
fun edit( fun edit(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody request: EditUserRequest, @Valid @RequestBody request: EditUserRequest,
): UserResponse = ): UserResponse =
UserResponse.fromDomain( UserResponse.fromDomain(
editUserUseCase.edit( userUseCase.edit(
id = id, id = id,
command = command =
EditUserCommand( EditUserCommand(
name = request.name, name = request.name,
jellyfinUserId = request.jellyfinUserId,
), ),
), ),
) )
@@ -59,5 +73,5 @@ class UserController(
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
fun delete( fun delete(
@PathVariable id: UUID, @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 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( data class CreateFilmRequest(
@field:NotBlank
@field:Size(max = 255)
val title: String, val title: String,
@field:NotBlank
val description: String, 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 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( data class CreateUserRequest(
@field:NotBlank
@field:Size(max = 255)
val name: String, val name: String,
@field:Email
@field:NotBlank
@field:Size(max = 320)
val email: String, val email: String,
) )
@@ -1,6 +1,31 @@
package com.project.movienight.adapters.web.dto.request 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( data class EditFilmRequest(
@field:NotBlank
@field:Size(max = 255)
val title: String, val title: String,
@field:NotBlank
val description: String, 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