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] 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