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
|
*.tar.gz
|
||||||
*.rar
|
*.rar
|
||||||
|
|
||||||
|
# Gradle wrapper
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||||
hs_err_pid*
|
hs_err_pid*
|
||||||
replay_pid*
|
replay_pid*
|
||||||
|
|||||||
Generated
+1
-1
@@ -2,6 +2,6 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="KotlinJpsPluginSettings">
|
<component name="KotlinJpsPluginSettings">
|
||||||
<option name="externalSystemId" value="Gradle" />
|
<option name="externalSystemId" value="Gradle" />
|
||||||
<option name="version" value="2.1.20" />
|
<option name="version" value="2.0.21" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
+3
-8
@@ -26,13 +26,7 @@ RUN --mount=type=cache,target=${GRADLE_USER_HOME} \
|
|||||||
COPY src src
|
COPY src src
|
||||||
|
|
||||||
RUN --mount=type=cache,target=${GRADLE_USER_HOME} \
|
RUN --mount=type=cache,target=${GRADLE_USER_HOME} \
|
||||||
./gradlew --no-daemon build \
|
./gradlew --no-daemon bootJar
|
||||||
-x test \
|
|
||||||
-x detekt \
|
|
||||||
-x ktlintCheck \
|
|
||||||
-x ktlintKotlinScriptCheck \
|
|
||||||
-x ktlintMainSourceSetCheck \
|
|
||||||
-x ktlintTestSourceSetCheck
|
|
||||||
|
|
||||||
RUN mkdir -p ${APP_HOME}/dist \
|
RUN mkdir -p ${APP_HOME}/dist \
|
||||||
&& cp ${APP_HOME}/build/libs/*.jar ${APP_HOME}/dist/${JAR_NAME} \
|
&& cp ${APP_HOME}/build/libs/*.jar ${APP_HOME}/dist/${JAR_NAME} \
|
||||||
@@ -60,6 +54,7 @@ COPY --from=builder --chown=app:app /workspace/dist/app.jar /app/app.jar
|
|||||||
USER app
|
USER app
|
||||||
|
|
||||||
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC" \
|
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC" \
|
||||||
|
SPRING_AOT_ENABLED=true \
|
||||||
SERVER_PORT=8080 \
|
SERVER_PORT=8080 \
|
||||||
PATH="/app:$PATH"
|
PATH="/app:$PATH"
|
||||||
|
|
||||||
@@ -70,4 +65,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
|
|||||||
|
|
||||||
STOPSIGNAL SIGTERM
|
STOPSIGNAL SIGTERM
|
||||||
|
|
||||||
ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} -Dserver.port=${SERVER_PORT} -jar /app/app.jar"]
|
ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} -Dspring.aot.enabled=${SPRING_AOT_ENABLED} -Dserver.port=${SERVER_PORT} -jar /app/app.jar"]
|
||||||
|
|||||||
+8
-5
@@ -15,6 +15,8 @@ plugins {
|
|||||||
jacoco
|
jacoco
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apply(plugin = "org.springframework.boot.aot")
|
||||||
|
|
||||||
apply(from = "$rootDir/gradle/docker.gradle.kts")
|
apply(from = "$rootDir/gradle/docker.gradle.kts")
|
||||||
|
|
||||||
group = "com.project"
|
group = "com.project"
|
||||||
@@ -28,11 +30,11 @@ java {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(platform(libs.sentry.bom))
|
implementation(platform(libs.sentry.bom))
|
||||||
implementation(platform(libs.spring.grpc.bom))
|
|
||||||
|
|
||||||
implementation(libs.spring.boot.starter.web)
|
implementation(libs.spring.boot.starter.web)
|
||||||
implementation(libs.spring.boot.starter.actuator)
|
implementation(libs.spring.boot.starter.actuator)
|
||||||
// implementation(libs.spring.boot.starter.security)
|
implementation(libs.spring.boot.starter.aop)
|
||||||
|
implementation(libs.spring.boot.starter.security)
|
||||||
implementation(libs.spring.boot.starter.cache)
|
implementation(libs.spring.boot.starter.cache)
|
||||||
implementation(libs.spring.boot.starter.data.jdbc)
|
implementation(libs.spring.boot.starter.data.jdbc)
|
||||||
implementation(libs.spring.boot.starter.validation)
|
implementation(libs.spring.boot.starter.validation)
|
||||||
@@ -40,12 +42,13 @@ dependencies {
|
|||||||
implementation(libs.flyway.database.postgresql)
|
implementation(libs.flyway.database.postgresql)
|
||||||
implementation(libs.kotlin.reflect)
|
implementation(libs.kotlin.reflect)
|
||||||
|
|
||||||
|
implementation("net.logstash.logback:logstash-logback-encoder:8.0")
|
||||||
|
|
||||||
implementation(libs.micrometer.tracing.bridge.otel)
|
implementation(libs.micrometer.tracing.bridge.otel)
|
||||||
implementation(libs.opentelemetry.exporter.otlp)
|
implementation(libs.opentelemetry.exporter.otlp)
|
||||||
implementation(libs.sentry.spring.boot.starter)
|
implementation(libs.sentry.spring.boot.starter)
|
||||||
|
|
||||||
implementation(libs.spring.grpc.starter)
|
implementation(libs.spring.boot.starter.oauth2.client)
|
||||||
implementation(libs.grpc.services)
|
|
||||||
|
|
||||||
runtimeOnly(libs.micrometer.registry.prometheus)
|
runtimeOnly(libs.micrometer.registry.prometheus)
|
||||||
runtimeOnly(libs.h2)
|
runtimeOnly(libs.h2)
|
||||||
@@ -55,7 +58,7 @@ dependencies {
|
|||||||
|
|
||||||
testImplementation(libs.spring.boot.starter.test)
|
testImplementation(libs.spring.boot.starter.test)
|
||||||
testImplementation(libs.kotlin.test.junit5)
|
testImplementation(libs.kotlin.test.junit5)
|
||||||
testImplementation(libs.spring.grpc.test)
|
testImplementation(libs.mockk)
|
||||||
testRuntimeOnly(libs.junit.platform.launcher)
|
testRuntimeOnly(libs.junit.platform.launcher)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,12 @@ comments:
|
|||||||
active: false
|
active: false
|
||||||
UndocumentedPublicProperty:
|
UndocumentedPublicProperty:
|
||||||
active: false
|
active: false
|
||||||
|
|
||||||
|
style:
|
||||||
|
MagicNumber:
|
||||||
|
active: false
|
||||||
|
ReturnCount:
|
||||||
|
max: 3
|
||||||
|
|
||||||
|
complexity:
|
||||||
|
active: false
|
||||||
|
|||||||
@@ -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"
|
protoc = "3.25.1"
|
||||||
grpc-java = "1.60.0"
|
grpc-java = "1.60.0"
|
||||||
springdoc = "2.8.6"
|
springdoc = "2.8.6"
|
||||||
|
mockk = "1.13.13"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
|
spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" }
|
||||||
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" }
|
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" }
|
||||||
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" }
|
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" }
|
||||||
|
spring-boot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop" }
|
||||||
spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" }
|
spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" }
|
||||||
spring-boot-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache" }
|
spring-boot-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache" }
|
||||||
spring-boot-starter-data-jdbc = { module = "org.springframework.boot:spring-boot-starter-data-jdbc" }
|
spring-boot-starter-data-jdbc = { module = "org.springframework.boot:spring-boot-starter-data-jdbc" }
|
||||||
@@ -33,6 +36,7 @@ flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql
|
|||||||
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" }
|
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" }
|
||||||
kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5" }
|
kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5" }
|
||||||
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
|
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
|
||||||
|
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||||
|
|
||||||
sentry-bom = { module = "io.sentry:sentry-bom", version.ref = "sentry" }
|
sentry-bom = { module = "io.sentry:sentry-bom", version.ref = "sentry" }
|
||||||
sentry-spring-boot-starter = { module = "io.sentry:sentry-spring-boot-starter-jakarta" }
|
sentry-spring-boot-starter = { module = "io.sentry:sentry-spring-boot-starter-jakarta" }
|
||||||
|
|||||||
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.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
@ConfigurationPropertiesScan("com.project.movienight.config")
|
@ConfigurationPropertiesScan("com.project.movienight.config")
|
||||||
class MovieNightApplication
|
class MovieNightApplication
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.project.movienight.adapters.jellyfin
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinCatalogPort
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinRemoteUser
|
||||||
|
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.http.HttpClient
|
||||||
|
import java.net.http.HttpRequest
|
||||||
|
import java.net.http.HttpResponse
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class JellyfinApiClient(
|
||||||
|
private val properties: JellyfinIntegrationProperties,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
) : JellyfinCatalogPort {
|
||||||
|
private val httpClient: HttpClient =
|
||||||
|
HttpClient
|
||||||
|
.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun fetchUsers(): List<JellyfinRemoteUser> =
|
||||||
|
request("Users")
|
||||||
|
.asItems()
|
||||||
|
.mapNotNull { node ->
|
||||||
|
val id = node.fieldText("Id") ?: return@mapNotNull null
|
||||||
|
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
request(
|
||||||
|
"Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData",
|
||||||
|
).asItems().mapNotNull { node ->
|
||||||
|
val itemId = node.fieldText("Id") ?: return@mapNotNull null
|
||||||
|
val providerIds = node["ProviderIds"]
|
||||||
|
val imdbId = providerIds?.fieldText("Imdb")
|
||||||
|
val people = node["People"]
|
||||||
|
val cast = people?.peopleByType("Actor", "GuestStar") ?: emptyList()
|
||||||
|
val directors = people?.peopleByType("Director") ?: emptyList()
|
||||||
|
JellyfinLibraryItemSnapshot(
|
||||||
|
jellyfinItemId = itemId,
|
||||||
|
title = node.fieldText("Name") ?: itemId,
|
||||||
|
description = node.fieldText("Overview") ?: "",
|
||||||
|
contentType = mapContentType(node.fieldText("Type")),
|
||||||
|
releaseYear = node["ProductionYear"]?.takeUnless { it.isNull }?.asInt(),
|
||||||
|
genres = node["Genres"]?.textList() ?: emptyList(),
|
||||||
|
cast = cast,
|
||||||
|
directors = directors,
|
||||||
|
platformRating = node["CommunityRating"]?.takeUnless { it.isNull }?.asDouble(),
|
||||||
|
imdbRating = null,
|
||||||
|
externalUrl = imdbId?.let { "https://www.imdb.com/title/$it/" },
|
||||||
|
jellyfinLibraryId = node.fieldText("ParentId"),
|
||||||
|
isPlayed =
|
||||||
|
node["UserData"]?.booleanField("Played") ?: node["UserData"]?.booleanField("IsPlayed") ?: false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(path: String): JsonNode {
|
||||||
|
val uri = URI.create("${properties.baseUrl.trimEnd('/')}/$path")
|
||||||
|
val request =
|
||||||
|
HttpRequest
|
||||||
|
.newBuilder(uri)
|
||||||
|
.timeout(Duration.ofMillis(properties.requestTimeoutMs))
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("X-Emby-Token", properties.apiKey)
|
||||||
|
.GET()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response =
|
||||||
|
try {
|
||||||
|
httpClient.send(request, HttpResponse.BodyHandlers.ofString())
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") exception: Exception,
|
||||||
|
) {
|
||||||
|
throw IllegalStateException("Failed to call Jellyfin at $uri", exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
check(response.statusCode() in 200..299) {
|
||||||
|
"Jellyfin request failed with status ${response.statusCode()} for $uri"
|
||||||
|
}
|
||||||
|
|
||||||
|
return objectMapper.readTree(response.body())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonNode.asItems(): List<JsonNode> =
|
||||||
|
when {
|
||||||
|
isArray -> map { it }
|
||||||
|
has("Items") && this["Items"].isArray -> this["Items"].map { it }
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonNode.fieldText(name: String): String? =
|
||||||
|
get(name)?.takeUnless { it.isNull }?.asText()?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
private fun JsonNode.booleanField(name: String): Boolean? = get(name)?.takeUnless { it.isNull }?.asBoolean()
|
||||||
|
|
||||||
|
private fun JsonNode.textList(): List<String> =
|
||||||
|
takeIf { it.isArray }?.mapNotNull { item ->
|
||||||
|
item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() }
|
||||||
|
}
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
private fun JsonNode.peopleByType(vararg types: String): List<String> {
|
||||||
|
if (!isArray) return emptyList()
|
||||||
|
return mapNotNull { person ->
|
||||||
|
val type = person.fieldText("Type") ?: return@mapNotNull null
|
||||||
|
if (types.any { it.equals(type, ignoreCase = true) }) person.fieldText("Name") else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapContentType(value: String?): ContentType =
|
||||||
|
when (value?.lowercase()) {
|
||||||
|
"movie" -> ContentType.FILM
|
||||||
|
"series" -> ContentType.SERIES
|
||||||
|
"episode" -> ContentType.EPISODE
|
||||||
|
else -> ContentType.OTHER
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.project.movienight.adapters.metrics
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||||
|
import com.project.movienight.domain.model.RecommendationEventType
|
||||||
|
import io.micrometer.core.instrument.Counter
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry
|
||||||
|
import io.micrometer.core.instrument.Timer
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class BusinessMetricsService(
|
||||||
|
private val meterRegistry: MeterRegistry,
|
||||||
|
) : BusinessMetricsPort {
|
||||||
|
private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total")
|
||||||
|
private val filmsCreated: Counter = meterRegistry.counter("business_films_created_total")
|
||||||
|
private val filmsEdited: Counter = meterRegistry.counter("business_films_edited_total")
|
||||||
|
private val filmsDeleted: Counter = meterRegistry.counter("business_films_deleted_total")
|
||||||
|
private val filmsBlocked: Counter = meterRegistry.counter("business_films_blocked_total")
|
||||||
|
private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total")
|
||||||
|
private val libraryEvents: Counter = meterRegistry.counter("business_library_events_total")
|
||||||
|
private val jellyfinSyncRuns: Counter = meterRegistry.counter("business_jellyfin_sync_runs_total")
|
||||||
|
private val jellyfinSyncedUsers: Counter = meterRegistry.counter("business_jellyfin_synced_users_total")
|
||||||
|
private val jellyfinSkippedUsers: Counter = meterRegistry.counter("business_jellyfin_skipped_users_total")
|
||||||
|
private val jellyfinSyncedItems: Counter = meterRegistry.counter("business_jellyfin_synced_items_total")
|
||||||
|
private val jellyfinSyncDuration: Timer =
|
||||||
|
Timer
|
||||||
|
.builder("business_jellyfin_sync_duration")
|
||||||
|
.publishPercentileHistogram()
|
||||||
|
.register(meterRegistry)
|
||||||
|
private val jellyfinSyncFailures: Counter = meterRegistry.counter("business_jellyfin_sync_failures_total")
|
||||||
|
private val jellyfinUnmappedUsersGaugeValue = AtomicInteger(0)
|
||||||
|
|
||||||
|
init {
|
||||||
|
meterRegistry.gauge("business_jellyfin_unmapped_users", jellyfinUnmappedUsersGaugeValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
|
||||||
|
|
||||||
|
override fun recordFilmCreated() {
|
||||||
|
filmsCreated.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recordFilmEdited() {
|
||||||
|
filmsEdited.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recordFilmDeleted() {
|
||||||
|
filmsDeleted.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recordFilmBlocked() {
|
||||||
|
filmsBlocked.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recordRecommendationRequest() {
|
||||||
|
recommendationRequests.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
|
||||||
|
Counter
|
||||||
|
.builder("recommendation_weights_updated_total")
|
||||||
|
.tag("eventType", eventType.name)
|
||||||
|
.register(meterRegistry)
|
||||||
|
.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recordRatingSubmitted() {
|
||||||
|
ratingsSubmitted.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recordLibraryEvent() {
|
||||||
|
libraryEvents.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recordJellyfinSync(summary: JellyfinSyncSummary) {
|
||||||
|
jellyfinSyncRuns.increment()
|
||||||
|
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
|
||||||
|
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
|
||||||
|
jellyfinSyncedItems.increment(summary.syncedItems.toDouble())
|
||||||
|
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recordJellyfinSyncFailure() {
|
||||||
|
jellyfinSyncFailures.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recordJellyfinUnmappedUser() {
|
||||||
|
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recordBackendWriteFailure() {
|
||||||
|
backendWriteFailures.increment()
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec
|
||||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
import com.project.movienight.domain.model.Film
|
import com.project.movienight.domain.model.Film
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
@@ -16,6 +18,21 @@ class FilmRepository(
|
|||||||
id = UUID.fromString(rs.getString("id")),
|
id = UUID.fromString(rs.getString("id")),
|
||||||
title = rs.getString("title"),
|
title = rs.getString("title"),
|
||||||
description = rs.getString("description"),
|
description = rs.getString("description"),
|
||||||
|
contentType =
|
||||||
|
runCatching {
|
||||||
|
ContentType.valueOf(
|
||||||
|
rs.getString("content_type"),
|
||||||
|
)
|
||||||
|
}.getOrDefault(ContentType.FILM),
|
||||||
|
releaseYear = rs.getObject("release_year")?.let { (it as Number).toInt() },
|
||||||
|
genres = DelimitedValueCodec.decodeList(rs.getString("genres")),
|
||||||
|
cast = DelimitedValueCodec.decodeList(rs.getString("cast_members")),
|
||||||
|
directors = DelimitedValueCodec.decodeList(rs.getString("directors")),
|
||||||
|
imdbRating = rs.getObject("imdb_rating")?.let { (it as Number).toDouble() },
|
||||||
|
platformRating = rs.getObject("platform_rating")?.let { (it as Number).toDouble() },
|
||||||
|
externalUrl = rs.getString("external_url"),
|
||||||
|
jellyfinItemId = rs.getString("jellyfin_item_id"),
|
||||||
|
jellyfinLibraryId = rs.getString("jellyfin_library_id"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,22 +41,67 @@ class FilmRepository(
|
|||||||
jdbc.update(
|
jdbc.update(
|
||||||
"""
|
"""
|
||||||
UPDATE films
|
UPDATE films
|
||||||
SET title = ?, description = ?
|
SET title = ?,
|
||||||
|
description = ?,
|
||||||
|
content_type = ?,
|
||||||
|
release_year = ?,
|
||||||
|
genres = ?,
|
||||||
|
cast_members = ?,
|
||||||
|
directors = ?,
|
||||||
|
imdb_rating = ?,
|
||||||
|
platform_rating = ?,
|
||||||
|
external_url = ?,
|
||||||
|
jellyfin_item_id = ?,
|
||||||
|
jellyfin_library_id = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
film.title,
|
film.title,
|
||||||
film.description,
|
film.description,
|
||||||
|
film.contentType.name,
|
||||||
|
film.releaseYear,
|
||||||
|
DelimitedValueCodec.encodeList(film.genres),
|
||||||
|
DelimitedValueCodec.encodeList(film.cast),
|
||||||
|
DelimitedValueCodec.encodeList(film.directors),
|
||||||
|
film.imdbRating,
|
||||||
|
film.platformRating,
|
||||||
|
film.externalUrl,
|
||||||
|
film.jellyfinItemId,
|
||||||
|
film.jellyfinLibraryId,
|
||||||
film.id,
|
film.id,
|
||||||
)
|
)
|
||||||
if (updatedRows == 0) {
|
if (updatedRows == 0) {
|
||||||
jdbc.update(
|
jdbc.update(
|
||||||
"""
|
"""
|
||||||
INSERT INTO films (id, title, description)
|
INSERT INTO films (
|
||||||
VALUES (?, ?, ?)
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content_type,
|
||||||
|
release_year,
|
||||||
|
genres,
|
||||||
|
cast_members,
|
||||||
|
directors,
|
||||||
|
imdb_rating,
|
||||||
|
platform_rating,
|
||||||
|
external_url,
|
||||||
|
jellyfin_item_id,
|
||||||
|
jellyfin_library_id
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
film.id,
|
film.id,
|
||||||
film.title,
|
film.title,
|
||||||
film.description,
|
film.description,
|
||||||
|
film.contentType.name,
|
||||||
|
film.releaseYear,
|
||||||
|
DelimitedValueCodec.encodeList(film.genres),
|
||||||
|
DelimitedValueCodec.encodeList(film.cast),
|
||||||
|
DelimitedValueCodec.encodeList(film.directors),
|
||||||
|
film.imdbRating,
|
||||||
|
film.platformRating,
|
||||||
|
film.externalUrl,
|
||||||
|
film.jellyfinItemId,
|
||||||
|
film.jellyfinLibraryId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return film
|
return film
|
||||||
@@ -48,20 +110,111 @@ class FilmRepository(
|
|||||||
override fun findById(id: UUID): Film? {
|
override fun findById(id: UUID): Film? {
|
||||||
val films =
|
val films =
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT id, title, description FROM films WHERE id = ?",
|
"""
|
||||||
|
SELECT id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content_type,
|
||||||
|
release_year,
|
||||||
|
genres,
|
||||||
|
cast_members,
|
||||||
|
directors,
|
||||||
|
imdb_rating,
|
||||||
|
platform_rating,
|
||||||
|
external_url,
|
||||||
|
jellyfin_item_id,
|
||||||
|
jellyfin_library_id
|
||||||
|
FROM films
|
||||||
|
WHERE id = ?
|
||||||
|
""".trimIndent(),
|
||||||
filmRowMapper,
|
filmRowMapper,
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
return films.firstOrNull()
|
return films.firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findByJellyfinItemId(jellyfinItemId: String): Film? {
|
||||||
|
val films =
|
||||||
|
jdbc.query(
|
||||||
|
"""
|
||||||
|
SELECT id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content_type,
|
||||||
|
release_year,
|
||||||
|
genres,
|
||||||
|
cast_members,
|
||||||
|
directors,
|
||||||
|
imdb_rating,
|
||||||
|
platform_rating,
|
||||||
|
external_url,
|
||||||
|
jellyfin_item_id,
|
||||||
|
jellyfin_library_id
|
||||||
|
FROM films
|
||||||
|
WHERE jellyfin_item_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
filmRowMapper,
|
||||||
|
jellyfinItemId,
|
||||||
|
)
|
||||||
|
return films.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
override fun findAll(): List<Film> =
|
override fun findAll(): List<Film> =
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT id, title, description FROM films",
|
"""
|
||||||
|
SELECT id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content_type,
|
||||||
|
release_year,
|
||||||
|
genres,
|
||||||
|
cast_members,
|
||||||
|
directors,
|
||||||
|
imdb_rating,
|
||||||
|
platform_rating,
|
||||||
|
external_url,
|
||||||
|
jellyfin_item_id,
|
||||||
|
jellyfin_library_id
|
||||||
|
FROM films
|
||||||
|
""".trimIndent(),
|
||||||
filmRowMapper,
|
filmRowMapper,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override fun findByTitle(title: String): Film? {
|
||||||
|
val films =
|
||||||
|
jdbc.query(
|
||||||
|
"""
|
||||||
|
SELECT id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content_type,
|
||||||
|
release_year,
|
||||||
|
genres,
|
||||||
|
cast_members,
|
||||||
|
directors,
|
||||||
|
imdb_rating,
|
||||||
|
platform_rating,
|
||||||
|
external_url,
|
||||||
|
jellyfin_item_id,
|
||||||
|
jellyfin_library_id
|
||||||
|
FROM films
|
||||||
|
WHERE title = ?
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 1
|
||||||
|
""".trimIndent(),
|
||||||
|
filmRowMapper,
|
||||||
|
title,
|
||||||
|
)
|
||||||
|
return films.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
override fun deleteById(id: UUID) {
|
override fun deleteById(id: UUID) {
|
||||||
jdbc.update("DELETE FROM films WHERE id = ?", id)
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
DELETE FROM films
|
||||||
|
WHERE id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
id,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.persistence.entity.UserEntity
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toDomain
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toEntity
|
||||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
|
import com.project.movienight.domain.model.AuthProvider
|
||||||
import com.project.movienight.domain.model.User
|
import com.project.movienight.domain.model.User
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
@@ -11,58 +16,208 @@ import java.util.UUID
|
|||||||
class UserRepository(
|
class UserRepository(
|
||||||
private val jdbc: JdbcTemplate,
|
private val jdbc: JdbcTemplate,
|
||||||
) : UserRepositoryPort {
|
) : UserRepositoryPort {
|
||||||
private val userRowMapper = { rs: ResultSet, _: Int ->
|
private val userEntityRowMapper = { rs: ResultSet, _: Int ->
|
||||||
User(
|
UserEntity(
|
||||||
id = UUID.fromString(rs.getString("id")),
|
id = UUID.fromString(rs.getString("id")),
|
||||||
name = rs.getString("name"),
|
name = rs.getString("name"),
|
||||||
email = rs.getString("email"),
|
email = rs.getString("email"),
|
||||||
library = null,
|
provider = rs.getString("provider"),
|
||||||
|
providerId = rs.getString("provider_id"),
|
||||||
|
jellyfinUserId = rs.getString("jellyfin_user_id"),
|
||||||
|
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun save(user: User): User {
|
override fun save(user: User): User {
|
||||||
|
val existingUser = findById(user.id)
|
||||||
|
|
||||||
|
val entity =
|
||||||
|
if (existingUser != null) {
|
||||||
|
user.toEntity(
|
||||||
|
provider = findProviderById(user.id),
|
||||||
|
providerId = findProviderIdById(user.id),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
user.toEntity()
|
||||||
|
}
|
||||||
|
|
||||||
val updatedRows =
|
val updatedRows =
|
||||||
jdbc.update(
|
jdbc.update(
|
||||||
"""
|
"""
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET name = ?, email = ?
|
SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
user.name,
|
entity.name,
|
||||||
user.email,
|
entity.email,
|
||||||
user.id,
|
entity.provider,
|
||||||
|
entity.providerId,
|
||||||
|
entity.jellyfinUserId,
|
||||||
|
entity.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (updatedRows == 0) {
|
if (updatedRows == 0) {
|
||||||
jdbc.update(
|
jdbc.update(
|
||||||
"""
|
"""
|
||||||
INSERT INTO users (id, name, email)
|
INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
user.id,
|
entity.id,
|
||||||
user.name,
|
entity.name,
|
||||||
user.email,
|
entity.email,
|
||||||
|
entity.provider,
|
||||||
|
entity.providerId,
|
||||||
|
entity.jellyfinUserId,
|
||||||
|
entity.createdAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun createOAuthUser(
|
||||||
|
user: User,
|
||||||
|
provider: AuthProvider,
|
||||||
|
providerId: String,
|
||||||
|
): User {
|
||||||
|
val entity = user.toEntity(provider = provider, providerId = providerId)
|
||||||
|
val updatedRows =
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.name,
|
||||||
|
entity.email,
|
||||||
|
entity.provider,
|
||||||
|
entity.providerId,
|
||||||
|
entity.jellyfinUserId,
|
||||||
|
entity.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updatedRows == 0) {
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.id,
|
||||||
|
entity.name,
|
||||||
|
entity.email,
|
||||||
|
entity.provider,
|
||||||
|
entity.providerId,
|
||||||
|
entity.jellyfinUserId,
|
||||||
|
entity.createdAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return findById(user.id) ?: user
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun linkOAuthAccount(
|
||||||
|
userId: UUID,
|
||||||
|
provider: AuthProvider,
|
||||||
|
providerId: String,
|
||||||
|
): User {
|
||||||
|
val updatedRows =
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET provider = ?, provider_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
provider.name,
|
||||||
|
providerId,
|
||||||
|
userId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updatedRows == 0) {
|
||||||
|
throw EntityNotFoundException(entity = "User", id = userId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
return findById(userId) ?: throw EntityNotFoundException(entity = "User", id = userId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
override fun findById(id: UUID): User? {
|
override fun findById(id: UUID): User? {
|
||||||
val users =
|
val entities =
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT id, name, email FROM users WHERE id = ?",
|
"SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users WHERE id = ?",
|
||||||
userRowMapper,
|
userEntityRowMapper,
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
return users.firstOrNull()
|
return entities.firstOrNull()?.toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByEmail(email: String): User? {
|
||||||
|
val entities =
|
||||||
|
jdbc.query(
|
||||||
|
"""
|
||||||
|
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE email = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
userEntityRowMapper,
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
return entities.firstOrNull()?.toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByJellyfinUserId(jellyfinUserId: String): User? {
|
||||||
|
val entities =
|
||||||
|
jdbc.query(
|
||||||
|
"""
|
||||||
|
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE jellyfin_user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
userEntityRowMapper,
|
||||||
|
jellyfinUserId,
|
||||||
|
)
|
||||||
|
return entities.firstOrNull()?.toDomain()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findAll(): List<User> =
|
override fun findAll(): List<User> =
|
||||||
jdbc.query(
|
jdbc
|
||||||
"SELECT id, name, email FROM users",
|
.query(
|
||||||
userRowMapper,
|
"SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users",
|
||||||
)
|
userEntityRowMapper,
|
||||||
|
).map { it.toDomain() }
|
||||||
|
|
||||||
override fun deleteById(id: UUID) {
|
override fun deleteById(id: UUID) {
|
||||||
jdbc.update("DELETE FROM users WHERE id = ?", id)
|
jdbc.update("DELETE FROM users WHERE id = ?", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findByProviderAndProviderId(
|
||||||
|
provider: AuthProvider,
|
||||||
|
providerId: String,
|
||||||
|
): User? {
|
||||||
|
val entities =
|
||||||
|
jdbc.query(
|
||||||
|
"""
|
||||||
|
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users
|
||||||
|
WHERE provider = ? AND provider_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
userEntityRowMapper,
|
||||||
|
provider.name,
|
||||||
|
providerId,
|
||||||
|
)
|
||||||
|
return entities.firstOrNull()?.toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findProviderById(id: UUID): AuthProvider? =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"SELECT provider FROM users WHERE id = ?",
|
||||||
|
{ rs: ResultSet, _: Int -> rs.getString("provider") },
|
||||||
|
id,
|
||||||
|
).firstOrNull()
|
||||||
|
?.let { AuthProvider.valueOf(it) }
|
||||||
|
|
||||||
|
private fun findProviderIdById(id: UUID): String? =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"SELECT provider_id FROM users WHERE id = ?",
|
||||||
|
{ rs: ResultSet, _: Int -> rs.getString("provider_id") },
|
||||||
|
id,
|
||||||
|
).firstOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
+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.BlockedValueException
|
||||||
import com.project.movienight.domain.exception.DomainException
|
import com.project.movienight.domain.exception.DomainException
|
||||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.slf4j.MDC
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import org.springframework.web.servlet.resource.NoResourceFoundException
|
||||||
|
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
class ApiExceptionHandler {
|
class ApiExceptionHandler {
|
||||||
@ExceptionHandler(EntityNotFoundException::class)
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
@ExceptionHandler(EntityNotFoundException::class, NoResourceFoundException::class)
|
||||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||||
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse =
|
fun handleNotFound(exception: Exception): ErrorResponse {
|
||||||
ErrorResponse(message = exception.message ?: "Entity not found")
|
val traceId = currentTraceId()
|
||||||
|
log.warn("Resource not found: traceId='{}', message='{}'", traceId, exception.message)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = exception.message ?: "Resource not found",
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(BlockedValueException::class)
|
@ExceptionHandler(BlockedValueException::class)
|
||||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse =
|
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse {
|
||||||
ErrorResponse(message = exception.message ?: "Blocked value")
|
val traceId = currentTraceId()
|
||||||
|
log.warn("Blocked value: traceId='{}', message='{}'", traceId, exception.message)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = exception.message ?: "Blocked value",
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(DomainException::class)
|
@ExceptionHandler(DomainException::class)
|
||||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
fun handleDomainException(exception: DomainException): ErrorResponse =
|
fun handleDomainException(exception: DomainException): ErrorResponse {
|
||||||
ErrorResponse(message = exception.message ?: "Domain error")
|
val traceId = currentTraceId()
|
||||||
|
log.warn("Domain error: traceId='{}', message='{}'", traceId, exception.message)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = exception.message ?: "Domain error",
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException::class)
|
||||||
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
fun handleValidationException(exception: MethodArgumentNotValidException): ErrorResponse {
|
||||||
|
val traceId = currentTraceId()
|
||||||
|
val details =
|
||||||
|
exception
|
||||||
|
.bindingResult
|
||||||
|
.fieldErrors
|
||||||
|
.joinToString("; ") { error -> "${error.field}: ${error.defaultMessage}" }
|
||||||
|
.ifBlank { "Invalid request" }
|
||||||
|
log.warn("Validation error: traceId='{}', message='{}'", traceId, details)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = details,
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ResponseStatusException::class)
|
||||||
|
fun handleResponseStatusException(exception: ResponseStatusException): ResponseEntity<ErrorResponse> {
|
||||||
|
val traceId = currentTraceId()
|
||||||
|
log.warn(
|
||||||
|
"HTTP error: traceId='{}', status='{}', message='{}'",
|
||||||
|
traceId,
|
||||||
|
exception.statusCode,
|
||||||
|
exception.reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseEntity
|
||||||
|
.status(exception.statusCode)
|
||||||
|
.body(
|
||||||
|
ErrorResponse(
|
||||||
|
message = exception.reason ?: exception.message,
|
||||||
|
traceId = traceId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception::class)
|
||||||
|
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
fun handleUnexpectedException(exception: Exception): ErrorResponse {
|
||||||
|
val traceId = currentTraceId()
|
||||||
|
log.error("Unexpected error: traceId='{}'", traceId, exception)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = "Internal server error",
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentTraceId(): String = MDC.get("traceId") ?: "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ErrorResponse(
|
data class ErrorResponse(
|
||||||
val message: String,
|
val message: String,
|
||||||
|
val traceId: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.domain.exception.DomainException
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
|
||||||
|
fun parseContentType(value: String): ContentType =
|
||||||
|
runCatching { ContentType.valueOf(value.uppercase()) }
|
||||||
|
.getOrElse { throw DomainException("Unsupported content type: $value") }
|
||||||
|
|
||||||
|
fun parseOptionalContentType(value: String?): ContentType? = value?.let { parseContentType(it) }
|
||||||
@@ -4,17 +4,19 @@ import com.project.movienight.adapters.web.dto.request.CreateFilmRequest
|
|||||||
import com.project.movienight.adapters.web.dto.request.EditFilmRequest
|
import com.project.movienight.adapters.web.dto.request.EditFilmRequest
|
||||||
import com.project.movienight.adapters.web.dto.response.FilmResponse
|
import com.project.movienight.adapters.web.dto.response.FilmResponse
|
||||||
import com.project.movienight.application.ports.input.CreateFilmCommand
|
import com.project.movienight.application.ports.input.CreateFilmCommand
|
||||||
import com.project.movienight.application.ports.input.CreateFilmUseCase
|
|
||||||
import com.project.movienight.application.ports.input.DeleteFilmUseCase
|
|
||||||
import com.project.movienight.application.ports.input.EditFilmCommand
|
import com.project.movienight.application.ports.input.EditFilmCommand
|
||||||
import com.project.movienight.application.ports.input.EditFilmUseCase
|
import com.project.movienight.application.ports.input.FilmUseCase
|
||||||
|
import jakarta.validation.Valid
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PatchMapping
|
import org.springframework.web.bind.annotation.PatchMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -22,20 +24,28 @@ import java.util.UUID
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/films")
|
@RequestMapping("/api/films")
|
||||||
class FilmController(
|
class FilmController(
|
||||||
private val createFilmUseCase: CreateFilmUseCase,
|
private val filmUseCase: FilmUseCase,
|
||||||
private val editFilmUseCase: EditFilmUseCase,
|
|
||||||
private val deleteFilmUseCase: DeleteFilmUseCase,
|
|
||||||
) {
|
) {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
fun create(
|
fun create(
|
||||||
@RequestBody request: CreateFilmRequest,
|
@Valid @RequestBody request: CreateFilmRequest,
|
||||||
): FilmResponse =
|
): FilmResponse =
|
||||||
FilmResponse.fromDomain(
|
FilmResponse.fromDomain(
|
||||||
createFilmUseCase.create(
|
filmUseCase.create(
|
||||||
CreateFilmCommand(
|
CreateFilmCommand(
|
||||||
title = request.title,
|
title = request.title,
|
||||||
description = request.description,
|
description = request.description,
|
||||||
|
contentType = parseContentType(request.contentType),
|
||||||
|
releaseYear = request.releaseYear,
|
||||||
|
genres = request.genres,
|
||||||
|
cast = request.cast,
|
||||||
|
directors = request.directors,
|
||||||
|
imdbRating = request.imdbRating,
|
||||||
|
platformRating = request.platformRating,
|
||||||
|
externalUrl = request.externalUrl,
|
||||||
|
jellyfinItemId = request.jellyfinItemId,
|
||||||
|
jellyfinLibraryId = request.jellyfinLibraryId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -43,15 +53,25 @@ class FilmController(
|
|||||||
@PatchMapping("/{id}")
|
@PatchMapping("/{id}")
|
||||||
fun edit(
|
fun edit(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@RequestBody request: EditFilmRequest,
|
@Valid @RequestBody request: EditFilmRequest,
|
||||||
): FilmResponse =
|
): FilmResponse =
|
||||||
FilmResponse.fromDomain(
|
FilmResponse.fromDomain(
|
||||||
editFilmUseCase.edit(
|
filmUseCase.edit(
|
||||||
id = id,
|
id = id,
|
||||||
command =
|
command =
|
||||||
EditFilmCommand(
|
EditFilmCommand(
|
||||||
title = request.title,
|
title = request.title,
|
||||||
description = request.description,
|
description = request.description,
|
||||||
|
contentType = parseContentType(request.contentType),
|
||||||
|
releaseYear = request.releaseYear,
|
||||||
|
genres = request.genres,
|
||||||
|
cast = request.cast,
|
||||||
|
directors = request.directors,
|
||||||
|
imdbRating = request.imdbRating,
|
||||||
|
platformRating = request.platformRating,
|
||||||
|
externalUrl = request.externalUrl,
|
||||||
|
jellyfinItemId = request.jellyfinItemId,
|
||||||
|
jellyfinLibraryId = request.jellyfinLibraryId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -60,5 +80,25 @@ class FilmController(
|
|||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
fun delete(
|
fun delete(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
) = deleteFilmUseCase.delete(id)
|
) = filmUseCase.delete(id)
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun getById(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): FilmResponse = FilmResponse.fromDomain(filmUseCase.getById(id))
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun getAll(): List<FilmResponse> = filmUseCase.getAll().map { FilmResponse.fromDomain(it) }
|
||||||
|
|
||||||
|
@GetMapping("/search")
|
||||||
|
fun searchByTitle(
|
||||||
|
@RequestParam title: String,
|
||||||
|
): ResponseEntity<FilmResponse> {
|
||||||
|
val film = filmUseCase.searchByTitle(title)
|
||||||
|
return if (film != null) {
|
||||||
|
ResponseEntity.ok(FilmResponse.fromDomain(film))
|
||||||
|
} else {
|
||||||
|
ResponseEntity.notFound().build()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
package com.project.movienight.adapters.web
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
import com.project.movienight.adapters.web.dto.request.CreateFilmLibraryRequest
|
import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse
|
||||||
import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse
|
import com.project.movienight.adapters.web.dto.response.FilmResponse
|
||||||
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
|
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
|
||||||
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
|
import com.project.movienight.application.ports.input.FilmLibraryUseCase
|
||||||
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
|
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
||||||
import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase
|
|
||||||
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
|
|
||||||
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
|
|
||||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
|
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
|
||||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
|
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
@@ -24,44 +19,29 @@ import java.util.UUID
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/users/{userId}/library")
|
@RequestMapping("/api/users/{userId}/library")
|
||||||
class FilmLibraryController(
|
class FilmLibraryController(
|
||||||
private val createFilmLibraryUseCase: CreateFilmLibraryUseCase,
|
private val filmLibraryUseCase: FilmLibraryUseCase,
|
||||||
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
|
|
||||||
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
|
|
||||||
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
|
|
||||||
) {
|
) {
|
||||||
@PostMapping
|
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
|
||||||
fun create(
|
|
||||||
@PathVariable userId: UUID,
|
|
||||||
@RequestBody request: CreateFilmLibraryRequest,
|
|
||||||
): FilmLibraryResponse =
|
|
||||||
FilmLibraryResponse.fromDomain(
|
|
||||||
createFilmLibraryUseCase.create(
|
|
||||||
CreateFilmLibraryCommand(
|
|
||||||
userId = userId,
|
|
||||||
name = request.name,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun get(
|
fun list(
|
||||||
@PathVariable userId: UUID,
|
@PathVariable userId: UUID,
|
||||||
): FilmLibraryResponse =
|
): List<FilmLibraryEntryResponse> =
|
||||||
FilmLibraryResponse.fromDomain(
|
filmLibraryUseCase
|
||||||
getFilmLibraryUseCase.getLibrary(
|
.list(userId)
|
||||||
GetFilmLibraryQuery(userId = userId),
|
.map { entry -> FilmLibraryEntryResponse.fromDomain(entry) }
|
||||||
),
|
|
||||||
)
|
@GetMapping("/entries")
|
||||||
|
fun listEntries(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
): List<FilmLibraryEntryResponse> = list(userId)
|
||||||
|
|
||||||
@PostMapping("/films/{filmId}")
|
@PostMapping("/films/{filmId}")
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
fun addFilm(
|
fun addFilm(
|
||||||
@PathVariable userId: UUID,
|
@PathVariable userId: UUID,
|
||||||
@PathVariable filmId: UUID,
|
@PathVariable filmId: UUID,
|
||||||
): FilmLibraryResponse =
|
): FilmLibraryEntryResponse =
|
||||||
FilmLibraryResponse.fromDomain(
|
FilmLibraryEntryResponse.fromDomain(
|
||||||
addFilmToLibraryUseCase.addFilm(
|
filmLibraryUseCase.addFilm(
|
||||||
AddFilmToLibraryCommand(
|
AddFilmToLibraryCommand(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
filmId = filmId,
|
filmId = filmId,
|
||||||
@@ -69,15 +49,36 @@ class FilmLibraryController(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@PostMapping("/films/{filmId}/viewed")
|
||||||
|
fun markViewed(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@PathVariable filmId: UUID,
|
||||||
|
): FilmLibraryEntryResponse =
|
||||||
|
FilmLibraryEntryResponse.fromDomain(
|
||||||
|
filmLibraryUseCase.markViewed(
|
||||||
|
MarkFilmViewedCommand(
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@DeleteMapping("/films/{filmId}")
|
@DeleteMapping("/films/{filmId}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
fun removeFilm(
|
fun removeFilm(
|
||||||
@PathVariable userId: UUID,
|
@PathVariable userId: UUID,
|
||||||
@PathVariable filmId: UUID,
|
@PathVariable filmId: UUID,
|
||||||
) = removeFilmFromLibraryUseCase.removeFilm(
|
) {
|
||||||
RemoveFilmFromLibraryCommand(
|
filmLibraryUseCase.removeFilm(
|
||||||
userId = userId,
|
RemoveFilmFromLibraryCommand(
|
||||||
filmId = filmId,
|
userId = userId,
|
||||||
),
|
filmId = filmId,
|
||||||
)
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/available-films")
|
||||||
|
fun getAvailableFilms(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
): List<FilmResponse> = filmLibraryUseCase.listAvailableFilms(userId).map { FilmResponse.fromDomain(it) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
|
||||||
|
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
|
||||||
|
import com.project.movienight.application.ports.input.FilmRatingUseCase
|
||||||
|
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/users/{userId}/ratings")
|
||||||
|
class FilmRatingController(
|
||||||
|
private val filmRatingUseCase: FilmRatingUseCase,
|
||||||
|
) {
|
||||||
|
@PostMapping("/films/{filmId}")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
fun rate(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@PathVariable filmId: UUID,
|
||||||
|
@Valid @RequestBody request: RateFilmRequest,
|
||||||
|
): FilmRatingResponse =
|
||||||
|
FilmRatingResponse.fromDomain(
|
||||||
|
filmRatingUseCase.rate(
|
||||||
|
RateFilmCommand(
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
score = request.score,
|
||||||
|
note = request.note,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun list(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
): List<FilmRatingResponse> = filmRatingUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest
|
||||||
|
import com.project.movienight.application.ports.input.HandleJellyfinEventCommand
|
||||||
|
import com.project.movienight.application.ports.input.JellyfinEventUseCase
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/integrations/jellyfin")
|
||||||
|
class JellyfinEventsController(
|
||||||
|
private val jellyfinEventUseCase: JellyfinEventUseCase,
|
||||||
|
private val authenticator: JellyfinPluginAuthenticator,
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
|
||||||
|
|
||||||
|
@PostMapping("/events")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
fun receiveEvent(
|
||||||
|
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||||
|
@Valid @RequestBody request: JellyfinEventRequest,
|
||||||
|
) {
|
||||||
|
authenticator.authenticate(token)
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
"Received Jellyfin event {} for user {} item {}",
|
||||||
|
request.eventId,
|
||||||
|
request.jellyfinUserId,
|
||||||
|
request.itemId,
|
||||||
|
)
|
||||||
|
jellyfinEventUseCase.handle(
|
||||||
|
HandleJellyfinEventCommand(
|
||||||
|
eventId = request.eventId,
|
||||||
|
serverId = null,
|
||||||
|
eventType = request.eventType,
|
||||||
|
occurredAt = request.occurredAt,
|
||||||
|
jellyfinUserId = request.jellyfinUserId,
|
||||||
|
itemId = request.itemId,
|
||||||
|
payload = request.payload,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class JellyfinPluginAuthenticator(
|
||||||
|
private val properties: JellyfinIntegrationProperties,
|
||||||
|
) {
|
||||||
|
fun authenticate(token: String?) {
|
||||||
|
if (!properties.enabled) {
|
||||||
|
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (properties.pluginToken.isNotBlank() && token != properties.pluginToken) {
|
||||||
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
|
||||||
|
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
|
||||||
|
import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse
|
||||||
|
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
|
||||||
|
import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse
|
||||||
|
import com.project.movienight.adapters.web.dto.response.RecommendationResponse
|
||||||
|
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
|
||||||
|
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
|
||||||
|
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
|
||||||
|
import com.project.movienight.application.ports.input.FilmLibraryUseCase
|
||||||
|
import com.project.movienight.application.ports.input.FilmRatingUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
|
||||||
|
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
||||||
|
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||||
|
import com.project.movienight.application.ports.input.RecommendationQuery
|
||||||
|
import com.project.movienight.application.ports.input.UserPreferencesUseCase
|
||||||
|
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.IdGenerator
|
||||||
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
|
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||||
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
|
import com.project.movienight.domain.model.Film
|
||||||
|
import com.project.movienight.domain.model.RecommendationStyle
|
||||||
|
import com.project.movienight.domain.model.User
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/integrations/jellyfin")
|
||||||
|
class JellyfinPluginController(
|
||||||
|
private val authenticator: JellyfinPluginAuthenticator,
|
||||||
|
private val userRepository: UserRepositoryPort,
|
||||||
|
private val filmRepository: FilmRepositoryPort,
|
||||||
|
private val idGenerator: IdGenerator,
|
||||||
|
private val getRecommendationsUseCase: GetRecommendationsUseCase,
|
||||||
|
private val filmRatingUseCase: FilmRatingUseCase,
|
||||||
|
private val filmLibraryUseCase: FilmLibraryUseCase,
|
||||||
|
private val userPreferencesUseCase: UserPreferencesUseCase,
|
||||||
|
private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase,
|
||||||
|
private val jellyfinProperties: JellyfinIntegrationProperties,
|
||||||
|
) {
|
||||||
|
@GetMapping("/users/{jellyfinUserId}/recommendations")
|
||||||
|
fun recommend(
|
||||||
|
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||||
|
@PathVariable jellyfinUserId: String,
|
||||||
|
@RequestParam(required = false) contentType: String?,
|
||||||
|
@RequestParam(required = false) mood: String?,
|
||||||
|
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
|
||||||
|
@RequestParam(required = false, defaultValue = "10") limit: Int,
|
||||||
|
): List<RecommendationResponse> {
|
||||||
|
authenticator.authenticate(token)
|
||||||
|
val user = resolveOrCreateUser(jellyfinUserId)
|
||||||
|
return getRecommendationsUseCase
|
||||||
|
.recommend(
|
||||||
|
RecommendationQuery(
|
||||||
|
userId = user.id,
|
||||||
|
contentType = parseOptionalContentType(contentType),
|
||||||
|
mood = mood,
|
||||||
|
libraryOnly = libraryOnly,
|
||||||
|
limit = limit,
|
||||||
|
),
|
||||||
|
).map { recommendation ->
|
||||||
|
RecommendationResponse.fromDomain(
|
||||||
|
recommendation = recommendation,
|
||||||
|
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{jellyfinUserId}/ratings/items/{jellyfinItemId}")
|
||||||
|
fun rate(
|
||||||
|
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||||
|
@PathVariable jellyfinUserId: String,
|
||||||
|
@PathVariable jellyfinItemId: String,
|
||||||
|
@Valid @RequestBody request: RateFilmRequest,
|
||||||
|
): FilmRatingResponse {
|
||||||
|
authenticator.authenticate(token)
|
||||||
|
val user = resolveOrCreateUser(jellyfinUserId)
|
||||||
|
val film = resolveFilm(jellyfinItemId)
|
||||||
|
return FilmRatingResponse.fromDomain(
|
||||||
|
filmRatingUseCase.rate(
|
||||||
|
RateFilmCommand(
|
||||||
|
userId = user.id,
|
||||||
|
filmId = film.id,
|
||||||
|
score = request.score,
|
||||||
|
note = request.note,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/users/{jellyfinUserId}/ratings")
|
||||||
|
fun ratings(
|
||||||
|
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||||
|
@PathVariable jellyfinUserId: String,
|
||||||
|
): List<FilmRatingResponse> {
|
||||||
|
authenticator.authenticate(token)
|
||||||
|
val user = resolveOrCreateUser(jellyfinUserId)
|
||||||
|
return filmRatingUseCase.getRatings(user.id).map { FilmRatingResponse.fromDomain(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{jellyfinUserId}/library/items/{jellyfinItemId}/viewed")
|
||||||
|
fun markViewed(
|
||||||
|
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||||
|
@PathVariable jellyfinUserId: String,
|
||||||
|
@PathVariable jellyfinItemId: String,
|
||||||
|
@RequestBody(required = false) request: JellyfinViewedRequest?,
|
||||||
|
): FilmLibraryEntryResponse {
|
||||||
|
authenticator.authenticate(token)
|
||||||
|
val user = resolveOrCreateUser(jellyfinUserId)
|
||||||
|
val film = resolveFilm(jellyfinItemId)
|
||||||
|
return FilmLibraryEntryResponse.fromDomain(
|
||||||
|
filmLibraryUseCase.markViewed(
|
||||||
|
MarkFilmViewedCommand(
|
||||||
|
userId = user.id,
|
||||||
|
filmId = film.id,
|
||||||
|
watchedAt = request?.watchedAt?.toLocalDateTime(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/users/{jellyfinUserId}/preferences")
|
||||||
|
fun preferences(
|
||||||
|
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||||
|
@PathVariable jellyfinUserId: String,
|
||||||
|
): UserPreferencesResponse {
|
||||||
|
authenticator.authenticate(token)
|
||||||
|
val user = resolveOrCreateUser(jellyfinUserId)
|
||||||
|
return userPreferencesUseCase.get(user.id)?.let { UserPreferencesResponse.fromDomain(it) }
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User preferences not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{jellyfinUserId}/recommendation-onboarding")
|
||||||
|
fun completeOnboarding(
|
||||||
|
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||||
|
@PathVariable jellyfinUserId: String,
|
||||||
|
@RequestBody request: RecommendationOnboardingRequest,
|
||||||
|
): RecommendationOnboardingResponse {
|
||||||
|
authenticator.authenticate(token)
|
||||||
|
val user = resolveOrCreateUser(jellyfinUserId)
|
||||||
|
return RecommendationOnboardingResponse.fromApplication(
|
||||||
|
completeRecommendationOnboardingUseCase.complete(
|
||||||
|
CompleteRecommendationOnboardingCommand(
|
||||||
|
userId = user.id,
|
||||||
|
weightedGenres = request.weightedGenres,
|
||||||
|
plotTypes = request.plotTypes,
|
||||||
|
eras = request.eras,
|
||||||
|
castAndDirectors = request.castAndDirectors,
|
||||||
|
moods = request.moods,
|
||||||
|
contentTypes = request.contentTypes.mapNotNull { runCatching { parseContentType(it) }.getOrNull() },
|
||||||
|
likedFilmIds = request.likedFilmIds,
|
||||||
|
dislikedFilmIds = request.dislikedFilmIds,
|
||||||
|
libraryFilmIds = request.libraryFilmIds,
|
||||||
|
watchedFilmIds = request.watchedFilmIds,
|
||||||
|
recommendationStyle = parseRecommendationStyle(request.recommendationStyle),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveOrCreateUser(jellyfinUserId: String): User =
|
||||||
|
normalizeJellyfinId(jellyfinUserId).let { normalizedId ->
|
||||||
|
userRepository.findByJellyfinUserId(normalizedId)
|
||||||
|
?: userRepository.save(
|
||||||
|
User(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
name = "Jellyfin User",
|
||||||
|
email = syntheticJellyfinEmail(normalizedId),
|
||||||
|
jellyfinUserId = normalizedId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveFilm(jellyfinItemId: String): Film =
|
||||||
|
normalizeJellyfinId(jellyfinItemId).let { normalizedId ->
|
||||||
|
filmRepository.findByJellyfinItemId(normalizedId)
|
||||||
|
?: throw EntityNotFoundException(entity = "Jellyfin item", id = jellyfinItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildWatchUrl(jellyfinItemId: String?): String? {
|
||||||
|
if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val baseUrl = jellyfinProperties.webUrl.trimEnd('/')
|
||||||
|
val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8)
|
||||||
|
return "$baseUrl/web/#/details?id=$encodedItemId"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRecommendationStyle(value: String): RecommendationStyle =
|
||||||
|
runCatching { RecommendationStyle.valueOf(value.uppercase()) }
|
||||||
|
.getOrDefault(RecommendationStyle.BALANCED)
|
||||||
|
|
||||||
|
private fun normalizeJellyfinId(value: String): String =
|
||||||
|
runCatching { UUID.fromString(value).toString().replace("-", "") }
|
||||||
|
.getOrDefault(value)
|
||||||
|
|
||||||
|
private fun syntheticJellyfinEmail(jellyfinUserId: String): String {
|
||||||
|
val safeId =
|
||||||
|
jellyfinUserId
|
||||||
|
.lowercase(Locale.getDefault())
|
||||||
|
.replace(Regex("[^a-z0-9._%+-]"), "-")
|
||||||
|
.take(240)
|
||||||
|
return "jellyfin-$safeId@movienight.local"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class JellyfinViewedRequest(
|
||||||
|
val watchedAt: OffsetDateTime? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.request.JellyfinSyncRequest
|
||||||
|
import com.project.movienight.application.ports.input.IngestJellyfinSyncCommand
|
||||||
|
import com.project.movienight.application.ports.input.JellyfinSyncItemCommand
|
||||||
|
import com.project.movienight.application.ports.input.JellyfinSyncUseCase
|
||||||
|
import com.project.movienight.application.ports.input.JellyfinSyncUserCommand
|
||||||
|
import com.project.movienight.application.ports.input.JellyfinSyncUserStateCommand
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncState
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/integrations/jellyfin")
|
||||||
|
class JellyfinSyncController(
|
||||||
|
private val jellyfinSyncUseCase: JellyfinSyncUseCase,
|
||||||
|
private val authenticator: JellyfinPluginAuthenticator,
|
||||||
|
) {
|
||||||
|
@PostMapping("/sync")
|
||||||
|
fun syncNow(
|
||||||
|
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||||
|
@Valid @RequestBody(required = false) request: JellyfinSyncRequest?,
|
||||||
|
): JellyfinSyncSummary {
|
||||||
|
authenticator.authenticate(token)
|
||||||
|
return if (request == null) {
|
||||||
|
jellyfinSyncUseCase.syncNow()
|
||||||
|
} else {
|
||||||
|
jellyfinSyncUseCase.ingest(request.toCommand())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/sync-state")
|
||||||
|
fun syncState(
|
||||||
|
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||||
|
): List<JellyfinSyncState> {
|
||||||
|
authenticator.authenticate(token)
|
||||||
|
return jellyfinSyncUseCase.getSyncStates()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JellyfinSyncRequest.toCommand(): IngestJellyfinSyncCommand =
|
||||||
|
IngestJellyfinSyncCommand(
|
||||||
|
users =
|
||||||
|
users.map { user ->
|
||||||
|
JellyfinSyncUserCommand(
|
||||||
|
jellyfinUserId = user.jellyfinUserId,
|
||||||
|
name = user.name,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
items =
|
||||||
|
items.map { item ->
|
||||||
|
JellyfinSyncItemCommand(
|
||||||
|
jellyfinItemId = item.jellyfinItemId,
|
||||||
|
title = item.title,
|
||||||
|
originalTitle = item.originalTitle,
|
||||||
|
description = item.description,
|
||||||
|
year = item.year,
|
||||||
|
genres = item.genres,
|
||||||
|
imdbId = item.imdbId,
|
||||||
|
tmdbId = item.tmdbId,
|
||||||
|
jellyfinLibraryId = item.jellyfinLibraryId,
|
||||||
|
userStates =
|
||||||
|
item.userStates.map { state ->
|
||||||
|
JellyfinSyncUserStateCommand(
|
||||||
|
jellyfinUserId = state.jellyfinUserId,
|
||||||
|
isViewed = state.isViewed,
|
||||||
|
playCount = state.playCount,
|
||||||
|
lastPlayedAt = state.lastPlayedAt,
|
||||||
|
userRating = state.userRating,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.response.RecommendationEventResponse
|
||||||
|
import com.project.movienight.adapters.web.dto.response.RecommendationResponse
|
||||||
|
import com.project.movienight.application.ports.input.AcceptRecommendationCommand
|
||||||
|
import com.project.movienight.application.ports.input.AcceptRecommendationUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
|
||||||
|
import com.project.movienight.application.ports.input.RecommendationQuery
|
||||||
|
import com.project.movienight.application.ports.input.RejectRecommendationCommand
|
||||||
|
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
|
||||||
|
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/users/{userId}/recommendations")
|
||||||
|
class RecommendationController(
|
||||||
|
private val getRecommendationsUseCase: GetRecommendationsUseCase,
|
||||||
|
private val acceptRecommendationUseCase: AcceptRecommendationUseCase,
|
||||||
|
private val rejectRecommendationUseCase: RejectRecommendationUseCase,
|
||||||
|
private val jellyfinProperties: JellyfinIntegrationProperties,
|
||||||
|
) {
|
||||||
|
@GetMapping
|
||||||
|
fun recommend(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@RequestParam(required = false) contentType: String?,
|
||||||
|
@RequestParam(required = false) mood: String?,
|
||||||
|
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
|
||||||
|
@RequestParam(required = false, defaultValue = "10") limit: Int,
|
||||||
|
): List<RecommendationResponse> =
|
||||||
|
getRecommendationsUseCase
|
||||||
|
.recommend(
|
||||||
|
RecommendationQuery(
|
||||||
|
userId = userId,
|
||||||
|
contentType = parseOptionalContentType(contentType),
|
||||||
|
mood = mood,
|
||||||
|
libraryOnly = libraryOnly,
|
||||||
|
limit = limit,
|
||||||
|
),
|
||||||
|
).map { recommendation ->
|
||||||
|
RecommendationResponse.fromDomain(
|
||||||
|
recommendation = recommendation,
|
||||||
|
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{filmId}/accept")
|
||||||
|
fun accept(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@PathVariable filmId: UUID,
|
||||||
|
): RecommendationEventResponse =
|
||||||
|
RecommendationEventResponse.fromDomain(
|
||||||
|
acceptRecommendationUseCase.accept(
|
||||||
|
AcceptRecommendationCommand(
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@PostMapping("/{filmId}/reject")
|
||||||
|
fun reject(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@PathVariable filmId: UUID,
|
||||||
|
): RecommendationEventResponse =
|
||||||
|
RecommendationEventResponse.fromDomain(
|
||||||
|
rejectRecommendationUseCase.reject(
|
||||||
|
RejectRecommendationCommand(
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun buildWatchUrl(jellyfinItemId: String?): String? {
|
||||||
|
if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val baseUrl = jellyfinProperties.webUrl.trimEnd('/')
|
||||||
|
val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8)
|
||||||
|
return "$baseUrl/web/#/details?id=$encodedItemId"
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.security.UserPrincipal
|
||||||
import com.project.movienight.adapters.web.dto.request.CreateUserRequest
|
import com.project.movienight.adapters.web.dto.request.CreateUserRequest
|
||||||
import com.project.movienight.adapters.web.dto.request.EditUserRequest
|
import com.project.movienight.adapters.web.dto.request.EditUserRequest
|
||||||
import com.project.movienight.adapters.web.dto.response.UserResponse
|
import com.project.movienight.adapters.web.dto.response.UserResponse
|
||||||
import com.project.movienight.application.ports.input.CreateUserCommand
|
import com.project.movienight.application.ports.input.CreateUserCommand
|
||||||
import com.project.movienight.application.ports.input.CreateUserUseCase
|
|
||||||
import com.project.movienight.application.ports.input.DeleteUserUseCase
|
|
||||||
import com.project.movienight.application.ports.input.EditUserCommand
|
import com.project.movienight.application.ports.input.EditUserCommand
|
||||||
import com.project.movienight.application.ports.input.EditUserUseCase
|
import com.project.movienight.application.ports.input.UserUseCase
|
||||||
|
import jakarta.validation.Valid
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PatchMapping
|
import org.springframework.web.bind.annotation.PatchMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
@@ -22,17 +24,15 @@ import java.util.UUID
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/users")
|
@RequestMapping("/api/users")
|
||||||
class UserController(
|
class UserController(
|
||||||
private val createUserUseCase: CreateUserUseCase,
|
private val userUseCase: UserUseCase,
|
||||||
private val editUserUseCase: EditUserUseCase,
|
|
||||||
private val deleteUserUseCase: DeleteUserUseCase,
|
|
||||||
) {
|
) {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
fun create(
|
fun create(
|
||||||
@RequestBody request: CreateUserRequest,
|
@Valid @RequestBody request: CreateUserRequest,
|
||||||
): UserResponse =
|
): UserResponse =
|
||||||
UserResponse.fromDomain(
|
UserResponse.fromDomain(
|
||||||
createUserUseCase.create(
|
userUseCase.create(
|
||||||
CreateUserCommand(
|
CreateUserCommand(
|
||||||
name = request.name,
|
name = request.name,
|
||||||
email = request.email,
|
email = request.email,
|
||||||
@@ -40,17 +40,31 @@ class UserController(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun getAll(): List<UserResponse> = userUseCase.getAll().map { UserResponse.fromDomain(it) }
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
fun getMe(
|
||||||
|
@AuthenticationPrincipal principal: UserPrincipal,
|
||||||
|
): UserResponse = UserResponse.fromDomain(userUseCase.getById(principal.getId()))
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun getById(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): UserResponse = UserResponse.fromDomain(userUseCase.getById(id))
|
||||||
|
|
||||||
@PatchMapping("/{id}")
|
@PatchMapping("/{id}")
|
||||||
fun edit(
|
fun edit(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@RequestBody request: EditUserRequest,
|
@Valid @RequestBody request: EditUserRequest,
|
||||||
): UserResponse =
|
): UserResponse =
|
||||||
UserResponse.fromDomain(
|
UserResponse.fromDomain(
|
||||||
editUserUseCase.edit(
|
userUseCase.edit(
|
||||||
id = id,
|
id = id,
|
||||||
command =
|
command =
|
||||||
EditUserCommand(
|
EditUserCommand(
|
||||||
name = request.name,
|
name = request.name,
|
||||||
|
jellyfinUserId = request.jellyfinUserId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -59,5 +73,5 @@ class UserController(
|
|||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
fun delete(
|
fun delete(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
) = deleteUserUseCase.delete(id)
|
) = userUseCase.delete(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest
|
||||||
|
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
|
||||||
|
import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
|
||||||
|
import com.project.movienight.application.ports.input.UserPreferencesUseCase
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/users/{userId}/preferences")
|
||||||
|
class UserPreferencesController(
|
||||||
|
private val userPreferencesUseCase: UserPreferencesUseCase,
|
||||||
|
) {
|
||||||
|
@PutMapping
|
||||||
|
fun upsert(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@Valid @RequestBody request: UpsertUserPreferencesRequest,
|
||||||
|
): UserPreferencesResponse =
|
||||||
|
UserPreferencesResponse.fromDomain(
|
||||||
|
userPreferencesUseCase.upsert(
|
||||||
|
UpsertUserPreferencesCommand(
|
||||||
|
userId = userId,
|
||||||
|
weightedGenres = request.weightedGenres,
|
||||||
|
plotTypes = request.plotTypes,
|
||||||
|
eras = request.eras,
|
||||||
|
castAndDirectors = request.castAndDirectors,
|
||||||
|
moods = request.moods,
|
||||||
|
contentTypes =
|
||||||
|
request.contentTypes.map { parseContentType(it) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun get(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
): UserPreferencesResponse? = userPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) }
|
||||||
|
}
|
||||||
+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
|
package com.project.movienight.adapters.web.dto.request
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max
|
||||||
|
import jakarta.validation.constraints.Min
|
||||||
|
import jakarta.validation.constraints.NotBlank
|
||||||
|
import jakarta.validation.constraints.Size
|
||||||
|
|
||||||
data class CreateFilmRequest(
|
data class CreateFilmRequest(
|
||||||
|
@field:NotBlank
|
||||||
|
@field:Size(max = 255)
|
||||||
val title: String,
|
val title: String,
|
||||||
|
@field:NotBlank
|
||||||
val description: String,
|
val description: String,
|
||||||
|
@field:NotBlank
|
||||||
|
val contentType: String = "FILM",
|
||||||
|
@field:Min(1888)
|
||||||
|
@field:Max(3000)
|
||||||
|
val releaseYear: Int? = null,
|
||||||
|
val genres: List<String> = emptyList(),
|
||||||
|
val cast: List<String> = emptyList(),
|
||||||
|
val directors: List<String> = emptyList(),
|
||||||
|
@field:Min(0)
|
||||||
|
@field:Max(10)
|
||||||
|
val imdbRating: Double? = null,
|
||||||
|
@field:Min(0)
|
||||||
|
@field:Max(10)
|
||||||
|
val platformRating: Double? = null,
|
||||||
|
val externalUrl: String? = null,
|
||||||
|
val jellyfinItemId: String? = null,
|
||||||
|
val jellyfinLibraryId: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
package com.project.movienight.adapters.web.dto.request
|
package com.project.movienight.adapters.web.dto.request
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email
|
||||||
|
import jakarta.validation.constraints.NotBlank
|
||||||
|
import jakarta.validation.constraints.Size
|
||||||
|
|
||||||
data class CreateUserRequest(
|
data class CreateUserRequest(
|
||||||
|
@field:NotBlank
|
||||||
|
@field:Size(max = 255)
|
||||||
val name: String,
|
val name: String,
|
||||||
|
@field:Email
|
||||||
|
@field:NotBlank
|
||||||
|
@field:Size(max = 320)
|
||||||
val email: String,
|
val email: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
package com.project.movienight.adapters.web.dto.request
|
package com.project.movienight.adapters.web.dto.request
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max
|
||||||
|
import jakarta.validation.constraints.Min
|
||||||
|
import jakarta.validation.constraints.NotBlank
|
||||||
|
import jakarta.validation.constraints.Size
|
||||||
|
|
||||||
data class EditFilmRequest(
|
data class EditFilmRequest(
|
||||||
|
@field:NotBlank
|
||||||
|
@field:Size(max = 255)
|
||||||
val title: String,
|
val title: String,
|
||||||
|
@field:NotBlank
|
||||||
val description: String,
|
val description: String,
|
||||||
|
@field:NotBlank
|
||||||
|
val contentType: String = "FILM",
|
||||||
|
@field:Min(1888)
|
||||||
|
@field:Max(3000)
|
||||||
|
val releaseYear: Int? = null,
|
||||||
|
val genres: List<String> = emptyList(),
|
||||||
|
val cast: List<String> = emptyList(),
|
||||||
|
val directors: List<String> = emptyList(),
|
||||||
|
@field:Min(0)
|
||||||
|
@field:Max(10)
|
||||||
|
val imdbRating: Double? = null,
|
||||||
|
@field:Min(0)
|
||||||
|
@field:Max(10)
|
||||||
|
val platformRating: Double? = null,
|
||||||
|
val externalUrl: String? = null,
|
||||||
|
val jellyfinItemId: String? = null,
|
||||||
|
val jellyfinLibraryId: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user