From ef1a11404ed453deabb13f225fbc8aca514ef401 Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 22 May 2026 23:29:50 +0300 Subject: [PATCH 1/4] =?UTF-8?q?=D0=A3=D1=81=D0=B8=D0=BB=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D1=81=D0=BE=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8E=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Рекомендации теперь сильнее опираются на явно высоко оценённые фильмы, штрафуют похожесть на негативные оценки и ослабляют широкие онбординг-фильтры при слабой релевантности. Добавлен регрессионный тест для сценария, где фильмы, похожие на любимые, должны ранжироваться выше простых совпадений по жанру и эпохе. --- .../services/RecommendationService.kt | 156 +++++++++++++++--- .../movienight/RecommendationSmokeTest.kt | 98 ++++++++++- 2 files changed, 231 insertions(+), 23 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index da8dd01..6f310ee 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -281,23 +281,28 @@ class RecommendationService( libraryEntries: List, filmsById: Map, weights: UserRecommendationWeights, - ): SparseVector { - val profile = MutableSparseVector() + ): UserTasteProfile { + val preferenceProfile = MutableSparseVector() + val positiveChoiceProfile = MutableSparseVector() + val negativeChoiceProfile = MutableSparseVector() + val libraryProfile = MutableSparseVector() preferences?.weightedGenres.orEmpty().forEach { (genre, weight) -> - profile.add(feature("genre", genre), weight.coerceAtLeast(1).toDouble() / MAX_PREFERENCE_WEIGHT) + preferenceProfile.add(feature("genre", genre), weight.coerceAtLeast(1).toDouble() / MAX_PREFERENCE_WEIGHT) } preferences?.plotTypes.orEmpty().forEach { plotType -> - tokenize(plotType).forEach { profile.add(feature("plot", it), PREFERENCE_PLOT_WEIGHT) } + tokenize(plotType).forEach { preferenceProfile.add(feature("plot", it), PREFERENCE_PLOT_WEIGHT) } } - preferences?.eras.orEmpty().forEach { profile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) } - preferences?.castAndDirectors.orEmpty().forEach { profile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) } - preferences?.moods.orEmpty().forEach { profile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) } + preferences?.eras.orEmpty().forEach { preferenceProfile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) } + preferences?.castAndDirectors.orEmpty().forEach { + preferenceProfile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) + } + preferences?.moods.orEmpty().forEach { preferenceProfile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) } preferences ?.contentTypes .orEmpty() .forEach { - profile.add( + preferenceProfile.add( feature("type", it.name), PREFERENCE_CONTENT_TYPE_WEIGHT, ) @@ -306,30 +311,47 @@ class RecommendationService( ratings.forEach { rating -> val film = filmsById[rating.filmId] ?: return@forEach val signal = ratingSignal(rating.score) - profile.add(buildFilmVector(film, weights).scale(signal)) + val filmVector = buildFilmVector(film, weights) + when { + signal >= POSITIVE_CHOICE_SIGNAL_THRESHOLD -> positiveChoiceProfile.add(filmVector.scale(signal)) + signal <= NEGATIVE_CHOICE_SIGNAL_THRESHOLD -> negativeChoiceProfile.add(filmVector.scale(-signal)) + } } libraryEntries.filterNot { it.isViewed }.forEach { entry -> val film = filmsById[entry.filmId] ?: return@forEach - profile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT)) + libraryProfile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT)) } - return profile.toSparseVector() + val overallProfile = MutableSparseVector() + overallProfile.add(preferenceProfile.toSparseVector()) + overallProfile.add(positiveChoiceProfile.toSparseVector().scale(EXPLICIT_CHOICE_PROFILE_WEIGHT)) + overallProfile.add(negativeChoiceProfile.toSparseVector().scale(-EXPLICIT_CHOICE_PROFILE_WEIGHT)) + overallProfile.add(libraryProfile.toSparseVector()) + + return UserTasteProfile( + overall = overallProfile.toSparseVector(), + preferences = preferenceProfile.toSparseVector(), + positiveChoices = positiveChoiceProfile.toSparseVector(), + negativeChoices = negativeChoiceProfile.toSparseVector(), + library = libraryProfile.toSparseVector(), + ) } private fun scoreFilm( film: Film, query: RecommendationQuery, preferences: UserPreferences?, - userProfile: SparseVector, + userProfile: UserTasteProfile, inLibrary: Boolean, weights: UserRecommendationWeights, ): ScoredRecommendation { val reasons = mutableListOf() val filmVector = buildFilmVector(film, weights) - val preferenceScore = cosineSimilarity(userProfile, filmVector) + val relevanceBreakdown = relevanceScore(userProfile, filmVector) + val preferenceScore = relevanceBreakdown.combined val qualityScore = qualityScore(film) - val contextScore = contextScore(film, query, preferences) + val contextScore = contextScore(film, query, preferences, userProfile, preferenceScore) val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE val diversityScore = diversityScore(film, preferences) val score = @@ -339,7 +361,9 @@ class RecommendationService( weights.noveltyWeight * noveltyScore + weights.diversityWeight * diversityScore - if (preferenceScore > STRONG_REASON_THRESHOLD) { + if (relevanceBreakdown.positiveSimilarity > EXPLICIT_CHOICE_REASON_THRESHOLD) { + reasons += "Similar to films you rated highly" + } else if (preferenceScore > STRONG_REASON_THRESHOLD) { reasons += "Similar to user preferences and rating history" } matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre -> @@ -397,10 +421,58 @@ class RecommendationService( return vector.toSparseVector() } + private fun relevanceScore( + userProfile: UserTasteProfile, + filmVector: SparseVector, + ): RelevanceBreakdown { + val overallSimilarity = cosineSimilarity(userProfile.overall, filmVector) + val preferenceSimilarity = cosineSimilarity(userProfile.preferences, filmVector) + val positiveSimilarity = cosineSimilarity(userProfile.positiveChoices, filmVector).coerceAtLeast(0.0) + val negativeSimilarity = cosineSimilarity(userProfile.negativeChoices, filmVector).coerceAtLeast(0.0) + val librarySimilarity = cosineSimilarity(userProfile.library, filmVector).coerceAtLeast(0.0) + + if (!userProfile.hasExplicitChoices) { + return RelevanceBreakdown( + combined = overallSimilarity, + positiveSimilarity = positiveSimilarity, + ) + } + + val positiveComponent = + if (userProfile.hasPositiveChoices) { + positiveSimilarity * POSITIVE_CHOICE_RELEVANCE_WEIGHT + } else { + 0.0 + } + val preferenceComponent = preferenceSimilarity.coerceAtLeast(0.0) * BROAD_PREFERENCE_RELEVANCE_WEIGHT + val libraryComponent = + if (userProfile.hasLibraryChoices) { + librarySimilarity * LIBRARY_CHOICE_RELEVANCE_WEIGHT + } else { + 0.0 + } + val fallbackComponent = overallSimilarity.coerceAtLeast(0.0) * OVERALL_RELEVANCE_FALLBACK_WEIGHT + val negativePenalty = + if (userProfile.hasNegativeChoices) { + negativeSimilarity * NEGATIVE_CHOICE_RELEVANCE_PENALTY + } else { + 0.0 + } + + return RelevanceBreakdown( + combined = + (positiveComponent + preferenceComponent + libraryComponent + fallbackComponent - negativePenalty) + .coerceIn(MIN_RELEVANCE_SCORE, MAX_RELEVANCE_SCORE), + positiveSimilarity = positiveSimilarity, + ) + } + private fun contextScore( film: Film, query: RecommendationQuery, preferences: UserPreferences?, + userProfile: UserTasteProfile, + relevanceScore: Double, ): Double { var score = 0.0 var checks = 0 @@ -426,7 +498,16 @@ class RecommendationService( } } - return if (checks == 0) BASE_CONTEXT_SCORE else score / checks + val baseScore = if (checks == 0) BASE_CONTEXT_SCORE else score / checks + if (!userProfile.hasExplicitChoices) { + return baseScore + } + + val relevanceGate = + MIN_CONTEXT_RELEVANCE_GATE + + (MAX_CONTEXT_RELEVANCE_GATE - MIN_CONTEXT_RELEVANCE_GATE) * + relevanceScore.coerceIn(0.0, 1.0) + return baseScore * relevanceGate } private fun qualityScore(film: Film): Double { @@ -452,9 +533,9 @@ class RecommendationService( val filmGenres = film.genres.map(::normalize).toSet() return when { preferredGenres.isEmpty() -> BASE_DIVERSITY_SCORE - filmGenres.none { it in preferredGenres } -> HIGH_DIVERSITY_SCORE - filmGenres.size > 1 -> MEDIUM_DIVERSITY_SCORE - else -> LOW_DIVERSITY_SCORE + filmGenres.none { it in preferredGenres } -> LOW_DIVERSITY_SCORE + filmGenres.size > 1 -> HIGH_DIVERSITY_SCORE + else -> MEDIUM_DIVERSITY_SCORE } } @@ -579,6 +660,24 @@ class RecommendationService( val diversityScore: Double, ) + private data class RelevanceBreakdown( + val combined: Double, + val positiveSimilarity: Double, + ) + + private data class UserTasteProfile( + val overall: SparseVector, + val preferences: SparseVector, + val positiveChoices: SparseVector, + val negativeChoices: SparseVector, + val library: SparseVector, + ) { + val hasPositiveChoices: Boolean = positiveChoices.values.isNotEmpty() + val hasNegativeChoices: Boolean = negativeChoices.values.isNotEmpty() + val hasLibraryChoices: Boolean = library.values.isNotEmpty() + val hasExplicitChoices: Boolean = hasPositiveChoices || hasNegativeChoices || hasLibraryChoices + } + private data class ScoreContributions( val relevance: Double, val quality: Double, @@ -636,6 +735,7 @@ class RecommendationService( private const val PREFERENCE_MOOD_WEIGHT = 0.8 private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5 private const val LIBRARY_SIGNAL_WEIGHT = 0.25 + private const val EXPLICIT_CHOICE_PROFILE_WEIGHT = 1.8 private const val LEARNING_RATE = 0.03 @@ -644,11 +744,23 @@ class RecommendationService( private const val BASE_CONTEXT_SCORE = 0.5 private const val BASE_QUALITY_SCORE = 0.5 private const val BASE_DIVERSITY_SCORE = 0.5 - private const val HIGH_DIVERSITY_SCORE = 1.0 - private const val MEDIUM_DIVERSITY_SCORE = 0.6 - private const val LOW_DIVERSITY_SCORE = 0.3 + private const val HIGH_DIVERSITY_SCORE = 0.75 + private const val MEDIUM_DIVERSITY_SCORE = 0.45 + private const val LOW_DIVERSITY_SCORE = 0.15 private const val STRONG_REASON_THRESHOLD = 0.15 + private const val EXPLICIT_CHOICE_REASON_THRESHOLD = 0.12 private const val QUALITY_REASON_THRESHOLD = 0.75 + private const val POSITIVE_CHOICE_SIGNAL_THRESHOLD = 0.4 + private const val NEGATIVE_CHOICE_SIGNAL_THRESHOLD = -0.3 + private const val POSITIVE_CHOICE_RELEVANCE_WEIGHT = 0.78 + private const val BROAD_PREFERENCE_RELEVANCE_WEIGHT = 0.12 + private const val LIBRARY_CHOICE_RELEVANCE_WEIGHT = 0.08 + private const val OVERALL_RELEVANCE_FALLBACK_WEIGHT = 0.08 + private const val NEGATIVE_CHOICE_RELEVANCE_PENALTY = 0.65 + private const val MIN_RELEVANCE_SCORE = -1.0 + private const val MAX_RELEVANCE_SCORE = 1.0 + private const val MIN_CONTEXT_RELEVANCE_GATE = 0.35 + private const val MAX_CONTEXT_RELEVANCE_GATE = 1.0 private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+") private val stopWords = diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index c8aaae2..b11b744 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -335,6 +335,99 @@ class RecommendationSmokeTest { } } + @Test + fun `should rank films similar to highly rated choices above broad onboarding matches`() { + mockMvc + .post("/api/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(CreateUserRequest(name = "Harry", email = "harry@example.com")) + }.andExpect { + status { isCreated() } + } + + val userId = + UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM users WHERE email = ?", + String::class.java, + "harry@example.com", + ), + ) + + val likedFirstFilmId = + createFilm( + title = "Wizard School Stone", + description = "A young wizard discovers a magic school, spells, friendship, and a hidden dark force.", + releaseYear = 2001, + genres = listOf("Fantasy", "Adventure", "Family"), + imdbRating = 8.0, + ) + val likedSecondFilmId = + createFilm( + title = "Chamber of Magic", + description = "Young friends return to a wizard school and uncover a secret chamber full of magical danger.", + releaseYear = 2002, + genres = listOf("Fantasy", "Adventure", "Family"), + imdbRating = 8.1, + ) + val magicCandidateId = + createFilm( + title = "Academy of Spells", + description = "A group of friends learns spells at a magic academy while facing a dark wizard.", + releaseYear = 2005, + genres = listOf("Fantasy", "Adventure", "Family"), + imdbRating = 7.0, + ) + createFilm( + title = "Highway Strike", + description = "An elite agent chases criminals through explosions, heists, and street fights.", + releaseYear = 2005, + genres = listOf("Action"), + imdbRating = 9.4, + ) + + mockMvc + .put("/api/users/$userId/preferences") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + UpsertUserPreferencesRequest( + weightedGenres = mapOf("Action" to 5), + eras = listOf("2000s"), + contentTypes = listOf("FILM"), + ), + ) + }.andExpect { + status { isOk() } + } + + listOf(likedFirstFilmId, likedSecondFilmId).forEach { filmId -> + mockMvc + .post("/api/users/$userId/ratings/films/$filmId") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(RateFilmRequest(score = 10, note = "Favorite")) + }.andExpect { + status { isCreated() } + } + + mockMvc + .post("/api/users/$userId/library/films/$filmId/viewed") + .andExpect { + status { isOk() } + } + } + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("limit", "2") + }.andExpect { + status { isOk() } + jsonPath("$[0].filmId") { value(magicCandidateId.toString()) } + jsonPath("$[0].reasons[0]") { value("Similar to films you rated highly") } + } + } + private fun cleanDatabase() { jdbcTemplate.execute("DELETE FROM recommendation_events") jdbcTemplate.execute("DELETE FROM user_recommendation_weights") @@ -370,6 +463,8 @@ class RecommendationSmokeTest { private fun createFilm( title: String, + description: String = "$title description", + releaseYear: Int? = null, genres: List, imdbRating: Double, ): UUID { @@ -380,8 +475,9 @@ class RecommendationSmokeTest { objectMapper.writeValueAsString( CreateFilmRequest( title = title, - description = "$title description", + description = description, contentType = "FILM", + releaseYear = releaseYear, genres = genres, imdbRating = imdbRating, ), -- 2.54.0 From eb3f0cd2063cdb50de11afe53dba40797e44a93e Mon Sep 17 00:00:00 2001 From: skettiks Date: Sat, 23 May 2026 00:14:18 +0300 Subject: [PATCH 2/4] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=80=D0=B0=D0=BD=D0=B6=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены семантические теги вкуса для фильмов, штраф за слабое совпадение с явно понравившимися фильмами и проверка кейса, где реальные оценки должны быть важнее широких onboarding-предпочтений. --- .../services/RecommendationService.kt | 82 ++++++++++++++++++- .../movienight/RecommendationSmokeTest.kt | 5 +- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index 6f310ee..f0eeb12 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -354,18 +354,22 @@ class RecommendationService( val contextScore = contextScore(film, query, preferences, userProfile, preferenceScore) val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE val diversityScore = diversityScore(film, preferences) - val score = + val rawScore = weights.relevanceWeight * preferenceScore + weights.qualityWeight * qualityScore + weights.contextWeight * contextScore + weights.noveltyWeight * noveltyScore + weights.diversityWeight * diversityScore + val score = rawScore - explicitChoiceMisfitPenalty(userProfile, relevanceBreakdown) if (relevanceBreakdown.positiveSimilarity > EXPLICIT_CHOICE_REASON_THRESHOLD) { reasons += "Similar to films you rated highly" } else if (preferenceScore > STRONG_REASON_THRESHOLD) { reasons += "Similar to user preferences and rating history" } + matchingPositiveTasteTags(film, userProfile).take(MAX_REASON_ITEMS).forEach { tag -> + reasons += "Shares taste signal: ${tag.toReasonLabel()}" + } matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre -> reasons += "Matches preferred genre: $genre" } @@ -409,11 +413,13 @@ class RecommendationService( val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() } val plotTokens = tokenize("${film.title} ${film.description}") val moods = inferredMoods(film) + val semanticTags = semanticTags(film) val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() } vector.add(feature("type", film.contentType.name), weights.contentTypeVectorWeight) distribute(vector, "genre", normalizedGenres, weights.genreVectorWeight) distribute(vector, "plot", plotTokens, weights.plotVectorWeight) + distribute(vector, "tag", semanticTags, weights.plotVectorWeight * SEMANTIC_TAG_VECTOR_WEIGHT_MULTIPLIER) distribute(vector, "mood", moods, weights.moodVectorWeight) film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) } distribute(vector, "person", people, weights.peopleVectorWeight) @@ -467,6 +473,23 @@ class RecommendationService( ) } + private fun explicitChoiceMisfitPenalty( + userProfile: UserTasteProfile, + relevanceBreakdown: RelevanceBreakdown, + ): Double { + if (!userProfile.hasPositiveChoices) { + return 0.0 + } + val fit = relevanceBreakdown.positiveSimilarity + if (fit >= POSITIVE_CHOICE_SOFT_FIT_THRESHOLD) { + return 0.0 + } + val missingFitRatio = + ((POSITIVE_CHOICE_SOFT_FIT_THRESHOLD - fit) / POSITIVE_CHOICE_SOFT_FIT_THRESHOLD) + .coerceIn(0.0, 1.0) + return EXPLICIT_CHOICE_MISFIT_MAX_PENALTY * missingFitRatio + } + private fun contextScore( film: Film, query: RecommendationQuery, @@ -516,7 +539,7 @@ class RecommendationService( film.imdbRating?.let { normalizeRating(it) }, film.platformRating?.let { normalizeRating(it) }, ) - return normalizedRatings.averageOrNull() ?: BASE_QUALITY_SCORE + return normalizedRatings.averageOrNull() ?: UNKNOWN_QUALITY_SCORE } private fun diversityScore( @@ -546,6 +569,25 @@ class RecommendationService( .keys } + private fun semanticTags(film: Film): Set { + val text = normalize("${film.title} ${film.description} ${film.genres.joinToString(" ")}") + return semanticTagLexicon + .filterValues { keywords -> keywords.any { keyword -> text.contains(keyword) } } + .keys + } + + private fun matchingPositiveTasteTags( + film: Film, + userProfile: UserTasteProfile, + ): List { + if (!userProfile.hasPositiveChoices) { + return emptyList() + } + return semanticTags(film) + .filter { tag -> userProfile.positiveChoices.values.containsKey(feature("tag", tag)) } + .sorted() + } + private fun matchingGenres( film: Film, preferences: UserPreferences?, @@ -622,6 +664,10 @@ class RecommendationService( .trim() .lowercase(Locale.getDefault()) + private fun String.toReasonLabel(): String = + split("-") + .joinToString(" ") { token -> token.replaceFirstChar { char -> char.titlecase(Locale.getDefault()) } } + private fun cosineSimilarity( left: SparseVector, right: SparseVector, @@ -736,13 +782,14 @@ class RecommendationService( private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5 private const val LIBRARY_SIGNAL_WEIGHT = 0.25 private const val EXPLICIT_CHOICE_PROFILE_WEIGHT = 1.8 + private const val SEMANTIC_TAG_VECTOR_WEIGHT_MULTIPLIER = 0.9 private const val LEARNING_RATE = 0.03 private const val LIBRARY_NOVELTY_SCORE = 0.85 private const val CATALOG_NOVELTY_SCORE = 0.65 private const val BASE_CONTEXT_SCORE = 0.5 - private const val BASE_QUALITY_SCORE = 0.5 + private const val UNKNOWN_QUALITY_SCORE = 0.42 private const val BASE_DIVERSITY_SCORE = 0.5 private const val HIGH_DIVERSITY_SCORE = 0.75 private const val MEDIUM_DIVERSITY_SCORE = 0.45 @@ -761,6 +808,8 @@ class RecommendationService( private const val MAX_RELEVANCE_SCORE = 1.0 private const val MIN_CONTEXT_RELEVANCE_GATE = 0.35 private const val MAX_CONTEXT_RELEVANCE_GATE = 1.0 + private const val POSITIVE_CHOICE_SOFT_FIT_THRESHOLD = 0.10 + private const val EXPLICIT_CHOICE_MISFIT_MAX_PENALTY = 0.12 private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+") private val stopWords = @@ -782,5 +831,32 @@ class RecommendationService( "romantic" to listOf("romance", "love", "relationship"), "focused" to listOf("science", "mission", "detective", "investigation", "sci-fi"), ) + private val semanticTagLexicon = + mapOf( + "magic-fantasy" to + listOf( + "magic", + "magical", + "wizard", + "witch", + "spell", + "sorcer", + "fantasy", + "enchanted", + "dragon", + ), + "wizard-school" to listOf("wizard school", "magic school", "academy", "school of magic"), + "young-adult" to listOf("young", "teen", "teenage", "teenager", "student", "coming of age"), + "family-adventure" to listOf("family", "friendship", "friends", "adventure", "quest"), + "quest-adventure" to listOf("quest", "journey", "treasure", "relic", "map", "kingdom"), + "heist-crime" to listOf("heist", "thief", "robbery", "criminal", "crime", "gang"), + "space-opera" to listOf("space", "spaceship", "galaxy", "planet", "alien", "starship"), + "superhero" to listOf("superhero", "hero", "masked", "powers", "mutant"), + "martial-arts" to listOf("martial", "kung fu", "samurai", "ninja", "warrior", "sword"), + "war-epic" to listOf("war", "battle", "army", "soldier", "general", "rebel"), + "mystery-investigation" to listOf("mystery", "detective", "investigation", "secret", "clue"), + "dark-fantasy" to listOf("dark force", "curse", "underworld", "demon", "monster"), + "animated-anime" to listOf("animation", "animated", "anime"), + ) } } diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index b11b744..2b24632 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -372,8 +372,8 @@ class RecommendationSmokeTest { ) val magicCandidateId = createFilm( - title = "Academy of Spells", - description = "A group of friends learns spells at a magic academy while facing a dark wizard.", + title = "Sorcerer Academy", + description = "A teenage student joins an academy with friends and faces an enchanted threat.", releaseYear = 2005, genres = listOf("Fantasy", "Adventure", "Family"), imdbRating = 7.0, @@ -425,6 +425,7 @@ class RecommendationSmokeTest { status { isOk() } jsonPath("$[0].filmId") { value(magicCandidateId.toString()) } jsonPath("$[0].reasons[0]") { value("Similar to films you rated highly") } + jsonPath("$[0].reasons[1]") { value("Shares taste signal: Family Adventure") } } } -- 2.54.0 From 360e2aa819940b8c8916aa7df693a2156f8aa148 Mon Sep 17 00:00:00 2001 From: skettiks Date: Sat, 23 May 2026 00:22:19 +0300 Subject: [PATCH 3/4] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разбиты длинные строки в RecommendationSmokeTest, из-за которых CI падал на ktlint и detekt. --- .../com/project/movienight/RecommendationSmokeTest.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index 2b24632..23e2518 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -340,7 +340,13 @@ class RecommendationSmokeTest { mockMvc .post("/api/users") { contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(CreateUserRequest(name = "Harry", email = "harry@example.com")) + content = + objectMapper.writeValueAsString( + CreateUserRequest( + name = "Harry", + email = "harry@example.com", + ), + ) }.andExpect { status { isCreated() } } @@ -365,7 +371,8 @@ class RecommendationSmokeTest { val likedSecondFilmId = createFilm( title = "Chamber of Magic", - description = "Young friends return to a wizard school and uncover a secret chamber full of magical danger.", + description = + "Young friends return to a wizard school and uncover a secret chamber full of magical danger.", releaseYear = 2002, genres = listOf("Fantasy", "Adventure", "Family"), imdbRating = 8.1, -- 2.54.0 From a008cb115eb89a3f9e1a1a2f369f31d9a15b6fa9 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 23 May 2026 01:02:44 +0300 Subject: [PATCH 4/4] chore(): refactor and improve plugin UI --- .../Configuration/ui.js | 655 +++++++++++++++--- 1 file changed, 568 insertions(+), 87 deletions(-) diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js index 32e0f1f..156eb1c 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js @@ -1,26 +1,249 @@ (function () { + if (typeof window.movieNightUiCleanup === 'function') { + window.movieNightUiCleanup(); + } + const PLUGIN_ID = "42c72919-d6ff-4f62-bb8c-0fac39efafdb"; + const ROUTE_RETRY_DELAYS_MS = [0, 100, 300, 700, 1500, 3000]; function getAlert() { if (typeof Dashboard !== 'undefined' && Dashboard.alert) { - return (options) => Dashboard.alert(options); + return (options) => Dashboard.alert(formatAlertMessage(options)); } return (options) => { - const msg = typeof options === 'string' ? options : (options.text || options.title); - alert(msg); + alert(formatAlertMessage(options)); }; } const showMsg = getAlert(); - function createTextButton(text, className, onClick) { + function formatAlertMessage(options) { + if (typeof options === 'string') return options; + if (!options) return ''; + return [options.title, options.text || options.message].filter(Boolean).join('\n\n'); + } + + function ensureMovieNightStyles() { + if (document.getElementById('movieNightUiStyles')) return; + + const style = document.createElement('style'); + style.id = 'movieNightUiStyles'; + style.textContent = ` + .movieNightActionButton { + align-items: center; + border: var(--defaultLighterBorder, 1px solid rgba(255,255,255,.16)); + border-radius: var(--smallRadius, 8px); + display: inline-flex; + gap: .55em; + min-height: 2.65em; + padding: .55em .9em; + transition: background-color .16s ease, border-color .16s ease, color .16s ease; + } + .movieNightActionButton:hover, + .movieNightActionButton:focus { + border-color: var(--dimTextColor, rgba(255,255,255,.45)); + color: #fff; + } + .movieNightActionButton .material-icons { + font-size: 1.35em; + } + .movieNightDetailButton { + color: var(--textColor, #fff); + } + .movieNightDetailButton .detailButton-content { + border-radius: 999px; + outline: 1px solid rgba(255,255,255,.16); + outline-offset: -1px; + } + .movieNightDetailButton:focus .detailButton-content, + .movieNightDetailButton:hover .detailButton-content { + background: rgba(255,255,255,.18); + } + .movieNightHomeButtons { + margin-bottom: 1.25em; + } + .movieNightPanel { + background: color-mix(in srgb, var(--headerColor, #202020) 78%, transparent); + border: var(--defaultBorder, 1px solid rgba(255,255,255,.12)); + border-radius: var(--smallRadius, 8px); + box-sizing: border-box; + padding: 1em; + } + .movieNightPanelHeader { + align-items: center; + display: flex; + gap: 1em; + justify-content: space-between; + margin-bottom: .75em; + } + .movieNightPanelHeader .sectionTitle { + margin: 0; + } + .movieNightSyncStatus { + color: var(--dimTextColor, rgba(255,255,255,.65)); + font-size: .86em; + text-align: right; + } + .movieNightBtnContainer { + display: flex; + flex-wrap: wrap; + gap: .65em; + } + .movieNightDialog { + background: color-mix(in srgb, var(--drawerColor, #1f1f1f) 92%, transparent) !important; + border: var(--defaultBorder, 1px solid rgba(255,255,255,.15)) !important; + border-radius: var(--smallRadius, 8px) !important; + box-shadow: var(--shadow, 0 18px 55px rgba(0,0,0,.55)) !important; + box-sizing: border-box; + color: var(--textColor, #fff) !important; + max-width: calc(100vw - 2em); + } + .movieNightDialogTitle { + align-items: center; + display: flex; + gap: .55em; + margin: 0; + font-size: 1.35em; + font-weight: 500; + } + .movieNightDialogTitle .material-icons { + color: var(--uiAccentColor, #00a4dc); + font-size: 1.25em; + } + .movieNightDialog .dialog-content { + color: var(--textColor, #fff); + } + .movieNightDialog .dialog-footer { + justify-content: flex-end; + } + .movieNightRecommendationList { + display: grid; + gap: .8em; + } + .movieNightRecommendation { + background: rgba(255,255,255,.055); + border: var(--defaultLighterBorder, 1px solid rgba(255,255,255,.14)); + border-radius: var(--smallRadius, 8px); + display: grid; + gap: .9em; + grid-template-columns: 76px minmax(0, 1fr); + padding: .75em; + } + .movieNightRecommendationPoster { + align-self: start; + aspect-ratio: 2 / 3; + background: rgba(255,255,255,.08); + border-radius: var(--smallerRadius, 6px); + object-fit: cover; + overflow: hidden; + width: 76px; + } + .movieNightRecommendationBody { + min-width: 0; + } + .movieNightRecommendationHeader { + align-items: start; + display: flex; + gap: 1em; + justify-content: space-between; + } + .movieNightRecommendationTitle { + color: #fff; + font-size: 1.08em; + font-weight: 600; + line-height: 1.25; + overflow-wrap: anywhere; + } + .movieNightRecommendationMeta, + .movieNightRecommendationReason { + color: var(--dimTextColor, rgba(255,255,255,.68)); + font-size: .9em; + margin-top: .25em; + } + .movieNightRecommendationScore { + background: rgba(255,255,255,.1); + border-radius: 999px; + color: #fff; + flex: 0 0 auto; + font-size: .82em; + padding: .28em .65em; + white-space: nowrap; + } + .movieNightRecommendationDescription { + color: rgba(255,255,255,.84); + display: -webkit-box; + line-height: 1.35; + margin-top: .65em; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + } + .movieNightRecommendationActions { + display: flex; + flex-wrap: wrap; + gap: .5em; + margin-top: .75em; + } + .movieNightRecommendationActions .emby-button { + min-height: 2.35em; + } + .movieNightField { + margin-bottom: 1em; + } + .movieNightField label { + color: var(--dimTextColor, rgba(255,255,255,.72)); + display: block; + font-size: .9em; + margin-bottom: .35em; + } + .movieNightFieldRow { + display: grid; + gap: 1em; + grid-template-columns: minmax(6em, .7fr) minmax(0, 1.3fr); + } + .movieNightDialog .emby-input { + box-sizing: border-box; + width: 100%; + } + @media (max-width: 42em) { + .movieNightRecommendation { + grid-template-columns: 56px minmax(0, 1fr); + } + .movieNightRecommendationPoster { + width: 56px; + } + .movieNightRecommendationHeader { + display: block; + } + .movieNightRecommendationScore { + display: inline-flex; + margin-top: .45em; + } + .movieNightPanelHeader { + align-items: flex-start; + flex-direction: column; + gap: .35em; + } + .movieNightSyncStatus { + text-align: left; + } + .movieNightFieldRow { + grid-template-columns: 1fr; + gap: 0; + } + } + `; + document.head.appendChild(style); + } + + function createTextButton(text, className, onClick, icon) { const btn = document.createElement('button'); btn.type = 'button'; btn.is = 'emby-button'; - btn.className = `emby-button raised ${className}`; - btn.style.margin = '0.5em'; - btn.style.padding = '0.4em 1em'; - btn.innerHTML = `${text}`; + btn.className = `emby-button raised movieNightActionButton ${className}`; + btn.innerHTML = icon + ? `${text}` + : `${text}`; btn.onclick = onClick; return btn; } @@ -29,7 +252,7 @@ const btn = document.createElement('button'); btn.type = 'button'; btn.is = 'emby-button'; - btn.className = `button-flat detailButton emby-button ${className}`; + btn.className = `button-flat detailButton emby-button movieNightDetailButton ${className}`; btn.title = title; btn.innerHTML = `
@@ -41,62 +264,76 @@ } async function injectUI() { + ensureMovieNightStyles(); await checkOnboarding(); // Item Detail Page - const detailButtons = document.querySelector('.mainDetailButtons'); - if (detailButtons) { - const itemId = getItemIdFromUrl(); - if (itemId) { - // MovieNight Rating - if (!document.querySelector('.btnMovieNightRate')) { - const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => { - e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId); - }); - insertInDetailRow(detailButtons, rateBtn); - } - // Mark Viewed in MovieNight - if (!document.querySelector('.btnMovieNightMarkViewed')) { - const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => { - e.preventDefault(); e.stopPropagation(); submitViewed(itemId); - }); - insertInDetailRow(detailButtons, viewedBtn); - } + const itemId = getItemIdFromUrl(); + document.querySelectorAll('.mainDetailButtons').forEach((detailButtons) => { + if (!itemId) return; + + // MovieNight Rating + const existingRateBtn = detailButtons.querySelector('.btnMovieNightRate'); + if (!existingRateBtn || existingRateBtn.dataset.movieNightItemId !== itemId) { + existingRateBtn?.remove(); + const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => { + e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId); + }); + rateBtn.dataset.movieNightItemId = itemId; + insertInDetailRow(detailButtons, rateBtn); } - } + // Mark Viewed in MovieNight + const existingViewedBtn = detailButtons.querySelector('.btnMovieNightMarkViewed'); + if (!existingViewedBtn || existingViewedBtn.dataset.movieNightItemId !== itemId) { + existingViewedBtn?.remove(); + const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => { + e.preventDefault(); e.stopPropagation(); submitViewed(itemId); + }); + viewedBtn.dataset.movieNightItemId = itemId; + insertInDetailRow(detailButtons, viewedBtn); + } + }); // Library Pages - const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x'); - if (toolBar && !document.querySelector('.btnMovieNightRecommend')) { - toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => { - e.preventDefault(); showRecommendation(); - })); - toolBar.appendChild(createTextButton('Add Movie', 'btnMovieNightAddMovie', (e) => { - e.preventDefault(); showAddMovieDialog(); - })); - } + document + .querySelectorAll('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x') + .forEach((toolBar) => { + if (!toolBar.querySelector('.btnMovieNightRecommend')) { + toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => { + e.preventDefault(); showRecommendation(); + }, 'auto_awesome')); + } + if (!toolBar.querySelector('.btnMovieNightAddMovie')) { + toolBar.appendChild(createTextButton('Add Movie', 'btnMovieNightAddMovie', (e) => { + e.preventDefault(); showAddMovieDialog(); + }, 'add')); + } + }); // Home Page - const homeSections = document.querySelector('.sections.homeSectionsContainer'); - if (homeSections && !document.querySelector('.movieNightHomeButtons')) { + document.querySelectorAll('.sections.homeSectionsContainer').forEach((homeSections) => { + if (homeSections.querySelector('.movieNightHomeButtons')) return; + const section = document.createElement('div'); section.className = 'verticalSection movieNightHomeButtons'; section.style.padding = '0 var(--sidePadding)'; section.innerHTML = ` -
-

MovieNight

- +
+
+

MovieNight

+ +
+
-
`; const btnContainer = section.querySelector('.movieNightBtnContainer'); - btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation)); - btnContainer.appendChild(createTextButton('Add Movie', 'btnMovieNightAddMovie', showAddMovieDialog)); - btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync)); + btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation, 'auto_awesome')); + btnContainer.appendChild(createTextButton('Add Movie', 'btnMovieNightAddMovie', showAddMovieDialog, 'add')); + btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync, 'sync')); homeSections.insertBefore(section, homeSections.firstChild); updateSyncStatus(); - } + }); } function insertInDetailRow(container, btn) { @@ -125,23 +362,21 @@ function createDialogBase(title) { const dialog = document.createElement('div'); - dialog.className = 'dialog'; + dialog.className = 'dialog movieNightDialog'; dialog.style.position = 'fixed'; dialog.style.top = '50%'; dialog.style.left = '50%'; dialog.style.transform = 'translate(-50%, -50%)'; dialog.style.zIndex = '99999'; - dialog.style.padding = '2.5em'; + dialog.style.padding = '1.25em'; dialog.style.minWidth = '350px'; - dialog.style.backgroundColor = '#1a1a1a'; - dialog.style.borderRadius = '1.5em'; - dialog.style.color = 'white'; - dialog.style.boxShadow = '0 20px 50px rgba(0,0,0,0.8)'; - dialog.style.border = '1px solid #444'; dialog.style.opacity = '1'; dialog.innerHTML = ` -

${title}

-
+

+ + ${title} +

+
@@ -187,23 +422,23 @@ const footer = dialog.querySelector('.dialog-footer'); content.innerHTML = ` -
- - +
+ +
-
-
- - +
+
+ +
-
- - +
+ +
-
- - +
+ +
`; @@ -311,10 +546,10 @@ async function checkOnboarding() { if (window.movieNightOnboardingChecked) return; - window.movieNightOnboardingChecked = true; const userId = ApiClient.getCurrentUserId(); if (!userId) return; + window.movieNightOnboardingChecked = true; try { const prefs = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Preferences`)); @@ -345,15 +580,10 @@ const userId = ApiClient.getCurrentUserId(); try { const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Recommendations`)); - const recommendations = typeof response === 'string' ? JSON.parse(response) : response; + const recommendations = normalizeRecommendations(response); if (recommendations && recommendations.length > 0) { - const rec = recommendations[0]; - const film = rec.film || rec; - showMsg({ - title: 'MovieNight Recommendation', - text: `How about watching: ${film.title}?\n\nReason: ${rec.reasons?.join(', ') || 'Based on your preferences'}` - }); + showRecommendationsDialog(recommendations); } else { showMsg('No recommendations found at the moment.'); } @@ -363,6 +593,187 @@ } } + function normalizeRecommendations(response) { + if (typeof response === 'string') { + return JSON.parse(response); + } + + if (Array.isArray(response)) { + return response; + } + + return response?.items || response?.recommendations || []; + } + + function getRecommendationFilm(recommendation) { + return recommendation?.film || recommendation || {}; + } + + function getRecommendationTitle(recommendation) { + const film = getRecommendationFilm(recommendation); + return film.title || recommendation?.title || 'Untitled'; + } + + function getRecommendationItemId(recommendation) { + const film = getRecommendationFilm(recommendation); + return recommendation?.jellyfinItemId || film.jellyfinItemId; + } + + function getRecommendationWatchUrl(recommendation) { + const itemId = getRecommendationItemId(recommendation); + if (recommendation?.watchUrl) return recommendation.watchUrl; + return itemId ? `${window.location.origin}/web/#/details?id=${encodeURIComponent(itemId)}` : null; + } + + function getRecommendationPosterUrl(recommendation) { + const itemId = getRecommendationItemId(recommendation); + if (!itemId) return null; + + if (typeof ApiClient !== 'undefined' && ApiClient.getUrl) { + return ApiClient.getUrl(`Items/${itemId}/Images/Primary`, { + fillHeight: 330, + fillWidth: 220, + quality: 90 + }); + } + + return `/Items/${encodeURIComponent(itemId)}/Images/Primary?fillHeight=330&fillWidth=220&quality=90`; + } + + function formatRecommendationScore(score) { + return typeof score === 'number' ? `${Math.round(score * 100)}% match` : ''; + } + + function showRecommendationsDialog(recommendations) { + const overlay = createOverlay(); + const dialog = createDialogBase('MovieNight Recommendations'); + dialog.style.width = 'min(760px, calc(100vw - 2em))'; + dialog.style.maxHeight = 'min(760px, calc(100vh - 2em))'; + dialog.style.display = 'flex'; + dialog.style.flexDirection = 'column'; + + const content = dialog.querySelector('.dialog-content'); + const footer = dialog.querySelector('.dialog-footer'); + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + content.style.overflowY = 'auto'; + content.style.paddingRight = '.25em'; + content.style.marginBottom = '1em'; + footer.querySelector('.btnCancel').textContent = 'Close'; + + const list = document.createElement('div'); + list.className = 'movieNightRecommendationList'; + content.replaceChildren(list); + + recommendations.forEach((recommendation) => { + list.appendChild(createRecommendationRow(recommendation, cleanup)); + }); + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + } + + function createRecommendationRow(recommendation, cleanup) { + const film = getRecommendationFilm(recommendation); + const itemId = getRecommendationItemId(recommendation); + const watchUrl = getRecommendationWatchUrl(recommendation); + const posterUrl = getRecommendationPosterUrl(recommendation); + + const row = document.createElement('div'); + row.className = 'movieNightRecommendation'; + + const poster = document.createElement('img'); + poster.className = 'movieNightRecommendationPoster'; + poster.alt = ''; + if (posterUrl) { + poster.src = posterUrl; + } + poster.onerror = () => { + poster.removeAttribute('src'); + }; + + const body = document.createElement('div'); + body.className = 'movieNightRecommendationBody'; + + const header = document.createElement('div'); + header.className = 'movieNightRecommendationHeader'; + + const titleBlock = document.createElement('div'); + titleBlock.style.minWidth = '0'; + + const title = document.createElement('div'); + title.className = 'movieNightRecommendationTitle'; + title.textContent = getRecommendationTitle(recommendation); + titleBlock.appendChild(title); + + const meta = [film.releaseYear, ...(film.genres || [])].filter(Boolean).join(' · '); + if (meta) { + const metaEl = document.createElement('div'); + metaEl.className = 'movieNightRecommendationMeta'; + metaEl.textContent = meta; + titleBlock.appendChild(metaEl); + } + + const score = document.createElement('div'); + score.className = 'movieNightRecommendationScore'; + score.textContent = formatRecommendationScore(recommendation?.score); + + header.appendChild(titleBlock); + if (score.textContent) header.appendChild(score); + body.appendChild(header); + + if (film.description) { + const description = document.createElement('div'); + description.className = 'movieNightRecommendationDescription'; + description.textContent = film.description; + body.appendChild(description); + } + + const reasons = recommendation?.reasons || []; + if (reasons.length) { + const reason = document.createElement('div'); + reason.className = 'movieNightRecommendationReason'; + reason.textContent = reasons.join(', '); + body.appendChild(reason); + } + + const actions = document.createElement('div'); + actions.className = 'movieNightRecommendationActions'; + + if (watchUrl) { + actions.appendChild(createRecommendationAction('Open', 'play_arrow', () => { + cleanup(); + window.location.href = watchUrl; + })); + } + + if (itemId) { + actions.appendChild(createRecommendationAction('Rate', 'star_rate', () => { + cleanup(); + showRatingDialog(itemId); + })); + actions.appendChild(createRecommendationAction('Viewed', 'visibility', () => { + cleanup(); + submitViewed(itemId); + })); + } + + body.appendChild(actions); + row.appendChild(poster); + row.appendChild(body); + return row; + } + + function createRecommendationAction(text, icon, onClick) { + return createTextButton(text, 'movieNightRecommendationAction', (e) => { + e.preventDefault(); + e.stopPropagation(); + onClick(); + }, icon); + } + async function addMovie(title, url, year, imdbId) { try { const data = { title, url }; @@ -439,17 +850,87 @@ } } - let timeout; - const throttledInject = () => { - if (timeout) return; - timeout = setTimeout(() => { - injectUI(); - timeout = null; - }, 100); - }; + let injectTimeout; + let injectInFlight = false; + let rerunAfterInject = false; + let routeRetryTimeouts = []; + const routeEventListeners = []; + const patchedHistoryMethods = []; - const observer = new MutationObserver(throttledInject); + async function runInject() { + if (injectInFlight) { + rerunAfterInject = true; + return; + } + + injectInFlight = true; + try { + await injectUI(); + } catch (err) { + console.error('MovieNight UI injection failed', err); + } finally { + injectInFlight = false; + if (rerunAfterInject) { + rerunAfterInject = false; + scheduleInject(); + } + } + } + + function scheduleInject(delay = 100) { + if (injectTimeout) return; + injectTimeout = setTimeout(() => { + injectTimeout = null; + runInject(); + }, delay); + } + + function scheduleRouteInject() { + routeRetryTimeouts.forEach(clearTimeout); + routeRetryTimeouts = ROUTE_RETRY_DELAYS_MS.map((delay) => { + return setTimeout(() => runInject(), delay); + }); + } + + function patchHistoryMethod(name) { + const current = history[name]; + const original = current?._movieNightOriginal || current; + if (typeof original !== 'function') return; + + history[name] = function () { + const result = original.apply(this, arguments); + scheduleRouteInject(); + return result; + }; + history[name]._movieNightOriginal = original; + patchedHistoryMethods.push(name); + } + + function addRouteEventListener(name) { + window.addEventListener(name, scheduleRouteInject); + routeEventListeners.push(name); + } + + patchHistoryMethod('pushState'); + patchHistoryMethod('replaceState'); + addRouteEventListener('hashchange'); + addRouteEventListener('popstate'); + addRouteEventListener('pageshow'); + + const observer = new MutationObserver(() => scheduleInject()); observer.observe(document.body, { childList: true, subtree: true }); - injectUI(); + window.movieNightUiCleanup = () => { + observer.disconnect(); + if (injectTimeout) clearTimeout(injectTimeout); + routeRetryTimeouts.forEach(clearTimeout); + routeEventListeners.forEach((name) => window.removeEventListener(name, scheduleRouteInject)); + patchedHistoryMethods.forEach((name) => { + const original = history[name]?._movieNightOriginal; + if (original) history[name] = original; + }); + window.movieNightUiCleanup = null; + }; + + scheduleRouteInject(); })(); -- 2.54.0