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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -42,6 +42,9 @@ gradle-app.setting
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# Gradle wrapper
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
Generated
+1
-1
@@ -2,6 +2,6 @@
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="externalSystemId" value="Gradle" />
|
||||
<option name="version" value="2.1.20" />
|
||||
<option name="version" value="2.0.21" />
|
||||
</component>
|
||||
</project>
|
||||
+3
-8
@@ -26,13 +26,7 @@ RUN --mount=type=cache,target=${GRADLE_USER_HOME} \
|
||||
COPY src src
|
||||
|
||||
RUN --mount=type=cache,target=${GRADLE_USER_HOME} \
|
||||
./gradlew --no-daemon build \
|
||||
-x test \
|
||||
-x detekt \
|
||||
-x ktlintCheck \
|
||||
-x ktlintKotlinScriptCheck \
|
||||
-x ktlintMainSourceSetCheck \
|
||||
-x ktlintTestSourceSetCheck
|
||||
./gradlew --no-daemon bootJar
|
||||
|
||||
RUN mkdir -p ${APP_HOME}/dist \
|
||||
&& cp ${APP_HOME}/build/libs/*.jar ${APP_HOME}/dist/${JAR_NAME} \
|
||||
@@ -60,6 +54,7 @@ COPY --from=builder --chown=app:app /workspace/dist/app.jar /app/app.jar
|
||||
USER app
|
||||
|
||||
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC" \
|
||||
SPRING_AOT_ENABLED=true \
|
||||
SERVER_PORT=8080 \
|
||||
PATH="/app:$PATH"
|
||||
|
||||
@@ -70,4 +65,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
|
||||
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} -Dserver.port=${SERVER_PORT} -jar /app/app.jar"]
|
||||
ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} -Dspring.aot.enabled=${SPRING_AOT_ENABLED} -Dserver.port=${SERVER_PORT} -jar /app/app.jar"]
|
||||
|
||||
+8
-5
@@ -15,6 +15,8 @@ plugins {
|
||||
jacoco
|
||||
}
|
||||
|
||||
apply(plugin = "org.springframework.boot.aot")
|
||||
|
||||
apply(from = "$rootDir/gradle/docker.gradle.kts")
|
||||
|
||||
group = "com.project"
|
||||
@@ -28,11 +30,11 @@ java {
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.sentry.bom))
|
||||
implementation(platform(libs.spring.grpc.bom))
|
||||
|
||||
implementation(libs.spring.boot.starter.web)
|
||||
implementation(libs.spring.boot.starter.actuator)
|
||||
// implementation(libs.spring.boot.starter.security)
|
||||
implementation(libs.spring.boot.starter.aop)
|
||||
implementation(libs.spring.boot.starter.security)
|
||||
implementation(libs.spring.boot.starter.cache)
|
||||
implementation(libs.spring.boot.starter.data.jdbc)
|
||||
implementation(libs.spring.boot.starter.validation)
|
||||
@@ -40,12 +42,13 @@ dependencies {
|
||||
implementation(libs.flyway.database.postgresql)
|
||||
implementation(libs.kotlin.reflect)
|
||||
|
||||
implementation("net.logstash.logback:logstash-logback-encoder:8.0")
|
||||
|
||||
implementation(libs.micrometer.tracing.bridge.otel)
|
||||
implementation(libs.opentelemetry.exporter.otlp)
|
||||
implementation(libs.sentry.spring.boot.starter)
|
||||
|
||||
implementation(libs.spring.grpc.starter)
|
||||
implementation(libs.grpc.services)
|
||||
implementation(libs.spring.boot.starter.oauth2.client)
|
||||
|
||||
runtimeOnly(libs.micrometer.registry.prometheus)
|
||||
runtimeOnly(libs.h2)
|
||||
@@ -55,7 +58,7 @@ dependencies {
|
||||
|
||||
testImplementation(libs.spring.boot.starter.test)
|
||||
testImplementation(libs.kotlin.test.junit5)
|
||||
testImplementation(libs.spring.grpc.test)
|
||||
testImplementation(libs.mockk)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,3 +7,12 @@ comments:
|
||||
active: false
|
||||
UndocumentedPublicProperty:
|
||||
active: false
|
||||
|
||||
style:
|
||||
MagicNumber:
|
||||
active: false
|
||||
ReturnCount:
|
||||
max: 3
|
||||
|
||||
complexity:
|
||||
active: false
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: cloudnative-pg
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://cloudnative-pg.github.io/charts
|
||||
targetRevision: 0.27.1
|
||||
chart: cloudnative-pg
|
||||
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: cloudnative-pg
|
||||
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
enabled: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- ApplyOutOfSyncOnly=true
|
||||
- ServerSideApply=true
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: external-secrets-operator
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://charts.external-secrets.io
|
||||
targetRevision: 2.5.0
|
||||
chart: external-secrets
|
||||
helm:
|
||||
valuesObject:
|
||||
webhook:
|
||||
create: false
|
||||
certController:
|
||||
create: false
|
||||
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: external-secrets
|
||||
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
enabled: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- ApplyOutOfSyncOnly=true
|
||||
- ServerSideApply=true
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: jellyfin
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://jellyfin.github.io/jellyfin-helm
|
||||
targetRevision: 2.7.0
|
||||
chart: jellyfin
|
||||
helm:
|
||||
valuesObject:
|
||||
replicaCount: 1
|
||||
persistence:
|
||||
config:
|
||||
size: 4Gi
|
||||
media:
|
||||
size: 20Gi
|
||||
metrics:
|
||||
enabled: true
|
||||
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: jellyfin
|
||||
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
enabled: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- ApplyOutOfSyncOnly=true
|
||||
- ServerSideApply=true
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: movienight-cnpg-bootstrap
|
||||
namespace: movienight
|
||||
spec:
|
||||
secretStoreRef:
|
||||
name: infisical
|
||||
kind: ClusterSecretStore
|
||||
|
||||
target:
|
||||
name: movienight-cnpg-bootstrap
|
||||
creationPolicy: Owner
|
||||
template:
|
||||
engineVersion: v2
|
||||
type: kubernetes.io/basic-auth
|
||||
data:
|
||||
username: "{{ .dbUsername }}"
|
||||
password: "{{ .dbPassword }}"
|
||||
|
||||
data:
|
||||
- secretKey: dbUsername
|
||||
remoteRef:
|
||||
key: /movienight/MOVIENIGHT_DB_USERNAME
|
||||
- secretKey: dbPassword
|
||||
remoteRef:
|
||||
key: /movienight/MOVIENIGHT_DB_PASSWORD
|
||||
|
||||
refreshInterval: 1h
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ClusterSecretStore
|
||||
metadata:
|
||||
name: infisical
|
||||
spec:
|
||||
provider:
|
||||
infisical:
|
||||
hostAPI: https://vault.itqdev.xyz
|
||||
auth:
|
||||
universalAuthCredentials:
|
||||
clientId:
|
||||
name: infisical-secret
|
||||
key: clientId
|
||||
namespace: external-secrets
|
||||
clientSecret:
|
||||
name: infisical-secret
|
||||
key: clientSecret
|
||||
namespace: external-secrets
|
||||
secretsScope:
|
||||
projectSlug: default-c-nay
|
||||
environmentSlug: prod
|
||||
secretsPath: /
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: jellyfin
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: movienight
|
||||
@@ -11,10 +11,13 @@ spring-grpc = "1.0.1"
|
||||
protoc = "3.25.1"
|
||||
grpc-java = "1.60.0"
|
||||
springdoc = "2.8.6"
|
||||
mockk = "1.13.13"
|
||||
|
||||
[libraries]
|
||||
spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" }
|
||||
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" }
|
||||
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" }
|
||||
spring-boot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop" }
|
||||
spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" }
|
||||
spring-boot-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache" }
|
||||
spring-boot-starter-data-jdbc = { module = "org.springframework.boot:spring-boot-starter-data-jdbc" }
|
||||
@@ -33,6 +36,7 @@ flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql
|
||||
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" }
|
||||
kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5" }
|
||||
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
|
||||
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||
|
||||
sentry-bom = { module = "io.sentry:sentry-bom", version.ref = "sentry" }
|
||||
sentry-spring-boot-starter = { module = "io.sentry:sentry-spring-boot-starter-jakarta" }
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to Jellyfin playback events and forwards thin payloads.
|
||||
/// </summary>
|
||||
public sealed class MovieNightPlaybackEventService : IHostedService
|
||||
{
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly MovieNightBackendClient _backendClient;
|
||||
private readonly ILogger<MovieNightPlaybackEventService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieNightPlaybackEventService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="sessionManager">Jellyfin session manager.</param>
|
||||
/// <param name="backendClient">Backend client.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public MovieNightPlaybackEventService(
|
||||
ISessionManager sessionManager,
|
||||
MovieNightBackendClient backendClient,
|
||||
ILogger<MovieNightPlaybackEventService> logger)
|
||||
{
|
||||
_sessionManager = sessionManager;
|
||||
_backendClient = backendClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_sessionManager.PlaybackStopped += OnPlaybackStopped;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e)
|
||||
{
|
||||
if (Plugin.Instance?.Configuration is not { Enabled: true, EnablePlaybackEvents: true })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.PlayedToCompletion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = e.Users?.FirstOrDefault()?.Id.ToString("N");
|
||||
var itemId = e.Item?.Id.ToString("N");
|
||||
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(itemId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var occurredAt = DateTimeOffset.UtcNow;
|
||||
var eventId = string.IsNullOrWhiteSpace(e.PlaySessionId)
|
||||
? $"playback-stopped:{userId}:{itemId}:{occurredAt.ToUnixTimeMilliseconds()}"
|
||||
: $"playback-stopped:{userId}:{itemId}:{e.PlaySessionId}";
|
||||
|
||||
var payload = new MovieNightEventPayload(
|
||||
EventId: eventId,
|
||||
EventType: "playback.stopped",
|
||||
OccurredAt: occurredAt,
|
||||
JellyfinUserId: userId,
|
||||
ItemId: itemId,
|
||||
PayloadVersion: 1,
|
||||
Payload: new Dictionary<string, object?>
|
||||
{
|
||||
["itemName"] = e.Item?.Name,
|
||||
["playSessionId"] = e.PlaySessionId,
|
||||
["positionTicks"] = e.PlaybackPositionTicks,
|
||||
["playedToCompletion"] = e.PlayedToCompletion
|
||||
});
|
||||
|
||||
_ = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _backendClient.PushEventAsync(payload, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "MovieNight playback event push failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for synchronizing the Jellyfin library with MovieNight.
|
||||
/// </summary>
|
||||
public class MovieNightSyncService
|
||||
{
|
||||
private readonly MovieNightBackendClient _backendClient;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly ILogger<MovieNightSyncService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieNightSyncService"/> class.
|
||||
/// </summary>
|
||||
public MovieNightSyncService(
|
||||
MovieNightBackendClient backendClient,
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IUserDataManager userDataManager,
|
||||
ILogger<MovieNightSyncService> logger)
|
||||
{
|
||||
_backendClient = backendClient;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_userDataManager = userDataManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs a full library sync.
|
||||
/// </summary>
|
||||
public async Task PerformSyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting MovieNight library sync");
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
var enabledLibraryIds = config?.EnabledLibraryIds ?? new List<string>();
|
||||
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = new[] { BaseItemKind.Movie },
|
||||
Recursive = true
|
||||
};
|
||||
|
||||
if (enabledLibraryIds.Count > 0)
|
||||
{
|
||||
query.AncestorIds = enabledLibraryIds.Select(Guid.Parse).ToArray();
|
||||
}
|
||||
|
||||
var items = _libraryManager.GetItemList(query);
|
||||
var users = _userManager.Users;
|
||||
var syncUsers = users.Select(u => new
|
||||
{
|
||||
jellyfinUserId = u.Id.ToString("N"),
|
||||
name = u.Username
|
||||
}).ToList();
|
||||
var syncItems = new List<object>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item is not Movie movie) continue;
|
||||
|
||||
var jellyfinItemId = movie.Id.ToString("N");
|
||||
var title = string.IsNullOrWhiteSpace(movie.Name) ? jellyfinItemId : movie.Name;
|
||||
|
||||
var itemData = new Dictionary<string, object?>
|
||||
{
|
||||
["jellyfinItemId"] = jellyfinItemId,
|
||||
["title"] = title,
|
||||
["originalTitle"] = movie.OriginalTitle,
|
||||
["description"] = movie.Overview,
|
||||
["year"] = movie.ProductionYear,
|
||||
["duration"] = movie.RunTimeTicks,
|
||||
["genres"] = movie.Genres,
|
||||
["posterUrl"] = $"/Items/{jellyfinItemId}/Images/Primary",
|
||||
["imdbId"] = movie.GetProviderId(MetadataProvider.Imdb),
|
||||
["tmdbId"] = movie.GetProviderId(MetadataProvider.Tmdb),
|
||||
["userStates"] = users.Select(u => {
|
||||
var userData = _userDataManager.GetUserData(u, movie);
|
||||
return new {
|
||||
jellyfinUserId = u.Id.ToString("N"),
|
||||
isViewed = userData?.Played ?? false,
|
||||
playCount = userData?.PlayCount ?? 0,
|
||||
lastPlayedAt = userData?.LastPlayedDate,
|
||||
userRating = userData?.Rating
|
||||
};
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
syncItems.Add(itemData);
|
||||
}
|
||||
|
||||
await _backendClient.SyncAsync(new { users = syncUsers, items = syncItems }, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("MovieNight library sync completed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# MovieNight Jellyfin Plugin
|
||||
|
||||
Thin Jellyfin server plugin for bridging Jellyfin playback/sync signals to the MovieNight backend.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cd plugins/jellyfin/Jellyfin.Plugin.MovieNight
|
||||
dotnet publish -c Release
|
||||
```
|
||||
|
||||
Install the published `net9.0` plugin files into the Jellyfin data directory under `plugins/MovieNight/`, then restart Jellyfin. This build targets Jellyfin `10.11.x`.
|
||||
|
||||
## Backend Contract Used
|
||||
|
||||
Current implemented calls:
|
||||
|
||||
- `POST /api/integrations/jellyfin/sync`
|
||||
- `GET /api/integrations/jellyfin/sync-state`
|
||||
- `POST /api/integrations/jellyfin/events`
|
||||
- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendations`
|
||||
- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/ratings/items/{jellyfin_item_id}`
|
||||
- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/library/items/{jellyfin_item_id}/viewed`
|
||||
- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/preferences`
|
||||
- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendation-onboarding`
|
||||
|
||||
Configure the backend with:
|
||||
|
||||
- `JELLYFIN_INTEGRATION_ENABLED=true`
|
||||
- `JELLYFIN_PLUGIN_TOKEN=<same token configured in the plugin>`
|
||||
- `JELLYFIN_WEB_URL=<browser URL of Jellyfin, used for recommendation watch links>`
|
||||
|
||||
`JELLYFIN_SYNC_ENABLED=true` is still accepted as a legacy alias for `JELLYFIN_INTEGRATION_ENABLED=true`.
|
||||
|
||||
Optional backend-pull sync values:
|
||||
|
||||
- `JELLYFIN_BASE_URL=<backend-reachable Jellyfin server URL>`
|
||||
- `JELLYFIN_API_KEY=<Jellyfin API key>`
|
||||
|
||||
The Jellyfin API key is only for backend-to-Jellyfin calls. The plugin token is a MovieNight shared secret for plugin-to-backend calls.
|
||||
|
||||
Configure the plugin with:
|
||||
|
||||
- Backend URL: MovieNight backend URL reachable from the Jellyfin server, for example `http://movienight-backend:8080`
|
||||
- Plugin token: the exact `JELLYFIN_PLUGIN_TOKEN` value
|
||||
- Enable MovieNight integration: checked
|
||||
- Enable periodic backend sync: checked if the plugin should push library state on an interval
|
||||
- Send playback stop events: checked if completed playback should mark films viewed in MovieNight
|
||||
|
||||
Event requests use JSON with:
|
||||
|
||||
- `event_id`
|
||||
- `event_type`
|
||||
- `occurred_at`
|
||||
- `jellyfin_user_id`
|
||||
- `item_id`
|
||||
- `payload_version`
|
||||
- `payload`
|
||||
|
||||
The plugin sends `X-MovieNight-Plugin-Token` when configured. Completed Jellyfin playback stop events are sent as `playback.stopped`. Transient event push failures are retried three times with the same `event_id`.
|
||||
|
||||
Sync requests push Jellyfin users, items, and per-user watched states to the backend. The backend creates MovieNight users for new Jellyfin users using their Jellyfin id as the stable mapping key, upserts films by `jellyfinItemId`, and uses the Jellyfin-facing endpoints above for UI actions so Jellyfin ids do not have to match MovieNight UUIDs. Run "Sync Library" once after installing/configuring the plugin so recommendations, rating, and viewed actions can resolve Jellyfin items.
|
||||
|
||||
The config page test action posts a small `plugin.test` event to `/api/integrations/jellyfin/events` and treats `200` as success and `401` as token/config failure.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: "MovieNight"
|
||||
guid: "42c72919-d6ff-4f62-bb8c-0fac39efafdb"
|
||||
version: 2
|
||||
targetAbi: "10.11.0.0"
|
||||
framework: net9.0
|
||||
owner: "movienight"
|
||||
overview: "Bridge Jellyfin events and sync triggers to MovieNight"
|
||||
description: "Thin Jellyfin plugin for MovieNight backend integration"
|
||||
category: "General"
|
||||
artifacts:
|
||||
- "Jellyfin.Plugin.MovieNight.dll"
|
||||
changelog: |-
|
||||
- Initial plugin implementation.
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import argparse
|
||||
import logging
|
||||
import re
|
||||
import random
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DATASET_URL = "https://raw.githubusercontent.com/sidooms/MovieTweetings/master/latest/movies.dat"
|
||||
DEFAULT_OUTPUT_DIR = "./Jellyfin_Movies"
|
||||
DEFAULT_COUNT = 1000
|
||||
|
||||
def download_dataset(url):
|
||||
logger.info(f"Downloading dataset from {url}...")
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
data = response.read().decode('utf-8')
|
||||
logger.info("Dataset downloaded successfully.")
|
||||
return data.splitlines()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download dataset: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def parse_movies(lines):
|
||||
"""
|
||||
Parse the movies.dat file.
|
||||
Format: IMDbID::Title (Year)::Genres
|
||||
Example: 0000008::Edison Kinetoscopic Record of a Sneeze (1894)::Documentary|Short
|
||||
"""
|
||||
movies = []
|
||||
# Regex to extract Title and Year from "Title (Year)"
|
||||
title_year_pattern = re.compile(r'(.*)\s+\((\d{4})\)$')
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = line.split('::')
|
||||
if len(parts) >= 2:
|
||||
imdb_id_raw = parts[0]
|
||||
title_year_raw = parts[1]
|
||||
|
||||
# Format IMDb ID to ttXXXXXXX
|
||||
if imdb_id_raw.isdigit():
|
||||
imdb_id = f"tt{imdb_id_raw.zfill(7)}"
|
||||
else:
|
||||
continue
|
||||
|
||||
match = title_year_pattern.match(title_year_raw)
|
||||
if match:
|
||||
title = match.group(1).strip()
|
||||
year = match.group(2)
|
||||
|
||||
# Clean title for filesystem (remove invalid characters)
|
||||
safe_title = re.sub(r'[\\/*?:"<>|]', "", title)
|
||||
safe_title = safe_title.strip()
|
||||
|
||||
if safe_title:
|
||||
movies.append({
|
||||
'imdb_id': imdb_id,
|
||||
'title': safe_title,
|
||||
'year': year
|
||||
})
|
||||
|
||||
logger.info(f"Parsed {len(movies)} valid movies from dataset.")
|
||||
return movies
|
||||
|
||||
def create_dummy_video(filepath):
|
||||
"""Create a minimal valid dummy video file (mp4)."""
|
||||
try:
|
||||
mp4_header = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom\x00\x00\x00\x00moov\x00\x00\x00\x08mvhd"
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(mp4_header)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create dummy video {filepath}: {e}")
|
||||
return False
|
||||
|
||||
def generate_library(movies, output_dir, count):
|
||||
"""Generate the folder structure and dummy files."""
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
logger.info(f"Created output directory: {output_dir}")
|
||||
|
||||
generated_imdb_ids = set()
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
failed_count = 0
|
||||
|
||||
logger.info(f"Starting generation of up to {count} movies...")
|
||||
|
||||
# Shuffle to get diverse movies
|
||||
random.shuffle(movies)
|
||||
|
||||
for movie in movies:
|
||||
if created_count >= count:
|
||||
break
|
||||
|
||||
if movie['imdb_id'] in generated_imdb_ids:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Jellyfin naming convention: Movie Name (year) [imdbid-tt1234567]
|
||||
folder_name = f"{movie['title']} ({movie['year']}) [imdbid-{movie['imdb_id']}]"
|
||||
folder_path = os.path.join(output_dir, folder_name)
|
||||
|
||||
file_name = f"{folder_name}.mp4"
|
||||
file_path = os.path.join(folder_path, file_name)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
skipped_count += 1
|
||||
generated_imdb_ids.add(movie['imdb_id'])
|
||||
continue
|
||||
|
||||
try:
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
if create_dummy_video(file_path):
|
||||
created_count += 1
|
||||
generated_imdb_ids.add(movie['imdb_id'])
|
||||
else:
|
||||
failed_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {folder_name}: {e}")
|
||||
failed_count += 1
|
||||
|
||||
logger.info("--- Generation Summary ---")
|
||||
logger.info(f"Target count: {count}")
|
||||
logger.info(f"Successfully created: {created_count}")
|
||||
logger.info(f"Skipped (already exists or duplicate): {skipped_count}")
|
||||
logger.info(f"Failed: {failed_count}")
|
||||
|
||||
return created_count > 0
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate a dummy Jellyfin movie library.")
|
||||
parser.add_argument("--output-dir", type=str, default=DEFAULT_OUTPUT_DIR,
|
||||
help=f"Directory to create the library in (default: {DEFAULT_OUTPUT_DIR})")
|
||||
parser.add_argument("--count", type=int, default=DEFAULT_COUNT,
|
||||
help=f"Number of movies to generate (default: {DEFAULT_COUNT})")
|
||||
parser.add_argument("--dataset-url", type=str, default=DEFAULT_DATASET_URL,
|
||||
help="URL to the movies.dat file")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
lines = download_dataset(args.dataset_url)
|
||||
if not lines:
|
||||
logger.error("No dataset lines to process.")
|
||||
sys.exit(1)
|
||||
|
||||
movies = parse_movies(lines)
|
||||
|
||||
if not movies:
|
||||
logger.error("No movies parsed from the dataset.")
|
||||
sys.exit(1)
|
||||
|
||||
if len(movies) < args.count:
|
||||
logger.warning(f"Requested {args.count} movies, but only {len(movies)} available.")
|
||||
args.count = len(movies)
|
||||
|
||||
success = generate_library(movies, args.output_dir, args.count)
|
||||
|
||||
if success:
|
||||
logger.info(f"Library generation complete. You can now mount '{os.path.abspath(args.output_dir)}' into Jellyfin.")
|
||||
else:
|
||||
logger.error("Library generation failed.")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,8 +3,10 @@ package com.project.movienight
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@ConfigurationPropertiesScan("com.project.movienight.config")
|
||||
class MovieNightApplication
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.project.movienight.adapters.jellyfin
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.project.movienight.application.ports.output.JellyfinCatalogPort
|
||||
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
|
||||
import com.project.movienight.application.ports.output.JellyfinRemoteUser
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import org.springframework.stereotype.Service
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
|
||||
@Service
|
||||
class JellyfinApiClient(
|
||||
private val properties: JellyfinIntegrationProperties,
|
||||
private val objectMapper: ObjectMapper,
|
||||
) : JellyfinCatalogPort {
|
||||
private val httpClient: HttpClient =
|
||||
HttpClient
|
||||
.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
|
||||
.build()
|
||||
|
||||
override fun fetchUsers(): List<JellyfinRemoteUser> =
|
||||
request("Users")
|
||||
.asItems()
|
||||
.mapNotNull { node ->
|
||||
val id = node.fieldText("Id") ?: return@mapNotNull null
|
||||
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
|
||||
}
|
||||
|
||||
override fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
|
||||
@Suppress("MaxLineLength")
|
||||
request(
|
||||
"Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData",
|
||||
).asItems().mapNotNull { node ->
|
||||
val itemId = node.fieldText("Id") ?: return@mapNotNull null
|
||||
val providerIds = node["ProviderIds"]
|
||||
val imdbId = providerIds?.fieldText("Imdb")
|
||||
val people = node["People"]
|
||||
val cast = people?.peopleByType("Actor", "GuestStar") ?: emptyList()
|
||||
val directors = people?.peopleByType("Director") ?: emptyList()
|
||||
JellyfinLibraryItemSnapshot(
|
||||
jellyfinItemId = itemId,
|
||||
title = node.fieldText("Name") ?: itemId,
|
||||
description = node.fieldText("Overview") ?: "",
|
||||
contentType = mapContentType(node.fieldText("Type")),
|
||||
releaseYear = node["ProductionYear"]?.takeUnless { it.isNull }?.asInt(),
|
||||
genres = node["Genres"]?.textList() ?: emptyList(),
|
||||
cast = cast,
|
||||
directors = directors,
|
||||
platformRating = node["CommunityRating"]?.takeUnless { it.isNull }?.asDouble(),
|
||||
imdbRating = null,
|
||||
externalUrl = imdbId?.let { "https://www.imdb.com/title/$it/" },
|
||||
jellyfinLibraryId = node.fieldText("ParentId"),
|
||||
isPlayed =
|
||||
node["UserData"]?.booleanField("Played") ?: node["UserData"]?.booleanField("IsPlayed") ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun request(path: String): JsonNode {
|
||||
val uri = URI.create("${properties.baseUrl.trimEnd('/')}/$path")
|
||||
val request =
|
||||
HttpRequest
|
||||
.newBuilder(uri)
|
||||
.timeout(Duration.ofMillis(properties.requestTimeoutMs))
|
||||
.header("Accept", "application/json")
|
||||
.header("X-Emby-Token", properties.apiKey)
|
||||
.GET()
|
||||
.build()
|
||||
|
||||
val response =
|
||||
try {
|
||||
httpClient.send(request, HttpResponse.BodyHandlers.ofString())
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") exception: Exception,
|
||||
) {
|
||||
throw IllegalStateException("Failed to call Jellyfin at $uri", exception)
|
||||
}
|
||||
|
||||
check(response.statusCode() in 200..299) {
|
||||
"Jellyfin request failed with status ${response.statusCode()} for $uri"
|
||||
}
|
||||
|
||||
return objectMapper.readTree(response.body())
|
||||
}
|
||||
|
||||
private fun JsonNode.asItems(): List<JsonNode> =
|
||||
when {
|
||||
isArray -> map { it }
|
||||
has("Items") && this["Items"].isArray -> this["Items"].map { it }
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
private fun JsonNode.fieldText(name: String): String? =
|
||||
get(name)?.takeUnless { it.isNull }?.asText()?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun JsonNode.booleanField(name: String): Boolean? = get(name)?.takeUnless { it.isNull }?.asBoolean()
|
||||
|
||||
private fun JsonNode.textList(): List<String> =
|
||||
takeIf { it.isArray }?.mapNotNull { item ->
|
||||
item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() }
|
||||
}
|
||||
?: emptyList()
|
||||
|
||||
private fun JsonNode.peopleByType(vararg types: String): List<String> {
|
||||
if (!isArray) return emptyList()
|
||||
return mapNotNull { person ->
|
||||
val type = person.fieldText("Type") ?: return@mapNotNull null
|
||||
if (types.any { it.equals(type, ignoreCase = true) }) person.fieldText("Name") else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapContentType(value: String?): ContentType =
|
||||
when (value?.lowercase()) {
|
||||
"movie" -> ContentType.FILM
|
||||
"series" -> ContentType.SERIES
|
||||
"episode" -> ContentType.EPISODE
|
||||
else -> ContentType.OTHER
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.project.movienight.adapters.metrics
|
||||
|
||||
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||
import com.project.movienight.domain.model.RecommendationEventType
|
||||
import io.micrometer.core.instrument.Counter
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import io.micrometer.core.instrument.Timer
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@Service
|
||||
class BusinessMetricsService(
|
||||
private val meterRegistry: MeterRegistry,
|
||||
) : BusinessMetricsPort {
|
||||
private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total")
|
||||
private val filmsCreated: Counter = meterRegistry.counter("business_films_created_total")
|
||||
private val filmsEdited: Counter = meterRegistry.counter("business_films_edited_total")
|
||||
private val filmsDeleted: Counter = meterRegistry.counter("business_films_deleted_total")
|
||||
private val filmsBlocked: Counter = meterRegistry.counter("business_films_blocked_total")
|
||||
private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total")
|
||||
private val libraryEvents: Counter = meterRegistry.counter("business_library_events_total")
|
||||
private val jellyfinSyncRuns: Counter = meterRegistry.counter("business_jellyfin_sync_runs_total")
|
||||
private val jellyfinSyncedUsers: Counter = meterRegistry.counter("business_jellyfin_synced_users_total")
|
||||
private val jellyfinSkippedUsers: Counter = meterRegistry.counter("business_jellyfin_skipped_users_total")
|
||||
private val jellyfinSyncedItems: Counter = meterRegistry.counter("business_jellyfin_synced_items_total")
|
||||
private val jellyfinSyncDuration: Timer =
|
||||
Timer
|
||||
.builder("business_jellyfin_sync_duration")
|
||||
.publishPercentileHistogram()
|
||||
.register(meterRegistry)
|
||||
private val jellyfinSyncFailures: Counter = meterRegistry.counter("business_jellyfin_sync_failures_total")
|
||||
private val jellyfinUnmappedUsersGaugeValue = AtomicInteger(0)
|
||||
|
||||
init {
|
||||
meterRegistry.gauge("business_jellyfin_unmapped_users", jellyfinUnmappedUsersGaugeValue)
|
||||
}
|
||||
|
||||
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
|
||||
|
||||
override fun recordFilmCreated() {
|
||||
filmsCreated.increment()
|
||||
}
|
||||
|
||||
override fun recordFilmEdited() {
|
||||
filmsEdited.increment()
|
||||
}
|
||||
|
||||
override fun recordFilmDeleted() {
|
||||
filmsDeleted.increment()
|
||||
}
|
||||
|
||||
override fun recordFilmBlocked() {
|
||||
filmsBlocked.increment()
|
||||
}
|
||||
|
||||
override fun recordRecommendationRequest() {
|
||||
recommendationRequests.increment()
|
||||
}
|
||||
|
||||
override fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
|
||||
Counter
|
||||
.builder("recommendation_weights_updated_total")
|
||||
.tag("eventType", eventType.name)
|
||||
.register(meterRegistry)
|
||||
.increment()
|
||||
}
|
||||
|
||||
override fun recordRatingSubmitted() {
|
||||
ratingsSubmitted.increment()
|
||||
}
|
||||
|
||||
override fun recordLibraryEvent() {
|
||||
libraryEvents.increment()
|
||||
}
|
||||
|
||||
override fun recordJellyfinSync(summary: JellyfinSyncSummary) {
|
||||
jellyfinSyncRuns.increment()
|
||||
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
|
||||
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
|
||||
jellyfinSyncedItems.increment(summary.syncedItems.toDouble())
|
||||
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
override fun recordJellyfinSyncFailure() {
|
||||
jellyfinSyncFailures.increment()
|
||||
}
|
||||
|
||||
override fun recordJellyfinUnmappedUser() {
|
||||
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
|
||||
}
|
||||
|
||||
override fun recordBackendWriteFailure() {
|
||||
backendWriteFailures.increment()
|
||||
}
|
||||
}
|
||||
+37
@@ -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,
|
||||
)
|
||||
+31
@@ -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,
|
||||
)
|
||||
+41
@@ -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 }),
|
||||
)
|
||||
+96
@@ -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)
|
||||
}
|
||||
}
|
||||
-73
@@ -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)
|
||||
}
|
||||
}
|
||||
+117
@@ -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()
|
||||
}
|
||||
+159
-6
@@ -1,6 +1,8 @@
|
||||
package com.project.movienight.adapters.persistence.jdbc
|
||||
|
||||
import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.Film
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
@@ -16,6 +18,21 @@ class FilmRepository(
|
||||
id = UUID.fromString(rs.getString("id")),
|
||||
title = rs.getString("title"),
|
||||
description = rs.getString("description"),
|
||||
contentType =
|
||||
runCatching {
|
||||
ContentType.valueOf(
|
||||
rs.getString("content_type"),
|
||||
)
|
||||
}.getOrDefault(ContentType.FILM),
|
||||
releaseYear = rs.getObject("release_year")?.let { (it as Number).toInt() },
|
||||
genres = DelimitedValueCodec.decodeList(rs.getString("genres")),
|
||||
cast = DelimitedValueCodec.decodeList(rs.getString("cast_members")),
|
||||
directors = DelimitedValueCodec.decodeList(rs.getString("directors")),
|
||||
imdbRating = rs.getObject("imdb_rating")?.let { (it as Number).toDouble() },
|
||||
platformRating = rs.getObject("platform_rating")?.let { (it as Number).toDouble() },
|
||||
externalUrl = rs.getString("external_url"),
|
||||
jellyfinItemId = rs.getString("jellyfin_item_id"),
|
||||
jellyfinLibraryId = rs.getString("jellyfin_library_id"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,22 +41,67 @@ class FilmRepository(
|
||||
jdbc.update(
|
||||
"""
|
||||
UPDATE films
|
||||
SET title = ?, description = ?
|
||||
SET title = ?,
|
||||
description = ?,
|
||||
content_type = ?,
|
||||
release_year = ?,
|
||||
genres = ?,
|
||||
cast_members = ?,
|
||||
directors = ?,
|
||||
imdb_rating = ?,
|
||||
platform_rating = ?,
|
||||
external_url = ?,
|
||||
jellyfin_item_id = ?,
|
||||
jellyfin_library_id = ?
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
film.title,
|
||||
film.description,
|
||||
film.contentType.name,
|
||||
film.releaseYear,
|
||||
DelimitedValueCodec.encodeList(film.genres),
|
||||
DelimitedValueCodec.encodeList(film.cast),
|
||||
DelimitedValueCodec.encodeList(film.directors),
|
||||
film.imdbRating,
|
||||
film.platformRating,
|
||||
film.externalUrl,
|
||||
film.jellyfinItemId,
|
||||
film.jellyfinLibraryId,
|
||||
film.id,
|
||||
)
|
||||
if (updatedRows == 0) {
|
||||
jdbc.update(
|
||||
"""
|
||||
INSERT INTO films (id, title, description)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO films (
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
content_type,
|
||||
release_year,
|
||||
genres,
|
||||
cast_members,
|
||||
directors,
|
||||
imdb_rating,
|
||||
platform_rating,
|
||||
external_url,
|
||||
jellyfin_item_id,
|
||||
jellyfin_library_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
film.id,
|
||||
film.title,
|
||||
film.description,
|
||||
film.contentType.name,
|
||||
film.releaseYear,
|
||||
DelimitedValueCodec.encodeList(film.genres),
|
||||
DelimitedValueCodec.encodeList(film.cast),
|
||||
DelimitedValueCodec.encodeList(film.directors),
|
||||
film.imdbRating,
|
||||
film.platformRating,
|
||||
film.externalUrl,
|
||||
film.jellyfinItemId,
|
||||
film.jellyfinLibraryId,
|
||||
)
|
||||
}
|
||||
return film
|
||||
@@ -48,20 +110,111 @@ class FilmRepository(
|
||||
override fun findById(id: UUID): Film? {
|
||||
val films =
|
||||
jdbc.query(
|
||||
"SELECT id, title, description FROM films WHERE id = ?",
|
||||
"""
|
||||
SELECT id,
|
||||
title,
|
||||
description,
|
||||
content_type,
|
||||
release_year,
|
||||
genres,
|
||||
cast_members,
|
||||
directors,
|
||||
imdb_rating,
|
||||
platform_rating,
|
||||
external_url,
|
||||
jellyfin_item_id,
|
||||
jellyfin_library_id
|
||||
FROM films
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
filmRowMapper,
|
||||
id,
|
||||
)
|
||||
return films.firstOrNull()
|
||||
}
|
||||
|
||||
override fun findByJellyfinItemId(jellyfinItemId: String): Film? {
|
||||
val films =
|
||||
jdbc.query(
|
||||
"""
|
||||
SELECT id,
|
||||
title,
|
||||
description,
|
||||
content_type,
|
||||
release_year,
|
||||
genres,
|
||||
cast_members,
|
||||
directors,
|
||||
imdb_rating,
|
||||
platform_rating,
|
||||
external_url,
|
||||
jellyfin_item_id,
|
||||
jellyfin_library_id
|
||||
FROM films
|
||||
WHERE jellyfin_item_id = ?
|
||||
""".trimIndent(),
|
||||
filmRowMapper,
|
||||
jellyfinItemId,
|
||||
)
|
||||
return films.firstOrNull()
|
||||
}
|
||||
|
||||
override fun findAll(): List<Film> =
|
||||
jdbc.query(
|
||||
"SELECT id, title, description FROM films",
|
||||
"""
|
||||
SELECT id,
|
||||
title,
|
||||
description,
|
||||
content_type,
|
||||
release_year,
|
||||
genres,
|
||||
cast_members,
|
||||
directors,
|
||||
imdb_rating,
|
||||
platform_rating,
|
||||
external_url,
|
||||
jellyfin_item_id,
|
||||
jellyfin_library_id
|
||||
FROM films
|
||||
""".trimIndent(),
|
||||
filmRowMapper,
|
||||
)
|
||||
|
||||
override fun findByTitle(title: String): Film? {
|
||||
val films =
|
||||
jdbc.query(
|
||||
"""
|
||||
SELECT id,
|
||||
title,
|
||||
description,
|
||||
content_type,
|
||||
release_year,
|
||||
genres,
|
||||
cast_members,
|
||||
directors,
|
||||
imdb_rating,
|
||||
platform_rating,
|
||||
external_url,
|
||||
jellyfin_item_id,
|
||||
jellyfin_library_id
|
||||
FROM films
|
||||
WHERE title = ?
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
""".trimIndent(),
|
||||
filmRowMapper,
|
||||
title,
|
||||
)
|
||||
return films.firstOrNull()
|
||||
}
|
||||
|
||||
override fun deleteById(id: UUID) {
|
||||
jdbc.update("DELETE FROM films WHERE id = ?", id)
|
||||
jdbc.update(
|
||||
"""
|
||||
DELETE FROM films
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -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
|
||||
}
|
||||
}
|
||||
+100
@@ -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() }
|
||||
}
|
||||
+116
@@ -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()
|
||||
}
|
||||
+97
@@ -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()
|
||||
}
|
||||
+130
@@ -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
|
||||
}
|
||||
}
|
||||
+175
-20
@@ -1,6 +1,11 @@
|
||||
package com.project.movienight.adapters.persistence.jdbc
|
||||
|
||||
import com.project.movienight.adapters.persistence.entity.UserEntity
|
||||
import com.project.movienight.adapters.persistence.entity.toDomain
|
||||
import com.project.movienight.adapters.persistence.entity.toEntity
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import com.project.movienight.domain.model.AuthProvider
|
||||
import com.project.movienight.domain.model.User
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
@@ -11,58 +16,208 @@ import java.util.UUID
|
||||
class UserRepository(
|
||||
private val jdbc: JdbcTemplate,
|
||||
) : UserRepositoryPort {
|
||||
private val userRowMapper = { rs: ResultSet, _: Int ->
|
||||
User(
|
||||
private val userEntityRowMapper = { rs: ResultSet, _: Int ->
|
||||
UserEntity(
|
||||
id = UUID.fromString(rs.getString("id")),
|
||||
name = rs.getString("name"),
|
||||
email = rs.getString("email"),
|
||||
library = null,
|
||||
provider = rs.getString("provider"),
|
||||
providerId = rs.getString("provider_id"),
|
||||
jellyfinUserId = rs.getString("jellyfin_user_id"),
|
||||
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun save(user: User): User {
|
||||
val existingUser = findById(user.id)
|
||||
|
||||
val entity =
|
||||
if (existingUser != null) {
|
||||
user.toEntity(
|
||||
provider = findProviderById(user.id),
|
||||
providerId = findProviderIdById(user.id),
|
||||
)
|
||||
} else {
|
||||
user.toEntity()
|
||||
}
|
||||
|
||||
val updatedRows =
|
||||
jdbc.update(
|
||||
"""
|
||||
UPDATE users
|
||||
SET name = ?, email = ?
|
||||
SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ?
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
user.name,
|
||||
user.email,
|
||||
user.id,
|
||||
entity.name,
|
||||
entity.email,
|
||||
entity.provider,
|
||||
entity.providerId,
|
||||
entity.jellyfinUserId,
|
||||
entity.id,
|
||||
)
|
||||
|
||||
if (updatedRows == 0) {
|
||||
jdbc.update(
|
||||
"""
|
||||
INSERT INTO users (id, name, email)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
user.id,
|
||||
user.name,
|
||||
user.email,
|
||||
entity.id,
|
||||
entity.name,
|
||||
entity.email,
|
||||
entity.provider,
|
||||
entity.providerId,
|
||||
entity.jellyfinUserId,
|
||||
entity.createdAt,
|
||||
)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
override fun createOAuthUser(
|
||||
user: User,
|
||||
provider: AuthProvider,
|
||||
providerId: String,
|
||||
): User {
|
||||
val entity = user.toEntity(provider = provider, providerId = providerId)
|
||||
val updatedRows =
|
||||
jdbc.update(
|
||||
"""
|
||||
UPDATE users
|
||||
SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ?
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
entity.name,
|
||||
entity.email,
|
||||
entity.provider,
|
||||
entity.providerId,
|
||||
entity.jellyfinUserId,
|
||||
entity.id,
|
||||
)
|
||||
|
||||
if (updatedRows == 0) {
|
||||
jdbc.update(
|
||||
"""
|
||||
INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
entity.id,
|
||||
entity.name,
|
||||
entity.email,
|
||||
entity.provider,
|
||||
entity.providerId,
|
||||
entity.jellyfinUserId,
|
||||
entity.createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
return findById(user.id) ?: user
|
||||
}
|
||||
|
||||
override fun linkOAuthAccount(
|
||||
userId: UUID,
|
||||
provider: AuthProvider,
|
||||
providerId: String,
|
||||
): User {
|
||||
val updatedRows =
|
||||
jdbc.update(
|
||||
"""
|
||||
UPDATE users
|
||||
SET provider = ?, provider_id = ?
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
provider.name,
|
||||
providerId,
|
||||
userId,
|
||||
)
|
||||
|
||||
if (updatedRows == 0) {
|
||||
throw EntityNotFoundException(entity = "User", id = userId.toString())
|
||||
}
|
||||
|
||||
return findById(userId) ?: throw EntityNotFoundException(entity = "User", id = userId.toString())
|
||||
}
|
||||
|
||||
override fun findById(id: UUID): User? {
|
||||
val users =
|
||||
val entities =
|
||||
jdbc.query(
|
||||
"SELECT id, name, email FROM users WHERE id = ?",
|
||||
userRowMapper,
|
||||
"SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users WHERE id = ?",
|
||||
userEntityRowMapper,
|
||||
id,
|
||||
)
|
||||
return users.firstOrNull()
|
||||
return entities.firstOrNull()?.toDomain()
|
||||
}
|
||||
|
||||
override fun findByEmail(email: String): User? {
|
||||
val entities =
|
||||
jdbc.query(
|
||||
"""
|
||||
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at
|
||||
FROM users
|
||||
WHERE email = ?
|
||||
""".trimIndent(),
|
||||
userEntityRowMapper,
|
||||
email,
|
||||
)
|
||||
return entities.firstOrNull()?.toDomain()
|
||||
}
|
||||
|
||||
override fun findByJellyfinUserId(jellyfinUserId: String): User? {
|
||||
val entities =
|
||||
jdbc.query(
|
||||
"""
|
||||
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at
|
||||
FROM users
|
||||
WHERE jellyfin_user_id = ?
|
||||
""".trimIndent(),
|
||||
userEntityRowMapper,
|
||||
jellyfinUserId,
|
||||
)
|
||||
return entities.firstOrNull()?.toDomain()
|
||||
}
|
||||
|
||||
override fun findAll(): List<User> =
|
||||
jdbc.query(
|
||||
"SELECT id, name, email FROM users",
|
||||
userRowMapper,
|
||||
)
|
||||
jdbc
|
||||
.query(
|
||||
"SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users",
|
||||
userEntityRowMapper,
|
||||
).map { it.toDomain() }
|
||||
|
||||
override fun deleteById(id: UUID) {
|
||||
jdbc.update("DELETE FROM users WHERE id = ?", id)
|
||||
}
|
||||
|
||||
override fun findByProviderAndProviderId(
|
||||
provider: AuthProvider,
|
||||
providerId: String,
|
||||
): User? {
|
||||
val entities =
|
||||
jdbc.query(
|
||||
"""
|
||||
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users
|
||||
WHERE provider = ? AND provider_id = ?
|
||||
""".trimIndent(),
|
||||
userEntityRowMapper,
|
||||
provider.name,
|
||||
providerId,
|
||||
)
|
||||
return entities.firstOrNull()?.toDomain()
|
||||
}
|
||||
|
||||
private fun findProviderById(id: UUID): AuthProvider? =
|
||||
jdbc
|
||||
.query(
|
||||
"SELECT provider FROM users WHERE id = ?",
|
||||
{ rs: ResultSet, _: Int -> rs.getString("provider") },
|
||||
id,
|
||||
).firstOrNull()
|
||||
?.let { AuthProvider.valueOf(it) }
|
||||
|
||||
private fun findProviderIdById(id: UUID): String? =
|
||||
jdbc
|
||||
.query(
|
||||
"SELECT provider_id FROM users WHERE id = ?",
|
||||
{ rs: ResultSet, _: Int -> rs.getString("provider_id") },
|
||||
id,
|
||||
).firstOrNull()
|
||||
}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.project.movienight.adapters.persistence.jdbc.support
|
||||
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
object DelimitedValueCodec {
|
||||
fun encodeList(values: List<String>): String = values.joinToString("|") { encode(it) }
|
||||
|
||||
fun decodeList(value: String?): List<String> =
|
||||
value
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.split("|")
|
||||
?.map { decode(it) }
|
||||
?: emptyList()
|
||||
|
||||
fun encodeWeightedMap(values: Map<String, Int>): String =
|
||||
values.entries.joinToString("|") { entry -> "${encode(entry.key)}:${entry.value}" }
|
||||
|
||||
fun decodeWeightedMap(value: String?): Map<String, Int> {
|
||||
if (value.isNullOrBlank()) return emptyMap()
|
||||
|
||||
return value
|
||||
.split("|")
|
||||
.mapNotNull { pair ->
|
||||
val parts = pair.split(":", limit = 2)
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
|
||||
val key = decode(parts[0])
|
||||
val weight = parts[1].toIntOrNull() ?: return@mapNotNull null
|
||||
key to weight
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
|
||||
|
||||
private fun decode(value: String): String = URLDecoder.decode(value, StandardCharsets.UTF_8)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import com.project.movienight.domain.model.AuthProvider
|
||||
import com.project.movienight.domain.model.User
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class CustomOAuth2UserService(
|
||||
private val userRepository: UserRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
) : DefaultOAuth2UserService() {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java)
|
||||
}
|
||||
|
||||
override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User {
|
||||
val oAuth2User = super.loadUser(userRequest)
|
||||
val registrationId = userRequest.clientRegistration.registrationId
|
||||
|
||||
log.debug("Processing OAuth2 login for provider: {}", registrationId)
|
||||
|
||||
return try {
|
||||
val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User)
|
||||
val user = findOrCreateUser(userInfo)
|
||||
UserPrincipal.create(user, oAuth2User.attributes)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
log.error("OAuth2 authentication failed: ${e.message}", e)
|
||||
throw OAuth2AuthenticationException("Failed to process OAuth2 user data")
|
||||
} catch (e: OAuth2AuthenticationException) {
|
||||
log.error("OAuth2 authentication failed: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun findOrCreateUser(userInfo: OAuth2UserInfo): User {
|
||||
val provider = AuthProvider.valueOf(userInfo.getProvider().uppercase())
|
||||
|
||||
val existingUser =
|
||||
userRepository.findByProviderAndProviderId(
|
||||
provider,
|
||||
userInfo.getProviderId(),
|
||||
)
|
||||
|
||||
return if (existingUser != null) {
|
||||
log.debug("User found by provider: {}", userInfo.getProvider())
|
||||
existingUser
|
||||
} else {
|
||||
val userByEmail = userRepository.findByEmail(userInfo.getEmail())
|
||||
|
||||
if (userByEmail != null) {
|
||||
log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail())
|
||||
userRepository.linkOAuthAccount(
|
||||
userId = userByEmail.id,
|
||||
provider = provider,
|
||||
providerId = userInfo.getProviderId(),
|
||||
)
|
||||
} else {
|
||||
log.debug("Creating new user for provider: {}", userInfo.getProvider())
|
||||
val newUser =
|
||||
User(
|
||||
id = idGenerator.generateId(),
|
||||
name = userInfo.getName(),
|
||||
email = userInfo.getEmail(),
|
||||
)
|
||||
userRepository.createOAuthUser(
|
||||
user = newUser,
|
||||
provider = provider,
|
||||
providerId = userInfo.getProviderId(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||
|
||||
class GoogleOAuth2UserInfo(
|
||||
private val attributes: Map<String, Any>,
|
||||
) : OAuth2UserInfo {
|
||||
override fun getProviderId(): String = attributes["sub"] as String
|
||||
|
||||
override fun getEmail(): String = attributes["email"] as String
|
||||
|
||||
override fun getName(): String = attributes["name"] as String
|
||||
|
||||
override fun getProvider(): String = "google"
|
||||
|
||||
override fun getAttributes(): Map<String, Any> = attributes
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||
|
||||
object OAuth2UserInfoFactory {
|
||||
fun getOAuth2UserInfo(
|
||||
registrationId: String,
|
||||
user: OAuth2User,
|
||||
): OAuth2UserInfo {
|
||||
val attributes = user.attributes
|
||||
|
||||
return when (registrationId.lowercase()) {
|
||||
"google" -> GoogleOAuth2UserInfo(attributes)
|
||||
"yandex" -> YandexOAuth2UserInfo(attributes)
|
||||
"vk" -> VkOAuth2UserInfo(attributes)
|
||||
else -> throw OAuth2AuthenticationException("Unknown provider: $registrationId")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
class SecurityConfiguration(
|
||||
private val customOAuth2UserService: CustomOAuth2UserService,
|
||||
) {
|
||||
@Bean
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http
|
||||
.oauth2Login { oauth2 ->
|
||||
oauth2
|
||||
.userInfoEndpoint { userInfo ->
|
||||
userInfo.userService(customOAuth2UserService)
|
||||
}.defaultSuccessUrl("/api/users/me", true)
|
||||
}.authorizeHttpRequests { auth ->
|
||||
auth
|
||||
.requestMatchers(
|
||||
"/",
|
||||
"/login/**",
|
||||
"/oauth2/**",
|
||||
"/h2-console/**",
|
||||
"/actuator/health",
|
||||
"/actuator/health/**",
|
||||
"/actuator/prometheus",
|
||||
).permitAll()
|
||||
.requestMatchers("/api/v1/docs/**", "/api/v1/swagger-ui/**", "/swagger-ui/**")
|
||||
.permitAll()
|
||||
.requestMatchers(
|
||||
"/api/integrations/jellyfin/**",
|
||||
).permitAll()
|
||||
.requestMatchers("/api/users/me")
|
||||
.authenticated()
|
||||
.requestMatchers("/api/**")
|
||||
.authenticated()
|
||||
.anyRequest()
|
||||
.authenticated()
|
||||
}.headers { headers ->
|
||||
headers.frameOptions { frameOptions ->
|
||||
frameOptions.sameOrigin()
|
||||
}
|
||||
}.csrf { csrf ->
|
||||
csrf.disable()
|
||||
}
|
||||
|
||||
return http.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import com.project.movienight.domain.model.User
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||
import java.util.UUID
|
||||
|
||||
class UserPrincipal(
|
||||
private val user: User,
|
||||
private val attributes: Map<String, Any>? = null,
|
||||
) : OAuth2User,
|
||||
UserDetails {
|
||||
fun getId(): UUID = user.id
|
||||
|
||||
override fun getName(): String = user.name
|
||||
|
||||
override fun getAttributes(): Map<String, Any> = attributes ?: emptyMap()
|
||||
|
||||
override fun getAuthorities(): Collection<GrantedAuthority> =
|
||||
listOf(
|
||||
SimpleGrantedAuthority("ROLE_USER"),
|
||||
)
|
||||
|
||||
override fun getPassword(): String = ""
|
||||
|
||||
override fun getUsername(): String = user.email
|
||||
|
||||
override fun isAccountNonExpired(): Boolean = true
|
||||
|
||||
override fun isAccountNonLocked(): Boolean = true
|
||||
|
||||
override fun isCredentialsNonExpired(): Boolean = true
|
||||
|
||||
override fun isEnabled(): Boolean = true
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
user: User,
|
||||
attributes: Map<String, Any>? = null,
|
||||
): UserPrincipal = UserPrincipal(user, attributes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||
|
||||
class VkOAuth2UserInfo(
|
||||
private val attributes: Map<String, Any>,
|
||||
) : OAuth2UserInfo {
|
||||
override fun getProviderId(): String =
|
||||
(attributes["response"] as? List<*>)
|
||||
?.firstOrNull()
|
||||
?.let { it as? Map<*, *> }
|
||||
?.get("id")
|
||||
?.toString() ?: ""
|
||||
|
||||
override fun getEmail(): String = attributes["email"]?.toString() ?: ""
|
||||
|
||||
override fun getName(): String {
|
||||
val response = attributes["response"] as? List<*>
|
||||
val first = response?.firstOrNull() as? Map<*, *>
|
||||
val firstName = first?.get("first_name")?.toString() ?: ""
|
||||
val lastName = first?.get("last_name")?.toString() ?: ""
|
||||
return "$firstName $lastName".trim()
|
||||
}
|
||||
|
||||
override fun getProvider(): String = "vk"
|
||||
|
||||
override fun getAttributes(): Map<String, Any> = attributes
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||
|
||||
class YandexOAuth2UserInfo(
|
||||
private val attributes: Map<String, Any>,
|
||||
) : OAuth2UserInfo {
|
||||
override fun getProviderId(): String = attributes["id"]?.toString() ?: ""
|
||||
|
||||
override fun getEmail(): String =
|
||||
(attributes["emails"] as? List<*>)
|
||||
?.firstOrNull()
|
||||
?.let { it as? Map<*, *> }
|
||||
?.get("value")
|
||||
?.toString() ?: ""
|
||||
|
||||
override fun getName(): String = attributes["display_name"]?.toString() ?: ""
|
||||
|
||||
override fun getProvider(): String = "yandex"
|
||||
|
||||
override fun getAttributes(): Map<String, Any> = attributes
|
||||
}
|
||||
@@ -3,29 +3,111 @@ package com.project.movienight.adapters.web
|
||||
import com.project.movienight.domain.exception.BlockedValueException
|
||||
import com.project.movienight.domain.exception.DomainException
|
||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException
|
||||
|
||||
@RestControllerAdvice
|
||||
class ApiExceptionHandler {
|
||||
@ExceptionHandler(EntityNotFoundException::class)
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@ExceptionHandler(EntityNotFoundException::class, NoResourceFoundException::class)
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse =
|
||||
ErrorResponse(message = exception.message ?: "Entity not found")
|
||||
fun handleNotFound(exception: Exception): ErrorResponse {
|
||||
val traceId = currentTraceId()
|
||||
log.warn("Resource not found: traceId='{}', message='{}'", traceId, exception.message)
|
||||
|
||||
return ErrorResponse(
|
||||
message = exception.message ?: "Resource not found",
|
||||
traceId = traceId,
|
||||
)
|
||||
}
|
||||
|
||||
@ExceptionHandler(BlockedValueException::class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse =
|
||||
ErrorResponse(message = exception.message ?: "Blocked value")
|
||||
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse {
|
||||
val traceId = currentTraceId()
|
||||
log.warn("Blocked value: traceId='{}', message='{}'", traceId, exception.message)
|
||||
|
||||
return ErrorResponse(
|
||||
message = exception.message ?: "Blocked value",
|
||||
traceId = traceId,
|
||||
)
|
||||
}
|
||||
|
||||
@ExceptionHandler(DomainException::class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
fun handleDomainException(exception: DomainException): ErrorResponse =
|
||||
ErrorResponse(message = exception.message ?: "Domain error")
|
||||
fun handleDomainException(exception: DomainException): ErrorResponse {
|
||||
val traceId = currentTraceId()
|
||||
log.warn("Domain error: traceId='{}', message='{}'", traceId, exception.message)
|
||||
|
||||
return ErrorResponse(
|
||||
message = exception.message ?: "Domain error",
|
||||
traceId = traceId,
|
||||
)
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException::class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
fun handleValidationException(exception: MethodArgumentNotValidException): ErrorResponse {
|
||||
val traceId = currentTraceId()
|
||||
val details =
|
||||
exception
|
||||
.bindingResult
|
||||
.fieldErrors
|
||||
.joinToString("; ") { error -> "${error.field}: ${error.defaultMessage}" }
|
||||
.ifBlank { "Invalid request" }
|
||||
log.warn("Validation error: traceId='{}', message='{}'", traceId, details)
|
||||
|
||||
return ErrorResponse(
|
||||
message = details,
|
||||
traceId = traceId,
|
||||
)
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException::class)
|
||||
fun handleResponseStatusException(exception: ResponseStatusException): ResponseEntity<ErrorResponse> {
|
||||
val traceId = currentTraceId()
|
||||
log.warn(
|
||||
"HTTP error: traceId='{}', status='{}', message='{}'",
|
||||
traceId,
|
||||
exception.statusCode,
|
||||
exception.reason,
|
||||
)
|
||||
|
||||
return ResponseEntity
|
||||
.status(exception.statusCode)
|
||||
.body(
|
||||
ErrorResponse(
|
||||
message = exception.reason ?: exception.message,
|
||||
traceId = traceId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception::class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
fun handleUnexpectedException(exception: Exception): ErrorResponse {
|
||||
val traceId = currentTraceId()
|
||||
log.error("Unexpected error: traceId='{}'", traceId, exception)
|
||||
|
||||
return ErrorResponse(
|
||||
message = "Internal server error",
|
||||
traceId = traceId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun currentTraceId(): String = MDC.get("traceId") ?: "unknown"
|
||||
}
|
||||
|
||||
data class ErrorResponse(
|
||||
val message: String,
|
||||
val traceId: String,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.domain.exception.DomainException
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
|
||||
fun parseContentType(value: String): ContentType =
|
||||
runCatching { ContentType.valueOf(value.uppercase()) }
|
||||
.getOrElse { throw DomainException("Unsupported content type: $value") }
|
||||
|
||||
fun parseOptionalContentType(value: String?): ContentType? = value?.let { parseContentType(it) }
|
||||
@@ -4,17 +4,19 @@ import com.project.movienight.adapters.web.dto.request.CreateFilmRequest
|
||||
import com.project.movienight.adapters.web.dto.request.EditFilmRequest
|
||||
import com.project.movienight.adapters.web.dto.response.FilmResponse
|
||||
import com.project.movienight.application.ports.input.CreateFilmCommand
|
||||
import com.project.movienight.application.ports.input.CreateFilmUseCase
|
||||
import com.project.movienight.application.ports.input.DeleteFilmUseCase
|
||||
import com.project.movienight.application.ports.input.EditFilmCommand
|
||||
import com.project.movienight.application.ports.input.EditFilmUseCase
|
||||
import com.project.movienight.application.ports.input.FilmUseCase
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PatchMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
@@ -22,20 +24,28 @@ import java.util.UUID
|
||||
@RestController
|
||||
@RequestMapping("/api/films")
|
||||
class FilmController(
|
||||
private val createFilmUseCase: CreateFilmUseCase,
|
||||
private val editFilmUseCase: EditFilmUseCase,
|
||||
private val deleteFilmUseCase: DeleteFilmUseCase,
|
||||
private val filmUseCase: FilmUseCase,
|
||||
) {
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun create(
|
||||
@RequestBody request: CreateFilmRequest,
|
||||
@Valid @RequestBody request: CreateFilmRequest,
|
||||
): FilmResponse =
|
||||
FilmResponse.fromDomain(
|
||||
createFilmUseCase.create(
|
||||
filmUseCase.create(
|
||||
CreateFilmCommand(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
contentType = parseContentType(request.contentType),
|
||||
releaseYear = request.releaseYear,
|
||||
genres = request.genres,
|
||||
cast = request.cast,
|
||||
directors = request.directors,
|
||||
imdbRating = request.imdbRating,
|
||||
platformRating = request.platformRating,
|
||||
externalUrl = request.externalUrl,
|
||||
jellyfinItemId = request.jellyfinItemId,
|
||||
jellyfinLibraryId = request.jellyfinLibraryId,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -43,15 +53,25 @@ class FilmController(
|
||||
@PatchMapping("/{id}")
|
||||
fun edit(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody request: EditFilmRequest,
|
||||
@Valid @RequestBody request: EditFilmRequest,
|
||||
): FilmResponse =
|
||||
FilmResponse.fromDomain(
|
||||
editFilmUseCase.edit(
|
||||
filmUseCase.edit(
|
||||
id = id,
|
||||
command =
|
||||
EditFilmCommand(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
contentType = parseContentType(request.contentType),
|
||||
releaseYear = request.releaseYear,
|
||||
genres = request.genres,
|
||||
cast = request.cast,
|
||||
directors = request.directors,
|
||||
imdbRating = request.imdbRating,
|
||||
platformRating = request.platformRating,
|
||||
externalUrl = request.externalUrl,
|
||||
jellyfinItemId = request.jellyfinItemId,
|
||||
jellyfinLibraryId = request.jellyfinLibraryId,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -60,5 +80,25 @@ class FilmController(
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
) = deleteFilmUseCase.delete(id)
|
||||
) = filmUseCase.delete(id)
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun getById(
|
||||
@PathVariable id: UUID,
|
||||
): FilmResponse = FilmResponse.fromDomain(filmUseCase.getById(id))
|
||||
|
||||
@GetMapping
|
||||
fun getAll(): List<FilmResponse> = filmUseCase.getAll().map { FilmResponse.fromDomain(it) }
|
||||
|
||||
@GetMapping("/search")
|
||||
fun searchByTitle(
|
||||
@RequestParam title: String,
|
||||
): ResponseEntity<FilmResponse> {
|
||||
val film = filmUseCase.searchByTitle(title)
|
||||
return if (film != null) {
|
||||
ResponseEntity.ok(FilmResponse.fromDomain(film))
|
||||
} else {
|
||||
ResponseEntity.notFound().build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.CreateFilmLibraryRequest
|
||||
import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse
|
||||
import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse
|
||||
import com.project.movienight.adapters.web.dto.response.FilmResponse
|
||||
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
|
||||
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
|
||||
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
|
||||
import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase
|
||||
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
|
||||
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
|
||||
import com.project.movienight.application.ports.input.FilmLibraryUseCase
|
||||
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
|
||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
@@ -24,44 +19,29 @@ import java.util.UUID
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/library")
|
||||
class FilmLibraryController(
|
||||
private val createFilmLibraryUseCase: CreateFilmLibraryUseCase,
|
||||
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
|
||||
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
|
||||
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
|
||||
private val filmLibraryUseCase: FilmLibraryUseCase,
|
||||
) {
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun create(
|
||||
@PathVariable userId: UUID,
|
||||
@RequestBody request: CreateFilmLibraryRequest,
|
||||
): FilmLibraryResponse =
|
||||
FilmLibraryResponse.fromDomain(
|
||||
createFilmLibraryUseCase.create(
|
||||
CreateFilmLibraryCommand(
|
||||
userId = userId,
|
||||
name = request.name,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun get(
|
||||
fun list(
|
||||
@PathVariable userId: UUID,
|
||||
): FilmLibraryResponse =
|
||||
FilmLibraryResponse.fromDomain(
|
||||
getFilmLibraryUseCase.getLibrary(
|
||||
GetFilmLibraryQuery(userId = userId),
|
||||
),
|
||||
)
|
||||
): List<FilmLibraryEntryResponse> =
|
||||
filmLibraryUseCase
|
||||
.list(userId)
|
||||
.map { entry -> FilmLibraryEntryResponse.fromDomain(entry) }
|
||||
|
||||
@GetMapping("/entries")
|
||||
fun listEntries(
|
||||
@PathVariable userId: UUID,
|
||||
): List<FilmLibraryEntryResponse> = list(userId)
|
||||
|
||||
@PostMapping("/films/{filmId}")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun addFilm(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
): FilmLibraryResponse =
|
||||
FilmLibraryResponse.fromDomain(
|
||||
addFilmToLibraryUseCase.addFilm(
|
||||
): FilmLibraryEntryResponse =
|
||||
FilmLibraryEntryResponse.fromDomain(
|
||||
filmLibraryUseCase.addFilm(
|
||||
AddFilmToLibraryCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
@@ -69,15 +49,36 @@ class FilmLibraryController(
|
||||
),
|
||||
)
|
||||
|
||||
@PostMapping("/films/{filmId}/viewed")
|
||||
fun markViewed(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
): FilmLibraryEntryResponse =
|
||||
FilmLibraryEntryResponse.fromDomain(
|
||||
filmLibraryUseCase.markViewed(
|
||||
MarkFilmViewedCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@DeleteMapping("/films/{filmId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
fun removeFilm(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
) = removeFilmFromLibraryUseCase.removeFilm(
|
||||
RemoveFilmFromLibraryCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
),
|
||||
)
|
||||
) {
|
||||
filmLibraryUseCase.removeFilm(
|
||||
RemoveFilmFromLibraryCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/available-films")
|
||||
fun getAvailableFilms(
|
||||
@PathVariable userId: UUID,
|
||||
): List<FilmResponse> = filmLibraryUseCase.listAvailableFilms(userId).map { FilmResponse.fromDomain(it) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
|
||||
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
|
||||
import com.project.movienight.application.ports.input.FilmRatingUseCase
|
||||
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/ratings")
|
||||
class FilmRatingController(
|
||||
private val filmRatingUseCase: FilmRatingUseCase,
|
||||
) {
|
||||
@PostMapping("/films/{filmId}")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun rate(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
@Valid @RequestBody request: RateFilmRequest,
|
||||
): FilmRatingResponse =
|
||||
FilmRatingResponse.fromDomain(
|
||||
filmRatingUseCase.rate(
|
||||
RateFilmCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
score = request.score,
|
||||
note = request.note,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun list(
|
||||
@PathVariable userId: UUID,
|
||||
): List<FilmRatingResponse> = filmRatingUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest
|
||||
import com.project.movienight.application.ports.input.HandleJellyfinEventCommand
|
||||
import com.project.movienight.application.ports.input.JellyfinEventUseCase
|
||||
import jakarta.validation.Valid
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestHeader
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/integrations/jellyfin")
|
||||
class JellyfinEventsController(
|
||||
private val jellyfinEventUseCase: JellyfinEventUseCase,
|
||||
private val authenticator: JellyfinPluginAuthenticator,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
|
||||
|
||||
@PostMapping("/events")
|
||||
@ResponseStatus(HttpStatus.OK)
|
||||
fun receiveEvent(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@Valid @RequestBody request: JellyfinEventRequest,
|
||||
) {
|
||||
authenticator.authenticate(token)
|
||||
|
||||
log.debug(
|
||||
"Received Jellyfin event {} for user {} item {}",
|
||||
request.eventId,
|
||||
request.jellyfinUserId,
|
||||
request.itemId,
|
||||
)
|
||||
jellyfinEventUseCase.handle(
|
||||
HandleJellyfinEventCommand(
|
||||
eventId = request.eventId,
|
||||
serverId = null,
|
||||
eventType = request.eventType,
|
||||
occurredAt = request.occurredAt,
|
||||
jellyfinUserId = request.jellyfinUserId,
|
||||
itemId = request.itemId,
|
||||
payload = request.payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
|
||||
@Component
|
||||
class JellyfinPluginAuthenticator(
|
||||
private val properties: JellyfinIntegrationProperties,
|
||||
) {
|
||||
fun authenticate(token: String?) {
|
||||
if (!properties.enabled) {
|
||||
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
|
||||
}
|
||||
|
||||
if (properties.pluginToken.isNotBlank() && token != properties.pluginToken) {
|
||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
|
||||
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
|
||||
import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse
|
||||
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
|
||||
import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse
|
||||
import com.project.movienight.adapters.web.dto.response.RecommendationResponse
|
||||
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
|
||||
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
|
||||
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
|
||||
import com.project.movienight.application.ports.input.FilmLibraryUseCase
|
||||
import com.project.movienight.application.ports.input.FilmRatingUseCase
|
||||
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
|
||||
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
||||
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||
import com.project.movienight.application.ports.input.RecommendationQuery
|
||||
import com.project.movienight.application.ports.input.UserPreferencesUseCase
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import com.project.movienight.domain.model.Film
|
||||
import com.project.movienight.domain.model.RecommendationStyle
|
||||
import com.project.movienight.domain.model.User
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestHeader
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/integrations/jellyfin")
|
||||
class JellyfinPluginController(
|
||||
private val authenticator: JellyfinPluginAuthenticator,
|
||||
private val userRepository: UserRepositoryPort,
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
private val getRecommendationsUseCase: GetRecommendationsUseCase,
|
||||
private val filmRatingUseCase: FilmRatingUseCase,
|
||||
private val filmLibraryUseCase: FilmLibraryUseCase,
|
||||
private val userPreferencesUseCase: UserPreferencesUseCase,
|
||||
private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase,
|
||||
private val jellyfinProperties: JellyfinIntegrationProperties,
|
||||
) {
|
||||
@GetMapping("/users/{jellyfinUserId}/recommendations")
|
||||
fun recommend(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@PathVariable jellyfinUserId: String,
|
||||
@RequestParam(required = false) contentType: String?,
|
||||
@RequestParam(required = false) mood: String?,
|
||||
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
|
||||
@RequestParam(required = false, defaultValue = "10") limit: Int,
|
||||
): List<RecommendationResponse> {
|
||||
authenticator.authenticate(token)
|
||||
val user = resolveOrCreateUser(jellyfinUserId)
|
||||
return getRecommendationsUseCase
|
||||
.recommend(
|
||||
RecommendationQuery(
|
||||
userId = user.id,
|
||||
contentType = parseOptionalContentType(contentType),
|
||||
mood = mood,
|
||||
libraryOnly = libraryOnly,
|
||||
limit = limit,
|
||||
),
|
||||
).map { recommendation ->
|
||||
RecommendationResponse.fromDomain(
|
||||
recommendation = recommendation,
|
||||
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/users/{jellyfinUserId}/ratings/items/{jellyfinItemId}")
|
||||
fun rate(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@PathVariable jellyfinUserId: String,
|
||||
@PathVariable jellyfinItemId: String,
|
||||
@Valid @RequestBody request: RateFilmRequest,
|
||||
): FilmRatingResponse {
|
||||
authenticator.authenticate(token)
|
||||
val user = resolveOrCreateUser(jellyfinUserId)
|
||||
val film = resolveFilm(jellyfinItemId)
|
||||
return FilmRatingResponse.fromDomain(
|
||||
filmRatingUseCase.rate(
|
||||
RateFilmCommand(
|
||||
userId = user.id,
|
||||
filmId = film.id,
|
||||
score = request.score,
|
||||
note = request.note,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/users/{jellyfinUserId}/ratings")
|
||||
fun ratings(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@PathVariable jellyfinUserId: String,
|
||||
): List<FilmRatingResponse> {
|
||||
authenticator.authenticate(token)
|
||||
val user = resolveOrCreateUser(jellyfinUserId)
|
||||
return filmRatingUseCase.getRatings(user.id).map { FilmRatingResponse.fromDomain(it) }
|
||||
}
|
||||
|
||||
@PostMapping("/users/{jellyfinUserId}/library/items/{jellyfinItemId}/viewed")
|
||||
fun markViewed(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@PathVariable jellyfinUserId: String,
|
||||
@PathVariable jellyfinItemId: String,
|
||||
@RequestBody(required = false) request: JellyfinViewedRequest?,
|
||||
): FilmLibraryEntryResponse {
|
||||
authenticator.authenticate(token)
|
||||
val user = resolveOrCreateUser(jellyfinUserId)
|
||||
val film = resolveFilm(jellyfinItemId)
|
||||
return FilmLibraryEntryResponse.fromDomain(
|
||||
filmLibraryUseCase.markViewed(
|
||||
MarkFilmViewedCommand(
|
||||
userId = user.id,
|
||||
filmId = film.id,
|
||||
watchedAt = request?.watchedAt?.toLocalDateTime(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/users/{jellyfinUserId}/preferences")
|
||||
fun preferences(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@PathVariable jellyfinUserId: String,
|
||||
): UserPreferencesResponse {
|
||||
authenticator.authenticate(token)
|
||||
val user = resolveOrCreateUser(jellyfinUserId)
|
||||
return userPreferencesUseCase.get(user.id)?.let { UserPreferencesResponse.fromDomain(it) }
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User preferences not found")
|
||||
}
|
||||
|
||||
@PostMapping("/users/{jellyfinUserId}/recommendation-onboarding")
|
||||
fun completeOnboarding(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@PathVariable jellyfinUserId: String,
|
||||
@RequestBody request: RecommendationOnboardingRequest,
|
||||
): RecommendationOnboardingResponse {
|
||||
authenticator.authenticate(token)
|
||||
val user = resolveOrCreateUser(jellyfinUserId)
|
||||
return RecommendationOnboardingResponse.fromApplication(
|
||||
completeRecommendationOnboardingUseCase.complete(
|
||||
CompleteRecommendationOnboardingCommand(
|
||||
userId = user.id,
|
||||
weightedGenres = request.weightedGenres,
|
||||
plotTypes = request.plotTypes,
|
||||
eras = request.eras,
|
||||
castAndDirectors = request.castAndDirectors,
|
||||
moods = request.moods,
|
||||
contentTypes = request.contentTypes.mapNotNull { runCatching { parseContentType(it) }.getOrNull() },
|
||||
likedFilmIds = request.likedFilmIds,
|
||||
dislikedFilmIds = request.dislikedFilmIds,
|
||||
libraryFilmIds = request.libraryFilmIds,
|
||||
watchedFilmIds = request.watchedFilmIds,
|
||||
recommendationStyle = parseRecommendationStyle(request.recommendationStyle),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveOrCreateUser(jellyfinUserId: String): User =
|
||||
normalizeJellyfinId(jellyfinUserId).let { normalizedId ->
|
||||
userRepository.findByJellyfinUserId(normalizedId)
|
||||
?: userRepository.save(
|
||||
User(
|
||||
id = idGenerator.generateId(),
|
||||
name = "Jellyfin User",
|
||||
email = syntheticJellyfinEmail(normalizedId),
|
||||
jellyfinUserId = normalizedId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveFilm(jellyfinItemId: String): Film =
|
||||
normalizeJellyfinId(jellyfinItemId).let { normalizedId ->
|
||||
filmRepository.findByJellyfinItemId(normalizedId)
|
||||
?: throw EntityNotFoundException(entity = "Jellyfin item", id = jellyfinItemId)
|
||||
}
|
||||
|
||||
private fun buildWatchUrl(jellyfinItemId: String?): String? {
|
||||
if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val baseUrl = jellyfinProperties.webUrl.trimEnd('/')
|
||||
val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8)
|
||||
return "$baseUrl/web/#/details?id=$encodedItemId"
|
||||
}
|
||||
|
||||
private fun parseRecommendationStyle(value: String): RecommendationStyle =
|
||||
runCatching { RecommendationStyle.valueOf(value.uppercase()) }
|
||||
.getOrDefault(RecommendationStyle.BALANCED)
|
||||
|
||||
private fun normalizeJellyfinId(value: String): String =
|
||||
runCatching { UUID.fromString(value).toString().replace("-", "") }
|
||||
.getOrDefault(value)
|
||||
|
||||
private fun syntheticJellyfinEmail(jellyfinUserId: String): String {
|
||||
val safeId =
|
||||
jellyfinUserId
|
||||
.lowercase(Locale.getDefault())
|
||||
.replace(Regex("[^a-z0-9._%+-]"), "-")
|
||||
.take(240)
|
||||
return "jellyfin-$safeId@movienight.local"
|
||||
}
|
||||
}
|
||||
|
||||
data class JellyfinViewedRequest(
|
||||
val watchedAt: OffsetDateTime? = null,
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.JellyfinSyncRequest
|
||||
import com.project.movienight.application.ports.input.IngestJellyfinSyncCommand
|
||||
import com.project.movienight.application.ports.input.JellyfinSyncItemCommand
|
||||
import com.project.movienight.application.ports.input.JellyfinSyncUseCase
|
||||
import com.project.movienight.application.ports.input.JellyfinSyncUserCommand
|
||||
import com.project.movienight.application.ports.input.JellyfinSyncUserStateCommand
|
||||
import com.project.movienight.domain.model.JellyfinSyncState
|
||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestHeader
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/integrations/jellyfin")
|
||||
class JellyfinSyncController(
|
||||
private val jellyfinSyncUseCase: JellyfinSyncUseCase,
|
||||
private val authenticator: JellyfinPluginAuthenticator,
|
||||
) {
|
||||
@PostMapping("/sync")
|
||||
fun syncNow(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@Valid @RequestBody(required = false) request: JellyfinSyncRequest?,
|
||||
): JellyfinSyncSummary {
|
||||
authenticator.authenticate(token)
|
||||
return if (request == null) {
|
||||
jellyfinSyncUseCase.syncNow()
|
||||
} else {
|
||||
jellyfinSyncUseCase.ingest(request.toCommand())
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/sync-state")
|
||||
fun syncState(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
): List<JellyfinSyncState> {
|
||||
authenticator.authenticate(token)
|
||||
return jellyfinSyncUseCase.getSyncStates()
|
||||
}
|
||||
|
||||
private fun JellyfinSyncRequest.toCommand(): IngestJellyfinSyncCommand =
|
||||
IngestJellyfinSyncCommand(
|
||||
users =
|
||||
users.map { user ->
|
||||
JellyfinSyncUserCommand(
|
||||
jellyfinUserId = user.jellyfinUserId,
|
||||
name = user.name,
|
||||
)
|
||||
},
|
||||
items =
|
||||
items.map { item ->
|
||||
JellyfinSyncItemCommand(
|
||||
jellyfinItemId = item.jellyfinItemId,
|
||||
title = item.title,
|
||||
originalTitle = item.originalTitle,
|
||||
description = item.description,
|
||||
year = item.year,
|
||||
genres = item.genres,
|
||||
imdbId = item.imdbId,
|
||||
tmdbId = item.tmdbId,
|
||||
jellyfinLibraryId = item.jellyfinLibraryId,
|
||||
userStates =
|
||||
item.userStates.map { state ->
|
||||
JellyfinSyncUserStateCommand(
|
||||
jellyfinUserId = state.jellyfinUserId,
|
||||
isViewed = state.isViewed,
|
||||
playCount = state.playCount,
|
||||
lastPlayedAt = state.lastPlayedAt,
|
||||
userRating = state.userRating,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.response.RecommendationEventResponse
|
||||
import com.project.movienight.adapters.web.dto.response.RecommendationResponse
|
||||
import com.project.movienight.application.ports.input.AcceptRecommendationCommand
|
||||
import com.project.movienight.application.ports.input.AcceptRecommendationUseCase
|
||||
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
|
||||
import com.project.movienight.application.ports.input.RecommendationQuery
|
||||
import com.project.movienight.application.ports.input.RejectRecommendationCommand
|
||||
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/recommendations")
|
||||
class RecommendationController(
|
||||
private val getRecommendationsUseCase: GetRecommendationsUseCase,
|
||||
private val acceptRecommendationUseCase: AcceptRecommendationUseCase,
|
||||
private val rejectRecommendationUseCase: RejectRecommendationUseCase,
|
||||
private val jellyfinProperties: JellyfinIntegrationProperties,
|
||||
) {
|
||||
@GetMapping
|
||||
fun recommend(
|
||||
@PathVariable userId: UUID,
|
||||
@RequestParam(required = false) contentType: String?,
|
||||
@RequestParam(required = false) mood: String?,
|
||||
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
|
||||
@RequestParam(required = false, defaultValue = "10") limit: Int,
|
||||
): List<RecommendationResponse> =
|
||||
getRecommendationsUseCase
|
||||
.recommend(
|
||||
RecommendationQuery(
|
||||
userId = userId,
|
||||
contentType = parseOptionalContentType(contentType),
|
||||
mood = mood,
|
||||
libraryOnly = libraryOnly,
|
||||
limit = limit,
|
||||
),
|
||||
).map { recommendation ->
|
||||
RecommendationResponse.fromDomain(
|
||||
recommendation = recommendation,
|
||||
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
|
||||
)
|
||||
}
|
||||
|
||||
@PostMapping("/{filmId}/accept")
|
||||
fun accept(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
): RecommendationEventResponse =
|
||||
RecommendationEventResponse.fromDomain(
|
||||
acceptRecommendationUseCase.accept(
|
||||
AcceptRecommendationCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@PostMapping("/{filmId}/reject")
|
||||
fun reject(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
): RecommendationEventResponse =
|
||||
RecommendationEventResponse.fromDomain(
|
||||
rejectRecommendationUseCase.reject(
|
||||
RejectRecommendationCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun buildWatchUrl(jellyfinItemId: String?): String? {
|
||||
if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val baseUrl = jellyfinProperties.webUrl.trimEnd('/')
|
||||
val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8)
|
||||
return "$baseUrl/web/#/details?id=$encodedItemId"
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
|
||||
import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse
|
||||
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
|
||||
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.RecommendationStyle
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/recommendation-onboarding")
|
||||
class RecommendationOnboardingController(
|
||||
private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase,
|
||||
) {
|
||||
@PostMapping
|
||||
fun complete(
|
||||
@PathVariable userId: UUID,
|
||||
@RequestBody request: RecommendationOnboardingRequest,
|
||||
): RecommendationOnboardingResponse =
|
||||
RecommendationOnboardingResponse.fromApplication(
|
||||
completeRecommendationOnboardingUseCase.complete(
|
||||
CompleteRecommendationOnboardingCommand(
|
||||
userId = userId,
|
||||
weightedGenres = request.weightedGenres,
|
||||
plotTypes = request.plotTypes,
|
||||
eras = request.eras,
|
||||
castAndDirectors = request.castAndDirectors,
|
||||
moods = request.moods,
|
||||
contentTypes = request.contentTypes.mapNotNull(::parseContentType),
|
||||
likedFilmIds = request.likedFilmIds,
|
||||
dislikedFilmIds = request.dislikedFilmIds,
|
||||
libraryFilmIds = request.libraryFilmIds,
|
||||
watchedFilmIds = request.watchedFilmIds,
|
||||
recommendationStyle = parseRecommendationStyle(request.recommendationStyle),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun parseContentType(value: String): ContentType? =
|
||||
runCatching { ContentType.valueOf(value.uppercase(Locale.getDefault())) }.getOrNull()
|
||||
|
||||
private fun parseRecommendationStyle(value: String): RecommendationStyle =
|
||||
runCatching { RecommendationStyle.valueOf(value.uppercase(Locale.getDefault())) }
|
||||
.getOrDefault(RecommendationStyle.BALANCED)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
class TraceIdFilter : OncePerRequestFilter() {
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
filterChain: FilterChain,
|
||||
) {
|
||||
val traceId = UUID.randomUUID().toString()
|
||||
MDC.put("traceId", traceId)
|
||||
|
||||
try {
|
||||
filterChain.doFilter(request, response)
|
||||
} finally {
|
||||
MDC.remove("traceId")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.security.UserPrincipal
|
||||
import com.project.movienight.adapters.web.dto.request.CreateUserRequest
|
||||
import com.project.movienight.adapters.web.dto.request.EditUserRequest
|
||||
import com.project.movienight.adapters.web.dto.response.UserResponse
|
||||
import com.project.movienight.application.ports.input.CreateUserCommand
|
||||
import com.project.movienight.application.ports.input.CreateUserUseCase
|
||||
import com.project.movienight.application.ports.input.DeleteUserUseCase
|
||||
import com.project.movienight.application.ports.input.EditUserCommand
|
||||
import com.project.movienight.application.ports.input.EditUserUseCase
|
||||
import com.project.movienight.application.ports.input.UserUseCase
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PatchMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
@@ -22,17 +24,15 @@ import java.util.UUID
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
class UserController(
|
||||
private val createUserUseCase: CreateUserUseCase,
|
||||
private val editUserUseCase: EditUserUseCase,
|
||||
private val deleteUserUseCase: DeleteUserUseCase,
|
||||
private val userUseCase: UserUseCase,
|
||||
) {
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun create(
|
||||
@RequestBody request: CreateUserRequest,
|
||||
@Valid @RequestBody request: CreateUserRequest,
|
||||
): UserResponse =
|
||||
UserResponse.fromDomain(
|
||||
createUserUseCase.create(
|
||||
userUseCase.create(
|
||||
CreateUserCommand(
|
||||
name = request.name,
|
||||
email = request.email,
|
||||
@@ -40,17 +40,31 @@ class UserController(
|
||||
),
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun getAll(): List<UserResponse> = userUseCase.getAll().map { UserResponse.fromDomain(it) }
|
||||
|
||||
@GetMapping("/me")
|
||||
fun getMe(
|
||||
@AuthenticationPrincipal principal: UserPrincipal,
|
||||
): UserResponse = UserResponse.fromDomain(userUseCase.getById(principal.getId()))
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun getById(
|
||||
@PathVariable id: UUID,
|
||||
): UserResponse = UserResponse.fromDomain(userUseCase.getById(id))
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
fun edit(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody request: EditUserRequest,
|
||||
@Valid @RequestBody request: EditUserRequest,
|
||||
): UserResponse =
|
||||
UserResponse.fromDomain(
|
||||
editUserUseCase.edit(
|
||||
userUseCase.edit(
|
||||
id = id,
|
||||
command =
|
||||
EditUserCommand(
|
||||
name = request.name,
|
||||
jellyfinUserId = request.jellyfinUserId,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -59,5 +73,5 @@ class UserController(
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
) = deleteUserUseCase.delete(id)
|
||||
) = userUseCase.delete(id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest
|
||||
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
|
||||
import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
|
||||
import com.project.movienight.application.ports.input.UserPreferencesUseCase
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/preferences")
|
||||
class UserPreferencesController(
|
||||
private val userPreferencesUseCase: UserPreferencesUseCase,
|
||||
) {
|
||||
@PutMapping
|
||||
fun upsert(
|
||||
@PathVariable userId: UUID,
|
||||
@Valid @RequestBody request: UpsertUserPreferencesRequest,
|
||||
): UserPreferencesResponse =
|
||||
UserPreferencesResponse.fromDomain(
|
||||
userPreferencesUseCase.upsert(
|
||||
UpsertUserPreferencesCommand(
|
||||
userId = userId,
|
||||
weightedGenres = request.weightedGenres,
|
||||
plotTypes = request.plotTypes,
|
||||
eras = request.eras,
|
||||
castAndDirectors = request.castAndDirectors,
|
||||
moods = request.moods,
|
||||
contentTypes =
|
||||
request.contentTypes.map { parseContentType(it) },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun get(
|
||||
@PathVariable userId: UUID,
|
||||
): UserPreferencesResponse? = userPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) }
|
||||
}
|
||||
+53
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
data class CreateFilmLibraryRequest(
|
||||
val name: String = "My films",
|
||||
)
|
||||
@@ -1,6 +1,31 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
import jakarta.validation.constraints.Max
|
||||
import jakarta.validation.constraints.Min
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.Size
|
||||
|
||||
data class CreateFilmRequest(
|
||||
@field:NotBlank
|
||||
@field:Size(max = 255)
|
||||
val title: String,
|
||||
@field:NotBlank
|
||||
val description: String,
|
||||
@field:NotBlank
|
||||
val contentType: String = "FILM",
|
||||
@field:Min(1888)
|
||||
@field:Max(3000)
|
||||
val releaseYear: Int? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val cast: List<String> = emptyList(),
|
||||
val directors: List<String> = emptyList(),
|
||||
@field:Min(0)
|
||||
@field:Max(10)
|
||||
val imdbRating: Double? = null,
|
||||
@field:Min(0)
|
||||
@field:Max(10)
|
||||
val platformRating: Double? = null,
|
||||
val externalUrl: String? = null,
|
||||
val jellyfinItemId: String? = null,
|
||||
val jellyfinLibraryId: String? = null,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
import jakarta.validation.constraints.Email
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.Size
|
||||
|
||||
data class CreateUserRequest(
|
||||
@field:NotBlank
|
||||
@field:Size(max = 255)
|
||||
val name: String,
|
||||
@field:Email
|
||||
@field:NotBlank
|
||||
@field:Size(max = 320)
|
||||
val email: String,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
import jakarta.validation.constraints.Max
|
||||
import jakarta.validation.constraints.Min
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.Size
|
||||
|
||||
data class EditFilmRequest(
|
||||
@field:NotBlank
|
||||
@field:Size(max = 255)
|
||||
val title: String,
|
||||
@field:NotBlank
|
||||
val description: String,
|
||||
@field:NotBlank
|
||||
val contentType: String = "FILM",
|
||||
@field:Min(1888)
|
||||
@field:Max(3000)
|
||||
val releaseYear: Int? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val cast: List<String> = emptyList(),
|
||||
val directors: List<String> = emptyList(),
|
||||
@field:Min(0)
|
||||
@field:Max(10)
|
||||
val imdbRating: Double? = null,
|
||||
@field:Min(0)
|
||||
@field:Max(10)
|
||||
val platformRating: Double? = null,
|
||||
val externalUrl: String? = null,
|
||||
val jellyfinItemId: String? = null,
|
||||
val jellyfinLibraryId: String? = null,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user