Merge pull request #50 from devitq/feat/extend-data-structures-48

feat: extended data structures
This commit was merged in pull request #50.
This commit is contained in:
ITQ
2026-05-21 00:37:11 +03:00
committed by GitHub
70 changed files with 2437 additions and 109 deletions
+9
View File
@@ -7,3 +7,12 @@ comments:
active: false active: false
UndocumentedPublicProperty: UndocumentedPublicProperty:
active: false active: false
style:
MagicNumber:
active: false
ReturnCount:
max: 3
complexity:
active: false
@@ -3,8 +3,10 @@ package com.project.movienight
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication @SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan("com.project.movienight.config") @ConfigurationPropertiesScan("com.project.movienight.config")
class MovieNightApplication class MovieNightApplication
@@ -0,0 +1,143 @@
package com.project.movienight.adapters.jellyfin
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
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
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<String>,
val cast: List<String>,
val directors: List<String>,
val platformRating: Double?,
val imdbRating: Double?,
val externalUrl: String?,
val jellyfinLibraryId: String?,
val isPlayed: Boolean,
)
@Service
class JellyfinApiClient(
private val properties: JellyfinIntegrationProperties,
private val objectMapper: ObjectMapper,
) {
private val httpClient: HttpClient =
HttpClient
.newBuilder()
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
.build()
fun fetchUsers(): List<JellyfinRemoteUser> =
request("Users")
.asItems()
.mapNotNull { node ->
val id = node.fieldText("Id") ?: return@mapNotNull null
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
}
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
@Suppress("MaxLineLength")
request(
"Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData",
).asItems().mapNotNull { node ->
val itemId = node.fieldText("Id") ?: return@mapNotNull null
val providerIds = node["ProviderIds"]
val imdbId = providerIds?.fieldText("Imdb")
val people = node["People"]
val cast = people?.peopleByType("Actor", "GuestStar") ?: emptyList()
val directors = people?.peopleByType("Director") ?: emptyList()
JellyfinLibraryItemSnapshot(
jellyfinItemId = itemId,
title = node.fieldText("Name") ?: itemId,
description = node.fieldText("Overview") ?: "",
contentType = mapContentType(node.fieldText("Type")),
releaseYear = node["ProductionYear"]?.takeUnless { it.isNull }?.asInt(),
genres = node["Genres"]?.textList() ?: emptyList(),
cast = cast,
directors = directors,
platformRating = node["CommunityRating"]?.takeUnless { it.isNull }?.asDouble(),
imdbRating = null,
externalUrl = imdbId?.let { "https://www.imdb.com/title/$it/" },
jellyfinLibraryId = node.fieldText("ParentId"),
isPlayed =
node["UserData"]?.booleanField("Played") ?: node["UserData"]?.booleanField("IsPlayed") ?: false,
)
}
private fun request(path: String): JsonNode {
val uri = URI.create("${properties.baseUrl.trimEnd('/')}/$path")
val request =
HttpRequest
.newBuilder(uri)
.timeout(Duration.ofMillis(properties.requestTimeoutMs))
.header("Accept", "application/json")
.header("X-Emby-Token", properties.apiKey)
.GET()
.build()
val response =
try {
httpClient.send(request, HttpResponse.BodyHandlers.ofString())
} catch (
@Suppress("TooGenericExceptionCaught") exception: Exception,
) {
throw IllegalStateException("Failed to call Jellyfin at $uri", exception)
}
check(response.statusCode() in 200..299) {
"Jellyfin request failed with status ${response.statusCode()} for $uri"
}
return objectMapper.readTree(response.body())
}
private fun JsonNode.asItems(): List<JsonNode> =
when {
isArray -> map { it }
has("Items") && this["Items"].isArray -> this["Items"].map { it }
else -> emptyList()
}
private fun JsonNode.fieldText(name: String): String? =
get(name)?.takeUnless { it.isNull }?.asText()?.takeIf { it.isNotBlank() }
private fun JsonNode.booleanField(name: String): Boolean? = get(name)?.takeUnless { it.isNull }?.asBoolean()
private fun JsonNode.textList(): List<String> =
takeIf { it.isArray }?.mapNotNull { item ->
item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() }
}
?: emptyList()
private fun JsonNode.peopleByType(vararg types: String): List<String> {
if (!isArray) return emptyList()
return mapNotNull { person ->
val type = person.fieldText("Type") ?: return@mapNotNull null
if (types.any { it.equals(type, ignoreCase = true) }) person.fieldText("Name") else null
}
}
private fun mapContentType(value: String?): ContentType =
when (value?.lowercase()) {
"movie" -> ContentType.FILM
"series" -> ContentType.SERIES
"episode" -> ContentType.EPISODE
else -> ContentType.OTHER
}
}
@@ -0,0 +1,66 @@
package com.project.movienight.adapters.metrics
import com.project.movienight.domain.model.JellyfinSyncSummary
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(
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")
fun recordRecommendationRequest() {
recommendationRequests.increment()
}
fun recordRatingSubmitted() {
ratingsSubmitted.increment()
}
fun recordLibraryEvent() {
libraryEvents.increment()
}
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)
}
fun recordJellyfinSyncFailure() {
jellyfinSyncFailures.increment()
}
fun recordJellyfinUnmappedUser() {
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
}
fun recordBackendWriteFailure() {
backendWriteFailures.increment()
}
}
@@ -0,0 +1,37 @@
package com.project.movienight.adapters.persistence.entity
import com.project.movienight.domain.model.FilmRating
import java.time.LocalDateTime
import java.util.UUID
data class FilmRatingEntity(
val id: UUID,
val userId: UUID,
val filmId: UUID,
val score: Int,
val note: String?,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
)
fun FilmRatingEntity.toDomain(): FilmRating =
FilmRating(
id = id,
userId = userId,
filmId = filmId,
score = score,
note = note,
createdAt = createdAt,
updatedAt = updatedAt,
)
fun FilmRating.toEntity(): FilmRatingEntity =
FilmRatingEntity(
id = id,
userId = userId,
filmId = filmId,
score = score,
note = note,
createdAt = createdAt,
updatedAt = updatedAt,
)
@@ -0,0 +1,31 @@
package com.project.movienight.adapters.persistence.entity
import com.project.movienight.domain.model.JellyfinSyncState
import java.time.LocalDateTime
import java.util.UUID
data class JellyfinSyncStateEntity(
val userId: UUID,
val lastSyncedAt: LocalDateTime?,
val lastSuccessfulSyncAt: LocalDateTime?,
val lastError: String?,
val syncedItemCount: Int,
)
fun JellyfinSyncStateEntity.toDomain(): JellyfinSyncState =
JellyfinSyncState(
userId = userId,
lastSyncedAt = lastSyncedAt,
lastSuccessfulSyncAt = lastSuccessfulSyncAt,
lastError = lastError,
syncedItemCount = syncedItemCount,
)
fun JellyfinSyncState.toEntity(): JellyfinSyncStateEntity =
JellyfinSyncStateEntity(
userId = userId,
lastSyncedAt = lastSyncedAt,
lastSuccessfulSyncAt = lastSuccessfulSyncAt,
lastError = lastError,
syncedItemCount = syncedItemCount,
)
@@ -11,6 +11,7 @@ data class UserEntity(
val email: String, val email: String,
val provider: String?, val provider: String?,
val providerId: String?, val providerId: String?,
val jellyfinUserId: String?,
val createdAt: LocalDateTime, val createdAt: LocalDateTime,
) )
@@ -20,6 +21,8 @@ fun UserEntity.toDomain(): User =
name = name, name = name,
email = email, email = email,
library = null, library = null,
preferences = null,
jellyfinUserId = jellyfinUserId,
) )
fun User.toEntity( fun User.toEntity(
@@ -33,5 +36,6 @@ fun User.toEntity(
email = email, email = email,
provider = provider?.name, provider = provider?.name,
providerId = providerId, providerId = providerId,
jellyfinUserId = jellyfinUserId,
createdAt = createdAt, createdAt = createdAt,
) )
@@ -0,0 +1,41 @@
package com.project.movienight.adapters.persistence.entity
import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.UserPreferences
import java.util.UUID
data class UserPreferencesEntity(
val userId: UUID,
val weightedGenres: String,
val plotTypes: String,
val eras: String,
val castAndDirectors: String,
val moods: String,
val contentTypes: String,
)
fun UserPreferencesEntity.toDomain(): UserPreferences =
UserPreferences(
userId = userId,
weightedGenres = DelimitedValueCodec.decodeWeightedMap(weightedGenres),
plotTypes = DelimitedValueCodec.decodeList(plotTypes),
eras = DelimitedValueCodec.decodeList(eras),
castAndDirectors = DelimitedValueCodec.decodeList(castAndDirectors),
moods = DelimitedValueCodec.decodeList(moods),
contentTypes =
DelimitedValueCodec.decodeList(contentTypes).mapNotNull { value ->
runCatching { ContentType.valueOf(value) }.getOrNull()
},
)
fun UserPreferences.toEntity(): UserPreferencesEntity =
UserPreferencesEntity(
userId = userId,
weightedGenres = DelimitedValueCodec.encodeWeightedMap(weightedGenres),
plotTypes = DelimitedValueCodec.encodeList(plotTypes),
eras = DelimitedValueCodec.encodeList(eras),
castAndDirectors = DelimitedValueCodec.encodeList(castAndDirectors),
moods = DelimitedValueCodec.encodeList(moods),
contentTypes = DelimitedValueCodec.encodeList(contentTypes.map { it.name }),
)
@@ -18,6 +18,7 @@ class FilmLibraryRepository(
filmId = UUID.fromString(rs.getString("film_id")), filmId = UUID.fromString(rs.getString("film_id")),
comment = rs.getString("comment"), comment = rs.getString("comment"),
isViewed = rs.getBoolean("is_viewed"), isViewed = rs.getBoolean("is_viewed"),
watchedAt = rs.getTimestamp("watched_at")?.toLocalDateTime(),
) )
} }
@@ -26,26 +27,28 @@ class FilmLibraryRepository(
jdbc.update( jdbc.update(
""" """
UPDATE favorites UPDATE favorites
SET user_id = ?, film_id = ?, comment = ?, is_viewed = ? SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?, watched_at = ?
WHERE id = ? WHERE id = ?
""".trimIndent(), """.trimIndent(),
filmLibrary.userId, filmLibrary.userId,
filmLibrary.filmId, filmLibrary.filmId,
filmLibrary.comment, filmLibrary.comment,
filmLibrary.isViewed, filmLibrary.isViewed,
filmLibrary.watchedAt,
filmLibrary.id, filmLibrary.id,
) )
if (updatedRows == 0) { if (updatedRows == 0) {
jdbc.update( jdbc.update(
""" """
INSERT INTO favorites (id, user_id, film_id, comment, is_viewed) INSERT INTO favorites (id, user_id, film_id, comment, is_viewed, watched_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""".trimIndent(), """.trimIndent(),
filmLibrary.id, filmLibrary.id,
filmLibrary.userId, filmLibrary.userId,
filmLibrary.filmId, filmLibrary.filmId,
filmLibrary.comment, filmLibrary.comment,
filmLibrary.isViewed, filmLibrary.isViewed,
filmLibrary.watchedAt,
) )
} }
return filmLibrary return filmLibrary
@@ -54,16 +57,33 @@ class FilmLibraryRepository(
override fun findById(id: UUID): FilmLibrary? { override fun findById(id: UUID): FilmLibrary? {
val entries = val entries =
jdbc.query( 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, filmLibraryRowMapper,
id, id,
) )
return entries.firstOrNull() 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<FilmLibrary> = override fun findAll(): List<FilmLibrary> =
jdbc.query( 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, filmLibraryRowMapper,
) )
@@ -0,0 +1,117 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.adapters.persistence.entity.FilmRatingEntity
import com.project.movienight.adapters.persistence.entity.toDomain
import com.project.movienight.adapters.persistence.entity.toEntity
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
import com.project.movienight.domain.model.FilmRating
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.time.LocalDateTime
import java.util.UUID
@Repository
class FilmRatingRepository(
private val jdbc: JdbcTemplate,
) : FilmRatingRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
FilmRatingEntity(
id = UUID.fromString(rs.getString("id")),
userId = UUID.fromString(rs.getString("user_id")),
filmId = UUID.fromString(rs.getString("film_id")),
score = rs.getInt("score"),
note = rs.getString("note"),
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(),
)
}
override fun save(rating: FilmRating): FilmRating {
val entity = rating.toEntity()
val updatedRows =
jdbc.update(
"""
UPDATE film_ratings
SET score = ?,
note = ?,
updated_at = ?
WHERE user_id = ?
AND film_id = ?
""".trimIndent(),
entity.score,
entity.note,
LocalDateTime.now(),
entity.userId,
entity.filmId,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO film_ratings (
id,
user_id,
film_id,
score,
note,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
entity.id,
entity.userId,
entity.filmId,
entity.score,
entity.note,
entity.createdAt,
entity.updatedAt,
)
}
return rating
}
override fun findByUserId(userId: UUID): List<FilmRating> =
jdbc
.query(
"""
SELECT id,
user_id,
film_id,
score,
note,
created_at,
updated_at
FROM film_ratings
WHERE user_id = ?
""".trimIndent(),
rowMapper,
userId,
).map { it.toDomain() }
override fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmRating? =
jdbc
.query(
"""
SELECT id,
user_id,
film_id,
score,
note,
created_at,
updated_at
FROM film_ratings
WHERE user_id = ?
AND film_id = ?
""".trimIndent(),
rowMapper,
userId,
filmId,
).firstOrNull()
?.toDomain()
}
@@ -1,6 +1,8 @@
package com.project.movienight.adapters.persistence.jdbc package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec
import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.Film import com.project.movienight.domain.model.Film
import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@@ -16,6 +18,21 @@ class FilmRepository(
id = UUID.fromString(rs.getString("id")), id = UUID.fromString(rs.getString("id")),
title = rs.getString("title"), title = rs.getString("title"),
description = rs.getString("description"), description = rs.getString("description"),
contentType =
runCatching {
ContentType.valueOf(
rs.getString("content_type"),
)
}.getOrDefault(ContentType.FILM),
releaseYear = rs.getObject("release_year")?.let { (it as Number).toInt() },
genres = DelimitedValueCodec.decodeList(rs.getString("genres")),
cast = DelimitedValueCodec.decodeList(rs.getString("cast_members")),
directors = DelimitedValueCodec.decodeList(rs.getString("directors")),
imdbRating = rs.getObject("imdb_rating")?.let { (it as Number).toDouble() },
platformRating = rs.getObject("platform_rating")?.let { (it as Number).toDouble() },
externalUrl = rs.getString("external_url"),
jellyfinItemId = rs.getString("jellyfin_item_id"),
jellyfinLibraryId = rs.getString("jellyfin_library_id"),
) )
} }
@@ -24,22 +41,67 @@ class FilmRepository(
jdbc.update( jdbc.update(
""" """
UPDATE films UPDATE films
SET title = ?, description = ? SET title = ?,
description = ?,
content_type = ?,
release_year = ?,
genres = ?,
cast_members = ?,
directors = ?,
imdb_rating = ?,
platform_rating = ?,
external_url = ?,
jellyfin_item_id = ?,
jellyfin_library_id = ?
WHERE id = ? WHERE id = ?
""".trimIndent(), """.trimIndent(),
film.title, film.title,
film.description, film.description,
film.contentType.name,
film.releaseYear,
DelimitedValueCodec.encodeList(film.genres),
DelimitedValueCodec.encodeList(film.cast),
DelimitedValueCodec.encodeList(film.directors),
film.imdbRating,
film.platformRating,
film.externalUrl,
film.jellyfinItemId,
film.jellyfinLibraryId,
film.id, film.id,
) )
if (updatedRows == 0) { if (updatedRows == 0) {
jdbc.update( jdbc.update(
""" """
INSERT INTO films (id, title, description) INSERT INTO films (
VALUES (?, ?, ?) id,
title,
description,
content_type,
release_year,
genres,
cast_members,
directors,
imdb_rating,
platform_rating,
external_url,
jellyfin_item_id,
jellyfin_library_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(), """.trimIndent(),
film.id, film.id,
film.title, film.title,
film.description, film.description,
film.contentType.name,
film.releaseYear,
DelimitedValueCodec.encodeList(film.genres),
DelimitedValueCodec.encodeList(film.cast),
DelimitedValueCodec.encodeList(film.directors),
film.imdbRating,
film.platformRating,
film.externalUrl,
film.jellyfinItemId,
film.jellyfinLibraryId,
) )
} }
return film return film
@@ -48,23 +110,124 @@ class FilmRepository(
override fun findById(id: UUID): Film? { override fun findById(id: UUID): Film? {
val films = val films =
jdbc.query( jdbc.query(
"SELECT id, title, description FROM films WHERE id = ?", """
SELECT id,
title,
description,
content_type,
release_year,
genres,
cast_members,
directors,
imdb_rating,
platform_rating,
external_url,
jellyfin_item_id,
jellyfin_library_id
FROM films
WHERE id = ?
""".trimIndent(),
filmRowMapper, filmRowMapper,
id, id,
) )
return films.firstOrNull() return films.firstOrNull()
} }
override fun findByJellyfinItemId(jellyfinItemId: String): Film? {
val films =
jdbc.query(
"""
SELECT id,
title,
description,
content_type,
release_year,
genres,
cast_members,
directors,
imdb_rating,
platform_rating,
external_url,
jellyfin_item_id,
jellyfin_library_id
FROM films
WHERE jellyfin_item_id = ?
""".trimIndent(),
filmRowMapper,
jellyfinItemId,
)
return films.firstOrNull()
}
override fun 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<Film> = override fun findAll(): List<Film> =
jdbc.query( jdbc.query(
"SELECT id, title, description FROM films", """
SELECT id,
title,
description,
content_type,
release_year,
genres,
cast_members,
directors,
imdb_rating,
platform_rating,
external_url,
jellyfin_item_id,
jellyfin_library_id
FROM films
""".trimIndent(),
filmRowMapper, filmRowMapper,
) )
override fun findByTitle(title: String): Film? { override fun findByTitle(title: String): Film? {
val films = val films =
jdbc.query( jdbc.query(
"SELECT id, title, description FROM films WHERE title = ? ORDER BY id LIMIT 1", """
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, filmRowMapper,
title, title,
) )
@@ -72,6 +235,12 @@ class FilmRepository(
} }
override fun deleteById(id: UUID) { override fun deleteById(id: UUID) {
jdbc.update("DELETE FROM films WHERE id = ?", id) jdbc.update(
"""
DELETE FROM films
WHERE id = ?
""".trimIndent(),
id,
)
} }
} }
@@ -0,0 +1,45 @@
package com.project.movienight.adapters.persistence.jdbc
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,
) {
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)
}
fun delete(eventId: String) {
val sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId"
val params = MapSqlParameterSource().addValue("eventId", eventId)
jdbc.update(sql, params)
}
}
@@ -0,0 +1,100 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.adapters.persistence.entity.JellyfinSyncStateEntity
import com.project.movienight.adapters.persistence.entity.toDomain
import com.project.movienight.adapters.persistence.entity.toEntity
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
import com.project.movienight.domain.model.JellyfinSyncState
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.util.UUID
@Repository
class JellyfinSyncStateRepository(
private val jdbc: JdbcTemplate,
) : JellyfinSyncStateRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
JellyfinSyncStateEntity(
userId = UUID.fromString(rs.getString("user_id")),
lastSyncedAt = rs.getTimestamp("last_synced_at")?.toLocalDateTime(),
lastSuccessfulSyncAt = rs.getTimestamp("last_successful_sync_at")?.toLocalDateTime(),
lastError = rs.getString("last_error"),
syncedItemCount = rs.getInt("synced_item_count"),
)
}
override fun save(state: JellyfinSyncState): JellyfinSyncState {
val entity = state.toEntity()
val updatedRows =
jdbc.update(
"""
UPDATE jellyfin_sync_state
SET last_synced_at = ?,
last_successful_sync_at = ?,
last_error = ?,
synced_item_count = ?,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = ?
""".trimIndent(),
entity.lastSyncedAt,
entity.lastSuccessfulSyncAt,
entity.lastError,
entity.syncedItemCount,
entity.userId,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO jellyfin_sync_state (
user_id,
last_synced_at,
last_successful_sync_at,
last_error,
synced_item_count
)
VALUES (?, ?, ?, ?, ?)
""".trimIndent(),
entity.userId,
entity.lastSyncedAt,
entity.lastSuccessfulSyncAt,
entity.lastError,
entity.syncedItemCount,
)
}
return state
}
override fun findByUserId(userId: UUID): JellyfinSyncState? =
jdbc
.query(
"""
SELECT user_id,
last_synced_at,
last_successful_sync_at,
last_error,
synced_item_count
FROM jellyfin_sync_state
WHERE user_id = ?
""".trimIndent(),
rowMapper,
userId,
).firstOrNull()
?.toDomain()
override fun findAll(): List<JellyfinSyncState> =
jdbc
.query(
"""
SELECT user_id,
last_synced_at,
last_successful_sync_at,
last_error,
synced_item_count
FROM jellyfin_sync_state
""".trimIndent(),
rowMapper,
).map { it.toDomain() }
}
@@ -0,0 +1,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()
}
@@ -22,6 +22,7 @@ class UserRepository(
email = rs.getString("email"), email = rs.getString("email"),
provider = rs.getString("provider"), provider = rs.getString("provider"),
providerId = rs.getString("provider_id"), providerId = rs.getString("provider_id"),
jellyfinUserId = rs.getString("jellyfin_user_id"),
createdAt = rs.getTimestamp("created_at").toLocalDateTime(), createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
) )
} }
@@ -45,27 +46,29 @@ class UserRepository(
jdbc.update( jdbc.update(
""" """
UPDATE users UPDATE users
SET name = ?, email = ?, provider = ?, provider_id = ? SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ?
WHERE id = ? WHERE id = ?
""".trimIndent(), """.trimIndent(),
entity.name, entity.name,
entity.email, entity.email,
entity.provider, entity.provider,
entity.providerId, entity.providerId,
entity.jellyfinUserId,
entity.id, entity.id,
) )
if (updatedRows == 0) { if (updatedRows == 0) {
jdbc.update( jdbc.update(
""" """
INSERT INTO users (id, name, email, provider, provider_id, created_at) INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(), """.trimIndent(),
entity.id, entity.id,
entity.name, entity.name,
entity.email, entity.email,
entity.provider, entity.provider,
entity.providerId, entity.providerId,
entity.jellyfinUserId,
entity.createdAt, entity.createdAt,
) )
} }
@@ -75,7 +78,7 @@ class UserRepository(
override fun findById(id: UUID): User? { override fun findById(id: UUID): User? {
val entities = val entities =
jdbc.query( jdbc.query(
"SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", "SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users WHERE id = ?",
userEntityRowMapper, userEntityRowMapper,
id, id,
) )
@@ -95,7 +98,7 @@ class UserRepository(
override fun findAll(): List<User> = override fun findAll(): List<User> =
jdbc jdbc
.query( .query(
"SELECT id, name, email, provider, provider_id, created_at FROM users", "SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users",
userEntityRowMapper, userEntityRowMapper,
).map { it.toDomain() } ).map { it.toDomain() }
@@ -110,8 +113,7 @@ class UserRepository(
val entities = val entities =
jdbc.query( jdbc.query(
""" """
SELECT id, name, email, provider, provider_id, created_at SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users
FROM users
WHERE provider = ? AND provider_id = ? WHERE provider = ? AND provider_id = ?
""".trimIndent(), """.trimIndent(),
userEntityRowMapper, userEntityRowMapper,
@@ -0,0 +1,38 @@
package com.project.movienight.adapters.persistence.jdbc.support
import java.net.URLDecoder
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
object DelimitedValueCodec {
fun encodeList(values: List<String>): String = values.joinToString("|") { encode(it) }
fun decodeList(value: String?): List<String> =
value
?.takeIf { it.isNotBlank() }
?.split("|")
?.map { decode(it) }
?: emptyList()
fun encodeWeightedMap(values: Map<String, Int>): String =
values.entries.joinToString("|") { entry -> "${encode(entry.key)}:${entry.value}" }
fun decodeWeightedMap(value: String?): Map<String, Int> {
if (value.isNullOrBlank()) return emptyMap()
return value
.split("|")
.mapNotNull { pair ->
val parts = pair.split(":", limit = 2)
if (parts.size != 2) return@mapNotNull null
val key = decode(parts[0])
val weight = parts[1].toIntOrNull() ?: return@mapNotNull null
key to weight
}.toMap()
}
private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
private fun decode(value: String): String = URLDecoder.decode(value, StandardCharsets.UTF_8)
}
@@ -25,6 +25,12 @@ import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import java.util.UUID import java.util.UUID
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 @RestController
@RequestMapping("/api/films") @RequestMapping("/api/films")
class FilmController( class FilmController(
@@ -45,6 +51,16 @@ class FilmController(
CreateFilmCommand( CreateFilmCommand(
title = request.title, title = request.title,
description = request.description, 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,6 +77,16 @@ class FilmController(
EditFilmCommand( EditFilmCommand(
title = request.title, title = request.title,
description = request.description, 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,
), ),
), ),
) )
@@ -11,6 +11,9 @@ import com.project.movienight.application.ports.input.GetAllFilmsUseCase
import com.project.movienight.application.ports.input.GetFilmByIdUseCase import com.project.movienight.application.ports.input.GetFilmByIdUseCase
import com.project.movienight.application.ports.input.GetFilmLibraryQuery import com.project.movienight.application.ports.input.GetFilmLibraryQuery
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase 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.RemoveFilmFromLibraryCommand
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.exception.EntityNotFoundException
@@ -30,10 +33,11 @@ import java.util.UUID
class FilmLibraryController( class FilmLibraryController(
private val createFilmLibraryUseCase: CreateFilmLibraryUseCase, private val createFilmLibraryUseCase: CreateFilmLibraryUseCase,
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
private val getFilmLibraryUseCase: GetFilmLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
private val getFilmByIdUseCase: GetFilmByIdUseCase,
private val getAllFilmsUseCase: GetAllFilmsUseCase, private val getAllFilmsUseCase: GetAllFilmsUseCase,
private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase,
) { ) {
@PostMapping @PostMapping
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
@@ -60,18 +64,10 @@ class FilmLibraryController(
), ),
) )
@GetMapping("/films") @GetMapping("/entries")
fun getAllFilmsInLibrary( fun list(
@PathVariable userId: UUID, @PathVariable userId: UUID,
): List<FilmResponse> { ): List<FilmLibraryResponse> = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) }
val library =
getFilmLibraryUseCase.getLibrary(
GetFilmLibraryQuery(userId = userId),
)
val film = getFilmByIdUseCase.getById(library.filmId)
return listOf(FilmResponse.fromDomain(film))
}
@PostMapping("/films/{filmId}") @PostMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
@@ -88,6 +84,20 @@ 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}") @DeleteMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
fun removeFilm( fun removeFilm(
@@ -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<FilmRatingResponse> = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
}
@@ -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,
)
}
}
@@ -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<JellyfinSyncState> = jellyfinSyncService.getSyncStates()
}
@@ -0,0 +1,34 @@
package com.project.movienight.adapters.web
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.RecommendationResult
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/recommendations")
class RecommendationController(
private val getRecommendationsUseCase: GetRecommendationsUseCase,
) {
@GetMapping
fun recommend(
@PathVariable userId: UUID,
@RequestParam(required = false) contentType: String?,
@RequestParam(required = false) mood: String?,
@RequestParam(required = false, defaultValue = "10") limit: Int,
): List<RecommendationResult> =
getRecommendationsUseCase.recommend(
RecommendationQuery(
userId = userId,
contentType = contentType?.let { runCatching { ContentType.valueOf(it) }.getOrNull() },
mood = mood,
limit = limit,
),
)
}
@@ -64,6 +64,7 @@ class UserController(
command = command =
EditUserCommand( EditUserCommand(
name = request.name, name = request.name,
jellyfinUserId = request.jellyfinUserId,
), ),
), ),
) )
@@ -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) }
}
@@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request
data class CreateFilmRequest( data class CreateFilmRequest(
val title: String, val title: String,
val description: String, val description: String,
val contentType: String = "FILM",
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
val imdbRating: Double? = null,
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
val jellyfinLibraryId: String? = null,
) )
@@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request
data class EditFilmRequest( data class EditFilmRequest(
val title: String, val title: String,
val description: String, val description: String,
val contentType: String = "FILM",
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
val imdbRating: Double? = null,
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
val jellyfinLibraryId: String? = null,
) )
@@ -2,4 +2,5 @@ package com.project.movienight.adapters.web.dto.request
data class EditUserRequest( data class EditUserRequest(
val name: String, val name: String,
val jellyfinUserId: String? = null,
) )
@@ -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<String, Any>? = null,
)
@@ -0,0 +1,6 @@
package com.project.movienight.adapters.web.dto.request
data class RateFilmRequest(
val score: Int,
val note: String? = null,
)
@@ -0,0 +1,10 @@
package com.project.movienight.adapters.web.dto.request
data class UpsertUserPreferencesRequest(
val weightedGenres: Map<String, Int> = emptyMap(),
val plotTypes: List<String> = emptyList(),
val eras: List<String> = emptyList(),
val castAndDirectors: List<String> = emptyList(),
val moods: List<String> = emptyList(),
val contentTypes: List<String> = emptyList(),
)
@@ -9,6 +9,7 @@ data class FilmLibraryResponse(
val filmId: UUID, val filmId: UUID,
val comment: String?, val comment: String?,
val isViewed: Boolean, val isViewed: Boolean,
val watchedAt: java.time.LocalDateTime?,
) { ) {
companion object { companion object {
fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse = fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse =
@@ -18,6 +19,7 @@ data class FilmLibraryResponse(
filmId = filmLibrary.filmId, filmId = filmLibrary.filmId,
comment = filmLibrary.comment, comment = filmLibrary.comment,
isViewed = filmLibrary.isViewed, isViewed = filmLibrary.isViewed,
watchedAt = filmLibrary.watchedAt,
) )
} }
} }
@@ -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,
)
}
}
@@ -1,5 +1,6 @@
package com.project.movienight.adapters.web.dto.response package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.Film import com.project.movienight.domain.model.Film
import java.util.UUID import java.util.UUID
@@ -7,6 +8,16 @@ data class FilmResponse(
val id: UUID, val id: UUID,
val title: String, val title: String,
val description: String, val description: String,
val contentType: ContentType,
val releaseYear: Int?,
val genres: List<String>,
val cast: List<String>,
val directors: List<String>,
val imdbRating: Double?,
val platformRating: Double?,
val externalUrl: String?,
val jellyfinItemId: String?,
val jellyfinLibraryId: String?,
) { ) {
companion object { companion object {
fun fromDomain(film: Film): FilmResponse = fun fromDomain(film: Film): FilmResponse =
@@ -14,6 +25,16 @@ data class FilmResponse(
id = film.id, id = film.id,
title = film.title, title = film.title,
description = film.description, 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,
) )
} }
} }
@@ -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<String, Int>,
val plotTypes: List<String>,
val eras: List<String>,
val castAndDirectors: List<String>,
val moods: List<String>,
val contentTypes: List<ContentType>,
) {
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,
)
}
}
@@ -7,6 +7,7 @@ data class UserResponse(
val id: UUID, val id: UUID,
val name: String, val name: String,
val email: String, val email: String,
val jellyfinUserId: String?,
) { ) {
companion object { companion object {
fun fromDomain(user: User): UserResponse = fun fromDomain(user: User): UserResponse =
@@ -14,6 +15,7 @@ data class UserResponse(
id = user.id, id = user.id,
name = user.name, name = user.name,
email = user.email, email = user.email,
jellyfinUserId = user.jellyfinUserId,
) )
} }
} }
@@ -1,6 +1,7 @@
package com.project.movienight.application.ports.input package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.FilmLibrary import com.project.movienight.domain.model.FilmLibrary
import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
interface CreateFilmLibraryUseCase { interface CreateFilmLibraryUseCase {
@@ -21,6 +22,16 @@ data class AddFilmToLibraryCommand(
val filmId: UUID, 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 { interface RemoveFilmFromLibraryUseCase {
fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary
} }
@@ -38,3 +49,7 @@ interface GetFilmLibraryUseCase {
data class GetFilmLibraryQuery( data class GetFilmLibraryQuery(
val userId: UUID, val userId: UUID,
) )
interface ListFilmLibraryEntriesUseCase {
fun list(userId: UUID): List<FilmLibrary>
}
@@ -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<FilmRating>
}
@@ -1,5 +1,6 @@
package com.project.movienight.application.ports.input package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.Film import com.project.movienight.domain.model.Film
import java.util.UUID import java.util.UUID
@@ -10,6 +11,16 @@ interface CreateFilmUseCase {
data class CreateFilmCommand( data class CreateFilmCommand(
val title: String, val title: String,
val description: String, val description: String,
val contentType: ContentType = ContentType.FILM,
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
val imdbRating: Double? = null,
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
val jellyfinLibraryId: String? = null,
) )
interface EditFilmUseCase { interface EditFilmUseCase {
@@ -22,6 +33,16 @@ interface EditFilmUseCase {
data class EditFilmCommand( data class EditFilmCommand(
val title: String, val title: String,
val description: String, val description: String,
val contentType: ContentType = ContentType.FILM,
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
val imdbRating: Double? = null,
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
val jellyfinLibraryId: String? = null,
) )
interface DeleteFilmUseCase { interface DeleteFilmUseCase {
@@ -0,0 +1,16 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.RecommendationResult
import java.util.UUID
interface GetRecommendationsUseCase {
fun recommend(query: RecommendationQuery): List<RecommendationResult>
}
data class RecommendationQuery(
val userId: UUID,
val contentType: ContentType? = null,
val mood: String? = null,
val limit: Int = 10,
)
@@ -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<String, Int> = emptyMap(),
val plotTypes: List<String> = emptyList(),
val eras: List<String> = emptyList(),
val castAndDirectors: List<String> = emptyList(),
val moods: List<String> = emptyList(),
val contentTypes: List<ContentType> = emptyList(),
)
interface GetUserPreferencesUseCase {
fun get(userId: UUID): UserPreferences?
}
@@ -21,6 +21,7 @@ interface EditUserUseCase {
data class EditUserCommand( data class EditUserCommand(
val name: String, val name: String,
val jellyfinUserId: String? = null,
) )
interface DeleteUserUseCase { interface DeleteUserUseCase {
@@ -8,6 +8,11 @@ interface FilmLibraryRepositoryPort {
fun findById(id: UUID): FilmLibrary? fun findById(id: UUID): FilmLibrary?
fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmLibrary?
fun findAll(): List<FilmLibrary> fun findAll(): List<FilmLibrary>
fun deleteById(id: UUID) fun deleteById(id: UUID)
@@ -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<FilmRating>
fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmRating?
}
@@ -8,6 +8,10 @@ interface FilmRepositoryPort {
fun findById(id: UUID): Film? fun findById(id: UUID): Film?
fun findByJellyfinItemId(jellyfinItemId: String): Film?
fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film?
fun findAll(): List<Film> fun findAll(): List<Film>
fun findByTitle(title: String): Film? fun findByTitle(title: String): Film?
@@ -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<JellyfinSyncState>
}
@@ -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?
}
@@ -1,11 +1,15 @@
package com.project.movienight.application.services 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.AddFilmToLibraryCommand
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase
import com.project.movienight.application.ports.input.GetFilmLibraryQuery import com.project.movienight.application.ports.input.GetFilmLibraryQuery
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase 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.RemoveFilmFromLibraryCommand
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
@@ -20,70 +24,105 @@ import java.util.UUID
class FilmLibraryService( class FilmLibraryService(
private val filmLibraryRepository: FilmLibraryRepositoryPort, private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val idGenerator: IdGenerator, private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
) : CreateFilmLibraryUseCase, ) : CreateFilmLibraryUseCase,
AddFilmToLibraryUseCase, AddFilmToLibraryUseCase,
MarkFilmViewedUseCase,
RemoveFilmFromLibraryUseCase, RemoveFilmFromLibraryUseCase,
GetFilmLibraryUseCase { GetFilmLibraryUseCase,
ListFilmLibraryEntriesUseCase {
override fun create(command: CreateFilmLibraryCommand): FilmLibrary { override fun create(command: CreateFilmLibraryCommand): FilmLibrary {
val existingLibrary = findByUserId(command.userId) findByUserId(command.userId)?.let { return it }
if (existingLibrary != null) { throw EntityNotFoundException(entity = "Film library", id = command.userId.toString())
return existingLibrary
}
return filmLibraryRepository.save(
FilmLibrary(
id = idGenerator.generateId(),
userId = command.userId,
filmId = idGenerator.generateId(),
comment = command.name,
isViewed = false,
),
)
} }
override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary {
val existingLibrary = findByUserId(command.userId) val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
if (existingLibrary == null) { if (existingEntry != null) {
return filmLibraryRepository.save( val saved =
filmLibraryRepository.save(
existingEntry.copy(
isViewed = false,
watchedAt = null,
),
)
businessMetricsService.recordLibraryEvent()
return saved
}
val saved =
filmLibraryRepository.save(
FilmLibrary( FilmLibrary(
id = idGenerator.generateId(), id = idGenerator.generateId(),
userId = command.userId, userId = command.userId,
filmId = command.filmId, filmId = command.filmId,
comment = null, comment = null,
isViewed = false, isViewed = false,
watchedAt = null,
), ),
) )
} businessMetricsService.recordLibraryEvent()
return saved
return filmLibraryRepository.save(
existingLibrary.copy(
filmId = command.filmId,
isViewed = false,
),
)
} }
override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary {
val existingLibrary = val existingLibrary =
findByUserId(command.userId) if (command.libraryId != null) {
?: throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) filmLibraryRepository.findById(command.libraryId)
?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString())
} else {
findByUserAndFilmId(command.userId, command.filmId)
?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString())
}
if (command.libraryId != null && command.libraryId != existingLibrary.id) { if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) {
throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString())
}
if (existingLibrary.filmId != command.filmId) {
throw DomainException("Film with id ${command.filmId} not found in user's library") throw DomainException("Film with id ${command.filmId} not found in user's library")
} }
filmLibraryRepository.deleteById(existingLibrary.id) filmLibraryRepository.deleteById(existingLibrary.id)
businessMetricsService.recordLibraryEvent()
return existingLibrary return existingLibrary
} }
override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary {
val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now()
val saved =
if (existingEntry == null) {
filmLibraryRepository.save(
FilmLibrary(
id = idGenerator.generateId(),
userId = command.userId,
filmId = command.filmId,
comment = null,
isViewed = true,
watchedAt = watchedAt,
),
)
} else {
filmLibraryRepository.save(
existingEntry.copy(
isViewed = true,
watchedAt = watchedAt,
),
)
}
businessMetricsService.recordLibraryEvent()
return saved
}
override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary = override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary =
findByUserId(query.userId) findByUserId(query.userId)
?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString())
override fun list(userId: UUID): List<FilmLibrary> = filmLibraryRepository.findAll().filter { it.userId == userId }
private fun findByUserId(userId: UUID): FilmLibrary? = 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 }
} }
@@ -0,0 +1,57 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.metrics.BusinessMetricsService
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.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: BusinessMetricsService,
) : 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<FilmRating> = filmRatingRepository.findByUserId(userId)
}
@@ -61,12 +61,20 @@ class FilmService(
id = idGenerator.generateId(), id = idGenerator.generateId(),
title = command.title, title = command.title,
description = command.description, 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) val saved = filmRepository.save(film)
filmCreatedCounter.increment() filmCreatedCounter.increment()
log.info("Film created: id='{}', title='{}'", saved.id, saved.title)
return saved return saved
} finally { } finally {
sample.stop(createFilmTimer) sample.stop(createFilmTimer)
@@ -93,23 +101,31 @@ class FilmService(
throw BlockedValueException(target = "Film", field = "description") throw BlockedValueException(target = "Film", field = "description")
} }
val film = filmRepository.findById(id) var film = filmRepository.findById(id)
if (film == null) { if (film == null) {
log.debug("Film not found for edit: id='{}'", id) log.debug("Film not found for edit: id='{}'", id)
throw EntityNotFoundException(entity = "Film", id = id.toString()) throw EntityNotFoundException(entity = "Film", id = id.toString())
} }
val updatedFilm = film =
film.copy( film.copy(
title = command.title, title = command.title,
description = command.description, 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(updatedFilm)
val saved = filmRepository.save(film)
filmEditedCounter.increment() filmEditedCounter.increment()
log.info("Film edited: id='{}'", saved.id)
return saved return saved
} finally { } finally {
sample.stop(editFilmTimer) sample.stop(editFilmTimer)
@@ -0,0 +1,81 @@
package com.project.movienight.application.services
import com.fasterxml.jackson.databind.ObjectMapper
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.adapters.persistence.jdbc.JellyfinEventRepository
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
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: JellyfinEventRepository,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
private val objectMapper: ObjectMapper,
private val businessMetricsService: BusinessMetricsService,
) {
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<String, Any>?,
) {
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
}
}
}
@@ -0,0 +1,153 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.jellyfin.JellyfinApiClient
import com.project.movienight.adapters.jellyfin.JellyfinLibraryItemSnapshot
import com.project.movienight.adapters.jellyfin.JellyfinRemoteUser
import com.project.movienight.adapters.metrics.BusinessMetricsService
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.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: JellyfinApiClient,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val syncStateRepository: JellyfinSyncStateRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
) {
@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<JellyfinSyncState> = 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,
),
)
}
}
}
@@ -0,0 +1,117 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
import com.project.movienight.application.ports.input.RecommendationQuery
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.ContentType
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.RecommendationResult
import org.springframework.stereotype.Service
@Service
class RecommendationService(
private val filmRepository: FilmRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort,
private val userPreferencesRepository: UserPreferencesRepositoryPort,
private val businessMetricsService: BusinessMetricsService,
) : GetRecommendationsUseCase {
override fun recommend(query: RecommendationQuery): List<RecommendationResult> {
businessMetricsService.recordRecommendationRequest()
val preferences = userPreferencesRepository.findByUserId(query.userId)
val ratings = filmRatingRepository.findByUserId(query.userId).associateBy { it.filmId }
val watchedFilmIds =
filmLibraryRepository
.findAll()
.filter {
it.userId == query.userId && it.isViewed
}.map { it.filmId }
.toSet()
return filmRepository
.findAll()
.asSequence()
.filter { film -> query.contentType == null || film.contentType == query.contentType }
.map { film ->
scoreFilm(film, query.mood, preferences, ratings[film.id] != null, watchedFilmIds.contains(film.id))
}.sortedByDescending { it.score }
.take(query.limit.coerceAtLeast(1))
.toList()
}
private fun scoreFilm(
film: Film,
mood: String?,
preferences: com.project.movienight.domain.model.UserPreferences?,
hasUserRating: Boolean,
watched: Boolean,
): RecommendationResult {
var score = 0.0
val reasons = mutableListOf<String>()
preferences?.contentTypes?.let {
if (it.isEmpty() || it.contains(film.contentType)) {
score += 2.0
reasons += "Matches content preference"
}
}
preferences?.weightedGenres?.forEach { (genre, weight) ->
if (film.genres.any { it.equals(genre, ignoreCase = true) }) {
score += weight
reasons += "Matches genre $genre"
}
}
preferences?.castAndDirectors?.forEach { favorite ->
val found =
film.cast.any { it.equals(favorite, ignoreCase = true) } ||
film.directors.any { it.equals(favorite, ignoreCase = true) }
if (found) {
score += 1.5
reasons += "Matches favorite creator or cast member $favorite"
}
}
preferences?.moods?.forEach { preferredMood ->
if (mood != null && preferredMood.equals(mood, ignoreCase = true)) {
score += 1.25
reasons += "Matches requested mood $mood"
}
}
film.imdbRating?.let {
score += it / 2.0
reasons += "Strong IMDb signal"
}
film.platformRating?.let {
score += it
reasons += "Strong platform signal"
}
if (hasUserRating) {
score += 2.0
reasons += "User has already rated similar content"
}
if (watched) {
score -= 3.0
reasons += "Already watched"
}
if (mood != null && film.title.contains(mood, ignoreCase = true)) {
score += 0.5
}
if (reasons.isEmpty()) {
reasons += "Baseline recommendation from library catalog"
}
return RecommendationResult(film = film, score = score, reasons = reasons)
}
}
@@ -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)
}
@@ -37,6 +37,7 @@ class UserService(
name = command.name, name = command.name,
email = command.email, email = command.email,
library = null, library = null,
jellyfinUserId = null,
) )
return userRepository.save(user) return userRepository.save(user)
} }
@@ -50,7 +51,13 @@ class UserService(
} }
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)
user =
user.copy(
name = command.name,
jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId,
)
return userRepository.save(user) return userRepository.save(user)
} }
@@ -0,0 +1,13 @@
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 apiKey: String = "",
val syncIntervalMs: Long = 1_800_000,
val requestTimeoutMs: Long = 20_000,
val pluginToken: String = "",
)
@@ -6,4 +6,21 @@ data class Film(
val id: UUID, val id: UUID,
val title: String, val title: String,
val description: String, val description: String,
val contentType: ContentType = ContentType.FILM,
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = 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,
}
@@ -1,5 +1,6 @@
package com.project.movienight.domain.model package com.project.movienight.domain.model
import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
data class FilmLibrary( data class FilmLibrary(
@@ -8,4 +9,5 @@ data class FilmLibrary(
val filmId: UUID, val filmId: UUID,
val comment: String?, val comment: String?,
val isViewed: Boolean, val isViewed: Boolean,
val watchedAt: LocalDateTime? = null,
) )
@@ -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,
)
@@ -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,
)
@@ -0,0 +1,16 @@
package com.project.movienight.domain.model
import java.util.UUID
data class RecommendationContext(
val userId: UUID,
val contentType: ContentType? = null,
val mood: String? = null,
val limit: Int = 10,
)
data class RecommendationResult(
val film: Film,
val score: Double,
val reasons: List<String>,
)
@@ -7,4 +7,6 @@ data class User(
val name: String, val name: String,
val email: String, val email: String,
val library: FilmLibrary?, val library: FilmLibrary?,
val preferences: UserPreferences? = null,
val jellyfinUserId: String? = null,
) )
@@ -0,0 +1,13 @@
package com.project.movienight.domain.model
import java.util.UUID
data class UserPreferences(
val userId: UUID,
val weightedGenres: Map<String, Int> = emptyMap(),
val plotTypes: List<String> = emptyList(),
val eras: List<String> = emptyList(),
val castAndDirectors: List<String> = emptyList(),
val moods: List<String> = emptyList(),
val contentTypes: List<ContentType> = emptyList(),
)
+8
View File
@@ -95,6 +95,14 @@ info:
description: MovieNight backend service description: MovieNight backend service
version: ${project.version:unknown} version: ${project.version:unknown}
integrations:
jellyfin:
enabled: ${JELLYFIN_SYNC_ENABLED:false}
base-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: services:
user: user:
blocked-names: blocked-names:
@@ -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);
@@ -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);
@@ -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);
@@ -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
);
@@ -0,0 +1,168 @@
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.UpsertUserPreferencesRequest
import org.junit.jupiter.api.AfterEach
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",
),
)
}.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
.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].film.id") { value(firstFilmId.toString()) }
}
}
private fun cleanDatabase() {
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")
}
}
@@ -18,6 +18,7 @@ class UserEntityMappingTest {
email = "john@email.com", email = "john@email.com",
provider = "GOOGLE", provider = "GOOGLE",
providerId = "google1234", providerId = "google1234",
jellyfinUserId = null,
createdAt = LocalDateTime.now(), createdAt = LocalDateTime.now(),
) )
val user = entity.toDomain() val user = entity.toDomain()
@@ -26,6 +27,7 @@ class UserEntityMappingTest {
assertEquals(entity.name, user.name) assertEquals(entity.name, user.name)
assertEquals(entity.email, user.email) assertEquals(entity.email, user.email)
assertNull(user.library) assertNull(user.library)
assertNull(user.jellyfinUserId)
} }
@Test @Test
@@ -1,5 +1,6 @@
package com.project.movienight.application.services 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.AddFilmToLibraryCommand
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
import com.project.movienight.application.ports.input.GetFilmLibraryQuery import com.project.movienight.application.ports.input.GetFilmLibraryQuery
@@ -23,51 +24,31 @@ import java.util.UUID
class FilmLibraryServiceTest { class FilmLibraryServiceTest {
private lateinit var filmLibraryRepository: FilmLibraryRepositoryPort private lateinit var filmLibraryRepository: FilmLibraryRepositoryPort
private lateinit var idGenerator: IdGenerator private lateinit var idGenerator: IdGenerator
private lateinit var businessMetricsService: BusinessMetricsService
private lateinit var filmLibraryService: FilmLibraryService private lateinit var filmLibraryService: FilmLibraryService
@BeforeEach @BeforeEach
fun setup() { fun setup() {
filmLibraryRepository = mockk() filmLibraryRepository = mockk()
idGenerator = mockk() idGenerator = mockk()
filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator) businessMetricsService = mockk(relaxed = true)
filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator, businessMetricsService)
} }
@Test @Test
fun `should create new film library when user has no library`() { fun `should throw EntityNotFoundException when creating library for user with no entries`() {
val userId = UUID.randomUUID() val userId = UUID.randomUUID()
val libraryId = UUID.randomUUID()
val filmId = UUID.randomUUID()
val command = CreateFilmLibraryCommand(userId = userId, name = "My Films") val command = CreateFilmLibraryCommand(userId = userId, name = "My Films")
val expectedLibrary =
FilmLibrary(
id = libraryId,
userId = userId,
filmId = filmId,
comment = "My Films",
isViewed = false,
)
every { filmLibraryRepository.findAll() } returns emptyList() every { filmLibraryRepository.findAll() } returns emptyList()
every { idGenerator.generateId() } returnsMany listOf(libraryId, filmId)
every {
filmLibraryRepository.save(
match {
it.userId == userId && it.comment == "My Films" && it.isViewed == false
},
)
} returns expectedLibrary
val result = filmLibraryService.create(command) assertThrows<EntityNotFoundException> {
filmLibraryService.create(command)
assertNotNull(result) }
assertEquals(libraryId, result.id)
assertEquals(userId, result.userId)
assertEquals(filmId, result.filmId)
assertEquals("My Films", result.comment)
verify(exactly = 1) { filmLibraryRepository.findAll() } verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 2) { idGenerator.generateId() } verify(exactly = 0) { idGenerator.generateId() }
verify(exactly = 1) { filmLibraryRepository.save(any()) } verify(exactly = 0) { filmLibraryRepository.save(any()) }
} }
@Test @Test
@@ -131,7 +112,7 @@ class FilmLibraryServiceTest {
} }
@Test @Test
fun `should add film to existing library`() { fun `should add film as a new library entry when another film already exists`() {
val userId = UUID.randomUUID() val userId = UUID.randomUUID()
val oldFilmId = UUID.randomUUID() val oldFilmId = UUID.randomUUID()
val newFilmId = UUID.randomUUID() val newFilmId = UUID.randomUUID()
@@ -144,16 +125,24 @@ class FilmLibraryServiceTest {
isViewed = true, isViewed = true,
) )
val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId) val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId)
val updatedLibrary = existingLibrary.copy(filmId = newFilmId, isViewed = false) val createdLibrary =
FilmLibrary(
id = UUID.randomUUID(),
userId = userId,
filmId = newFilmId,
comment = null,
isViewed = false,
)
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
every { idGenerator.generateId() } returns createdLibrary.id
every { every {
filmLibraryRepository.save( filmLibraryRepository.save(
match { match {
it.filmId == newFilmId && it.isViewed == false it.id == createdLibrary.id && it.userId == userId && it.filmId == newFilmId && it.isViewed == false
}, },
) )
} returns updatedLibrary } returns createdLibrary
val result = filmLibraryService.addFilm(command) val result = filmLibraryService.addFilm(command)
@@ -161,7 +150,7 @@ class FilmLibraryServiceTest {
assertEquals(false, result.isViewed) assertEquals(false, result.isViewed)
verify(exactly = 1) { filmLibraryRepository.findAll() } verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 0) { idGenerator.generateId() } verify(exactly = 1) { idGenerator.generateId() }
verify(exactly = 1) { filmLibraryRepository.save(any()) } verify(exactly = 1) { filmLibraryRepository.save(any()) }
} }
@@ -253,13 +242,13 @@ class FilmLibraryServiceTest {
libraryId = wrongLibraryId, libraryId = wrongLibraryId,
) )
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) every { filmLibraryRepository.findById(wrongLibraryId) } returns null
assertThrows<EntityNotFoundException> { assertThrows<EntityNotFoundException> {
filmLibraryService.removeFilm(command) filmLibraryService.removeFilm(command)
} }
verify(exactly = 1) { filmLibraryRepository.findAll() } verify(exactly = 1) { filmLibraryRepository.findById(wrongLibraryId) }
verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } verify(exactly = 0) { filmLibraryRepository.deleteById(any()) }
} }