From 75bbe27198a119a6ffcdf1486705b0df613128a4 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:50:22 +0300 Subject: [PATCH 01/14] feat(migrations): extended data structures --- src/main/resources/db/migration/V1__init.sql | 49 ++++++++++++++++++- .../db/migration/V2__jellyfin_events.sql | 14 ++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V2__jellyfin_events.sql diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 900f6b5..e98c8ff 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -4,13 +4,24 @@ CREATE TABLE IF NOT EXISTS public.users ( email VARCHAR(320) NOT NULL UNIQUE, provider VARCHAR(64), provider_id VARCHAR(255), + jellyfin_user_id VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS public.films ( id UUID PRIMARY KEY, title VARCHAR(255) NOT NULL, - description TEXT NOT NULL + description TEXT NOT NULL, + content_type VARCHAR(32) NOT NULL DEFAULT 'FILM', + release_year INT, + genres TEXT NOT NULL DEFAULT '', + cast_members TEXT NOT NULL DEFAULT '', + directors TEXT NOT NULL DEFAULT '', + imdb_rating DOUBLE PRECISION, + platform_rating DOUBLE PRECISION, + external_url TEXT, + jellyfin_item_id VARCHAR(255), + jellyfin_library_id VARCHAR(255) ); CREATE TABLE IF NOT EXISTS public.favorites ( @@ -19,6 +30,42 @@ CREATE TABLE IF NOT EXISTS public.favorites ( film_id UUID NOT NULL, comment VARCHAR(1024), is_viewed BOOLEAN NOT NULL DEFAULT FALSE, + watched_at TIMESTAMP, CONSTRAINT favorites_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, CONSTRAINT favorites_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE ); + +CREATE TABLE IF NOT EXISTS public.user_preferences ( + user_id UUID PRIMARY KEY, + weighted_genres TEXT NOT NULL DEFAULT '', + plot_types TEXT NOT NULL DEFAULT '', + eras TEXT NOT NULL DEFAULT '', + cast_and_directors TEXT NOT NULL DEFAULT '', + moods TEXT NOT NULL DEFAULT '', + content_types TEXT NOT NULL DEFAULT '', + CONSTRAINT user_preferences_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS public.film_ratings ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + film_id UUID NOT NULL, + score INT NOT NULL, + note VARCHAR(2048), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT film_ratings_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_score_range CHECK (score >= 1 AND score <= 10), + CONSTRAINT film_ratings_user_film_unique UNIQUE (user_id, film_id) +); + +CREATE TABLE IF NOT EXISTS public.jellyfin_sync_state ( + user_id UUID PRIMARY KEY, + last_synced_at TIMESTAMP, + last_successful_sync_at TIMESTAMP, + last_error TEXT, + synced_item_count INT NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT jellyfin_sync_state_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V2__jellyfin_events.sql b/src/main/resources/db/migration/V2__jellyfin_events.sql new file mode 100644 index 0000000..9f7b8fa --- /dev/null +++ b/src/main/resources/db/migration/V2__jellyfin_events.sql @@ -0,0 +1,14 @@ +-- Create table to store Jellyfin events for idempotency and auditing +CREATE TABLE IF NOT EXISTS jellyfin_events ( + event_id VARCHAR(255) PRIMARY KEY, + server_id VARCHAR(255), + event_type VARCHAR(255) NOT NULL, + occurred_at TIMESTAMP WITH TIME ZONE, + jellyfin_user_id VARCHAR(255), + jellyfin_item_id VARCHAR(255), + payload JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_jellyfin_events_user ON jellyfin_events(jellyfin_user_id); +CREATE INDEX IF NOT EXISTS idx_jellyfin_events_item ON jellyfin_events(jellyfin_item_id); -- 2.54.0 From 54305f7d162e884e286385b6d27fe739c17db697 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:51:09 +0300 Subject: [PATCH 02/14] chore(persistence): actualized jdbc adapters according to new data structures --- .../persistence/jdbc/FilmLibraryRepository.kt | 27 ++++-- .../persistence/jdbc/FilmRatingRepository.kt | 85 +++++++++++++++++++ .../persistence/jdbc/FilmRepository.kt | 67 +++++++++++++-- .../jdbc/JellyfinEventRepository.kt | 43 ++++++++++ .../jdbc/JellyfinSyncStateRepository.kt | 75 ++++++++++++++++ .../jdbc/UserPreferencesRepository.kt | 74 ++++++++++++++++ .../persistence/jdbc/UserRepository.kt | 15 ++-- .../jdbc/support/DelimitedValueCodec.kt | 38 +++++++++ 8 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt index f5603cb..fa7f153 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt @@ -18,6 +18,7 @@ class FilmLibraryRepository( filmId = UUID.fromString(rs.getString("film_id")), comment = rs.getString("comment"), isViewed = rs.getBoolean("is_viewed"), + watchedAt = rs.getTimestamp("watched_at")?.toLocalDateTime(), ) } @@ -26,26 +27,28 @@ class FilmLibraryRepository( jdbc.update( """ UPDATE favorites - SET user_id = ?, film_id = ?, comment = ?, is_viewed = ? + SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?, watched_at = ? WHERE id = ? """.trimIndent(), filmLibrary.userId, filmLibrary.filmId, filmLibrary.comment, filmLibrary.isViewed, + filmLibrary.watchedAt, filmLibrary.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO favorites (id, user_id, film_id, comment, is_viewed) - VALUES (?, ?, ?, ?, ?) + INSERT INTO favorites (id, user_id, film_id, comment, is_viewed, watched_at) + VALUES (?, ?, ?, ?, ?, ?) """.trimIndent(), filmLibrary.id, filmLibrary.userId, filmLibrary.filmId, filmLibrary.comment, filmLibrary.isViewed, + filmLibrary.watchedAt, ) } return filmLibrary @@ -54,16 +57,30 @@ class FilmLibraryRepository( override fun findById(id: UUID): FilmLibrary? { val entries = jdbc.query( - "SELECT id, user_id, film_id, comment, is_viewed FROM favorites WHERE id = ?", + "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE id = ?", filmLibraryRowMapper, id, ) return entries.firstOrNull() } + override fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibrary? { + val entries = + jdbc.query( + "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ? AND film_id = ?", + filmLibraryRowMapper, + userId, + filmId, + ) + return entries.firstOrNull() + } + override fun findAll(): List = jdbc.query( - "SELECT id, user_id, film_id, comment, is_viewed FROM favorites", + "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites", filmLibraryRowMapper, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt new file mode 100644 index 0000000..fac103b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt @@ -0,0 +1,85 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.adapters.persistence.entity.FilmRatingEntity +import com.project.movienight.adapters.persistence.entity.toDomain +import com.project.movienight.adapters.persistence.entity.toEntity +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.domain.model.FilmRating +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.time.LocalDateTime +import java.util.UUID + +@Repository +class FilmRatingRepository( + private val jdbc: JdbcTemplate, +) : FilmRatingRepositoryPort { + private val rowMapper = { rs: ResultSet, _: Int -> + FilmRatingEntity( + id = UUID.fromString(rs.getString("id")), + userId = UUID.fromString(rs.getString("user_id")), + filmId = UUID.fromString(rs.getString("film_id")), + score = rs.getInt("score"), + note = rs.getString("note"), + createdAt = rs.getTimestamp("created_at").toLocalDateTime(), + updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(), + ) + } + + override fun save(rating: FilmRating): FilmRating { + val entity = rating.toEntity() + val updatedRows = + jdbc.update( + """ + UPDATE film_ratings + SET score = ?, note = ?, updated_at = ? + WHERE user_id = ? AND film_id = ? + """.trimIndent(), + entity.score, + entity.note, + LocalDateTime.now(), + entity.userId, + entity.filmId, + ) + + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO film_ratings (id, user_id, film_id, score, note, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + entity.id, + entity.userId, + entity.filmId, + entity.score, + entity.note, + entity.createdAt, + entity.updatedAt, + ) + } + + return rating + } + + override fun findByUserId(userId: UUID): List = + jdbc + .query( + "SELECT id, user_id, film_id, score, note, created_at, updated_at FROM film_ratings WHERE user_id = ?", + 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 = ?", + rowMapper, + userId, + filmId, + ).firstOrNull() + ?.toDomain() +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index 2883aca..8da5f94 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -1,6 +1,8 @@ package com.project.movienight.adapters.persistence.jdbc +import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository @@ -16,6 +18,21 @@ class FilmRepository( id = UUID.fromString(rs.getString("id")), title = rs.getString("title"), description = rs.getString("description"), + contentType = + runCatching { + ContentType.valueOf( + rs.getString("content_type"), + ) + }.getOrDefault(ContentType.FILM), + releaseYear = rs.getObject("release_year")?.let { (it as Number).toInt() }, + genres = DelimitedValueCodec.decodeList(rs.getString("genres")), + cast = DelimitedValueCodec.decodeList(rs.getString("cast_members")), + directors = DelimitedValueCodec.decodeList(rs.getString("directors")), + imdbRating = rs.getObject("imdb_rating")?.let { (it as Number).toDouble() }, + platformRating = rs.getObject("platform_rating")?.let { (it as Number).toDouble() }, + externalUrl = rs.getString("external_url"), + jellyfinItemId = rs.getString("jellyfin_item_id"), + jellyfinLibraryId = rs.getString("jellyfin_library_id"), ) } @@ -24,22 +41,42 @@ class FilmRepository( jdbc.update( """ UPDATE films - SET title = ?, description = ? + SET title = ?, description = ?, content_type = ?, release_year = ?, genres = ?, cast_members = ?, directors = ?, imdb_rating = ?, platform_rating = ?, external_url = ?, jellyfin_item_id = ?, jellyfin_library_id = ? WHERE id = ? """.trimIndent(), film.title, film.description, + film.contentType.name, + film.releaseYear, + DelimitedValueCodec.encodeList(film.genres), + DelimitedValueCodec.encodeList(film.cast), + DelimitedValueCodec.encodeList(film.directors), + film.imdbRating, + film.platformRating, + film.externalUrl, + film.jellyfinItemId, + film.jellyfinLibraryId, film.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO films (id, title, description) - VALUES (?, ?, ?) + INSERT INTO films (id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent(), film.id, film.title, film.description, + film.contentType.name, + film.releaseYear, + DelimitedValueCodec.encodeList(film.genres), + DelimitedValueCodec.encodeList(film.cast), + DelimitedValueCodec.encodeList(film.directors), + film.imdbRating, + film.platformRating, + film.externalUrl, + film.jellyfinItemId, + film.jellyfinLibraryId, ) } return film @@ -48,16 +85,36 @@ class FilmRepository( override fun findById(id: UUID): Film? { val films = jdbc.query( - "SELECT id, title, description FROM films WHERE id = ?", + "SELECT id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id FROM films WHERE id = ?", filmRowMapper, id, ) return films.firstOrNull() } + override fun findByJellyfinItemId(jellyfinItemId: String): Film? { + val films = + jdbc.query( + "SELECT id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id FROM films WHERE jellyfin_item_id = ?", + 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 = ?", + filmRowMapper, + jellyfinLibraryId, + ) + return films.firstOrNull() + } + override fun findAll(): List = jdbc.query( - "SELECT id, title, description FROM films", + "SELECT id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id FROM films", filmRowMapper, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt new file mode 100644 index 0000000..6092c47 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -0,0 +1,43 @@ +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 exists(eventId: String): Boolean { + val sql = "SELECT 1 FROM jellyfin_events WHERE event_id = :eventId" + val params = MapSqlParameterSource().addValue("eventId", eventId) + return jdbc.query(sql, params) { rs, _ -> rs.getInt(1) }.any() + } + + fun save( + eventId: String, + serverId: String?, + eventType: String, + occurredAt: java.time.OffsetDateTime?, + jellyfinUserId: String?, + jellyfinItemId: String?, + payload: String?, + ) { + 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) + + jdbc.update(sql, params) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt new file mode 100644 index 0000000..151b525 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt @@ -0,0 +1,75 @@ +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 = ?", + rowMapper, + userId, + ).firstOrNull() + ?.toDomain() + + override fun findAll(): List = + jdbc + .query( + "SELECT user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count FROM jellyfin_sync_state", + rowMapper, + ).map { it.toDomain() } +} \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt new file mode 100644 index 0000000..58e056e --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt @@ -0,0 +1,74 @@ +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 = ?", + rowMapper, + userId, + ).firstOrNull() + ?.toDomain() +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index 6c70b4c..f6e261a 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -22,6 +22,7 @@ class UserRepository( email = rs.getString("email"), provider = rs.getString("provider"), providerId = rs.getString("provider_id"), + jellyfinUserId = rs.getString("jellyfin_user_id"), createdAt = rs.getTimestamp("created_at").toLocalDateTime(), ) } @@ -32,26 +33,28 @@ class UserRepository( jdbc.update( """ UPDATE users - SET name = ?, email = ?, provider = ?, provider_id = ? + SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ? WHERE id = ? """.trimIndent(), entity.name, entity.email, entity.provider, entity.providerId, + entity.jellyfinUserId, entity.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email, provider, provider_id, created_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) """.trimIndent(), entity.id, entity.name, entity.email, entity.provider, entity.providerId, + entity.jellyfinUserId, entity.createdAt, ) } @@ -61,7 +64,7 @@ class UserRepository( override fun findById(id: UUID): User? { val entities = 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, id, ) @@ -71,7 +74,7 @@ class UserRepository( override fun findAll(): List = jdbc .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, ).map { it.toDomain() } @@ -86,7 +89,7 @@ class UserRepository( val entities = jdbc.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 WHERE provider = ? AND provider_id = ? """.trimIndent(), userEntityRowMapper, diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt new file mode 100644 index 0000000..d671f71 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt @@ -0,0 +1,38 @@ +package com.project.movienight.adapters.persistence.jdbc.support + +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +object DelimitedValueCodec { + fun encodeList(values: List): String = values.joinToString("|") { encode(it) } + + fun decodeList(value: String?): List = + value + ?.takeIf { it.isNotBlank() } + ?.split("|") + ?.map { decode(it) } + ?: emptyList() + + fun encodeWeightedMap(values: Map): String = + values.entries.joinToString("|") { entry -> "${encode(entry.key)}:${entry.value}" } + + fun decodeWeightedMap(value: String?): Map { + if (value.isNullOrBlank()) return emptyMap() + + return value + .split("|") + .mapNotNull { pair -> + val parts = pair.split(":", limit = 2) + if (parts.size != 2) return@mapNotNull null + + val key = decode(parts[0]) + val weight = parts[1].toIntOrNull() ?: return@mapNotNull null + key to weight + }.toMap() + } + + private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8) + + private fun decode(value: String): String = URLDecoder.decode(value, StandardCharsets.UTF_8) +} -- 2.54.0 From 8e89fe5f7fba13c9458f633c729d2c3c7776eb57 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:51:39 +0300 Subject: [PATCH 03/14] feat(entities): actualized entities and added new --- .../persistence/entity/FilmRatingEntity.kt | 37 +++++++++++++++++ .../entity/JellyfinSyncStateEntity.kt | 31 ++++++++++++++ .../adapters/persistence/entity/UserEntity.kt | 4 ++ .../entity/UserPreferencesEntity.kt | 41 +++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt new file mode 100644 index 0000000..10a86db --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt @@ -0,0 +1,37 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.domain.model.FilmRating +import java.time.LocalDateTime +import java.util.UUID + +data class FilmRatingEntity( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String?, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, +) + +fun FilmRatingEntity.toDomain(): FilmRating = + FilmRating( + id = id, + userId = userId, + filmId = filmId, + score = score, + note = note, + createdAt = createdAt, + updatedAt = updatedAt, + ) + +fun FilmRating.toEntity(): FilmRatingEntity = + FilmRatingEntity( + id = id, + userId = userId, + filmId = filmId, + score = score, + note = note, + createdAt = createdAt, + updatedAt = updatedAt, + ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt new file mode 100644 index 0000000..5720ba9 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt @@ -0,0 +1,31 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.domain.model.JellyfinSyncState +import java.time.LocalDateTime +import java.util.UUID + +data class JellyfinSyncStateEntity( + val userId: UUID, + val lastSyncedAt: LocalDateTime?, + val lastSuccessfulSyncAt: LocalDateTime?, + val lastError: String?, + val syncedItemCount: Int, +) + +fun JellyfinSyncStateEntity.toDomain(): JellyfinSyncState = + JellyfinSyncState( + userId = userId, + lastSyncedAt = lastSyncedAt, + lastSuccessfulSyncAt = lastSuccessfulSyncAt, + lastError = lastError, + syncedItemCount = syncedItemCount, + ) + +fun JellyfinSyncState.toEntity(): JellyfinSyncStateEntity = + JellyfinSyncStateEntity( + userId = userId, + lastSyncedAt = lastSyncedAt, + lastSuccessfulSyncAt = lastSuccessfulSyncAt, + lastError = lastError, + syncedItemCount = syncedItemCount, + ) \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt index 0beda74..58e2c3c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -11,6 +11,7 @@ data class UserEntity( val email: String, val provider: String?, val providerId: String?, + val jellyfinUserId: String?, val createdAt: LocalDateTime, ) @@ -20,6 +21,8 @@ fun UserEntity.toDomain(): User = name = name, email = email, library = null, + preferences = null, + jellyfinUserId = jellyfinUserId, ) fun User.toEntity( @@ -33,5 +36,6 @@ fun User.toEntity( email = email, provider = provider?.name, providerId = providerId, + jellyfinUserId = jellyfinUserId, createdAt = createdAt, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt new file mode 100644 index 0000000..706d175 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt @@ -0,0 +1,41 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +data class UserPreferencesEntity( + val userId: UUID, + val weightedGenres: String, + val plotTypes: String, + val eras: String, + val castAndDirectors: String, + val moods: String, + val contentTypes: String, +) + +fun UserPreferencesEntity.toDomain(): UserPreferences = + UserPreferences( + userId = userId, + weightedGenres = DelimitedValueCodec.decodeWeightedMap(weightedGenres), + plotTypes = DelimitedValueCodec.decodeList(plotTypes), + eras = DelimitedValueCodec.decodeList(eras), + castAndDirectors = DelimitedValueCodec.decodeList(castAndDirectors), + moods = DelimitedValueCodec.decodeList(moods), + contentTypes = + DelimitedValueCodec.decodeList(contentTypes).mapNotNull { value -> + runCatching { ContentType.valueOf(value) }.getOrNull() + }, + ) + +fun UserPreferences.toEntity(): UserPreferencesEntity = + UserPreferencesEntity( + userId = userId, + weightedGenres = DelimitedValueCodec.encodeWeightedMap(weightedGenres), + plotTypes = DelimitedValueCodec.encodeList(plotTypes), + eras = DelimitedValueCodec.encodeList(eras), + castAndDirectors = DelimitedValueCodec.encodeList(castAndDirectors), + moods = DelimitedValueCodec.encodeList(moods), + contentTypes = DelimitedValueCodec.encodeList(contentTypes.map { it.name }), + ) -- 2.54.0 From 432659500b00ebdd2ef180e7ec814013144a5c48 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:57:46 +0300 Subject: [PATCH 04/14] feat(domain): extended and actualized domain models --- .../project/movienight/domain/model/Film.kt | 17 +++++++++++++++++ .../movienight/domain/model/FilmLibrary.kt | 2 ++ .../movienight/domain/model/FilmRating.kt | 14 ++++++++++++++ .../domain/model/JellyfinSyncState.kt | 19 +++++++++++++++++++ .../domain/model/RecommendationContext.kt | 16 ++++++++++++++++ .../project/movienight/domain/model/User.kt | 2 ++ .../domain/model/UserPreferences.kt | 13 +++++++++++++ 7 files changed, 83 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt diff --git a/src/main/kotlin/com/project/movienight/domain/model/Film.kt b/src/main/kotlin/com/project/movienight/domain/model/Film.kt index 32122de..76f2657 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/Film.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/Film.kt @@ -6,4 +6,21 @@ data class Film( val id: UUID, val title: String, val description: String, + val contentType: ContentType = ContentType.FILM, + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) + +enum class ContentType { + FILM, + SERIES, + EPISODE, + OTHER, +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt index 868f57a..8d7861c 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt @@ -1,5 +1,6 @@ package com.project.movienight.domain.model +import java.time.LocalDateTime import java.util.UUID data class FilmLibrary( @@ -8,4 +9,5 @@ data class FilmLibrary( val filmId: UUID, val comment: String?, val isViewed: Boolean, + val watchedAt: LocalDateTime? = null, ) diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt new file mode 100644 index 0000000..380060d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt @@ -0,0 +1,14 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class FilmRating( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String? = null, + val createdAt: LocalDateTime = LocalDateTime.now(), + val updatedAt: LocalDateTime = createdAt, +) diff --git a/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt b/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt new file mode 100644 index 0000000..d2395fa --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt @@ -0,0 +1,19 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class JellyfinSyncState( + val userId: UUID, + val lastSyncedAt: LocalDateTime? = null, + val lastSuccessfulSyncAt: LocalDateTime? = null, + val lastError: String? = null, + val syncedItemCount: Int = 0, +) + +data class JellyfinSyncSummary( + val syncedUsers: Int, + val skippedUsers: Int, + val syncedItems: Int, + val durationMs: Long, +) diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt new file mode 100644 index 0000000..1049513 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt @@ -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, +) diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index b4f2d9b..236a698 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -7,4 +7,6 @@ data class User( val name: String, val email: String, val library: FilmLibrary?, + val preferences: UserPreferences? = null, + val jellyfinUserId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt b/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt new file mode 100644 index 0000000..451e227 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt @@ -0,0 +1,13 @@ +package com.project.movienight.domain.model + +import java.util.UUID + +data class UserPreferences( + val userId: UUID, + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) -- 2.54.0 From 967c1b818cd671b5fbd684084cfca8cd7584edb1 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:58:26 +0300 Subject: [PATCH 05/14] chore(style): reformatted some files --- .../entity/JellyfinSyncStateEntity.kt | 2 +- .../jdbc/JellyfinEventRepository.kt | 22 ++++++++++--------- .../jdbc/JellyfinSyncStateRepository.kt | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt index 5720ba9..5edd5c9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt @@ -28,4 +28,4 @@ fun JellyfinSyncState.toEntity(): JellyfinSyncStateEntity = lastSuccessfulSyncAt = lastSuccessfulSyncAt, lastError = lastError, syncedItemCount = syncedItemCount, - ) \ No newline at end of file + ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt index 6092c47..37f58ca 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -23,20 +23,22 @@ class JellyfinEventRepository( jellyfinItemId: String?, payload: String?, ) { - val sql = """ + 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() + """.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) + val params = + MapSqlParameterSource() + .addValue("eventId", eventId) + .addValue("serverId", serverId) + .addValue("eventType", eventType) + .addValue("occurredAt", occurredAt) + .addValue("jellyfinUserId", jellyfinUserId) + .addValue("jellyfinItemId", jellyfinItemId) + .addValue("payload", payload) jdbc.update(sql, params) } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt index 151b525..2f12ad5 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt @@ -72,4 +72,4 @@ class JellyfinSyncStateRepository( "SELECT user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count FROM jellyfin_sync_state", rowMapper, ).map { it.toDomain() } -} \ No newline at end of file +} -- 2.54.0 From b94599a6520b5c9b46f5c7c4f1cdcccd669ef3d8 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 03:24:37 +0300 Subject: [PATCH 06/14] style(): yet another formatting improvements --- config/detekt/detekt.yaml | 9 ++ .../persistence/jdbc/FilmLibraryRepository.kt | 5 +- .../persistence/jdbc/FilmRatingRepository.kt | 42 ++++++- .../persistence/jdbc/FilmRepository.kt | 108 ++++++++++++++++-- .../jdbc/JellyfinSyncStateRepository.kt | 33 +++++- .../jdbc/UserPreferencesRepository.kt | 29 ++++- 6 files changed, 206 insertions(+), 20 deletions(-) diff --git a/config/detekt/detekt.yaml b/config/detekt/detekt.yaml index 9b7c718..1cb8259 100644 --- a/config/detekt/detekt.yaml +++ b/config/detekt/detekt.yaml @@ -7,3 +7,12 @@ comments: active: false UndocumentedPublicProperty: active: false + +style: + MagicNumber: + active: false + ReturnCount: + max: 3 + +complexity: + active: false diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt index fa7f153..9fa474d 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt @@ -70,7 +70,10 @@ class FilmLibraryRepository( ): FilmLibrary? { val entries = jdbc.query( - "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ? AND film_id = ?", + """ + SELECT id, user_id, film_id, comment, is_viewed, watched_at + FROM favorites WHERE user_id = ? AND film_id = ? + """.trimIndent(), filmLibraryRowMapper, userId, filmId, diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt index fac103b..85334d0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt @@ -33,8 +33,11 @@ class FilmRatingRepository( jdbc.update( """ UPDATE film_ratings - SET score = ?, note = ?, updated_at = ? - WHERE user_id = ? AND film_id = ? + SET score = ?, + note = ?, + updated_at = ? + WHERE user_id = ? + AND film_id = ? """.trimIndent(), entity.score, entity.note, @@ -46,7 +49,15 @@ class FilmRatingRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO film_ratings (id, user_id, film_id, score, note, created_at, updated_at) + INSERT INTO film_ratings ( + id, + user_id, + film_id, + score, + note, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) """.trimIndent(), entity.id, @@ -65,7 +76,17 @@ class FilmRatingRepository( override fun findByUserId(userId: UUID): List = jdbc .query( - "SELECT id, user_id, film_id, score, note, created_at, updated_at FROM film_ratings WHERE user_id = ?", + """ + SELECT id, + user_id, + film_id, + score, + note, + created_at, + updated_at + FROM film_ratings + WHERE user_id = ? + """.trimIndent(), rowMapper, userId, ).map { it.toDomain() } @@ -76,7 +97,18 @@ class FilmRatingRepository( ): FilmRating? = jdbc .query( - "SELECT id, user_id, film_id, score, note, created_at, updated_at FROM film_ratings WHERE user_id = ? AND film_id = ?", + """ + 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, diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index 8da5f94..93f580a 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -41,7 +41,18 @@ class FilmRepository( jdbc.update( """ UPDATE films - SET title = ?, description = ?, content_type = ?, release_year = ?, genres = ?, cast_members = ?, directors = ?, imdb_rating = ?, platform_rating = ?, external_url = ?, jellyfin_item_id = ?, jellyfin_library_id = ? + SET title = ?, + description = ?, + content_type = ?, + release_year = ?, + genres = ?, + cast_members = ?, + directors = ?, + imdb_rating = ?, + platform_rating = ?, + external_url = ?, + jellyfin_item_id = ?, + jellyfin_library_id = ? WHERE id = ? """.trimIndent(), film.title, @@ -61,7 +72,21 @@ class FilmRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO films (id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id) + INSERT INTO films ( + id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent(), film.id, @@ -85,7 +110,23 @@ class FilmRepository( override fun findById(id: UUID): 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 id = ?", + """ + SELECT id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + FROM films + WHERE id = ? + """.trimIndent(), filmRowMapper, id, ) @@ -95,7 +136,23 @@ class FilmRepository( 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 = ?", + """ + 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, ) @@ -105,7 +162,23 @@ class FilmRepository( 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 = ?", + """ + 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, ) @@ -114,11 +187,32 @@ class FilmRepository( override fun findAll(): List = 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", + """ + SELECT id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + FROM films + """.trimIndent(), filmRowMapper, ) override fun deleteById(id: UUID) { - jdbc.update("DELETE FROM films WHERE id = ?", id) + jdbc.update( + """ + DELETE FROM films + WHERE id = ? + """.trimIndent(), + id, + ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt index 2f12ad5..29deb18 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt @@ -30,7 +30,11 @@ class JellyfinSyncStateRepository( jdbc.update( """ UPDATE jellyfin_sync_state - SET last_synced_at = ?, last_successful_sync_at = ?, last_error = ?, synced_item_count = ?, updated_at = CURRENT_TIMESTAMP + SET last_synced_at = ?, + last_successful_sync_at = ?, + last_error = ?, + synced_item_count = ?, + updated_at = CURRENT_TIMESTAMP WHERE user_id = ? """.trimIndent(), entity.lastSyncedAt, @@ -43,7 +47,13 @@ class JellyfinSyncStateRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO jellyfin_sync_state (user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count) + INSERT INTO jellyfin_sync_state ( + user_id, + last_synced_at, + last_successful_sync_at, + last_error, + synced_item_count + ) VALUES (?, ?, ?, ?, ?) """.trimIndent(), entity.userId, @@ -60,7 +70,15 @@ class JellyfinSyncStateRepository( 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 = ?", + """ + 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() @@ -69,7 +87,14 @@ class JellyfinSyncStateRepository( override fun findAll(): List = jdbc .query( - "SELECT user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count FROM jellyfin_sync_state", + """ + SELECT user_id, + last_synced_at, + last_successful_sync_at, + last_error, + synced_item_count + FROM jellyfin_sync_state + """.trimIndent(), rowMapper, ).map { it.toDomain() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt index 58e056e..33ec0cc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt @@ -32,7 +32,12 @@ class UserPreferencesRepository( jdbc.update( """ UPDATE user_preferences - SET weighted_genres = ?, plot_types = ?, eras = ?, cast_and_directors = ?, moods = ?, content_types = ? + SET weighted_genres = ?, + plot_types = ?, + eras = ?, + cast_and_directors = ?, + moods = ?, + content_types = ? WHERE user_id = ? """.trimIndent(), entity.weightedGenres, @@ -47,7 +52,15 @@ class UserPreferencesRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO user_preferences (user_id, weighted_genres, plot_types, eras, cast_and_directors, moods, content_types) + INSERT INTO user_preferences ( + user_id, + weighted_genres, + plot_types, + eras, + cast_and_directors, + moods, + content_types + ) VALUES (?, ?, ?, ?, ?, ?, ?) """.trimIndent(), entity.userId, @@ -66,7 +79,17 @@ class UserPreferencesRepository( 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 = ?", + """ + SELECT user_id, + weighted_genres, + plot_types, + eras, + cast_and_directors, + moods, + content_types + FROM user_preferences + WHERE user_id = ? + """.trimIndent(), rowMapper, userId, ).firstOrNull() -- 2.54.0 From aa89455e503ce0d3e80b087d59907deb486aba5a Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 03:24:58 +0300 Subject: [PATCH 07/14] fix(): test fix after restructuring --- .../adapters/persistence/entity/UserEntityMappingTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt index cdf0c42..4e65569 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -18,6 +18,7 @@ class UserEntityMappingTest { email = "john@email.com", provider = "GOOGLE", providerId = "google1234", + jellyfinUserId = null, createdAt = LocalDateTime.now(), ) val user = entity.toDomain() @@ -26,6 +27,7 @@ class UserEntityMappingTest { assertEquals(entity.name, user.name) assertEquals(entity.email, user.email) assertNull(user.library) + assertNull(user.jellyfinUserId) } @Test -- 2.54.0 From a48463b723427576a2ba16f5a751eae877fe24db Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 03:34:56 +0300 Subject: [PATCH 08/14] feat(ports): extended and actualized repository interfaces --- .../ports/output/FilmLibraryRepositoryPort.kt | 5 +++++ .../ports/output/FilmRatingRepositoryPort.kt | 15 +++++++++++++++ .../ports/output/FilmRepositoryPort.kt | 4 ++++ .../output/JellyfinSyncStateRepositoryPort.kt | 12 ++++++++++++ .../ports/output/UserPreferencesRepositoryPort.kt | 10 ++++++++++ 5 files changed, 46 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt index a3eb9c9..933f45c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt @@ -8,6 +8,11 @@ interface FilmLibraryRepositoryPort { fun findById(id: UUID): FilmLibrary? + fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibrary? + fun findAll(): List fun deleteById(id: UUID) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt new file mode 100644 index 0000000..908e5ff --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt @@ -0,0 +1,15 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.FilmRating +import java.util.UUID + +interface FilmRatingRepositoryPort { + fun save(rating: FilmRating): FilmRating + + fun findByUserId(userId: UUID): List + + fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmRating? +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt index c0d3938..d18b2e6 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt @@ -8,6 +8,10 @@ interface FilmRepositoryPort { fun findById(id: UUID): Film? + fun findByJellyfinItemId(jellyfinItemId: String): Film? + + fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? + fun findAll(): List fun findByTitle(title: String): Film? diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt new file mode 100644 index 0000000..78d75b5 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt @@ -0,0 +1,12 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.JellyfinSyncState +import java.util.UUID + +interface JellyfinSyncStateRepositoryPort { + fun save(state: JellyfinSyncState): JellyfinSyncState + + fun findByUserId(userId: UUID): JellyfinSyncState? + + fun findAll(): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt new file mode 100644 index 0000000..0d110b5 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt @@ -0,0 +1,10 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +interface UserPreferencesRepositoryPort { + fun save(preferences: UserPreferences): UserPreferences + + fun findByUserId(userId: UUID): UserPreferences? +} -- 2.54.0 From ea7e66c1a00bc4bfa69be7d0a79e986e93d39963 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 03:38:30 +0300 Subject: [PATCH 09/14] (scope): [body] [footer(s)] --- .../movienight/MovieNightApplication.kt | 2 + .../movienight/adapters/web/FilmController.kt | 26 ++++ .../adapters/web/FilmLibraryController.kt | 35 ++++-- .../movienight/adapters/web/UserController.kt | 1 + .../web/dto/request/CreateFilmRequest.kt | 10 ++ .../web/dto/request/EditFilmRequest.kt | 10 ++ .../web/dto/request/EditUserRequest.kt | 1 + .../web/dto/response/FilmLibraryResponse.kt | 2 + .../adapters/web/dto/response/FilmResponse.kt | 21 ++++ .../adapters/web/dto/response/UserResponse.kt | 2 + .../ports/input/FilmLibraryUseCase.kt | 15 +++ .../application/ports/input/FilmUseCase.kt | 21 ++++ .../application/ports/input/UserUseCase.kt | 1 + .../services/FilmLibraryService.kt | 117 +++++++++++++----- .../application/services/FilmService.kt | 61 ++++----- .../application/services/UserService.kt | 9 +- src/main/resources/application.yaml | 8 ++ .../services/FilmLibraryServiceTest.kt | 36 ++++-- 18 files changed, 291 insertions(+), 87 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index 39274c8..6db897a 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -3,8 +3,10 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication +@EnableScheduling @ConfigurationPropertiesScan("com.project.movienight.config") class MovieNightApplication diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index 3699e33..9bec40b 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -25,6 +25,12 @@ import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import java.util.UUID +private fun String.toContentTypeOrFilm(): com.project.movienight.domain.model.ContentType = + runCatching { + com.project.movienight.domain.model.ContentType + .valueOf(this) + }.getOrDefault(com.project.movienight.domain.model.ContentType.FILM) + @RestController @RequestMapping("/api/films") class FilmController( @@ -45,6 +51,16 @@ class FilmController( CreateFilmCommand( title = request.title, description = request.description, + contentType = request.contentType.toContentTypeOrFilm(), + releaseYear = request.releaseYear, + genres = request.genres, + cast = request.cast, + directors = request.directors, + imdbRating = request.imdbRating, + platformRating = request.platformRating, + externalUrl = request.externalUrl, + jellyfinItemId = request.jellyfinItemId, + jellyfinLibraryId = request.jellyfinLibraryId, ), ), ) @@ -61,6 +77,16 @@ class FilmController( EditFilmCommand( title = request.title, description = request.description, + contentType = request.contentType.toContentTypeOrFilm(), + releaseYear = request.releaseYear, + genres = request.genres, + cast = request.cast, + directors = request.directors, + imdbRating = request.imdbRating, + platformRating = request.platformRating, + externalUrl = request.externalUrl, + jellyfinItemId = request.jellyfinItemId, + jellyfinLibraryId = request.jellyfinLibraryId, ), ), ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index c2ed037..12c2c5e 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -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.GetFilmLibraryQuery import com.project.movienight.application.ports.input.GetFilmLibraryUseCase +import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand +import com.project.movienight.application.ports.input.MarkFilmViewedUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase import com.project.movienight.domain.exception.EntityNotFoundException @@ -30,10 +33,10 @@ import java.util.UUID class FilmLibraryController( private val createFilmLibraryUseCase: CreateFilmLibraryUseCase, private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, + private val markFilmViewedUseCase: MarkFilmViewedUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, - private val getFilmByIdUseCase: GetFilmByIdUseCase, - private val getAllFilmsUseCase: GetAllFilmsUseCase, + private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -60,18 +63,10 @@ class FilmLibraryController( ), ) - @GetMapping("/films") - fun getAllFilmsInLibrary( + @GetMapping("/entries") + fun list( @PathVariable userId: UUID, - ): List { - val library = - getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId), - ) - - val film = getFilmByIdUseCase.getById(library.filmId) - return listOf(FilmResponse.fromDomain(film)) - } + ): List = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) } @PostMapping("/films/{filmId}") @ResponseStatus(HttpStatus.CREATED) @@ -88,6 +83,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}") @ResponseStatus(HttpStatus.NO_CONTENT) fun removeFilm( diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index bccf5bc..e06954c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -64,6 +64,7 @@ class UserController( command = EditUserCommand( name = request.name, + jellyfinUserId = request.jellyfinUserId, ), ), ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt index 994429d..82f7348 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt @@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request data class CreateFilmRequest( val title: String, val description: String, + val contentType: String = "FILM", + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt index 9e476c3..60eddce 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt @@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request data class EditFilmRequest( val title: String, val description: String, + val contentType: String = "FILM", + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt index 83ddd24..358e0e4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt @@ -2,4 +2,5 @@ package com.project.movienight.adapters.web.dto.request data class EditUserRequest( val name: String, + val jellyfinUserId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt index 8ba6c01..90a339d 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt @@ -9,6 +9,7 @@ data class FilmLibraryResponse( val filmId: UUID, val comment: String?, val isViewed: Boolean, + val watchedAt: java.time.LocalDateTime?, ) { companion object { fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse = @@ -18,6 +19,7 @@ data class FilmLibraryResponse( filmId = filmLibrary.filmId, comment = filmLibrary.comment, isViewed = filmLibrary.isViewed, + watchedAt = filmLibrary.watchedAt, ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt index 239196d..4948540 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.web.dto.response +import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import java.util.UUID @@ -7,6 +8,16 @@ data class FilmResponse( val id: UUID, val title: String, val description: String, + val contentType: ContentType, + val releaseYear: Int?, + val genres: List, + val cast: List, + val directors: List, + val imdbRating: Double?, + val platformRating: Double?, + val externalUrl: String?, + val jellyfinItemId: String?, + val jellyfinLibraryId: String?, ) { companion object { fun fromDomain(film: Film): FilmResponse = @@ -14,6 +25,16 @@ data class FilmResponse( id = film.id, title = film.title, description = film.description, + contentType = film.contentType, + releaseYear = film.releaseYear, + genres = film.genres, + cast = film.cast, + directors = film.directors, + imdbRating = film.imdbRating, + platformRating = film.platformRating, + externalUrl = film.externalUrl, + jellyfinItemId = film.jellyfinItemId, + jellyfinLibraryId = film.jellyfinLibraryId, ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt index 48f5dd8..b1b94f4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt @@ -7,6 +7,7 @@ data class UserResponse( val id: UUID, val name: String, val email: String, + val jellyfinUserId: String?, ) { companion object { fun fromDomain(user: User): UserResponse = @@ -14,6 +15,7 @@ data class UserResponse( id = user.id, name = user.name, email = user.email, + jellyfinUserId = user.jellyfinUserId, ) } } diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt index cf9a0b8..3100547 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt @@ -1,6 +1,7 @@ package com.project.movienight.application.ports.input import com.project.movienight.domain.model.FilmLibrary +import java.time.LocalDateTime import java.util.UUID interface CreateFilmLibraryUseCase { @@ -21,6 +22,16 @@ data class AddFilmToLibraryCommand( val filmId: UUID, ) +interface MarkFilmViewedUseCase { + fun markViewed(command: MarkFilmViewedCommand): FilmLibrary +} + +data class MarkFilmViewedCommand( + val userId: UUID, + val filmId: UUID, + val watchedAt: LocalDateTime? = null, +) + interface RemoveFilmFromLibraryUseCase { fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary } @@ -38,3 +49,7 @@ interface GetFilmLibraryUseCase { data class GetFilmLibraryQuery( val userId: UUID, ) + +interface ListFilmLibraryEntriesUseCase { + fun list(userId: UUID): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt index db3f4b0..27098c7 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt @@ -1,5 +1,6 @@ package com.project.movienight.application.ports.input +import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import java.util.UUID @@ -10,6 +11,16 @@ interface CreateFilmUseCase { data class CreateFilmCommand( val title: String, val description: String, + val contentType: ContentType = ContentType.FILM, + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) interface EditFilmUseCase { @@ -22,6 +33,16 @@ interface EditFilmUseCase { data class EditFilmCommand( val title: String, val description: String, + val contentType: ContentType = ContentType.FILM, + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) interface DeleteFilmUseCase { diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt index b417525..b066a4f 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt @@ -21,6 +21,7 @@ interface EditUserUseCase { data class EditUserCommand( val name: String, + val jellyfinUserId: String? = null, ) interface DeleteUserUseCase { diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index ba64e2a..13ec743 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,11 +1,15 @@ 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.AddFilmToLibraryUseCase import com.project.movienight.application.ports.input.CreateFilmLibraryCommand import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase import com.project.movienight.application.ports.input.GetFilmLibraryQuery import com.project.movienight.application.ports.input.GetFilmLibraryUseCase +import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand +import com.project.movienight.application.ports.input.MarkFilmViewedUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort @@ -20,70 +24,119 @@ import java.util.UUID class FilmLibraryService( private val filmLibraryRepository: FilmLibraryRepositoryPort, private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsService, ) : CreateFilmLibraryUseCase, AddFilmToLibraryUseCase, + MarkFilmViewedUseCase, RemoveFilmFromLibraryUseCase, - GetFilmLibraryUseCase { + GetFilmLibraryUseCase, + ListFilmLibraryEntriesUseCase { override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - val existingLibrary = findByUserId(command.userId) - if (existingLibrary != null) { - return existingLibrary - } + findByUserId(command.userId)?.let { return it } - return filmLibraryRepository.save( - FilmLibrary( - id = idGenerator.generateId(), - userId = command.userId, - filmId = idGenerator.generateId(), - comment = command.name, - isViewed = false, - ), - ) + val libraryId = idGenerator.generateId() + val saved = + filmLibraryRepository.save( + FilmLibrary( + id = libraryId, + userId = command.userId, + filmId = libraryId, + comment = command.name, + isViewed = false, + watchedAt = null, + ), + ) + businessMetricsService.recordLibraryEvent() + return saved } override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { - val existingLibrary = findByUserId(command.userId) - if (existingLibrary == null) { - return filmLibraryRepository.save( + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + if (existingEntry != null) { + val saved = + filmLibraryRepository.save( + existingEntry.copy( + isViewed = false, + watchedAt = null, + ), + ) + businessMetricsService.recordLibraryEvent() + return saved + } + + val saved = + filmLibraryRepository.save( FilmLibrary( id = idGenerator.generateId(), userId = command.userId, filmId = command.filmId, comment = null, isViewed = false, + watchedAt = null, ), ) - } - - return filmLibraryRepository.save( - existingLibrary.copy( - filmId = command.filmId, - isViewed = false, - ), - ) + businessMetricsService.recordLibraryEvent() + return saved } override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { val existingLibrary = - findByUserId(command.userId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) + if (command.libraryId != null) { + 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) { - throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) - } - - if (existingLibrary.filmId != command.filmId) { + if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { throw DomainException("Film with id ${command.filmId} not found in user's library") } filmLibraryRepository.deleteById(existingLibrary.id) + businessMetricsService.recordLibraryEvent() 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 = findByUserId(query.userId) ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) + override fun list(userId: UUID): List = filmLibraryRepository.findAll().filter { it.userId == userId } + private fun findByUserId(userId: UUID): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId } + + private fun findByUserAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index bbd32b1..564667f 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -56,21 +56,23 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) - val saved = filmRepository.save(film) - - filmCreatedCounter.increment() - - log.info("Film created: id='{}', title='{}'", saved.id, saved.title) - return saved - } finally { - sample.stop(createFilmTimer) - } + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) + return filmRepository.save(film) } override fun edit( @@ -100,20 +102,23 @@ class FilmService( throw EntityNotFoundException(entity = "Film", id = id.toString()) } - val updatedFilm = - film.copy( - title = command.title, - description = command.description, - ) - val saved = filmRepository.save(updatedFilm) + film = + film.copy( + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) - filmEditedCounter.increment() - - log.info("Film edited: id='{}'", saved.id) - return saved - } finally { - sample.stop(editFilmTimer) - } + return filmRepository.save(film) } override fun delete(id: UUID) { diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index da2a84b..684da5f 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -37,6 +37,7 @@ class UserService( name = command.name, email = command.email, library = null, + jellyfinUserId = null, ) return userRepository.save(user) } @@ -50,7 +51,13 @@ class UserService( } var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - user = user.copy(name = command.name) + + user = + user.copy( + name = command.name, + jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, + ) + return userRepository.save(user) } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 284e69a..9f562dc 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -95,6 +95,14 @@ info: description: MovieNight backend service 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: user: blocked-names: diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt index 1556f83..b029c84 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -1,5 +1,6 @@ package com.project.movienight.application.services +import com.project.movienight.adapters.metrics.BusinessMetricsService import com.project.movienight.application.ports.input.AddFilmToLibraryCommand import com.project.movienight.application.ports.input.CreateFilmLibraryCommand import com.project.movienight.application.ports.input.GetFilmLibraryQuery @@ -23,32 +24,33 @@ import java.util.UUID class FilmLibraryServiceTest { private lateinit var filmLibraryRepository: FilmLibraryRepositoryPort private lateinit var idGenerator: IdGenerator + private lateinit var businessMetricsService: BusinessMetricsService private lateinit var filmLibraryService: FilmLibraryService @BeforeEach fun setup() { filmLibraryRepository = mockk() idGenerator = mockk() - filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator) + businessMetricsService = mockk(relaxed = true) + filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator, businessMetricsService) } @Test fun `should create new film library when user has no library`() { val userId = UUID.randomUUID() val libraryId = UUID.randomUUID() - val filmId = UUID.randomUUID() val command = CreateFilmLibraryCommand(userId = userId, name = "My Films") val expectedLibrary = FilmLibrary( id = libraryId, userId = userId, - filmId = filmId, + filmId = libraryId, comment = "My Films", isViewed = false, ) every { filmLibraryRepository.findAll() } returns emptyList() - every { idGenerator.generateId() } returnsMany listOf(libraryId, filmId) + every { idGenerator.generateId() } returns libraryId every { filmLibraryRepository.save( match { @@ -62,11 +64,11 @@ class FilmLibraryServiceTest { assertNotNull(result) assertEquals(libraryId, result.id) assertEquals(userId, result.userId) - assertEquals(filmId, result.filmId) + assertEquals(libraryId, result.filmId) assertEquals("My Films", result.comment) verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 2) { idGenerator.generateId() } + verify(exactly = 1) { idGenerator.generateId() } verify(exactly = 1) { filmLibraryRepository.save(any()) } } @@ -131,7 +133,7 @@ class FilmLibraryServiceTest { } @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 oldFilmId = UUID.randomUUID() val newFilmId = UUID.randomUUID() @@ -144,16 +146,24 @@ class FilmLibraryServiceTest { isViewed = true, ) 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 { idGenerator.generateId() } returns createdLibrary.id every { filmLibraryRepository.save( 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) @@ -161,7 +171,7 @@ class FilmLibraryServiceTest { assertEquals(false, result.isViewed) verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 1) { idGenerator.generateId() } verify(exactly = 1) { filmLibraryRepository.save(any()) } } @@ -253,13 +263,13 @@ class FilmLibraryServiceTest { libraryId = wrongLibraryId, ) - every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + every { filmLibraryRepository.findById(wrongLibraryId) } returns null assertThrows { filmLibraryService.removeFilm(command) } - verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { filmLibraryRepository.findById(wrongLibraryId) } verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } } -- 2.54.0 From 5f2a6d12ac982e245aaddd1169ec275653a430f1 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 16:57:19 +0300 Subject: [PATCH 10/14] core: restore compilation and tests (ports, DTOs, repo fixes, metrics) --- .../metrics/BusinessMetricsService.kt | 66 +++++++ .../persistence/jdbc/FilmRepository.kt | 20 ++- .../adapters/web/FilmLibraryController.kt | 1 + .../adapters/web/FilmRatingController.kt | 46 +++++ .../adapters/web/RecommendationController.kt | 34 ++++ .../adapters/web/UserPreferencesController.kt | 53 ++++++ .../web/dto/request/RateFilmRequest.kt | 6 + .../request/UpsertUserPreferencesRequest.kt | 10 ++ .../web/dto/response/FilmRatingResponse.kt | 28 +++ .../dto/response/UserPreferencesResponse.kt | 28 +++ .../ports/input/FilmRatingUseCase.kt | 19 ++ .../ports/input/GetRecommendationsUseCase.kt | 16 ++ .../ports/input/UserPreferencesUseCase.kt | 23 +++ .../application/services/FilmRatingService.kt | 57 ++++++ .../application/services/FilmService.kt | 81 +++++---- .../services/RecommendationService.kt | 117 ++++++++++++ .../services/UserPreferencesService.kt | 29 +++ .../movienight/RecommendationSmokeTest.kt | 168 ++++++++++++++++++ 18 files changed, 766 insertions(+), 36 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt create mode 100644 src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt new file mode 100644 index 0000000..6a8f2d0 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -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() + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index a661396..b1d4ab2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -209,7 +209,25 @@ class FilmRepository( override fun findByTitle(title: String): Film? { val films = 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, title, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 12c2c5e..81115cd 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -36,6 +36,7 @@ class FilmLibraryController( private val markFilmViewedUseCase: MarkFilmViewedUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase, ) { @PostMapping diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt new file mode 100644 index 0000000..fec4889 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt @@ -0,0 +1,46 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.RateFilmRequest +import com.project.movienight.adapters.web.dto.response.FilmRatingResponse +import com.project.movienight.application.ports.input.GetFilmRatingsUseCase +import com.project.movienight.application.ports.input.RateFilmCommand +import com.project.movienight.application.ports.input.RateFilmUseCase +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/ratings") +class FilmRatingController( + private val rateFilmUseCase: RateFilmUseCase, + private val getFilmRatingsUseCase: GetFilmRatingsUseCase, +) { + @PostMapping("/films/{filmId}") + @ResponseStatus(HttpStatus.CREATED) + fun rate( + @PathVariable userId: UUID, + @PathVariable filmId: UUID, + @RequestBody request: RateFilmRequest, + ): FilmRatingResponse = + FilmRatingResponse.fromDomain( + rateFilmUseCase.rate( + RateFilmCommand( + userId = userId, + filmId = filmId, + score = request.score, + note = request.note, + ), + ), + ) + + @GetMapping + fun list( + @PathVariable userId: UUID, + ): List = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt new file mode 100644 index 0000000..8cf7823 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -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 = + getRecommendationsUseCase.recommend( + RecommendationQuery( + userId = userId, + contentType = contentType?.let { runCatching { ContentType.valueOf(it) }.getOrNull() }, + mood = mood, + limit = limit, + ), + ) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt new file mode 100644 index 0000000..276565b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt @@ -0,0 +1,53 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest +import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse +import com.project.movienight.application.ports.input.GetUserPreferencesUseCase +import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand +import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase +import com.project.movienight.domain.model.ContentType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/preferences") +class UserPreferencesController( + private val upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase, + private val getUserPreferencesUseCase: GetUserPreferencesUseCase, +) { + @PutMapping + fun upsert( + @PathVariable userId: UUID, + @RequestBody request: UpsertUserPreferencesRequest, + ): UserPreferencesResponse = + UserPreferencesResponse.fromDomain( + upsertUserPreferencesUseCase.upsert( + UpsertUserPreferencesCommand( + userId = userId, + weightedGenres = request.weightedGenres, + plotTypes = request.plotTypes, + eras = request.eras, + castAndDirectors = request.castAndDirectors, + moods = request.moods, + contentTypes = + request.contentTypes.mapNotNull { + runCatching { + ContentType.valueOf( + it, + ) + }.getOrNull() + }, + ), + ), + ) + + @GetMapping + fun get( + @PathVariable userId: UUID, + ): UserPreferencesResponse? = getUserPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt new file mode 100644 index 0000000..1f44e39 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt @@ -0,0 +1,6 @@ +package com.project.movienight.adapters.web.dto.request + +data class RateFilmRequest( + val score: Int, + val note: String? = null, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt new file mode 100644 index 0000000..38c809e --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt @@ -0,0 +1,10 @@ +package com.project.movienight.adapters.web.dto.request + +data class UpsertUserPreferencesRequest( + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt new file mode 100644 index 0000000..8f276fc --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt @@ -0,0 +1,28 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.FilmRating +import java.time.LocalDateTime +import java.util.UUID + +data class FilmRatingResponse( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String?, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, +) { + companion object { + fun fromDomain(rating: FilmRating): FilmRatingResponse = + FilmRatingResponse( + id = rating.id, + userId = rating.userId, + filmId = rating.filmId, + score = rating.score, + note = rating.note, + createdAt = rating.createdAt, + updatedAt = rating.updatedAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt new file mode 100644 index 0000000..2388d3f --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt @@ -0,0 +1,28 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +data class UserPreferencesResponse( + val userId: UUID, + val weightedGenres: Map, + val plotTypes: List, + val eras: List, + val castAndDirectors: List, + val moods: List, + val contentTypes: List, +) { + companion object { + fun fromDomain(preferences: UserPreferences): UserPreferencesResponse = + UserPreferencesResponse( + userId = preferences.userId, + weightedGenres = preferences.weightedGenres, + plotTypes = preferences.plotTypes, + eras = preferences.eras, + castAndDirectors = preferences.castAndDirectors, + moods = preferences.moods, + contentTypes = preferences.contentTypes, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt new file mode 100644 index 0000000..37c8226 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt @@ -0,0 +1,19 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.FilmRating +import java.util.UUID + +interface RateFilmUseCase { + fun rate(command: RateFilmCommand): FilmRating +} + +data class RateFilmCommand( + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String? = null, +) + +interface GetFilmRatingsUseCase { + fun getRatings(userId: UUID): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt new file mode 100644 index 0000000..de9f91f --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt @@ -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 +} + +data class RecommendationQuery( + val userId: UUID, + val contentType: ContentType? = null, + val mood: String? = null, + val limit: Int = 10, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt new file mode 100644 index 0000000..b44820c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt @@ -0,0 +1,23 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +interface UpsertUserPreferencesUseCase { + fun upsert(command: UpsertUserPreferencesCommand): UserPreferences +} + +data class UpsertUserPreferencesCommand( + val userId: UUID, + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) + +interface GetUserPreferencesUseCase { + fun get(userId: UUID): UserPreferences? +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt new file mode 100644 index 0000000..738bada --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt @@ -0,0 +1,57 @@ +package com.project.movienight.application.services + +import com.project.movienight.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 = filmRatingRepository.findByUserId(userId) +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 564667f..7424e84 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -40,7 +40,7 @@ class FilmService( try { log.debug( - "Create film request received: title='{}', descriptionLength={}", + "Create film request received: title='{}', descriptionLength={}'", command.title, command.description.length, ) @@ -56,23 +56,29 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) - return filmRepository.save(film) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) + + val saved = filmRepository.save(film) + filmCreatedCounter.increment() + return saved + } finally { + sample.stop(createFilmTimer) + } } override fun edit( @@ -95,30 +101,35 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = filmRepository.findById(id) + var film = filmRepository.findById(id) if (film == null) { log.debug("Film not found for edit: id='{}'", id) throw EntityNotFoundException(entity = "Film", id = id.toString()) } - film = - film.copy( - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) + film = + film.copy( + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) - return filmRepository.save(film) + val saved = filmRepository.save(film) + filmEditedCounter.increment() + return saved + } finally { + sample.stop(editFilmTimer) + } } override fun delete(id: UUID) { diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt new file mode 100644 index 0000000..cc6bc39 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -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 { + 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() + + 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) + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt new file mode 100644 index 0000000..de388ce --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt @@ -0,0 +1,29 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.GetUserPreferencesUseCase +import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand +import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.domain.model.UserPreferences +import org.springframework.stereotype.Service + +@Service +class UserPreferencesService( + private val userPreferencesRepository: UserPreferencesRepositoryPort, +) : UpsertUserPreferencesUseCase, + GetUserPreferencesUseCase { + override fun upsert(command: UpsertUserPreferencesCommand): UserPreferences = + userPreferencesRepository.save( + UserPreferences( + userId = command.userId, + weightedGenres = command.weightedGenres, + plotTypes = command.plotTypes, + eras = command.eras, + castAndDirectors = command.castAndDirectors, + moods = command.moods, + contentTypes = command.contentTypes, + ), + ) + + override fun get(userId: java.util.UUID): UserPreferences? = userPreferencesRepository.findByUserId(userId) +} diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt new file mode 100644 index 0000000..a7ab288 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -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.54.0 From d0860cf137a088fcb0140b53b74fa88bfd1858f3 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 16:57:53 +0300 Subject: [PATCH 11/14] integrations(jellyfin): add event ingestion and sync scaffolding + migration --- .../adapters/jellyfin/JellyfinApiClient.kt | 143 ++++++++++++++++ .../adapters/web/JellyfinEventsController.kt | 52 ++++++ .../adapters/web/JellyfinSyncController.kt | 21 +++ .../web/dto/request/JellyfinEventRequest.kt | 21 +++ .../services/JellyfinEventService.kt | 70 ++++++++ .../services/JellyfinSyncService.kt | 153 ++++++++++++++++++ .../config/JellyfinIntegrationProperties.kt | 13 ++ ...fin_events.sql => V3__jellyfin_events.sql} | 0 8 files changed, 473 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt create mode 100644 src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt rename src/main/resources/db/migration/{V2__jellyfin_events.sql => V3__jellyfin_events.sql} (100%) diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt new file mode 100644 index 0000000..a21d103 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -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, + val cast: List, + val directors: List, + 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 = + 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 = + @Suppress("MaxLineLength") + request( + "Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData", + ).asItems().mapNotNull { node -> + val itemId = node.fieldText("Id") ?: return@mapNotNull null + val providerIds = node["ProviderIds"] + val imdbId = providerIds?.fieldText("Imdb") + val people = node["People"] + val cast = people?.peopleByType("Actor", "GuestStar") ?: emptyList() + val directors = people?.peopleByType("Director") ?: emptyList() + JellyfinLibraryItemSnapshot( + jellyfinItemId = itemId, + title = node.fieldText("Name") ?: itemId, + description = node.fieldText("Overview") ?: "", + contentType = mapContentType(node.fieldText("Type")), + releaseYear = node["ProductionYear"]?.takeUnless { it.isNull }?.asInt(), + genres = node["Genres"]?.textList() ?: emptyList(), + cast = cast, + directors = directors, + platformRating = node["CommunityRating"]?.takeUnless { it.isNull }?.asDouble(), + imdbRating = null, + externalUrl = imdbId?.let { "https://www.imdb.com/title/$it/" }, + jellyfinLibraryId = node.fieldText("ParentId"), + isPlayed = + node["UserData"]?.booleanField("Played") ?: node["UserData"]?.booleanField("IsPlayed") ?: false, + ) + } + + private fun request(path: String): JsonNode { + val uri = URI.create("${properties.baseUrl.trimEnd('/')}/$path") + val request = + HttpRequest + .newBuilder(uri) + .timeout(Duration.ofMillis(properties.requestTimeoutMs)) + .header("Accept", "application/json") + .header("X-Emby-Token", properties.apiKey) + .GET() + .build() + + val response = + try { + httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + } catch ( + @Suppress("TooGenericExceptionCaught") exception: Exception, + ) { + throw IllegalStateException("Failed to call Jellyfin at $uri", exception) + } + + check(response.statusCode() !in 200..299) { + "Jellyfin request failed with status ${response.statusCode()} for $uri" + } + + return objectMapper.readTree(response.body()) + } + + private fun JsonNode.asItems(): List = + when { + isArray -> map { it } + has("Items") && this["Items"].isArray -> this["Items"].map { it } + else -> emptyList() + } + + private fun JsonNode.fieldText(name: String): String? = + get(name)?.takeUnless { it.isNull }?.asText()?.takeIf { it.isNotBlank() } + + private fun JsonNode.booleanField(name: String): Boolean? = get(name)?.takeUnless { it.isNull }?.asBoolean() + + private fun JsonNode.textList(): List = + takeIf { it.isArray }?.mapNotNull { item -> + item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() } + } + ?: emptyList() + + private fun JsonNode.peopleByType(vararg types: String): List { + if (!isArray) return emptyList() + return mapNotNull { person -> + val type = person.fieldText("Type") ?: return@mapNotNull null + if (types.any { it.equals(type, ignoreCase = true) }) person.fieldText("Name") else null + } + } + + private fun mapContentType(value: String?): ContentType = + when (value?.lowercase()) { + "movie" -> ContentType.FILM + "series" -> ContentType.SERIES + "episode" -> ContentType.EPISODE + else -> ContentType.OTHER + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt new file mode 100644 index 0000000..3708530 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -0,0 +1,52 @@ +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.pluginToken.isNotBlank()) { + if (token == null || token != properties.pluginToken) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token") + } + } + + log.debug( + "Received Jellyfin event {} for user {} item {}", + request.eventId, + request.jellyfinUserId, + request.itemId, + ) + jellyfinEventService.handleEvent( + eventId = request.eventId, + serverId = null, + eventType = request.eventType, + occurredAt = request.occurredAt, + jellyfinUserId = request.jellyfinUserId, + itemId = request.itemId, + payload = request.payload, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt new file mode 100644 index 0000000..74aac90 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -0,0 +1,21 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.application.services.JellyfinSyncService +import com.project.movienight.domain.model.JellyfinSyncState +import com.project.movienight.domain.model.JellyfinSyncSummary +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/integrations/jellyfin") +class JellyfinSyncController( + private val jellyfinSyncService: JellyfinSyncService, +) { + @PostMapping("/sync") + fun syncNow(): JellyfinSyncSummary = jellyfinSyncService.syncNow() + + @GetMapping("/sync-state") + fun syncState(): List = jellyfinSyncService.getSyncStates() +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt new file mode 100644 index 0000000..68abfe8 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt @@ -0,0 +1,21 @@ +package com.project.movienight.adapters.web.dto.request + +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.OffsetDateTime + +data class JellyfinEventRequest( + @JsonProperty("event_id") + val eventId: String, + @JsonProperty("event_type") + val eventType: String, + @JsonProperty("occurred_at") + val occurredAt: OffsetDateTime, + @JsonProperty("jellyfin_user_id") + val jellyfinUserId: String, + @JsonProperty("item_id") + val itemId: String, + @JsonProperty("payload_version") + val payloadVersion: Int = 1, + @JsonProperty("payload") + val payload: Map? = null, +) diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt new file mode 100644 index 0000000..8ff3f6b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -0,0 +1,70 @@ +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?, + ) { + if (jellyfinEventRepository.exists(eventId = eventId)) { + return + } + + val payloadJson = payload?.let { objectMapper.writeValueAsString(it) } + jellyfinEventRepository.save(eventId, serverId, eventType, occurredAt, jellyfinUserId, itemId, payloadJson) + + try { + if (playbackEventTypes.contains(eventType)) { + val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId } + if (localUser == null) { + businessMetricsService.recordJellyfinUnmappedUser() + return + } + + val film = filmRepository.findByJellyfinItemId(itemId) + if (film == null) { + businessMetricsService.recordBackendWriteFailure() + return + } + + markFilmViewedUseCase.markViewed( + MarkFilmViewedCommand( + userId = localUser.id, + filmId = film.id, + watchedAt = occurredAt.toLocalDateTime(), + ), + ) + businessMetricsService.recordLibraryEvent() + } + } catch ( + @Suppress("TooGenericExceptionCaught") ex: RuntimeException, + ) { + businessMetricsService.recordBackendWriteFailure() + throw ex + } + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt new file mode 100644 index 0000000..46faa88 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -0,0 +1,153 @@ +package com.project.movienight.application.services + +import com.project.movienight.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 = syncStateRepository.findAll() + + private fun syncItem( + userId: java.util.UUID, + item: JellyfinLibraryItemSnapshot, + ) { + val film = + filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy( + title = item.title, + description = item.description, + contentType = item.contentType, + releaseYear = item.releaseYear, + genres = item.genres, + cast = item.cast, + directors = item.directors, + imdbRating = item.imdbRating, + platformRating = item.platformRating, + externalUrl = item.externalUrl, + jellyfinItemId = item.jellyfinItemId, + jellyfinLibraryId = item.jellyfinLibraryId, + ) ?: Film( + id = idGenerator.generateId(), + title = item.title, + description = item.description, + contentType = item.contentType, + releaseYear = item.releaseYear, + genres = item.genres, + cast = item.cast, + directors = item.directors, + imdbRating = item.imdbRating, + platformRating = item.platformRating, + externalUrl = item.externalUrl, + jellyfinItemId = item.jellyfinItemId, + jellyfinLibraryId = item.jellyfinLibraryId, + ) + + val savedFilm = filmRepository.save(film) + + if (item.isPlayed) { + val watchedAt = LocalDateTime.now() + val existingEntry = filmLibraryRepository.findByUserIdAndFilmId(userId, savedFilm.id) + filmLibraryRepository.save( + existingEntry?.copy( + isViewed = true, + watchedAt = watchedAt, + ) ?: FilmLibrary( + id = idGenerator.generateId(), + userId = userId, + filmId = savedFilm.id, + comment = null, + isViewed = true, + watchedAt = watchedAt, + ), + ) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt new file mode 100644 index 0000000..5a59400 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt @@ -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 = "", +) diff --git a/src/main/resources/db/migration/V2__jellyfin_events.sql b/src/main/resources/db/migration/V3__jellyfin_events.sql similarity index 100% rename from src/main/resources/db/migration/V2__jellyfin_events.sql rename to src/main/resources/db/migration/V3__jellyfin_events.sql -- 2.54.0 From eec2814bc3f7e78c9bea92a2b9c99794dd037fff Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 19:48:44 +0300 Subject: [PATCH 12/14] feat(migrations): added migrations for ratings and added some jellyfin ids --- .../db/migration/V4__add_ratings_table.sql | 17 +++++++++++++++++ .../migration/V5__add_jellyfin_id_columns.sql | 12 ++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/main/resources/db/migration/V4__add_ratings_table.sql create mode 100644 src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql diff --git a/src/main/resources/db/migration/V4__add_ratings_table.sql b/src/main/resources/db/migration/V4__add_ratings_table.sql new file mode 100644 index 0000000..be1f105 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_ratings_table.sql @@ -0,0 +1,17 @@ +-- Create ratings table to store user film ratings +CREATE TABLE ratings ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + film_id BIGINT NOT NULL REFERENCES films(id) ON DELETE CASCADE, + rating NUMERIC(3, 1) NOT NULL CHECK (rating >= 0 AND rating <= 10), + source VARCHAR(50) NOT NULL DEFAULT 'MOVIENIGHT', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_user_film_rating UNIQUE (user_id, film_id) +); + +-- Create index on user_id for efficient lookups by user +CREATE INDEX idx_ratings_user_id ON ratings(user_id); + +-- Create index on film_id for efficient lookups by film +CREATE INDEX idx_ratings_film_id ON ratings(film_id); diff --git a/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql new file mode 100644 index 0000000..c7c990b --- /dev/null +++ b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql @@ -0,0 +1,12 @@ +-- Add jellyfin_id columns for mapping between MovieNight and Jellyfin +ALTER TABLE films + ADD COLUMN jellyfin_id UUID UNIQUE NULL, + ADD COLUMN jellyfin_library_id UUID NULL; + +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 UNIQUE NULL; + +CREATE INDEX idx_users_jellyfin_id ON users(jellyfin_id); -- 2.54.0 From 1f22be340190d9415ae37c682198fd0e0f0963d0 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 20:06:17 +0300 Subject: [PATCH 13/14] migrations: fix jellyfin id DDL for h2 compatibility --- .../db/migration/V5__add_jellyfin_id_columns.sql | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql index c7c990b..25d719c 100644 --- a/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql +++ b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql @@ -1,12 +1,17 @@ -- Add jellyfin_id columns for mapping between MovieNight and Jellyfin ALTER TABLE films - ADD COLUMN jellyfin_id UUID UNIQUE NULL, - ADD COLUMN jellyfin_library_id UUID NULL; + 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 UNIQUE NULL; + 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); -- 2.54.0 From 50923e2e5abb73cf4b906ecd256bf8852ec5432f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 18:33:34 +0000 Subject: [PATCH 14/14] fix: address PR review thread issues Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/d4d6ebbb-2508-484e-accf-c891be54750f Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../adapters/jellyfin/JellyfinApiClient.kt | 2 +- .../jdbc/JellyfinEventRepository.kt | 14 ++-- .../adapters/web/JellyfinEventsController.kt | 4 ++ .../services/FilmLibraryService.kt | 16 +---- .../application/services/FilmService.kt | 2 +- .../services/JellyfinEventService.kt | 19 +++-- src/main/resources/db/migration/V1__init.sql | 49 +------------ .../db/migration/V6__extend_schema.sql | 70 +++++++++++++++++++ .../services/FilmLibraryServiceTest.kt | 33 ++------- 9 files changed, 106 insertions(+), 103 deletions(-) create mode 100644 src/main/resources/db/migration/V6__extend_schema.sql diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt index a21d103..71670be 100644 --- a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -100,7 +100,7 @@ class JellyfinApiClient( throw IllegalStateException("Failed to call Jellyfin at $uri", exception) } - check(response.statusCode() !in 200..299) { + check(response.statusCode() in 200..299) { "Jellyfin request failed with status ${response.statusCode()} for $uri" } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt index 37f58ca..153eba7 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -8,12 +8,6 @@ import org.springframework.stereotype.Repository class JellyfinEventRepository( private val jdbc: NamedParameterJdbcTemplate, ) { - fun exists(eventId: String): Boolean { - val sql = "SELECT 1 FROM jellyfin_events WHERE event_id = :eventId" - val params = MapSqlParameterSource().addValue("eventId", eventId) - return jdbc.query(sql, params) { rs, _ -> rs.getInt(1) }.any() - } - fun save( eventId: String, serverId: String?, @@ -22,7 +16,7 @@ class JellyfinEventRepository( 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) @@ -40,6 +34,12 @@ class JellyfinEventRepository( .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) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt index 3708530..81ee52c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -27,6 +27,10 @@ class JellyfinEventsController( @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") diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 13ec743..2924922 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -33,21 +33,7 @@ class FilmLibraryService( ListFilmLibraryEntriesUseCase { override fun create(command: CreateFilmLibraryCommand): FilmLibrary { findByUserId(command.userId)?.let { return it } - - val libraryId = idGenerator.generateId() - val saved = - filmLibraryRepository.save( - FilmLibrary( - id = libraryId, - userId = command.userId, - filmId = libraryId, - comment = command.name, - isViewed = false, - watchedAt = null, - ), - ) - businessMetricsService.recordLibraryEvent() - return saved + throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) } override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 7424e84..d69a6c8 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -40,7 +40,7 @@ class FilmService( try { log.debug( - "Create film request received: title='{}', descriptionLength={}'", + "Create film request received: title='{}', descriptionLength={}", command.title, command.description.length, ) diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt index 8ff3f6b..64be033 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -30,23 +30,33 @@ class JellyfinEventService( itemId: String, payload: Map?, ) { - if (jellyfinEventRepository.exists(eventId = eventId)) { + 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 } - val payloadJson = payload?.let { objectMapper.writeValueAsString(it) } - jellyfinEventRepository.save(eventId, serverId, eventType, occurredAt, jellyfinUserId, itemId, payloadJson) - 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 } @@ -63,6 +73,7 @@ class JellyfinEventService( } catch ( @Suppress("TooGenericExceptionCaught") ex: RuntimeException, ) { + jellyfinEventRepository.delete(eventId) businessMetricsService.recordBackendWriteFailure() throw ex } diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 1dcc5c9..ee51933 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -5,24 +5,13 @@ CREATE TABLE IF NOT EXISTS public.users ( password VARCHAR(255), provider VARCHAR(64), provider_id VARCHAR(255), - jellyfin_user_id VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS public.films ( id UUID PRIMARY KEY, title VARCHAR(255) NOT NULL, - description TEXT NOT NULL, - content_type VARCHAR(32) NOT NULL DEFAULT 'FILM', - release_year INT, - genres TEXT NOT NULL DEFAULT '', - cast_members TEXT NOT NULL DEFAULT '', - directors TEXT NOT NULL DEFAULT '', - imdb_rating DOUBLE PRECISION, - platform_rating DOUBLE PRECISION, - external_url TEXT, - jellyfin_item_id VARCHAR(255), - jellyfin_library_id VARCHAR(255) + description TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS public.favorites ( @@ -31,42 +20,6 @@ CREATE TABLE IF NOT EXISTS public.favorites ( film_id UUID NOT NULL, comment VARCHAR(1024), is_viewed BOOLEAN NOT NULL DEFAULT FALSE, - watched_at TIMESTAMP, CONSTRAINT favorites_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, CONSTRAINT favorites_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE ); - -CREATE TABLE IF NOT EXISTS public.user_preferences ( - user_id UUID PRIMARY KEY, - weighted_genres TEXT NOT NULL DEFAULT '', - plot_types TEXT NOT NULL DEFAULT '', - eras TEXT NOT NULL DEFAULT '', - cast_and_directors TEXT NOT NULL DEFAULT '', - moods TEXT NOT NULL DEFAULT '', - content_types TEXT NOT NULL DEFAULT '', - CONSTRAINT user_preferences_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS public.film_ratings ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - film_id UUID NOT NULL, - score INT NOT NULL, - note VARCHAR(2048), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT film_ratings_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, - CONSTRAINT film_ratings_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE, - CONSTRAINT film_ratings_score_range CHECK (score >= 1 AND score <= 10), - CONSTRAINT film_ratings_user_film_unique UNIQUE (user_id, film_id) -); - -CREATE TABLE IF NOT EXISTS public.jellyfin_sync_state ( - user_id UUID PRIMARY KEY, - last_synced_at TIMESTAMP, - last_successful_sync_at TIMESTAMP, - last_error TEXT, - synced_item_count INT NOT NULL DEFAULT 0, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT jellyfin_sync_state_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE -); diff --git a/src/main/resources/db/migration/V6__extend_schema.sql b/src/main/resources/db/migration/V6__extend_schema.sql new file mode 100644 index 0000000..3c7189f --- /dev/null +++ b/src/main/resources/db/migration/V6__extend_schema.sql @@ -0,0 +1,70 @@ +ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS jellyfin_user_id VARCHAR(255); + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS content_type VARCHAR(32) NOT NULL DEFAULT 'FILM'; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS release_year INT; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS genres TEXT NOT NULL DEFAULT ''; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS cast_members TEXT NOT NULL DEFAULT ''; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS directors TEXT NOT NULL DEFAULT ''; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS imdb_rating DOUBLE PRECISION; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS platform_rating DOUBLE PRECISION; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS external_url TEXT; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS jellyfin_item_id VARCHAR(255); + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS jellyfin_library_id VARCHAR(255); + +ALTER TABLE public.favorites + ADD COLUMN IF NOT EXISTS watched_at TIMESTAMP; + +CREATE TABLE IF NOT EXISTS public.user_preferences ( + user_id UUID PRIMARY KEY, + weighted_genres TEXT NOT NULL DEFAULT '', + plot_types TEXT NOT NULL DEFAULT '', + eras TEXT NOT NULL DEFAULT '', + cast_and_directors TEXT NOT NULL DEFAULT '', + moods TEXT NOT NULL DEFAULT '', + content_types TEXT NOT NULL DEFAULT '', + CONSTRAINT user_preferences_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS public.film_ratings ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + film_id UUID NOT NULL, + score INT NOT NULL, + note VARCHAR(2048), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT film_ratings_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_score_range CHECK (score >= 1 AND score <= 10), + CONSTRAINT film_ratings_user_film_unique UNIQUE (user_id, film_id) +); + +CREATE TABLE IF NOT EXISTS public.jellyfin_sync_state ( + user_id UUID PRIMARY KEY, + last_synced_at TIMESTAMP, + last_successful_sync_at TIMESTAMP, + last_error TEXT, + synced_item_count INT NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT jellyfin_sync_state_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt index b029c84..7145507 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -36,40 +36,19 @@ class FilmLibraryServiceTest { } @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 libraryId = UUID.randomUUID() val command = CreateFilmLibraryCommand(userId = userId, name = "My Films") - val expectedLibrary = - FilmLibrary( - id = libraryId, - userId = userId, - filmId = libraryId, - comment = "My Films", - isViewed = false, - ) every { filmLibraryRepository.findAll() } returns emptyList() - every { idGenerator.generateId() } returns libraryId - every { - filmLibraryRepository.save( - match { - it.userId == userId && it.comment == "My Films" && it.isViewed == false - }, - ) - } returns expectedLibrary - val result = filmLibraryService.create(command) - - assertNotNull(result) - assertEquals(libraryId, result.id) - assertEquals(userId, result.userId) - assertEquals(libraryId, result.filmId) - assertEquals("My Films", result.comment) + assertThrows { + filmLibraryService.create(command) + } verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 1) { idGenerator.generateId() } - verify(exactly = 1) { filmLibraryRepository.save(any()) } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 0) { filmLibraryRepository.save(any()) } } @Test -- 2.54.0