diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
new file mode 100644
index 0000000..8cd8d59
--- /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: Run CI quality gate
+ run: ./gradlew clean check bootJar --stacktrace --no-daemon
+
+ - name: Set artifact name
+ id: meta
+ run: echo "artifact-name=build-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT"
+
+ - 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..ece7eb1
--- /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/') }}
+ 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/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..468b2ce 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,10 @@ 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.security)
implementation(libs.spring.boot.starter.cache)
implementation(libs.spring.boot.starter.data.jdbc)
implementation(libs.spring.boot.starter.validation)
@@ -40,12 +41,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 +57,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/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2e91484..72e81e2 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -11,8 +11,10 @@ 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-security = { module = "org.springframework.boot:spring-boot-starter-security" }
@@ -33,6 +35,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/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..1a107da
--- /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.JellyfinLibraryItemSnapshot
+import com.project.movienight.application.ports.output.JellyfinRemoteUser
+import com.project.movienight.application.ports.output.JellyfinSyncSourcePort
+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,
+) : JellyfinSyncSourcePort {
+ 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..d06d815
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt
@@ -0,0 +1,76 @@
+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,
+) {
+ private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_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_seconds")
+ .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 recordRecommendationRequest() {
+ recommendationRequests.increment()
+ }
+
+ fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
+ Counter
+ .builder("recommendation_weights_updated_total")
+ .tag("eventType", eventType.name)
+ .register(meterRegistry)
+ .increment()
+ }
+
+ 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..58e2c3c
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt
@@ -0,0 +1,41 @@
+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,
+ library = null,
+ 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/FilmLibraryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt
index f5603cb..9fa474d 100644
--- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt
@@ -18,6 +18,7 @@ class FilmLibraryRepository(
filmId = UUID.fromString(rs.getString("film_id")),
comment = rs.getString("comment"),
isViewed = rs.getBoolean("is_viewed"),
+ watchedAt = rs.getTimestamp("watched_at")?.toLocalDateTime(),
)
}
@@ -26,26 +27,28 @@ class FilmLibraryRepository(
jdbc.update(
"""
UPDATE favorites
- SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?
+ SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?, watched_at = ?
WHERE id = ?
""".trimIndent(),
filmLibrary.userId,
filmLibrary.filmId,
filmLibrary.comment,
filmLibrary.isViewed,
+ filmLibrary.watchedAt,
filmLibrary.id,
)
if (updatedRows == 0) {
jdbc.update(
"""
- INSERT INTO favorites (id, user_id, film_id, comment, is_viewed)
- VALUES (?, ?, ?, ?, ?)
+ INSERT INTO favorites (id, user_id, film_id, comment, is_viewed, watched_at)
+ VALUES (?, ?, ?, ?, ?, ?)
""".trimIndent(),
filmLibrary.id,
filmLibrary.userId,
filmLibrary.filmId,
filmLibrary.comment,
filmLibrary.isViewed,
+ filmLibrary.watchedAt,
)
}
return filmLibrary
@@ -54,16 +57,33 @@ class FilmLibraryRepository(
override fun findById(id: UUID): FilmLibrary? {
val entries =
jdbc.query(
- "SELECT id, user_id, film_id, comment, is_viewed FROM favorites WHERE id = ?",
+ "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE id = ?",
filmLibraryRowMapper,
id,
)
return entries.firstOrNull()
}
+ override fun findByUserIdAndFilmId(
+ userId: UUID,
+ filmId: UUID,
+ ): FilmLibrary? {
+ val entries =
+ jdbc.query(
+ """
+ SELECT id, user_id, film_id, comment, is_viewed, watched_at
+ FROM favorites WHERE user_id = ? AND film_id = ?
+ """.trimIndent(),
+ filmLibraryRowMapper,
+ userId,
+ filmId,
+ )
+ return entries.firstOrNull()
+ }
+
override fun findAll(): List =
jdbc.query(
- "SELECT id, user_id, film_id, comment, is_viewed FROM favorites",
+ "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites",
filmLibraryRowMapper,
)
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..b1d4ab2 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,137 @@ 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 findByJellyfinLibraryId(jellyfinLibraryId: 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_library_id = ?
+ """.trimIndent(),
+ filmRowMapper,
+ jellyfinLibraryId,
+ )
+ 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..80334de
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt
@@ -0,0 +1,46 @@
+package com.project.movienight.adapters.persistence.jdbc
+
+import com.project.movienight.application.ports.output.JellyfinEventRepositoryPort
+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,
+) : JellyfinEventRepositoryPort {
+ override fun save(
+ eventId: String,
+ serverId: String?,
+ eventType: String,
+ occurredAt: java.time.OffsetDateTime?,
+ jellyfinUserId: String?,
+ jellyfinItemId: String?,
+ payload: String?,
+ ): Int {
+ 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", eventId)
+ .addValue("serverId", serverId)
+ .addValue("eventType", eventType)
+ .addValue("occurredAt", occurredAt)
+ .addValue("jellyfinUserId", jellyfinUserId)
+ .addValue("jellyfinItemId", jellyfinItemId)
+ .addValue("payload", payload)
+
+ return jdbc.update(sql, params)
+ }
+
+ override fun delete(eventId: String) {
+ val sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId"
+ val params = MapSqlParameterSource().addValue("eventId", eventId)
+ jdbc.update(sql, params)
+ }
+}
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..ae34f89 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,10 @@
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.model.AuthProvider
import com.project.movienight.domain.model.User
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
@@ -11,58 +15,111 @@ 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) {
+ val existingEntity = existingUser.toEntity()
+ user.toEntity(
+ provider = existingEntity.provider?.let { AuthProvider.valueOf(it) },
+ providerId = existingEntity.providerId,
+ createdAt = existingEntity.createdAt,
+ )
+ } 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 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, created_at FROM users WHERE email = ?",
+ userEntityRowMapper,
+ email,
+ )
+ 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()
+ }
}
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..b64ea71
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt
@@ -0,0 +1,86 @@
+package com.project.movienight.adapters.security
+
+import com.project.movienight.adapters.persistence.entity.toDomain
+import com.project.movienight.adapters.persistence.entity.toEntity
+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())
+ val entity =
+ userByEmail.toEntity(
+ provider = provider,
+ providerId = userInfo.getProviderId(),
+ )
+ userRepository.save(entity.toDomain())
+ } else {
+ log.debug("Creating new user for provider: {}", userInfo.getProvider())
+ val newUser =
+ User(
+ id = idGenerator.generateId(),
+ name = userInfo.getName(),
+ email = userInfo.getEmail(),
+ library = null,
+ )
+ val entity =
+ newUser.toEntity(
+ provider = provider,
+ providerId = userInfo.getProviderId(),
+ )
+ userRepository.save(entity.toDomain())
+ }
+ }
+ }
+}
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..0bcb1b3
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt
@@ -0,0 +1,42 @@
+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")
+ .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..40b3362 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt
@@ -3,6 +3,8 @@ 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.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
@@ -10,22 +12,60 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class ApiExceptionHandler {
+ private val log = LoggerFactory.getLogger(javaClass)
+
@ExceptionHandler(EntityNotFoundException::class)
@ResponseStatus(HttpStatus.NOT_FOUND)
- fun handleNotFound(exception: EntityNotFoundException): ErrorResponse =
- ErrorResponse(message = exception.message ?: "Entity not found")
+ fun handleNotFound(exception: EntityNotFoundException): ErrorResponse {
+ val traceId = currentTraceId()
+ log.warn("Entity not found: traceId='{}', message='{}'", traceId, exception.message)
+
+ return ErrorResponse(
+ message = exception.message ?: "Entity 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(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/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt
index d4d52ef..9bec40b 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt
@@ -8,23 +8,38 @@ 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.GetAllFilmsUseCase
+import com.project.movienight.application.ports.input.GetFilmByIdUseCase
+import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase
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
+private fun String.toContentTypeOrFilm(): com.project.movienight.domain.model.ContentType =
+ runCatching {
+ com.project.movienight.domain.model.ContentType
+ .valueOf(this)
+ }.getOrDefault(com.project.movienight.domain.model.ContentType.FILM)
+
@RestController
@RequestMapping("/api/films")
class FilmController(
private val createFilmUseCase: CreateFilmUseCase,
private val editFilmUseCase: EditFilmUseCase,
private val deleteFilmUseCase: DeleteFilmUseCase,
+ private val getFilmByIdUseCase: GetFilmByIdUseCase,
+ private val getAllFilmsUseCase: GetAllFilmsUseCase,
+ private val searchFilmByTitleUseCase: SearchFilmByTitleUseCase,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@@ -36,6 +51,16 @@ class FilmController(
CreateFilmCommand(
title = request.title,
description = request.description,
+ contentType = request.contentType.toContentTypeOrFilm(),
+ 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,
),
),
)
@@ -52,6 +77,16 @@ class FilmController(
EditFilmCommand(
title = request.title,
description = request.description,
+ contentType = request.contentType.toContentTypeOrFilm(),
+ 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,
),
),
)
@@ -61,4 +96,24 @@ class FilmController(
fun delete(
@PathVariable id: UUID,
) = deleteFilmUseCase.delete(id)
+
+ @GetMapping("/{id}")
+ fun getById(
+ @PathVariable id: UUID,
+ ): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id))
+
+ @GetMapping
+ fun getAll(): List = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) }
+
+ @GetMapping("/search")
+ fun searchByTitle(
+ @RequestParam title: String,
+ ): ResponseEntity {
+ val film = searchFilmByTitleUseCase.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..81115cd 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt
@@ -2,14 +2,21 @@ 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.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.GetAllFilmsUseCase
+import com.project.movienight.application.ports.input.GetFilmByIdUseCase
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
+import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase
+import com.project.movienight.application.ports.input.MarkFilmViewedCommand
+import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
+import com.project.movienight.domain.exception.EntityNotFoundException
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
@@ -26,8 +33,11 @@ import java.util.UUID
class FilmLibraryController(
private val createFilmLibraryUseCase: CreateFilmLibraryUseCase,
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
+ private val markFilmViewedUseCase: MarkFilmViewedUseCase,
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
+ private val getAllFilmsUseCase: GetAllFilmsUseCase,
+ private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@@ -54,6 +64,11 @@ class FilmLibraryController(
),
)
+ @GetMapping("/entries")
+ fun list(
+ @PathVariable userId: UUID,
+ ): List = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) }
+
@PostMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.CREATED)
fun addFilm(
@@ -69,15 +84,58 @@ class FilmLibraryController(
),
)
+ @PostMapping("/films/{filmId}/viewed")
+ fun markViewed(
+ @PathVariable userId: UUID,
+ @PathVariable filmId: UUID,
+ ): FilmLibraryResponse =
+ FilmLibraryResponse.fromDomain(
+ markFilmViewedUseCase.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,
- ),
- )
+ ) {
+ removeFilmFromLibraryUseCase.removeFilm(
+ RemoveFilmFromLibraryCommand(
+ userId = userId,
+ filmId = filmId,
+ ),
+ )
+ }
+
+ @GetMapping("/available-films")
+ fun getAvailableFilms(
+ @PathVariable userId: UUID,
+ ): List {
+ val userLibrary =
+ runCatching {
+ getFilmLibraryUseCase.getLibrary(
+ GetFilmLibraryQuery(userId = userId),
+ )
+ }.onFailure { exception ->
+ if (exception !is EntityNotFoundException) {
+ throw exception
+ }
+ }.getOrNull()
+
+ val allFilms = getAllFilmsUseCase.getAll()
+
+ val availableFilms =
+ if (userLibrary != null) {
+ allFilms.filter { it.id != userLibrary.filmId }
+ } else {
+ allFilms
+ }
+
+ return availableFilms.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..fec4889
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt
@@ -0,0 +1,46 @@
+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.GetFilmRatingsUseCase
+import com.project.movienight.application.ports.input.RateFilmCommand
+import com.project.movienight.application.ports.input.RateFilmUseCase
+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 rateFilmUseCase: RateFilmUseCase,
+ private val getFilmRatingsUseCase: GetFilmRatingsUseCase,
+) {
+ @PostMapping("/films/{filmId}")
+ @ResponseStatus(HttpStatus.CREATED)
+ fun rate(
+ @PathVariable userId: UUID,
+ @PathVariable filmId: UUID,
+ @RequestBody request: RateFilmRequest,
+ ): FilmRatingResponse =
+ FilmRatingResponse.fromDomain(
+ rateFilmUseCase.rate(
+ RateFilmCommand(
+ userId = userId,
+ filmId = filmId,
+ score = request.score,
+ note = request.note,
+ ),
+ ),
+ )
+
+ @GetMapping
+ fun list(
+ @PathVariable userId: UUID,
+ ): List = getFilmRatingsUseCase.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..81ee52c
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt
@@ -0,0 +1,56 @@
+package com.project.movienight.adapters.web
+
+import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest
+import com.project.movienight.application.services.JellyfinEventService
+import com.project.movienight.config.JellyfinIntegrationProperties
+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
+import org.springframework.web.server.ResponseStatusException
+
+@RestController
+@RequestMapping("/api/integrations/jellyfin")
+class JellyfinEventsController(
+ private val jellyfinEventService: JellyfinEventService,
+ private val properties: JellyfinIntegrationProperties,
+) {
+ 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?,
+ @RequestBody request: JellyfinEventRequest,
+ ) {
+ if (!properties.enabled) {
+ throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
+ }
+
+ if (properties.pluginToken.isNotBlank()) {
+ if (token == null || token != properties.pluginToken) {
+ throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
+ }
+ }
+
+ log.debug(
+ "Received Jellyfin event {} for user {} item {}",
+ request.eventId,
+ request.jellyfinUserId,
+ request.itemId,
+ )
+ jellyfinEventService.handleEvent(
+ 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/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt
new file mode 100644
index 0000000..74aac90
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt
@@ -0,0 +1,21 @@
+package com.project.movienight.adapters.web
+
+import com.project.movienight.application.services.JellyfinSyncService
+import com.project.movienight.domain.model.JellyfinSyncState
+import com.project.movienight.domain.model.JellyfinSyncSummary
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+@RequestMapping("/api/integrations/jellyfin")
+class JellyfinSyncController(
+ private val jellyfinSyncService: JellyfinSyncService,
+) {
+ @PostMapping("/sync")
+ fun syncNow(): JellyfinSyncSummary = jellyfinSyncService.syncNow()
+
+ @GetMapping("/sync-state")
+ fun syncState(): List = jellyfinSyncService.getSyncStates()
+}
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..6d5bdd9
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt
@@ -0,0 +1,92 @@
+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 com.project.movienight.domain.model.ContentType
+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 = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() },
+ 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..e06954c 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt
@@ -8,8 +8,11 @@ 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.GetAllUsersUseCase
+import com.project.movienight.application.ports.input.GetUserByIdUseCase
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.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
@@ -25,6 +28,8 @@ class UserController(
private val createUserUseCase: CreateUserUseCase,
private val editUserUseCase: EditUserUseCase,
private val deleteUserUseCase: DeleteUserUseCase,
+ private val getUserByIdUseCase: GetUserByIdUseCase,
+ private val getAllUsersUseCase: GetAllUsersUseCase,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@@ -40,6 +45,14 @@ class UserController(
),
)
+ @GetMapping
+ fun getAll(): List = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) }
+
+ @GetMapping("/{id}")
+ fun getById(
+ @PathVariable id: UUID,
+ ): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id))
+
@PatchMapping("/{id}")
fun edit(
@PathVariable id: UUID,
@@ -51,6 +64,7 @@ class UserController(
command =
EditUserCommand(
name = request.name,
+ jellyfinUserId = request.jellyfinUserId,
),
),
)
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..276565b
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt
@@ -0,0 +1,53 @@
+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.GetUserPreferencesUseCase
+import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
+import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase
+import com.project.movienight.domain.model.ContentType
+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 upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase,
+ private val getUserPreferencesUseCase: GetUserPreferencesUseCase,
+) {
+ @PutMapping
+ fun upsert(
+ @PathVariable userId: UUID,
+ @RequestBody request: UpsertUserPreferencesRequest,
+ ): UserPreferencesResponse =
+ UserPreferencesResponse.fromDomain(
+ upsertUserPreferencesUseCase.upsert(
+ UpsertUserPreferencesCommand(
+ userId = userId,
+ weightedGenres = request.weightedGenres,
+ plotTypes = request.plotTypes,
+ eras = request.eras,
+ castAndDirectors = request.castAndDirectors,
+ moods = request.moods,
+ contentTypes =
+ request.contentTypes.mapNotNull {
+ runCatching {
+ ContentType.valueOf(
+ it,
+ )
+ }.getOrNull()
+ },
+ ),
+ ),
+ )
+
+ @GetMapping
+ fun get(
+ @PathVariable userId: UUID,
+ ): UserPreferencesResponse? = getUserPreferencesUseCase.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/CreateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt
index 994429d..82f7348 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
@@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request
data class CreateFilmRequest(
val title: String,
val description: String,
+ val contentType: String = "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,
)
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..60eddce 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
@@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request
data class EditFilmRequest(
val title: String,
val description: String,
+ val contentType: String = "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,
)
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..358e0e4 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
@@ -2,4 +2,5 @@ package com.project.movienight.adapters.web.dto.request
data class EditUserRequest(
val name: String,
+ 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..68abfe8
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt
@@ -0,0 +1,21 @@
+package com.project.movienight.adapters.web.dto.request
+
+import com.fasterxml.jackson.annotation.JsonProperty
+import java.time.OffsetDateTime
+
+data class JellyfinEventRequest(
+ @JsonProperty("event_id")
+ val eventId: String,
+ @JsonProperty("event_type")
+ val eventType: String,
+ @JsonProperty("occurred_at")
+ val occurredAt: OffsetDateTime,
+ @JsonProperty("jellyfin_user_id")
+ val jellyfinUserId: String,
+ @JsonProperty("item_id")
+ 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/RateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt
new file mode 100644
index 0000000..1f44e39
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt
@@ -0,0 +1,6 @@
+package com.project.movienight.adapters.web.dto.request
+
+data class RateFilmRequest(
+ val score: Int,
+ 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/FilmLibraryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt
index 8ba6c01..90a339d 100644
--- 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
@@ -9,6 +9,7 @@ data class FilmLibraryResponse(
val filmId: UUID,
val comment: String?,
val isViewed: Boolean,
+ val watchedAt: java.time.LocalDateTime?,
) {
companion object {
fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse =
@@ -18,6 +19,7 @@ data class FilmLibraryResponse(
filmId = filmLibrary.filmId,
comment = filmLibrary.comment,
isViewed = filmLibrary.isViewed,
+ watchedAt = filmLibrary.watchedAt,
)
}
}
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..3100547 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,6 +1,7 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.FilmLibrary
+import java.time.LocalDateTime
import java.util.UUID
interface CreateFilmLibraryUseCase {
@@ -21,6 +22,16 @@ data class AddFilmToLibraryCommand(
val filmId: UUID,
)
+interface MarkFilmViewedUseCase {
+ fun markViewed(command: MarkFilmViewedCommand): FilmLibrary
+}
+
+data class MarkFilmViewedCommand(
+ val userId: UUID,
+ val filmId: UUID,
+ val watchedAt: LocalDateTime? = null,
+)
+
interface RemoveFilmFromLibraryUseCase {
fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary
}
@@ -38,3 +49,7 @@ interface GetFilmLibraryUseCase {
data class GetFilmLibraryQuery(
val userId: UUID,
)
+
+interface ListFilmLibraryEntriesUseCase {
+ fun list(userId: UUID): List
+}
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..37c8226
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt
@@ -0,0 +1,19 @@
+package com.project.movienight.application.ports.input
+
+import com.project.movienight.domain.model.FilmRating
+import java.util.UUID
+
+interface RateFilmUseCase {
+ fun rate(command: RateFilmCommand): FilmRating
+}
+
+data class RateFilmCommand(
+ val userId: UUID,
+ val filmId: UUID,
+ val score: Int,
+ val note: String? = null,
+)
+
+interface GetFilmRatingsUseCase {
+ fun getRatings(userId: UUID): List
+}
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..27098c7 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,5 +1,6 @@
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
@@ -10,6 +11,16 @@ interface CreateFilmUseCase {
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 {
@@ -22,8 +33,30 @@ interface EditFilmUseCase {
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)
}
+
+interface GetFilmByIdUseCase {
+ fun getById(id: UUID): Film
+}
+
+interface GetAllFilmsUseCase {
+ fun getAll(): List
+}
+
+interface SearchFilmByTitleUseCase {
+ fun searchByTitle(title: String): Film?
+}
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/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..b44820c
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt
@@ -0,0 +1,23 @@
+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 UpsertUserPreferencesUseCase {
+ fun upsert(command: UpsertUserPreferencesCommand): 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(),
+)
+
+interface GetUserPreferencesUseCase {
+ fun get(userId: UUID): UserPreferences?
+}
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..b066a4f 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
@@ -21,8 +21,17 @@ interface EditUserUseCase {
data class EditUserCommand(
val name: String,
+ val jellyfinUserId: String? = null,
)
interface DeleteUserUseCase {
fun delete(id: UUID)
}
+
+interface GetUserByIdUseCase {
+ fun getById(id: UUID): User
+}
+
+interface GetAllUsersUseCase {
+ fun getAll(): List
+}
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..34766f7
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt
@@ -0,0 +1,19 @@
+package com.project.movienight.application.ports.output
+
+import com.project.movienight.domain.model.JellyfinSyncSummary
+
+interface BusinessMetricsPort {
+ fun recordRecommendationRequest()
+
+ fun recordRatingSubmitted()
+
+ fun recordLibraryEvent()
+
+ fun recordJellyfinSync(summary: JellyfinSyncSummary)
+
+ fun recordJellyfinSyncFailure()
+
+ fun recordJellyfinUnmappedUser()
+
+ fun recordBackendWriteFailure()
+}
\ No newline at end of file
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
index a3eb9c9..933f45c 100644
--- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt
+++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt
@@ -8,6 +8,11 @@ interface FilmLibraryRepositoryPort {
fun findById(id: UUID): FilmLibrary?
+ fun findByUserIdAndFilmId(
+ userId: UUID,
+ filmId: 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..d18b2e6 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,13 @@ interface FilmRepositoryPort {
fun findById(id: UUID): Film?
+ fun findByJellyfinItemId(jellyfinItemId: String): Film?
+
+ fun findByJellyfinLibraryId(jellyfinLibraryId: 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/JellyfinEventRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventRepositoryPort.kt
new file mode 100644
index 0000000..eb084f3
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventRepositoryPort.kt
@@ -0,0 +1,17 @@
+package com.project.movienight.application.ports.output
+
+import java.time.OffsetDateTime
+
+interface JellyfinEventRepositoryPort {
+ fun save(
+ eventId: String,
+ serverId: String?,
+ eventType: String,
+ occurredAt: OffsetDateTime?,
+ jellyfinUserId: String?,
+ jellyfinItemId: String?,
+ payload: String?,
+ ): Int
+
+ fun delete(eventId: String)
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncSourcePort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncSourcePort.kt
new file mode 100644
index 0000000..4113a7d
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncSourcePort.kt
@@ -0,0 +1,30 @@
+package com.project.movienight.application.ports.output
+
+import com.project.movienight.domain.model.ContentType
+
+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,
+)
+
+interface JellyfinSyncSourcePort {
+ fun fetchUsers(): List
+
+ fun fetchLibraryItems(userId: String): List
+}
\ No newline at end of file
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..dd69728 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,5 +1,6 @@
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
@@ -8,7 +9,14 @@ interface UserRepositoryPort {
fun findById(id: UUID): User?
+ fun findByEmail(email: 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..af4ed13 100644
--- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt
+++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt
@@ -6,13 +6,18 @@ 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.ListFilmLibraryEntriesUseCase
+import com.project.movienight.application.ports.input.MarkFilmViewedCommand
+import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
+import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
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 org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.UUID
@@ -20,70 +25,173 @@ import java.util.UUID
class FilmLibraryService(
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val idGenerator: IdGenerator,
+ private val businessMetricsService: BusinessMetricsPort,
) : CreateFilmLibraryUseCase,
AddFilmToLibraryUseCase,
+ MarkFilmViewedUseCase,
RemoveFilmFromLibraryUseCase,
- GetFilmLibraryUseCase {
+ GetFilmLibraryUseCase,
+ ListFilmLibraryEntriesUseCase {
+ private val log = LoggerFactory.getLogger(javaClass)
+
override fun create(command: CreateFilmLibraryCommand): FilmLibrary {
- val existingLibrary = findByUserId(command.userId)
- if (existingLibrary != null) {
- return existingLibrary
+ log.info("Creating film library for user: {}", command.userId)
+ log.debug("Create library request: userId={}, name={}", command.userId, command.name)
+
+ val existing = findByUserId(command.userId)
+ if (existing != null) {
+ log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id)
+ return existing
}
- return filmLibraryRepository.save(
- FilmLibrary(
- id = idGenerator.generateId(),
- userId = command.userId,
- filmId = idGenerator.generateId(),
- comment = command.name,
- isViewed = false,
- ),
- )
+ log.warn("Library not found for user {}, cannot create", command.userId)
+ throw EntityNotFoundException(entity = "Film library", id = command.userId.toString())
}
override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary {
- val existingLibrary = findByUserId(command.userId)
- if (existingLibrary == null) {
- return filmLibraryRepository.save(
+ log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId)
+
+ val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
+ if (existingEntry != null) {
+ log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id)
+ val saved =
+ filmLibraryRepository.save(
+ existingEntry.copy(
+ isViewed = false,
+ watchedAt = null,
+ ),
+ )
+ businessMetricsService.recordLibraryEvent()
+ log.info(
+ "Film re-added to library: userId={}, filmId={}, entryId={}",
+ command.userId,
+ command.filmId,
+ saved.id,
+ )
+ return saved
+ }
+
+ val saved =
+ filmLibraryRepository.save(
FilmLibrary(
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()
+ log.info(
+ "Film added to library: userId={}, filmId={}, entryId={}",
+ command.userId,
+ command.filmId,
+ saved.id,
)
+ return saved
}
override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary {
+ log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId)
+
val existingLibrary =
- findByUserId(command.userId)
- ?: throw EntityNotFoundException(entity = "Film library", id = command.userId.toString())
+ if (command.libraryId != null) {
+ log.debug("Looking up by libraryId: {}", command.libraryId)
+ filmLibraryRepository.findById(command.libraryId)
+ ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString())
+ } else {
+ log.debug("Looking up by userId and filmId")
+ findByUserAndFilmId(command.userId, command.filmId)
+ ?: throw EntityNotFoundException(entity = "Film library", 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 (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) {
+ log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId)
throw DomainException("Film with id ${command.filmId} not found in user's library")
}
filmLibraryRepository.deleteById(existingLibrary.id)
+ businessMetricsService.recordLibraryEvent()
+ log.info(
+ "Film removed from library: userId={}, filmId={}, entryId={}",
+ command.userId,
+ command.filmId,
+ existingLibrary.id,
+ )
return existingLibrary
}
- override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary =
- findByUserId(query.userId)
- ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString())
+ override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary {
+ log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId)
+
+ val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
+ val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now()
+
+ log.debug("Marking as viewed at: {}", watchedAt)
+
+ val saved =
+ if (existingEntry == null) {
+ log.debug("Film not in library, creating new entry as viewed")
+ filmLibraryRepository.save(
+ FilmLibrary(
+ id = idGenerator.generateId(),
+ userId = command.userId,
+ filmId = command.filmId,
+ comment = null,
+ isViewed = true,
+ watchedAt = watchedAt,
+ ),
+ )
+ } else {
+ log.debug(
+ "Updating existing entry: entryId={}, was viewed={}",
+ existingEntry.id,
+ existingEntry.isViewed,
+ )
+ filmLibraryRepository.save(
+ existingEntry.copy(
+ isViewed = true,
+ watchedAt = watchedAt,
+ ),
+ )
+ }
+ businessMetricsService.recordLibraryEvent()
+ log.info(
+ "Film marked as viewed: userId={}, filmId={}, entryId={}",
+ command.userId,
+ command.filmId,
+ saved.id,
+ )
+ return saved
+ }
+
+ override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary {
+ log.debug("Getting library for user: {}", query.userId)
+ val library =
+ findByUserId(query.userId)
+ ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString())
+ log.debug("Library found: userId={}, libraryId={}", query.userId, library.id)
+ return library
+ }
+
+ override fun list(userId: UUID): List {
+ log.debug("Listing all library entries for user: {}", userId)
+ val entries = filmLibraryRepository.findAll().filter { it.userId == userId }
+ log.info("User {} has {} films in library", userId, entries.size)
+ return entries
+ }
private fun findByUserId(userId: UUID): FilmLibrary? =
- filmLibraryRepository.findAll().firstOrNull { it.userId == userId }
+ filmLibraryRepository.findAll().firstOrNull {
+ it.userId == userId
+ }
+
+ private fun findByUserAndFilmId(
+ userId: UUID,
+ filmId: UUID,
+ ): FilmLibrary? =
+ filmLibraryRepository.findAll().firstOrNull {
+ it.userId == userId && it.filmId == filmId
+ }
}
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..54f7d72
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt
@@ -0,0 +1,57 @@
+package com.project.movienight.application.services
+
+import com.project.movienight.application.ports.input.GetFilmRatingsUseCase
+import com.project.movienight.application.ports.input.RateFilmCommand
+import com.project.movienight.application.ports.input.RateFilmUseCase
+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,
+) : RateFilmUseCase,
+ GetFilmRatingsUseCase {
+ 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..93de089 100644
--- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt
+++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt
@@ -5,12 +5,19 @@ 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.GetAllFilmsUseCase
+import com.project.movienight.application.ports.input.GetFilmByIdUseCase
+import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase
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.instrument.Counter
+import io.micrometer.core.instrument.MeterRegistry
+import io.micrometer.core.instrument.Timer
+import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.UUID
@@ -19,47 +26,215 @@ class FilmService(
private val filmRepository: FilmRepositoryPort,
private val idGenerator: IdGenerator,
private val filmConfig: FilmServiceProperties,
+ private val meterRegistry: MeterRegistry,
) : CreateFilmUseCase,
EditFilmUseCase,
- DeleteFilmUseCase {
- override fun create(command: CreateFilmCommand): Film {
- if (filmConfig.isBlocked(command.title)) {
- throw BlockedValueException(target = "Film", field = "title")
- }
- if (filmConfig.isBlocked(command.description)) {
- throw BlockedValueException(target = "Film", field = "description")
- }
+ DeleteFilmUseCase,
+ GetFilmByIdUseCase,
+ GetAllFilmsUseCase,
+ SearchFilmByTitleUseCase {
+ private val log = LoggerFactory.getLogger(javaClass)
- val film =
- Film(
- id = idGenerator.generateId(),
- title = command.title,
- description = command.description,
- )
- return filmRepository.save(film)
+ override fun create(command: CreateFilmCommand): Film {
+ log.info("Creating new film: title='{}', contentType={}", command.title, command.contentType)
+ log.debug(
+ "Create film request details: title='{}', descriptionLength={}, genres={}, releaseYear={}",
+ command.title,
+ command.description.length,
+ command.genres,
+ command.releaseYear,
+ )
+
+ val sample = Timer.start(meterRegistry)
+
+ try {
+ if (filmConfig.isBlocked(command.title)) {
+ log.warn("Film creation blocked: title contains blocked pattern '{}'", command.title)
+ filmBlockedCounter.increment()
+ throw BlockedValueException(target = "Film", field = "title")
+ }
+ if (filmConfig.isBlocked(command.description)) {
+ log.warn("Film creation blocked: description contains blocked pattern")
+ filmBlockedCounter.increment()
+ throw BlockedValueException(target = "Film", field = "description")
+ }
+
+ val film =
+ Film(
+ 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,
+ )
+
+ val saved = filmRepository.save(film)
+ filmCreatedCounter.increment()
+ log.info("Film created successfully: id={}, title='{}'", saved.id, saved.title)
+ return saved
+ } finally {
+ sample.stop(createFilmTimer)
+ }
}
override fun edit(
id: UUID,
command: EditFilmCommand,
): Film {
- if (filmConfig.isBlocked(command.title)) {
- throw BlockedValueException(target = "Film", field = "title")
+ log.info("Editing film: id={}", id)
+ log.debug(
+ "Edit film request details: id={}, title='{}', descriptionLength={}, genres={}",
+ id,
+ command.title,
+ command.description.length,
+ command.genres,
+ )
+
+ val sample = Timer.start(meterRegistry)
+
+ try {
+ if (filmConfig.isBlocked(command.title)) {
+ log.warn("Film edit blocked: title contains blocked pattern '{}'", command.title)
+ filmBlockedCounter.increment()
+ throw BlockedValueException(target = "Film", field = "title")
+ }
+ if (filmConfig.isBlocked(command.description)) {
+ log.warn("Film edit blocked: description contains blocked pattern")
+ filmBlockedCounter.increment()
+ throw BlockedValueException(target = "Film", field = "description")
+ }
+
+ var film = filmRepository.findById(id)
+
+ if (film == null) {
+ log.warn("Film not found for edit: id='{}'", id)
+ throw EntityNotFoundException(entity = "Film", id = id.toString())
+ }
+
+ log.debug("Existing film found: id={}, current title='{}'", film.id, film.title)
+
+ film =
+ 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,
+ )
+
+ val saved = filmRepository.save(film)
+ filmEditedCounter.increment()
+ log.info("Film edited successfully: id={}, new title='{}'", saved.id, saved.title)
+ return saved
+ } finally {
+ sample.stop(editFilmTimer)
}
- if (filmConfig.isBlocked(command.description)) {
- throw BlockedValueException(target = "Film", field = "description")
- }
-
- var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
-
- film = film.copy(title = command.title, description = command.description)
-
- return filmRepository.save(film)
}
override fun delete(id: UUID) {
- filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
+ log.info("Deleting film: id={}", id)
- filmRepository.deleteById(id)
+ val sample = Timer.start(meterRegistry)
+
+ try {
+ val film = filmRepository.findById(id)
+
+ if (film == null) {
+ log.warn("Film not found for delete: id='{}'", id)
+ throw EntityNotFoundException(entity = "Film", id = id.toString())
+ }
+
+ log.debug("Film found for deletion: id={}, title='{}'", film.id, film.title)
+
+ filmRepository.deleteById(id)
+ filmDeletedCounter.increment()
+ log.info("Film deleted successfully: id={}, title='{}'", id, film.title)
+ } finally {
+ sample.stop(deleteFilmTimer)
+ }
}
+
+ override fun getById(id: UUID): Film {
+ log.debug("Fetching film by id: {}", id)
+ val film =
+ filmRepository.findById(id)
+ ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
+ log.debug("Film found: id={}, title='{}'", film.id, film.title)
+ return film
+ }
+
+ override fun getAll(): List {
+ log.debug("Fetching all films")
+ val films = filmRepository.findAll()
+ log.info("Retrieved {} films from database", films.size)
+ return films
+ }
+
+ override fun searchByTitle(title: String): Film? {
+ log.debug("Searching film by title: '{}'", title)
+ val film = filmRepository.findByTitle(title)
+ if (film != null) {
+ log.info("Film found by title '{}': id={}", title, film.id)
+ } else {
+ log.debug("No film found with title: '{}'", title)
+ }
+ return film
+ }
+
+ private val filmCreatedCounter =
+ Counter
+ .builder("film_created_total")
+ .description("Total number of created films")
+ .register(meterRegistry)
+
+ private val filmEditedCounter =
+ Counter
+ .builder("film_edited_total")
+ .description("Total number of successfully edited films")
+ .register(meterRegistry)
+
+ private val filmDeletedCounter =
+ Counter
+ .builder("film_deleted_total")
+ .description("Total number of successfully deleted films")
+ .register(meterRegistry)
+
+ private val filmBlockedCounter =
+ Counter
+ .builder("films.blocked")
+ .description("Total blocked film operations")
+ .register(meterRegistry)
+
+ private val createFilmTimer =
+ Timer
+ .builder("films.create.duration")
+ .description("Film creation duration")
+ .register(meterRegistry)
+
+ private val editFilmTimer =
+ Timer
+ .builder("films.edit.duration")
+ .description("Film edit duration")
+ .register(meterRegistry)
+
+ private val deleteFilmTimer =
+ Timer
+ .builder("films.delete.duration")
+ .description("Film deletion duration")
+ .register(meterRegistry)
}
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..d4fe6a0
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt
@@ -0,0 +1,81 @@
+package com.project.movienight.application.services
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.project.movienight.application.ports.input.MarkFilmViewedCommand
+import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
+import com.project.movienight.application.ports.output.BusinessMetricsPort
+import com.project.movienight.application.ports.output.JellyfinEventRepositoryPort
+import com.project.movienight.application.ports.output.FilmRepositoryPort
+import com.project.movienight.application.ports.output.UserRepositoryPort
+import org.springframework.stereotype.Service
+import java.time.OffsetDateTime
+
+@Service
+class JellyfinEventService(
+ private val jellyfinEventRepository: JellyfinEventRepositoryPort,
+ private val userRepository: UserRepositoryPort,
+ private val filmRepository: FilmRepositoryPort,
+ private val markFilmViewedUseCase: MarkFilmViewedUseCase,
+ private val objectMapper: ObjectMapper,
+ private val businessMetricsService: BusinessMetricsPort,
+) {
+ private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed")
+
+ fun handleEvent(
+ eventId: String,
+ serverId: String?,
+ eventType: String,
+ occurredAt: OffsetDateTime,
+ jellyfinUserId: String,
+ itemId: String,
+ payload: Map?,
+ ) {
+ val payloadJson = payload?.let { objectMapper.writeValueAsString(it) }
+ val inserted =
+ jellyfinEventRepository.save(
+ eventId = eventId,
+ serverId = serverId,
+ eventType = eventType,
+ occurredAt = occurredAt,
+ jellyfinUserId = jellyfinUserId,
+ jellyfinItemId = itemId,
+ payload = payloadJson,
+ )
+ if (inserted != 1) {
+ return
+ }
+
+ try {
+ if (playbackEventTypes.contains(eventType)) {
+ val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId }
+ if (localUser == null) {
+ jellyfinEventRepository.delete(eventId)
+ businessMetricsService.recordJellyfinUnmappedUser()
+ return
+ }
+
+ val film = filmRepository.findByJellyfinItemId(itemId)
+ if (film == null) {
+ jellyfinEventRepository.delete(eventId)
+ businessMetricsService.recordBackendWriteFailure()
+ return
+ }
+
+ markFilmViewedUseCase.markViewed(
+ MarkFilmViewedCommand(
+ userId = localUser.id,
+ filmId = film.id,
+ watchedAt = occurredAt.toLocalDateTime(),
+ ),
+ )
+ businessMetricsService.recordLibraryEvent()
+ }
+ } catch (
+ @Suppress("TooGenericExceptionCaught") ex: RuntimeException,
+ ) {
+ jellyfinEventRepository.delete(eventId)
+ businessMetricsService.recordBackendWriteFailure()
+ throw ex
+ }
+ }
+}
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..a6776d1
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt
@@ -0,0 +1,153 @@
+package com.project.movienight.application.services
+
+import com.project.movienight.application.ports.output.BusinessMetricsPort
+import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
+import com.project.movienight.application.ports.output.FilmRepositoryPort
+import com.project.movienight.application.ports.output.IdGenerator
+import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
+import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
+import com.project.movienight.application.ports.output.JellyfinRemoteUser
+import com.project.movienight.application.ports.output.JellyfinSyncSourcePort
+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.FilmLibrary
+import com.project.movienight.domain.model.JellyfinSyncState
+import com.project.movienight.domain.model.JellyfinSyncSummary
+import org.springframework.scheduling.annotation.Scheduled
+import org.springframework.stereotype.Service
+import java.time.Duration
+import java.time.Instant
+import java.time.LocalDateTime
+
+@Service
+class JellyfinSyncService(
+ private val properties: JellyfinIntegrationProperties,
+ private val jellyfinApiClient: JellyfinSyncSourcePort,
+ private val userRepository: UserRepositoryPort,
+ private val filmRepository: FilmRepositoryPort,
+ private val filmLibraryRepository: FilmLibraryRepositoryPort,
+ private val syncStateRepository: JellyfinSyncStateRepositoryPort,
+ private val idGenerator: IdGenerator,
+ private val businessMetricsService: BusinessMetricsPort,
+) {
+ @Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
+ fun scheduledSync() {
+ if (properties.enabled) {
+ syncNow()
+ }
+ }
+
+ fun syncNow(): JellyfinSyncSummary {
+ if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) {
+ return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0)
+ }
+
+ val startedAt = Instant.now()
+ val remoteUsers = jellyfinApiClient.fetchUsers()
+ val localUsersByJellyfinId =
+ userRepository
+ .findAll()
+ .mapNotNull { user ->
+ user.jellyfinUserId?.let { it to user }
+ }.toMap()
+
+ var syncedUsers = 0
+ var skippedUsers = 0
+ var syncedItems = 0
+
+ remoteUsers.forEach { remoteUser ->
+ val localUser = localUsersByJellyfinId[remoteUser.id]
+ if (localUser == null) {
+ skippedUsers += 1
+ return@forEach
+ }
+
+ val items = jellyfinApiClient.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
+ }
+
+ fun getSyncStates(): List = syncStateRepository.findAll()
+
+ private fun syncItem(
+ userId: java.util.UUID,
+ item: JellyfinLibraryItemSnapshot,
+ ) {
+ val film =
+ filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy(
+ title = item.title,
+ description = item.description,
+ contentType = item.contentType,
+ releaseYear = item.releaseYear,
+ genres = item.genres,
+ cast = item.cast,
+ directors = item.directors,
+ imdbRating = item.imdbRating,
+ platformRating = item.platformRating,
+ externalUrl = item.externalUrl,
+ jellyfinItemId = item.jellyfinItemId,
+ jellyfinLibraryId = item.jellyfinLibraryId,
+ ) ?: Film(
+ id = idGenerator.generateId(),
+ title = item.title,
+ description = item.description,
+ contentType = item.contentType,
+ releaseYear = item.releaseYear,
+ genres = item.genres,
+ cast = item.cast,
+ directors = item.directors,
+ imdbRating = item.imdbRating,
+ platformRating = item.platformRating,
+ externalUrl = item.externalUrl,
+ jellyfinItemId = item.jellyfinItemId,
+ jellyfinLibraryId = item.jellyfinLibraryId,
+ )
+
+ val savedFilm = filmRepository.save(film)
+
+ if (item.isPlayed) {
+ val watchedAt = LocalDateTime.now()
+ val existingEntry = filmLibraryRepository.findByUserIdAndFilmId(userId, savedFilm.id)
+ filmLibraryRepository.save(
+ existingEntry?.copy(
+ isViewed = true,
+ watchedAt = watchedAt,
+ ) ?: FilmLibrary(
+ id = idGenerator.generateId(),
+ userId = userId,
+ filmId = savedFilm.id,
+ comment = null,
+ isViewed = true,
+ watchedAt = watchedAt,
+ ),
+ )
+ }
+ }
+}
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..8f9fe04
--- /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.FilmLibraryRepositoryPort
+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.FilmLibrary
+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 filmLibraryRepository: FilmLibraryRepositoryPort,
+ 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,
+ ): FilmLibrary {
+ val watchedAt = LocalDateTime.now().takeIf { isViewed }
+ val existing = filmLibraryRepository.findByUserIdAndFilmId(userId, filmId)
+ return filmLibraryRepository.save(
+ existing?.copy(isViewed = isViewed, watchedAt = watchedAt)
+ ?: FilmLibrary(
+ 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..e8a7722
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt
@@ -0,0 +1,674 @@
+package com.project.movienight.application.services
+
+import com.project.movienight.adapters.metrics.BusinessMetricsService
+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.FilmLibraryRepositoryPort
+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.FilmLibrary
+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 filmLibraryRepository: FilmLibraryRepositoryPort,
+ 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: BusinessMetricsService,
+) : 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 = filmLibraryRepository.findAll().filter { it.userId == 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,
+ ): SparseVector {
+ val profile = MutableSparseVector()
+
+ preferences?.weightedGenres.orEmpty().forEach { (genre, weight) ->
+ profile.add(feature("genre", genre), weight.coerceAtLeast(1).toDouble() / MAX_PREFERENCE_WEIGHT)
+ }
+ preferences?.plotTypes.orEmpty().forEach { plotType ->
+ tokenize(plotType).forEach { profile.add(feature("plot", it), PREFERENCE_PLOT_WEIGHT) }
+ }
+ preferences?.eras.orEmpty().forEach { profile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) }
+ preferences?.castAndDirectors.orEmpty().forEach { profile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) }
+ preferences?.moods.orEmpty().forEach { profile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) }
+ preferences
+ ?.contentTypes
+ .orEmpty()
+ .forEach {
+ profile.add(
+ feature("type", it.name),
+ PREFERENCE_CONTENT_TYPE_WEIGHT,
+ )
+ }
+
+ ratings.forEach { rating ->
+ val film = filmsById[rating.filmId] ?: return@forEach
+ val signal = ratingSignal(rating.score)
+ profile.add(buildFilmVector(film, weights).scale(signal))
+ }
+
+ libraryEntries.filterNot { it.isViewed }.forEach { entry ->
+ val film = filmsById[entry.filmId] ?: return@forEach
+ profile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT))
+ }
+
+ return profile.toSparseVector()
+ }
+
+ private fun scoreFilm(
+ film: Film,
+ query: RecommendationQuery,
+ preferences: UserPreferences?,
+ userProfile: SparseVector,
+ inLibrary: Boolean,
+ weights: UserRecommendationWeights,
+ ): ScoredRecommendation {
+ val reasons = mutableListOf()
+ val filmVector = buildFilmVector(film, weights)
+ val preferenceScore = cosineSimilarity(userProfile, filmVector)
+ val qualityScore = qualityScore(film)
+ val contextScore = contextScore(film, query, preferences)
+ val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE
+ val diversityScore = diversityScore(film, preferences)
+ val score =
+ weights.relevanceWeight * preferenceScore +
+ weights.qualityWeight * qualityScore +
+ weights.contextWeight * contextScore +
+ weights.noveltyWeight * noveltyScore +
+ weights.diversityWeight * diversityScore
+
+ if (preferenceScore > STRONG_REASON_THRESHOLD) {
+ reasons += "Similar to user preferences and rating history"
+ }
+ 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 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, "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 contextScore(
+ film: Film,
+ query: RecommendationQuery,
+ preferences: UserPreferences?,
+ ): 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
+ }
+ }
+ }
+
+ return if (checks == 0) BASE_CONTEXT_SCORE else score / checks
+ }
+
+ private fun qualityScore(film: Film): Double {
+ val normalizedRatings =
+ listOfNotNull(
+ film.imdbRating?.let { normalizeRating(it) },
+ film.platformRating?.let { normalizeRating(it) },
+ )
+ return normalizedRatings.averageOrNull() ?: BASE_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 } -> HIGH_DIVERSITY_SCORE
+ filmGenres.size > 1 -> MEDIUM_DIVERSITY_SCORE
+ else -> LOW_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 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 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 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 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 BASE_QUALITY_SCORE = 0.5
+ private const val BASE_DIVERSITY_SCORE = 0.5
+ private const val HIGH_DIVERSITY_SCORE = 1.0
+ private const val MEDIUM_DIVERSITY_SCORE = 0.6
+ private const val LOW_DIVERSITY_SCORE = 0.3
+ private const val STRONG_REASON_THRESHOLD = 0.15
+ private const val QUALITY_REASON_THRESHOLD = 0.75
+
+ 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"),
+ )
+ }
+}
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..de388ce
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt
@@ -0,0 +1,29 @@
+package com.project.movienight.application.services
+
+import com.project.movienight.application.ports.input.GetUserPreferencesUseCase
+import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
+import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase
+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,
+) : UpsertUserPreferencesUseCase,
+ GetUserPreferencesUseCase {
+ 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..ba8fd76 100644
--- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt
+++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt
@@ -5,12 +5,15 @@ 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.GetAllUsersUseCase
+import com.project.movienight.application.ports.input.GetUserByIdUseCase
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 org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.UUID
@@ -21,9 +24,17 @@ class UserService(
private val userConfig: UserServiceProperties,
) : CreateUserUseCase,
EditUserUseCase,
- DeleteUserUseCase {
+ DeleteUserUseCase,
+ GetUserByIdUseCase,
+ GetAllUsersUseCase {
+ private val log = LoggerFactory.getLogger(javaClass)
+
override fun create(command: CreateUserCommand): User {
+ log.info("Creating new user with email: {}", command.email)
+ log.debug("Create user request: name='{}', email='{}'", command.name, command.email)
+
if (userConfig.isBlocked(command.name)) {
+ log.warn("User creation blocked: name contains blocked pattern '{}'", command.name)
throw BlockedValueException(target = "User", field = "name")
}
@@ -33,28 +44,70 @@ class UserService(
name = command.name,
email = command.email,
library = null,
+ jellyfinUserId = null,
)
- return userRepository.save(user)
+ val saved = userRepository.save(user)
+
+ log.info("User created successfully: id={}, email='{}'", saved.id, saved.email)
+ return saved
}
override fun edit(
id: UUID,
command: EditUserCommand,
): User {
+ log.info("Editing user: id={}", id)
+ log.debug("Edit user request: id={}, name='{}', jellyfinUserId={}", id, command.name, command.jellyfinUserId)
+
if (userConfig.isBlocked(command.name)) {
+ log.warn("User edit blocked: name contains blocked pattern '{}'", command.name)
throw BlockedValueException(target = "User", field = "name")
}
- var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
+ var user =
+ userRepository.findById(id)
+ ?: throw EntityNotFoundException(entity = "User", id = id.toString())
- user = user.copy(name = command.name)
+ log.debug("Existing user found: id={}, current name='{}'", user.id, user.name)
- return userRepository.save(user)
+ user =
+ user.copy(
+ name = command.name,
+ jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId,
+ )
+
+ val saved = userRepository.save(user)
+ log.info("User edited successfully: id={}, new name='{}'", saved.id, saved.name)
+ return saved
}
override fun delete(id: UUID) {
- userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
+ log.info("Deleting user: id={}", id)
+ log.debug("Delete user request: id={}", id)
+
+ val user =
+ userRepository.findById(id)
+ ?: throw EntityNotFoundException(entity = "User", id = id.toString())
+
+ log.debug("User found for deletion: id={}, email='{}'", user.id, user.email)
userRepository.deleteById(id)
+ log.info("User deleted successfully: id={}", id)
+ }
+
+ override fun getById(id: UUID): User {
+ log.debug("Fetching user by id: {}", id)
+ val user =
+ userRepository.findById(id)
+ ?: throw EntityNotFoundException(entity = "User", id = id.toString())
+ log.debug("User found: id={}, name='{}', email='{}'", user.id, user.name, user.email)
+ return user
+ }
+
+ override fun getAll(): List {
+ log.debug("Fetching all users")
+ val users = userRepository.findAll()
+ log.info("Retrieved {} users from database", users.size)
+ return users
}
}
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..a4dc555
--- /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 = "",
+ 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/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/FilmLibrary.kt
index 868f57a..8d7861c 100644
--- a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt
+++ b/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt
@@ -1,5 +1,6 @@
package com.project.movienight.domain.model
+import java.time.LocalDateTime
import java.util.UUID
data class FilmLibrary(
@@ -8,4 +9,5 @@ data class FilmLibrary(
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/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/RecommendationResult.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationResult.kt
new file mode 100644
index 0000000..5b5b0a4
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationResult.kt
@@ -0,0 +1,7 @@
+package com.project.movienight.domain.model
+
+data class RecommendationResult(
+ val film: Film,
+ val score: Double,
+ val reasons: List,
+)
\ No newline at end of file
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..236a698 100644
--- a/src/main/kotlin/com/project/movienight/domain/model/User.kt
+++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt
@@ -7,4 +7,6 @@ data class User(
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..e277aa3 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -25,6 +25,38 @@ 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:
shutdown: graceful
@@ -63,6 +95,15 @@ info:
description: MovieNight backend service
version: ${project.version:unknown}
+integrations:
+ jellyfin:
+ enabled: ${JELLYFIN_SYNC_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}
+
services:
user:
blocked-names:
@@ -74,3 +115,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/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..be1f105
--- /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 BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ film_id BIGINT 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/logback-spring.xml b/src/main/resources/logback-spring.xml
new file mode 100644
index 0000000..dd704c8
--- /dev/null
+++ b/src/main/resources/logback-spring.xml
@@ -0,0 +1,23 @@
+
+
+
+ %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/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt
new file mode 100644
index 0000000..c8aaae2
--- /dev/null
+++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt
@@ -0,0 +1,409 @@
+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() }
+ }
+ }
+
+ 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,
+ genres: List,
+ imdbRating: Double,
+ ): UUID {
+ mockMvc
+ .post("/api/films") {
+ contentType = MediaType.APPLICATION_JSON
+ content =
+ objectMapper.writeValueAsString(
+ CreateFilmRequest(
+ title = title,
+ description = "$title description",
+ contentType = "FILM",
+ 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..4e65569
--- /dev/null
+++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt
@@ -0,0 +1,84 @@
+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.library)
+ assertNull(user.jellyfinUserId)
+ }
+
+ @Test
+ fun `toEntity maps User with OAuth provider`() {
+ val user =
+ User(
+ id = UUID.randomUUID(),
+ name = "Jane",
+ email = "jane@mail.com",
+ library = null,
+ )
+
+ 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",
+ library = null,
+ )
+
+ 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",
+ library = null,
+ )
+
+ 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/FilmLibraryRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt
new file mode 100644
index 0000000..d880f02
--- /dev/null
+++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.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.FilmLibrary
+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 FilmLibraryRepositoryIntegrationTest {
+ @Autowired
+ private lateinit var filmLibraryRepository: FilmLibraryRepository
+
+ @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 =
+ FilmLibrary(
+ id = UUID.randomUUID(),
+ userId = testUser.id,
+ filmId = testFilm.id,
+ comment = "Отличный фильм!",
+ isViewed = false,
+ )
+
+ val savedEntry = filmLibraryRepository.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 = FilmLibrary(entryId, testUser.id, testFilm.id, "Хочу посмотреть", false)
+ filmLibraryRepository.save(originalEntry)
+
+ val updatedEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true)
+ val result = filmLibraryRepository.save(updatedEntry)
+
+ assertEquals(entryId, result.id)
+ assertEquals("Уже посмотрел, потрясающе!", result.comment)
+ assertTrue(result.isViewed)
+
+ val foundEntry = filmLibraryRepository.findById(entryId)
+ assertNotNull(foundEntry)
+ assertEquals("Уже посмотрел, потрясающе!", foundEntry?.comment)
+ assertTrue(foundEntry?.isViewed ?: false)
+ }
+
+ @Test
+ fun `should find film library entry by id`() {
+ val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false)
+ filmLibraryRepository.save(entry)
+
+ val foundEntry = filmLibraryRepository.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 = filmLibraryRepository.findById(nonExistentId)
+
+ assertNull(foundEntry)
+ }
+
+ @Test
+ fun `should find all film library entries`() {
+ val entry1 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 1", false)
+ val entry2 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 2", true)
+ val entry3 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
+
+ filmLibraryRepository.save(entry1)
+ filmLibraryRepository.save(entry2)
+ filmLibraryRepository.save(entry3)
+
+ val allEntries = filmLibraryRepository.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 = filmLibraryRepository.findAll()
+
+ assertTrue(allEntries.isEmpty())
+ }
+
+ @Test
+ fun `should delete film library entry by id`() {
+ val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Для удаления", false)
+ filmLibraryRepository.save(entry)
+
+ filmLibraryRepository.deleteById(entry.id)
+
+ val foundEntry = filmLibraryRepository.findById(entry.id)
+ assertNull(foundEntry)
+ }
+
+ @Test
+ fun `should not throw exception when deleting non-existent entry`() {
+ val nonExistentId = UUID.randomUUID()
+
+ filmLibraryRepository.deleteById(nonExistentId)
+ }
+
+ @Test
+ fun `should save entry with null comment`() {
+ val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
+
+ val savedEntry = filmLibraryRepository.save(entry)
+
+ assertNotNull(savedEntry)
+ assertNull(savedEntry.comment)
+ }
+
+ @Test
+ fun `should save entry with isViewed true`() {
+ val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true)
+
+ val savedEntry = filmLibraryRepository.save(entry)
+
+ assertNotNull(savedEntry)
+ assertTrue(savedEntry.isViewed)
+ }
+
+ @Test
+ fun `should save entry with isViewed false`() {
+ val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false)
+
+ val savedEntry = filmLibraryRepository.save(entry)
+
+ assertNotNull(savedEntry)
+ assertFalse(savedEntry.isViewed)
+ }
+
+ @Test
+ fun `should cascade delete entries when user is deleted`() {
+ val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false)
+ filmLibraryRepository.save(entry)
+
+ userRepository.deleteById(testUser.id)
+
+ val foundEntry = filmLibraryRepository.findById(entry.id)
+ assertNull(foundEntry)
+ }
+
+ @Test
+ fun `should cascade delete entries when film is deleted`() {
+ val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Запись о фильме", false)
+ filmLibraryRepository.save(entry)
+
+ filmRepository.deleteById(testFilm.id)
+
+ val foundEntry = filmLibraryRepository.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..02b588a
--- /dev/null
+++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt
@@ -0,0 +1,160 @@
+package com.project.movienight.adapters.persistence.jdbc
+
+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",
+ library = null,
+ )
+
+ 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", null)
+ userRepository.save(originalUser)
+
+ val updatedUser = User(userId, "Jane Doe", "jane@example.com", null)
+ 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", null)
+ 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", null)
+ val user2 = User(UUID.randomUUID(), "Jane Smith", "jane@example.com", null)
+ val user3 = User(UUID.randomUUID(), "Bob Johnson", "bob@example.com", null)
+
+ 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", null)
+ 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)
+ }
+}
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..033cccd
--- /dev/null
+++ b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt
@@ -0,0 +1,77 @@
+package com.project.movienight.adapters.web
+
+import com.project.movienight.application.ports.input.CreateFilmUseCase
+import com.project.movienight.application.ports.input.DeleteFilmUseCase
+import com.project.movienight.application.ports.input.EditFilmUseCase
+import com.project.movienight.application.ports.input.GetAllFilmsUseCase
+import com.project.movienight.application.ports.input.GetFilmByIdUseCase
+import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase
+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 searchFilmByTitleUseCase: SearchFilmByTitleUseCase
+
+ @BeforeEach
+ fun setup() {
+ searchFilmByTitleUseCase = mockk()
+
+ val controller =
+ FilmController(
+ createFilmUseCase = mockk(),
+ editFilmUseCase = mockk(),
+ deleteFilmUseCase = mockk(),
+ getFilmByIdUseCase = mockk(),
+ getAllFilmsUseCase = mockk(),
+ searchFilmByTitleUseCase = searchFilmByTitleUseCase,
+ )
+
+ 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 { searchFilmByTitleUseCase.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) { searchFilmByTitleUseCase.searchByTitle(title) }
+ }
+
+ @Test
+ fun `search returns 404 when title is not found`() {
+ val title = "Unknown Title"
+
+ every { searchFilmByTitleUseCase.searchByTitle(title) } returns null
+
+ mockMvc
+ .get("/api/films/search") {
+ param("title", title)
+ }.andExpect {
+ status { isNotFound() }
+ content { string("") }
+ }
+
+ verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) }
+ }
+}
diff --git a/src/test/kotlin/com/project/movienight/adapters/web/RecommendationControllerTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/RecommendationControllerTest.kt
new file mode 100644
index 0000000..75e222e
--- /dev/null
+++ b/src/test/kotlin/com/project/movienight/adapters/web/RecommendationControllerTest.kt
@@ -0,0 +1,26 @@
+package com.project.movienight.adapters.web
+
+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.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.get
+import java.util.UUID
+
+@SpringBootTest
+@AutoConfigureMockMvc(addFilters = false)
+class RecommendationControllerTest {
+ @Autowired
+ private lateinit var mockMvc: MockMvc
+
+ @Test
+ fun `invalid content type should return bad request`() {
+ mockMvc
+ .get("/api/users/${UUID.randomUUID()}/recommendations") {
+ param("contentType", "INVALID")
+ }.andExpect {
+ status { isBadRequest() }
+ }
+ }
+}
\ No newline at end of file
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..7145507
--- /dev/null
+++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt
@@ -0,0 +1,290 @@
+package com.project.movienight.application.services
+
+import com.project.movienight.adapters.metrics.BusinessMetricsService
+import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
+import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
+import com.project.movienight.application.ports.input.GetFilmLibraryQuery
+import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
+import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
+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 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 filmLibraryRepository: FilmLibraryRepositoryPort
+ private lateinit var idGenerator: IdGenerator
+ private lateinit var businessMetricsService: BusinessMetricsService
+ private lateinit var filmLibraryService: FilmLibraryService
+
+ @BeforeEach
+ fun setup() {
+ filmLibraryRepository = mockk()
+ idGenerator = mockk()
+ businessMetricsService = mockk(relaxed = true)
+ filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator, businessMetricsService)
+ }
+
+ @Test
+ fun `should throw EntityNotFoundException when creating library for user with no entries`() {
+ val userId = UUID.randomUUID()
+ val command = CreateFilmLibraryCommand(userId = userId, name = "My Films")
+
+ every { filmLibraryRepository.findAll() } returns emptyList()
+
+ assertThrows {
+ filmLibraryService.create(command)
+ }
+
+ verify(exactly = 1) { filmLibraryRepository.findAll() }
+ verify(exactly = 0) { idGenerator.generateId() }
+ verify(exactly = 0) { filmLibraryRepository.save(any()) }
+ }
+
+ @Test
+ fun `should return existing library when user already has one`() {
+ val userId = UUID.randomUUID()
+ val existingLibrary =
+ FilmLibrary(
+ id = UUID.randomUUID(),
+ userId = userId,
+ filmId = UUID.randomUUID(),
+ comment = "Existing Library",
+ isViewed = false,
+ )
+ val command = CreateFilmLibraryCommand(userId = userId, name = "New Library")
+
+ every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
+
+ val result = filmLibraryService.create(command)
+
+ assertEquals(existingLibrary, result)
+
+ verify(exactly = 1) { filmLibraryRepository.findAll() }
+ verify(exactly = 0) { idGenerator.generateId() }
+ verify(exactly = 0) { filmLibraryRepository.save(any()) }
+ }
+
+ @Test
+ fun `should add film to new library when user has no library`() {
+ val userId = UUID.randomUUID()
+ val filmId = UUID.randomUUID()
+ val libraryId = UUID.randomUUID()
+ val command = AddFilmToLibraryCommand(userId = userId, filmId = filmId)
+ val expectedLibrary =
+ FilmLibrary(
+ id = libraryId,
+ userId = userId,
+ filmId = filmId,
+ comment = null,
+ isViewed = false,
+ )
+
+ every { filmLibraryRepository.findAll() } returns emptyList()
+ every { idGenerator.generateId() } returns libraryId
+ every {
+ filmLibraryRepository.save(
+ match {
+ it.userId == userId && it.filmId == filmId && it.comment == null && it.isViewed == false
+ },
+ )
+ } returns expectedLibrary
+
+ val result = filmLibraryService.addFilm(command)
+
+ assertNotNull(result)
+ assertEquals(filmId, result.filmId)
+ assertEquals(userId, result.userId)
+
+ verify(exactly = 1) { filmLibraryRepository.findAll() }
+ verify(exactly = 1) { idGenerator.generateId() }
+ verify(exactly = 1) { filmLibraryRepository.save(any()) }
+ }
+
+ @Test
+ fun `should add film as a new library entry when another film already exists`() {
+ val userId = UUID.randomUUID()
+ val oldFilmId = UUID.randomUUID()
+ val newFilmId = UUID.randomUUID()
+ val existingLibrary =
+ FilmLibrary(
+ id = UUID.randomUUID(),
+ userId = userId,
+ filmId = oldFilmId,
+ comment = "My Library",
+ isViewed = true,
+ )
+ val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId)
+ val createdLibrary =
+ FilmLibrary(
+ id = UUID.randomUUID(),
+ userId = userId,
+ filmId = newFilmId,
+ comment = null,
+ isViewed = false,
+ )
+
+ every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
+ every { idGenerator.generateId() } returns createdLibrary.id
+ every {
+ filmLibraryRepository.save(
+ match {
+ it.id == createdLibrary.id && it.userId == userId && it.filmId == newFilmId && it.isViewed == false
+ },
+ )
+ } returns createdLibrary
+
+ val result = filmLibraryService.addFilm(command)
+
+ assertEquals(newFilmId, result.filmId)
+ assertEquals(false, result.isViewed)
+
+ verify(exactly = 1) { filmLibraryRepository.findAll() }
+ verify(exactly = 1) { idGenerator.generateId() }
+ verify(exactly = 1) { filmLibraryRepository.save(any()) }
+ }
+
+ @Test
+ fun `should remove film from library successfully`() {
+ val userId = UUID.randomUUID()
+ val filmId = UUID.randomUUID()
+ val libraryId = UUID.randomUUID()
+ val existingLibrary =
+ FilmLibrary(
+ id = libraryId,
+ userId = userId,
+ filmId = filmId,
+ comment = "My Library",
+ isViewed = false,
+ )
+ val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId)
+
+ every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
+ justRun { filmLibraryRepository.deleteById(libraryId) }
+
+ val result = filmLibraryService.removeFilm(command)
+
+ assertEquals(existingLibrary, result)
+
+ verify(exactly = 1) { filmLibraryRepository.findAll() }
+ verify(exactly = 1) { filmLibraryRepository.deleteById(libraryId) }
+ }
+
+ @Test
+ fun `should throw EntityNotFoundException when removing film from non-existent library`() {
+ val userId = UUID.randomUUID()
+ val filmId = UUID.randomUUID()
+ val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId)
+
+ every { filmLibraryRepository.findAll() } returns emptyList()
+
+ assertThrows {
+ filmLibraryService.removeFilm(command)
+ }
+
+ verify(exactly = 1) { filmLibraryRepository.findAll() }
+ verify(exactly = 0) { filmLibraryRepository.deleteById(any()) }
+ }
+
+ @Test
+ fun `should throw DomainException when removing film that is not in library`() {
+ val userId = UUID.randomUUID()
+ val libraryFilmId = UUID.randomUUID()
+ val differentFilmId = UUID.randomUUID()
+ val existingLibrary =
+ FilmLibrary(
+ id = UUID.randomUUID(),
+ userId = userId,
+ filmId = libraryFilmId,
+ comment = "My Library",
+ isViewed = false,
+ )
+ val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = differentFilmId)
+
+ every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
+
+ assertThrows {
+ filmLibraryService.removeFilm(command)
+ }
+
+ verify(exactly = 1) { filmLibraryRepository.findAll() }
+ verify(exactly = 0) { filmLibraryRepository.deleteById(any()) }
+ }
+
+ @Test
+ fun `should throw EntityNotFoundException when libraryId does not match`() {
+ val userId = UUID.randomUUID()
+ val filmId = UUID.randomUUID()
+ val actualLibraryId = UUID.randomUUID()
+ val wrongLibraryId = UUID.randomUUID()
+ val existingLibrary =
+ FilmLibrary(
+ id = actualLibraryId,
+ userId = userId,
+ filmId = filmId,
+ comment = "My Library",
+ isViewed = false,
+ )
+ val command =
+ RemoveFilmFromLibraryCommand(
+ userId = userId,
+ filmId = filmId,
+ libraryId = wrongLibraryId,
+ )
+
+ every { filmLibraryRepository.findById(wrongLibraryId) } returns null
+
+ assertThrows {
+ filmLibraryService.removeFilm(command)
+ }
+
+ verify(exactly = 1) { filmLibraryRepository.findById(wrongLibraryId) }
+ verify(exactly = 0) { filmLibraryRepository.deleteById(any()) }
+ }
+
+ @Test
+ fun `should get library successfully`() {
+ val userId = UUID.randomUUID()
+ val existingLibrary =
+ FilmLibrary(
+ id = UUID.randomUUID(),
+ userId = userId,
+ filmId = UUID.randomUUID(),
+ comment = "My Library",
+ isViewed = false,
+ )
+ val query = GetFilmLibraryQuery(userId = userId)
+
+ every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
+
+ val result = filmLibraryService.getLibrary(query)
+
+ assertEquals(existingLibrary, result)
+
+ verify(exactly = 1) { filmLibraryRepository.findAll() }
+ }
+
+ @Test
+ fun `should throw EntityNotFoundException when getting non-existent library`() {
+ val userId = UUID.randomUUID()
+ val query = GetFilmLibraryQuery(userId = userId)
+
+ every { filmLibraryRepository.findAll() } returns emptyList()
+
+ assertThrows {
+ filmLibraryService.getLibrary(query)
+ }
+
+ verify(exactly = 1) { filmLibraryRepository.findAll() }
+ }
+}
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..e0f96be
--- /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.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.instrument.simple.SimpleMeterRegistry
+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 meterRegistry: SimpleMeterRegistry
+ private lateinit var filmService: FilmService
+
+ @BeforeEach
+ fun setup() {
+ filmRepository = mockk()
+ idGenerator = mockk()
+ filmConfig = mockk()
+ meterRegistry = SimpleMeterRegistry()
+ filmService = FilmService(filmRepository, idGenerator, filmConfig, meterRegistry)
+ }
+
+ @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/RecommendationServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/RecommendationServiceTest.kt
new file mode 100644
index 0000000..ea8e84a
--- /dev/null
+++ b/src/test/kotlin/com/project/movienight/application/services/RecommendationServiceTest.kt
@@ -0,0 +1,56 @@
+package com.project.movienight.application.services
+
+import com.project.movienight.application.ports.input.RecommendationQuery
+import com.project.movienight.application.ports.output.BusinessMetricsPort
+import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
+import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
+import com.project.movienight.application.ports.output.FilmRepositoryPort
+import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
+import com.project.movienight.domain.model.Film
+import com.project.movienight.domain.model.FilmRating
+import com.project.movienight.domain.model.UserPreferences
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import java.util.UUID
+
+class RecommendationServiceTest {
+ private val filmRepository: FilmRepositoryPort = mockk()
+ private val filmLibraryRepository: FilmLibraryRepositoryPort = mockk()
+ private val filmRatingRepository: FilmRatingRepositoryPort = mockk()
+ private val userPreferencesRepository: UserPreferencesRepositoryPort = mockk()
+ private val businessMetricsPort: BusinessMetricsPort = mockk(relaxed = true)
+
+ private val recommendationService =
+ RecommendationService(
+ filmRepository = filmRepository,
+ filmLibraryRepository = filmLibraryRepository,
+ filmRatingRepository = filmRatingRepository,
+ userPreferencesRepository = userPreferencesRepository,
+ businessMetricsService = businessMetricsPort,
+ )
+
+ @Test
+ fun `should rank highly rated films above poorly rated films`() {
+ val userId = UUID.randomUUID()
+ val lowRatedFilmId = UUID.randomUUID()
+ val highRatedFilmId = UUID.randomUUID()
+
+ val lowRatedFilm = Film(id = lowRatedFilmId, title = "Low", description = "Low")
+ val highRatedFilm = Film(id = highRatedFilmId, title = "High", description = "High")
+
+ every { userPreferencesRepository.findByUserId(userId) } returns UserPreferences(userId = userId)
+ every { filmRatingRepository.findByUserId(userId) } returns
+ listOf(
+ FilmRating(id = UUID.randomUUID(), userId = userId, filmId = lowRatedFilmId, score = 1),
+ FilmRating(id = UUID.randomUUID(), userId = userId, filmId = highRatedFilmId, score = 10),
+ )
+ every { filmLibraryRepository.findAll() } returns emptyList()
+ every { filmRepository.findAll() } returns listOf(lowRatedFilm, highRatedFilm)
+
+ val result = recommendationService.recommend(RecommendationQuery(userId = userId))
+
+ assertEquals(highRatedFilmId, result.first().film.id)
+ }
+}
\ No newline at end of file
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..c54a909
--- /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", library = null)
+
+ 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", library = null)
+ val updatedUser = User(id = userId, name = "Jane Doe", email = "john@example.com", library = null)
+
+ 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", library = null)
+
+ 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/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/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