diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..e7960c4 --- /dev/null +++ b/.github/workflows/build.yaml @@ -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 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..dd165c3 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000..0854d36 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -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 diff --git a/.github/workflows/jellyfin-plugin.yaml b/.github/workflows/jellyfin-plugin.yaml new file mode 100644 index 0000000..874d201 --- /dev/null +++ b/.github/workflows/jellyfin-plugin.yaml @@ -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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..0fdfcb1 --- /dev/null +++ b/.github/workflows/release.yaml @@ -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}" diff --git a/.gitignore b/.gitignore index ee59915..795dcc9 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ gradle-app.setting *.tar.gz *.rar +# Gradle wrapper +!gradle/wrapper/gradle-wrapper.jar + # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 88cf87c..9b361e3 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -2,6 +2,6 @@ \ No newline at end of file diff --git a/Containerfile b/Containerfile index 7e1867b..1fda1fa 100644 --- a/Containerfile +++ b/Containerfile @@ -26,13 +26,7 @@ RUN --mount=type=cache,target=${GRADLE_USER_HOME} \ COPY src src RUN --mount=type=cache,target=${GRADLE_USER_HOME} \ - ./gradlew --no-daemon build \ - -x test \ - -x detekt \ - -x ktlintCheck \ - -x ktlintKotlinScriptCheck \ - -x ktlintMainSourceSetCheck \ - -x ktlintTestSourceSetCheck + ./gradlew --no-daemon bootJar RUN mkdir -p ${APP_HOME}/dist \ && cp ${APP_HOME}/build/libs/*.jar ${APP_HOME}/dist/${JAR_NAME} \ @@ -60,6 +54,7 @@ COPY --from=builder --chown=app:app /workspace/dist/app.jar /app/app.jar USER app ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC" \ + SPRING_AOT_ENABLED=true \ SERVER_PORT=8080 \ PATH="/app:$PATH" @@ -70,4 +65,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ STOPSIGNAL SIGTERM -ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} -Dserver.port=${SERVER_PORT} -jar /app/app.jar"] +ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} -Dspring.aot.enabled=${SPRING_AOT_ENABLED} -Dserver.port=${SERVER_PORT} -jar /app/app.jar"] diff --git a/build.gradle.kts b/build.gradle.kts index 18d7d23..48eb951 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,6 +15,8 @@ plugins { jacoco } +apply(plugin = "org.springframework.boot.aot") + apply(from = "$rootDir/gradle/docker.gradle.kts") group = "com.project" @@ -28,11 +30,11 @@ java { dependencies { implementation(platform(libs.sentry.bom)) - implementation(platform(libs.spring.grpc.bom)) implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.actuator) -// implementation(libs.spring.boot.starter.security) + implementation(libs.spring.boot.starter.aop) + implementation(libs.spring.boot.starter.security) implementation(libs.spring.boot.starter.cache) implementation(libs.spring.boot.starter.data.jdbc) implementation(libs.spring.boot.starter.validation) @@ -40,12 +42,13 @@ dependencies { implementation(libs.flyway.database.postgresql) implementation(libs.kotlin.reflect) + implementation("net.logstash.logback:logstash-logback-encoder:8.0") + implementation(libs.micrometer.tracing.bridge.otel) implementation(libs.opentelemetry.exporter.otlp) implementation(libs.sentry.spring.boot.starter) - implementation(libs.spring.grpc.starter) - implementation(libs.grpc.services) + implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) @@ -55,7 +58,7 @@ dependencies { testImplementation(libs.spring.boot.starter.test) testImplementation(libs.kotlin.test.junit5) - testImplementation(libs.spring.grpc.test) + testImplementation(libs.mockk) testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/config/detekt/detekt.yaml b/config/detekt/detekt.yaml index 9b7c718..1cb8259 100644 --- a/config/detekt/detekt.yaml +++ b/config/detekt/detekt.yaml @@ -7,3 +7,12 @@ comments: active: false UndocumentedPublicProperty: active: false + +style: + MagicNumber: + active: false + ReturnCount: + max: 3 + +complexity: + active: false diff --git a/deploy/argocd/cloudnative-pg.yaml b/deploy/argocd/cloudnative-pg.yaml new file mode 100644 index 0000000..8aae1e4 --- /dev/null +++ b/deploy/argocd/cloudnative-pg.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: cloudnative-pg + namespace: argocd +spec: + project: default + source: + repoURL: https://cloudnative-pg.github.io/charts + targetRevision: 0.27.1 + chart: cloudnative-pg + + destination: + server: https://kubernetes.default.svc + namespace: cloudnative-pg + + syncPolicy: + automated: + prune: true + selfHeal: true + enabled: true + syncOptions: + - CreateNamespace=true + - ApplyOutOfSyncOnly=true + - ServerSideApply=true diff --git a/deploy/argocd/external-secrets-operator.yaml b/deploy/argocd/external-secrets-operator.yaml new file mode 100644 index 0000000..9d0f430 --- /dev/null +++ b/deploy/argocd/external-secrets-operator.yaml @@ -0,0 +1,32 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: external-secrets-operator + namespace: argocd +spec: + project: default + source: + repoURL: https://charts.external-secrets.io + targetRevision: 2.5.0 + chart: external-secrets + helm: + valuesObject: + webhook: + create: false + certController: + create: false + + destination: + server: https://kubernetes.default.svc + namespace: external-secrets + + syncPolicy: + automated: + prune: true + selfHeal: true + enabled: true + syncOptions: + - CreateNamespace=true + - ApplyOutOfSyncOnly=true + - ServerSideApply=true diff --git a/deploy/argocd/grafana.yaml b/deploy/argocd/grafana.yaml new file mode 100644 index 0000000..9d34053 --- /dev/null +++ b/deploy/argocd/grafana.yaml @@ -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 diff --git a/deploy/argocd/jellyfin.yaml b/deploy/argocd/jellyfin.yaml new file mode 100644 index 0000000..898df16 --- /dev/null +++ b/deploy/argocd/jellyfin.yaml @@ -0,0 +1,36 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: jellyfin + namespace: argocd +spec: + project: default + source: + repoURL: https://jellyfin.github.io/jellyfin-helm + targetRevision: 2.7.0 + chart: jellyfin + helm: + valuesObject: + replicaCount: 1 + persistence: + config: + size: 4Gi + media: + size: 20Gi + metrics: + enabled: true + + destination: + server: https://kubernetes.default.svc + namespace: jellyfin + + syncPolicy: + automated: + prune: true + selfHeal: true + enabled: true + syncOptions: + - CreateNamespace=true + - ApplyOutOfSyncOnly=true + - ServerSideApply=true diff --git a/deploy/argocd/victoriametrics-agent.yaml b/deploy/argocd/victoriametrics-agent.yaml new file mode 100644 index 0000000..b984493 --- /dev/null +++ b/deploy/argocd/victoriametrics-agent.yaml @@ -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 diff --git a/deploy/argocd/victoriametrics-alert.yaml b/deploy/argocd/victoriametrics-alert.yaml new file mode 100644 index 0000000..3035280 --- /dev/null +++ b/deploy/argocd/victoriametrics-alert.yaml @@ -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 diff --git a/deploy/argocd/victoriametrics-operator.yaml b/deploy/argocd/victoriametrics-operator.yaml new file mode 100644 index 0000000..f80a759 --- /dev/null +++ b/deploy/argocd/victoriametrics-operator.yaml @@ -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 diff --git a/deploy/argocd/victoriametrics-single.yaml b/deploy/argocd/victoriametrics-single.yaml new file mode 100644 index 0000000..64979b4 --- /dev/null +++ b/deploy/argocd/victoriametrics-single.yaml @@ -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 diff --git a/deploy/helm/movienight/Chart.yaml b/deploy/helm/movienight/Chart.yaml new file mode 100644 index 0000000..6280bbb --- /dev/null +++ b/deploy/helm/movienight/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: movienight +description: MovieNight backend +type: application +version: 0.1.0 +appVersion: "0.0.1" diff --git a/deploy/helm/movienight/templates/NOTES.txt b/deploy/helm/movienight/templates/NOTES.txt new file mode 100644 index 0000000..9eb3212 --- /dev/null +++ b/deploy/helm/movienight/templates/NOTES.txt @@ -0,0 +1,25 @@ +MovieNight backend has been deployed. + +Backend: + Service: {{ include "movienight.fullname" . }}-backend + Port: {{ .Values.backend.service.port }} + +Postgres: +{{- if .Values.postgres.url }} + Using explicit SPRING_DATASOURCE_URL. +{{- else if .Values.postgres.existingSecret.name }} + Using secret {{ .Values.postgres.existingSecret.name }}. +{{- else if .Values.postgres.cluster.enabled }} + CNPG Cluster: {{ include "movienight.postgresClusterName" . }} + JDBC URL: {{ include "movienight.postgresJdbcUrl" . }} +{{- else }} + No Postgres values provided. The app will fall back to its embedded H2 defaults. +{{- end }} + +Gateway: +{{- if .Values.gateway.enabled }} + Gateway: {{ include "movienight.gatewayName" . }} + GatewayClass: {{ .Values.gateway.className }} +{{- else }} + Disabled. +{{- end }} diff --git a/deploy/helm/movienight/templates/_helpers.tpl b/deploy/helm/movienight/templates/_helpers.tpl new file mode 100644 index 0000000..f86ef93 --- /dev/null +++ b/deploy/helm/movienight/templates/_helpers.tpl @@ -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 -}} diff --git a/deploy/helm/movienight/templates/backend/deployment.yaml b/deploy/helm/movienight/templates/backend/deployment.yaml new file mode 100644 index 0000000..a5337ed --- /dev/null +++ b/deploy/helm/movienight/templates/backend/deployment.yaml @@ -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 }} diff --git a/deploy/helm/movienight/templates/backend/service.yaml b/deploy/helm/movienight/templates/backend/service.yaml new file mode 100644 index 0000000..4e3b62e --- /dev/null +++ b/deploy/helm/movienight/templates/backend/service.yaml @@ -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 }} diff --git a/deploy/helm/movienight/templates/gateway/gateway.yaml b/deploy/helm/movienight/templates/gateway/gateway.yaml new file mode 100644 index 0000000..f2a2f4c --- /dev/null +++ b/deploy/helm/movienight/templates/gateway/gateway.yaml @@ -0,0 +1,50 @@ +{{- if .Values.gateway.enabled }} +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: {{ include "movienight.gatewayName" . }} + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "gateway") | nindent 4 }} + {{- with .Values.gateway.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- if or .Values.global.annotations .Values.gateway.annotations }} + annotations: + {{- with .Values.global.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.gateway.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +spec: + gatewayClassName: {{ required "gateway.className is required when gateway.enabled=true" .Values.gateway.className | quote }} + listeners: + {{- if .Values.gateway.http.enabled }} + - name: http + protocol: HTTP + port: {{ .Values.gateway.http.port }} + {{- if .Values.gateway.listenerHostname }} + hostname: {{ .Values.gateway.listenerHostname | quote }} + {{- end }} + allowedRoutes: + namespaces: + from: Same + {{- end }} + {{- if .Values.gateway.https.enabled }} + - name: https + protocol: HTTPS + port: {{ .Values.gateway.https.port }} + {{- if .Values.gateway.listenerHostname }} + hostname: {{ .Values.gateway.listenerHostname | quote }} + {{- end }} + tls: + mode: Terminate + certificateRefs: + - kind: Secret + name: {{ required "gateway.https.secretName is required when gateway.https.enabled=true" .Values.gateway.https.secretName }} + allowedRoutes: + namespaces: + from: Same + {{- end }} +{{- end }} diff --git a/deploy/helm/movienight/templates/gateway/httproute.yaml b/deploy/helm/movienight/templates/gateway/httproute.yaml new file mode 100644 index 0000000..5449768 --- /dev/null +++ b/deploy/helm/movienight/templates/gateway/httproute.yaml @@ -0,0 +1,29 @@ +{{- if and .Values.routes.enabled .Values.gateway.enabled }} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "movienight.fullname" . }} + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "route") | nindent 4 }} + {{- with .Values.global.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + - name: {{ include "movienight.gatewayName" . }} + {{- if .Values.gateway.hostnames }} + hostnames: + {{- toYaml .Values.gateway.hostnames | nindent 4 }} + {{- end }} + rules: + {{- if and .Values.routes.backend.enabled .Values.backend.enabled }} + - matches: + - path: + type: PathPrefix + value: {{ .Values.routes.backend.pathPrefix | quote }} + backendRefs: + - name: {{ include "movienight.fullname" . }}-backend + port: {{ .Values.backend.service.port }} + {{- end }} +{{- end }} diff --git a/deploy/helm/movienight/templates/observability/grafana-dashboard-business.yaml b/deploy/helm/movienight/templates/observability/grafana-dashboard-business.yaml new file mode 100644 index 0000000..a70fab4 --- /dev/null +++ b/deploy/helm/movienight/templates/observability/grafana-dashboard-business.yaml @@ -0,0 +1,1092 @@ +{{- if and .Values.observability.enabled .Values.observability.grafana.dashboards.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "movienight.fullname" . }}-grafana-business-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-business.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": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "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": "sum(rate({__name__=~\"business_recommendation_requests_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "recommendations", + "range": true, + "refId": "A" + } + ], + "title": "Recommendation Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "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({__name__=~\"business_ratings_submitted_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "ratings", + "range": true, + "refId": "A" + } + ], + "title": "Rating Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "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({__name__=~\"business_library_events_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "library events", + "range": true, + "refId": "A" + } + ], + "title": "Library Event 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": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "ops" + }, + "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({__name__=~\"business_films_blocked_total(_total)?|business_jellyfin_backend_write_failures_total(_total)?|business_jellyfin_sync_failures_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "failures", + "range": true, + "refId": "A" + } + ], + "title": "Business Failure Rate", + "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": "ops" + }, + "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(rate({__name__=~\"business_recommendation_requests_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "recommendations", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_ratings_submitted_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "ratings", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_library_events_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "library", + "range": true, + "refId": "C" + } + ], + "title": "Core Business Throughput", + "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": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "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": "sum(rate({__name__=~\"business_films_created_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "created", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_films_edited_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "edited", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_films_deleted_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "deleted", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_films_blocked_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "blocked", + "range": true, + "refId": "D" + } + ], + "title": "Film Mutations", + "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": 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({__name__=~\"business_jellyfin_sync_runs_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "sync runs", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_jellyfin_synced_users_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "synced users", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_jellyfin_synced_items_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "synced items", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_jellyfin_skipped_users_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "skipped users", + "range": true, + "refId": "D" + } + ], + "title": "Jellyfin Sync Activity", + "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(rate({__name__=~\"business_jellyfin_sync_failures_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "sync failures", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_jellyfin_backend_write_failures_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "write failures", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(business_jellyfin_unmapped_users{job=~\"$job\"})", + "instant": false, + "legendFormat": "unmapped users", + "range": true, + "refId": "C" + } + ], + "title": "Jellyfin Problems", + "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": 0, + "y": 20 + }, + "id": 9, + "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) (rate(business_jellyfin_sync_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])))", + "instant": false, + "legendFormat": "p95", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(business_jellyfin_sync_duration_seconds_sum{job=~\"$job\"}[$__rate_interval])) / clamp_min(sum(rate(business_jellyfin_sync_duration_seconds_count{job=~\"$job\"}[$__rate_interval])), 0.001)", + "instant": false, + "legendFormat": "mean", + "range": true, + "refId": "B" + } + ], + "title": "Jellyfin Sync Duration", + "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": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 20 + }, + "id": 10, + "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 (eventType) (rate({__name__=~\"recommendation_weights_updated_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "range": true, + "refId": "A" + } + ], + "title": "Recommendation Weight Updates", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "30s", + "schemaVersion": 42, + "tags": [ + "movienight", + "business" + ], + "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(up, job)", + "hide": 0, + "includeAll": false, + "label": "Job", + "multi": false, + "name": "job", + "options": [], + "query": { + "query": "label_values(up, job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "/movienight-backend/", + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "MovieNight Business", + "uid": "movienight-business", + "version": 1, + "weekStart": "" + } +{{- end }} diff --git a/deploy/helm/movienight/templates/observability/grafana-dashboard-red.yaml b/deploy/helm/movienight/templates/observability/grafana-dashboard-red.yaml new file mode 100644 index 0000000..8ba66fc --- /dev/null +++ b/deploy/helm/movienight/templates/observability/grafana-dashboard-red.yaml @@ -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 }} diff --git a/deploy/helm/movienight/templates/observability/grafana-datasource.yaml b/deploy/helm/movienight/templates/observability/grafana-datasource.yaml new file mode 100644 index 0000000..2ebc49c --- /dev/null +++ b/deploy/helm/movienight/templates/observability/grafana-datasource.yaml @@ -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 }} diff --git a/deploy/helm/movienight/templates/postgres/cluster.yaml b/deploy/helm/movienight/templates/postgres/cluster.yaml new file mode 100644 index 0000000..57212c1 --- /dev/null +++ b/deploy/helm/movienight/templates/postgres/cluster.yaml @@ -0,0 +1,36 @@ +{{- if .Values.postgres.cluster.enabled }} +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: {{ include "movienight.postgresClusterName" . }} + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "postgres") | nindent 4 }} + {{- with .Values.postgres.cluster.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- if or .Values.global.annotations .Values.postgres.cluster.annotations }} + annotations: + {{- with .Values.global.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.postgres.cluster.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +spec: + instances: {{ .Values.postgres.cluster.instances }} + storage: + size: {{ .Values.postgres.cluster.storage.size | quote }} + {{- if .Values.postgres.cluster.storage.storageClass }} + storageClass: {{ .Values.postgres.cluster.storage.storageClass | quote }} + {{- end }} + bootstrap: + initdb: + database: {{ .Values.postgres.cluster.database | quote }} + owner: {{ .Values.postgres.cluster.owner | quote }} + secret: + name: {{ required "postgres.cluster.bootstrapSecretName is required when postgres.cluster.enabled=true" .Values.postgres.cluster.bootstrapSecretName }} + {{- with .Values.postgres.cluster.extraSpec }} + {{- toYaml . | nindent 2 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/movienight/templates/rbac/serviceaccount.yaml b/deploy/helm/movienight/templates/rbac/serviceaccount.yaml new file mode 100644 index 0000000..9930827 --- /dev/null +++ b/deploy/helm/movienight/templates/rbac/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "movienight.serviceAccountName" . }} + labels: + {{- include "movienight.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/movienight/values.schema.json b/deploy/helm/movienight/values.schema.json new file mode 100644 index 0000000..61677a9 --- /dev/null +++ b/deploy/helm/movienight/values.schema.json @@ -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 + } + } +} diff --git a/deploy/helm/movienight/values.yaml b/deploy/helm/movienight/values.yaml new file mode 100644 index 0000000..9d26eac --- /dev/null +++ b/deploy/helm/movienight/values.yaml @@ -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 diff --git a/deploy/manifests/backend-secret.yaml b/deploy/manifests/backend-secret.yaml new file mode 100644 index 0000000..7c92aa2 --- /dev/null +++ b/deploy/manifests/backend-secret.yaml @@ -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 diff --git a/deploy/manifests/bootstrap-secret.yaml b/deploy/manifests/bootstrap-secret.yaml new file mode 100644 index 0000000..34d42b9 --- /dev/null +++ b/deploy/manifests/bootstrap-secret.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: movienight-cnpg-bootstrap + namespace: movienight +spec: + secretStoreRef: + name: infisical + kind: ClusterSecretStore + + target: + name: movienight-cnpg-bootstrap + creationPolicy: Owner + template: + engineVersion: v2 + type: kubernetes.io/basic-auth + data: + username: "{{ .dbUsername }}" + password: "{{ .dbPassword }}" + + data: + - secretKey: dbUsername + remoteRef: + key: /movienight/MOVIENIGHT_DB_USERNAME + - secretKey: dbPassword + remoteRef: + key: /movienight/MOVIENIGHT_DB_PASSWORD + + refreshInterval: 1h diff --git a/deploy/manifests/cluster-secret-store.yaml b/deploy/manifests/cluster-secret-store.yaml new file mode 100644 index 0000000..177cf89 --- /dev/null +++ b/deploy/manifests/cluster-secret-store.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ClusterSecretStore +metadata: + name: infisical +spec: + provider: + infisical: + hostAPI: https://vault.itqdev.xyz + auth: + universalAuthCredentials: + clientId: + name: infisical-secret + key: clientId + namespace: external-secrets + clientSecret: + name: infisical-secret + key: clientSecret + namespace: external-secrets + secretsScope: + projectSlug: default-c-nay + environmentSlug: prod + secretsPath: / diff --git a/deploy/manifests/ns.yaml b/deploy/manifests/ns.yaml new file mode 100644 index 0000000..b395506 --- /dev/null +++ b/deploy/manifests/ns.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: jellyfin + +--- +apiVersion: v1 +kind: Namespace +metadata: + name: movienight diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e91484..8dbb373 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,10 +11,13 @@ spring-grpc = "1.0.1" protoc = "3.25.1" grpc-java = "1.60.0" springdoc = "2.8.6" +mockk = "1.13.13" [libraries] +spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" } spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" } +spring-boot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop" } spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" } spring-boot-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache" } spring-boot-starter-data-jdbc = { module = "org.springframework.boot:spring-boot-starter-data-jdbc" } @@ -33,6 +36,7 @@ flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" } kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } sentry-bom = { module = "io.sentry:sentry-bom", version.ref = "sentry" } sentry-spring-boot-starter = { module = "io.sentry:sentry-spring-boot-starter-jakarta" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/guideline.md b/guideline.md new file mode 100644 index 0000000..963c094 --- /dev/null +++ b/guideline.md @@ -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. + \ No newline at end of file diff --git a/plugins/jellyfin/.gitignore b/plugins/jellyfin/.gitignore new file mode 100644 index 0000000..5967294 --- /dev/null +++ b/plugins/jellyfin/.gitignore @@ -0,0 +1,2 @@ +**/bin/ +**/obj/ diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..2e97960 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.MovieNight.Configuration; + +/// +/// MovieNight plugin settings persisted by Jellyfin. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// Gets or sets a value indicating whether integration calls are enabled. + /// + public bool Enabled { get; set; } + + /// + /// Gets or sets the MovieNight backend base URL. + /// + public string BackendBaseUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the backend plugin token. + /// + public string ApiToken { get; set; } = string.Empty; + + /// + /// Gets or sets the periodic sync interval in minutes. + /// + public int SyncIntervalMinutes { get; set; } = 30; + + /// + /// Gets or sets a value indicating whether playback stop events are pushed to MovieNight. + /// + public bool EnablePlaybackEvents { get; set; } = true; + + /// + /// Gets or sets a value indicating whether periodic backend sync is enabled. + /// + public bool EnablePeriodicSync { get; set; } = true; + + /// + /// Gets or sets enabled Jellyfin library ids. Empty means all libraries. + /// + public List EnabledLibraryIds { get; set; } = new(); + + /// + /// Gets or sets the path where .strm files will be created. + /// + public string StrmOutputPath { get; set; } = string.Empty; +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js new file mode 100644 index 0000000..7e38881 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js @@ -0,0 +1,94 @@ +const movieNightConfigPage = { + pluginId: "42c72919-d6ff-4f62-bb8c-0fac39efafdb", + + loadConfiguration(view) { + Dashboard.showLoadingMsg(); + + return ApiClient.getPluginConfiguration(this.pluginId) + .then((config) => { + view.querySelector("#BackendBaseUrl").value = + config.BackendBaseUrl || ""; + view.querySelector("#ApiToken").value = config.ApiToken || ""; + view.querySelector("#SyncIntervalMinutes").value = + config.SyncIntervalMinutes || 30; + view.querySelector("#StrmOutputPath").value = + config.StrmOutputPath || ""; + view.querySelector("#Enabled").checked = config.Enabled || false; + view.querySelector("#EnablePeriodicSync").checked = + config.EnablePeriodicSync !== false; + view.querySelector("#EnablePlaybackEvents").checked = + config.EnablePlaybackEvents !== false; + + const uiScriptUrl = ApiClient.getUrl("web/ConfigurationPage", { + name: "MovieNight.ui.js", + }); + view.querySelector("#UIScriptUrl").innerText = uiScriptUrl; + }) + .finally(() => { + Dashboard.hideLoadingMsg(); + }); + }, + + saveConfiguration(view) { + const form = view.querySelector("#MovieNightConfigForm"); + Dashboard.showLoadingMsg(); + + return ApiClient.getPluginConfiguration(this.pluginId) + .then((config) => { + config.BackendBaseUrl = form.querySelector("#BackendBaseUrl").value; + config.ApiToken = form.querySelector("#ApiToken").value; + config.SyncIntervalMinutes = parseInt( + form.querySelector("#SyncIntervalMinutes").value || "30", + 10, + ); + config.StrmOutputPath = form.querySelector("#StrmOutputPath").value; + config.Enabled = form.querySelector("#Enabled").checked; + config.EnablePeriodicSync = + form.querySelector("#EnablePeriodicSync").checked; + config.EnablePlaybackEvents = + form.querySelector("#EnablePlaybackEvents").checked; + + return ApiClient.updatePluginConfiguration(this.pluginId, config); + }) + .then((result) => { + Dashboard.processPluginConfigurationUpdateResult(result); + }) + .finally(() => { + Dashboard.hideLoadingMsg(); + }); + }, + + testConnection() { + Dashboard.showLoadingMsg(); + + return ApiClient.ajax({ + type: "POST", + url: ApiClient.getUrl("MovieNight/TestConnection"), + }) + .then((result) => { + Dashboard.alert((result && result.message) || "OK"); + }) + .catch(() => { + Dashboard.alert("MovieNight connection test failed"); + }) + .finally(() => { + Dashboard.hideLoadingMsg(); + }); + }, +}; + +export default function (view) { + movieNightConfigPage.loadConfiguration(view); + + view + .querySelector("#MovieNightConfigForm") + .addEventListener("submit", (event) => { + event.preventDefault(); + movieNightConfigPage.saveConfiguration(view); + }); + + view.querySelector("#TestConnection").addEventListener("click", (event) => { + event.preventDefault(); + movieNightConfigPage.testConnection(); + }); +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html new file mode 100644 index 0000000..adb20db --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html @@ -0,0 +1,74 @@ + + + + MovieNight + + +
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
Directory where .strm files will be created for new films.
+
+ + + + + + + +
+ +
+ +
+ +
+ +
+

UI Integration

+

To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin Custom JavaScript setting (Dashboard > General):

+ +
+
+
+
+
+ + + diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js new file mode 100644 index 0000000..156eb1c --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js @@ -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 + ? `${text}` + : `${text}`; + btn.onclick = onClick; + return btn; + } + + function createIconButton(icon, title, className, onClick) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.is = 'emby-button'; + btn.className = `button-flat detailButton emby-button movieNightDetailButton ${className}`; + btn.title = title; + btn.innerHTML = ` +
+ +
+ `; + 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 = ` +
+
+

MovieNight

+ +
+
+
+ `; + 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 = ` +

+ + ${title} +

+
+ + `; + return dialog; + } + + async function showRatingDialog(itemId) { + const overlay = createOverlay(); + const dialog = createDialogBase('Rate on MovieNight'); + const content = dialog.querySelector('.dialog-content'); + + content.innerHTML = `
`; + const grid = content.querySelector('.rating-grid'); + + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + for (let i = 1; i <= 10; i++) { + const btn = document.createElement('button'); + btn.type = 'button'; btn.is = 'emby-button'; + btn.className = 'emby-button raised'; + btn.innerText = i; + btn.style.padding = '0.8em 0'; + btn.style.textAlign = 'center'; + btn.style.display = 'flex'; + btn.style.alignItems = 'center'; + btn.style.justifyContent = 'center'; + btn.style.fontSize = '1.2em'; + btn.onclick = async () => { cleanup(); await submitRating(itemId, i); }; + grid.appendChild(btn); + } + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + } + + async function showAddMovieDialog() { + const overlay = createOverlay(); + const dialog = createDialogBase('Add Movie'); + const content = dialog.querySelector('.dialog-content'); + const footer = dialog.querySelector('.dialog-footer'); + + content.innerHTML = ` +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ `; + + const btnAdd = document.createElement('button'); + btnAdd.className = 'emby-button raised button-submit'; + btnAdd.style.flex = '2'; + btnAdd.style.backgroundColor = '#0064d2'; + btnAdd.innerHTML = 'Add Film'; + footer.insertBefore(btnAdd, footer.firstChild); + + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + btnAdd.onclick = async () => { + const title = dialog.querySelector('.txtTitle').value; + const year = dialog.querySelector('.txtYear').value; + const imdbId = dialog.querySelector('.txtImdb').value; + const url = dialog.querySelector('.txtUrl').value; + if (!title) return; + cleanup(); + await addMovie(title, url, year, imdbId); + }; + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + dialog.querySelector('.txtTitle').focus(); + } + + async function showOnboardingDialog() { + const overlay = createOverlay(); + const dialog = createDialogBase('Welcome to MovieNight!'); + dialog.style.minWidth = '450px'; + const content = dialog.querySelector('.dialog-content'); + const footer = dialog.querySelector('.dialog-footer'); + + content.innerHTML = ` +

Pick your preferences to get better recommendations.

+
+ +
+
+
+ +
+
+
+ +
+
+ `; + + const genres = ["Action", "Comedy", "Drama", "Sci-Fi", "Horror", "Thriller", "Animation", "Documentary"]; + const eras = ["1980s", "1990s", "2000s", "2010s", "2020s"]; + const types = ["FILM", "SERIES"]; + + const selections = { genres: new Set(), eras: new Set(), types: new Set() }; + + const createChip = (text, container, type) => { + const chip = document.createElement('div'); + chip.innerText = text; + chip.style.cssText = 'padding:0.4em 1em; border-radius:2em; border:1px solid #444; cursor:pointer; font-size:0.9em; transition:all 0.2s;'; + chip.onclick = () => { + if (selections[type].has(text)) { + selections[type].delete(text); + chip.style.backgroundColor = 'transparent'; + chip.style.borderColor = '#444'; + } else { + selections[type].add(text); + chip.style.backgroundColor = '#0064d2'; + chip.style.borderColor = '#0064d2'; + } + }; + container.appendChild(chip); + }; + + genres.forEach(g => createChip(g, content.querySelector('.genre-chips'), 'genres')); + eras.forEach(e => createChip(e, content.querySelector('.era-chips'), 'eras')); + types.forEach(t => createChip(t, content.querySelector('.type-chips'), 'types')); + + const btnSave = document.createElement('button'); + btnSave.className = 'emby-button raised button-submit'; + btnSave.style.flex = '2'; + btnSave.style.backgroundColor = '#0064d2'; + btnSave.innerHTML = 'Save & Start'; + footer.insertBefore(btnSave, footer.firstChild); + + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + btnSave.onclick = async () => { + const payload = { + weightedGenres: Object.fromEntries([...selections.genres].map(g => [g, 5])), + eras: [...selections.eras], + contentTypes: [...selections.types] + }; + cleanup(); + await completeOnboarding(payload); + }; + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + } + + async function checkOnboarding() { + if (window.movieNightOnboardingChecked) return; + + 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(); +})(); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs new file mode 100644 index 0000000..7d94e7c --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs @@ -0,0 +1,258 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.MovieNight.Services; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Plugin.MovieNight.Controllers; + +/// +/// Admin endpoints for the MovieNight plugin. +/// +[ApiController] +[Route("MovieNight")] +public class MovieNightController : ControllerBase +{ + private readonly MovieNightBackendClient _backendClient; + private readonly MovieNightSyncService _syncService; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightController( + MovieNightBackendClient backendClient, + MovieNightSyncService syncService) + { + _backendClient = backendClient; + _syncService = syncService; + } + + /// + /// Ping endpoint for connectivity checks. + /// + [HttpGet("Ping")] + public ActionResult Ping() => Ok("Pong"); + + /// + /// Returns plugin status. + /// + /// Status response. + [HttpGet("Status")] + [Authorize] + public ActionResult GetStatus() + { + var configuration = Plugin.Instance?.Configuration; + return new MovieNightPluginStatus( + Enabled: configuration?.Enabled ?? false, + BackendBaseUrl: configuration?.BackendBaseUrl ?? string.Empty, + PeriodicSyncEnabled: configuration?.EnablePeriodicSync ?? false, + PlaybackEventsEnabled: configuration?.EnablePlaybackEvents ?? false, + SyncIntervalMinutes: configuration?.SyncIntervalMinutes ?? 30); + } + + /// + /// Tests backend connectivity. + /// + /// Cancellation token. + /// Connection result. + [HttpPost("TestConnection")] + [Authorize] + public async Task> TestConnection(CancellationToken cancellationToken) + { + return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Triggers backend sync. + /// + /// Cancellation token. + /// Backend response. + [HttpPost("Sync")] + [Authorize] + public async Task> Sync(CancellationToken cancellationToken) + { + await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false); + return Ok("Sync triggered"); + } + + /// + /// Gets backend sync state. + /// + /// Cancellation token. + /// Backend response. + [HttpGet("SyncState")] + [Authorize] + public async Task SyncState(CancellationToken cancellationToken) + { + var body = await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false); + return Content(body, "application/json"); + } + + /// + /// Gets recommendations for the current user. + /// + [HttpGet("Users/{userId}/Recommendations")] + [Authorize] + public async Task GetRecommendations( + [FromRoute] string userId, + [FromQuery] string? contentType, + [FromQuery] string? mood, + [FromQuery] int limit = 10, + CancellationToken cancellationToken = default) + { + var body = await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false); + return Content(body, "application/json"); + } + + /// + /// Posts a rating for a film. + /// + [HttpPost("Users/{userId}/Ratings/Films/{filmId}")] + [Authorize] + public async Task PostRating( + [FromRoute] string userId, + [FromRoute] string filmId, + [FromBody] RatingRequest request, + CancellationToken cancellationToken) + { + await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false); + return Ok(); + } + + /// + /// Marks a film as viewed. + /// + [HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")] + [Authorize] + public async Task MarkViewed( + [FromRoute] string userId, + [FromRoute] string filmId, + [FromBody] ViewedRequest request, + CancellationToken cancellationToken) + { + await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false); + return Ok(); + } + + /// + /// Gets user preferences. + /// + [HttpGet("Users/{userId}/Preferences")] + [Authorize] + public async Task GetPreferences( + [FromRoute] string userId, + CancellationToken cancellationToken) + { + var body = await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false); + return body is null ? NotFound() : Content(body, "application/json"); + } + + /// + /// Completes onboarding for a user. + /// + [HttpPost("Users/{userId}/Onboarding")] + [Authorize] + public async Task CompleteOnboarding( + [FromRoute] string userId, + [FromBody] object payload, + CancellationToken cancellationToken) + { + await _backendClient.CompleteOnboardingAsync(userId, payload, cancellationToken).ConfigureAwait(false); + return Ok(); + } + + /// + /// Creates a new film by generating a .strm file in a folder-per-movie structure. + /// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm + /// + [HttpPost("Films")] + [Authorize] + public async Task CreateFilm([FromBody] CreateFilmRequest request) + { + var config = Plugin.Instance?.Configuration; + if (config == null || string.IsNullOrWhiteSpace(config.StrmOutputPath)) + { + return BadRequest("STRM output path is not configured."); + } + + if (string.IsNullOrWhiteSpace(request.Title)) + { + return BadRequest("Movie title is required."); + } + + try + { + // Construct name: "Movie Name (Year) [imdbid-ttXXXXXXX]" + var folderName = request.Title.Trim(); + if (request.Year.HasValue) + { + folderName += $" ({request.Year})"; + } + if (!string.IsNullOrWhiteSpace(request.ImdbId)) + { + var ttId = request.ImdbId.Trim().ToLowerInvariant(); + if (!ttId.StartsWith("tt")) ttId = "tt" + ttId; + folderName += $" [imdbid-{ttId}]"; + } + + // Sanitize for file system + var invalidChars = Path.GetInvalidFileNameChars(); + var safeFolderName = new string(folderName.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray()); + + var movieDirectory = Path.Combine(config.StrmOutputPath, safeFolderName); + if (!Directory.Exists(movieDirectory)) + { + Directory.CreateDirectory(movieDirectory); + } + + var filePath = Path.Combine(movieDirectory, $"{safeFolderName}.strm"); + + var strmContent = string.IsNullOrWhiteSpace(request.Url) + ? "http://placeholder.url/upload_me_later" + : request.Url.Trim(); + + await System.IO.File.WriteAllTextAsync(filePath, strmContent).ConfigureAwait(false); + + return Ok(new { FilePath = filePath, FolderName = safeFolderName }); + } + catch (Exception ex) + { + return StatusCode(500, $"Failed to create film: {ex.Message}"); + } + } +} + +/// +/// Create film request. +/// +public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId); + +/// +/// Rating request. +/// +public sealed record RatingRequest(int Score, string? Note); + +/// +/// Viewed request. +/// +public sealed record ViewedRequest(DateTimeOffset? WatchedAt); + +/// +/// MovieNight plugin status response. +/// +/// Whether integration is enabled. +/// Backend base URL. +/// Whether periodic sync is enabled. +/// Whether playback events are enabled. +/// Sync interval in minutes. +public sealed record MovieNightPluginStatus( + bool Enabled, + string BackendBaseUrl, + bool PeriodicSyncEnabled, + bool PlaybackEventsEnabled, + int SyncIntervalMinutes); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj new file mode 100644 index 0000000..a8e6ec0 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj @@ -0,0 +1,36 @@ + + + + net9.0 + Jellyfin.Plugin.MovieNight + Jellyfin.Plugin.MovieNight + 1.0.0.1 + GPL-3.0-or-later + enable + true + false + + + + + + runtime + + + runtime + + + runtime + + + + + + + + + + + + + diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs new file mode 100644 index 0000000..73a5718 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Jellyfin.Plugin.MovieNight.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace Jellyfin.Plugin.MovieNight; + +/// +/// MovieNight Jellyfin plugin. +/// +public class Plugin : BasePlugin, IHasWebPages +{ + /// + /// Initializes a new instance of the class. + /// + /// Application paths. + /// XML serializer. + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + public override string Name => "MovieNight"; + + /// + public override Guid Id => Guid.Parse("42c72919-d6ff-4f62-bb8c-0fac39efafdb"); + + /// + public override string Description => "Bridges Jellyfin playback and sync signals to the MovieNight backend."; + + /// + /// Gets the current plugin instance. + /// + public static Plugin? Instance { get; private set; } + + /// + public IEnumerable GetPages() + { + return + [ + new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.configPage.html", + GetType().Namespace) + }, + new PluginPageInfo + { + Name = Name + ".js", + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.config.js", + GetType().Namespace) + }, + new PluginPageInfo + { + Name = Name + ".ui.js", + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.ui.js", + GetType().Namespace) + } + ]; + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs new file mode 100644 index 0000000..b4fc366 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs @@ -0,0 +1,21 @@ +using Jellyfin.Plugin.MovieNight.Services; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.Plugin.MovieNight; + +/// +/// Registers MovieNight services with Jellyfin. +/// +public class PluginServiceRegistrator : IPluginServiceRegistrator +{ + /// + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddHostedService(); + serviceCollection.AddHostedService(); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs new file mode 100644 index 0000000..90381b9 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs @@ -0,0 +1,323 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Thin HTTP client for the MovieNight backend. +/// +public class MovieNightBackendClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + /// + /// Initializes a new instance of the class. + /// + /// Logger. + public MovieNightBackendClient(ILogger logger) + { + _logger = logger; + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(20) + }; + } + + /// + /// Calls backend health. + /// + /// Cancellation token. + /// Connection result. + public async Task TestConnectionAsync(CancellationToken cancellationToken) + { + var payload = new MovieNightEventPayload( + EventId: $"plugin-test:{Guid.NewGuid():N}", + EventType: "plugin.test", + OccurredAt: DateTimeOffset.UtcNow, + JellyfinUserId: "movienight-plugin-test-user", + ItemId: "movienight-plugin-test-item", + PayloadVersion: 1, + Payload: new Dictionary + { + ["source"] = "config-test" + }); + var request = CreateEventRequest(payload); + if (request is null) + { + return MovieNightConnectionResult.Failed("Plugin is not configured."); + } + + try + { + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + return response.IsSuccessStatusCode + ? MovieNightConnectionResult.Ok() + : MovieNightConnectionResult.Failed($"Backend event endpoint returned {(int)response.StatusCode}."); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + _logger.LogWarning(ex, "MovieNight connection test failed"); + return MovieNightConnectionResult.Failed(ex.Message); + } + } + + /// + /// Pushes library sync data to the backend. + /// + /// Sync payload. + /// Cancellation token. + /// Backend response body. + public async Task SyncAsync(object payload, CancellationToken cancellationToken) + { + var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/sync"); + if (request is null) + { + return "Plugin is not configured."; + } + + request.Content = JsonContent.Create(payload, options: JsonOptions); + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return body; + } + + /// + /// Gets recommendations for a user. + /// + public async Task GetRecommendationsAsync(string userId, string? contentType, string? mood, int limit, CancellationToken cancellationToken) + { + userId = NormalizeJellyfinId(userId); + var query = $"?limit={limit}"; + if (!string.IsNullOrEmpty(contentType)) query += $"&contentType={Uri.EscapeDataString(contentType)}"; + if (!string.IsNullOrEmpty(mood)) query += $"&mood={Uri.EscapeDataString(mood)}"; + + var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/recommendations{query}"); + if (request is null) return "Plugin is not configured."; + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return body; + } + + /// + /// Posts a rating for a film. + /// + public async Task PostRatingAsync(string userId, string filmId, int score, string? note, CancellationToken cancellationToken) + { + userId = NormalizeJellyfinId(userId); + filmId = NormalizeJellyfinId(filmId); + var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/ratings/items/{filmId}"); + if (request is null) return; + + request.Content = JsonContent.Create(new { score, note }, options: JsonOptions); + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + /// + /// Gets ratings for a user. + /// + public async Task GetRatingsAsync(string userId, CancellationToken cancellationToken) + { + userId = NormalizeJellyfinId(userId); + var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/ratings"); + if (request is null) return "[]"; + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return body; + } + + /// + /// Marks a film as viewed. + /// + public async Task MarkViewedAsync(string userId, string filmId, DateTimeOffset? watchedAt, CancellationToken cancellationToken) + { + userId = NormalizeJellyfinId(userId); + filmId = NormalizeJellyfinId(filmId); + var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/library/items/{filmId}/viewed"); + if (request is null) return; + + request.Content = JsonContent.Create(new { watchedAt }, options: JsonOptions); + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + /// + /// Reads backend sync state. + /// + /// Cancellation token. + /// Backend response body. + public async Task GetSyncStateAsync(CancellationToken cancellationToken) + { + var request = CreateRequest(HttpMethod.Get, "/api/integrations/jellyfin/sync-state"); + if (request is null) + { + return "Plugin is not configured."; + } + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return body; + } + + /// + /// Gets user preferences. + /// + public async Task GetPreferencesAsync(string userId, CancellationToken cancellationToken) + { + userId = NormalizeJellyfinId(userId); + var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/preferences"); + if (request is null) return null; + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return body; + } + + /// + /// Completes onboarding for a user. + /// + public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken) + { + userId = NormalizeJellyfinId(userId); + var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/recommendation-onboarding"); + if (request is null) return; + + request.Content = JsonContent.Create(payload, options: JsonOptions); + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + /// + /// Pushes an event payload to the backend event endpoint. + /// + /// Event payload. + /// Cancellation token. + /// A task. + public async Task PushEventAsync(MovieNightEventPayload payload, CancellationToken cancellationToken) + { + for (var attempt = 1; attempt <= 3; attempt++) + { + var request = CreateEventRequest(payload); + if (request is null) + { + return; + } + + try + { + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return; + } + + if ((int)response.StatusCode == 401) + { + _logger.LogWarning("MovieNight event push was rejected with 401 Unauthorized"); + return; + } + + _logger.LogDebug("MovieNight event push returned status {StatusCode}", response.StatusCode); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + _logger.LogDebug(ex, "MovieNight event push attempt {Attempt} failed", attempt); + } + + if (attempt < 3) + { + await Task.Delay(TimeSpan.FromSeconds(attempt * 2), cancellationToken).ConfigureAwait(false); + } + } + } + + private static HttpRequestMessage? CreateEventRequest(MovieNightEventPayload payload) + { + var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/events"); + if (request is null) + { + return null; + } + + request.Content = JsonContent.Create(payload, options: JsonOptions); + return request; + } + + private static string? GetBaseUrl() + { + var value = Plugin.Instance?.Configuration.BackendBaseUrl?.Trim(); + return string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/'); + } + + private static string NormalizeJellyfinId(string value) + { + return Guid.TryParse(value, out var guid) ? guid.ToString("N") : value; + } + + private static bool IsEnabled() + { + var configuration = Plugin.Instance?.Configuration; + return configuration is { Enabled: true } && !string.IsNullOrWhiteSpace(configuration.BackendBaseUrl); + } + + private static HttpRequestMessage? CreateRequest(HttpMethod method, string path) + { + if (!IsEnabled()) + { + return null; + } + + var baseUrl = GetBaseUrl(); + if (baseUrl is null) + { + return null; + } + + var request = new HttpRequestMessage(method, new Uri(baseUrl + path)); + var token = Plugin.Instance?.Configuration.ApiToken; + if (!string.IsNullOrWhiteSpace(token)) + { + request.Headers.Add("X-MovieNight-Plugin-Token", token); + } + + return request; + } +} + +/// +/// Backend connection result. +/// +/// Whether the call succeeded. +/// Result message. +public sealed record MovieNightConnectionResult(bool Success, string Message) +{ + /// + /// Creates a successful result. + /// + /// Connection result. + public static MovieNightConnectionResult Ok() => new(true, "OK"); + + /// + /// Creates a failed result. + /// + /// Failure message. + /// Connection result. + public static MovieNightConnectionResult Failed(string message) => new(false, message); +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs new file mode 100644 index 0000000..926199b --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Event payload sent to MovieNight. +/// +/// Idempotency key. +/// Event type. +/// Event timestamp. +/// Jellyfin user id. +/// Jellyfin item id. +/// Payload version. +/// Extra event data. +public sealed record MovieNightEventPayload( + [property: JsonPropertyName("event_id")] + string EventId, + [property: JsonPropertyName("event_type")] + string EventType, + [property: JsonPropertyName("occurred_at")] + DateTimeOffset OccurredAt, + [property: JsonPropertyName("jellyfin_user_id")] + string JellyfinUserId, + [property: JsonPropertyName("item_id")] + string ItemId, + [property: JsonPropertyName("payload_version")] + int PayloadVersion, + [property: JsonPropertyName("payload")] + IReadOnlyDictionary Payload); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs new file mode 100644 index 0000000..bd72c5e --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Jellyfin.Data.Enums; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Periodically asks MovieNight to run its current Jellyfin sync. +/// +public sealed class MovieNightPeriodicSyncService : BackgroundService +{ + private readonly MovieNightSyncService _syncService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightPeriodicSyncService( + MovieNightSyncService syncService, + ILogger logger) + { + _syncService = syncService; + _logger = logger; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var delay = GetDelay(); + try + { + await Task.Delay(delay, stoppingToken).ConfigureAwait(false); + if (!ShouldRun()) + { + continue; + } + + await _syncService.PerformSyncAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "MovieNight periodic sync failed"); + } + } + } + + private static bool ShouldRun() + { + var configuration = Plugin.Instance?.Configuration; + return configuration is { Enabled: true, EnablePeriodicSync: true }; + } + + private static TimeSpan GetDelay() + { + var minutes = Plugin.Instance?.Configuration.SyncIntervalMinutes ?? 30; + return TimeSpan.FromMinutes(Math.Clamp(minutes, 1, 1440)); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs new file mode 100644 index 0000000..89a5f84 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Subscribes to Jellyfin playback events and forwards thin payloads. +/// +public sealed class MovieNightPlaybackEventService : IHostedService +{ + private readonly ISessionManager _sessionManager; + private readonly MovieNightBackendClient _backendClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Jellyfin session manager. + /// Backend client. + /// Logger. + public MovieNightPlaybackEventService( + ISessionManager sessionManager, + MovieNightBackendClient backendClient, + ILogger logger) + { + _sessionManager = sessionManager; + _backendClient = backendClient; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _sessionManager.PlaybackStopped += OnPlaybackStopped; + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _sessionManager.PlaybackStopped -= OnPlaybackStopped; + return Task.CompletedTask; + } + + private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e) + { + if (Plugin.Instance?.Configuration is not { Enabled: true, EnablePlaybackEvents: true }) + { + return; + } + + if (!e.PlayedToCompletion) + { + return; + } + + var userId = e.Users?.FirstOrDefault()?.Id.ToString("N"); + var itemId = e.Item?.Id.ToString("N"); + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(itemId)) + { + return; + } + + var occurredAt = DateTimeOffset.UtcNow; + var eventId = string.IsNullOrWhiteSpace(e.PlaySessionId) + ? $"playback-stopped:{userId}:{itemId}:{occurredAt.ToUnixTimeMilliseconds()}" + : $"playback-stopped:{userId}:{itemId}:{e.PlaySessionId}"; + + var payload = new MovieNightEventPayload( + EventId: eventId, + EventType: "playback.stopped", + OccurredAt: occurredAt, + JellyfinUserId: userId, + ItemId: itemId, + PayloadVersion: 1, + Payload: new Dictionary + { + ["itemName"] = e.Item?.Name, + ["playSessionId"] = e.PlaySessionId, + ["positionTicks"] = e.PlaybackPositionTicks, + ["playedToCompletion"] = e.PlayedToCompletion + }); + + _ = Task.Run( + async () => + { + try + { + await _backendClient.PushEventAsync(payload, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "MovieNight playback event push failed"); + } + }); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs new file mode 100644 index 0000000..93da778 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Jellyfin.Data.Enums; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Service for synchronizing the Jellyfin library with MovieNight. +/// +public class MovieNightSyncService +{ + private readonly MovieNightBackendClient _backendClient; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightSyncService( + MovieNightBackendClient backendClient, + ILibraryManager libraryManager, + IUserManager userManager, + IUserDataManager userDataManager, + ILogger logger) + { + _backendClient = backendClient; + _libraryManager = libraryManager; + _userManager = userManager; + _userDataManager = userDataManager; + _logger = logger; + } + + /// + /// Performs a full library sync. + /// + public async Task PerformSyncAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting MovieNight library sync"); + + var config = Plugin.Instance?.Configuration; + var enabledLibraryIds = config?.EnabledLibraryIds ?? new List(); + + var query = new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Movie }, + Recursive = true + }; + + if (enabledLibraryIds.Count > 0) + { + query.AncestorIds = enabledLibraryIds.Select(Guid.Parse).ToArray(); + } + + var items = _libraryManager.GetItemList(query); + var users = _userManager.Users; + var syncUsers = users.Select(u => new + { + jellyfinUserId = u.Id.ToString("N"), + name = u.Username + }).ToList(); + var syncItems = new List(); + + foreach (var item in items) + { + if (item is not Movie movie) continue; + + var jellyfinItemId = movie.Id.ToString("N"); + var title = string.IsNullOrWhiteSpace(movie.Name) ? jellyfinItemId : movie.Name; + + var itemData = new Dictionary + { + ["jellyfinItemId"] = jellyfinItemId, + ["title"] = title, + ["originalTitle"] = movie.OriginalTitle, + ["description"] = movie.Overview, + ["year"] = movie.ProductionYear, + ["duration"] = movie.RunTimeTicks, + ["genres"] = movie.Genres, + ["posterUrl"] = $"/Items/{jellyfinItemId}/Images/Primary", + ["imdbId"] = movie.GetProviderId(MetadataProvider.Imdb), + ["tmdbId"] = movie.GetProviderId(MetadataProvider.Tmdb), + ["userStates"] = users.Select(u => { + var userData = _userDataManager.GetUserData(u, movie); + return new { + jellyfinUserId = u.Id.ToString("N"), + isViewed = userData?.Played ?? false, + playCount = userData?.PlayCount ?? 0, + lastPlayedAt = userData?.LastPlayedDate, + userRating = userData?.Rating + }; + }).ToList() + }; + + syncItems.Add(itemData); + } + + await _backendClient.SyncAsync(new { users = syncUsers, items = syncItems }, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("MovieNight library sync completed"); + } +} diff --git a/plugins/jellyfin/README.md b/plugins/jellyfin/README.md new file mode 100644 index 0000000..2c3b06a --- /dev/null +++ b/plugins/jellyfin/README.md @@ -0,0 +1,64 @@ +# MovieNight Jellyfin Plugin + +Thin Jellyfin server plugin for bridging Jellyfin playback/sync signals to the MovieNight backend. + +## Build + +```bash +cd plugins/jellyfin/Jellyfin.Plugin.MovieNight +dotnet publish -c Release +``` + +Install the published `net9.0` plugin files into the Jellyfin data directory under `plugins/MovieNight/`, then restart Jellyfin. This build targets Jellyfin `10.11.x`. + +## Backend Contract Used + +Current implemented calls: + +- `POST /api/integrations/jellyfin/sync` +- `GET /api/integrations/jellyfin/sync-state` +- `POST /api/integrations/jellyfin/events` +- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendations` +- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/ratings/items/{jellyfin_item_id}` +- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/library/items/{jellyfin_item_id}/viewed` +- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/preferences` +- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendation-onboarding` + +Configure the backend with: + +- `JELLYFIN_INTEGRATION_ENABLED=true` +- `JELLYFIN_PLUGIN_TOKEN=` +- `JELLYFIN_WEB_URL=` + +`JELLYFIN_SYNC_ENABLED=true` is still accepted as a legacy alias for `JELLYFIN_INTEGRATION_ENABLED=true`. + +Optional backend-pull sync values: + +- `JELLYFIN_BASE_URL=` +- `JELLYFIN_API_KEY=` + +The Jellyfin API key is only for backend-to-Jellyfin calls. The plugin token is a MovieNight shared secret for plugin-to-backend calls. + +Configure the plugin with: + +- Backend URL: MovieNight backend URL reachable from the Jellyfin server, for example `http://movienight-backend:8080` +- Plugin token: the exact `JELLYFIN_PLUGIN_TOKEN` value +- Enable MovieNight integration: checked +- Enable periodic backend sync: checked if the plugin should push library state on an interval +- Send playback stop events: checked if completed playback should mark films viewed in MovieNight + +Event requests use JSON with: + +- `event_id` +- `event_type` +- `occurred_at` +- `jellyfin_user_id` +- `item_id` +- `payload_version` +- `payload` + +The plugin sends `X-MovieNight-Plugin-Token` when configured. Completed Jellyfin playback stop events are sent as `playback.stopped`. Transient event push failures are retried three times with the same `event_id`. + +Sync requests push Jellyfin users, items, and per-user watched states to the backend. The backend creates MovieNight users for new Jellyfin users using their Jellyfin id as the stable mapping key, upserts films by `jellyfinItemId`, and uses the Jellyfin-facing endpoints above for UI actions so Jellyfin ids do not have to match MovieNight UUIDs. Run "Sync Library" once after installing/configuring the plugin so recommendations, rating, and viewed actions can resolve Jellyfin items. + +The config page test action posts a small `plugin.test` event to `/api/integrations/jellyfin/events` and treats `200` as success and `401` as token/config failure. diff --git a/plugins/jellyfin/build.yaml b/plugins/jellyfin/build.yaml new file mode 100644 index 0000000..3c846f3 --- /dev/null +++ b/plugins/jellyfin/build.yaml @@ -0,0 +1,14 @@ +--- +name: "MovieNight" +guid: "42c72919-d6ff-4f62-bb8c-0fac39efafdb" +version: 2 +targetAbi: "10.11.0.0" +framework: net9.0 +owner: "movienight" +overview: "Bridge Jellyfin events and sync triggers to MovieNight" +description: "Thin Jellyfin plugin for MovieNight backend integration" +category: "General" +artifacts: + - "Jellyfin.Plugin.MovieNight.dll" +changelog: |- + - Initial plugin implementation. diff --git a/scripts/generate_library.py b/scripts/generate_library.py new file mode 100644 index 0000000..1f84920 --- /dev/null +++ b/scripts/generate_library.py @@ -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() diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index 39274c8..6db897a 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -3,8 +3,10 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication +@EnableScheduling @ConfigurationPropertiesScan("com.project.movienight.config") class MovieNightApplication diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt new file mode 100644 index 0000000..2aea71c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -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 = + 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 = + @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 = + 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 = + takeIf { it.isArray }?.mapNotNull { item -> + item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() } + } + ?: emptyList() + + private fun JsonNode.peopleByType(vararg types: String): List { + 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 + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt new file mode 100644 index 0000000..012c9e3 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -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() + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt new file mode 100644 index 0000000..10a86db --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt @@ -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, + ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt new file mode 100644 index 0000000..5edd5c9 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt @@ -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, + ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt new file mode 100644 index 0000000..2a9c0c6 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -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, + ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt new file mode 100644 index 0000000..706d175 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt @@ -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 }), + ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt new file mode 100644 index 0000000..beaf6cb --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt @@ -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 = + 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 = + 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) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt deleted file mode 100644 index f5603cb..0000000 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt +++ /dev/null @@ -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 = - 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) - } -} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt new file mode 100644 index 0000000..85334d0 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt @@ -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 = + 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() +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index 2883aca..cdfc6b9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -1,6 +1,8 @@ package com.project.movienight.adapters.persistence.jdbc +import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository @@ -16,6 +18,21 @@ class FilmRepository( id = UUID.fromString(rs.getString("id")), title = rs.getString("title"), description = rs.getString("description"), + contentType = + runCatching { + ContentType.valueOf( + rs.getString("content_type"), + ) + }.getOrDefault(ContentType.FILM), + releaseYear = rs.getObject("release_year")?.let { (it as Number).toInt() }, + genres = DelimitedValueCodec.decodeList(rs.getString("genres")), + cast = DelimitedValueCodec.decodeList(rs.getString("cast_members")), + directors = DelimitedValueCodec.decodeList(rs.getString("directors")), + imdbRating = rs.getObject("imdb_rating")?.let { (it as Number).toDouble() }, + platformRating = rs.getObject("platform_rating")?.let { (it as Number).toDouble() }, + externalUrl = rs.getString("external_url"), + jellyfinItemId = rs.getString("jellyfin_item_id"), + jellyfinLibraryId = rs.getString("jellyfin_library_id"), ) } @@ -24,22 +41,67 @@ class FilmRepository( jdbc.update( """ UPDATE films - SET title = ?, description = ? + SET title = ?, + description = ?, + content_type = ?, + release_year = ?, + genres = ?, + cast_members = ?, + directors = ?, + imdb_rating = ?, + platform_rating = ?, + external_url = ?, + jellyfin_item_id = ?, + jellyfin_library_id = ? WHERE id = ? """.trimIndent(), film.title, film.description, + film.contentType.name, + film.releaseYear, + DelimitedValueCodec.encodeList(film.genres), + DelimitedValueCodec.encodeList(film.cast), + DelimitedValueCodec.encodeList(film.directors), + film.imdbRating, + film.platformRating, + film.externalUrl, + film.jellyfinItemId, + film.jellyfinLibraryId, film.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO films (id, title, description) - VALUES (?, ?, ?) + INSERT INTO films ( + id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent(), film.id, film.title, film.description, + film.contentType.name, + film.releaseYear, + DelimitedValueCodec.encodeList(film.genres), + DelimitedValueCodec.encodeList(film.cast), + DelimitedValueCodec.encodeList(film.directors), + film.imdbRating, + film.platformRating, + film.externalUrl, + film.jellyfinItemId, + film.jellyfinLibraryId, ) } return film @@ -48,20 +110,111 @@ class FilmRepository( override fun findById(id: UUID): Film? { val films = jdbc.query( - "SELECT id, title, description FROM films WHERE id = ?", + """ + SELECT id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + FROM films + WHERE id = ? + """.trimIndent(), filmRowMapper, id, ) return films.firstOrNull() } + override fun findByJellyfinItemId(jellyfinItemId: String): Film? { + val films = + jdbc.query( + """ + SELECT id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + FROM films + WHERE jellyfin_item_id = ? + """.trimIndent(), + filmRowMapper, + jellyfinItemId, + ) + return films.firstOrNull() + } + override fun findAll(): List = jdbc.query( - "SELECT id, title, description FROM films", + """ + SELECT id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + FROM films + """.trimIndent(), filmRowMapper, ) + override fun findByTitle(title: String): Film? { + val films = + jdbc.query( + """ + SELECT id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + FROM films + WHERE title = ? + ORDER BY id + LIMIT 1 + """.trimIndent(), + filmRowMapper, + title, + ) + return films.firstOrNull() + } + override fun deleteById(id: UUID) { - jdbc.update("DELETE FROM films WHERE id = ?", id) + jdbc.update( + """ + DELETE FROM films + WHERE id = ? + """.trimIndent(), + id, + ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt new file mode 100644 index 0000000..a3ec81f --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -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 + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt new file mode 100644 index 0000000..29deb18 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt @@ -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 = + 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() } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt new file mode 100644 index 0000000..ab0f0f8 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt @@ -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 = + 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() +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt new file mode 100644 index 0000000..33ec0cc --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt @@ -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() +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt new file mode 100644 index 0000000..c549925 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt @@ -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 + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index a6607e5..02949ed 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -1,6 +1,11 @@ package com.project.movienight.adapters.persistence.jdbc +import com.project.movienight.adapters.persistence.entity.UserEntity +import com.project.movienight.adapters.persistence.entity.toDomain +import com.project.movienight.adapters.persistence.entity.toEntity import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.AuthProvider import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository @@ -11,58 +16,208 @@ import java.util.UUID class UserRepository( private val jdbc: JdbcTemplate, ) : UserRepositoryPort { - private val userRowMapper = { rs: ResultSet, _: Int -> - User( + private val userEntityRowMapper = { rs: ResultSet, _: Int -> + UserEntity( id = UUID.fromString(rs.getString("id")), name = rs.getString("name"), email = rs.getString("email"), - library = null, + provider = rs.getString("provider"), + providerId = rs.getString("provider_id"), + jellyfinUserId = rs.getString("jellyfin_user_id"), + createdAt = rs.getTimestamp("created_at").toLocalDateTime(), ) } override fun save(user: User): User { + val existingUser = findById(user.id) + + val entity = + if (existingUser != null) { + user.toEntity( + provider = findProviderById(user.id), + providerId = findProviderIdById(user.id), + ) + } else { + user.toEntity() + } + val updatedRows = jdbc.update( """ UPDATE users - SET name = ?, email = ? + SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ? WHERE id = ? """.trimIndent(), - user.name, - user.email, - user.id, + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.jellyfinUserId, + entity.id, ) + if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email) - VALUES (?, ?, ?) + INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) """.trimIndent(), - user.id, - user.name, - user.email, + entity.id, + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.jellyfinUserId, + entity.createdAt, ) } return user } + override fun createOAuthUser( + user: User, + provider: AuthProvider, + providerId: String, + ): User { + val entity = user.toEntity(provider = provider, providerId = providerId) + val updatedRows = + jdbc.update( + """ + UPDATE users + SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ? + WHERE id = ? + """.trimIndent(), + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.jellyfinUserId, + entity.id, + ) + + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + entity.id, + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.jellyfinUserId, + entity.createdAt, + ) + } + + return findById(user.id) ?: user + } + + override fun linkOAuthAccount( + userId: UUID, + provider: AuthProvider, + providerId: String, + ): User { + val updatedRows = + jdbc.update( + """ + UPDATE users + SET provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + provider.name, + providerId, + userId, + ) + + if (updatedRows == 0) { + throw EntityNotFoundException(entity = "User", id = userId.toString()) + } + + return findById(userId) ?: throw EntityNotFoundException(entity = "User", id = userId.toString()) + } + override fun findById(id: UUID): User? { - val users = + val entities = jdbc.query( - "SELECT id, name, email FROM users WHERE id = ?", - userRowMapper, + "SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users WHERE id = ?", + userEntityRowMapper, id, ) - return users.firstOrNull() + return entities.firstOrNull()?.toDomain() + } + + override fun findByEmail(email: String): User? { + val entities = + jdbc.query( + """ + SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at + FROM users + WHERE email = ? + """.trimIndent(), + userEntityRowMapper, + email, + ) + return entities.firstOrNull()?.toDomain() + } + + override fun findByJellyfinUserId(jellyfinUserId: String): User? { + val entities = + jdbc.query( + """ + SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at + FROM users + WHERE jellyfin_user_id = ? + """.trimIndent(), + userEntityRowMapper, + jellyfinUserId, + ) + return entities.firstOrNull()?.toDomain() } override fun findAll(): List = - jdbc.query( - "SELECT id, name, email FROM users", - userRowMapper, - ) + jdbc + .query( + "SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) } + + override fun findByProviderAndProviderId( + provider: AuthProvider, + providerId: String, + ): User? { + val entities = + jdbc.query( + """ + SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users + WHERE provider = ? AND provider_id = ? + """.trimIndent(), + userEntityRowMapper, + provider.name, + providerId, + ) + return entities.firstOrNull()?.toDomain() + } + + private fun findProviderById(id: UUID): AuthProvider? = + jdbc + .query( + "SELECT provider FROM users WHERE id = ?", + { rs: ResultSet, _: Int -> rs.getString("provider") }, + id, + ).firstOrNull() + ?.let { AuthProvider.valueOf(it) } + + private fun findProviderIdById(id: UUID): String? = + jdbc + .query( + "SELECT provider_id FROM users WHERE id = ?", + { rs: ResultSet, _: Int -> rs.getString("provider_id") }, + id, + ).firstOrNull() } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt new file mode 100644 index 0000000..d671f71 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt @@ -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 = values.joinToString("|") { encode(it) } + + fun decodeList(value: String?): List = + value + ?.takeIf { it.isNotBlank() } + ?.split("|") + ?.map { decode(it) } + ?: emptyList() + + fun encodeWeightedMap(values: Map): String = + values.entries.joinToString("|") { entry -> "${encode(entry.key)}:${entry.value}" } + + fun decodeWeightedMap(value: String?): Map { + 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) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt new file mode 100644 index 0000000..7b90df8 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -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(), + ) + } + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt new file mode 100644 index 0000000..123f9a0 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -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, +) : 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 = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt new file mode 100644 index 0000000..1faf660 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -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") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt new file mode 100644 index 0000000..537fdc8 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -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() + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt new file mode 100644 index 0000000..dd8eb93 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -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? = null, +) : OAuth2User, + UserDetails { + fun getId(): UUID = user.id + + override fun getName(): String = user.name + + override fun getAttributes(): Map = attributes ?: emptyMap() + + override fun getAuthorities(): Collection = + 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? = null, + ): UserPrincipal = UserPrincipal(user, attributes) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt new file mode 100644 index 0000000..9b55f35 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -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, +) : 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 = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt new file mode 100644 index 0000000..dc71df9 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -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, +) : 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 = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt index 27a236d..0d9a087 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt @@ -3,29 +3,111 @@ package com.project.movienight.adapters.web import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.DomainException import com.project.movienight.domain.exception.EntityNotFoundException +import org.slf4j.LoggerFactory +import org.slf4j.MDC import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.servlet.resource.NoResourceFoundException @RestControllerAdvice class ApiExceptionHandler { - @ExceptionHandler(EntityNotFoundException::class) + private val log = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(EntityNotFoundException::class, NoResourceFoundException::class) @ResponseStatus(HttpStatus.NOT_FOUND) - fun handleNotFound(exception: EntityNotFoundException): ErrorResponse = - ErrorResponse(message = exception.message ?: "Entity not found") + fun handleNotFound(exception: Exception): ErrorResponse { + val traceId = currentTraceId() + log.warn("Resource not found: traceId='{}', message='{}'", traceId, exception.message) + + return ErrorResponse( + message = exception.message ?: "Resource not found", + traceId = traceId, + ) + } @ExceptionHandler(BlockedValueException::class) @ResponseStatus(HttpStatus.BAD_REQUEST) - fun handleBlockedValue(exception: BlockedValueException): ErrorResponse = - ErrorResponse(message = exception.message ?: "Blocked value") + fun handleBlockedValue(exception: BlockedValueException): ErrorResponse { + val traceId = currentTraceId() + log.warn("Blocked value: traceId='{}', message='{}'", traceId, exception.message) + + return ErrorResponse( + message = exception.message ?: "Blocked value", + traceId = traceId, + ) + } @ExceptionHandler(DomainException::class) @ResponseStatus(HttpStatus.BAD_REQUEST) - fun handleDomainException(exception: DomainException): ErrorResponse = - ErrorResponse(message = exception.message ?: "Domain error") + fun handleDomainException(exception: DomainException): ErrorResponse { + val traceId = currentTraceId() + log.warn("Domain error: traceId='{}', message='{}'", traceId, exception.message) + + return ErrorResponse( + message = exception.message ?: "Domain error", + traceId = traceId, + ) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + fun handleValidationException(exception: MethodArgumentNotValidException): ErrorResponse { + val traceId = currentTraceId() + val details = + exception + .bindingResult + .fieldErrors + .joinToString("; ") { error -> "${error.field}: ${error.defaultMessage}" } + .ifBlank { "Invalid request" } + log.warn("Validation error: traceId='{}', message='{}'", traceId, details) + + return ErrorResponse( + message = details, + traceId = traceId, + ) + } + + @ExceptionHandler(ResponseStatusException::class) + fun handleResponseStatusException(exception: ResponseStatusException): ResponseEntity { + val traceId = currentTraceId() + log.warn( + "HTTP error: traceId='{}', status='{}', message='{}'", + traceId, + exception.statusCode, + exception.reason, + ) + + return ResponseEntity + .status(exception.statusCode) + .body( + ErrorResponse( + message = exception.reason ?: exception.message, + traceId = traceId, + ), + ) + } + + @ExceptionHandler(Exception::class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + fun handleUnexpectedException(exception: Exception): ErrorResponse { + val traceId = currentTraceId() + log.error("Unexpected error: traceId='{}'", traceId, exception) + + return ErrorResponse( + message = "Internal server error", + traceId = traceId, + ) + } + + private fun currentTraceId(): String = MDC.get("traceId") ?: "unknown" } data class ErrorResponse( val message: String, + val traceId: String, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt b/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt new file mode 100644 index 0000000..cf8ed0b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt @@ -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) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index d4d52ef..1a35dea 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -4,17 +4,19 @@ import com.project.movienight.adapters.web.dto.request.CreateFilmRequest import com.project.movienight.adapters.web.dto.request.EditFilmRequest import com.project.movienight.adapters.web.dto.response.FilmResponse import com.project.movienight.application.ports.input.CreateFilmCommand -import com.project.movienight.application.ports.input.CreateFilmUseCase -import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand -import com.project.movienight.application.ports.input.EditFilmUseCase +import com.project.movienight.application.ports.input.FilmUseCase +import jakarta.validation.Valid import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import java.util.UUID @@ -22,20 +24,28 @@ import java.util.UUID @RestController @RequestMapping("/api/films") class FilmController( - private val createFilmUseCase: CreateFilmUseCase, - private val editFilmUseCase: EditFilmUseCase, - private val deleteFilmUseCase: DeleteFilmUseCase, + private val filmUseCase: FilmUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) fun create( - @RequestBody request: CreateFilmRequest, + @Valid @RequestBody request: CreateFilmRequest, ): FilmResponse = FilmResponse.fromDomain( - createFilmUseCase.create( + filmUseCase.create( CreateFilmCommand( title = request.title, description = request.description, + contentType = parseContentType(request.contentType), + releaseYear = request.releaseYear, + genres = request.genres, + cast = request.cast, + directors = request.directors, + imdbRating = request.imdbRating, + platformRating = request.platformRating, + externalUrl = request.externalUrl, + jellyfinItemId = request.jellyfinItemId, + jellyfinLibraryId = request.jellyfinLibraryId, ), ), ) @@ -43,15 +53,25 @@ class FilmController( @PatchMapping("/{id}") fun edit( @PathVariable id: UUID, - @RequestBody request: EditFilmRequest, + @Valid @RequestBody request: EditFilmRequest, ): FilmResponse = FilmResponse.fromDomain( - editFilmUseCase.edit( + filmUseCase.edit( id = id, command = EditFilmCommand( title = request.title, description = request.description, + contentType = parseContentType(request.contentType), + releaseYear = request.releaseYear, + genres = request.genres, + cast = request.cast, + directors = request.directors, + imdbRating = request.imdbRating, + platformRating = request.platformRating, + externalUrl = request.externalUrl, + jellyfinItemId = request.jellyfinItemId, + jellyfinLibraryId = request.jellyfinLibraryId, ), ), ) @@ -60,5 +80,25 @@ class FilmController( @ResponseStatus(HttpStatus.NO_CONTENT) fun delete( @PathVariable id: UUID, - ) = deleteFilmUseCase.delete(id) + ) = filmUseCase.delete(id) + + @GetMapping("/{id}") + fun getById( + @PathVariable id: UUID, + ): FilmResponse = FilmResponse.fromDomain(filmUseCase.getById(id)) + + @GetMapping + fun getAll(): List = filmUseCase.getAll().map { FilmResponse.fromDomain(it) } + + @GetMapping("/search") + fun searchByTitle( + @RequestParam title: String, + ): ResponseEntity { + val film = filmUseCase.searchByTitle(title) + return if (film != null) { + ResponseEntity.ok(FilmResponse.fromDomain(film)) + } else { + ResponseEntity.notFound().build() + } + } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 59022b0..e955445 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -1,21 +1,16 @@ package com.project.movienight.adapters.web -import com.project.movienight.adapters.web.dto.request.CreateFilmLibraryRequest -import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse +import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse +import com.project.movienight.adapters.web.dto.response.FilmResponse import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase -import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase -import com.project.movienight.application.ports.input.GetFilmLibraryQuery -import com.project.movienight.application.ports.input.GetFilmLibraryUseCase +import com.project.movienight.application.ports.input.FilmLibraryUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController @@ -24,44 +19,29 @@ import java.util.UUID @RestController @RequestMapping("/api/users/{userId}/library") class FilmLibraryController( - private val createFilmLibraryUseCase: CreateFilmLibraryUseCase, - private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, - private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, - private val getFilmLibraryUseCase: GetFilmLibraryUseCase, + private val filmLibraryUseCase: FilmLibraryUseCase, ) { - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - fun create( - @PathVariable userId: UUID, - @RequestBody request: CreateFilmLibraryRequest, - ): FilmLibraryResponse = - FilmLibraryResponse.fromDomain( - createFilmLibraryUseCase.create( - CreateFilmLibraryCommand( - userId = userId, - name = request.name, - ), - ), - ) - @GetMapping - fun get( + fun list( @PathVariable userId: UUID, - ): FilmLibraryResponse = - FilmLibraryResponse.fromDomain( - getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId), - ), - ) + ): List = + filmLibraryUseCase + .list(userId) + .map { entry -> FilmLibraryEntryResponse.fromDomain(entry) } + + @GetMapping("/entries") + fun listEntries( + @PathVariable userId: UUID, + ): List = list(userId) @PostMapping("/films/{filmId}") @ResponseStatus(HttpStatus.CREATED) fun addFilm( @PathVariable userId: UUID, @PathVariable filmId: UUID, - ): FilmLibraryResponse = - FilmLibraryResponse.fromDomain( - addFilmToLibraryUseCase.addFilm( + ): FilmLibraryEntryResponse = + FilmLibraryEntryResponse.fromDomain( + filmLibraryUseCase.addFilm( AddFilmToLibraryCommand( userId = userId, filmId = filmId, @@ -69,15 +49,36 @@ class FilmLibraryController( ), ) + @PostMapping("/films/{filmId}/viewed") + fun markViewed( + @PathVariable userId: UUID, + @PathVariable filmId: UUID, + ): FilmLibraryEntryResponse = + FilmLibraryEntryResponse.fromDomain( + filmLibraryUseCase.markViewed( + MarkFilmViewedCommand( + userId = userId, + filmId = filmId, + ), + ), + ) + @DeleteMapping("/films/{filmId}") @ResponseStatus(HttpStatus.NO_CONTENT) fun removeFilm( @PathVariable userId: UUID, @PathVariable filmId: UUID, - ) = removeFilmFromLibraryUseCase.removeFilm( - RemoveFilmFromLibraryCommand( - userId = userId, - filmId = filmId, - ), - ) + ) { + filmLibraryUseCase.removeFilm( + RemoveFilmFromLibraryCommand( + userId = userId, + filmId = filmId, + ), + ) + } + + @GetMapping("/available-films") + fun getAvailableFilms( + @PathVariable userId: UUID, + ): List = filmLibraryUseCase.listAvailableFilms(userId).map { FilmResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt new file mode 100644 index 0000000..b86b5f1 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt @@ -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 = filmRatingUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt new file mode 100644 index 0000000..2d33a64 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -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, + ), + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt new file mode 100644 index 0000000..c2bbb1b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt @@ -0,0 +1,21 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.config.JellyfinIntegrationProperties +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException + +@Component +class JellyfinPluginAuthenticator( + private val properties: JellyfinIntegrationProperties, +) { + fun authenticate(token: String?) { + if (!properties.enabled) { + throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled") + } + + if (properties.pluginToken.isNotBlank() && token != properties.pluginToken) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt new file mode 100644 index 0000000..10bda37 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt @@ -0,0 +1,227 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.RateFilmRequest +import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest +import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse +import com.project.movienight.adapters.web.dto.response.FilmRatingResponse +import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse +import com.project.movienight.adapters.web.dto.response.RecommendationResponse +import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase +import com.project.movienight.application.ports.input.FilmLibraryUseCase +import com.project.movienight.application.ports.input.FilmRatingUseCase +import com.project.movienight.application.ports.input.GetRecommendationsUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand +import com.project.movienight.application.ports.input.RateFilmCommand +import com.project.movienight.application.ports.input.RecommendationQuery +import com.project.movienight.application.ports.input.UserPreferencesUseCase +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.config.JellyfinIntegrationProperties +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.RecommendationStyle +import com.project.movienight.domain.model.User +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.time.OffsetDateTime +import java.util.Locale +import java.util.UUID + +@RestController +@RequestMapping("/api/integrations/jellyfin") +class JellyfinPluginController( + private val authenticator: JellyfinPluginAuthenticator, + private val userRepository: UserRepositoryPort, + private val filmRepository: FilmRepositoryPort, + private val idGenerator: IdGenerator, + private val getRecommendationsUseCase: GetRecommendationsUseCase, + private val filmRatingUseCase: FilmRatingUseCase, + private val filmLibraryUseCase: FilmLibraryUseCase, + private val userPreferencesUseCase: UserPreferencesUseCase, + private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase, + private val jellyfinProperties: JellyfinIntegrationProperties, +) { + @GetMapping("/users/{jellyfinUserId}/recommendations") + fun recommend( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + @RequestParam(required = false) contentType: String?, + @RequestParam(required = false) mood: String?, + @RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean, + @RequestParam(required = false, defaultValue = "10") limit: Int, + ): List { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + return getRecommendationsUseCase + .recommend( + RecommendationQuery( + userId = user.id, + contentType = parseOptionalContentType(contentType), + mood = mood, + libraryOnly = libraryOnly, + limit = limit, + ), + ).map { recommendation -> + RecommendationResponse.fromDomain( + recommendation = recommendation, + watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId), + ) + } + } + + @PostMapping("/users/{jellyfinUserId}/ratings/items/{jellyfinItemId}") + fun rate( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + @PathVariable jellyfinItemId: String, + @Valid @RequestBody request: RateFilmRequest, + ): FilmRatingResponse { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + val film = resolveFilm(jellyfinItemId) + return FilmRatingResponse.fromDomain( + filmRatingUseCase.rate( + RateFilmCommand( + userId = user.id, + filmId = film.id, + score = request.score, + note = request.note, + ), + ), + ) + } + + @GetMapping("/users/{jellyfinUserId}/ratings") + fun ratings( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + ): List { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + return filmRatingUseCase.getRatings(user.id).map { FilmRatingResponse.fromDomain(it) } + } + + @PostMapping("/users/{jellyfinUserId}/library/items/{jellyfinItemId}/viewed") + fun markViewed( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + @PathVariable jellyfinItemId: String, + @RequestBody(required = false) request: JellyfinViewedRequest?, + ): FilmLibraryEntryResponse { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + val film = resolveFilm(jellyfinItemId) + return FilmLibraryEntryResponse.fromDomain( + filmLibraryUseCase.markViewed( + MarkFilmViewedCommand( + userId = user.id, + filmId = film.id, + watchedAt = request?.watchedAt?.toLocalDateTime(), + ), + ), + ) + } + + @GetMapping("/users/{jellyfinUserId}/preferences") + fun preferences( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + ): UserPreferencesResponse { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + return userPreferencesUseCase.get(user.id)?.let { UserPreferencesResponse.fromDomain(it) } + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User preferences not found") + } + + @PostMapping("/users/{jellyfinUserId}/recommendation-onboarding") + fun completeOnboarding( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + @RequestBody request: RecommendationOnboardingRequest, + ): RecommendationOnboardingResponse { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + return RecommendationOnboardingResponse.fromApplication( + completeRecommendationOnboardingUseCase.complete( + CompleteRecommendationOnboardingCommand( + userId = user.id, + weightedGenres = request.weightedGenres, + plotTypes = request.plotTypes, + eras = request.eras, + castAndDirectors = request.castAndDirectors, + moods = request.moods, + contentTypes = request.contentTypes.mapNotNull { runCatching { parseContentType(it) }.getOrNull() }, + likedFilmIds = request.likedFilmIds, + dislikedFilmIds = request.dislikedFilmIds, + libraryFilmIds = request.libraryFilmIds, + watchedFilmIds = request.watchedFilmIds, + recommendationStyle = parseRecommendationStyle(request.recommendationStyle), + ), + ), + ) + } + + private fun resolveOrCreateUser(jellyfinUserId: String): User = + normalizeJellyfinId(jellyfinUserId).let { normalizedId -> + userRepository.findByJellyfinUserId(normalizedId) + ?: userRepository.save( + User( + id = idGenerator.generateId(), + name = "Jellyfin User", + email = syntheticJellyfinEmail(normalizedId), + jellyfinUserId = normalizedId, + ), + ) + } + + private fun resolveFilm(jellyfinItemId: String): Film = + normalizeJellyfinId(jellyfinItemId).let { normalizedId -> + filmRepository.findByJellyfinItemId(normalizedId) + ?: throw EntityNotFoundException(entity = "Jellyfin item", id = jellyfinItemId) + } + + private fun buildWatchUrl(jellyfinItemId: String?): String? { + if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) { + return null + } + + val baseUrl = jellyfinProperties.webUrl.trimEnd('/') + val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8) + return "$baseUrl/web/#/details?id=$encodedItemId" + } + + private fun parseRecommendationStyle(value: String): RecommendationStyle = + runCatching { RecommendationStyle.valueOf(value.uppercase()) } + .getOrDefault(RecommendationStyle.BALANCED) + + private fun normalizeJellyfinId(value: String): String = + runCatching { UUID.fromString(value).toString().replace("-", "") } + .getOrDefault(value) + + private fun syntheticJellyfinEmail(jellyfinUserId: String): String { + val safeId = + jellyfinUserId + .lowercase(Locale.getDefault()) + .replace(Regex("[^a-z0-9._%+-]"), "-") + .take(240) + return "jellyfin-$safeId@movienight.local" + } +} + +data class JellyfinViewedRequest( + val watchedAt: OffsetDateTime? = null, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt new file mode 100644 index 0000000..29d5585 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -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 { + authenticator.authenticate(token) + return jellyfinSyncUseCase.getSyncStates() + } + + private fun JellyfinSyncRequest.toCommand(): IngestJellyfinSyncCommand = + IngestJellyfinSyncCommand( + users = + users.map { user -> + JellyfinSyncUserCommand( + jellyfinUserId = user.jellyfinUserId, + name = user.name, + ) + }, + items = + items.map { item -> + JellyfinSyncItemCommand( + jellyfinItemId = item.jellyfinItemId, + title = item.title, + originalTitle = item.originalTitle, + description = item.description, + year = item.year, + genres = item.genres, + imdbId = item.imdbId, + tmdbId = item.tmdbId, + jellyfinLibraryId = item.jellyfinLibraryId, + userStates = + item.userStates.map { state -> + JellyfinSyncUserStateCommand( + jellyfinUserId = state.jellyfinUserId, + isViewed = state.isViewed, + playCount = state.playCount, + lastPlayedAt = state.lastPlayedAt, + userRating = state.userRating, + ) + }, + ) + }, + ) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt new file mode 100644 index 0000000..cd37beb --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -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 = + 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" + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt new file mode 100644 index 0000000..0ff38f6 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt @@ -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) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt b/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt new file mode 100644 index 0000000..596cb47 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt @@ -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") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index f270736..ee74235 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -1,15 +1,17 @@ package com.project.movienight.adapters.web +import com.project.movienight.adapters.security.UserPrincipal import com.project.movienight.adapters.web.dto.request.CreateUserRequest import com.project.movienight.adapters.web.dto.request.EditUserRequest import com.project.movienight.adapters.web.dto.response.UserResponse import com.project.movienight.application.ports.input.CreateUserCommand -import com.project.movienight.application.ports.input.CreateUserUseCase -import com.project.movienight.application.ports.input.DeleteUserUseCase import com.project.movienight.application.ports.input.EditUserCommand -import com.project.movienight.application.ports.input.EditUserUseCase +import com.project.movienight.application.ports.input.UserUseCase +import jakarta.validation.Valid import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -22,17 +24,15 @@ import java.util.UUID @RestController @RequestMapping("/api/users") class UserController( - private val createUserUseCase: CreateUserUseCase, - private val editUserUseCase: EditUserUseCase, - private val deleteUserUseCase: DeleteUserUseCase, + private val userUseCase: UserUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) fun create( - @RequestBody request: CreateUserRequest, + @Valid @RequestBody request: CreateUserRequest, ): UserResponse = UserResponse.fromDomain( - createUserUseCase.create( + userUseCase.create( CreateUserCommand( name = request.name, email = request.email, @@ -40,17 +40,31 @@ class UserController( ), ) + @GetMapping + fun getAll(): List = userUseCase.getAll().map { UserResponse.fromDomain(it) } + + @GetMapping("/me") + fun getMe( + @AuthenticationPrincipal principal: UserPrincipal, + ): UserResponse = UserResponse.fromDomain(userUseCase.getById(principal.getId())) + + @GetMapping("/{id}") + fun getById( + @PathVariable id: UUID, + ): UserResponse = UserResponse.fromDomain(userUseCase.getById(id)) + @PatchMapping("/{id}") fun edit( @PathVariable id: UUID, - @RequestBody request: EditUserRequest, + @Valid @RequestBody request: EditUserRequest, ): UserResponse = UserResponse.fromDomain( - editUserUseCase.edit( + userUseCase.edit( id = id, command = EditUserCommand( name = request.name, + jellyfinUserId = request.jellyfinUserId, ), ), ) @@ -59,5 +73,5 @@ class UserController( @ResponseStatus(HttpStatus.NO_CONTENT) fun delete( @PathVariable id: UUID, - ) = deleteUserUseCase.delete(id) + ) = userUseCase.delete(id) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt new file mode 100644 index 0000000..074fb15 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt @@ -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) } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt new file mode 100644 index 0000000..4c5dc1b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt @@ -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, + ), + ), + ) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt deleted file mode 100644 index dc6cb11..0000000 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.project.movienight.adapters.web.dto.request - -data class CreateFilmLibraryRequest( - val name: String = "My films", -) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt index 994429d..a07f049 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt @@ -1,6 +1,31 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + data class CreateFilmRequest( + @field:NotBlank + @field:Size(max = 255) val title: String, + @field:NotBlank val description: String, + @field:NotBlank + val contentType: String = "FILM", + @field:Min(1888) + @field:Max(3000) + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = 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, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt index 73dcb0f..94308b2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt @@ -1,6 +1,15 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + data class CreateUserRequest( + @field:NotBlank + @field:Size(max = 255) val name: String, + @field:Email + @field:NotBlank + @field:Size(max = 320) val email: String, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt index 9e476c3..20d88e5 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt @@ -1,6 +1,31 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + data class EditFilmRequest( + @field:NotBlank + @field:Size(max = 255) val title: String, + @field:NotBlank val description: String, + @field:NotBlank + val contentType: String = "FILM", + @field:Min(1888) + @field:Max(3000) + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = 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, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt index 83ddd24..b459396 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt @@ -1,5 +1,12 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + data class EditUserRequest( + @field:NotBlank + @field:Size(max = 255) val name: String, + @field:Size(max = 255) + val jellyfinUserId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt new file mode 100644 index 0000000..973f7b8 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt @@ -0,0 +1,26 @@ +package com.project.movienight.adapters.web.dto.request + +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.NotBlank +import java.time.OffsetDateTime + +data class JellyfinEventRequest( + @JsonProperty("event_id") + @field:NotBlank + val eventId: String, + @JsonProperty("event_type") + @field:NotBlank + val eventType: String, + @JsonProperty("occurred_at") + val occurredAt: OffsetDateTime, + @JsonProperty("jellyfin_user_id") + @field:NotBlank + val jellyfinUserId: String, + @JsonProperty("item_id") + @field:NotBlank + val itemId: String, + @JsonProperty("payload_version") + val payloadVersion: Int = 1, + @JsonProperty("payload") + val payload: Map? = null, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt new file mode 100644 index 0000000..d916f71 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt @@ -0,0 +1,43 @@ +package com.project.movienight.adapters.web.dto.request + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import java.time.OffsetDateTime + +data class JellyfinSyncRequest( + @field:Valid + val users: List = emptyList(), + @field:Valid + val items: List = emptyList(), +) + +data class JellyfinSyncUserRequest( + @field:NotBlank + val jellyfinUserId: String, + val name: String? = null, +) + +data class JellyfinSyncItemRequest( + @field:NotBlank + val jellyfinItemId: String, + @field:NotBlank + val title: String, + val originalTitle: String? = null, + val description: String? = null, + val year: Int? = null, + val genres: List = emptyList(), + val imdbId: String? = null, + val tmdbId: String? = null, + val jellyfinLibraryId: String? = null, + @field:Valid + val userStates: List = emptyList(), +) + +data class JellyfinSyncUserStateRequest( + @field:NotBlank + val jellyfinUserId: String, + val isViewed: Boolean = false, + val playCount: Int = 0, + val lastPlayedAt: OffsetDateTime? = null, + val userRating: Double? = null, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt new file mode 100644 index 0000000..c2a8077 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt @@ -0,0 +1,13 @@ +package com.project.movienight.adapters.web.dto.request + +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.Size + +data class RateFilmRequest( + @field:Min(1) + @field:Max(10) + val score: Int, + @field:Size(max = 2048) + val note: String? = null, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt new file mode 100644 index 0000000..0480531 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt @@ -0,0 +1,17 @@ +package com.project.movienight.adapters.web.dto.request + +import java.util.UUID + +data class RecommendationOnboardingRequest( + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), + val likedFilmIds: List = emptyList(), + val dislikedFilmIds: List = emptyList(), + val libraryFilmIds: List = emptyList(), + val watchedFilmIds: List = emptyList(), + val recommendationStyle: String = "BALANCED", +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt new file mode 100644 index 0000000..3c0846b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt @@ -0,0 +1,15 @@ +package com.project.movienight.adapters.web.dto.request + +data class UpdateUserRecommendationWeightsRequest( + val relevanceWeight: Double, + val qualityWeight: Double, + val contextWeight: Double, + val noveltyWeight: Double, + val diversityWeight: Double, + val genreVectorWeight: Double, + val plotVectorWeight: Double, + val moodVectorWeight: Double, + val eraVectorWeight: Double, + val peopleVectorWeight: Double, + val contentTypeVectorWeight: Double, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt new file mode 100644 index 0000000..38c809e --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt @@ -0,0 +1,10 @@ +package com.project.movienight.adapters.web.dto.request + +data class UpsertUserPreferencesRequest( + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt new file mode 100644 index 0000000..2a6d670 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt @@ -0,0 +1,26 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.FilmLibraryEntry +import java.time.LocalDateTime +import java.util.UUID + +data class FilmLibraryEntryResponse( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val comment: String?, + val isViewed: Boolean, + val watchedAt: LocalDateTime?, +) { + companion object { + fun fromDomain(entry: FilmLibraryEntry): FilmLibraryEntryResponse = + FilmLibraryEntryResponse( + id = entry.id, + userId = entry.userId, + filmId = entry.filmId, + comment = entry.comment, + isViewed = entry.isViewed, + watchedAt = entry.watchedAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt deleted file mode 100644 index 8ba6c01..0000000 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.project.movienight.adapters.web.dto.response - -import com.project.movienight.domain.model.FilmLibrary -import java.util.UUID - -data class FilmLibraryResponse( - val id: UUID, - val userId: UUID, - val filmId: UUID, - val comment: String?, - val isViewed: Boolean, -) { - companion object { - fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse = - FilmLibraryResponse( - id = filmLibrary.id, - userId = filmLibrary.userId, - filmId = filmLibrary.filmId, - comment = filmLibrary.comment, - isViewed = filmLibrary.isViewed, - ) - } -} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt new file mode 100644 index 0000000..8f276fc --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt @@ -0,0 +1,28 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.FilmRating +import java.time.LocalDateTime +import java.util.UUID + +data class FilmRatingResponse( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String?, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, +) { + companion object { + fun fromDomain(rating: FilmRating): FilmRatingResponse = + FilmRatingResponse( + id = rating.id, + userId = rating.userId, + filmId = rating.filmId, + score = rating.score, + note = rating.note, + createdAt = rating.createdAt, + updatedAt = rating.updatedAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt index 239196d..4948540 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.web.dto.response +import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import java.util.UUID @@ -7,6 +8,16 @@ data class FilmResponse( val id: UUID, val title: String, val description: String, + val contentType: ContentType, + val releaseYear: Int?, + val genres: List, + val cast: List, + val directors: List, + val imdbRating: Double?, + val platformRating: Double?, + val externalUrl: String?, + val jellyfinItemId: String?, + val jellyfinLibraryId: String?, ) { companion object { fun fromDomain(film: Film): FilmResponse = @@ -14,6 +25,16 @@ data class FilmResponse( id = film.id, title = film.title, description = film.description, + contentType = film.contentType, + releaseYear = film.releaseYear, + genres = film.genres, + cast = film.cast, + directors = film.directors, + imdbRating = film.imdbRating, + platformRating = film.platformRating, + externalUrl = film.externalUrl, + jellyfinItemId = film.jellyfinItemId, + jellyfinLibraryId = film.jellyfinLibraryId, ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt new file mode 100644 index 0000000..4fc12ca --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt @@ -0,0 +1,37 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.RecommendationEvent +import com.project.movienight.domain.model.RecommendationEventType +import java.time.LocalDateTime +import java.util.UUID + +data class RecommendationEventResponse( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val eventType: RecommendationEventType, + val score: Double?, + val relevanceScore: Double?, + val qualityScore: Double?, + val contextScore: Double?, + val noveltyScore: Double?, + val diversityScore: Double?, + val createdAt: LocalDateTime, +) { + companion object { + fun fromDomain(event: RecommendationEvent): RecommendationEventResponse = + RecommendationEventResponse( + id = event.id, + userId = event.userId, + filmId = event.filmId, + eventType = event.eventType, + score = event.score, + relevanceScore = event.relevanceScore, + qualityScore = event.qualityScore, + contextScore = event.contextScore, + noveltyScore = event.noveltyScore, + diversityScore = event.diversityScore, + createdAt = event.createdAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt new file mode 100644 index 0000000..6cd2810 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt @@ -0,0 +1,27 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.application.ports.input.RecommendationOnboardingResult +import java.util.UUID + +data class RecommendationOnboardingResponse( + val userId: UUID, + val preferences: UserPreferencesResponse, + val weights: UserRecommendationWeightsResponse, + val likedFilmsCount: Int, + val dislikedFilmsCount: Int, + val libraryFilmsCount: Int, + val watchedFilmsCount: Int, +) { + companion object { + fun fromApplication(result: RecommendationOnboardingResult): RecommendationOnboardingResponse = + RecommendationOnboardingResponse( + userId = result.userId, + preferences = UserPreferencesResponse.fromDomain(result.preferences), + weights = UserRecommendationWeightsResponse.fromDomain(result.weights), + likedFilmsCount = result.likedFilmsCount, + dislikedFilmsCount = result.dislikedFilmsCount, + libraryFilmsCount = result.libraryFilmsCount, + watchedFilmsCount = result.watchedFilmsCount, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt new file mode 100644 index 0000000..da78e72 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt @@ -0,0 +1,32 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.RecommendationResult +import java.util.UUID + +data class RecommendationResponse( + val filmId: UUID, + val title: String, + val score: Double, + val reasons: List, + val jellyfinItemId: String?, + val watchUrl: String?, + val film: FilmResponse, +) { + companion object { + fun fromDomain( + recommendation: RecommendationResult, + watchUrl: String?, + ): RecommendationResponse { + val film = recommendation.film + return RecommendationResponse( + filmId = film.id, + title = film.title, + score = recommendation.score, + reasons = recommendation.reasons, + jellyfinItemId = film.jellyfinItemId, + watchUrl = watchUrl, + film = FilmResponse.fromDomain(film), + ) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt new file mode 100644 index 0000000..2388d3f --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt @@ -0,0 +1,28 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +data class UserPreferencesResponse( + val userId: UUID, + val weightedGenres: Map, + val plotTypes: List, + val eras: List, + val castAndDirectors: List, + val moods: List, + val contentTypes: List, +) { + companion object { + fun fromDomain(preferences: UserPreferences): UserPreferencesResponse = + UserPreferencesResponse( + userId = preferences.userId, + weightedGenres = preferences.weightedGenres, + plotTypes = preferences.plotTypes, + eras = preferences.eras, + castAndDirectors = preferences.castAndDirectors, + moods = preferences.moods, + contentTypes = preferences.contentTypes, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt new file mode 100644 index 0000000..0b22037 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt @@ -0,0 +1,40 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.UserRecommendationWeights +import java.time.LocalDateTime +import java.util.UUID + +data class UserRecommendationWeightsResponse( + val userId: UUID, + val relevanceWeight: Double, + val qualityWeight: Double, + val contextWeight: Double, + val noveltyWeight: Double, + val diversityWeight: Double, + val genreVectorWeight: Double, + val plotVectorWeight: Double, + val moodVectorWeight: Double, + val eraVectorWeight: Double, + val peopleVectorWeight: Double, + val contentTypeVectorWeight: Double, + val updatedAt: LocalDateTime, +) { + companion object { + fun fromDomain(weights: UserRecommendationWeights): UserRecommendationWeightsResponse = + UserRecommendationWeightsResponse( + userId = weights.userId, + relevanceWeight = weights.relevanceWeight, + qualityWeight = weights.qualityWeight, + contextWeight = weights.contextWeight, + noveltyWeight = weights.noveltyWeight, + diversityWeight = weights.diversityWeight, + genreVectorWeight = weights.genreVectorWeight, + plotVectorWeight = weights.plotVectorWeight, + moodVectorWeight = weights.moodVectorWeight, + eraVectorWeight = weights.eraVectorWeight, + peopleVectorWeight = weights.peopleVectorWeight, + contentTypeVectorWeight = weights.contentTypeVectorWeight, + updatedAt = weights.updatedAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt index 48f5dd8..b1b94f4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt @@ -7,6 +7,7 @@ data class UserResponse( val id: UUID, val name: String, val email: String, + val jellyfinUserId: String?, ) { companion object { fun fromDomain(user: User): UserResponse = @@ -14,6 +15,7 @@ data class UserResponse( id = user.id, name = user.name, email = user.email, + jellyfinUserId = user.jellyfinUserId, ) } } diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt index cf9a0b8..39d9688 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt @@ -1,19 +1,20 @@ package com.project.movienight.application.ports.input -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibraryEntry +import java.time.LocalDateTime import java.util.UUID -interface CreateFilmLibraryUseCase { - fun create(command: CreateFilmLibraryCommand): FilmLibrary -} +interface FilmLibraryUseCase { + fun addFilm(command: AddFilmToLibraryCommand): FilmLibraryEntry -data class CreateFilmLibraryCommand( - val userId: UUID, - val name: String = "Мои фильмы", -) + fun markViewed(command: MarkFilmViewedCommand): FilmLibraryEntry -interface AddFilmToLibraryUseCase { - fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary + fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibraryEntry + + fun list(userId: UUID): List + + fun listAvailableFilms(userId: UUID): List } data class AddFilmToLibraryCommand( @@ -21,20 +22,14 @@ data class AddFilmToLibraryCommand( val filmId: UUID, ) -interface RemoveFilmFromLibraryUseCase { - fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary -} +data class MarkFilmViewedCommand( + val userId: UUID, + val filmId: UUID, + val watchedAt: LocalDateTime? = null, +) data class RemoveFilmFromLibraryCommand( val userId: UUID, val filmId: UUID, - val libraryId: UUID? = null, -) - -interface GetFilmLibraryUseCase { - fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary -} - -data class GetFilmLibraryQuery( - val userId: UUID, + val entryId: UUID? = null, ) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt new file mode 100644 index 0000000..5a411a1 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt @@ -0,0 +1,17 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.FilmRating +import java.util.UUID + +interface FilmRatingUseCase { + fun rate(command: RateFilmCommand): FilmRating + + fun getRatings(userId: UUID): List +} + +data class RateFilmCommand( + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String? = null, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt index 3622878..c88bc2d 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt @@ -1,29 +1,52 @@ package com.project.movienight.application.ports.input +import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import java.util.UUID -interface CreateFilmUseCase { +interface FilmUseCase { fun create(command: CreateFilmCommand): Film + + fun edit( + id: UUID, + command: EditFilmCommand, + ): Film + + fun delete(id: UUID) + + fun getById(id: UUID): Film + + fun getAll(): List + + fun searchByTitle(title: String): Film? } data class CreateFilmCommand( val title: String, val description: String, + val contentType: ContentType = ContentType.FILM, + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) -interface EditFilmUseCase { - fun edit( - id: UUID, - command: EditFilmCommand, - ): Film -} - data class EditFilmCommand( val title: String, val description: String, + val contentType: ContentType = ContentType.FILM, + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) - -interface DeleteFilmUseCase { - fun delete(id: UUID) -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt new file mode 100644 index 0000000..146e3bc --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt @@ -0,0 +1,36 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationEvent +import com.project.movienight.domain.model.RecommendationResult +import java.util.UUID + +interface GetRecommendationsUseCase { + fun recommend(query: RecommendationQuery): List +} + +data class RecommendationQuery( + val userId: UUID, + val contentType: ContentType? = null, + val mood: String? = null, + val libraryOnly: Boolean = false, + val limit: Int = 10, +) + +interface AcceptRecommendationUseCase { + fun accept(command: AcceptRecommendationCommand): RecommendationEvent +} + +data class AcceptRecommendationCommand( + val userId: UUID, + val filmId: UUID, +) + +interface RejectRecommendationUseCase { + fun reject(command: RejectRecommendationCommand): RecommendationEvent +} + +data class RejectRecommendationCommand( + val userId: UUID, + val filmId: UUID, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt new file mode 100644 index 0000000..6be11a2 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt @@ -0,0 +1,58 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.JellyfinSyncState +import com.project.movienight.domain.model.JellyfinSyncSummary +import java.time.OffsetDateTime + +interface JellyfinEventUseCase { + fun handle(command: HandleJellyfinEventCommand) +} + +data class HandleJellyfinEventCommand( + val eventId: String, + val serverId: String?, + val eventType: String, + val occurredAt: OffsetDateTime, + val jellyfinUserId: String, + val itemId: String, + val payload: Map?, +) + +interface JellyfinSyncUseCase { + fun syncNow(): JellyfinSyncSummary + + fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary + + fun getSyncStates(): List +} + +data class IngestJellyfinSyncCommand( + val users: List = emptyList(), + val items: List = emptyList(), +) + +data class JellyfinSyncUserCommand( + val jellyfinUserId: String, + val name: String?, +) + +data class JellyfinSyncItemCommand( + val jellyfinItemId: String, + val title: String, + val originalTitle: String?, + val description: String?, + val year: Int?, + val genres: List, + val imdbId: String?, + val tmdbId: String?, + val jellyfinLibraryId: String?, + val userStates: List, +) + +data class JellyfinSyncUserStateCommand( + val jellyfinUserId: String, + val isViewed: Boolean, + val playCount: Int, + val lastPlayedAt: OffsetDateTime?, + val userRating: Double?, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt new file mode 100644 index 0000000..0d54caf --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt @@ -0,0 +1,36 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationStyle +import com.project.movienight.domain.model.UserPreferences +import com.project.movienight.domain.model.UserRecommendationWeights +import java.util.UUID + +interface CompleteRecommendationOnboardingUseCase { + fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult +} + +data class CompleteRecommendationOnboardingCommand( + val userId: UUID, + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), + val likedFilmIds: List = emptyList(), + val dislikedFilmIds: List = emptyList(), + val libraryFilmIds: List = emptyList(), + val watchedFilmIds: List = emptyList(), + val recommendationStyle: RecommendationStyle = RecommendationStyle.BALANCED, +) + +data class RecommendationOnboardingResult( + val userId: UUID, + val preferences: UserPreferences, + val weights: UserRecommendationWeights, + val likedFilmsCount: Int, + val dislikedFilmsCount: Int, + val libraryFilmsCount: Int, + val watchedFilmsCount: Int, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt new file mode 100644 index 0000000..fcf539c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt @@ -0,0 +1,21 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +interface UserPreferencesUseCase { + fun upsert(command: UpsertUserPreferencesCommand): UserPreferences + + fun get(userId: UUID): UserPreferences? +} + +data class UpsertUserPreferencesCommand( + val userId: UUID, + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt new file mode 100644 index 0000000..9bfaa6b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt @@ -0,0 +1,27 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.UserRecommendationWeights +import java.util.UUID + +interface GetUserRecommendationWeightsUseCase { + fun get(userId: UUID): UserRecommendationWeights +} + +interface UpdateUserRecommendationWeightsUseCase { + fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights +} + +data class UpdateUserRecommendationWeightsCommand( + val userId: UUID, + val relevanceWeight: Double, + val qualityWeight: Double, + val contextWeight: Double, + val noveltyWeight: Double, + val diversityWeight: Double, + val genreVectorWeight: Double, + val plotVectorWeight: Double, + val moodVectorWeight: Double, + val eraVectorWeight: Double, + val peopleVectorWeight: Double, + val contentTypeVectorWeight: Double, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt index c889946..2cef36c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt @@ -3,8 +3,19 @@ package com.project.movienight.application.ports.input import com.project.movienight.domain.model.User import java.util.UUID -interface CreateUserUseCase { +interface UserUseCase { fun create(command: CreateUserCommand): User + + fun edit( + id: UUID, + command: EditUserCommand, + ): User + + fun delete(id: UUID) + + fun getById(id: UUID): User + + fun getAll(): List } data class CreateUserCommand( @@ -12,17 +23,7 @@ data class CreateUserCommand( val email: String, ) -interface EditUserUseCase { - fun edit( - id: UUID, - command: EditUserCommand, - ): User -} - data class EditUserCommand( val name: String, + val jellyfinUserId: String? = null, ) - -interface DeleteUserUseCase { - fun delete(id: UUID) -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt new file mode 100644 index 0000000..e45db2b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt @@ -0,0 +1,13 @@ +package com.project.movienight.application.ports.input.security + +interface OAuth2UserInfo { + fun getProviderId(): String + + fun getEmail(): String + + fun getName(): String + + fun getProvider(): String + + fun getAttributes(): Map +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt new file mode 100644 index 0000000..516fafd --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt @@ -0,0 +1,30 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.JellyfinSyncSummary +import com.project.movienight.domain.model.RecommendationEventType + +interface BusinessMetricsPort { + fun recordFilmCreated() + + fun recordFilmEdited() + + fun recordFilmDeleted() + + fun recordFilmBlocked() + + fun recordRecommendationRequest() + + fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) + + fun recordRatingSubmitted() + + fun recordLibraryEvent() + + fun recordJellyfinSync(summary: JellyfinSyncSummary) + + fun recordJellyfinSyncFailure() + + fun recordJellyfinUnmappedUser() + + fun recordBackendWriteFailure() +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt new file mode 100644 index 0000000..825bb06 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt @@ -0,0 +1,21 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.FilmLibraryEntry +import java.util.UUID + +interface FilmLibraryEntryRepositoryPort { + fun save(entry: FilmLibraryEntry): FilmLibraryEntry + + fun findById(id: UUID): FilmLibraryEntry? + + fun findByUserId(userId: UUID): List + + fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibraryEntry? + + fun findAll(): List + + fun deleteById(id: UUID) +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt deleted file mode 100644 index a3eb9c9..0000000 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.project.movienight.application.ports.output - -import com.project.movienight.domain.model.FilmLibrary -import java.util.UUID - -interface FilmLibraryRepositoryPort { - fun save(filmLibrary: FilmLibrary): FilmLibrary - - fun findById(id: UUID): FilmLibrary? - - fun findAll(): List - - fun deleteById(id: UUID) -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt new file mode 100644 index 0000000..908e5ff --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt @@ -0,0 +1,15 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.FilmRating +import java.util.UUID + +interface FilmRatingRepositoryPort { + fun save(rating: FilmRating): FilmRating + + fun findByUserId(userId: UUID): List + + fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmRating? +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt index 91d45b6..1883fe9 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt @@ -8,7 +8,11 @@ interface FilmRepositoryPort { fun findById(id: UUID): Film? + fun findByJellyfinItemId(jellyfinItemId: String): Film? + fun findAll(): List + fun findByTitle(title: String): Film? + fun deleteById(id: UUID) } diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt new file mode 100644 index 0000000..0e48699 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt @@ -0,0 +1,30 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.ContentType + +interface JellyfinCatalogPort { + fun fetchUsers(): List + + fun fetchLibraryItems(userId: String): List +} + +data class JellyfinRemoteUser( + val id: String, + val name: String, +) + +data class JellyfinLibraryItemSnapshot( + val jellyfinItemId: String, + val title: String, + val description: String, + val contentType: ContentType, + val releaseYear: Int?, + val genres: List, + val cast: List, + val directors: List, + val platformRating: Double?, + val imdbRating: Double?, + val externalUrl: String?, + val jellyfinLibraryId: String?, + val isPlayed: Boolean, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt new file mode 100644 index 0000000..b7b7751 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt @@ -0,0 +1,17 @@ +package com.project.movienight.application.ports.output + +import java.time.OffsetDateTime + +interface JellyfinEventStorePort { + fun save(event: JellyfinEventRecord): Boolean +} + +data class JellyfinEventRecord( + val eventId: String, + val serverId: String?, + val eventType: String, + val occurredAt: OffsetDateTime?, + val jellyfinUserId: String?, + val jellyfinItemId: String?, + val payload: String?, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt new file mode 100644 index 0000000..78d75b5 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt @@ -0,0 +1,12 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.JellyfinSyncState +import java.util.UUID + +interface JellyfinSyncStateRepositoryPort { + fun save(state: JellyfinSyncState): JellyfinSyncState + + fun findByUserId(userId: UUID): JellyfinSyncState? + + fun findAll(): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt new file mode 100644 index 0000000..5903a4c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt @@ -0,0 +1,15 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.RecommendationEvent +import java.util.UUID + +interface RecommendationEventRepositoryPort { + fun save(event: RecommendationEvent): RecommendationEvent + + fun findByUserId(userId: UUID): List + + fun findLatestRecommended( + userId: UUID, + filmId: UUID, + ): RecommendationEvent? +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt new file mode 100644 index 0000000..0d110b5 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt @@ -0,0 +1,10 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +interface UserPreferencesRepositoryPort { + fun save(preferences: UserPreferences): UserPreferences + + fun findByUserId(userId: UUID): UserPreferences? +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt new file mode 100644 index 0000000..f4bade2 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt @@ -0,0 +1,10 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.UserRecommendationWeights +import java.util.UUID + +interface UserRecommendationWeightsRepositoryPort { + fun findByUserId(userId: UUID): UserRecommendationWeights? + + fun save(weights: UserRecommendationWeights): UserRecommendationWeights +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index af8ef0a..980ec30 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -1,14 +1,36 @@ package com.project.movienight.application.ports.output +import com.project.movienight.domain.model.AuthProvider import com.project.movienight.domain.model.User import java.util.UUID interface UserRepositoryPort { fun save(user: User): User + fun createOAuthUser( + user: User, + provider: AuthProvider, + providerId: String, + ): User + + fun linkOAuthAccount( + userId: UUID, + provider: AuthProvider, + providerId: String, + ): User + fun findById(id: UUID): User? + fun findByEmail(email: String): User? + + fun findByJellyfinUserId(jellyfinUserId: String): User? + fun findAll(): List fun deleteById(id: UUID) + + fun findByProviderAndProviderId( + provider: AuthProvider, + providerId: String, + ): User? } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index ba64e2a..c78336e 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,89 +1,115 @@ package com.project.movienight.application.services import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase -import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase -import com.project.movienight.application.ports.input.GetFilmLibraryQuery -import com.project.movienight.application.ports.input.GetFilmLibraryUseCase +import com.project.movienight.application.ports.input.FilmLibraryUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.domain.exception.DomainException import com.project.movienight.domain.exception.EntityNotFoundException -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibraryEntry import org.springframework.stereotype.Service import java.util.UUID @Service class FilmLibraryService( - private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, + private val filmRepository: FilmRepositoryPort, private val idGenerator: IdGenerator, -) : CreateFilmLibraryUseCase, - AddFilmToLibraryUseCase, - RemoveFilmFromLibraryUseCase, - GetFilmLibraryUseCase { - override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - val existingLibrary = findByUserId(command.userId) - if (existingLibrary != null) { - return existingLibrary + private val businessMetricsService: BusinessMetricsPort, +) : FilmLibraryUseCase { + override fun addFilm(command: AddFilmToLibraryCommand): FilmLibraryEntry { + ensureFilmExists(command.filmId) + + val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId) + if (existingEntry != null) { + val saved = + filmLibraryEntryRepository.save( + existingEntry.copy( + isViewed = false, + watchedAt = null, + ), + ) + businessMetricsService.recordLibraryEvent() + return saved } - return filmLibraryRepository.save( - FilmLibrary( - id = idGenerator.generateId(), - userId = command.userId, - filmId = idGenerator.generateId(), - comment = command.name, - isViewed = false, - ), - ) - } - - override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { - val existingLibrary = findByUserId(command.userId) - if (existingLibrary == null) { - return filmLibraryRepository.save( - FilmLibrary( + val saved = + filmLibraryEntryRepository.save( + FilmLibraryEntry( id = idGenerator.generateId(), userId = command.userId, filmId = command.filmId, comment = null, isViewed = false, + watchedAt = null, ), ) - } - - return filmLibraryRepository.save( - existingLibrary.copy( - filmId = command.filmId, - isViewed = false, - ), - ) + businessMetricsService.recordLibraryEvent() + return saved } - override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { - val existingLibrary = - findByUserId(command.userId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) + override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibraryEntry { + val existingEntry = + if (command.entryId != null) { + filmLibraryEntryRepository.findById(command.entryId) + ?: throw EntityNotFoundException(entity = "Film library entry", id = command.entryId.toString()) + } else { + filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId) + ?: throw EntityNotFoundException(entity = "Film library entry", id = command.filmId.toString()) + } - if (command.libraryId != null && command.libraryId != existingLibrary.id) { - throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) - } - - if (existingLibrary.filmId != command.filmId) { + if (existingEntry.userId != command.userId || existingEntry.filmId != command.filmId) { throw DomainException("Film with id ${command.filmId} not found in user's library") } - filmLibraryRepository.deleteById(existingLibrary.id) - return existingLibrary + filmLibraryEntryRepository.deleteById(existingEntry.id) + businessMetricsService.recordLibraryEvent() + return existingEntry } - override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary = - findByUserId(query.userId) - ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) + override fun markViewed(command: MarkFilmViewedCommand): FilmLibraryEntry { + ensureFilmExists(command.filmId) - private fun findByUserId(userId: UUID): FilmLibrary? = - filmLibraryRepository.findAll().firstOrNull { it.userId == userId } + val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId) + val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() + + val saved = + if (existingEntry == null) { + filmLibraryEntryRepository.save( + FilmLibraryEntry( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + comment = null, + isViewed = true, + watchedAt = watchedAt, + ), + ) + } else { + filmLibraryEntryRepository.save( + existingEntry.copy( + isViewed = true, + watchedAt = watchedAt, + ), + ) + } + businessMetricsService.recordLibraryEvent() + return saved + } + + override fun list(userId: UUID): List = filmLibraryEntryRepository.findByUserId(userId) + + override fun listAvailableFilms(userId: UUID): List { + val libraryFilmIds = list(userId).map { it.filmId }.toSet() + return filmRepository.findAll().filter { it.id !in libraryFilmIds } + } + + private fun ensureFilmExists(filmId: UUID) { + filmRepository.findById(filmId) ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString()) + } } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt new file mode 100644 index 0000000..0653171 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt @@ -0,0 +1,55 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.FilmRatingUseCase +import com.project.movienight.application.ports.input.RateFilmCommand +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.domain.exception.DomainException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.FilmRating +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.util.UUID + +@Service +class FilmRatingService( + private val filmRepository: FilmRepositoryPort, + private val filmRatingRepository: FilmRatingRepositoryPort, + private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsPort, +) : FilmRatingUseCase { + override fun rate(command: RateFilmCommand): FilmRating { + if (command.score !in 1..10) { + throw DomainException("Film rating score must be between 1 and 10") + } + + filmRepository.findById(command.filmId) + ?: throw EntityNotFoundException(entity = "Film", id = command.filmId.toString()) + + val existingRating = filmRatingRepository.findByUserIdAndFilmId(command.userId, command.filmId) + val now = LocalDateTime.now() + + val rating = + if (existingRating == null) { + FilmRating( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + score = command.score, + note = command.note, + createdAt = now, + updatedAt = now, + ) + } else { + existingRating.copy(score = command.score, note = command.note, updatedAt = now) + } + + val savedRating = filmRatingRepository.save(rating) + businessMetricsService.recordRatingSubmitted() + return savedRating + } + + override fun getRatings(userId: UUID): List = filmRatingRepository.findByUserId(userId) +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 4166760..30715f2 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -1,16 +1,17 @@ package com.project.movienight.application.services import com.project.movienight.application.ports.input.CreateFilmCommand -import com.project.movienight.application.ports.input.CreateFilmUseCase -import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand -import com.project.movienight.application.ports.input.EditFilmUseCase +import com.project.movienight.application.ports.input.FilmUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.config.FilmServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.Film +import io.micrometer.core.annotation.Timed +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -19,14 +20,29 @@ class FilmService( private val filmRepository: FilmRepositoryPort, private val idGenerator: IdGenerator, private val filmConfig: FilmServiceProperties, -) : CreateFilmUseCase, - EditFilmUseCase, - DeleteFilmUseCase { + private val businessMetricsService: BusinessMetricsPort, +) : FilmUseCase { + private val log = LoggerFactory.getLogger(javaClass) + + @Timed( + value = "business_films_create_duration_seconds", + description = "Film creation duration", + ) override fun create(command: CreateFilmCommand): Film { + log.debug( + "Create film request received: title='{}', descriptionLength={}", + command.title, + command.description.length, + ) + if (filmConfig.isBlocked(command.title)) { + log.debug("Create film blocked by title policy: title='{}'", command.title) + businessMetricsService.recordFilmBlocked() throw BlockedValueException(target = "Film", field = "title") } if (filmConfig.isBlocked(command.description)) { + log.debug("Create film blocked by description policy") + businessMetricsService.recordFilmBlocked() throw BlockedValueException(target = "Film", field = "description") } @@ -35,31 +51,93 @@ class FilmService( id = idGenerator.generateId(), title = command.title, description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, ) - return filmRepository.save(film) + + val saved = filmRepository.save(film) + businessMetricsService.recordFilmCreated() + return saved } + @Timed( + value = "business_films_edit_duration_seconds", + description = "Film edit duration", + ) override fun edit( id: UUID, command: EditFilmCommand, ): Film { + log.debug("Edit film with id: {}", id) + if (filmConfig.isBlocked(command.title)) { + log.debug("Edit film blocked by title policy: title='{}'", command.title) + businessMetricsService.recordFilmBlocked() throw BlockedValueException(target = "Film", field = "title") } if (filmConfig.isBlocked(command.description)) { + log.debug("Edit film blocked by description policy") + businessMetricsService.recordFilmBlocked() throw BlockedValueException(target = "Film", field = "description") } - var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + val film = + filmRepository.findById(id) + ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - film = film.copy(title = command.title, description = command.description) - - return filmRepository.save(film) + val saved = + filmRepository.save( + film.copy( + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ), + ) + businessMetricsService.recordFilmEdited() + return saved } + @Timed( + value = "business_films_delete_duration_seconds", + description = "Film deletion duration", + ) override fun delete(id: UUID) { - filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + log.debug("Delete film with id: {}", id) + + val film = filmRepository.findById(id) + + if (film == null) { + log.debug("Film not found for delete: id='{}'", id) + throw EntityNotFoundException(entity = "Film", id = id.toString()) + } filmRepository.deleteById(id) + businessMetricsService.recordFilmDeleted() + + log.info("Film deleted: id='{}'", id) } + + override fun getById(id: UUID): Film = + filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + + override fun getAll(): List = filmRepository.findAll() + + override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) } diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt new file mode 100644 index 0000000..2747229 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -0,0 +1,82 @@ +package com.project.movienight.application.services + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.application.ports.input.FilmLibraryUseCase +import com.project.movienight.application.ports.input.HandleJellyfinEventCommand +import com.project.movienight.application.ports.input.JellyfinEventUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.JellyfinEventRecord +import com.project.movienight.application.ports.output.JellyfinEventStorePort +import com.project.movienight.application.ports.output.UserRepositoryPort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class JellyfinEventService( + private val jellyfinEventStore: JellyfinEventStorePort, + private val userRepository: UserRepositoryPort, + private val filmRepository: FilmRepositoryPort, + private val filmLibraryUseCase: FilmLibraryUseCase, + private val objectMapper: ObjectMapper, + private val businessMetricsService: BusinessMetricsPort, +) : JellyfinEventUseCase { + private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed") + + @Transactional + override fun handle(command: HandleJellyfinEventCommand) { + val jellyfinUserId = normalizeJellyfinId(command.jellyfinUserId) + val jellyfinItemId = normalizeJellyfinId(command.itemId) + val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) } + val inserted = + jellyfinEventStore.save( + JellyfinEventRecord( + eventId = command.eventId, + serverId = command.serverId, + eventType = command.eventType, + occurredAt = command.occurredAt, + jellyfinUserId = jellyfinUserId, + jellyfinItemId = jellyfinItemId, + payload = payloadJson, + ), + ) + if (!inserted) { + return + } + + try { + if (playbackEventTypes.contains(command.eventType)) { + val localUser = userRepository.findByJellyfinUserId(jellyfinUserId) + if (localUser == null) { + businessMetricsService.recordJellyfinUnmappedUser() + return + } + + val film = filmRepository.findByJellyfinItemId(jellyfinItemId) + if (film == null) { + businessMetricsService.recordBackendWriteFailure() + return + } + + filmLibraryUseCase.markViewed( + MarkFilmViewedCommand( + userId = localUser.id, + filmId = film.id, + watchedAt = command.occurredAt.toLocalDateTime(), + ), + ) + } + } catch ( + @Suppress("TooGenericExceptionCaught") ex: RuntimeException, + ) { + businessMetricsService.recordBackendWriteFailure() + throw ex + } + } + + private fun normalizeJellyfinId(value: String): String = + runCatching { UUID.fromString(value).toString().replace("-", "") } + .getOrDefault(value) +} diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt new file mode 100644 index 0000000..56fe3fe --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -0,0 +1,313 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.IngestJellyfinSyncCommand +import com.project.movienight.application.ports.input.JellyfinSyncUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.JellyfinCatalogPort +import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot +import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.config.JellyfinIntegrationProperties +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibraryEntry +import com.project.movienight.domain.model.JellyfinSyncState +import com.project.movienight.domain.model.JellyfinSyncSummary +import com.project.movienight.domain.model.User +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime +import java.util.Locale +import java.util.UUID + +@Service +class JellyfinSyncService( + private val properties: JellyfinIntegrationProperties, + private val jellyfinCatalog: JellyfinCatalogPort, + private val userRepository: UserRepositoryPort, + private val filmRepository: FilmRepositoryPort, + private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, + private val syncStateRepository: JellyfinSyncStateRepositoryPort, + private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsPort, +) : JellyfinSyncUseCase { + @Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}") + fun scheduledSync() { + if (properties.enabled) { + syncNow() + } + } + + override fun syncNow(): JellyfinSyncSummary { + if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) { + return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0) + } + + return try { + runSync() + } catch ( + @Suppress("TooGenericExceptionCaught") ex: RuntimeException, + ) { + businessMetricsService.recordJellyfinSyncFailure() + throw ex + } + } + + override fun getSyncStates(): List = syncStateRepository.findAll() + + override fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary { + if (!properties.enabled) { + return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0) + } + + return try { + ingestPluginSync(command) + } catch ( + @Suppress("TooGenericExceptionCaught") ex: RuntimeException, + ) { + businessMetricsService.recordJellyfinSyncFailure() + throw ex + } + } + + private fun runSync(): JellyfinSyncSummary { + val startedAt = Instant.now() + val remoteUsers = jellyfinCatalog.fetchUsers() + val localUsersByJellyfinId = + userRepository + .findAll() + .mapNotNull { user -> + user.jellyfinUserId?.let { normalizeJellyfinId(it) to user } + }.toMap() + + var syncedUsers = 0 + var skippedUsers = 0 + var syncedItems = 0 + + remoteUsers.forEach { remoteUser -> + val localUser = localUsersByJellyfinId[normalizeJellyfinId(remoteUser.id)] + if (localUser == null) { + skippedUsers += 1 + return@forEach + } + + val items = jellyfinCatalog.fetchLibraryItems(remoteUser.id) + items.forEach { item -> + syncItem(localUser.id, item) + syncedItems += 1 + } + + val now = LocalDateTime.now() + syncStateRepository.save( + JellyfinSyncState( + userId = localUser.id, + lastSyncedAt = now, + lastSuccessfulSyncAt = now, + lastError = null, + syncedItemCount = items.size, + ), + ) + syncedUsers += 1 + } + + val summary = + JellyfinSyncSummary( + syncedUsers = syncedUsers, + skippedUsers = skippedUsers, + syncedItems = syncedItems, + durationMs = Duration.between(startedAt, Instant.now()).toMillis(), + ) + businessMetricsService.recordJellyfinSync(summary) + return summary + } + + private fun syncItem( + userId: UUID, + item: JellyfinLibraryItemSnapshot, + ) { + val savedFilm = upsertFilm(item) + + if (item.isPlayed) { + markFilmViewed( + userId = userId, + filmId = savedFilm.id, + watchedAt = LocalDateTime.now(), + ) + } + } + + private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film { + val normalizedItem = + item.copy( + jellyfinItemId = normalizeJellyfinId(item.jellyfinItemId), + jellyfinLibraryId = item.jellyfinLibraryId?.let(::normalizeJellyfinId), + ) + val film = + filmRepository.findByJellyfinItemId(normalizedItem.jellyfinItemId)?.copy( + title = normalizedItem.title, + description = normalizedItem.description, + contentType = normalizedItem.contentType, + releaseYear = normalizedItem.releaseYear, + genres = normalizedItem.genres, + cast = normalizedItem.cast, + directors = normalizedItem.directors, + imdbRating = normalizedItem.imdbRating, + platformRating = normalizedItem.platformRating, + externalUrl = normalizedItem.externalUrl, + jellyfinItemId = normalizedItem.jellyfinItemId, + jellyfinLibraryId = normalizedItem.jellyfinLibraryId, + ) ?: Film( + id = idGenerator.generateId(), + title = normalizedItem.title, + description = normalizedItem.description, + contentType = normalizedItem.contentType, + releaseYear = normalizedItem.releaseYear, + genres = normalizedItem.genres, + cast = normalizedItem.cast, + directors = normalizedItem.directors, + imdbRating = normalizedItem.imdbRating, + platformRating = normalizedItem.platformRating, + externalUrl = normalizedItem.externalUrl, + jellyfinItemId = normalizedItem.jellyfinItemId, + jellyfinLibraryId = normalizedItem.jellyfinLibraryId, + ) + + return filmRepository.save(film) + } + + private fun markFilmViewed( + userId: UUID, + filmId: UUID, + watchedAt: LocalDateTime, + ) { + val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) + filmLibraryEntryRepository.save( + existingEntry?.copy( + isViewed = true, + watchedAt = watchedAt, + ) ?: FilmLibraryEntry( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + comment = null, + isViewed = true, + watchedAt = watchedAt, + ), + ) + } + + private fun ingestPluginSync(command: IngestJellyfinSyncCommand): JellyfinSyncSummary { + val startedAt = Instant.now() + upsertPluginUsers(command) + val localUsersByJellyfinId = + userRepository + .findAll() + .mapNotNull { user -> user.jellyfinUserId?.let { normalizeJellyfinId(it) to user } } + .toMap() + + val skippedUserIds = mutableSetOf() + val syncedCountsByUserId = mutableMapOf() + + command.items.forEach { item -> + val savedFilm = + upsertFilm( + JellyfinLibraryItemSnapshot( + jellyfinItemId = item.jellyfinItemId, + title = item.title, + description = item.description ?: item.originalTitle ?: "", + contentType = ContentType.FILM, + releaseYear = item.year, + genres = item.genres, + cast = emptyList(), + directors = emptyList(), + platformRating = null, + imdbRating = null, + externalUrl = item.imdbId?.let { "https://www.imdb.com/title/$it/" }, + jellyfinLibraryId = item.jellyfinLibraryId, + isPlayed = false, + ), + ) + + item.userStates.forEach { state -> + val stateUserId = normalizeJellyfinId(state.jellyfinUserId) + val localUser = localUsersByJellyfinId[stateUserId] + if (localUser == null) { + skippedUserIds += stateUserId + return@forEach + } + + syncedCountsByUserId[localUser.id] = syncedCountsByUserId.getOrDefault(localUser.id, 0) + 1 + if (state.isViewed || state.playCount > 0) { + markFilmViewed( + userId = localUser.id, + filmId = savedFilm.id, + watchedAt = state.lastPlayedAt?.toLocalDateTime() ?: LocalDateTime.now(), + ) + } + } + } + + val now = LocalDateTime.now() + syncedCountsByUserId.forEach { (userId, itemCount) -> + syncStateRepository.save( + JellyfinSyncState( + userId = userId, + lastSyncedAt = now, + lastSuccessfulSyncAt = now, + lastError = null, + syncedItemCount = itemCount, + ), + ) + } + + val summary = + JellyfinSyncSummary( + syncedUsers = syncedCountsByUserId.size, + skippedUsers = skippedUserIds.size, + syncedItems = command.items.size, + durationMs = Duration.between(startedAt, Instant.now()).toMillis(), + ) + businessMetricsService.recordJellyfinSync(summary) + return summary + } + + private fun upsertPluginUsers(command: IngestJellyfinSyncCommand) { + command.users.forEach { remoteUser -> + val jellyfinUserId = + remoteUser.jellyfinUserId + .takeIf { it.isNotBlank() } + ?.let(::normalizeJellyfinId) + ?: return@forEach + if (userRepository.findByJellyfinUserId(jellyfinUserId) != null) { + return@forEach + } + + userRepository.save( + User( + id = idGenerator.generateId(), + name = remoteUser.name?.takeIf { it.isNotBlank() } ?: "Jellyfin User", + email = syntheticJellyfinEmail(jellyfinUserId), + jellyfinUserId = jellyfinUserId, + ), + ) + } + } + + private fun syntheticJellyfinEmail(jellyfinUserId: String): String { + val safeId = + jellyfinUserId + .lowercase(Locale.getDefault()) + .replace(Regex("[^a-z0-9._%+-]"), "-") + .take(240) + return "jellyfin-$safeId@movienight.local" + } + + private fun normalizeJellyfinId(value: String): String = + runCatching { UUID.fromString(value).toString().replace("-", "") } + .getOrDefault(value) +} diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt new file mode 100644 index 0000000..ee02a92 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt @@ -0,0 +1,150 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase +import com.project.movienight.application.ports.input.RecommendationOnboardingResult +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.FilmLibraryEntry +import com.project.movienight.domain.model.FilmRating +import com.project.movienight.domain.model.UserPreferences +import com.project.movienight.domain.model.UserRecommendationWeights +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.util.UUID + +@Service +class RecommendationOnboardingService( + private val userRepository: UserRepositoryPort, + private val filmRepository: FilmRepositoryPort, + private val userPreferencesRepository: UserPreferencesRepositoryPort, + private val filmRatingRepository: FilmRatingRepositoryPort, + private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, + private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort, + private val idGenerator: IdGenerator, +) : CompleteRecommendationOnboardingUseCase { + override fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult { + userRepository.findById(command.userId) + ?: throw EntityNotFoundException(entity = "User", id = command.userId.toString()) + + val filmIds = + ( + command.likedFilmIds + + command.dislikedFilmIds + + command.libraryFilmIds + + command.watchedFilmIds + ).distinct() + ensureFilmsExist(filmIds) + + val preferences = + userPreferencesRepository.save( + UserPreferences( + userId = command.userId, + weightedGenres = command.weightedGenres, + plotTypes = command.plotTypes, + eras = command.eras, + castAndDirectors = command.castAndDirectors, + moods = command.moods, + contentTypes = command.contentTypes, + ), + ) + + command.likedFilmIds.distinct().forEach { filmId -> + saveRating(userId = command.userId, filmId = filmId, score = LIKED_SCORE, note = ONBOARDING_LIKED_NOTE) + } + command.dislikedFilmIds.distinct().forEach { filmId -> + saveRating( + userId = command.userId, + filmId = filmId, + score = DISLIKED_SCORE, + note = ONBOARDING_DISLIKED_NOTE, + ) + } + command.libraryFilmIds.distinct().forEach { filmId -> + saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = false) + } + command.watchedFilmIds.distinct().forEach { filmId -> + saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = true) + } + + val weights = + userRecommendationWeightsRepository.save( + UserRecommendationWeights.forStyle( + userId = command.userId, + style = command.recommendationStyle, + ), + ) + + return RecommendationOnboardingResult( + userId = command.userId, + preferences = preferences, + weights = weights, + likedFilmsCount = command.likedFilmIds.distinct().size, + dislikedFilmsCount = command.dislikedFilmIds.distinct().size, + libraryFilmsCount = command.libraryFilmIds.distinct().size, + watchedFilmsCount = command.watchedFilmIds.distinct().size, + ) + } + + private fun ensureFilmsExist(filmIds: List) { + filmIds.forEach { filmId -> + filmRepository.findById(filmId) + ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString()) + } + } + + private fun saveRating( + userId: UUID, + filmId: UUID, + score: Int, + note: String, + ): FilmRating { + val now = LocalDateTime.now() + val existing = filmRatingRepository.findByUserIdAndFilmId(userId, filmId) + return filmRatingRepository.save( + existing?.copy(score = score, note = note, updatedAt = now) + ?: FilmRating( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + score = score, + note = note, + createdAt = now, + updatedAt = now, + ), + ) + } + + private fun saveLibraryEntry( + userId: UUID, + filmId: UUID, + isViewed: Boolean, + ): FilmLibraryEntry { + val watchedAt = LocalDateTime.now().takeIf { isViewed } + val existing = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) + return filmLibraryEntryRepository.save( + existing?.copy(isViewed = isViewed, watchedAt = watchedAt) + ?: FilmLibraryEntry( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + comment = null, + isViewed = isViewed, + watchedAt = watchedAt, + ), + ) + } + + private companion object { + private const val LIKED_SCORE = 10 + private const val DISLIKED_SCORE = 2 + private const val ONBOARDING_LIKED_NOTE = "Onboarding liked" + private const val ONBOARDING_DISLIKED_NOTE = "Onboarding disliked" + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt new file mode 100644 index 0000000..f0eeb12 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -0,0 +1,862 @@ +package com.project.movienight.application.services + +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.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibraryEntry +import com.project.movienight.domain.model.FilmRating +import com.project.movienight.domain.model.RecommendationEvent +import com.project.movienight.domain.model.RecommendationEventType +import com.project.movienight.domain.model.RecommendationResult +import com.project.movienight.domain.model.UserPreferences +import com.project.movienight.domain.model.UserRecommendationWeights +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.util.Locale +import java.util.UUID +import kotlin.math.sqrt + +@Service +class RecommendationService( + private val filmRepository: FilmRepositoryPort, + private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, + private val filmRatingRepository: FilmRatingRepositoryPort, + private val userPreferencesRepository: UserPreferencesRepositoryPort, + private val userRepository: UserRepositoryPort, + private val recommendationEventRepository: RecommendationEventRepositoryPort, + private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort, + private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsPort, +) : GetRecommendationsUseCase, + AcceptRecommendationUseCase, + RejectRecommendationUseCase { + private val log = LoggerFactory.getLogger(javaClass) + + override fun recommend(query: RecommendationQuery): List { + businessMetricsService.recordRecommendationRequest() + userRepository.findById(query.userId) + ?: throw EntityNotFoundException(entity = "User", id = query.userId.toString()) + + val preferences = userPreferencesRepository.findByUserId(query.userId) + val ratings = filmRatingRepository.findByUserId(query.userId) + val libraryEntries = filmLibraryEntryRepository.findByUserId(query.userId) + val libraryFilmIds = libraryEntries.map { it.filmId }.toSet() + val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet() + val films = filmRepository.findAll() + val filmsById = films.associateBy { it.id } + val weights = findWeights(query.userId) + val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById, weights) + + val candidates = + films + .asSequence() + .filter { film -> query.contentType == null || film.contentType == query.contentType } + .filter { film -> film.id !in watchedFilmIds } + .filter { film -> !query.libraryOnly || film.id in libraryFilmIds } + .toList() + val scoredCandidates = + candidates.map { film -> + scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds, weights) + } + val recommendationComparator = + compareByDescending { it.result.score }.thenBy { + it.result.film.title + } + val scoredRecommendations = + scoredCandidates + .sortedWith(recommendationComparator) + .take(query.limit.coerceAtLeast(1)) + + scoredRecommendations.forEach { recommendation -> + saveEvent( + userId = query.userId, + filmId = recommendation.result.film.id, + eventType = RecommendationEventType.RECOMMENDED, + score = recommendation.result.score, + relevanceScore = recommendation.relevanceScore, + qualityScore = recommendation.qualityScore, + contextScore = recommendation.contextScore, + noveltyScore = recommendation.noveltyScore, + diversityScore = recommendation.diversityScore, + ) + } + + log.info( + RECOMMENDATION_COMPLETED_LOG, + query.userId, + query.contentType, + !query.mood.isNullOrBlank(), + query.libraryOnly, + query.limit, + candidates.size, + scoredRecommendations.size, + ) + if (log.isDebugEnabled) { + log.debug( + "Recommendation top results: userId='{}', results='{}'", + query.userId, + scoredRecommendations.joinToString(separator = ",") { "${it.result.film.id}:${it.result.score}" }, + ) + } + + return scoredRecommendations.map { it.result } + } + + override fun accept(command: AcceptRecommendationCommand): RecommendationEvent = + saveFeedbackEvent( + userId = command.userId, + filmId = command.filmId, + eventType = RecommendationEventType.ACCEPTED, + ) + + override fun reject(command: RejectRecommendationCommand): RecommendationEvent = + saveFeedbackEvent( + userId = command.userId, + filmId = command.filmId, + eventType = RecommendationEventType.REJECTED, + ) + + private fun saveFeedbackEvent( + userId: UUID, + filmId: UUID, + eventType: RecommendationEventType, + ): RecommendationEvent { + userRepository.findById(userId) + ?: throw EntityNotFoundException(entity = "User", id = userId.toString()) + filmRepository.findById(filmId) + ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString()) + + val lastRecommendation = recommendationEventRepository.findLatestRecommended(userId, filmId) + val event = + saveEvent( + userId = userId, + filmId = filmId, + eventType = eventType, + score = lastRecommendation?.score, + relevanceScore = lastRecommendation?.relevanceScore, + qualityScore = lastRecommendation?.qualityScore, + contextScore = lastRecommendation?.contextScore, + noveltyScore = lastRecommendation?.noveltyScore, + diversityScore = lastRecommendation?.diversityScore, + ) + + if (lastRecommendation != null) { + updateRecommendationWeights( + userId = userId, + eventType = eventType, + recommendation = lastRecommendation, + ) + } else { + log.info( + "Recommendation feedback saved without weight update: userId='{}', filmId='{}', eventType='{}'", + userId, + filmId, + eventType, + ) + } + + log.info( + RECOMMENDATION_FEEDBACK_SAVED_LOG, + userId, + filmId, + eventType, + ) + + return event + } + + private fun saveEvent( + userId: UUID, + filmId: UUID, + eventType: RecommendationEventType, + score: Double?, + relevanceScore: Double? = null, + qualityScore: Double? = null, + contextScore: Double? = null, + noveltyScore: Double? = null, + diversityScore: Double? = null, + ): RecommendationEvent = + recommendationEventRepository.save( + RecommendationEvent( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + eventType = eventType, + score = score, + relevanceScore = relevanceScore, + qualityScore = qualityScore, + contextScore = contextScore, + noveltyScore = noveltyScore, + diversityScore = diversityScore, + createdAt = LocalDateTime.now(), + ), + ) + + private fun findWeights(userId: UUID): UserRecommendationWeights = + ( + userRecommendationWeightsRepository.findByUserId(userId) + ?: UserRecommendationWeights.defaultFor(userId) + ).normalized() + + private fun updateRecommendationWeights( + userId: UUID, + eventType: RecommendationEventType, + recommendation: RecommendationEvent, + ) { + val current = findWeights(userId) + val contributions = scoreContributions(recommendation, current) ?: return + val direction = + when (eventType) { + RecommendationEventType.ACCEPTED -> 1.0 + RecommendationEventType.REJECTED -> -1.0 + RecommendationEventType.RECOMMENDED -> return + } + + val updated = + current + .copy( + relevanceWeight = current.relevanceWeight + direction * LEARNING_RATE * contributions.relevance, + qualityWeight = current.qualityWeight + direction * LEARNING_RATE * contributions.quality, + contextWeight = current.contextWeight + direction * LEARNING_RATE * contributions.context, + noveltyWeight = current.noveltyWeight + direction * LEARNING_RATE * contributions.novelty, + diversityWeight = current.diversityWeight + direction * LEARNING_RATE * contributions.diversity, + ).normalized(updatedAt = LocalDateTime.now()) + + val saved = userRecommendationWeightsRepository.save(updated) + businessMetricsService.recordRecommendationWeightsUpdated(eventType) + log.info( + RECOMMENDATION_WEIGHTS_UPDATED_LOG, + userId, + eventType, + current.hashCode(), + saved.hashCode(), + ) + } + + private fun scoreContributions( + recommendation: RecommendationEvent, + weights: UserRecommendationWeights, + ): ScoreContributions? { + val rawContributions = + listOf( + weights.relevanceWeight to recommendation.relevanceScore, + weights.qualityWeight to recommendation.qualityScore, + weights.contextWeight to recommendation.contextScore, + weights.noveltyWeight to recommendation.noveltyScore, + weights.diversityWeight to recommendation.diversityScore, + ).map { (weight, score) -> + weight * (score?.takeIf { value -> value.isFinite() }?.coerceAtLeast(0.0) ?: 0.0) + } + val total = rawContributions.sum() + if (total <= 0.0) { + return null + } + return ScoreContributions( + relevance = rawContributions[0] / total, + quality = rawContributions[1] / total, + context = rawContributions[2] / total, + novelty = rawContributions[3] / total, + diversity = rawContributions[4] / total, + ) + } + + private fun buildUserProfile( + preferences: UserPreferences?, + ratings: List, + libraryEntries: List, + filmsById: Map, + weights: UserRecommendationWeights, + ): UserTasteProfile { + val preferenceProfile = MutableSparseVector() + val positiveChoiceProfile = MutableSparseVector() + val negativeChoiceProfile = MutableSparseVector() + val libraryProfile = MutableSparseVector() + + preferences?.weightedGenres.orEmpty().forEach { (genre, weight) -> + preferenceProfile.add(feature("genre", genre), weight.coerceAtLeast(1).toDouble() / MAX_PREFERENCE_WEIGHT) + } + preferences?.plotTypes.orEmpty().forEach { plotType -> + tokenize(plotType).forEach { preferenceProfile.add(feature("plot", it), PREFERENCE_PLOT_WEIGHT) } + } + preferences?.eras.orEmpty().forEach { preferenceProfile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) } + preferences?.castAndDirectors.orEmpty().forEach { + preferenceProfile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) + } + preferences?.moods.orEmpty().forEach { preferenceProfile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) } + preferences + ?.contentTypes + .orEmpty() + .forEach { + preferenceProfile.add( + feature("type", it.name), + PREFERENCE_CONTENT_TYPE_WEIGHT, + ) + } + + ratings.forEach { rating -> + val film = filmsById[rating.filmId] ?: return@forEach + val signal = ratingSignal(rating.score) + val filmVector = buildFilmVector(film, weights) + when { + signal >= POSITIVE_CHOICE_SIGNAL_THRESHOLD -> positiveChoiceProfile.add(filmVector.scale(signal)) + signal <= NEGATIVE_CHOICE_SIGNAL_THRESHOLD -> negativeChoiceProfile.add(filmVector.scale(-signal)) + } + } + + libraryEntries.filterNot { it.isViewed }.forEach { entry -> + val film = filmsById[entry.filmId] ?: return@forEach + libraryProfile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT)) + } + + val overallProfile = MutableSparseVector() + overallProfile.add(preferenceProfile.toSparseVector()) + overallProfile.add(positiveChoiceProfile.toSparseVector().scale(EXPLICIT_CHOICE_PROFILE_WEIGHT)) + overallProfile.add(negativeChoiceProfile.toSparseVector().scale(-EXPLICIT_CHOICE_PROFILE_WEIGHT)) + overallProfile.add(libraryProfile.toSparseVector()) + + return UserTasteProfile( + overall = overallProfile.toSparseVector(), + preferences = preferenceProfile.toSparseVector(), + positiveChoices = positiveChoiceProfile.toSparseVector(), + negativeChoices = negativeChoiceProfile.toSparseVector(), + library = libraryProfile.toSparseVector(), + ) + } + + private fun scoreFilm( + film: Film, + query: RecommendationQuery, + preferences: UserPreferences?, + userProfile: UserTasteProfile, + inLibrary: Boolean, + weights: UserRecommendationWeights, + ): ScoredRecommendation { + val reasons = mutableListOf() + val filmVector = buildFilmVector(film, weights) + val relevanceBreakdown = relevanceScore(userProfile, filmVector) + val preferenceScore = relevanceBreakdown.combined + val qualityScore = qualityScore(film) + val contextScore = contextScore(film, query, preferences, userProfile, preferenceScore) + val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE + val diversityScore = diversityScore(film, preferences) + val rawScore = + weights.relevanceWeight * preferenceScore + + weights.qualityWeight * qualityScore + + weights.contextWeight * contextScore + + weights.noveltyWeight * noveltyScore + + weights.diversityWeight * diversityScore + val score = rawScore - explicitChoiceMisfitPenalty(userProfile, relevanceBreakdown) + + if (relevanceBreakdown.positiveSimilarity > EXPLICIT_CHOICE_REASON_THRESHOLD) { + reasons += "Similar to films you rated highly" + } else if (preferenceScore > STRONG_REASON_THRESHOLD) { + reasons += "Similar to user preferences and rating history" + } + matchingPositiveTasteTags(film, userProfile).take(MAX_REASON_ITEMS).forEach { tag -> + reasons += "Shares taste signal: ${tag.toReasonLabel()}" + } + matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre -> + reasons += "Matches preferred genre: $genre" + } + matchingPeople(film, preferences).take(MAX_REASON_ITEMS).forEach { person -> + reasons += "Matches preferred cast or director: $person" + } + query.mood?.takeIf { inferredMoods(film).contains(normalize(it)) }?.let { mood -> + reasons += "Matches requested mood: $mood" + } + film.releaseYear?.let { year -> + if (preferences?.eras.orEmpty().any { normalize(it) == normalize(decadeOf(year)) }) { + reasons += "Matches preferred era: ${decadeOf(year)}" + } + } + if (qualityScore >= QUALITY_REASON_THRESHOLD) { + reasons += "High rating signal" + } + if (inLibrary) { + reasons += "Already in user library" + } + + if (reasons.isEmpty()) { + reasons += "Baseline recommendation from catalog quality" + } + + return ScoredRecommendation( + result = RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()), + relevanceScore = preferenceScore, + qualityScore = qualityScore, + contextScore = contextScore, + noveltyScore = noveltyScore, + diversityScore = diversityScore, + ) + } + + private fun buildFilmVector( + film: Film, + weights: UserRecommendationWeights, + ): SparseVector { + val vector = MutableSparseVector() + val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() } + val plotTokens = tokenize("${film.title} ${film.description}") + val moods = inferredMoods(film) + val semanticTags = semanticTags(film) + val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() } + + vector.add(feature("type", film.contentType.name), weights.contentTypeVectorWeight) + distribute(vector, "genre", normalizedGenres, weights.genreVectorWeight) + distribute(vector, "plot", plotTokens, weights.plotVectorWeight) + distribute(vector, "tag", semanticTags, weights.plotVectorWeight * SEMANTIC_TAG_VECTOR_WEIGHT_MULTIPLIER) + distribute(vector, "mood", moods, weights.moodVectorWeight) + film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) } + distribute(vector, "person", people, weights.peopleVectorWeight) + + return vector.toSparseVector() + } + + private fun relevanceScore( + userProfile: UserTasteProfile, + filmVector: SparseVector, + ): RelevanceBreakdown { + val overallSimilarity = cosineSimilarity(userProfile.overall, filmVector) + val preferenceSimilarity = cosineSimilarity(userProfile.preferences, filmVector) + val positiveSimilarity = cosineSimilarity(userProfile.positiveChoices, filmVector).coerceAtLeast(0.0) + val negativeSimilarity = cosineSimilarity(userProfile.negativeChoices, filmVector).coerceAtLeast(0.0) + val librarySimilarity = cosineSimilarity(userProfile.library, filmVector).coerceAtLeast(0.0) + + if (!userProfile.hasExplicitChoices) { + return RelevanceBreakdown( + combined = overallSimilarity, + positiveSimilarity = positiveSimilarity, + ) + } + + val positiveComponent = + if (userProfile.hasPositiveChoices) { + positiveSimilarity * POSITIVE_CHOICE_RELEVANCE_WEIGHT + } else { + 0.0 + } + val preferenceComponent = preferenceSimilarity.coerceAtLeast(0.0) * BROAD_PREFERENCE_RELEVANCE_WEIGHT + val libraryComponent = + if (userProfile.hasLibraryChoices) { + librarySimilarity * LIBRARY_CHOICE_RELEVANCE_WEIGHT + } else { + 0.0 + } + val fallbackComponent = overallSimilarity.coerceAtLeast(0.0) * OVERALL_RELEVANCE_FALLBACK_WEIGHT + val negativePenalty = + if (userProfile.hasNegativeChoices) { + negativeSimilarity * NEGATIVE_CHOICE_RELEVANCE_PENALTY + } else { + 0.0 + } + + return RelevanceBreakdown( + combined = + (positiveComponent + preferenceComponent + libraryComponent + fallbackComponent - negativePenalty) + .coerceIn(MIN_RELEVANCE_SCORE, MAX_RELEVANCE_SCORE), + positiveSimilarity = positiveSimilarity, + ) + } + + private fun explicitChoiceMisfitPenalty( + userProfile: UserTasteProfile, + relevanceBreakdown: RelevanceBreakdown, + ): Double { + if (!userProfile.hasPositiveChoices) { + return 0.0 + } + val fit = relevanceBreakdown.positiveSimilarity + if (fit >= POSITIVE_CHOICE_SOFT_FIT_THRESHOLD) { + return 0.0 + } + val missingFitRatio = + ((POSITIVE_CHOICE_SOFT_FIT_THRESHOLD - fit) / POSITIVE_CHOICE_SOFT_FIT_THRESHOLD) + .coerceIn(0.0, 1.0) + return EXPLICIT_CHOICE_MISFIT_MAX_PENALTY * missingFitRatio + } + + private fun contextScore( + film: Film, + query: RecommendationQuery, + preferences: UserPreferences?, + userProfile: UserTasteProfile, + relevanceScore: Double, + ): Double { + var score = 0.0 + var checks = 0 + + query.mood?.let { + checks += 1 + if (inferredMoods(film).contains(normalize(it))) { + score += 1.0 + } + } + preferences?.contentTypes?.takeIf { it.isNotEmpty() }?.let { + checks += 1 + if (film.contentType in it) { + score += 1.0 + } + } + preferences?.eras?.takeIf { it.isNotEmpty() }?.let { eras -> + film.releaseYear?.let { + checks += 1 + if (eras.any { era -> normalize(era) == normalize(decadeOf(it)) }) { + score += 1.0 + } + } + } + + val baseScore = if (checks == 0) BASE_CONTEXT_SCORE else score / checks + if (!userProfile.hasExplicitChoices) { + return baseScore + } + + val relevanceGate = + MIN_CONTEXT_RELEVANCE_GATE + + (MAX_CONTEXT_RELEVANCE_GATE - MIN_CONTEXT_RELEVANCE_GATE) * + relevanceScore.coerceIn(0.0, 1.0) + return baseScore * relevanceGate + } + + private fun qualityScore(film: Film): Double { + val normalizedRatings = + listOfNotNull( + film.imdbRating?.let { normalizeRating(it) }, + film.platformRating?.let { normalizeRating(it) }, + ) + return normalizedRatings.averageOrNull() ?: UNKNOWN_QUALITY_SCORE + } + + private fun diversityScore( + film: Film, + preferences: UserPreferences?, + ): Double { + val preferredGenres = + preferences + ?.weightedGenres + .orEmpty() + .keys + .map(::normalize) + .toSet() + val filmGenres = film.genres.map(::normalize).toSet() + return when { + preferredGenres.isEmpty() -> BASE_DIVERSITY_SCORE + filmGenres.none { it in preferredGenres } -> LOW_DIVERSITY_SCORE + filmGenres.size > 1 -> HIGH_DIVERSITY_SCORE + else -> MEDIUM_DIVERSITY_SCORE + } + } + + private fun inferredMoods(film: Film): Set { + val text = normalize("${film.title} ${film.description} ${film.genres.joinToString(" ")}") + return moodLexicon + .filterValues { keywords -> keywords.any { keyword -> text.contains(keyword) } } + .keys + } + + private fun semanticTags(film: Film): Set { + val text = normalize("${film.title} ${film.description} ${film.genres.joinToString(" ")}") + return semanticTagLexicon + .filterValues { keywords -> keywords.any { keyword -> text.contains(keyword) } } + .keys + } + + private fun matchingPositiveTasteTags( + film: Film, + userProfile: UserTasteProfile, + ): List { + if (!userProfile.hasPositiveChoices) { + return emptyList() + } + return semanticTags(film) + .filter { tag -> userProfile.positiveChoices.values.containsKey(feature("tag", tag)) } + .sorted() + } + + private fun matchingGenres( + film: Film, + preferences: UserPreferences?, + ): List { + val filmGenres = film.genres.associateBy { normalize(it) } + return preferences + ?.weightedGenres + .orEmpty() + .keys + .map(::normalize) + .mapNotNull { filmGenres[it] } + } + + private fun matchingPeople( + film: Film, + preferences: UserPreferences?, + ): List { + val people = (film.cast + film.directors).associateBy { normalize(it) } + return preferences + ?.castAndDirectors + .orEmpty() + .map(::normalize) + .mapNotNull { people[it] } + } + + private fun distribute( + vector: MutableSparseVector, + namespace: String, + values: Collection, + totalWeight: Double, + ) { + val uniqueValues = values.map(::normalize).filter { it.isNotBlank() }.distinct() + if (uniqueValues.isEmpty()) { + return + } + val itemWeight = totalWeight / uniqueValues.size + uniqueValues.forEach { vector.add(feature(namespace, it), itemWeight) } + } + + private fun ratingSignal(score: Int): Double = + when (score.coerceIn(MIN_USER_RATING, MAX_USER_RATING)) { + 10 -> 1.0 + 9 -> 0.9 + 8 -> 0.7 + 7 -> 0.4 + 6 -> 0.1 + 5 -> 0.0 + 4 -> -0.3 + 3 -> -0.5 + 2 -> -0.8 + else -> -1.0 + } + + private fun normalizeRating(rating: Double): Double = (rating / MAX_RATING_VALUE).coerceIn(0.0, 1.0) + + private fun decadeOf(year: Int): String = "${year / 10 * 10}s" + + private fun tokenize(text: String): List = + normalize(text) + .split(tokenSeparatorRegex) + .asSequence() + .filter { it.length >= MIN_TOKEN_LENGTH } + .filterNot { it in stopWords } + .distinct() + .toList() + + private fun feature( + namespace: String, + value: String, + ): String = "$namespace:${normalize(value)}" + + private fun normalize(value: String): String = + value + .trim() + .lowercase(Locale.getDefault()) + + private fun String.toReasonLabel(): String = + split("-") + .joinToString(" ") { token -> token.replaceFirstChar { char -> char.titlecase(Locale.getDefault()) } } + + private fun cosineSimilarity( + left: SparseVector, + right: SparseVector, + ): Double { + if (left.values.isEmpty() || right.values.isEmpty()) { + return 0.0 + } + + val dot = + left.values + .entries + .sumOf { (feature, weight) -> weight * (right.values[feature] ?: 0.0) } + val leftNorm = sqrt(left.values.values.sumOf { it * it }) + val rightNorm = sqrt(right.values.values.sumOf { it * it }) + if (leftNorm == 0.0 || rightNorm == 0.0) { + return 0.0 + } + + return dot / (leftNorm * rightNorm) + } + + private fun roundScore(score: Double): Double = + kotlin.math.round(score * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR + + private fun Iterable.averageOrNull(): Double? { + val values = toList() + return values.takeIf { it.isNotEmpty() }?.average() + } + + private data class ScoredRecommendation( + val result: RecommendationResult, + val relevanceScore: Double, + val qualityScore: Double, + val contextScore: Double, + val noveltyScore: Double, + val diversityScore: Double, + ) + + private data class RelevanceBreakdown( + val combined: Double, + val positiveSimilarity: Double, + ) + + private data class UserTasteProfile( + val overall: SparseVector, + val preferences: SparseVector, + val positiveChoices: SparseVector, + val negativeChoices: SparseVector, + val library: SparseVector, + ) { + val hasPositiveChoices: Boolean = positiveChoices.values.isNotEmpty() + val hasNegativeChoices: Boolean = negativeChoices.values.isNotEmpty() + val hasLibraryChoices: Boolean = library.values.isNotEmpty() + val hasExplicitChoices: Boolean = hasPositiveChoices || hasNegativeChoices || hasLibraryChoices + } + + private data class ScoreContributions( + val relevance: Double, + val quality: Double, + val context: Double, + val novelty: Double, + val diversity: Double, + ) + + private data class SparseVector( + val values: Map, + ) { + fun scale(weight: Double): SparseVector = SparseVector(values.mapValues { it.value * weight }) + } + + private class MutableSparseVector { + private val values = mutableMapOf() + + fun add( + feature: String, + weight: Double, + ) { + if (weight == 0.0) { + return + } + values[feature] = (values[feature] ?: 0.0) + weight + } + + fun add(vector: SparseVector) { + vector.values.forEach { (feature, weight) -> add(feature, weight) } + } + + fun toSparseVector(): SparseVector = SparseVector(values.filterValues { it != 0.0 }) + } + + private companion object { + private const val RECOMMENDATION_COMPLETED_LOG = + "Recommendation request completed: userId='{}', contentType='{}', moodPresent={}, " + + "libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}" + private const val RECOMMENDATION_FEEDBACK_SAVED_LOG = + "Recommendation feedback saved: userId='{}', filmId='{}', eventType='{}'" + private const val RECOMMENDATION_WEIGHTS_UPDATED_LOG = + "Recommendation weights updated: userId='{}', eventType='{}', oldWeightsHash={}, newWeightsHash={}" + + private const val MAX_PREFERENCE_WEIGHT = 5.0 + private const val MAX_RATING_VALUE = 10.0 + private const val MIN_USER_RATING = 1 + private const val MAX_USER_RATING = 10 + private const val MIN_TOKEN_LENGTH = 3 + private const val MAX_REASON_ITEMS = 2 + private const val SCORE_ROUNDING_FACTOR = 1000.0 + + private const val PREFERENCE_PLOT_WEIGHT = 0.6 + private const val PREFERENCE_ERA_WEIGHT = 0.7 + private const val PREFERENCE_PERSON_WEIGHT = 0.8 + private const val PREFERENCE_MOOD_WEIGHT = 0.8 + private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5 + private const val LIBRARY_SIGNAL_WEIGHT = 0.25 + private const val EXPLICIT_CHOICE_PROFILE_WEIGHT = 1.8 + private const val SEMANTIC_TAG_VECTOR_WEIGHT_MULTIPLIER = 0.9 + + private const val LEARNING_RATE = 0.03 + + private const val LIBRARY_NOVELTY_SCORE = 0.85 + private const val CATALOG_NOVELTY_SCORE = 0.65 + private const val BASE_CONTEXT_SCORE = 0.5 + private const val UNKNOWN_QUALITY_SCORE = 0.42 + private const val BASE_DIVERSITY_SCORE = 0.5 + private const val HIGH_DIVERSITY_SCORE = 0.75 + private const val MEDIUM_DIVERSITY_SCORE = 0.45 + private const val LOW_DIVERSITY_SCORE = 0.15 + private const val STRONG_REASON_THRESHOLD = 0.15 + private const val EXPLICIT_CHOICE_REASON_THRESHOLD = 0.12 + private const val QUALITY_REASON_THRESHOLD = 0.75 + private const val POSITIVE_CHOICE_SIGNAL_THRESHOLD = 0.4 + private const val NEGATIVE_CHOICE_SIGNAL_THRESHOLD = -0.3 + private const val POSITIVE_CHOICE_RELEVANCE_WEIGHT = 0.78 + private const val BROAD_PREFERENCE_RELEVANCE_WEIGHT = 0.12 + private const val LIBRARY_CHOICE_RELEVANCE_WEIGHT = 0.08 + private const val OVERALL_RELEVANCE_FALLBACK_WEIGHT = 0.08 + private const val NEGATIVE_CHOICE_RELEVANCE_PENALTY = 0.65 + private const val MIN_RELEVANCE_SCORE = -1.0 + private const val MAX_RELEVANCE_SCORE = 1.0 + private const val MIN_CONTEXT_RELEVANCE_GATE = 0.35 + private const val MAX_CONTEXT_RELEVANCE_GATE = 1.0 + private const val POSITIVE_CHOICE_SOFT_FIT_THRESHOLD = 0.10 + private const val EXPLICIT_CHOICE_MISFIT_MAX_PENALTY = 0.12 + + private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+") + private val stopWords = + setOf( + "and", + "the", + "for", + "with", + "about", + "into", + "from", + ) + private val moodLexicon = + mapOf( + "tense" to listOf("thriller", "suspense", "tension", "rescue", "crime"), + "slow-burn" to listOf("slow", "meditative", "grounded"), + "feel-good" to listOf("comedy", "family", "summer", "kind", "warm"), + "dark" to listOf("dark", "noir", "horror", "murder", "crime"), + "romantic" to listOf("romance", "love", "relationship"), + "focused" to listOf("science", "mission", "detective", "investigation", "sci-fi"), + ) + private val semanticTagLexicon = + mapOf( + "magic-fantasy" to + listOf( + "magic", + "magical", + "wizard", + "witch", + "spell", + "sorcer", + "fantasy", + "enchanted", + "dragon", + ), + "wizard-school" to listOf("wizard school", "magic school", "academy", "school of magic"), + "young-adult" to listOf("young", "teen", "teenage", "teenager", "student", "coming of age"), + "family-adventure" to listOf("family", "friendship", "friends", "adventure", "quest"), + "quest-adventure" to listOf("quest", "journey", "treasure", "relic", "map", "kingdom"), + "heist-crime" to listOf("heist", "thief", "robbery", "criminal", "crime", "gang"), + "space-opera" to listOf("space", "spaceship", "galaxy", "planet", "alien", "starship"), + "superhero" to listOf("superhero", "hero", "masked", "powers", "mutant"), + "martial-arts" to listOf("martial", "kung fu", "samurai", "ninja", "warrior", "sword"), + "war-epic" to listOf("war", "battle", "army", "soldier", "general", "rebel"), + "mystery-investigation" to listOf("mystery", "detective", "investigation", "secret", "clue"), + "dark-fantasy" to listOf("dark force", "curse", "underworld", "demon", "monster"), + "animated-anime" to listOf("animation", "animated", "anime"), + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt new file mode 100644 index 0000000..02fb178 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt @@ -0,0 +1,27 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand +import com.project.movienight.application.ports.input.UserPreferencesUseCase +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.domain.model.UserPreferences +import org.springframework.stereotype.Service + +@Service +class UserPreferencesService( + private val userPreferencesRepository: UserPreferencesRepositoryPort, +) : UserPreferencesUseCase { + override fun upsert(command: UpsertUserPreferencesCommand): UserPreferences = + userPreferencesRepository.save( + UserPreferences( + userId = command.userId, + weightedGenres = command.weightedGenres, + plotTypes = command.plotTypes, + eras = command.eras, + castAndDirectors = command.castAndDirectors, + moods = command.moods, + contentTypes = command.contentTypes, + ), + ) + + override fun get(userId: java.util.UUID): UserPreferences? = userPreferencesRepository.findByUserId(userId) +} diff --git a/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt b/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt new file mode 100644 index 0000000..fc30d72 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt @@ -0,0 +1,51 @@ +package com.project.movienight.application.services + +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 com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.UserRecommendationWeights +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class UserRecommendationWeightsService( + private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort, + private val userRepository: UserRepositoryPort, +) : GetUserRecommendationWeightsUseCase, + UpdateUserRecommendationWeightsUseCase { + override fun get(userId: UUID): UserRecommendationWeights { + ensureUserExists(userId) + return ( + userRecommendationWeightsRepository.findByUserId(userId) + ?: UserRecommendationWeights.defaultFor(userId) + ).normalized() + } + + override fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights { + ensureUserExists(command.userId) + return userRecommendationWeightsRepository.save( + UserRecommendationWeights( + userId = command.userId, + relevanceWeight = command.relevanceWeight, + qualityWeight = command.qualityWeight, + contextWeight = command.contextWeight, + noveltyWeight = command.noveltyWeight, + diversityWeight = command.diversityWeight, + genreVectorWeight = command.genreVectorWeight, + plotVectorWeight = command.plotVectorWeight, + moodVectorWeight = command.moodVectorWeight, + eraVectorWeight = command.eraVectorWeight, + peopleVectorWeight = command.peopleVectorWeight, + contentTypeVectorWeight = command.contentTypeVectorWeight, + ), + ) + } + + private fun ensureUserExists(userId: UUID) { + userRepository.findById(userId) + ?: throw EntityNotFoundException(entity = "User", id = userId.toString()) + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index be32d6a..dc0e1e0 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -1,10 +1,8 @@ package com.project.movienight.application.services import com.project.movienight.application.ports.input.CreateUserCommand -import com.project.movienight.application.ports.input.CreateUserUseCase -import com.project.movienight.application.ports.input.DeleteUserUseCase import com.project.movienight.application.ports.input.EditUserCommand -import com.project.movienight.application.ports.input.EditUserUseCase +import com.project.movienight.application.ports.input.UserUseCase import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.config.UserServiceProperties @@ -19,9 +17,7 @@ class UserService( private val userRepository: UserRepositoryPort, private val idGenerator: IdGenerator, private val userConfig: UserServiceProperties, -) : CreateUserUseCase, - EditUserUseCase, - DeleteUserUseCase { +) : UserUseCase { override fun create(command: CreateUserCommand): User { if (userConfig.isBlocked(command.name)) { throw BlockedValueException(target = "User", field = "name") @@ -32,7 +28,7 @@ class UserService( id = idGenerator.generateId(), name = command.name, email = command.email, - library = null, + jellyfinUserId = null, ) return userRepository.save(user) } @@ -47,14 +43,22 @@ class UserService( var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - user = user.copy(name = command.name) + user = + user.copy( + name = command.name, + jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, + ) return userRepository.save(user) } override fun delete(id: UUID) { userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - userRepository.deleteById(id) } + + override fun getById(id: UUID): User = + userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + + override fun getAll(): List = userRepository.findAll() } diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt new file mode 100644 index 0000000..efb1f22 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt @@ -0,0 +1,14 @@ +package com.project.movienight.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "integrations.jellyfin") +data class JellyfinIntegrationProperties( + val enabled: Boolean = false, + val baseUrl: String = "", + val webUrl: String = baseUrl, + val apiKey: String = "", + val syncIntervalMs: Long = 1_800_000, + val requestTimeoutMs: Long = 20_000, + val pluginToken: String = "", +) diff --git a/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt b/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt new file mode 100644 index 0000000..3971bc3 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt @@ -0,0 +1,12 @@ +package com.project.movienight.config + +import io.micrometer.core.aop.TimedAspect +import io.micrometer.core.instrument.MeterRegistry +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class MetricsConfiguration { + @Bean + fun timedAspect(meterRegistry: MeterRegistry): TimedAspect = TimedAspect(meterRegistry) +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt b/src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt new file mode 100644 index 0000000..4926b93 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt @@ -0,0 +1,7 @@ +package com.project.movienight.domain.model + +enum class AuthProvider { + GOOGLE, + YANDEX, + VK, +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/Film.kt b/src/main/kotlin/com/project/movienight/domain/model/Film.kt index 32122de..76f2657 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/Film.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/Film.kt @@ -6,4 +6,21 @@ data class Film( val id: UUID, val title: String, val description: String, + val contentType: ContentType = ContentType.FILM, + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) + +enum class ContentType { + FILM, + SERIES, + EPISODE, + OTHER, +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt similarity index 64% rename from src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt rename to src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt index 868f57a..cc81c1a 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt @@ -1,11 +1,13 @@ package com.project.movienight.domain.model +import java.time.LocalDateTime import java.util.UUID -data class FilmLibrary( +data class FilmLibraryEntry( val id: UUID, val userId: UUID, val filmId: UUID, val comment: String?, val isViewed: Boolean, + val watchedAt: LocalDateTime? = null, ) diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt new file mode 100644 index 0000000..380060d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt @@ -0,0 +1,14 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class FilmRating( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String? = null, + val createdAt: LocalDateTime = LocalDateTime.now(), + val updatedAt: LocalDateTime = createdAt, +) diff --git a/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt b/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt new file mode 100644 index 0000000..d2395fa --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt @@ -0,0 +1,19 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class JellyfinSyncState( + val userId: UUID, + val lastSyncedAt: LocalDateTime? = null, + val lastSuccessfulSyncAt: LocalDateTime? = null, + val lastError: String? = null, + val syncedItemCount: Int = 0, +) + +data class JellyfinSyncSummary( + val syncedUsers: Int, + val skippedUsers: Int, + val syncedItems: Int, + val durationMs: Long, +) diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt new file mode 100644 index 0000000..142754a --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt @@ -0,0 +1,17 @@ +package com.project.movienight.domain.model + +import java.util.UUID + +data class RecommendationContext( + val userId: UUID, + val contentType: ContentType? = null, + val mood: String? = null, + val libraryOnly: Boolean = false, + val limit: Int = 10, +) + +data class RecommendationResult( + val film: Film, + val score: Double, + val reasons: List, +) diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt new file mode 100644 index 0000000..3549398 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt @@ -0,0 +1,24 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class RecommendationEvent( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val eventType: RecommendationEventType, + val score: Double? = null, + val relevanceScore: Double? = null, + val qualityScore: Double? = null, + val contextScore: Double? = null, + val noveltyScore: Double? = null, + val diversityScore: Double? = null, + val createdAt: LocalDateTime = LocalDateTime.now(), +) + +enum class RecommendationEventType { + RECOMMENDED, + ACCEPTED, + REJECTED, +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt new file mode 100644 index 0000000..fda5b1d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt @@ -0,0 +1,9 @@ +package com.project.movienight.domain.model + +enum class RecommendationStyle { + BALANCED, + QUALITY_FIRST, + MOOD_FIRST, + DISCOVERY, + SIMILAR_TO_FAVORITES, +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index b4f2d9b..10bc566 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -6,5 +6,6 @@ data class User( val id: UUID, val name: String, val email: String, - val library: FilmLibrary?, + val preferences: UserPreferences? = null, + val jellyfinUserId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt b/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt new file mode 100644 index 0000000..451e227 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt @@ -0,0 +1,13 @@ +package com.project.movienight.domain.model + +import java.util.UUID + +data class UserPreferences( + val userId: UUID, + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) diff --git a/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt b/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt new file mode 100644 index 0000000..ebc8635 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt @@ -0,0 +1,233 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class UserRecommendationWeights( + val userId: UUID, + val relevanceWeight: Double = DEFAULT_RELEVANCE_WEIGHT, + val qualityWeight: Double = DEFAULT_QUALITY_WEIGHT, + val contextWeight: Double = DEFAULT_CONTEXT_WEIGHT, + val noveltyWeight: Double = DEFAULT_NOVELTY_WEIGHT, + val diversityWeight: Double = DEFAULT_DIVERSITY_WEIGHT, + val genreVectorWeight: Double = DEFAULT_GENRE_VECTOR_WEIGHT, + val plotVectorWeight: Double = DEFAULT_PLOT_VECTOR_WEIGHT, + val moodVectorWeight: Double = DEFAULT_MOOD_VECTOR_WEIGHT, + val eraVectorWeight: Double = DEFAULT_ERA_VECTOR_WEIGHT, + val peopleVectorWeight: Double = DEFAULT_PEOPLE_VECTOR_WEIGHT, + val contentTypeVectorWeight: Double = DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT, + val updatedAt: LocalDateTime = LocalDateTime.now(), +) { + fun normalized(updatedAt: LocalDateTime = this.updatedAt): UserRecommendationWeights { + val scoreWeights = + normalizeBounded( + values = + listOf( + relevanceWeight, + qualityWeight, + contextWeight, + noveltyWeight, + diversityWeight, + ), + defaults = DEFAULT_SCORE_WEIGHTS, + min = MIN_SCORE_WEIGHT, + max = MAX_SCORE_WEIGHT, + ) + val vectorWeights = + normalizeBounded( + values = + listOf( + genreVectorWeight, + plotVectorWeight, + moodVectorWeight, + eraVectorWeight, + peopleVectorWeight, + contentTypeVectorWeight, + ), + defaults = DEFAULT_VECTOR_WEIGHTS, + min = MIN_VECTOR_WEIGHT, + max = MAX_VECTOR_WEIGHT, + ) + + return copy( + relevanceWeight = scoreWeights[0], + qualityWeight = scoreWeights[1], + contextWeight = scoreWeights[2], + noveltyWeight = scoreWeights[3], + diversityWeight = scoreWeights[4], + genreVectorWeight = vectorWeights[0], + plotVectorWeight = vectorWeights[1], + moodVectorWeight = vectorWeights[2], + eraVectorWeight = vectorWeights[3], + peopleVectorWeight = vectorWeights[4], + contentTypeVectorWeight = vectorWeights[5], + updatedAt = updatedAt, + ) + } + + companion object { + const val DEFAULT_RELEVANCE_WEIGHT = 0.55 + const val DEFAULT_QUALITY_WEIGHT = 0.15 + const val DEFAULT_CONTEXT_WEIGHT = 0.10 + const val DEFAULT_NOVELTY_WEIGHT = 0.10 + const val DEFAULT_DIVERSITY_WEIGHT = 0.10 + + const val DEFAULT_GENRE_VECTOR_WEIGHT = 0.25 + const val DEFAULT_PLOT_VECTOR_WEIGHT = 0.35 + const val DEFAULT_MOOD_VECTOR_WEIGHT = 0.15 + const val DEFAULT_ERA_VECTOR_WEIGHT = 0.10 + const val DEFAULT_PEOPLE_VECTOR_WEIGHT = 0.10 + const val DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT = 0.05 + + const val MIN_SCORE_WEIGHT = 0.05 + const val MAX_SCORE_WEIGHT = 0.75 + const val MIN_VECTOR_WEIGHT = 0.03 + const val MAX_VECTOR_WEIGHT = 0.60 + + private val DEFAULT_SCORE_WEIGHTS = + listOf( + DEFAULT_RELEVANCE_WEIGHT, + DEFAULT_QUALITY_WEIGHT, + DEFAULT_CONTEXT_WEIGHT, + DEFAULT_NOVELTY_WEIGHT, + DEFAULT_DIVERSITY_WEIGHT, + ) + private val DEFAULT_VECTOR_WEIGHTS = + listOf( + DEFAULT_GENRE_VECTOR_WEIGHT, + DEFAULT_PLOT_VECTOR_WEIGHT, + DEFAULT_MOOD_VECTOR_WEIGHT, + DEFAULT_ERA_VECTOR_WEIGHT, + DEFAULT_PEOPLE_VECTOR_WEIGHT, + DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT, + ) + + fun defaultFor(userId: UUID): UserRecommendationWeights = UserRecommendationWeights(userId = userId) + + fun forStyle( + userId: UUID, + style: RecommendationStyle, + ): UserRecommendationWeights = + when (style) { + RecommendationStyle.BALANCED -> { + defaultFor(userId) + } + + RecommendationStyle.QUALITY_FIRST -> { + UserRecommendationWeights( + userId = userId, + relevanceWeight = 0.40, + qualityWeight = 0.35, + contextWeight = 0.10, + noveltyWeight = 0.05, + diversityWeight = 0.10, + ) + } + + RecommendationStyle.MOOD_FIRST -> { + UserRecommendationWeights( + userId = userId, + relevanceWeight = 0.45, + qualityWeight = 0.10, + contextWeight = 0.25, + noveltyWeight = 0.10, + diversityWeight = 0.10, + moodVectorWeight = 0.30, + ) + } + + RecommendationStyle.DISCOVERY -> { + UserRecommendationWeights( + userId = userId, + relevanceWeight = 0.30, + qualityWeight = 0.10, + contextWeight = 0.10, + noveltyWeight = 0.25, + diversityWeight = 0.25, + ) + } + + RecommendationStyle.SIMILAR_TO_FAVORITES -> { + UserRecommendationWeights( + userId = userId, + relevanceWeight = 0.70, + qualityWeight = 0.10, + contextWeight = 0.10, + noveltyWeight = 0.05, + diversityWeight = 0.05, + genreVectorWeight = 0.30, + plotVectorWeight = 0.40, + peopleVectorWeight = 0.15, + ) + } + }.normalized() + + private fun normalizeBounded( + values: List, + defaults: List, + min: Double, + max: Double, + ): List { + val sanitized = values.map { value -> if (value.isFinite() && value > 0.0) value else 0.0 } + val source = sanitized.takeIf { it.sum() > 0.0 } ?: defaults + val normalized = source.map { it / source.sum() } + return projectToBounds(normalized, min, max) + } + + private fun projectToBounds( + values: List, + min: Double, + max: Double, + ): List { + val result = values.map { it.coerceIn(min, max) }.toMutableList() + var iterations = 0 + var adjusting = true + + while (iterations < values.size * 2 && adjusting) { + iterations += 1 + val diff = 1.0 - result.sum() + if (kotlin.math.abs(diff) <= NORMALIZATION_EPSILON) { + adjusting = false + } else { + adjusting = redistribute(result, diff, min, max) + } + } + + return result + } + + private fun redistribute( + result: MutableList, + diff: Double, + min: Double, + max: Double, + ): Boolean = + if (diff > 0.0) { + val candidates = result.indices.filter { result[it] < max } + val capacity = candidates.sumOf { max - result[it] } + if (capacity > 0.0) { + candidates.forEach { index -> + val increment = diff * ((max - result[index]) / capacity) + result[index] = (result[index] + increment).coerceAtMost(max) + } + true + } else { + false + } + } else { + val candidates = result.indices.filter { result[it] > min } + val capacity = candidates.sumOf { result[it] - min } + if (capacity > 0.0) { + candidates.forEach { index -> + val decrement = -diff * ((result[index] - min) / capacity) + result[index] = (result[index] - decrement).coerceAtLeast(min) + } + true + } else { + false + } + } + + private const val NORMALIZATION_EPSILON = 0.0000001 + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f36c77d..6778584 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -7,7 +7,6 @@ spring: url: ${SPRING_DATASOURCE_URL:jdbc:h2:mem:movienight;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE} username: ${SPRING_DATASOURCE_USERNAME:sa} password: ${SPRING_DATASOURCE_PASSWORD:} - driver-class-name: ${SPRING_DATASOURCE_DRIVER_CLASS_NAME:org.h2.Driver} hikari: maximum-pool-size: ${SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE:20} minimum-idle: ${SPRING_DATASOURCE_HIKARI_MINIMUM_IDLE:5} @@ -25,11 +24,46 @@ spring: console: enabled: ${SPRING_H2_CONSOLE_ENABLED:true} path: /h2-console + security: + oauth2: + client: + registration: + google: + client-id: ${OAUTH2_GOOGLE_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_GOOGLE_CLIENT_SECRET:test-secret} + scope: email,profile + yandex: + client-id: ${OAUTH2_YANDEX_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_YANDEX_CLIENT_SECRET:test-secret} + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: login:email,login:avatar + vk: + client-id: ${OAUTH2_VK_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_VK_CLIENT_SECRET:test-secret} + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: email + provider: + yandex: + authorization-uri: https://oauth.yandex.ru/authorize + token-uri: https://oauth.yandex.ru/token + user-info-uri: https://login.yandex.ru/info + user-name-attribute: id + vk: + authorization-uri: https://oauth.vk.com/authorize + token-uri: https://oauth.vk.com/access_token + user-info-uri: https://api.vk.com/method/users.get?v=5.131&fields=photo_200 + user-name-attribute: response server: + port: ${SERVER_PORT:8080} shutdown: graceful management: + server: + port: ${MANAGEMENT_SERVER_PORT:8081} endpoints: web: base-path: /actuator @@ -45,6 +79,12 @@ management: enabled: true readinessstate: enabled: true + metrics: + tags: + application: ${MANAGEMENT_METRICS_TAGS_APPLICATION:${spring.application.name}} + distribution: + percentiles-histogram: + http.server.requests: ${HTTP_SERVER_REQUESTS_HISTOGRAM_ENABLED:false} info: env: enabled: true @@ -63,6 +103,16 @@ info: description: MovieNight backend service version: ${project.version:unknown} +integrations: + jellyfin: + enabled: ${JELLYFIN_INTEGRATION_ENABLED:false} + base-url: ${JELLYFIN_BASE_URL:} + web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}} + api-key: ${JELLYFIN_API_KEY:} + sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000} + request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000} + plugin-token: ${JELLYFIN_PLUGIN_TOKEN:} + services: user: blocked-names: @@ -74,3 +124,11 @@ services: - censored - epstein - python + +logging: + level: + com.project.movienight: DEBUG + org.springframework: WARN + org.flywaydb: WARN + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n" diff --git a/src/main/resources/db/ER.md b/src/main/resources/db/ER.md index 4fecb18..5c0a829 100644 --- a/src/main/resources/db/ER.md +++ b/src/main/resources/db/ER.md @@ -6,12 +6,26 @@ erDiagram UUID id PK VARCHAR name VARCHAR email + VARCHAR provider + VARCHAR provider_id + VARCHAR jellyfin_user_id + TIMESTAMP created_at } films { UUID id PK VARCHAR title TEXT description + VARCHAR content_type + INT release_year + TEXT genres + TEXT cast_members + TEXT directors + DOUBLE imdb_rating + DOUBLE platform_rating + TEXT external_url + VARCHAR jellyfin_item_id + VARCHAR jellyfin_library_id } favorites { @@ -20,8 +34,53 @@ erDiagram UUID film_id FK VARCHAR comment BOOLEAN is_viewed + TIMESTAMP watched_at + } + + user_preferences { + UUID user_id PK,FK + TEXT weighted_genres + TEXT plot_types + TEXT eras + TEXT cast_and_directors + TEXT moods + TEXT content_types + } + + film_ratings { + UUID id PK + UUID user_id FK + UUID film_id FK + INT score + VARCHAR note + TIMESTAMP created_at + TIMESTAMP updated_at + } + + jellyfin_events { + VARCHAR event_id PK + VARCHAR server_id + VARCHAR event_type + TIMESTAMP occurred_at + VARCHAR jellyfin_user_id + VARCHAR jellyfin_item_id + JSON payload + TIMESTAMP created_at + } + + jellyfin_sync_state { + UUID user_id PK,FK + TIMESTAMP last_synced_at + TIMESTAMP last_successful_sync_at + TEXT last_error + INT synced_item_count + TIMESTAMP updated_at } users ||--o{ favorites : has films ||--o{ favorites : linked + users ||--o{ film_ratings : rates + films ||--o{ film_ratings : rated + users ||--|| user_preferences : configures + users ||--|| jellyfin_sync_state : syncs ``` diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 11017d1..ee51933 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -1,7 +1,11 @@ CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, - email VARCHAR(320) NOT NULL UNIQUE + email VARCHAR(320) NOT NULL UNIQUE, + password VARCHAR(255), + provider VARCHAR(64), + provider_id VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS public.films ( diff --git a/src/main/resources/db/migration/V2__add_oauth2_index.sql b/src/main/resources/db/migration/V2__add_oauth2_index.sql new file mode 100644 index 0000000..b92102c --- /dev/null +++ b/src/main/resources/db/migration/V2__add_oauth2_index.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_provider_id +ON users(provider, provider_id); diff --git a/src/main/resources/db/migration/V3__jellyfin_events.sql b/src/main/resources/db/migration/V3__jellyfin_events.sql new file mode 100644 index 0000000..9f7b8fa --- /dev/null +++ b/src/main/resources/db/migration/V3__jellyfin_events.sql @@ -0,0 +1,14 @@ +-- Create table to store Jellyfin events for idempotency and auditing +CREATE TABLE IF NOT EXISTS jellyfin_events ( + event_id VARCHAR(255) PRIMARY KEY, + server_id VARCHAR(255), + event_type VARCHAR(255) NOT NULL, + occurred_at TIMESTAMP WITH TIME ZONE, + jellyfin_user_id VARCHAR(255), + jellyfin_item_id VARCHAR(255), + payload JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_jellyfin_events_user ON jellyfin_events(jellyfin_user_id); +CREATE INDEX IF NOT EXISTS idx_jellyfin_events_item ON jellyfin_events(jellyfin_item_id); diff --git a/src/main/resources/db/migration/V4__add_ratings_table.sql b/src/main/resources/db/migration/V4__add_ratings_table.sql new file mode 100644 index 0000000..2141e8f --- /dev/null +++ b/src/main/resources/db/migration/V4__add_ratings_table.sql @@ -0,0 +1,17 @@ +-- Create ratings table to store user film ratings +CREATE TABLE ratings ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + film_id UUID NOT NULL REFERENCES films(id) ON DELETE CASCADE, + rating NUMERIC(3, 1) NOT NULL CHECK (rating >= 0 AND rating <= 10), + source VARCHAR(50) NOT NULL DEFAULT 'MOVIENIGHT', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_user_film_rating UNIQUE (user_id, film_id) +); + +-- Create index on user_id for efficient lookups by user +CREATE INDEX idx_ratings_user_id ON ratings(user_id); + +-- Create index on film_id for efficient lookups by film +CREATE INDEX idx_ratings_film_id ON ratings(film_id); diff --git a/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql new file mode 100644 index 0000000..25d719c --- /dev/null +++ b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql @@ -0,0 +1,17 @@ +-- Add jellyfin_id columns for mapping between MovieNight and Jellyfin +ALTER TABLE films + ADD COLUMN jellyfin_id UUID; + +ALTER TABLE films + ADD CONSTRAINT uq_films_jellyfin_id UNIQUE (jellyfin_id); + +CREATE INDEX idx_films_jellyfin_id ON films(jellyfin_id); + +-- Add jellyfin_id to users for sync mapping +ALTER TABLE users + ADD COLUMN jellyfin_id UUID; + +ALTER TABLE users + ADD CONSTRAINT uq_users_jellyfin_id UNIQUE (jellyfin_id); + +CREATE INDEX idx_users_jellyfin_id ON users(jellyfin_id); diff --git a/src/main/resources/db/migration/V6__extend_schema.sql b/src/main/resources/db/migration/V6__extend_schema.sql new file mode 100644 index 0000000..3c7189f --- /dev/null +++ b/src/main/resources/db/migration/V6__extend_schema.sql @@ -0,0 +1,70 @@ +ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS jellyfin_user_id VARCHAR(255); + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS content_type VARCHAR(32) NOT NULL DEFAULT 'FILM'; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS release_year INT; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS genres TEXT NOT NULL DEFAULT ''; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS cast_members TEXT NOT NULL DEFAULT ''; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS directors TEXT NOT NULL DEFAULT ''; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS imdb_rating DOUBLE PRECISION; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS platform_rating DOUBLE PRECISION; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS external_url TEXT; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS jellyfin_item_id VARCHAR(255); + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS jellyfin_library_id VARCHAR(255); + +ALTER TABLE public.favorites + ADD COLUMN IF NOT EXISTS watched_at TIMESTAMP; + +CREATE TABLE IF NOT EXISTS public.user_preferences ( + user_id UUID PRIMARY KEY, + weighted_genres TEXT NOT NULL DEFAULT '', + plot_types TEXT NOT NULL DEFAULT '', + eras TEXT NOT NULL DEFAULT '', + cast_and_directors TEXT NOT NULL DEFAULT '', + moods TEXT NOT NULL DEFAULT '', + content_types TEXT NOT NULL DEFAULT '', + CONSTRAINT user_preferences_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS public.film_ratings ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + film_id UUID NOT NULL, + score INT NOT NULL, + note VARCHAR(2048), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT film_ratings_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_score_range CHECK (score >= 1 AND score <= 10), + CONSTRAINT film_ratings_user_film_unique UNIQUE (user_id, film_id) +); + +CREATE TABLE IF NOT EXISTS public.jellyfin_sync_state ( + user_id UUID PRIMARY KEY, + last_synced_at TIMESTAMP, + last_successful_sync_at TIMESTAMP, + last_error TEXT, + synced_item_count INT NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT jellyfin_sync_state_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V7__recommendation_events.sql b/src/main/resources/db/migration/V7__recommendation_events.sql new file mode 100644 index 0000000..3ecd8ba --- /dev/null +++ b/src/main/resources/db/migration/V7__recommendation_events.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS public.recommendation_events ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + film_id UUID NOT NULL, + event_type VARCHAR(64) NOT NULL, + score DOUBLE PRECISION, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT recommendation_events_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, + CONSTRAINT recommendation_events_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_recommendation_events_user_created + ON public.recommendation_events(user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_recommendation_events_film + ON public.recommendation_events(film_id); + +CREATE INDEX IF NOT EXISTS idx_recommendation_events_type + ON public.recommendation_events(event_type); diff --git a/src/main/resources/db/migration/V8__user_recommendation_weights.sql b/src/main/resources/db/migration/V8__user_recommendation_weights.sql new file mode 100644 index 0000000..238870e --- /dev/null +++ b/src/main/resources/db/migration/V8__user_recommendation_weights.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS public.user_recommendation_weights ( + user_id UUID PRIMARY KEY, + relevance_weight DOUBLE PRECISION NOT NULL DEFAULT 0.55, + quality_weight DOUBLE PRECISION NOT NULL DEFAULT 0.15, + context_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + novelty_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + diversity_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + genre_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.25, + plot_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.35, + mood_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.15, + era_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + people_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + content_type_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.05, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT user_recommendation_weights_user_fk + FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS relevance_score DOUBLE PRECISION; + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS quality_score DOUBLE PRECISION; + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS context_score DOUBLE PRECISION; + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS novelty_score DOUBLE PRECISION; + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS diversity_score DOUBLE PRECISION; + +CREATE INDEX IF NOT EXISTS idx_recommendation_events_user_film_type_created + ON public.recommendation_events(user_id, film_id, event_type, created_at DESC); diff --git a/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql b/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql new file mode 100644 index 0000000..9415580 --- /dev/null +++ b/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql @@ -0,0 +1,11 @@ +DROP TABLE IF EXISTS public.ratings; + +ALTER TABLE public.users + DROP COLUMN IF EXISTS jellyfin_id; + +ALTER TABLE public.films + DROP COLUMN IF EXISTS jellyfin_id; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_jellyfin_user_id ON public.users(jellyfin_user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_films_jellyfin_item_id ON public.films(jellyfin_item_id); +CREATE INDEX IF NOT EXISTS idx_films_jellyfin_library_id ON public.films(jellyfin_library_id); diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..21a05b1 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,31 @@ + + + + %d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n + + + + + + + + + + + + logs/app.json + + logs/app-%d{yyyy-MM-dd}.json + 30 + + + + + + + + + + + + diff --git a/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt b/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt new file mode 100644 index 0000000..a963e59 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt @@ -0,0 +1,17 @@ +package com.project.movienight + +import org.junit.jupiter.api.Test +import kotlin.test.assertNotNull + +class ClassLoaderTest { + @Test + fun `can load OAuth2ClientProperties class`() { + val clazz = + Class.forName( + "org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties", + ) + assertNotNull(clazz) + println("Successfully loaded: ${clazz.name}") + println("ClassLoader: ${clazz.classLoader}") + } +} diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt new file mode 100644 index 0000000..23e2518 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -0,0 +1,513 @@ +package com.project.movienight + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateFilmRequest +import com.project.movienight.adapters.web.dto.request.CreateUserRequest +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.request.UpdateUserRecommendationWeightsRequest +import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.put +import java.util.UUID + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class RecommendationSmokeTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @BeforeEach + fun setup() { + cleanDatabase() + } + + @AfterEach + fun cleanup() { + cleanDatabase() + } + + @Test + fun `should create data and return a ranked recommendation`() { + mockMvc + .post("/api/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(CreateUserRequest(name = "Jane", email = "jane@example.com")) + }.andExpect { + status { isCreated() } + } + + val userId = + UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM users WHERE email = ?", + String::class.java, + "jane@example.com", + ), + ) + + mockMvc + .post("/api/films") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + CreateFilmRequest( + title = "Orbital Drift", + description = "A science-fiction rescue mission", + contentType = "FILM", + genres = listOf("SCI-FI", "THRILLER"), + directors = listOf("Nora Finch"), + imdbRating = 8.7, + platformRating = 9.0, + externalUrl = "https://example.com/orbital-drift", + jellyfinItemId = "orbital-drift-item", + ), + ) + }.andExpect { + status { isCreated() } + } + + mockMvc + .post("/api/films") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + CreateFilmRequest( + title = "Small Town Summer", + description = "A grounded family drama", + contentType = "FILM", + genres = listOf("DRAMA"), + directors = listOf("Ava Reed"), + imdbRating = 7.1, + platformRating = 6.8, + ), + ) + }.andExpect { + status { isCreated() } + } + + val createdFilms = jdbcTemplate.queryForList("SELECT id, title FROM films ORDER BY title") + val filmIdByTitle = + createdFilms.associate { row -> + row["title"].toString() to UUID.fromString(row["id"].toString()) + } + val firstFilmId = filmIdByTitle.getValue("Orbital Drift") + val secondFilmId = filmIdByTitle.getValue("Small Town Summer") + + mockMvc + .get("/api/users/$userId/recommendation-weights") + .andExpect { + status { isOk() } + jsonPath("$.relevanceWeight") { value(0.55) } + jsonPath("$.plotVectorWeight") { value(0.35) } + } + + mockMvc + .put("/api/users/$userId/recommendation-weights") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + UpdateUserRecommendationWeightsRequest( + relevanceWeight = 0.60, + qualityWeight = 0.10, + contextWeight = 0.15, + noveltyWeight = 0.10, + diversityWeight = 0.05, + genreVectorWeight = 0.30, + plotVectorWeight = 0.30, + moodVectorWeight = 0.20, + eraVectorWeight = 0.05, + peopleVectorWeight = 0.10, + contentTypeVectorWeight = 0.05, + ), + ) + }.andExpect { + status { isOk() } + jsonPath("$.relevanceWeight") { value(0.6) } + jsonPath("$.genreVectorWeight") { value(0.3) } + } + + mockMvc + .put("/api/users/$userId/preferences") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + UpsertUserPreferencesRequest( + weightedGenres = mapOf("SCI-FI" to 5), + moods = listOf("focused"), + contentTypes = listOf("FILM"), + ), + ) + }.andExpect { + status { isOk() } + jsonPath("$.weightedGenres['SCI-FI']") { value(5) } + } + + mockMvc + .post("/api/users/$userId/ratings/films/$firstFilmId") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(RateFilmRequest(score = 10, note = "Great fit")) + }.andExpect { + status { isCreated() } + jsonPath("$.score") { value(10) } + } + + mockMvc + .post("/api/users/$userId/library/films/$secondFilmId/viewed") + .andExpect { + status { isOk() } + jsonPath("$.viewed") { value(true) } + } + + mockMvc + .get("/api/users/$userId/ratings") + .andExpect { + status { isOk() } + jsonPath("$[0].filmId") { value(firstFilmId.toString()) } + } + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("limit", "2") + }.andExpect { + status { isOk() } + jsonPath("$[0].filmId") { value(firstFilmId.toString()) } + jsonPath("$[0].film.id") { value(firstFilmId.toString()) } + jsonPath("$[0].watchUrl") { + value("https://jellyfin.example.test/web/#/details?id=orbital-drift-item") + } + jsonPath("$[0].reasons[0]") { exists() } + } + + val recommendedBreakdownCount = + jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) + FROM recommendation_events + WHERE user_id = ? + AND film_id = ? + AND event_type = 'RECOMMENDED' + AND relevance_score IS NOT NULL + AND quality_score IS NOT NULL + """.trimIndent(), + Int::class.java, + userId, + firstFilmId, + ) + assertTrue((recommendedBreakdownCount ?: 0) > 0) + + val weightsBeforeFeedback = findScoreWeights(userId) + + mockMvc + .post("/api/users/$userId/recommendations/$firstFilmId/accept") + .andExpect { + status { isOk() } + jsonPath("$.filmId") { value(firstFilmId.toString()) } + jsonPath("$.eventType") { value("ACCEPTED") } + jsonPath("$.relevanceScore") { exists() } + } + + val weightsAfterAccept = findScoreWeights(userId) + assertNotEquals(weightsBeforeFeedback, weightsAfterAccept) + assertTrue(weightsAfterAccept.all { it in 0.05..0.75 }) + + mockMvc + .post("/api/users/$userId/recommendations/$firstFilmId/reject") + .andExpect { + status { isOk() } + jsonPath("$.filmId") { value(firstFilmId.toString()) } + jsonPath("$.eventType") { value("REJECTED") } + } + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("libraryOnly", "true") + param("limit", "2") + }.andExpect { + status { isOk() } + jsonPath("$") { isEmpty() } + } + } + + @Test + fun `should complete recommendation onboarding`() { + mockMvc + .post("/api/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(CreateUserRequest(name = "Alex", email = "alex@example.com")) + }.andExpect { + status { isCreated() } + } + + val userId = + UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM users WHERE email = ?", + String::class.java, + "alex@example.com", + ), + ) + + val likedFilmId = createFilm(title = "Neon Rescue", genres = listOf("SCI-FI"), imdbRating = 8.8) + val dislikedFilmId = createFilm(title = "Quiet Village", genres = listOf("DRAMA"), imdbRating = 5.0) + val libraryFilmId = createFilm(title = "Space Trial", genres = listOf("SCI-FI"), imdbRating = 7.8) + val watchedFilmId = createFilm(title = "Old Mission", genres = listOf("THRILLER"), imdbRating = 8.1) + + mockMvc + .post("/api/users/$userId/recommendation-onboarding") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + RecommendationOnboardingRequest( + weightedGenres = mapOf("SCI-FI" to 5, "THRILLER" to 3), + moods = listOf("focused", "tense"), + contentTypes = listOf("FILM"), + likedFilmIds = listOf(likedFilmId), + dislikedFilmIds = listOf(dislikedFilmId), + libraryFilmIds = listOf(libraryFilmId), + watchedFilmIds = listOf(watchedFilmId), + recommendationStyle = "DISCOVERY", + ), + ) + }.andExpect { + status { isOk() } + jsonPath("$.preferences.weightedGenres['SCI-FI']") { value(5) } + jsonPath("$.weights.noveltyWeight") { value(0.25) } + jsonPath("$.weights.diversityWeight") { value(0.25) } + jsonPath("$.likedFilmsCount") { value(1) } + jsonPath("$.dislikedFilmsCount") { value(1) } + jsonPath("$.libraryFilmsCount") { value(1) } + jsonPath("$.watchedFilmsCount") { value(1) } + } + + assertDatabaseCount( + """ + SELECT COUNT(*) + FROM film_ratings + WHERE user_id = ? + AND film_id IN (?, ?) + """.trimIndent(), + userId, + likedFilmId, + dislikedFilmId, + ) + assertDatabaseCount( + """ + SELECT COUNT(*) + FROM favorites + WHERE user_id = ? + AND film_id = ? + AND is_viewed = TRUE + """.trimIndent(), + userId, + watchedFilmId, + ) + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("limit", "3") + }.andExpect { + status { isOk() } + jsonPath("$[0].reasons[0]") { exists() } + } + } + + @Test + fun `should rank films similar to highly rated choices above broad onboarding matches`() { + mockMvc + .post("/api/users") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + CreateUserRequest( + name = "Harry", + email = "harry@example.com", + ), + ) + }.andExpect { + status { isCreated() } + } + + val userId = + UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM users WHERE email = ?", + String::class.java, + "harry@example.com", + ), + ) + + val likedFirstFilmId = + createFilm( + title = "Wizard School Stone", + description = "A young wizard discovers a magic school, spells, friendship, and a hidden dark force.", + releaseYear = 2001, + genres = listOf("Fantasy", "Adventure", "Family"), + imdbRating = 8.0, + ) + val likedSecondFilmId = + createFilm( + title = "Chamber of Magic", + description = + "Young friends return to a wizard school and uncover a secret chamber full of magical danger.", + releaseYear = 2002, + genres = listOf("Fantasy", "Adventure", "Family"), + imdbRating = 8.1, + ) + val magicCandidateId = + createFilm( + title = "Sorcerer Academy", + description = "A teenage student joins an academy with friends and faces an enchanted threat.", + releaseYear = 2005, + genres = listOf("Fantasy", "Adventure", "Family"), + imdbRating = 7.0, + ) + createFilm( + title = "Highway Strike", + description = "An elite agent chases criminals through explosions, heists, and street fights.", + releaseYear = 2005, + genres = listOf("Action"), + imdbRating = 9.4, + ) + + mockMvc + .put("/api/users/$userId/preferences") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + UpsertUserPreferencesRequest( + weightedGenres = mapOf("Action" to 5), + eras = listOf("2000s"), + contentTypes = listOf("FILM"), + ), + ) + }.andExpect { + status { isOk() } + } + + listOf(likedFirstFilmId, likedSecondFilmId).forEach { filmId -> + mockMvc + .post("/api/users/$userId/ratings/films/$filmId") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(RateFilmRequest(score = 10, note = "Favorite")) + }.andExpect { + status { isCreated() } + } + + mockMvc + .post("/api/users/$userId/library/films/$filmId/viewed") + .andExpect { + status { isOk() } + } + } + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("limit", "2") + }.andExpect { + status { isOk() } + jsonPath("$[0].filmId") { value(magicCandidateId.toString()) } + jsonPath("$[0].reasons[0]") { value("Similar to films you rated highly") } + jsonPath("$[0].reasons[1]") { value("Shares taste signal: Family Adventure") } + } + } + + private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM recommendation_events") + jdbcTemplate.execute("DELETE FROM user_recommendation_weights") + jdbcTemplate.execute("DELETE FROM film_ratings") + jdbcTemplate.execute("DELETE FROM user_preferences") + jdbcTemplate.execute("DELETE FROM favorites") + jdbcTemplate.execute("DELETE FROM films") + jdbcTemplate.execute("DELETE FROM users") + } + + private fun findScoreWeights(userId: UUID): List = + jdbcTemplate + .queryForMap( + """ + SELECT relevance_weight, + quality_weight, + context_weight, + novelty_weight, + diversity_weight + FROM user_recommendation_weights + WHERE user_id = ? + """.trimIndent(), + userId, + ).let { row -> + listOf( + row.getValue("RELEVANCE_WEIGHT"), + row.getValue("QUALITY_WEIGHT"), + row.getValue("CONTEXT_WEIGHT"), + row.getValue("NOVELTY_WEIGHT"), + row.getValue("DIVERSITY_WEIGHT"), + ).map { (it as Number).toDouble() } + } + + private fun createFilm( + title: String, + description: String = "$title description", + releaseYear: Int? = null, + genres: List, + imdbRating: Double, + ): UUID { + mockMvc + .post("/api/films") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + CreateFilmRequest( + title = title, + description = description, + contentType = "FILM", + releaseYear = releaseYear, + genres = genres, + imdbRating = imdbRating, + ), + ) + }.andExpect { + status { isCreated() } + } + + return UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM films WHERE title = ?", + String::class.java, + title, + ), + ) + } + + private fun assertDatabaseCount( + sql: String, + vararg args: Any, + ) { + val count = jdbcTemplate.queryForObject(sql, Int::class.java, *args) + assertTrue((count ?: 0) > 0) + } +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt new file mode 100644 index 0000000..859d9a4 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -0,0 +1,80 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.domain.model.AuthProvider +import com.project.movienight.domain.model.User +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class UserEntityMappingTest { + @Test + fun `toDomain maps UserEntity correctly`() { + val entity = + UserEntity( + id = UUID.randomUUID(), + name = "John Pork", + email = "john@email.com", + provider = "GOOGLE", + providerId = "google1234", + jellyfinUserId = null, + createdAt = LocalDateTime.now(), + ) + val user = entity.toDomain() + + assertEquals(entity.id, user.id) + assertEquals(entity.name, user.name) + assertEquals(entity.email, user.email) + assertNull(user.jellyfinUserId) + } + + @Test + fun `toEntity maps User with OAuth provider`() { + val user = + User( + id = UUID.randomUUID(), + name = "Jane", + email = "jane@mail.com", + ) + + val entity = user.toEntity(AuthProvider.YANDEX, "yandex456") + + assertEquals(user.id, entity.id) + assertEquals(user.name, entity.name) + assertEquals(user.email, entity.email) + assertEquals("YANDEX", entity.provider) + assertEquals("yandex456", entity.providerId) + } + + @Test + fun `toEntity maps User without OAuth provider`() { + val user = + User( + id = UUID.randomUUID(), + name = "Bob", + email = "bob@mail.com", + ) + + val entity = user.toEntity() + + assertNull(entity.provider) + assertNull(entity.providerId) + } + + @Test + fun `mapping is reversible for basic fields`() { + val original = + User( + id = UUID.randomUUID(), + name = "Alice", + email = "alice@email.com", + ) + + val mapped = original.toEntity().toDomain() + + assertEquals(original.id, mapped.id) + assertEquals(original.name, mapped.name) + assertEquals(original.email, mapped.email) + } +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt new file mode 100644 index 0000000..fa8c616 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt @@ -0,0 +1,220 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibraryEntry +import com.project.movienight.domain.model.User +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import java.util.UUID + +@SpringBootTest +@ActiveProfiles("test") +class FilmLibraryEntryRepositoryIntegrationTest { + @Autowired + private lateinit var filmLibraryEntryRepository: FilmLibraryEntryRepository + + @Autowired + private lateinit var userRepository: UserRepository + + @Autowired + private lateinit var filmRepository: FilmRepository + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + private lateinit var testUser: User + private lateinit var testFilm: Film + + @BeforeEach + fun setup() { + cleanDatabase() + createTestData() + } + + @AfterEach + fun cleanup() { + cleanDatabase() + } + + private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM favorites") + jdbcTemplate.execute("DELETE FROM films") + jdbcTemplate.execute("DELETE FROM users") + } + + private fun createTestData() { + testUser = User(UUID.randomUUID(), "Иван Иванов", "ivan@example.com", null) + testFilm = Film(UUID.randomUUID(), "Начало", "Захватывающий триллер") + userRepository.save(testUser) + filmRepository.save(testFilm) + } + + @Test + fun `should save new film library entry and return saved entry`() { + val entry = + FilmLibraryEntry( + id = UUID.randomUUID(), + userId = testUser.id, + filmId = testFilm.id, + comment = "Отличный фильм!", + isViewed = false, + ) + + val savedEntry = filmLibraryEntryRepository.save(entry) + + assertNotNull(savedEntry) + assertEquals(entry.id, savedEntry.id) + assertEquals(entry.userId, savedEntry.userId) + assertEquals(entry.filmId, savedEntry.filmId) + assertEquals(entry.comment, savedEntry.comment) + assertEquals(entry.isViewed, savedEntry.isViewed) + } + + @Test + fun `should update existing film library entry`() { + val entryId = UUID.randomUUID() + val originalEntry = FilmLibraryEntry(entryId, testUser.id, testFilm.id, "Хочу посмотреть", false) + filmLibraryEntryRepository.save(originalEntry) + + val updatedEntry = FilmLibraryEntry(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true) + val result = filmLibraryEntryRepository.save(updatedEntry) + + assertEquals(entryId, result.id) + assertEquals("Уже посмотрел, потрясающе!", result.comment) + assertTrue(result.isViewed) + + val foundEntry = filmLibraryEntryRepository.findById(entryId) + assertNotNull(foundEntry) + assertEquals("Уже посмотрел, потрясающе!", foundEntry?.comment) + assertTrue(foundEntry?.isViewed ?: false) + } + + @Test + fun `should find film library entry by id`() { + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false) + filmLibraryEntryRepository.save(entry) + + val foundEntry = filmLibraryEntryRepository.findById(entry.id) + + assertNotNull(foundEntry) + assertEquals(entry.id, foundEntry?.id) + assertEquals(entry.userId, foundEntry?.userId) + assertEquals(entry.filmId, foundEntry?.filmId) + assertEquals(entry.comment, foundEntry?.comment) + assertEquals(entry.isViewed, foundEntry?.isViewed) + } + + @Test + fun `should return null when film library entry not found by id`() { + val nonExistentId = UUID.randomUUID() + + val foundEntry = filmLibraryEntryRepository.findById(nonExistentId) + + assertNull(foundEntry) + } + + @Test + fun `should find all film library entries`() { + val entry1 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 1", false) + val entry2 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 2", true) + val entry3 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, null, false) + + filmLibraryEntryRepository.save(entry1) + filmLibraryEntryRepository.save(entry2) + filmLibraryEntryRepository.save(entry3) + + val allEntries = filmLibraryEntryRepository.findAll() + + assertEquals(3, allEntries.size) + assertTrue(allEntries.any { it.id == entry1.id }) + assertTrue(allEntries.any { it.id == entry2.id }) + assertTrue(allEntries.any { it.id == entry3.id }) + } + + @Test + fun `should return empty list when no film library entries exist`() { + val allEntries = filmLibraryEntryRepository.findAll() + + assertTrue(allEntries.isEmpty()) + } + + @Test + fun `should delete film library entry by id`() { + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Для удаления", false) + filmLibraryEntryRepository.save(entry) + + filmLibraryEntryRepository.deleteById(entry.id) + + val foundEntry = filmLibraryEntryRepository.findById(entry.id) + assertNull(foundEntry) + } + + @Test + fun `should not throw exception when deleting non-existent entry`() { + val nonExistentId = UUID.randomUUID() + + filmLibraryEntryRepository.deleteById(nonExistentId) + } + + @Test + fun `should save entry with null comment`() { + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, null, false) + + val savedEntry = filmLibraryEntryRepository.save(entry) + + assertNotNull(savedEntry) + assertNull(savedEntry.comment) + } + + @Test + fun `should save entry with isViewed true`() { + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true) + + val savedEntry = filmLibraryEntryRepository.save(entry) + + assertNotNull(savedEntry) + assertTrue(savedEntry.isViewed) + } + + @Test + fun `should save entry with isViewed false`() { + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false) + + val savedEntry = filmLibraryEntryRepository.save(entry) + + assertNotNull(savedEntry) + assertFalse(savedEntry.isViewed) + } + + @Test + fun `should cascade delete entries when user is deleted`() { + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false) + filmLibraryEntryRepository.save(entry) + + userRepository.deleteById(testUser.id) + + val foundEntry = filmLibraryEntryRepository.findById(entry.id) + assertNull(foundEntry) + } + + @Test + fun `should cascade delete entries when film is deleted`() { + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Запись о фильме", false) + filmLibraryEntryRepository.save(entry) + + filmRepository.deleteById(testFilm.id) + + val foundEntry = filmLibraryEntryRepository.findById(entry.id) + assertNull(foundEntry) + } +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt new file mode 100644 index 0000000..3c38a23 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt @@ -0,0 +1,153 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.domain.model.Film +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import java.util.UUID + +@SpringBootTest +@ActiveProfiles("test") +class FilmRepositoryIntegrationTest { + @Autowired + private lateinit var filmRepository: FilmRepository + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @BeforeEach + fun setup() { + cleanDatabase() + } + + @AfterEach + fun cleanup() { + cleanDatabase() + } + + private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM favorites") + jdbcTemplate.execute("DELETE FROM films") + jdbcTemplate.execute("DELETE FROM users") + } + + @Test + fun `should save new film and return saved film`() { + val film = + Film( + id = UUID.randomUUID(), + title = "Начало", + description = "Захватывающий триллер о снах внутри снов", + ) + + val savedFilm = filmRepository.save(film) + + assertNotNull(savedFilm) + assertEquals(film.id, savedFilm.id) + assertEquals(film.title, savedFilm.title) + assertEquals(film.description, savedFilm.description) + } + + @Test + fun `should update existing film`() { + val filmId = UUID.randomUUID() + val originalFilm = Film(filmId, "Начало", "Оригинальное описание") + filmRepository.save(originalFilm) + + val updatedFilm = Film(filmId, "Начало (Обновлено)", "Обновленное описание с дополнительными деталями") + val result = filmRepository.save(updatedFilm) + + assertEquals(filmId, result.id) + assertEquals("Начало (Обновлено)", result.title) + assertEquals("Обновленное описание с дополнительными деталями", result.description) + + val foundFilm = filmRepository.findById(filmId) + assertNotNull(foundFilm) + assertEquals("Начало (Обновлено)", foundFilm?.title) + assertEquals("Обновленное описание с дополнительными деталями", foundFilm?.description) + } + + @Test + fun `should find film by id`() { + val film = Film(UUID.randomUUID(), "Матрица", "Хакер узнает правду о реальности") + filmRepository.save(film) + + val foundFilm = filmRepository.findById(film.id) + + assertNotNull(foundFilm) + assertEquals(film.id, foundFilm?.id) + assertEquals(film.title, foundFilm?.title) + assertEquals(film.description, foundFilm?.description) + } + + @Test + fun `should return null when film not found by id`() { + val nonExistentId = UUID.randomUUID() + + val foundFilm = filmRepository.findById(nonExistentId) + + assertNull(foundFilm) + } + + @Test + fun `should find all films`() { + val film1 = Film(UUID.randomUUID(), "Начало", "Сны внутри снов") + val film2 = Film(UUID.randomUUID(), "Матрица", "Реальность не то, чем кажется") + val film3 = Film(UUID.randomUUID(), "Интерстеллар", "Путешествие сквозь пространство и время") + + filmRepository.save(film1) + filmRepository.save(film2) + filmRepository.save(film3) + + val allFilms = filmRepository.findAll() + + assertEquals(3, allFilms.size) + assertTrue(allFilms.any { it.id == film1.id }) + assertTrue(allFilms.any { it.id == film2.id }) + assertTrue(allFilms.any { it.id == film3.id }) + } + + @Test + fun `should return empty list when no films exist`() { + val allFilms = filmRepository.findAll() + + assertTrue(allFilms.isEmpty()) + } + + @Test + fun `should delete film by id`() { + val film = Film(UUID.randomUUID(), "Начало", "Сны внутри снов") + filmRepository.save(film) + + filmRepository.deleteById(film.id) + + val foundFilm = filmRepository.findById(film.id) + assertNull(foundFilm) + } + + @Test + fun `should not throw exception when deleting non-existent film`() { + val nonExistentId = UUID.randomUUID() + + filmRepository.deleteById(nonExistentId) + } + + @Test + fun `should save film with long description`() { + val longDescription = "А".repeat(1000) + val film = Film(UUID.randomUUID(), "Тестовый фильм", longDescription) + + val savedFilm = filmRepository.save(film) + + assertNotNull(savedFilm) + assertEquals(longDescription, savedFilm.description) + } +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt new file mode 100644 index 0000000..7d0c2bd --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt @@ -0,0 +1,197 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.domain.model.AuthProvider +import com.project.movienight.domain.model.User +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import java.util.UUID + +@SpringBootTest +@ActiveProfiles("test") +class UserRepositoryIntegrationTest { + @Autowired + private lateinit var userRepository: UserRepository + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @BeforeEach + fun setup() { + cleanDatabase() + } + + @AfterEach + fun cleanup() { + cleanDatabase() + } + + private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM favorites") + jdbcTemplate.execute("DELETE FROM films") + jdbcTemplate.execute("DELETE FROM users") + } + + @Test + fun `should save new user and return saved user`() { + val user = + User( + id = UUID.randomUUID(), + name = "John Doe", + email = "john@example.com", + ) + + val savedUser = userRepository.save(user) + + assertNotNull(savedUser) + assertEquals(user.id, savedUser.id) + assertEquals(user.name, savedUser.name) + assertEquals(user.email, savedUser.email) + } + + @Test + fun `should update existing user`() { + // given + val userId = UUID.randomUUID() + val originalUser = User(userId, "John Doe", "john@example.com") + userRepository.save(originalUser) + + val updatedUser = User(userId, "Jane Doe", "jane@example.com") + val result = userRepository.save(updatedUser) + + assertEquals(userId, result.id) + assertEquals("Jane Doe", result.name) + assertEquals("jane@example.com", result.email) + + val foundUser = userRepository.findById(userId) + assertNotNull(foundUser) + assertEquals("Jane Doe", foundUser?.name) + assertEquals("jane@example.com", foundUser?.email) + } + + @Test + fun `should find user by id`() { + // given + val user = User(UUID.randomUUID(), "John Doe", "john@example.com") + userRepository.save(user) + + // when + val foundUser = userRepository.findById(user.id) + + // then + assertNotNull(foundUser) + assertEquals(user.id, foundUser?.id) + assertEquals(user.name, foundUser?.name) + assertEquals(user.email, foundUser?.email) + } + + @Test + fun `should return null when user not found by id`() { + // given + val nonExistentId = UUID.randomUUID() + + // when + val foundUser = userRepository.findById(nonExistentId) + + // then + assertNull(foundUser) + } + + @Test + fun `should find all users`() { + // given + val user1 = User(UUID.randomUUID(), "John Doe", "john@example.com") + val user2 = User(UUID.randomUUID(), "Jane Smith", "jane@example.com") + val user3 = User(UUID.randomUUID(), "Bob Johnson", "bob@example.com") + + userRepository.save(user1) + userRepository.save(user2) + userRepository.save(user3) + + // when + val allUsers = userRepository.findAll() + + // then + assertEquals(3, allUsers.size) + assertTrue(allUsers.any { it.id == user1.id }) + assertTrue(allUsers.any { it.id == user2.id }) + assertTrue(allUsers.any { it.id == user3.id }) + } + + @Test + fun `should return empty list when no users exist`() { + // when + val allUsers = userRepository.findAll() + + // then + assertTrue(allUsers.isEmpty()) + } + + @Test + fun `should delete user by id`() { + // given + val user = User(UUID.randomUUID(), "John Doe", "john@example.com") + userRepository.save(user) + + // when + userRepository.deleteById(user.id) + + // then + val foundUser = userRepository.findById(user.id) + assertNull(foundUser) + } + + @Test + fun `should not throw exception when deleting non-existent user`() { + // given + val nonExistentId = UUID.randomUUID() + + // when & then (no exception should be thrown) + userRepository.deleteById(nonExistentId) + } + + @Test + fun `should create OAuth user with provider identity`() { + val user = User(UUID.randomUUID(), "OAuth User", "oauth@example.com") + + val savedUser = userRepository.createOAuthUser(user, AuthProvider.GOOGLE, "google-123") + + assertEquals(user.id, savedUser.id) + assertEquals(user.email, savedUser.email) + + val foundByProvider = userRepository.findByProviderAndProviderId(AuthProvider.GOOGLE, "google-123") + assertNotNull(foundByProvider) + assertEquals(user.id, foundByProvider?.id) + } + + @Test + fun `should link OAuth account to existing user`() { + val user = userRepository.save(User(UUID.randomUUID(), "Link User", "link@example.com")) + + val linkedUser = userRepository.linkOAuthAccount(user.id, AuthProvider.YANDEX, "yandex-456") + + assertEquals(user.id, linkedUser.id) + val foundByProvider = userRepository.findByProviderAndProviderId(AuthProvider.YANDEX, "yandex-456") + assertNotNull(foundByProvider) + assertEquals(user.id, foundByProvider?.id) + } + + @Test + fun `find by email should include jellyfin user id`() { + val user = userRepository.save(User(UUID.randomUUID(), "Jellyfin User", "jellyfin@example.com")) + jdbcTemplate.update("UPDATE users SET jellyfin_user_id = ? WHERE id = ?", "jellyfin-789", user.id) + + val foundUser = userRepository.findByEmail(user.email) + + assertNotNull(foundUser) + assertEquals("jellyfin-789", foundUser?.jellyfinUserId) + } +} diff --git a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt new file mode 100644 index 0000000..04a794d --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt @@ -0,0 +1,67 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.application.ports.input.FilmUseCase +import com.project.movienight.domain.model.Film +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.util.UUID + +class FilmControllerSearchTest { + private lateinit var mockMvc: MockMvc + private lateinit var filmUseCase: FilmUseCase + + @BeforeEach + fun setup() { + filmUseCase = mockk() + + val controller = + FilmController( + filmUseCase = filmUseCase, + ) + + mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + } + + @Test + fun `search returns film when title exists`() { + val title = "Inception" + val film = Film(id = UUID.randomUUID(), title = title, description = "A dream heist") + + every { filmUseCase.searchByTitle(title) } returns film + + mockMvc + .get("/api/films/search") { + param("title", title) + }.andExpect { + status { isOk() } + jsonPath("$.id") { value(film.id.toString()) } + jsonPath("$.title") { value(title) } + jsonPath("$.description") { value("A dream heist") } + } + + verify(exactly = 1) { filmUseCase.searchByTitle(title) } + } + + @Test + fun `search returns 404 when title is not found`() { + val title = "Unknown Title" + + every { filmUseCase.searchByTitle(title) } returns null + + mockMvc + .get("/api/films/search") { + param("title", title) + }.andExpect { + status { isNotFound() } + content { string("") } + } + + verify(exactly = 1) { filmUseCase.searchByTitle(title) } + } +} diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt new file mode 100644 index 0000000..beb7ec6 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -0,0 +1,191 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.AddFilmToLibraryCommand +import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.domain.exception.DomainException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibraryEntry +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.UUID + +class FilmLibraryServiceTest { + private lateinit var filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort + private lateinit var filmRepository: FilmRepositoryPort + private lateinit var idGenerator: IdGenerator + private lateinit var businessMetricsService: BusinessMetricsPort + private lateinit var filmLibraryService: FilmLibraryService + + @BeforeEach + fun setup() { + filmLibraryEntryRepository = mockk() + filmRepository = mockk() + idGenerator = mockk() + businessMetricsService = mockk(relaxed = true) + filmLibraryService = + FilmLibraryService( + filmLibraryEntryRepository, + filmRepository, + idGenerator, + businessMetricsService, + ) + } + + @Test + fun `should add film as new library entry`() { + val userId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val entryId = UUID.randomUUID() + val command = AddFilmToLibraryCommand(userId = userId, filmId = filmId) + val expectedEntry = + FilmLibraryEntry( + id = entryId, + userId = userId, + filmId = filmId, + comment = null, + isViewed = false, + ) + + every { filmRepository.findById(filmId) } returns Film(filmId, "Film", "Description") + every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns null + every { idGenerator.generateId() } returns entryId + every { + filmLibraryEntryRepository.save( + match { + it.userId == userId && it.filmId == filmId && it.comment == null && it.isViewed == false + }, + ) + } returns expectedEntry + + val result = filmLibraryService.addFilm(command) + + assertNotNull(result) + assertEquals(filmId, result.filmId) + assertEquals(userId, result.userId) + + verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } + verify(exactly = 1) { idGenerator.generateId() } + verify(exactly = 1) { filmLibraryEntryRepository.save(any()) } + } + + @Test + fun `should reset viewed state when adding existing entry`() { + val userId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val existingEntry = + FilmLibraryEntry( + id = UUID.randomUUID(), + userId = userId, + filmId = filmId, + comment = "My Library", + isViewed = true, + ) + val updatedEntry = existingEntry.copy(isViewed = false, watchedAt = null) + + every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns existingEntry + every { filmRepository.findById(filmId) } returns Film(filmId, "Film", "Description") + every { filmLibraryEntryRepository.save(updatedEntry) } returns updatedEntry + + val result = filmLibraryService.addFilm(AddFilmToLibraryCommand(userId = userId, filmId = filmId)) + + assertEquals(updatedEntry, result) + + verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 1) { filmLibraryEntryRepository.save(updatedEntry) } + } + + @Test + fun `should remove film from library successfully`() { + val userId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val entryId = UUID.randomUUID() + val existingEntry = + FilmLibraryEntry( + id = entryId, + userId = userId, + filmId = filmId, + comment = "My Library", + isViewed = false, + ) + val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId) + + every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns existingEntry + justRun { filmLibraryEntryRepository.deleteById(entryId) } + + val result = filmLibraryService.removeFilm(command) + + assertEquals(existingEntry, result) + + verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } + verify(exactly = 1) { filmLibraryEntryRepository.deleteById(entryId) } + } + + @Test + fun `should throw EntityNotFoundException when removing non-existent entry`() { + val userId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId) + + every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns null + + assertThrows { + filmLibraryService.removeFilm(command) + } + + verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } + verify(exactly = 0) { filmLibraryEntryRepository.deleteById(any()) } + } + + @Test + fun `should throw DomainException when entry id belongs to another film`() { + val userId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val entryId = UUID.randomUUID() + val existingEntry = + FilmLibraryEntry( + id = entryId, + userId = userId, + filmId = UUID.randomUUID(), + comment = "My Library", + isViewed = false, + ) + val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId, entryId = entryId) + + every { filmLibraryEntryRepository.findById(entryId) } returns existingEntry + + assertThrows { + filmLibraryService.removeFilm(command) + } + + verify(exactly = 1) { filmLibraryEntryRepository.findById(entryId) } + verify(exactly = 0) { filmLibraryEntryRepository.deleteById(any()) } + } + + @Test + fun `should list entries by user`() { + val userId = UUID.randomUUID() + val entries = + listOf( + FilmLibraryEntry(UUID.randomUUID(), userId, UUID.randomUUID(), null, false), + ) + + every { filmLibraryEntryRepository.findByUserId(userId) } returns entries + + assertEquals(entries, filmLibraryService.list(userId)) + + verify(exactly = 1) { filmLibraryEntryRepository.findByUserId(userId) } + } +} diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt new file mode 100644 index 0000000..5c48c2c --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt @@ -0,0 +1,184 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.CreateFilmCommand +import com.project.movienight.application.ports.input.EditFilmCommand +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.config.FilmServiceProperties +import com.project.movienight.domain.exception.BlockedValueException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.Film +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.UUID + +class FilmServiceTest { + private lateinit var filmRepository: FilmRepositoryPort + private lateinit var idGenerator: IdGenerator + private lateinit var filmConfig: FilmServiceProperties + private lateinit var businessMetricsService: BusinessMetricsPort + private lateinit var filmService: FilmService + + @BeforeEach + fun setup() { + filmRepository = mockk() + idGenerator = mockk() + filmConfig = mockk() + businessMetricsService = mockk(relaxed = true) + filmService = FilmService(filmRepository, idGenerator, filmConfig, businessMetricsService) + } + + @Test + fun `should create film successfully`() { + val command = CreateFilmCommand(title = "Inception", description = "A mind-bending thriller") + val filmId = UUID.randomUUID() + val expectedFilm = Film(id = filmId, title = "Inception", description = "A mind-bending thriller") + + every { filmConfig.isBlocked("Inception") } returns false + every { filmConfig.isBlocked("A mind-bending thriller") } returns false + every { idGenerator.generateId() } returns filmId + every { filmRepository.save(any()) } returns expectedFilm + + val result = filmService.create(command) + + assertNotNull(result) + assertEquals(filmId, result.id) + assertEquals("Inception", result.title) + assertEquals("A mind-bending thriller", result.description) + + verify(exactly = 1) { filmConfig.isBlocked("Inception") } + verify(exactly = 1) { filmConfig.isBlocked("A mind-bending thriller") } + verify(exactly = 1) { idGenerator.generateId() } + verify(exactly = 1) { filmRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when creating film with blocked title`() { + val command = CreateFilmCommand(title = "censored", description = "Some description") + + every { filmConfig.isBlocked("censored") } returns true + every { filmConfig.isBlocked("Some description") } returns false + + assertThrows { + filmService.create(command) + } + + verify(exactly = 1) { filmConfig.isBlocked("censored") } + verify(exactly = 0) { filmConfig.isBlocked("Some description") } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 0) { filmRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when creating film with blocked description`() { + val command = CreateFilmCommand(title = "Good Film", description = "python") + + every { filmConfig.isBlocked("Good Film") } returns false + every { filmConfig.isBlocked("python") } returns true + + assertThrows { + filmService.create(command) + } + + verify(exactly = 1) { filmConfig.isBlocked("Good Film") } + verify(exactly = 1) { filmConfig.isBlocked("python") } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 0) { filmRepository.save(any()) } + } + + @Test + fun `should edit film successfully`() { + val filmId = UUID.randomUUID() + val command = EditFilmCommand(title = "Inception 2", description = "The sequel") + val existingFilm = Film(id = filmId, title = "Inception", description = "A mind-bending thriller") + val updatedFilm = Film(id = filmId, title = "Inception 2", description = "The sequel") + + every { filmConfig.isBlocked("Inception 2") } returns false + every { filmConfig.isBlocked("The sequel") } returns false + every { filmRepository.findById(filmId) } returns existingFilm + every { filmRepository.save(any()) } returns updatedFilm + + val result = filmService.edit(filmId, command) + + assertNotNull(result) + assertEquals(filmId, result.id) + assertEquals("Inception 2", result.title) + assertEquals("The sequel", result.description) + + verify(exactly = 1) { filmConfig.isBlocked("Inception 2") } + verify(exactly = 1) { filmConfig.isBlocked("The sequel") } + verify(exactly = 1) { filmRepository.findById(filmId) } + verify(exactly = 1) { filmRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when editing film with blocked title`() { + val filmId = UUID.randomUUID() + val command = EditFilmCommand(title = "epstein", description = "Some description") + + every { filmConfig.isBlocked("epstein") } returns true + + assertThrows { + filmService.edit(filmId, command) + } + + verify(exactly = 1) { filmConfig.isBlocked("epstein") } + verify(exactly = 0) { filmRepository.findById(any()) } + verify(exactly = 0) { filmRepository.save(any()) } + } + + @Test + fun `should throw EntityNotFoundException when editing non-existent film`() { + val filmId = UUID.randomUUID() + val command = EditFilmCommand(title = "New Title", description = "New Description") + + every { filmConfig.isBlocked("New Title") } returns false + every { filmConfig.isBlocked("New Description") } returns false + every { filmRepository.findById(filmId) } returns null + + assertThrows { + filmService.edit(filmId, command) + } + + verify(exactly = 1) { filmConfig.isBlocked("New Title") } + verify(exactly = 1) { filmConfig.isBlocked("New Description") } + verify(exactly = 1) { filmRepository.findById(filmId) } + verify(exactly = 0) { filmRepository.save(any()) } + } + + @Test + fun `should delete film successfully`() { + val filmId = UUID.randomUUID() + val existingFilm = Film(id = filmId, title = "Inception", description = "A mind-bending thriller") + + every { filmRepository.findById(filmId) } returns existingFilm + justRun { filmRepository.deleteById(filmId) } + + filmService.delete(filmId) + + verify(exactly = 1) { filmRepository.findById(filmId) } + verify(exactly = 1) { filmRepository.deleteById(filmId) } + } + + @Test + fun `should throw EntityNotFoundException when deleting non-existent film`() { + val filmId = UUID.randomUUID() + + every { filmRepository.findById(filmId) } returns null + + assertThrows { + filmService.delete(filmId) + } + + verify(exactly = 1) { filmRepository.findById(filmId) } + verify(exactly = 0) { filmRepository.deleteById(any()) } + } +} diff --git a/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt new file mode 100644 index 0000000..ce94f2e --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt @@ -0,0 +1,155 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.CreateUserCommand +import com.project.movienight.application.ports.input.EditUserCommand +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.config.UserServiceProperties +import com.project.movienight.domain.exception.BlockedValueException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.User +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.UUID + +class UserServiceTest { + private lateinit var userRepository: UserRepositoryPort + private lateinit var idGenerator: IdGenerator + private lateinit var userConfig: UserServiceProperties + private lateinit var userService: UserService + + @BeforeEach + fun setup() { + userRepository = mockk() + idGenerator = mockk() + userConfig = mockk() + userService = UserService(userRepository, idGenerator, userConfig) + } + + @Test + fun `should create user successfully`() { + val command = CreateUserCommand(name = "John Doe", email = "john@example.com") + val userId = UUID.randomUUID() + val expectedUser = User(id = userId, name = "John Doe", email = "john@example.com") + + every { userConfig.isBlocked("John Doe") } returns false + every { idGenerator.generateId() } returns userId + every { userRepository.save(any()) } returns expectedUser + + val result = userService.create(command) + + assertNotNull(result) + assertEquals(userId, result.id) + assertEquals("John Doe", result.name) + assertEquals("john@example.com", result.email) + + verify(exactly = 1) { userConfig.isBlocked("John Doe") } + verify(exactly = 1) { idGenerator.generateId() } + verify(exactly = 1) { userRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when creating user with blocked name`() { + val command = CreateUserCommand(name = "admin", email = "admin@example.com") + + every { userConfig.isBlocked("admin") } returns true + + assertThrows { + userService.create(command) + } + + verify(exactly = 1) { userConfig.isBlocked("admin") } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 0) { userRepository.save(any()) } + } + + @Test + fun `should edit user successfully`() { + val userId = UUID.randomUUID() + val command = EditUserCommand(name = "Jane Doe") + val existingUser = User(id = userId, name = "John Doe", email = "john@example.com") + val updatedUser = User(id = userId, name = "Jane Doe", email = "john@example.com") + + every { userConfig.isBlocked("Jane Doe") } returns false + every { userRepository.findById(userId) } returns existingUser + every { userRepository.save(any()) } returns updatedUser + + val result = userService.edit(userId, command) + + assertNotNull(result) + assertEquals(userId, result.id) + assertEquals("Jane Doe", result.name) + + verify(exactly = 1) { userConfig.isBlocked("Jane Doe") } + verify(exactly = 1) { userRepository.findById(userId) } + verify(exactly = 1) { userRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when editing user with blocked name`() { + val userId = UUID.randomUUID() + val command = EditUserCommand(name = "root") + + every { userConfig.isBlocked("root") } returns true + + assertThrows { + userService.edit(userId, command) + } + + verify(exactly = 1) { userConfig.isBlocked("root") } + verify(exactly = 0) { userRepository.findById(any()) } + verify(exactly = 0) { userRepository.save(any()) } + } + + @Test + fun `should throw EntityNotFoundException when editing non-existent user`() { + val userId = UUID.randomUUID() + val command = EditUserCommand(name = "Jane Doe") + + every { userConfig.isBlocked("Jane Doe") } returns false + every { userRepository.findById(userId) } returns null + + assertThrows { + userService.edit(userId, command) + } + + verify(exactly = 1) { userConfig.isBlocked("Jane Doe") } + verify(exactly = 1) { userRepository.findById(userId) } + verify(exactly = 0) { userRepository.save(any()) } + } + + @Test + fun `should delete user successfully`() { + val userId = UUID.randomUUID() + val existingUser = User(id = userId, name = "John Doe", email = "john@example.com") + + every { userRepository.findById(userId) } returns existingUser + justRun { userRepository.deleteById(userId) } + + userService.delete(userId) + + verify(exactly = 1) { userRepository.findById(userId) } + verify(exactly = 1) { userRepository.deleteById(userId) } + } + + @Test + fun `should throw EntityNotFoundException when deleting non-existent user`() { + val userId = UUID.randomUUID() + + every { userRepository.findById(userId) } returns null + + assertThrows { + userService.delete(userId) + } + + verify(exactly = 1) { userRepository.findById(userId) } + verify(exactly = 0) { userRepository.deleteById(any()) } + } +} diff --git a/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt b/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt new file mode 100644 index 0000000..34ae9c7 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt @@ -0,0 +1,29 @@ +package com.project.movienight.config + +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain + +@TestConfiguration +@EnableWebSecurity +class TestSecurityConfiguration { + @Bean + @Primary + fun testSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .authorizeHttpRequests { auth -> + auth.anyRequest().permitAll() + }.csrf { csrf -> + csrf.disable() + }.headers { headers -> + headers.frameOptions { frameOptions -> + frameOptions.sameOrigin() + } + } + + return http.build() + } +} diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt new file mode 100644 index 0000000..c91e49e --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt @@ -0,0 +1,140 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateFilmRequest +import com.project.movienight.adapters.web.dto.request.EditFilmRequest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +class FilmControllerTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `create film should return 201 CREATED`() { + val request = + CreateFilmRequest( + title = "The Matrix", + description = "A computer hacker learns about the true nature of reality", + ) + + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.title").value("The Matrix")) + .andExpect(jsonPath("$.description").value("A computer hacker learns about the true nature of reality")) + .andExpect(jsonPath("$.id").exists()) + } + + @Test + fun `edit film should return updated film`() { + val createRequest = + CreateFilmRequest( + title = "Old Title", + description = "Old Description", + ) + + val response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)), + ).andReturn() + + val filmId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + val editRequest = + EditFilmRequest( + title = "New Title", + description = "New Description", + ) + + mockMvc + .perform( + patch("/api/films/$filmId") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(editRequest)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("New Title")) + .andExpect(jsonPath("$.description").value("New Description")) + } + + @Test + fun `search film by title should return film`() { + val request = + CreateFilmRequest( + title = "Inception", + description = "Dream within a dream", + ) + + mockMvc.perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ) + + mockMvc + .perform( + get("/api/films/search") + .param("title", "Inception"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("Inception")) + .andExpect(jsonPath("$.description").value("Dream within a dream")) + } + + @Test + fun `search film by non-existent title should return 404`() { + mockMvc + .perform( + get("/api/films/search") + .param("title", "NonExistentFilm12345"), + ).andExpect(status().isNotFound) + } + + @Test + fun `delete film should return 204 NO CONTENT`() { + val request = + CreateFilmRequest( + title = "Film To Delete", + description = "This film will be deleted", + ) + + val response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andReturn() + + val filmId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + mockMvc + .perform(delete("/api/films/$filmId")) + .andExpect(status().isNoContent()) + + mockMvc + .perform( + get("/api/films/search").param("title", "Film To Delete"), + ).andExpect(status().isNotFound) + } +} diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt new file mode 100644 index 0000000..16d835d --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt @@ -0,0 +1,204 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateFilmRequest +import com.project.movienight.adapters.web.dto.request.CreateUserRequest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@Transactional +class FilmLibraryControllerTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `add film to library should work`() { + val userRequest = + CreateUserRequest( + name = "Film Adder", + email = "adder@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val filmRequest = + CreateFilmRequest( + title = "Library Film", + description = "Film description", + ) + val filmResponse = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)), + ).andReturn() + val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() + + mockMvc + .perform( + post("/api/users/$userId/library/films/$filmId"), + ).andExpect(status().isCreated()) + } + + @Test + fun `remove film from library should return 204`() { + val userRequest = + CreateUserRequest( + name = "Remove Film", + email = "remove@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val filmRequest = + CreateFilmRequest( + title = "Film To Remove", + description = "Will be removed", + ) + val filmResponse = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)), + ).andReturn() + val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() + + mockMvc.perform(post("/api/users/$userId/library/films/$filmId")) + mockMvc + .perform(delete("/api/users/$userId/library/films/$filmId")) + .andExpect(status().isNoContent()) + } + + @Test + fun `get available films should exclude film in user's library`() { + val userRequest = + CreateUserRequest( + name = "Available Films User", + email = "availablefilms@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val film1Request = + CreateFilmRequest( + title = "Film In Library", + description = "This will be in the library", + ) + val film1Response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(film1Request)), + ).andReturn() + val film1Id = objectMapper.readTree(film1Response.response.contentAsString).get("id").asText() + + val film2Request = + CreateFilmRequest( + title = "Film Not In Library", + description = "This will not be in the library", + ) + val film2Response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(film2Request)), + ).andReturn() + val film2Id = objectMapper.readTree(film2Response.response.contentAsString).get("id").asText() + + mockMvc.perform(post("/api/users/$userId/library/films/$film1Id")) + + val result = + mockMvc + .perform( + get("/api/users/$userId/library/available-films"), + ).andExpect(status().isOk) + .andReturn() + + val responseBody = result.response.contentAsString + val films = objectMapper.readTree(responseBody) + val returnedIds = films.toList().map { it.get("id").asText() } + assert(!returnedIds.contains(film1Id)) { "Film in library should not appear in available films" } + assert(returnedIds.contains(film2Id)) { "Film not in library should appear in available films" } + } + + @Test + fun `get available films for user without library returns all films`() { + val userRequest = + CreateUserRequest( + name = "No Library User", + email = "nolibrary@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val filmRequest = + CreateFilmRequest( + title = "Available Film", + description = "Should appear in available films", + ) + val filmResponse = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)), + ).andReturn() + val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() + + val result = + mockMvc + .perform( + get("/api/users/$userId/library/available-films"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$[*].id").isArray) + .andReturn() + + val responseBody = result.response.contentAsString + val films = objectMapper.readTree(responseBody) + val returnedIds = films.toList().map { it.get("id").asText() } + assert(returnedIds.contains(filmId)) { "Film should appear in available films when user has no library" } + } +} diff --git a/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt b/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt new file mode 100644 index 0000000..0a98a25 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt @@ -0,0 +1,148 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import org.hamcrest.Matchers.hasItem +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional + +@SpringBootTest( + properties = [ + "integrations.jellyfin.enabled=true", + "integrations.jellyfin.plugin-token=test-token", + "integrations.jellyfin.web-url=https://jellyfin.example.test", + ], +) +@AutoConfigureMockMvc(addFilters = false) +@Transactional +class JellyfinPluginContractTest { + private val jellyfinUserId = "11111111111111111111111111111111" + private val dashedJellyfinUserId = "11111111-1111-1111-1111-111111111111" + private val jellyfinItemId = "22222222222222222222222222222222" + private val dashedJellyfinItemId = "22222222-2222-2222-2222-222222222222" + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `plugin sync payload creates mapped user and film`() { + postSyncPayload() + + val syncedRecommendationTitlePath = "$[?(@.jellyfinItemId == '$jellyfinItemId')].title" + val syncedRecommendationWatchUrlPath = "$[?(@.jellyfinItemId == '$jellyfinItemId')].watchUrl" + val expectedWatchUrl = "https://jellyfin.example.test/web/#/details?id=$jellyfinItemId" + + mockMvc + .perform( + get("/api/integrations/jellyfin/users/$dashedJellyfinUserId/recommendations") + .header("X-MovieNight-Plugin-Token", "test-token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$[*].jellyfinItemId").value(hasItem(jellyfinItemId))) + .andExpect(jsonPath(syncedRecommendationTitlePath).value(hasItem("Jellyfin Contract Film"))) + .andExpect(jsonPath(syncedRecommendationWatchUrlPath).value(hasItem(expectedWatchUrl))) + + mockMvc + .perform( + post("/api/integrations/jellyfin/users/$dashedJellyfinUserId/ratings/items/$dashedJellyfinItemId") + .header("X-MovieNight-Plugin-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"score":8,"note":"From Jellyfin UI"}"""), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.score").value(8)) + + val viewedPath = + "/api/integrations/jellyfin/users/$dashedJellyfinUserId/library/items/" + + "$dashedJellyfinItemId/viewed" + + mockMvc + .perform( + post(viewedPath) + .header("X-MovieNight-Plugin-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"watchedAt":"2026-05-22T10:15:30Z"}"""), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewed").value(true)) + + mockMvc + .perform( + get("/api/integrations/jellyfin/sync-state") + .header("X-MovieNight-Plugin-Token", "test-token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$[0].syncedItemCount").value(1)) + } + + @Test + fun `plugin token is required when configured`() { + mockMvc + .perform( + post("/api/integrations/jellyfin/sync") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(syncPayload())), + ).andExpect(status().isUnauthorized) + } + + @Test + fun `plugin onboarding can create user before first sync`() { + mockMvc + .perform( + post("/api/integrations/jellyfin/users/$dashedJellyfinUserId/recommendation-onboarding") + .header("X-MovieNight-Plugin-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"weightedGenres":{"Drama":5},"contentTypes":["FILM"]}"""), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.userId").exists()) + } + + private fun postSyncPayload() { + mockMvc + .perform( + post("/api/integrations/jellyfin/sync") + .header("X-MovieNight-Plugin-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(syncPayload())), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.syncedUsers").value(1)) + .andExpect(jsonPath("$.syncedItems").value(1)) + } + + private fun syncPayload(): Map = + mapOf( + "users" to + listOf( + mapOf( + "jellyfinUserId" to jellyfinUserId, + "name" to "Jellyfin User", + ), + ), + "items" to + listOf( + mapOf( + "jellyfinItemId" to jellyfinItemId, + "title" to "Jellyfin Contract Film", + "description" to "Synced from plugin payload", + "year" to 2026, + "genres" to listOf("Drama"), + "imdbId" to "tt1234567", + "userStates" to + listOf( + mapOf( + "jellyfinUserId" to jellyfinUserId, + "isViewed" to false, + "playCount" to 0, + ), + ), + ), + ), + ) +} diff --git a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt new file mode 100644 index 0000000..342fda9 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt @@ -0,0 +1,108 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateUserRequest +import com.project.movienight.adapters.web.dto.request.EditUserRequest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@Transactional +class UserControllerTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `create user should return 201 CREATED`() { + val request = + CreateUserRequest( + name = "John Doe", + email = "john@example.com", + ) + + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.name").value("John Doe")) + .andExpect(jsonPath("$.email").value("john@example.com")) + .andExpect(jsonPath("$.id").exists()) + } + + @Test + fun `edit user should return updated user`() { + val createRequest = + CreateUserRequest( + name = "Old Name", + email = "edit@example.com", + ) + + val response = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)), + ).andReturn() + + val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + val editRequest = EditUserRequest(name = "New Name") + + mockMvc + .perform( + patch("/api/users/$userId") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(editRequest)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.name").value("New Name")) + .andExpect(jsonPath("$.email").value("edit@example.com")) + } + + @Test + fun `delete user should return 204 NO CONTENT`() { + val request = + CreateUserRequest( + name = "User To Delete", + email = "delete@example.com", + ) + + val response = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andReturn() + + val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + mockMvc + .perform(delete("/api/users/$userId")) + .andExpect(status().isNoContent()) + } + + @Test + fun `delete non-existent user should return 404`() { + val nonExistentId = "123e4567-e89b-12d3-a456-426614174000" + mockMvc + .perform(delete("/api/users/$nonExistentId")) + .andExpect(status().isNotFound()) + } +} diff --git a/src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt b/src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt new file mode 100644 index 0000000..7f2ee9e --- /dev/null +++ b/src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt @@ -0,0 +1,74 @@ +package com.project.movienight.domain.model + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.UUID + +class UserRecommendationWeightsTest { + @Test + fun `should keep default weights normalized`() { + val weights = UserRecommendationWeights.defaultFor(UUID.randomUUID()).normalized() + + assertEquals(1.0, weights.scoreWeightSum(), EPSILON) + assertEquals(1.0, weights.vectorWeightSum(), EPSILON) + assertEquals(0.55, weights.relevanceWeight, EPSILON) + assertEquals(0.35, weights.plotVectorWeight, EPSILON) + } + + @Test + fun `should normalize and bound invalid weights`() { + val weights = + UserRecommendationWeights( + userId = UUID.randomUUID(), + relevanceWeight = 100.0, + qualityWeight = -5.0, + contextWeight = 0.0, + noveltyWeight = 0.0, + diversityWeight = 0.0, + genreVectorWeight = 100.0, + plotVectorWeight = 0.0, + moodVectorWeight = 0.0, + eraVectorWeight = 0.0, + peopleVectorWeight = 0.0, + contentTypeVectorWeight = 0.0, + ).normalized() + + assertEquals(1.0, weights.scoreWeightSum(), EPSILON) + assertEquals(1.0, weights.vectorWeightSum(), EPSILON) + assertTrue( + listOf( + weights.relevanceWeight, + weights.qualityWeight, + weights.contextWeight, + weights.noveltyWeight, + weights.diversityWeight, + ).all { it in UserRecommendationWeights.MIN_SCORE_WEIGHT..UserRecommendationWeights.MAX_SCORE_WEIGHT }, + ) + assertTrue( + listOf( + weights.genreVectorWeight, + weights.plotVectorWeight, + weights.moodVectorWeight, + weights.eraVectorWeight, + weights.peopleVectorWeight, + weights.contentTypeVectorWeight, + ).all { it in UserRecommendationWeights.MIN_VECTOR_WEIGHT..UserRecommendationWeights.MAX_VECTOR_WEIGHT }, + ) + } + + private fun UserRecommendationWeights.scoreWeightSum(): Double = + relevanceWeight + qualityWeight + contextWeight + noveltyWeight + diversityWeight + + private fun UserRecommendationWeights.vectorWeightSum(): Double = + genreVectorWeight + + plotVectorWeight + + moodVectorWeight + + eraVectorWeight + + peopleVectorWeight + + contentTypeVectorWeight + + private companion object { + private const val EPSILON = 0.000001 + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..03e6517 --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,32 @@ +spring: + application: + name: MovieNight-Test + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + driver-class-name: org.h2.Driver + flyway: + enabled: true + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + locations: classpath:db/migration + baseline-on-migrate: true + h2: + console: + enabled: false + +services: + user: + blocked-names: + - admin + - root + - system + film: + blocked-patterns: + - censored + - epstein + - python + +integrations: + jellyfin: + web-url: https://jellyfin.example.test