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:
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.entity
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.FilmRating
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class FilmRatingEntity(
|
||||||
|
val id: UUID,
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
val score: Int,
|
||||||
|
val note: String?,
|
||||||
|
val createdAt: LocalDateTime,
|
||||||
|
val updatedAt: LocalDateTime,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun FilmRatingEntity.toDomain(): FilmRating =
|
||||||
|
FilmRating(
|
||||||
|
id = id,
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
score = score,
|
||||||
|
note = note,
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun FilmRating.toEntity(): FilmRatingEntity =
|
||||||
|
FilmRatingEntity(
|
||||||
|
id = id,
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
score = score,
|
||||||
|
note = note,
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
)
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.entity
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncState
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class JellyfinSyncStateEntity(
|
||||||
|
val userId: UUID,
|
||||||
|
val lastSyncedAt: LocalDateTime?,
|
||||||
|
val lastSuccessfulSyncAt: LocalDateTime?,
|
||||||
|
val lastError: String?,
|
||||||
|
val syncedItemCount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun JellyfinSyncStateEntity.toDomain(): JellyfinSyncState =
|
||||||
|
JellyfinSyncState(
|
||||||
|
userId = userId,
|
||||||
|
lastSyncedAt = lastSyncedAt,
|
||||||
|
lastSuccessfulSyncAt = lastSuccessfulSyncAt,
|
||||||
|
lastError = lastError,
|
||||||
|
syncedItemCount = syncedItemCount,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun JellyfinSyncState.toEntity(): JellyfinSyncStateEntity =
|
||||||
|
JellyfinSyncStateEntity(
|
||||||
|
userId = userId,
|
||||||
|
lastSyncedAt = lastSyncedAt,
|
||||||
|
lastSuccessfulSyncAt = lastSuccessfulSyncAt,
|
||||||
|
lastError = lastError,
|
||||||
|
syncedItemCount = syncedItemCount,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.entity
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class UserPreferencesEntity(
|
||||||
|
val userId: UUID,
|
||||||
|
val weightedGenres: String,
|
||||||
|
val plotTypes: String,
|
||||||
|
val eras: String,
|
||||||
|
val castAndDirectors: String,
|
||||||
|
val moods: String,
|
||||||
|
val contentTypes: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun UserPreferencesEntity.toDomain(): UserPreferences =
|
||||||
|
UserPreferences(
|
||||||
|
userId = userId,
|
||||||
|
weightedGenres = DelimitedValueCodec.decodeWeightedMap(weightedGenres),
|
||||||
|
plotTypes = DelimitedValueCodec.decodeList(plotTypes),
|
||||||
|
eras = DelimitedValueCodec.decodeList(eras),
|
||||||
|
castAndDirectors = DelimitedValueCodec.decodeList(castAndDirectors),
|
||||||
|
moods = DelimitedValueCodec.decodeList(moods),
|
||||||
|
contentTypes =
|
||||||
|
DelimitedValueCodec.decodeList(contentTypes).mapNotNull { value ->
|
||||||
|
runCatching { ContentType.valueOf(value) }.getOrNull()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fun UserPreferences.toEntity(): UserPreferencesEntity =
|
||||||
|
UserPreferencesEntity(
|
||||||
|
userId = userId,
|
||||||
|
weightedGenres = DelimitedValueCodec.encodeWeightedMap(weightedGenres),
|
||||||
|
plotTypes = DelimitedValueCodec.encodeList(plotTypes),
|
||||||
|
eras = DelimitedValueCodec.encodeList(eras),
|
||||||
|
castAndDirectors = DelimitedValueCodec.encodeList(castAndDirectors),
|
||||||
|
moods = DelimitedValueCodec.encodeList(moods),
|
||||||
|
contentTypes = DelimitedValueCodec.encodeList(contentTypes.map { it.name }),
|
||||||
|
)
|
||||||
+25
-5
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.persistence.entity.FilmRatingEntity
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toDomain
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toEntity
|
||||||
|
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.FilmRating
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.sql.ResultSet
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class FilmRatingRepository(
|
||||||
|
private val jdbc: JdbcTemplate,
|
||||||
|
) : FilmRatingRepositoryPort {
|
||||||
|
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||||
|
FilmRatingEntity(
|
||||||
|
id = UUID.fromString(rs.getString("id")),
|
||||||
|
userId = UUID.fromString(rs.getString("user_id")),
|
||||||
|
filmId = UUID.fromString(rs.getString("film_id")),
|
||||||
|
score = rs.getInt("score"),
|
||||||
|
note = rs.getString("note"),
|
||||||
|
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
|
||||||
|
updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(rating: FilmRating): FilmRating {
|
||||||
|
val entity = rating.toEntity()
|
||||||
|
val updatedRows =
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
UPDATE film_ratings
|
||||||
|
SET score = ?,
|
||||||
|
note = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND film_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.score,
|
||||||
|
entity.note,
|
||||||
|
LocalDateTime.now(),
|
||||||
|
entity.userId,
|
||||||
|
entity.filmId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updatedRows == 0) {
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
INSERT INTO film_ratings (
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
film_id,
|
||||||
|
score,
|
||||||
|
note,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.id,
|
||||||
|
entity.userId,
|
||||||
|
entity.filmId,
|
||||||
|
entity.score,
|
||||||
|
entity.note,
|
||||||
|
entity.createdAt,
|
||||||
|
entity.updatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rating
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByUserId(userId: UUID): List<FilmRating> =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT id,
|
||||||
|
user_id,
|
||||||
|
film_id,
|
||||||
|
score,
|
||||||
|
note,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM film_ratings
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
).map { it.toDomain() }
|
||||||
|
|
||||||
|
override fun findByUserIdAndFilmId(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
): FilmRating? =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT id,
|
||||||
|
user_id,
|
||||||
|
film_id,
|
||||||
|
score,
|
||||||
|
note,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM film_ratings
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND film_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
filmId,
|
||||||
|
).firstOrNull()
|
||||||
|
?.toDomain()
|
||||||
|
}
|
||||||
+176
-7
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.persistence.entity.JellyfinSyncStateEntity
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toDomain
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toEntity
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncState
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.sql.ResultSet
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class JellyfinSyncStateRepository(
|
||||||
|
private val jdbc: JdbcTemplate,
|
||||||
|
) : JellyfinSyncStateRepositoryPort {
|
||||||
|
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||||
|
JellyfinSyncStateEntity(
|
||||||
|
userId = UUID.fromString(rs.getString("user_id")),
|
||||||
|
lastSyncedAt = rs.getTimestamp("last_synced_at")?.toLocalDateTime(),
|
||||||
|
lastSuccessfulSyncAt = rs.getTimestamp("last_successful_sync_at")?.toLocalDateTime(),
|
||||||
|
lastError = rs.getString("last_error"),
|
||||||
|
syncedItemCount = rs.getInt("synced_item_count"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(state: JellyfinSyncState): JellyfinSyncState {
|
||||||
|
val entity = state.toEntity()
|
||||||
|
val updatedRows =
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
UPDATE jellyfin_sync_state
|
||||||
|
SET last_synced_at = ?,
|
||||||
|
last_successful_sync_at = ?,
|
||||||
|
last_error = ?,
|
||||||
|
synced_item_count = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.lastSyncedAt,
|
||||||
|
entity.lastSuccessfulSyncAt,
|
||||||
|
entity.lastError,
|
||||||
|
entity.syncedItemCount,
|
||||||
|
entity.userId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updatedRows == 0) {
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
INSERT INTO jellyfin_sync_state (
|
||||||
|
user_id,
|
||||||
|
last_synced_at,
|
||||||
|
last_successful_sync_at,
|
||||||
|
last_error,
|
||||||
|
synced_item_count
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.userId,
|
||||||
|
entity.lastSyncedAt,
|
||||||
|
entity.lastSuccessfulSyncAt,
|
||||||
|
entity.lastError,
|
||||||
|
entity.syncedItemCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByUserId(userId: UUID): JellyfinSyncState? =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT user_id,
|
||||||
|
last_synced_at,
|
||||||
|
last_successful_sync_at,
|
||||||
|
last_error,
|
||||||
|
synced_item_count
|
||||||
|
FROM jellyfin_sync_state
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
).firstOrNull()
|
||||||
|
?.toDomain()
|
||||||
|
|
||||||
|
override fun findAll(): List<JellyfinSyncState> =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT user_id,
|
||||||
|
last_synced_at,
|
||||||
|
last_successful_sync_at,
|
||||||
|
last_error,
|
||||||
|
synced_item_count
|
||||||
|
FROM jellyfin_sync_state
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
).map { it.toDomain() }
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.persistence.entity.UserPreferencesEntity
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toDomain
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toEntity
|
||||||
|
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.sql.ResultSet
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class UserPreferencesRepository(
|
||||||
|
private val jdbc: JdbcTemplate,
|
||||||
|
) : UserPreferencesRepositoryPort {
|
||||||
|
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||||
|
UserPreferencesEntity(
|
||||||
|
userId = UUID.fromString(rs.getString("user_id")),
|
||||||
|
weightedGenres = rs.getString("weighted_genres"),
|
||||||
|
plotTypes = rs.getString("plot_types"),
|
||||||
|
eras = rs.getString("eras"),
|
||||||
|
castAndDirectors = rs.getString("cast_and_directors"),
|
||||||
|
moods = rs.getString("moods"),
|
||||||
|
contentTypes = rs.getString("content_types"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(preferences: UserPreferences): UserPreferences {
|
||||||
|
val entity = preferences.toEntity()
|
||||||
|
val updatedRows =
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
UPDATE user_preferences
|
||||||
|
SET weighted_genres = ?,
|
||||||
|
plot_types = ?,
|
||||||
|
eras = ?,
|
||||||
|
cast_and_directors = ?,
|
||||||
|
moods = ?,
|
||||||
|
content_types = ?
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.weightedGenres,
|
||||||
|
entity.plotTypes,
|
||||||
|
entity.eras,
|
||||||
|
entity.castAndDirectors,
|
||||||
|
entity.moods,
|
||||||
|
entity.contentTypes,
|
||||||
|
entity.userId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updatedRows == 0) {
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_preferences (
|
||||||
|
user_id,
|
||||||
|
weighted_genres,
|
||||||
|
plot_types,
|
||||||
|
eras,
|
||||||
|
cast_and_directors,
|
||||||
|
moods,
|
||||||
|
content_types
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.userId,
|
||||||
|
entity.weightedGenres,
|
||||||
|
entity.plotTypes,
|
||||||
|
entity.eras,
|
||||||
|
entity.castAndDirectors,
|
||||||
|
entity.moods,
|
||||||
|
entity.contentTypes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferences
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByUserId(userId: UUID): UserPreferences? =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT user_id,
|
||||||
|
weighted_genres,
|
||||||
|
plot_types,
|
||||||
|
eras,
|
||||||
|
cast_and_directors,
|
||||||
|
moods,
|
||||||
|
content_types
|
||||||
|
FROM user_preferences
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
).firstOrNull()
|
||||||
|
?.toDomain()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.jdbc.support
|
||||||
|
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
object DelimitedValueCodec {
|
||||||
|
fun encodeList(values: List<String>): String = values.joinToString("|") { encode(it) }
|
||||||
|
|
||||||
|
fun decodeList(value: String?): List<String> =
|
||||||
|
value
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.split("|")
|
||||||
|
?.map { decode(it) }
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
fun encodeWeightedMap(values: Map<String, Int>): String =
|
||||||
|
values.entries.joinToString("|") { entry -> "${encode(entry.key)}:${entry.value}" }
|
||||||
|
|
||||||
|
fun decodeWeightedMap(value: String?): Map<String, Int> {
|
||||||
|
if (value.isNullOrBlank()) return emptyMap()
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split("|")
|
||||||
|
.mapNotNull { pair ->
|
||||||
|
val parts = pair.split(":", limit = 2)
|
||||||
|
if (parts.size != 2) return@mapNotNull null
|
||||||
|
|
||||||
|
val key = decode(parts[0])
|
||||||
|
val weight = parts[1].toIntOrNull() ?: return@mapNotNull null
|
||||||
|
key to weight
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
|
||||||
|
|
||||||
|
private fun decode(value: String): String = URLDecoder.decode(value, StandardCharsets.UTF_8)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+21
@@ -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,
|
||||||
|
)
|
||||||
+10
@@ -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(),
|
||||||
|
)
|
||||||
+2
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -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 {
|
||||||
|
|||||||
+16
@@ -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,
|
||||||
|
)
|
||||||
+23
@@ -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 {
|
||||||
|
|||||||
+5
@@ -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)
|
||||||
|
|||||||
+15
@@ -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?
|
||||||
|
|||||||
+12
@@ -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>
|
||||||
|
}
|
||||||
+10
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -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
|
||||||
|
|||||||
+25
-36
@@ -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()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user