Implement user onboarding and enhanced .strm file creation
- Added automated onboarding dialog for new users to pick genres, eras, and content types. - Enhanced "Add Movie" functionality to support folder-per-movie structure with Year and IMDb ID. - Improved ui.js with custom dialogs for Onboarding, Rating, and Adding Movies. - Fixed API accessibility by using standard [Authorize] attributes. - Added "Mark Viewed" and "Sync" actions to the UI. Co-authored-by: devitq <118541411+devitq@users.noreply.github.com>
This commit is contained in:
co-authored by
devitq
parent
4dc806263a
commit
b59514a6eb
@@ -40,7 +40,10 @@
|
|||||||
return btn;
|
return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectUI() {
|
async function injectUI() {
|
||||||
|
// Check for onboarding
|
||||||
|
await checkOnboarding();
|
||||||
|
|
||||||
// 1. Item Detail Page
|
// 1. Item Detail Page
|
||||||
const detailButtons = document.querySelector('.mainDetailButtons');
|
const detailButtons = document.querySelector('.mainDetailButtons');
|
||||||
if (detailButtons) {
|
if (detailButtons) {
|
||||||
@@ -231,6 +234,114 @@
|
|||||||
dialog.querySelector('.txtTitle').focus();
|
dialog.querySelector('.txtTitle').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showOnboardingDialog() {
|
||||||
|
const overlay = createOverlay();
|
||||||
|
const dialog = createDialogBase('Welcome to MovieNight!');
|
||||||
|
dialog.style.minWidth = '450px';
|
||||||
|
const content = dialog.querySelector('.dialog-content');
|
||||||
|
const footer = dialog.querySelector('.dialog-footer');
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<p style="margin-bottom:1.5em; opacity:0.8; text-align:center;">Pick your preferences to get better recommendations.</p>
|
||||||
|
<div style="margin-bottom:1.5em;">
|
||||||
|
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Favorite Genres</label>
|
||||||
|
<div class="genre-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:1.5em;">
|
||||||
|
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Preferred Eras</label>
|
||||||
|
<div class="era-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Content Types</label>
|
||||||
|
<div class="type-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const genres = ["Action", "Comedy", "Drama", "Sci-Fi", "Horror", "Thriller", "Animation", "Documentary"];
|
||||||
|
const eras = ["1980s", "1990s", "2000s", "2010s", "2020s"];
|
||||||
|
const types = ["FILM", "SERIES"];
|
||||||
|
|
||||||
|
const selections = { genres: new Set(), eras: new Set(), types: new Set() };
|
||||||
|
|
||||||
|
const createChip = (text, container, type) => {
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.innerText = text;
|
||||||
|
chip.style.cssText = 'padding:0.4em 1em; border-radius:2em; border:1px solid #444; cursor:pointer; font-size:0.9em; transition:all 0.2s;';
|
||||||
|
chip.onclick = () => {
|
||||||
|
if (selections[type].has(text)) {
|
||||||
|
selections[type].delete(text);
|
||||||
|
chip.style.backgroundColor = 'transparent';
|
||||||
|
chip.style.borderColor = '#444';
|
||||||
|
} else {
|
||||||
|
selections[type].add(text);
|
||||||
|
chip.style.backgroundColor = '#0064d2';
|
||||||
|
chip.style.borderColor = '#0064d2';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
container.appendChild(chip);
|
||||||
|
};
|
||||||
|
|
||||||
|
genres.forEach(g => createChip(g, content.querySelector('.genre-chips'), 'genres'));
|
||||||
|
eras.forEach(e => createChip(e, content.querySelector('.era-chips'), 'eras'));
|
||||||
|
types.forEach(t => createChip(t, content.querySelector('.type-chips'), 'types'));
|
||||||
|
|
||||||
|
const btnSave = document.createElement('button');
|
||||||
|
btnSave.className = 'emby-button raised button-submit';
|
||||||
|
btnSave.style.flex = '2';
|
||||||
|
btnSave.style.backgroundColor = '#0064d2';
|
||||||
|
btnSave.innerHTML = '<span>Save & Start</span>';
|
||||||
|
footer.insertBefore(btnSave, footer.firstChild);
|
||||||
|
|
||||||
|
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
|
||||||
|
|
||||||
|
btnSave.onclick = async () => {
|
||||||
|
const payload = {
|
||||||
|
weightedGenres: Object.fromEntries([...selections.genres].map(g => [g, 5])),
|
||||||
|
eras: [...selections.eras],
|
||||||
|
contentTypes: [...selections.types]
|
||||||
|
};
|
||||||
|
cleanup();
|
||||||
|
await completeOnboarding(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.querySelector('.btnCancel').onclick = cleanup;
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
|
||||||
|
overlay.appendChild(dialog);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkOnboarding() {
|
||||||
|
if (window.movieNightOnboardingChecked) return;
|
||||||
|
window.movieNightOnboardingChecked = true;
|
||||||
|
|
||||||
|
const userId = ApiClient.getCurrentUserId();
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prefs = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Preferences`));
|
||||||
|
if (!prefs || (!Object.keys(prefs.weightedGenres || {}).length && !prefs.eras?.length)) {
|
||||||
|
showOnboardingDialog();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 404) showOnboardingDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeOnboarding(payload) {
|
||||||
|
const userId = ApiClient.getCurrentUserId();
|
||||||
|
try {
|
||||||
|
await ApiClient.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Onboarding`),
|
||||||
|
data: JSON.stringify(payload),
|
||||||
|
contentType: 'application/json'
|
||||||
|
});
|
||||||
|
showMsg('Welcome! Your preferences have been saved.');
|
||||||
|
} catch (err) {
|
||||||
|
showMsg('Failed to save onboarding preferences.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function showRecommendation() {
|
async function showRecommendation() {
|
||||||
const userId = ApiClient.getCurrentUserId();
|
const userId = ApiClient.getCurrentUserId();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -137,6 +137,32 @@ public class MovieNightController : ControllerBase
|
|||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets user preferences.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("Users/{userId}/Preferences")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<string?>> GetPreferences(
|
||||||
|
[FromRoute] string userId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Completes onboarding for a user.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("Users/{userId}/Onboarding")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult> CompleteOnboarding(
|
||||||
|
[FromRoute] string userId,
|
||||||
|
[FromBody] object payload,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _backendClient.CompleteOnboardingAsync(userId, payload, cancellationToken).ConfigureAwait(false);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new film by generating a .strm file in a folder-per-movie structure.
|
/// Creates a new film by generating a .strm file in a folder-per-movie structure.
|
||||||
/// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm
|
/// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm
|
||||||
|
|||||||
@@ -167,6 +167,34 @@ public class MovieNightBackendClient
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets user preferences.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> GetPreferencesAsync(string userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/preferences");
|
||||||
|
if (request is null) return null;
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Completes onboarding for a user.
|
||||||
|
/// </summary>
|
||||||
|
public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/recommendation-onboarding");
|
||||||
|
if (request is null) return;
|
||||||
|
|
||||||
|
request.Content = JsonContent.Create(payload, options: JsonOptions);
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pushes an event payload to the backend event endpoint.
|
/// Pushes an event payload to the backend event endpoint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -2,16 +2,6 @@
|
|||||||
|
|
||||||
Thin Jellyfin server plugin for bridging Jellyfin playback/sync signals to the MovieNight backend.
|
Thin Jellyfin server plugin for bridging Jellyfin playback/sync signals to the MovieNight backend.
|
||||||
|
|
||||||
## Instructions
|
|
||||||
|
|
||||||
Brief instructions on how to integrate this plugin to Jellyfin.
|
|
||||||
|
|
||||||
1. Install this plugin
|
|
||||||
2. Setup plugin in plugin settings
|
|
||||||
2. Install [JavaScript Inejector plugin](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector)
|
|
||||||
3. Add [ui.js](./Jellyfin.Plugin.MovieNight/Configuration/ui.js) file to JavaScript Injector
|
|
||||||
5. You're all set! (i hope)
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package com.project.movienight
|
|||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
@ConfigurationPropertiesScan("com.project.movienight.config")
|
@ConfigurationPropertiesScan("com.project.movienight.config")
|
||||||
class MovieNightApplication
|
class MovieNightApplication
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.project.movienight.adapters.jellyfin
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.http.HttpClient
|
||||||
|
import java.net.http.HttpRequest
|
||||||
|
import java.net.http.HttpResponse
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
data class JellyfinRemoteUser(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class JellyfinLibraryItemSnapshot(
|
||||||
|
val jellyfinItemId: String,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val contentType: ContentType,
|
||||||
|
val releaseYear: Int?,
|
||||||
|
val genres: List<String>,
|
||||||
|
val cast: List<String>,
|
||||||
|
val directors: List<String>,
|
||||||
|
val platformRating: Double?,
|
||||||
|
val imdbRating: Double?,
|
||||||
|
val externalUrl: String?,
|
||||||
|
val jellyfinLibraryId: String?,
|
||||||
|
val isPlayed: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class JellyfinApiClient(
|
||||||
|
private val properties: JellyfinIntegrationProperties,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
) {
|
||||||
|
private val httpClient: HttpClient =
|
||||||
|
HttpClient
|
||||||
|
.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun fetchUsers(): List<JellyfinRemoteUser> =
|
||||||
|
request("Users")
|
||||||
|
.asItems()
|
||||||
|
.mapNotNull { node ->
|
||||||
|
val id = node.fieldText("Id") ?: return@mapNotNull null
|
||||||
|
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
request(
|
||||||
|
"Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData",
|
||||||
|
).asItems().mapNotNull { node ->
|
||||||
|
val itemId = node.fieldText("Id") ?: return@mapNotNull null
|
||||||
|
val providerIds = node["ProviderIds"]
|
||||||
|
val imdbId = providerIds?.fieldText("Imdb")
|
||||||
|
val people = node["People"]
|
||||||
|
val cast = people?.peopleByType("Actor", "GuestStar") ?: emptyList()
|
||||||
|
val directors = people?.peopleByType("Director") ?: emptyList()
|
||||||
|
JellyfinLibraryItemSnapshot(
|
||||||
|
jellyfinItemId = itemId,
|
||||||
|
title = node.fieldText("Name") ?: itemId,
|
||||||
|
description = node.fieldText("Overview") ?: "",
|
||||||
|
contentType = mapContentType(node.fieldText("Type")),
|
||||||
|
releaseYear = node["ProductionYear"]?.takeUnless { it.isNull }?.asInt(),
|
||||||
|
genres = node["Genres"]?.textList() ?: emptyList(),
|
||||||
|
cast = cast,
|
||||||
|
directors = directors,
|
||||||
|
platformRating = node["CommunityRating"]?.takeUnless { it.isNull }?.asDouble(),
|
||||||
|
imdbRating = null,
|
||||||
|
externalUrl = imdbId?.let { "https://www.imdb.com/title/$it/" },
|
||||||
|
jellyfinLibraryId = node.fieldText("ParentId"),
|
||||||
|
isPlayed =
|
||||||
|
node["UserData"]?.booleanField("Played") ?: node["UserData"]?.booleanField("IsPlayed") ?: false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(path: String): JsonNode {
|
||||||
|
val uri = URI.create("${properties.baseUrl.trimEnd('/')}/$path")
|
||||||
|
val request =
|
||||||
|
HttpRequest
|
||||||
|
.newBuilder(uri)
|
||||||
|
.timeout(Duration.ofMillis(properties.requestTimeoutMs))
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("X-Emby-Token", properties.apiKey)
|
||||||
|
.GET()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response =
|
||||||
|
try {
|
||||||
|
httpClient.send(request, HttpResponse.BodyHandlers.ofString())
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") exception: Exception,
|
||||||
|
) {
|
||||||
|
throw IllegalStateException("Failed to call Jellyfin at $uri", exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
check(response.statusCode() in 200..299) {
|
||||||
|
"Jellyfin request failed with status ${response.statusCode()} for $uri"
|
||||||
|
}
|
||||||
|
|
||||||
|
return objectMapper.readTree(response.body())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonNode.asItems(): List<JsonNode> =
|
||||||
|
when {
|
||||||
|
isArray -> map { it }
|
||||||
|
has("Items") && this["Items"].isArray -> this["Items"].map { it }
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonNode.fieldText(name: String): String? =
|
||||||
|
get(name)?.takeUnless { it.isNull }?.asText()?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
private fun JsonNode.booleanField(name: String): Boolean? = get(name)?.takeUnless { it.isNull }?.asBoolean()
|
||||||
|
|
||||||
|
private fun JsonNode.textList(): List<String> =
|
||||||
|
takeIf { it.isArray }?.mapNotNull { item ->
|
||||||
|
item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() }
|
||||||
|
}
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
private fun JsonNode.peopleByType(vararg types: String): List<String> {
|
||||||
|
if (!isArray) return emptyList()
|
||||||
|
return mapNotNull { person ->
|
||||||
|
val type = person.fieldText("Type") ?: return@mapNotNull null
|
||||||
|
if (types.any { it.equals(type, ignoreCase = true) }) person.fieldText("Name") else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapContentType(value: String?): ContentType =
|
||||||
|
when (value?.lowercase()) {
|
||||||
|
"movie" -> ContentType.FILM
|
||||||
|
"series" -> ContentType.SERIES
|
||||||
|
"episode" -> ContentType.EPISODE
|
||||||
|
else -> ContentType.OTHER
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.project.movienight.adapters.metrics
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||||
|
import com.project.movienight.domain.model.RecommendationEventType
|
||||||
|
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(
|
||||||
|
private val 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 recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
|
||||||
|
Counter
|
||||||
|
.builder("recommendation_weights_updated_total")
|
||||||
|
.tag("eventType", eventType.name)
|
||||||
|
.register(meterRegistry)
|
||||||
|
.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recordRatingSubmitted() {
|
||||||
|
ratingsSubmitted.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recordLibraryEvent() {
|
||||||
|
libraryEvents.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recordJellyfinSync(summary: JellyfinSyncSummary) {
|
||||||
|
jellyfinSyncRuns.increment()
|
||||||
|
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
|
||||||
|
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
|
||||||
|
jellyfinSyncedItems.increment(summary.syncedItems.toDouble())
|
||||||
|
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recordJellyfinSyncFailure() {
|
||||||
|
jellyfinSyncFailures.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recordJellyfinUnmappedUser() {
|
||||||
|
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recordBackendWriteFailure() {
|
||||||
|
backendWriteFailures.increment()
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.entity
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.FilmRating
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class FilmRatingEntity(
|
||||||
|
val id: UUID,
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
val score: Int,
|
||||||
|
val note: String?,
|
||||||
|
val createdAt: LocalDateTime,
|
||||||
|
val updatedAt: LocalDateTime,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun FilmRatingEntity.toDomain(): FilmRating =
|
||||||
|
FilmRating(
|
||||||
|
id = id,
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
score = score,
|
||||||
|
note = note,
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun FilmRating.toEntity(): FilmRatingEntity =
|
||||||
|
FilmRatingEntity(
|
||||||
|
id = id,
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
score = score,
|
||||||
|
note = note,
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
)
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.entity
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncState
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class JellyfinSyncStateEntity(
|
||||||
|
val userId: UUID,
|
||||||
|
val lastSyncedAt: LocalDateTime?,
|
||||||
|
val lastSuccessfulSyncAt: LocalDateTime?,
|
||||||
|
val lastError: String?,
|
||||||
|
val syncedItemCount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun JellyfinSyncStateEntity.toDomain(): JellyfinSyncState =
|
||||||
|
JellyfinSyncState(
|
||||||
|
userId = userId,
|
||||||
|
lastSyncedAt = lastSyncedAt,
|
||||||
|
lastSuccessfulSyncAt = lastSuccessfulSyncAt,
|
||||||
|
lastError = lastError,
|
||||||
|
syncedItemCount = syncedItemCount,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun JellyfinSyncState.toEntity(): JellyfinSyncStateEntity =
|
||||||
|
JellyfinSyncStateEntity(
|
||||||
|
userId = userId,
|
||||||
|
lastSyncedAt = lastSyncedAt,
|
||||||
|
lastSuccessfulSyncAt = lastSuccessfulSyncAt,
|
||||||
|
lastError = lastError,
|
||||||
|
syncedItemCount = syncedItemCount,
|
||||||
|
)
|
||||||
@@ -11,6 +11,7 @@ data class UserEntity(
|
|||||||
val email: String,
|
val email: String,
|
||||||
val provider: String?,
|
val provider: String?,
|
||||||
val providerId: String?,
|
val providerId: String?,
|
||||||
|
val jellyfinUserId: String?,
|
||||||
val createdAt: LocalDateTime,
|
val createdAt: LocalDateTime,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ fun UserEntity.toDomain(): User =
|
|||||||
name = name,
|
name = name,
|
||||||
email = email,
|
email = email,
|
||||||
library = null,
|
library = null,
|
||||||
|
preferences = null,
|
||||||
|
jellyfinUserId = jellyfinUserId,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun User.toEntity(
|
fun User.toEntity(
|
||||||
@@ -33,5 +36,6 @@ fun User.toEntity(
|
|||||||
email = email,
|
email = email,
|
||||||
provider = provider?.name,
|
provider = provider?.name,
|
||||||
providerId = providerId,
|
providerId = providerId,
|
||||||
|
jellyfinUserId = jellyfinUserId,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
)
|
)
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.entity
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class UserPreferencesEntity(
|
||||||
|
val userId: UUID,
|
||||||
|
val weightedGenres: String,
|
||||||
|
val plotTypes: String,
|
||||||
|
val eras: String,
|
||||||
|
val castAndDirectors: String,
|
||||||
|
val moods: String,
|
||||||
|
val contentTypes: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun UserPreferencesEntity.toDomain(): UserPreferences =
|
||||||
|
UserPreferences(
|
||||||
|
userId = userId,
|
||||||
|
weightedGenres = DelimitedValueCodec.decodeWeightedMap(weightedGenres),
|
||||||
|
plotTypes = DelimitedValueCodec.decodeList(plotTypes),
|
||||||
|
eras = DelimitedValueCodec.decodeList(eras),
|
||||||
|
castAndDirectors = DelimitedValueCodec.decodeList(castAndDirectors),
|
||||||
|
moods = DelimitedValueCodec.decodeList(moods),
|
||||||
|
contentTypes =
|
||||||
|
DelimitedValueCodec.decodeList(contentTypes).mapNotNull { value ->
|
||||||
|
runCatching { ContentType.valueOf(value) }.getOrNull()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fun UserPreferences.toEntity(): UserPreferencesEntity =
|
||||||
|
UserPreferencesEntity(
|
||||||
|
userId = userId,
|
||||||
|
weightedGenres = DelimitedValueCodec.encodeWeightedMap(weightedGenres),
|
||||||
|
plotTypes = DelimitedValueCodec.encodeList(plotTypes),
|
||||||
|
eras = DelimitedValueCodec.encodeList(eras),
|
||||||
|
castAndDirectors = DelimitedValueCodec.encodeList(castAndDirectors),
|
||||||
|
moods = DelimitedValueCodec.encodeList(moods),
|
||||||
|
contentTypes = DelimitedValueCodec.encodeList(contentTypes.map { it.name }),
|
||||||
|
)
|
||||||
+25
-5
@@ -18,6 +18,7 @@ class FilmLibraryRepository(
|
|||||||
filmId = UUID.fromString(rs.getString("film_id")),
|
filmId = UUID.fromString(rs.getString("film_id")),
|
||||||
comment = rs.getString("comment"),
|
comment = rs.getString("comment"),
|
||||||
isViewed = rs.getBoolean("is_viewed"),
|
isViewed = rs.getBoolean("is_viewed"),
|
||||||
|
watchedAt = rs.getTimestamp("watched_at")?.toLocalDateTime(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,26 +27,28 @@ class FilmLibraryRepository(
|
|||||||
jdbc.update(
|
jdbc.update(
|
||||||
"""
|
"""
|
||||||
UPDATE favorites
|
UPDATE favorites
|
||||||
SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?
|
SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?, watched_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
filmLibrary.userId,
|
filmLibrary.userId,
|
||||||
filmLibrary.filmId,
|
filmLibrary.filmId,
|
||||||
filmLibrary.comment,
|
filmLibrary.comment,
|
||||||
filmLibrary.isViewed,
|
filmLibrary.isViewed,
|
||||||
|
filmLibrary.watchedAt,
|
||||||
filmLibrary.id,
|
filmLibrary.id,
|
||||||
)
|
)
|
||||||
if (updatedRows == 0) {
|
if (updatedRows == 0) {
|
||||||
jdbc.update(
|
jdbc.update(
|
||||||
"""
|
"""
|
||||||
INSERT INTO favorites (id, user_id, film_id, comment, is_viewed)
|
INSERT INTO favorites (id, user_id, film_id, comment, is_viewed, watched_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
filmLibrary.id,
|
filmLibrary.id,
|
||||||
filmLibrary.userId,
|
filmLibrary.userId,
|
||||||
filmLibrary.filmId,
|
filmLibrary.filmId,
|
||||||
filmLibrary.comment,
|
filmLibrary.comment,
|
||||||
filmLibrary.isViewed,
|
filmLibrary.isViewed,
|
||||||
|
filmLibrary.watchedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return filmLibrary
|
return filmLibrary
|
||||||
@@ -54,16 +57,33 @@ class FilmLibraryRepository(
|
|||||||
override fun findById(id: UUID): FilmLibrary? {
|
override fun findById(id: UUID): FilmLibrary? {
|
||||||
val entries =
|
val entries =
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT id, user_id, film_id, comment, is_viewed FROM favorites WHERE id = ?",
|
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE id = ?",
|
||||||
filmLibraryRowMapper,
|
filmLibraryRowMapper,
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
return entries.firstOrNull()
|
return entries.firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findByUserIdAndFilmId(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
): FilmLibrary? {
|
||||||
|
val entries =
|
||||||
|
jdbc.query(
|
||||||
|
"""
|
||||||
|
SELECT id, user_id, film_id, comment, is_viewed, watched_at
|
||||||
|
FROM favorites WHERE user_id = ? AND film_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
filmLibraryRowMapper,
|
||||||
|
userId,
|
||||||
|
filmId,
|
||||||
|
)
|
||||||
|
return entries.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
override fun findAll(): List<FilmLibrary> =
|
override fun findAll(): List<FilmLibrary> =
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT id, user_id, film_id, comment, is_viewed FROM favorites",
|
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites",
|
||||||
filmLibraryRowMapper,
|
filmLibraryRowMapper,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.persistence.entity.FilmRatingEntity
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toDomain
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toEntity
|
||||||
|
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.FilmRating
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.sql.ResultSet
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class FilmRatingRepository(
|
||||||
|
private val jdbc: JdbcTemplate,
|
||||||
|
) : FilmRatingRepositoryPort {
|
||||||
|
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||||
|
FilmRatingEntity(
|
||||||
|
id = UUID.fromString(rs.getString("id")),
|
||||||
|
userId = UUID.fromString(rs.getString("user_id")),
|
||||||
|
filmId = UUID.fromString(rs.getString("film_id")),
|
||||||
|
score = rs.getInt("score"),
|
||||||
|
note = rs.getString("note"),
|
||||||
|
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
|
||||||
|
updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(rating: FilmRating): FilmRating {
|
||||||
|
val entity = rating.toEntity()
|
||||||
|
val updatedRows =
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
UPDATE film_ratings
|
||||||
|
SET score = ?,
|
||||||
|
note = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND film_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.score,
|
||||||
|
entity.note,
|
||||||
|
LocalDateTime.now(),
|
||||||
|
entity.userId,
|
||||||
|
entity.filmId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updatedRows == 0) {
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
INSERT INTO film_ratings (
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
film_id,
|
||||||
|
score,
|
||||||
|
note,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.id,
|
||||||
|
entity.userId,
|
||||||
|
entity.filmId,
|
||||||
|
entity.score,
|
||||||
|
entity.note,
|
||||||
|
entity.createdAt,
|
||||||
|
entity.updatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rating
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByUserId(userId: UUID): List<FilmRating> =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT id,
|
||||||
|
user_id,
|
||||||
|
film_id,
|
||||||
|
score,
|
||||||
|
note,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM film_ratings
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
).map { it.toDomain() }
|
||||||
|
|
||||||
|
override fun findByUserIdAndFilmId(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
): FilmRating? =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT id,
|
||||||
|
user_id,
|
||||||
|
film_id,
|
||||||
|
score,
|
||||||
|
note,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM film_ratings
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND film_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
filmId,
|
||||||
|
).firstOrNull()
|
||||||
|
?.toDomain()
|
||||||
|
}
|
||||||
+185
-6
@@ -1,6 +1,8 @@
|
|||||||
package com.project.movienight.adapters.persistence.jdbc
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec
|
||||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
import com.project.movienight.domain.model.Film
|
import com.project.movienight.domain.model.Film
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
@@ -16,6 +18,21 @@ class FilmRepository(
|
|||||||
id = UUID.fromString(rs.getString("id")),
|
id = UUID.fromString(rs.getString("id")),
|
||||||
title = rs.getString("title"),
|
title = rs.getString("title"),
|
||||||
description = rs.getString("description"),
|
description = rs.getString("description"),
|
||||||
|
contentType =
|
||||||
|
runCatching {
|
||||||
|
ContentType.valueOf(
|
||||||
|
rs.getString("content_type"),
|
||||||
|
)
|
||||||
|
}.getOrDefault(ContentType.FILM),
|
||||||
|
releaseYear = rs.getObject("release_year")?.let { (it as Number).toInt() },
|
||||||
|
genres = DelimitedValueCodec.decodeList(rs.getString("genres")),
|
||||||
|
cast = DelimitedValueCodec.decodeList(rs.getString("cast_members")),
|
||||||
|
directors = DelimitedValueCodec.decodeList(rs.getString("directors")),
|
||||||
|
imdbRating = rs.getObject("imdb_rating")?.let { (it as Number).toDouble() },
|
||||||
|
platformRating = rs.getObject("platform_rating")?.let { (it as Number).toDouble() },
|
||||||
|
externalUrl = rs.getString("external_url"),
|
||||||
|
jellyfinItemId = rs.getString("jellyfin_item_id"),
|
||||||
|
jellyfinLibraryId = rs.getString("jellyfin_library_id"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,22 +41,67 @@ class FilmRepository(
|
|||||||
jdbc.update(
|
jdbc.update(
|
||||||
"""
|
"""
|
||||||
UPDATE films
|
UPDATE films
|
||||||
SET title = ?, description = ?
|
SET title = ?,
|
||||||
|
description = ?,
|
||||||
|
content_type = ?,
|
||||||
|
release_year = ?,
|
||||||
|
genres = ?,
|
||||||
|
cast_members = ?,
|
||||||
|
directors = ?,
|
||||||
|
imdb_rating = ?,
|
||||||
|
platform_rating = ?,
|
||||||
|
external_url = ?,
|
||||||
|
jellyfin_item_id = ?,
|
||||||
|
jellyfin_library_id = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
film.title,
|
film.title,
|
||||||
film.description,
|
film.description,
|
||||||
|
film.contentType.name,
|
||||||
|
film.releaseYear,
|
||||||
|
DelimitedValueCodec.encodeList(film.genres),
|
||||||
|
DelimitedValueCodec.encodeList(film.cast),
|
||||||
|
DelimitedValueCodec.encodeList(film.directors),
|
||||||
|
film.imdbRating,
|
||||||
|
film.platformRating,
|
||||||
|
film.externalUrl,
|
||||||
|
film.jellyfinItemId,
|
||||||
|
film.jellyfinLibraryId,
|
||||||
film.id,
|
film.id,
|
||||||
)
|
)
|
||||||
if (updatedRows == 0) {
|
if (updatedRows == 0) {
|
||||||
jdbc.update(
|
jdbc.update(
|
||||||
"""
|
"""
|
||||||
INSERT INTO films (id, title, description)
|
INSERT INTO films (
|
||||||
VALUES (?, ?, ?)
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content_type,
|
||||||
|
release_year,
|
||||||
|
genres,
|
||||||
|
cast_members,
|
||||||
|
directors,
|
||||||
|
imdb_rating,
|
||||||
|
platform_rating,
|
||||||
|
external_url,
|
||||||
|
jellyfin_item_id,
|
||||||
|
jellyfin_library_id
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
film.id,
|
film.id,
|
||||||
film.title,
|
film.title,
|
||||||
film.description,
|
film.description,
|
||||||
|
film.contentType.name,
|
||||||
|
film.releaseYear,
|
||||||
|
DelimitedValueCodec.encodeList(film.genres),
|
||||||
|
DelimitedValueCodec.encodeList(film.cast),
|
||||||
|
DelimitedValueCodec.encodeList(film.directors),
|
||||||
|
film.imdbRating,
|
||||||
|
film.platformRating,
|
||||||
|
film.externalUrl,
|
||||||
|
film.jellyfinItemId,
|
||||||
|
film.jellyfinLibraryId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return film
|
return film
|
||||||
@@ -48,20 +110,137 @@ class FilmRepository(
|
|||||||
override fun findById(id: UUID): Film? {
|
override fun findById(id: UUID): Film? {
|
||||||
val films =
|
val films =
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT id, title, description FROM films WHERE id = ?",
|
"""
|
||||||
|
SELECT id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content_type,
|
||||||
|
release_year,
|
||||||
|
genres,
|
||||||
|
cast_members,
|
||||||
|
directors,
|
||||||
|
imdb_rating,
|
||||||
|
platform_rating,
|
||||||
|
external_url,
|
||||||
|
jellyfin_item_id,
|
||||||
|
jellyfin_library_id
|
||||||
|
FROM films
|
||||||
|
WHERE id = ?
|
||||||
|
""".trimIndent(),
|
||||||
filmRowMapper,
|
filmRowMapper,
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
return films.firstOrNull()
|
return films.firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findByJellyfinItemId(jellyfinItemId: String): Film? {
|
||||||
|
val films =
|
||||||
|
jdbc.query(
|
||||||
|
"""
|
||||||
|
SELECT id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content_type,
|
||||||
|
release_year,
|
||||||
|
genres,
|
||||||
|
cast_members,
|
||||||
|
directors,
|
||||||
|
imdb_rating,
|
||||||
|
platform_rating,
|
||||||
|
external_url,
|
||||||
|
jellyfin_item_id,
|
||||||
|
jellyfin_library_id
|
||||||
|
FROM films
|
||||||
|
WHERE jellyfin_item_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
filmRowMapper,
|
||||||
|
jellyfinItemId,
|
||||||
|
)
|
||||||
|
return films.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? {
|
||||||
|
val films =
|
||||||
|
jdbc.query(
|
||||||
|
"""
|
||||||
|
SELECT id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content_type,
|
||||||
|
release_year,
|
||||||
|
genres,
|
||||||
|
cast_members,
|
||||||
|
directors,
|
||||||
|
imdb_rating,
|
||||||
|
platform_rating,
|
||||||
|
external_url,
|
||||||
|
jellyfin_item_id,
|
||||||
|
jellyfin_library_id
|
||||||
|
FROM films
|
||||||
|
WHERE jellyfin_library_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
filmRowMapper,
|
||||||
|
jellyfinLibraryId,
|
||||||
|
)
|
||||||
|
return films.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
override fun findAll(): List<Film> =
|
override fun findAll(): List<Film> =
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT id, title, description FROM films",
|
"""
|
||||||
|
SELECT id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content_type,
|
||||||
|
release_year,
|
||||||
|
genres,
|
||||||
|
cast_members,
|
||||||
|
directors,
|
||||||
|
imdb_rating,
|
||||||
|
platform_rating,
|
||||||
|
external_url,
|
||||||
|
jellyfin_item_id,
|
||||||
|
jellyfin_library_id
|
||||||
|
FROM films
|
||||||
|
""".trimIndent(),
|
||||||
filmRowMapper,
|
filmRowMapper,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override fun findByTitle(title: String): Film? {
|
||||||
|
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 title = ?
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 1
|
||||||
|
""".trimIndent(),
|
||||||
|
filmRowMapper,
|
||||||
|
title,
|
||||||
|
)
|
||||||
|
return films.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
override fun deleteById(id: UUID) {
|
override fun deleteById(id: UUID) {
|
||||||
jdbc.update("DELETE FROM films WHERE id = ?", id)
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
DELETE FROM films
|
||||||
|
WHERE id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
id,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class JellyfinEventRepository(
|
||||||
|
private val jdbc: NamedParameterJdbcTemplate,
|
||||||
|
) {
|
||||||
|
fun save(
|
||||||
|
eventId: String,
|
||||||
|
serverId: String?,
|
||||||
|
eventType: String,
|
||||||
|
occurredAt: java.time.OffsetDateTime?,
|
||||||
|
jellyfinUserId: String?,
|
||||||
|
jellyfinItemId: String?,
|
||||||
|
payload: String?,
|
||||||
|
): Int {
|
||||||
|
val sql =
|
||||||
|
"""
|
||||||
|
INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload)
|
||||||
|
VALUES (:eventId, :serverId, :eventType, :occurredAt, :jellyfinUserId, :jellyfinItemId, cast(:payload as jsonb))
|
||||||
|
ON CONFLICT (event_id) DO NOTHING
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val params =
|
||||||
|
MapSqlParameterSource()
|
||||||
|
.addValue("eventId", eventId)
|
||||||
|
.addValue("serverId", serverId)
|
||||||
|
.addValue("eventType", eventType)
|
||||||
|
.addValue("occurredAt", occurredAt)
|
||||||
|
.addValue("jellyfinUserId", jellyfinUserId)
|
||||||
|
.addValue("jellyfinItemId", jellyfinItemId)
|
||||||
|
.addValue("payload", payload)
|
||||||
|
|
||||||
|
return jdbc.update(sql, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(eventId: String) {
|
||||||
|
val sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId"
|
||||||
|
val params = MapSqlParameterSource().addValue("eventId", eventId)
|
||||||
|
jdbc.update(sql, params)
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.persistence.entity.JellyfinSyncStateEntity
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toDomain
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toEntity
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncState
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.sql.ResultSet
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class JellyfinSyncStateRepository(
|
||||||
|
private val jdbc: JdbcTemplate,
|
||||||
|
) : JellyfinSyncStateRepositoryPort {
|
||||||
|
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||||
|
JellyfinSyncStateEntity(
|
||||||
|
userId = UUID.fromString(rs.getString("user_id")),
|
||||||
|
lastSyncedAt = rs.getTimestamp("last_synced_at")?.toLocalDateTime(),
|
||||||
|
lastSuccessfulSyncAt = rs.getTimestamp("last_successful_sync_at")?.toLocalDateTime(),
|
||||||
|
lastError = rs.getString("last_error"),
|
||||||
|
syncedItemCount = rs.getInt("synced_item_count"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(state: JellyfinSyncState): JellyfinSyncState {
|
||||||
|
val entity = state.toEntity()
|
||||||
|
val updatedRows =
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
UPDATE jellyfin_sync_state
|
||||||
|
SET last_synced_at = ?,
|
||||||
|
last_successful_sync_at = ?,
|
||||||
|
last_error = ?,
|
||||||
|
synced_item_count = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.lastSyncedAt,
|
||||||
|
entity.lastSuccessfulSyncAt,
|
||||||
|
entity.lastError,
|
||||||
|
entity.syncedItemCount,
|
||||||
|
entity.userId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updatedRows == 0) {
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
INSERT INTO jellyfin_sync_state (
|
||||||
|
user_id,
|
||||||
|
last_synced_at,
|
||||||
|
last_successful_sync_at,
|
||||||
|
last_error,
|
||||||
|
synced_item_count
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.userId,
|
||||||
|
entity.lastSyncedAt,
|
||||||
|
entity.lastSuccessfulSyncAt,
|
||||||
|
entity.lastError,
|
||||||
|
entity.syncedItemCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByUserId(userId: UUID): JellyfinSyncState? =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT user_id,
|
||||||
|
last_synced_at,
|
||||||
|
last_successful_sync_at,
|
||||||
|
last_error,
|
||||||
|
synced_item_count
|
||||||
|
FROM jellyfin_sync_state
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
).firstOrNull()
|
||||||
|
?.toDomain()
|
||||||
|
|
||||||
|
override fun findAll(): List<JellyfinSyncState> =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT user_id,
|
||||||
|
last_synced_at,
|
||||||
|
last_successful_sync_at,
|
||||||
|
last_error,
|
||||||
|
synced_item_count
|
||||||
|
FROM jellyfin_sync_state
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
).map { it.toDomain() }
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.RecommendationEvent
|
||||||
|
import com.project.movienight.domain.model.RecommendationEventType
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.sql.ResultSet
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class RecommendationEventRepository(
|
||||||
|
private val jdbc: JdbcTemplate,
|
||||||
|
) : RecommendationEventRepositoryPort {
|
||||||
|
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||||
|
RecommendationEvent(
|
||||||
|
id = UUID.fromString(rs.getString("id")),
|
||||||
|
userId = UUID.fromString(rs.getString("user_id")),
|
||||||
|
filmId = UUID.fromString(rs.getString("film_id")),
|
||||||
|
eventType = RecommendationEventType.valueOf(rs.getString("event_type")),
|
||||||
|
score = rs.getObject("score")?.let { (it as Number).toDouble() },
|
||||||
|
relevanceScore = rs.getObject("relevance_score")?.let { (it as Number).toDouble() },
|
||||||
|
qualityScore = rs.getObject("quality_score")?.let { (it as Number).toDouble() },
|
||||||
|
contextScore = rs.getObject("context_score")?.let { (it as Number).toDouble() },
|
||||||
|
noveltyScore = rs.getObject("novelty_score")?.let { (it as Number).toDouble() },
|
||||||
|
diversityScore = rs.getObject("diversity_score")?.let { (it as Number).toDouble() },
|
||||||
|
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(event: RecommendationEvent): RecommendationEvent {
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
INSERT INTO recommendation_events (
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
film_id,
|
||||||
|
event_type,
|
||||||
|
score,
|
||||||
|
relevance_score,
|
||||||
|
quality_score,
|
||||||
|
context_score,
|
||||||
|
novelty_score,
|
||||||
|
diversity_score,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
event.id,
|
||||||
|
event.userId,
|
||||||
|
event.filmId,
|
||||||
|
event.eventType.name,
|
||||||
|
event.score,
|
||||||
|
event.relevanceScore,
|
||||||
|
event.qualityScore,
|
||||||
|
event.contextScore,
|
||||||
|
event.noveltyScore,
|
||||||
|
event.diversityScore,
|
||||||
|
event.createdAt,
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByUserId(userId: UUID): List<RecommendationEvent> =
|
||||||
|
jdbc.query(
|
||||||
|
"""
|
||||||
|
SELECT id,
|
||||||
|
user_id,
|
||||||
|
film_id,
|
||||||
|
event_type,
|
||||||
|
score,
|
||||||
|
relevance_score,
|
||||||
|
quality_score,
|
||||||
|
context_score,
|
||||||
|
novelty_score,
|
||||||
|
diversity_score,
|
||||||
|
created_at
|
||||||
|
FROM recommendation_events
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun findLatestRecommended(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
): RecommendationEvent? =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT id,
|
||||||
|
user_id,
|
||||||
|
film_id,
|
||||||
|
event_type,
|
||||||
|
score,
|
||||||
|
relevance_score,
|
||||||
|
quality_score,
|
||||||
|
context_score,
|
||||||
|
novelty_score,
|
||||||
|
diversity_score,
|
||||||
|
created_at
|
||||||
|
FROM recommendation_events
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND film_id = ?
|
||||||
|
AND event_type = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
filmId,
|
||||||
|
RecommendationEventType.RECOMMENDED.name,
|
||||||
|
).firstOrNull()
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.persistence.entity.UserPreferencesEntity
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toDomain
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toEntity
|
||||||
|
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.sql.ResultSet
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class UserPreferencesRepository(
|
||||||
|
private val jdbc: JdbcTemplate,
|
||||||
|
) : UserPreferencesRepositoryPort {
|
||||||
|
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||||
|
UserPreferencesEntity(
|
||||||
|
userId = UUID.fromString(rs.getString("user_id")),
|
||||||
|
weightedGenres = rs.getString("weighted_genres"),
|
||||||
|
plotTypes = rs.getString("plot_types"),
|
||||||
|
eras = rs.getString("eras"),
|
||||||
|
castAndDirectors = rs.getString("cast_and_directors"),
|
||||||
|
moods = rs.getString("moods"),
|
||||||
|
contentTypes = rs.getString("content_types"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(preferences: UserPreferences): UserPreferences {
|
||||||
|
val entity = preferences.toEntity()
|
||||||
|
val updatedRows =
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
UPDATE user_preferences
|
||||||
|
SET weighted_genres = ?,
|
||||||
|
plot_types = ?,
|
||||||
|
eras = ?,
|
||||||
|
cast_and_directors = ?,
|
||||||
|
moods = ?,
|
||||||
|
content_types = ?
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.weightedGenres,
|
||||||
|
entity.plotTypes,
|
||||||
|
entity.eras,
|
||||||
|
entity.castAndDirectors,
|
||||||
|
entity.moods,
|
||||||
|
entity.contentTypes,
|
||||||
|
entity.userId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updatedRows == 0) {
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_preferences (
|
||||||
|
user_id,
|
||||||
|
weighted_genres,
|
||||||
|
plot_types,
|
||||||
|
eras,
|
||||||
|
cast_and_directors,
|
||||||
|
moods,
|
||||||
|
content_types
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
entity.userId,
|
||||||
|
entity.weightedGenres,
|
||||||
|
entity.plotTypes,
|
||||||
|
entity.eras,
|
||||||
|
entity.castAndDirectors,
|
||||||
|
entity.moods,
|
||||||
|
entity.contentTypes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferences
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByUserId(userId: UUID): UserPreferences? =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT user_id,
|
||||||
|
weighted_genres,
|
||||||
|
plot_types,
|
||||||
|
eras,
|
||||||
|
cast_and_directors,
|
||||||
|
moods,
|
||||||
|
content_types
|
||||||
|
FROM user_preferences
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
).firstOrNull()
|
||||||
|
?.toDomain()
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
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 UserRecommendationWeightsRepository(
|
||||||
|
private val jdbc: JdbcTemplate,
|
||||||
|
) : UserRecommendationWeightsRepositoryPort {
|
||||||
|
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||||
|
UserRecommendationWeights(
|
||||||
|
userId = UUID.fromString(rs.getString("user_id")),
|
||||||
|
relevanceWeight = rs.getDouble("relevance_weight"),
|
||||||
|
qualityWeight = rs.getDouble("quality_weight"),
|
||||||
|
contextWeight = rs.getDouble("context_weight"),
|
||||||
|
noveltyWeight = rs.getDouble("novelty_weight"),
|
||||||
|
diversityWeight = rs.getDouble("diversity_weight"),
|
||||||
|
genreVectorWeight = rs.getDouble("genre_vector_weight"),
|
||||||
|
plotVectorWeight = rs.getDouble("plot_vector_weight"),
|
||||||
|
moodVectorWeight = rs.getDouble("mood_vector_weight"),
|
||||||
|
eraVectorWeight = rs.getDouble("era_vector_weight"),
|
||||||
|
peopleVectorWeight = rs.getDouble("people_vector_weight"),
|
||||||
|
contentTypeVectorWeight = rs.getDouble("content_type_vector_weight"),
|
||||||
|
updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByUserId(userId: UUID): UserRecommendationWeights? =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT user_id,
|
||||||
|
relevance_weight,
|
||||||
|
quality_weight,
|
||||||
|
context_weight,
|
||||||
|
novelty_weight,
|
||||||
|
diversity_weight,
|
||||||
|
genre_vector_weight,
|
||||||
|
plot_vector_weight,
|
||||||
|
mood_vector_weight,
|
||||||
|
era_vector_weight,
|
||||||
|
people_vector_weight,
|
||||||
|
content_type_vector_weight,
|
||||||
|
updated_at
|
||||||
|
FROM user_recommendation_weights
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
).firstOrNull()
|
||||||
|
|
||||||
|
override fun save(weights: UserRecommendationWeights): UserRecommendationWeights {
|
||||||
|
val normalized = weights.normalized(updatedAt = LocalDateTime.now())
|
||||||
|
val updatedRows =
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
UPDATE user_recommendation_weights
|
||||||
|
SET relevance_weight = ?,
|
||||||
|
quality_weight = ?,
|
||||||
|
context_weight = ?,
|
||||||
|
novelty_weight = ?,
|
||||||
|
diversity_weight = ?,
|
||||||
|
genre_vector_weight = ?,
|
||||||
|
plot_vector_weight = ?,
|
||||||
|
mood_vector_weight = ?,
|
||||||
|
era_vector_weight = ?,
|
||||||
|
people_vector_weight = ?,
|
||||||
|
content_type_vector_weight = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
normalized.relevanceWeight,
|
||||||
|
normalized.qualityWeight,
|
||||||
|
normalized.contextWeight,
|
||||||
|
normalized.noveltyWeight,
|
||||||
|
normalized.diversityWeight,
|
||||||
|
normalized.genreVectorWeight,
|
||||||
|
normalized.plotVectorWeight,
|
||||||
|
normalized.moodVectorWeight,
|
||||||
|
normalized.eraVectorWeight,
|
||||||
|
normalized.peopleVectorWeight,
|
||||||
|
normalized.contentTypeVectorWeight,
|
||||||
|
normalized.updatedAt,
|
||||||
|
normalized.userId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updatedRows == 0) {
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_recommendation_weights (
|
||||||
|
user_id,
|
||||||
|
relevance_weight,
|
||||||
|
quality_weight,
|
||||||
|
context_weight,
|
||||||
|
novelty_weight,
|
||||||
|
diversity_weight,
|
||||||
|
genre_vector_weight,
|
||||||
|
plot_vector_weight,
|
||||||
|
mood_vector_weight,
|
||||||
|
era_vector_weight,
|
||||||
|
people_vector_weight,
|
||||||
|
content_type_vector_weight,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
normalized.userId,
|
||||||
|
normalized.relevanceWeight,
|
||||||
|
normalized.qualityWeight,
|
||||||
|
normalized.contextWeight,
|
||||||
|
normalized.noveltyWeight,
|
||||||
|
normalized.diversityWeight,
|
||||||
|
normalized.genreVectorWeight,
|
||||||
|
normalized.plotVectorWeight,
|
||||||
|
normalized.moodVectorWeight,
|
||||||
|
normalized.eraVectorWeight,
|
||||||
|
normalized.peopleVectorWeight,
|
||||||
|
normalized.contentTypeVectorWeight,
|
||||||
|
normalized.updatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,36 +22,53 @@ class UserRepository(
|
|||||||
email = rs.getString("email"),
|
email = rs.getString("email"),
|
||||||
provider = rs.getString("provider"),
|
provider = rs.getString("provider"),
|
||||||
providerId = rs.getString("provider_id"),
|
providerId = rs.getString("provider_id"),
|
||||||
|
jellyfinUserId = rs.getString("jellyfin_user_id"),
|
||||||
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
|
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun save(user: User): User {
|
override fun save(user: User): User {
|
||||||
val entity = user.toEntity()
|
val existingUser = findById(user.id)
|
||||||
|
|
||||||
|
val entity =
|
||||||
|
if (existingUser != null) {
|
||||||
|
val existingEntity = existingUser.toEntity()
|
||||||
|
user.toEntity(
|
||||||
|
provider = existingEntity.provider?.let { AuthProvider.valueOf(it) },
|
||||||
|
providerId = existingEntity.providerId,
|
||||||
|
createdAt = existingEntity.createdAt,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
user.toEntity()
|
||||||
|
}
|
||||||
|
|
||||||
val updatedRows =
|
val updatedRows =
|
||||||
jdbc.update(
|
jdbc.update(
|
||||||
"""
|
"""
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET name = ?, email = ?, provider = ?, provider_id = ?
|
SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
entity.name,
|
entity.name,
|
||||||
entity.email,
|
entity.email,
|
||||||
entity.provider,
|
entity.provider,
|
||||||
entity.providerId,
|
entity.providerId,
|
||||||
|
entity.jellyfinUserId,
|
||||||
entity.id,
|
entity.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (updatedRows == 0) {
|
if (updatedRows == 0) {
|
||||||
jdbc.update(
|
jdbc.update(
|
||||||
"""
|
"""
|
||||||
INSERT INTO users (id, name, email, provider, provider_id, created_at)
|
INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
entity.id,
|
entity.id,
|
||||||
entity.name,
|
entity.name,
|
||||||
entity.email,
|
entity.email,
|
||||||
entity.provider,
|
entity.provider,
|
||||||
entity.providerId,
|
entity.providerId,
|
||||||
|
entity.jellyfinUserId,
|
||||||
entity.createdAt,
|
entity.createdAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -61,17 +78,27 @@ class UserRepository(
|
|||||||
override fun findById(id: UUID): User? {
|
override fun findById(id: UUID): User? {
|
||||||
val entities =
|
val entities =
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?",
|
"SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users WHERE id = ?",
|
||||||
userEntityRowMapper,
|
userEntityRowMapper,
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
return entities.firstOrNull()?.toDomain()
|
return entities.firstOrNull()?.toDomain()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findByEmail(email: String): User? {
|
||||||
|
val entities =
|
||||||
|
jdbc.query(
|
||||||
|
"SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?",
|
||||||
|
userEntityRowMapper,
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
return entities.firstOrNull()?.toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
override fun findAll(): List<User> =
|
override fun findAll(): List<User> =
|
||||||
jdbc
|
jdbc
|
||||||
.query(
|
.query(
|
||||||
"SELECT id, name, email, provider, provider_id, created_at FROM users",
|
"SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users",
|
||||||
userEntityRowMapper,
|
userEntityRowMapper,
|
||||||
).map { it.toDomain() }
|
).map { it.toDomain() }
|
||||||
|
|
||||||
@@ -86,7 +113,7 @@ class UserRepository(
|
|||||||
val entities =
|
val entities =
|
||||||
jdbc.query(
|
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 = ?
|
WHERE provider = ? AND provider_id = ?
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
userEntityRowMapper,
|
userEntityRowMapper,
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.jdbc.support
|
||||||
|
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
object DelimitedValueCodec {
|
||||||
|
fun encodeList(values: List<String>): String = values.joinToString("|") { encode(it) }
|
||||||
|
|
||||||
|
fun decodeList(value: String?): List<String> =
|
||||||
|
value
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.split("|")
|
||||||
|
?.map { decode(it) }
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
fun encodeWeightedMap(values: Map<String, Int>): String =
|
||||||
|
values.entries.joinToString("|") { entry -> "${encode(entry.key)}:${entry.value}" }
|
||||||
|
|
||||||
|
fun decodeWeightedMap(value: String?): Map<String, Int> {
|
||||||
|
if (value.isNullOrBlank()) return emptyMap()
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split("|")
|
||||||
|
.mapNotNull { pair ->
|
||||||
|
val parts = pair.split(":", limit = 2)
|
||||||
|
if (parts.size != 2) return@mapNotNull null
|
||||||
|
|
||||||
|
val key = decode(parts[0])
|
||||||
|
val weight = parts[1].toIntOrNull() ?: return@mapNotNull null
|
||||||
|
key to weight
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
|
||||||
|
|
||||||
|
private fun decode(value: String): String = URLDecoder.decode(value, StandardCharsets.UTF_8)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.project.movienight.adapters.security
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toDomain
|
||||||
|
import com.project.movienight.adapters.persistence.entity.toEntity
|
||||||
|
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||||
|
import com.project.movienight.application.ports.output.IdGenerator
|
||||||
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.AuthProvider
|
||||||
|
import com.project.movienight.domain.model.User
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
|
||||||
|
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
|
||||||
|
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class CustomOAuth2UserService(
|
||||||
|
private val userRepository: UserRepositoryPort,
|
||||||
|
private val idGenerator: IdGenerator,
|
||||||
|
) : DefaultOAuth2UserService() {
|
||||||
|
companion object {
|
||||||
|
private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User {
|
||||||
|
val oAuth2User = super.loadUser(userRequest)
|
||||||
|
val registrationId = userRequest.clientRegistration.registrationId
|
||||||
|
|
||||||
|
log.debug("Processing OAuth2 login for provider: {}", registrationId)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User)
|
||||||
|
val user = findOrCreateUser(userInfo)
|
||||||
|
UserPrincipal.create(user, oAuth2User.attributes)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
log.error("OAuth2 authentication failed: ${e.message}", e)
|
||||||
|
throw OAuth2AuthenticationException("Failed to process OAuth2 user data")
|
||||||
|
} catch (e: OAuth2AuthenticationException) {
|
||||||
|
log.error("OAuth2 authentication failed: ${e.message}", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findOrCreateUser(userInfo: OAuth2UserInfo): User {
|
||||||
|
val provider = AuthProvider.valueOf(userInfo.getProvider().uppercase())
|
||||||
|
|
||||||
|
val existingUser =
|
||||||
|
userRepository.findByProviderAndProviderId(
|
||||||
|
provider,
|
||||||
|
userInfo.getProviderId(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return if (existingUser != null) {
|
||||||
|
log.debug("User found by provider: {}", userInfo.getProvider())
|
||||||
|
existingUser
|
||||||
|
} else {
|
||||||
|
val userByEmail = userRepository.findByEmail(userInfo.getEmail())
|
||||||
|
|
||||||
|
if (userByEmail != null) {
|
||||||
|
log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail())
|
||||||
|
val entity =
|
||||||
|
userByEmail.toEntity(
|
||||||
|
provider = provider,
|
||||||
|
providerId = userInfo.getProviderId(),
|
||||||
|
)
|
||||||
|
userRepository.save(entity.toDomain())
|
||||||
|
} else {
|
||||||
|
log.debug("Creating new user for provider: {}", userInfo.getProvider())
|
||||||
|
val newUser =
|
||||||
|
User(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
name = userInfo.getName(),
|
||||||
|
email = userInfo.getEmail(),
|
||||||
|
library = null,
|
||||||
|
)
|
||||||
|
val entity =
|
||||||
|
newUser.toEntity(
|
||||||
|
provider = provider,
|
||||||
|
providerId = userInfo.getProviderId(),
|
||||||
|
)
|
||||||
|
userRepository.save(entity.toDomain())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.project.movienight.adapters.security
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||||
|
|
||||||
|
class GoogleOAuth2UserInfo(
|
||||||
|
private val attributes: Map<String, Any>,
|
||||||
|
) : OAuth2UserInfo {
|
||||||
|
override fun getProviderId(): String = attributes["sub"] as String
|
||||||
|
|
||||||
|
override fun getEmail(): String = attributes["email"] as String
|
||||||
|
|
||||||
|
override fun getName(): String = attributes["name"] as String
|
||||||
|
|
||||||
|
override fun getProvider(): String = "google"
|
||||||
|
|
||||||
|
override fun getAttributes(): Map<String, Any> = attributes
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.project.movienight.adapters.security
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
|
||||||
|
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||||
|
|
||||||
|
object OAuth2UserInfoFactory {
|
||||||
|
fun getOAuth2UserInfo(
|
||||||
|
registrationId: String,
|
||||||
|
user: OAuth2User,
|
||||||
|
): OAuth2UserInfo {
|
||||||
|
val attributes = user.attributes
|
||||||
|
|
||||||
|
return when (registrationId.lowercase()) {
|
||||||
|
"google" -> GoogleOAuth2UserInfo(attributes)
|
||||||
|
"yandex" -> YandexOAuth2UserInfo(attributes)
|
||||||
|
"vk" -> VkOAuth2UserInfo(attributes)
|
||||||
|
else -> throw OAuth2AuthenticationException("Unknown provider: $registrationId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.project.movienight.adapters.security
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||||
|
import org.springframework.security.web.SecurityFilterChain
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
class SecurityConfiguration(
|
||||||
|
private val customOAuth2UserService: CustomOAuth2UserService,
|
||||||
|
) {
|
||||||
|
@Bean
|
||||||
|
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||||
|
http
|
||||||
|
.oauth2Login { oauth2 ->
|
||||||
|
oauth2
|
||||||
|
.userInfoEndpoint { userInfo ->
|
||||||
|
userInfo.userService(customOAuth2UserService)
|
||||||
|
}.defaultSuccessUrl("/api/users/me", true)
|
||||||
|
}.authorizeHttpRequests { auth ->
|
||||||
|
auth
|
||||||
|
.requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health")
|
||||||
|
.permitAll()
|
||||||
|
.requestMatchers("/api/users/me")
|
||||||
|
.authenticated()
|
||||||
|
.requestMatchers("/api/**")
|
||||||
|
.authenticated()
|
||||||
|
.anyRequest()
|
||||||
|
.authenticated()
|
||||||
|
}.headers { headers ->
|
||||||
|
headers.frameOptions { frameOptions ->
|
||||||
|
frameOptions.sameOrigin()
|
||||||
|
}
|
||||||
|
}.csrf { csrf ->
|
||||||
|
csrf.disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.project.movienight.adapters.security
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.User
|
||||||
|
import org.springframework.security.core.GrantedAuthority
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
|
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class UserPrincipal(
|
||||||
|
private val user: User,
|
||||||
|
private val attributes: Map<String, Any>? = null,
|
||||||
|
) : OAuth2User,
|
||||||
|
UserDetails {
|
||||||
|
fun getId(): UUID = user.id
|
||||||
|
|
||||||
|
override fun getName(): String = user.name
|
||||||
|
|
||||||
|
override fun getAttributes(): Map<String, Any> = attributes ?: emptyMap()
|
||||||
|
|
||||||
|
override fun getAuthorities(): Collection<GrantedAuthority> =
|
||||||
|
listOf(
|
||||||
|
SimpleGrantedAuthority("ROLE_USER"),
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun getPassword(): String = ""
|
||||||
|
|
||||||
|
override fun getUsername(): String = user.email
|
||||||
|
|
||||||
|
override fun isAccountNonExpired(): Boolean = true
|
||||||
|
|
||||||
|
override fun isAccountNonLocked(): Boolean = true
|
||||||
|
|
||||||
|
override fun isCredentialsNonExpired(): Boolean = true
|
||||||
|
|
||||||
|
override fun isEnabled(): Boolean = true
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun create(
|
||||||
|
user: User,
|
||||||
|
attributes: Map<String, Any>? = null,
|
||||||
|
): UserPrincipal = UserPrincipal(user, attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.project.movienight.adapters.security
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||||
|
|
||||||
|
class VkOAuth2UserInfo(
|
||||||
|
private val attributes: Map<String, Any>,
|
||||||
|
) : OAuth2UserInfo {
|
||||||
|
override fun getProviderId(): String =
|
||||||
|
(attributes["response"] as? List<*>)
|
||||||
|
?.firstOrNull()
|
||||||
|
?.let { it as? Map<*, *> }
|
||||||
|
?.get("id")
|
||||||
|
?.toString() ?: ""
|
||||||
|
|
||||||
|
override fun getEmail(): String = attributes["email"]?.toString() ?: ""
|
||||||
|
|
||||||
|
override fun getName(): String {
|
||||||
|
val response = attributes["response"] as? List<*>
|
||||||
|
val first = response?.firstOrNull() as? Map<*, *>
|
||||||
|
val firstName = first?.get("first_name")?.toString() ?: ""
|
||||||
|
val lastName = first?.get("last_name")?.toString() ?: ""
|
||||||
|
return "$firstName $lastName".trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getProvider(): String = "vk"
|
||||||
|
|
||||||
|
override fun getAttributes(): Map<String, Any> = attributes
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.project.movienight.adapters.security
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||||
|
|
||||||
|
class YandexOAuth2UserInfo(
|
||||||
|
private val attributes: Map<String, Any>,
|
||||||
|
) : OAuth2UserInfo {
|
||||||
|
override fun getProviderId(): String = attributes["id"]?.toString() ?: ""
|
||||||
|
|
||||||
|
override fun getEmail(): String =
|
||||||
|
(attributes["emails"] as? List<*>)
|
||||||
|
?.firstOrNull()
|
||||||
|
?.let { it as? Map<*, *> }
|
||||||
|
?.get("value")
|
||||||
|
?.toString() ?: ""
|
||||||
|
|
||||||
|
override fun getName(): String = attributes["display_name"]?.toString() ?: ""
|
||||||
|
|
||||||
|
override fun getProvider(): String = "yandex"
|
||||||
|
|
||||||
|
override fun getAttributes(): Map<String, Any> = attributes
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package com.project.movienight.adapters.web
|
|||||||
import com.project.movienight.domain.exception.BlockedValueException
|
import com.project.movienight.domain.exception.BlockedValueException
|
||||||
import com.project.movienight.domain.exception.DomainException
|
import com.project.movienight.domain.exception.DomainException
|
||||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.slf4j.MDC
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
@@ -10,22 +12,60 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
|
|||||||
|
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
class ApiExceptionHandler {
|
class ApiExceptionHandler {
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
@ExceptionHandler(EntityNotFoundException::class)
|
@ExceptionHandler(EntityNotFoundException::class)
|
||||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||||
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse =
|
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse {
|
||||||
ErrorResponse(message = exception.message ?: "Entity not found")
|
val traceId = currentTraceId()
|
||||||
|
log.warn("Entity not found: traceId='{}', message='{}'", traceId, exception.message)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = exception.message ?: "Entity not found",
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(BlockedValueException::class)
|
@ExceptionHandler(BlockedValueException::class)
|
||||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse =
|
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse {
|
||||||
ErrorResponse(message = exception.message ?: "Blocked value")
|
val traceId = currentTraceId()
|
||||||
|
log.warn("Blocked value: traceId='{}', message='{}'", traceId, exception.message)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = exception.message ?: "Blocked value",
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(DomainException::class)
|
@ExceptionHandler(DomainException::class)
|
||||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
fun handleDomainException(exception: DomainException): ErrorResponse =
|
fun handleDomainException(exception: DomainException): ErrorResponse {
|
||||||
ErrorResponse(message = exception.message ?: "Domain error")
|
val traceId = currentTraceId()
|
||||||
|
log.warn("Domain error: traceId='{}', message='{}'", traceId, exception.message)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = exception.message ?: "Domain error",
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception::class)
|
||||||
|
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
fun handleUnexpectedException(exception: Exception): ErrorResponse {
|
||||||
|
val traceId = currentTraceId()
|
||||||
|
log.error("Unexpected error: traceId='{}'", traceId, exception)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = "Internal server error",
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentTraceId(): String = MDC.get("traceId") ?: "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ErrorResponse(
|
data class ErrorResponse(
|
||||||
val message: String,
|
val message: String,
|
||||||
|
val traceId: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,23 +8,38 @@ import com.project.movienight.application.ports.input.CreateFilmUseCase
|
|||||||
import com.project.movienight.application.ports.input.DeleteFilmUseCase
|
import com.project.movienight.application.ports.input.DeleteFilmUseCase
|
||||||
import com.project.movienight.application.ports.input.EditFilmCommand
|
import com.project.movienight.application.ports.input.EditFilmCommand
|
||||||
import com.project.movienight.application.ports.input.EditFilmUseCase
|
import com.project.movienight.application.ports.input.EditFilmUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetAllFilmsUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetFilmByIdUseCase
|
||||||
|
import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PatchMapping
|
import org.springframework.web.bind.annotation.PatchMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
private fun String.toContentTypeOrFilm(): com.project.movienight.domain.model.ContentType =
|
||||||
|
runCatching {
|
||||||
|
com.project.movienight.domain.model.ContentType
|
||||||
|
.valueOf(this)
|
||||||
|
}.getOrDefault(com.project.movienight.domain.model.ContentType.FILM)
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/films")
|
@RequestMapping("/api/films")
|
||||||
class FilmController(
|
class FilmController(
|
||||||
private val createFilmUseCase: CreateFilmUseCase,
|
private val createFilmUseCase: CreateFilmUseCase,
|
||||||
private val editFilmUseCase: EditFilmUseCase,
|
private val editFilmUseCase: EditFilmUseCase,
|
||||||
private val deleteFilmUseCase: DeleteFilmUseCase,
|
private val deleteFilmUseCase: DeleteFilmUseCase,
|
||||||
|
private val getFilmByIdUseCase: GetFilmByIdUseCase,
|
||||||
|
private val getAllFilmsUseCase: GetAllFilmsUseCase,
|
||||||
|
private val searchFilmByTitleUseCase: SearchFilmByTitleUseCase,
|
||||||
) {
|
) {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@@ -36,6 +51,16 @@ class FilmController(
|
|||||||
CreateFilmCommand(
|
CreateFilmCommand(
|
||||||
title = request.title,
|
title = request.title,
|
||||||
description = request.description,
|
description = request.description,
|
||||||
|
contentType = request.contentType.toContentTypeOrFilm(),
|
||||||
|
releaseYear = request.releaseYear,
|
||||||
|
genres = request.genres,
|
||||||
|
cast = request.cast,
|
||||||
|
directors = request.directors,
|
||||||
|
imdbRating = request.imdbRating,
|
||||||
|
platformRating = request.platformRating,
|
||||||
|
externalUrl = request.externalUrl,
|
||||||
|
jellyfinItemId = request.jellyfinItemId,
|
||||||
|
jellyfinLibraryId = request.jellyfinLibraryId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -52,6 +77,16 @@ class FilmController(
|
|||||||
EditFilmCommand(
|
EditFilmCommand(
|
||||||
title = request.title,
|
title = request.title,
|
||||||
description = request.description,
|
description = request.description,
|
||||||
|
contentType = request.contentType.toContentTypeOrFilm(),
|
||||||
|
releaseYear = request.releaseYear,
|
||||||
|
genres = request.genres,
|
||||||
|
cast = request.cast,
|
||||||
|
directors = request.directors,
|
||||||
|
imdbRating = request.imdbRating,
|
||||||
|
platformRating = request.platformRating,
|
||||||
|
externalUrl = request.externalUrl,
|
||||||
|
jellyfinItemId = request.jellyfinItemId,
|
||||||
|
jellyfinLibraryId = request.jellyfinLibraryId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -61,4 +96,24 @@ class FilmController(
|
|||||||
fun delete(
|
fun delete(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
) = deleteFilmUseCase.delete(id)
|
) = deleteFilmUseCase.delete(id)
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun getById(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id))
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun getAll(): List<FilmResponse> = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) }
|
||||||
|
|
||||||
|
@GetMapping("/search")
|
||||||
|
fun searchByTitle(
|
||||||
|
@RequestParam title: String,
|
||||||
|
): ResponseEntity<FilmResponse> {
|
||||||
|
val film = searchFilmByTitleUseCase.searchByTitle(title)
|
||||||
|
return if (film != null) {
|
||||||
|
ResponseEntity.ok(FilmResponse.fromDomain(film))
|
||||||
|
} else {
|
||||||
|
ResponseEntity.notFound().build()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,21 @@ package com.project.movienight.adapters.web
|
|||||||
|
|
||||||
import com.project.movienight.adapters.web.dto.request.CreateFilmLibraryRequest
|
import com.project.movienight.adapters.web.dto.request.CreateFilmLibraryRequest
|
||||||
import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse
|
import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse
|
||||||
|
import com.project.movienight.adapters.web.dto.response.FilmResponse
|
||||||
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
|
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
|
||||||
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
|
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
|
||||||
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
|
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
|
||||||
import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase
|
import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetAllFilmsUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetFilmByIdUseCase
|
||||||
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
|
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
|
||||||
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
|
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
|
||||||
|
import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase
|
||||||
|
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
||||||
|
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
|
||||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
|
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
|
||||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
|
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
|
||||||
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
@@ -26,8 +33,11 @@ import java.util.UUID
|
|||||||
class FilmLibraryController(
|
class FilmLibraryController(
|
||||||
private val createFilmLibraryUseCase: CreateFilmLibraryUseCase,
|
private val createFilmLibraryUseCase: CreateFilmLibraryUseCase,
|
||||||
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
|
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
|
||||||
|
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
|
||||||
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
|
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
|
||||||
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
|
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
|
||||||
|
private val getAllFilmsUseCase: GetAllFilmsUseCase,
|
||||||
|
private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase,
|
||||||
) {
|
) {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@@ -54,6 +64,11 @@ class FilmLibraryController(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@GetMapping("/entries")
|
||||||
|
fun list(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
): List<FilmLibraryResponse> = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) }
|
||||||
|
|
||||||
@PostMapping("/films/{filmId}")
|
@PostMapping("/films/{filmId}")
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
fun addFilm(
|
fun addFilm(
|
||||||
@@ -69,6 +84,20 @@ class FilmLibraryController(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@PostMapping("/films/{filmId}/viewed")
|
||||||
|
fun markViewed(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@PathVariable filmId: UUID,
|
||||||
|
): FilmLibraryResponse =
|
||||||
|
FilmLibraryResponse.fromDomain(
|
||||||
|
markFilmViewedUseCase.markViewed(
|
||||||
|
MarkFilmViewedCommand(
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@DeleteMapping("/films/{filmId}")
|
@DeleteMapping("/films/{filmId}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
fun removeFilm(
|
fun removeFilm(
|
||||||
@@ -82,4 +111,31 @@ class FilmLibraryController(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/available-films")
|
||||||
|
fun getAvailableFilms(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
): List<FilmResponse> {
|
||||||
|
val userLibrary =
|
||||||
|
runCatching {
|
||||||
|
getFilmLibraryUseCase.getLibrary(
|
||||||
|
GetFilmLibraryQuery(userId = userId),
|
||||||
|
)
|
||||||
|
}.onFailure { exception ->
|
||||||
|
if (exception !is EntityNotFoundException) {
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
val allFilms = getAllFilmsUseCase.getAll()
|
||||||
|
|
||||||
|
val availableFilms =
|
||||||
|
if (userLibrary != null) {
|
||||||
|
allFilms.filter { it.id != userLibrary.filmId }
|
||||||
|
} else {
|
||||||
|
allFilms
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableFilms.map { FilmResponse.fromDomain(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
|
||||||
|
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
|
||||||
|
import com.project.movienight.application.ports.input.GetFilmRatingsUseCase
|
||||||
|
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||||
|
import com.project.movienight.application.ports.input.RateFilmUseCase
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/users/{userId}/ratings")
|
||||||
|
class FilmRatingController(
|
||||||
|
private val rateFilmUseCase: RateFilmUseCase,
|
||||||
|
private val getFilmRatingsUseCase: GetFilmRatingsUseCase,
|
||||||
|
) {
|
||||||
|
@PostMapping("/films/{filmId}")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
fun rate(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@PathVariable filmId: UUID,
|
||||||
|
@RequestBody request: RateFilmRequest,
|
||||||
|
): FilmRatingResponse =
|
||||||
|
FilmRatingResponse.fromDomain(
|
||||||
|
rateFilmUseCase.rate(
|
||||||
|
RateFilmCommand(
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
score = request.score,
|
||||||
|
note = request.note,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun list(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
): List<FilmRatingResponse> = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest
|
||||||
|
import com.project.movienight.application.services.JellyfinEventService
|
||||||
|
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/integrations/jellyfin")
|
||||||
|
class JellyfinEventsController(
|
||||||
|
private val jellyfinEventService: JellyfinEventService,
|
||||||
|
private val properties: JellyfinIntegrationProperties,
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
|
||||||
|
|
||||||
|
@PostMapping("/events")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
fun receiveEvent(
|
||||||
|
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||||
|
@RequestBody request: JellyfinEventRequest,
|
||||||
|
) {
|
||||||
|
if (!properties.enabled) {
|
||||||
|
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (properties.pluginToken.isNotBlank()) {
|
||||||
|
if (token == null || token != properties.pluginToken) {
|
||||||
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
"Received Jellyfin event {} for user {} item {}",
|
||||||
|
request.eventId,
|
||||||
|
request.jellyfinUserId,
|
||||||
|
request.itemId,
|
||||||
|
)
|
||||||
|
jellyfinEventService.handleEvent(
|
||||||
|
eventId = request.eventId,
|
||||||
|
serverId = null,
|
||||||
|
eventType = request.eventType,
|
||||||
|
occurredAt = request.occurredAt,
|
||||||
|
jellyfinUserId = request.jellyfinUserId,
|
||||||
|
itemId = request.itemId,
|
||||||
|
payload = request.payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.application.services.JellyfinSyncService
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncState
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/integrations/jellyfin")
|
||||||
|
class JellyfinSyncController(
|
||||||
|
private val jellyfinSyncService: JellyfinSyncService,
|
||||||
|
) {
|
||||||
|
@PostMapping("/sync")
|
||||||
|
fun syncNow(): JellyfinSyncSummary = jellyfinSyncService.syncNow()
|
||||||
|
|
||||||
|
@GetMapping("/sync-state")
|
||||||
|
fun syncState(): List<JellyfinSyncState> = jellyfinSyncService.getSyncStates()
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.response.RecommendationEventResponse
|
||||||
|
import com.project.movienight.adapters.web.dto.response.RecommendationResponse
|
||||||
|
import com.project.movienight.application.ports.input.AcceptRecommendationCommand
|
||||||
|
import com.project.movienight.application.ports.input.AcceptRecommendationUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
|
||||||
|
import com.project.movienight.application.ports.input.RecommendationQuery
|
||||||
|
import com.project.movienight.application.ports.input.RejectRecommendationCommand
|
||||||
|
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
|
||||||
|
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||||
|
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.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/users/{userId}/recommendations")
|
||||||
|
class RecommendationController(
|
||||||
|
private val getRecommendationsUseCase: GetRecommendationsUseCase,
|
||||||
|
private val acceptRecommendationUseCase: AcceptRecommendationUseCase,
|
||||||
|
private val rejectRecommendationUseCase: RejectRecommendationUseCase,
|
||||||
|
private val jellyfinProperties: JellyfinIntegrationProperties,
|
||||||
|
) {
|
||||||
|
@GetMapping
|
||||||
|
fun recommend(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@RequestParam(required = false) contentType: String?,
|
||||||
|
@RequestParam(required = false) mood: String?,
|
||||||
|
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
|
||||||
|
@RequestParam(required = false, defaultValue = "10") limit: Int,
|
||||||
|
): List<RecommendationResponse> =
|
||||||
|
getRecommendationsUseCase
|
||||||
|
.recommend(
|
||||||
|
RecommendationQuery(
|
||||||
|
userId = userId,
|
||||||
|
contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() },
|
||||||
|
mood = mood,
|
||||||
|
libraryOnly = libraryOnly,
|
||||||
|
limit = limit,
|
||||||
|
),
|
||||||
|
).map { recommendation ->
|
||||||
|
RecommendationResponse.fromDomain(
|
||||||
|
recommendation = recommendation,
|
||||||
|
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{filmId}/accept")
|
||||||
|
fun accept(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@PathVariable filmId: UUID,
|
||||||
|
): RecommendationEventResponse =
|
||||||
|
RecommendationEventResponse.fromDomain(
|
||||||
|
acceptRecommendationUseCase.accept(
|
||||||
|
AcceptRecommendationCommand(
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@PostMapping("/{filmId}/reject")
|
||||||
|
fun reject(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@PathVariable filmId: UUID,
|
||||||
|
): RecommendationEventResponse =
|
||||||
|
RecommendationEventResponse.fromDomain(
|
||||||
|
rejectRecommendationUseCase.reject(
|
||||||
|
RejectRecommendationCommand(
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun buildWatchUrl(jellyfinItemId: String?): String? {
|
||||||
|
if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val baseUrl = jellyfinProperties.webUrl.trimEnd('/')
|
||||||
|
val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8)
|
||||||
|
return "$baseUrl/web/#/details?id=$encodedItemId"
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
|
||||||
|
import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse
|
||||||
|
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
|
||||||
|
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import com.project.movienight.domain.model.RecommendationStyle
|
||||||
|
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.RestController
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/users/{userId}/recommendation-onboarding")
|
||||||
|
class RecommendationOnboardingController(
|
||||||
|
private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase,
|
||||||
|
) {
|
||||||
|
@PostMapping
|
||||||
|
fun complete(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@RequestBody request: RecommendationOnboardingRequest,
|
||||||
|
): RecommendationOnboardingResponse =
|
||||||
|
RecommendationOnboardingResponse.fromApplication(
|
||||||
|
completeRecommendationOnboardingUseCase.complete(
|
||||||
|
CompleteRecommendationOnboardingCommand(
|
||||||
|
userId = userId,
|
||||||
|
weightedGenres = request.weightedGenres,
|
||||||
|
plotTypes = request.plotTypes,
|
||||||
|
eras = request.eras,
|
||||||
|
castAndDirectors = request.castAndDirectors,
|
||||||
|
moods = request.moods,
|
||||||
|
contentTypes = request.contentTypes.mapNotNull(::parseContentType),
|
||||||
|
likedFilmIds = request.likedFilmIds,
|
||||||
|
dislikedFilmIds = request.dislikedFilmIds,
|
||||||
|
libraryFilmIds = request.libraryFilmIds,
|
||||||
|
watchedFilmIds = request.watchedFilmIds,
|
||||||
|
recommendationStyle = parseRecommendationStyle(request.recommendationStyle),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseContentType(value: String): ContentType? =
|
||||||
|
runCatching { ContentType.valueOf(value.uppercase(Locale.getDefault())) }.getOrNull()
|
||||||
|
|
||||||
|
private fun parseRecommendationStyle(value: String): RecommendationStyle =
|
||||||
|
runCatching { RecommendationStyle.valueOf(value.uppercase(Locale.getDefault())) }
|
||||||
|
.getOrDefault(RecommendationStyle.BALANCED)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import org.slf4j.MDC
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class TraceIdFilter : OncePerRequestFilter() {
|
||||||
|
override fun doFilterInternal(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
filterChain: FilterChain,
|
||||||
|
) {
|
||||||
|
val traceId = UUID.randomUUID().toString()
|
||||||
|
MDC.put("traceId", traceId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
filterChain.doFilter(request, response)
|
||||||
|
} finally {
|
||||||
|
MDC.remove("traceId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,11 @@ import com.project.movienight.application.ports.input.CreateUserUseCase
|
|||||||
import com.project.movienight.application.ports.input.DeleteUserUseCase
|
import com.project.movienight.application.ports.input.DeleteUserUseCase
|
||||||
import com.project.movienight.application.ports.input.EditUserCommand
|
import com.project.movienight.application.ports.input.EditUserCommand
|
||||||
import com.project.movienight.application.ports.input.EditUserUseCase
|
import com.project.movienight.application.ports.input.EditUserUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetAllUsersUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetUserByIdUseCase
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PatchMapping
|
import org.springframework.web.bind.annotation.PatchMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
@@ -25,6 +28,8 @@ class UserController(
|
|||||||
private val createUserUseCase: CreateUserUseCase,
|
private val createUserUseCase: CreateUserUseCase,
|
||||||
private val editUserUseCase: EditUserUseCase,
|
private val editUserUseCase: EditUserUseCase,
|
||||||
private val deleteUserUseCase: DeleteUserUseCase,
|
private val deleteUserUseCase: DeleteUserUseCase,
|
||||||
|
private val getUserByIdUseCase: GetUserByIdUseCase,
|
||||||
|
private val getAllUsersUseCase: GetAllUsersUseCase,
|
||||||
) {
|
) {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@@ -40,6 +45,14 @@ class UserController(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun getAll(): List<UserResponse> = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) }
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun getById(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id))
|
||||||
|
|
||||||
@PatchMapping("/{id}")
|
@PatchMapping("/{id}")
|
||||||
fun edit(
|
fun edit(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@@ -51,6 +64,7 @@ class UserController(
|
|||||||
command =
|
command =
|
||||||
EditUserCommand(
|
EditUserCommand(
|
||||||
name = request.name,
|
name = request.name,
|
||||||
|
jellyfinUserId = request.jellyfinUserId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest
|
||||||
|
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
|
||||||
|
import com.project.movienight.application.ports.input.GetUserPreferencesUseCase
|
||||||
|
import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
|
||||||
|
import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/users/{userId}/preferences")
|
||||||
|
class UserPreferencesController(
|
||||||
|
private val upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase,
|
||||||
|
private val getUserPreferencesUseCase: GetUserPreferencesUseCase,
|
||||||
|
) {
|
||||||
|
@PutMapping
|
||||||
|
fun upsert(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@RequestBody request: UpsertUserPreferencesRequest,
|
||||||
|
): UserPreferencesResponse =
|
||||||
|
UserPreferencesResponse.fromDomain(
|
||||||
|
upsertUserPreferencesUseCase.upsert(
|
||||||
|
UpsertUserPreferencesCommand(
|
||||||
|
userId = userId,
|
||||||
|
weightedGenres = request.weightedGenres,
|
||||||
|
plotTypes = request.plotTypes,
|
||||||
|
eras = request.eras,
|
||||||
|
castAndDirectors = request.castAndDirectors,
|
||||||
|
moods = request.moods,
|
||||||
|
contentTypes =
|
||||||
|
request.contentTypes.mapNotNull {
|
||||||
|
runCatching {
|
||||||
|
ContentType.valueOf(
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun get(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
): UserPreferencesResponse? = getUserPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) }
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.request.UpdateUserRecommendationWeightsRequest
|
||||||
|
import com.project.movienight.adapters.web.dto.response.UserRecommendationWeightsResponse
|
||||||
|
import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase
|
||||||
|
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand
|
||||||
|
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase
|
||||||
|
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}/recommendation-weights")
|
||||||
|
class UserRecommendationWeightsController(
|
||||||
|
private val getUserRecommendationWeightsUseCase: GetUserRecommendationWeightsUseCase,
|
||||||
|
private val updateUserRecommendationWeightsUseCase: UpdateUserRecommendationWeightsUseCase,
|
||||||
|
) {
|
||||||
|
@GetMapping
|
||||||
|
fun get(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
): UserRecommendationWeightsResponse =
|
||||||
|
UserRecommendationWeightsResponse.fromDomain(
|
||||||
|
getUserRecommendationWeightsUseCase.get(userId),
|
||||||
|
)
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
fun update(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@RequestBody request: UpdateUserRecommendationWeightsRequest,
|
||||||
|
): UserRecommendationWeightsResponse =
|
||||||
|
UserRecommendationWeightsResponse.fromDomain(
|
||||||
|
updateUserRecommendationWeightsUseCase.update(
|
||||||
|
UpdateUserRecommendationWeightsCommand(
|
||||||
|
userId = userId,
|
||||||
|
relevanceWeight = request.relevanceWeight,
|
||||||
|
qualityWeight = request.qualityWeight,
|
||||||
|
contextWeight = request.contextWeight,
|
||||||
|
noveltyWeight = request.noveltyWeight,
|
||||||
|
diversityWeight = request.diversityWeight,
|
||||||
|
genreVectorWeight = request.genreVectorWeight,
|
||||||
|
plotVectorWeight = request.plotVectorWeight,
|
||||||
|
moodVectorWeight = request.moodVectorWeight,
|
||||||
|
eraVectorWeight = request.eraVectorWeight,
|
||||||
|
peopleVectorWeight = request.peopleVectorWeight,
|
||||||
|
contentTypeVectorWeight = request.contentTypeVectorWeight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request
|
|||||||
data class CreateFilmRequest(
|
data class CreateFilmRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
|
val contentType: String = "FILM",
|
||||||
|
val releaseYear: Int? = null,
|
||||||
|
val genres: List<String> = emptyList(),
|
||||||
|
val cast: List<String> = emptyList(),
|
||||||
|
val directors: List<String> = emptyList(),
|
||||||
|
val imdbRating: Double? = null,
|
||||||
|
val platformRating: Double? = null,
|
||||||
|
val externalUrl: String? = null,
|
||||||
|
val jellyfinItemId: String? = null,
|
||||||
|
val jellyfinLibraryId: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request
|
|||||||
data class EditFilmRequest(
|
data class EditFilmRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
|
val contentType: String = "FILM",
|
||||||
|
val releaseYear: Int? = null,
|
||||||
|
val genres: List<String> = emptyList(),
|
||||||
|
val cast: List<String> = emptyList(),
|
||||||
|
val directors: List<String> = emptyList(),
|
||||||
|
val imdbRating: Double? = null,
|
||||||
|
val platformRating: Double? = null,
|
||||||
|
val externalUrl: String? = null,
|
||||||
|
val jellyfinItemId: String? = null,
|
||||||
|
val jellyfinLibraryId: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ package com.project.movienight.adapters.web.dto.request
|
|||||||
|
|
||||||
data class EditUserRequest(
|
data class EditUserRequest(
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val jellyfinUserId: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.request
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
data class JellyfinEventRequest(
|
||||||
|
@JsonProperty("event_id")
|
||||||
|
val eventId: String,
|
||||||
|
@JsonProperty("event_type")
|
||||||
|
val eventType: String,
|
||||||
|
@JsonProperty("occurred_at")
|
||||||
|
val occurredAt: OffsetDateTime,
|
||||||
|
@JsonProperty("jellyfin_user_id")
|
||||||
|
val jellyfinUserId: String,
|
||||||
|
@JsonProperty("item_id")
|
||||||
|
val itemId: String,
|
||||||
|
@JsonProperty("payload_version")
|
||||||
|
val payloadVersion: Int = 1,
|
||||||
|
@JsonProperty("payload")
|
||||||
|
val payload: Map<String, Any>? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.request
|
||||||
|
|
||||||
|
data class RateFilmRequest(
|
||||||
|
val score: Int,
|
||||||
|
val note: String? = null,
|
||||||
|
)
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.request
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class RecommendationOnboardingRequest(
|
||||||
|
val weightedGenres: Map<String, Int> = emptyMap(),
|
||||||
|
val plotTypes: List<String> = emptyList(),
|
||||||
|
val eras: List<String> = emptyList(),
|
||||||
|
val castAndDirectors: List<String> = emptyList(),
|
||||||
|
val moods: List<String> = emptyList(),
|
||||||
|
val contentTypes: List<String> = emptyList(),
|
||||||
|
val likedFilmIds: List<UUID> = emptyList(),
|
||||||
|
val dislikedFilmIds: List<UUID> = emptyList(),
|
||||||
|
val libraryFilmIds: List<UUID> = emptyList(),
|
||||||
|
val watchedFilmIds: List<UUID> = emptyList(),
|
||||||
|
val recommendationStyle: String = "BALANCED",
|
||||||
|
)
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.request
|
||||||
|
|
||||||
|
data class UpdateUserRecommendationWeightsRequest(
|
||||||
|
val relevanceWeight: Double,
|
||||||
|
val qualityWeight: Double,
|
||||||
|
val contextWeight: Double,
|
||||||
|
val noveltyWeight: Double,
|
||||||
|
val diversityWeight: Double,
|
||||||
|
val genreVectorWeight: Double,
|
||||||
|
val plotVectorWeight: Double,
|
||||||
|
val moodVectorWeight: Double,
|
||||||
|
val eraVectorWeight: Double,
|
||||||
|
val peopleVectorWeight: Double,
|
||||||
|
val contentTypeVectorWeight: Double,
|
||||||
|
)
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.request
|
||||||
|
|
||||||
|
data class UpsertUserPreferencesRequest(
|
||||||
|
val weightedGenres: Map<String, Int> = emptyMap(),
|
||||||
|
val plotTypes: List<String> = emptyList(),
|
||||||
|
val eras: List<String> = emptyList(),
|
||||||
|
val castAndDirectors: List<String> = emptyList(),
|
||||||
|
val moods: List<String> = emptyList(),
|
||||||
|
val contentTypes: List<String> = emptyList(),
|
||||||
|
)
|
||||||
+2
@@ -9,6 +9,7 @@ data class FilmLibraryResponse(
|
|||||||
val filmId: UUID,
|
val filmId: UUID,
|
||||||
val comment: String?,
|
val comment: String?,
|
||||||
val isViewed: Boolean,
|
val isViewed: Boolean,
|
||||||
|
val watchedAt: java.time.LocalDateTime?,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse =
|
fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse =
|
||||||
@@ -18,6 +19,7 @@ data class FilmLibraryResponse(
|
|||||||
filmId = filmLibrary.filmId,
|
filmId = filmLibrary.filmId,
|
||||||
comment = filmLibrary.comment,
|
comment = filmLibrary.comment,
|
||||||
isViewed = filmLibrary.isViewed,
|
isViewed = filmLibrary.isViewed,
|
||||||
|
watchedAt = filmLibrary.watchedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.response
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.FilmRating
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class FilmRatingResponse(
|
||||||
|
val id: UUID,
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
val score: Int,
|
||||||
|
val note: String?,
|
||||||
|
val createdAt: LocalDateTime,
|
||||||
|
val updatedAt: LocalDateTime,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromDomain(rating: FilmRating): FilmRatingResponse =
|
||||||
|
FilmRatingResponse(
|
||||||
|
id = rating.id,
|
||||||
|
userId = rating.userId,
|
||||||
|
filmId = rating.filmId,
|
||||||
|
score = rating.score,
|
||||||
|
note = rating.note,
|
||||||
|
createdAt = rating.createdAt,
|
||||||
|
updatedAt = rating.updatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.project.movienight.adapters.web.dto.response
|
package com.project.movienight.adapters.web.dto.response
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
import com.project.movienight.domain.model.Film
|
import com.project.movienight.domain.model.Film
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -7,6 +8,16 @@ data class FilmResponse(
|
|||||||
val id: UUID,
|
val id: UUID,
|
||||||
val title: String,
|
val title: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
|
val contentType: ContentType,
|
||||||
|
val releaseYear: Int?,
|
||||||
|
val genres: List<String>,
|
||||||
|
val cast: List<String>,
|
||||||
|
val directors: List<String>,
|
||||||
|
val imdbRating: Double?,
|
||||||
|
val platformRating: Double?,
|
||||||
|
val externalUrl: String?,
|
||||||
|
val jellyfinItemId: String?,
|
||||||
|
val jellyfinLibraryId: String?,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromDomain(film: Film): FilmResponse =
|
fun fromDomain(film: Film): FilmResponse =
|
||||||
@@ -14,6 +25,16 @@ data class FilmResponse(
|
|||||||
id = film.id,
|
id = film.id,
|
||||||
title = film.title,
|
title = film.title,
|
||||||
description = film.description,
|
description = film.description,
|
||||||
|
contentType = film.contentType,
|
||||||
|
releaseYear = film.releaseYear,
|
||||||
|
genres = film.genres,
|
||||||
|
cast = film.cast,
|
||||||
|
directors = film.directors,
|
||||||
|
imdbRating = film.imdbRating,
|
||||||
|
platformRating = film.platformRating,
|
||||||
|
externalUrl = film.externalUrl,
|
||||||
|
jellyfinItemId = film.jellyfinItemId,
|
||||||
|
jellyfinLibraryId = film.jellyfinLibraryId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.response
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.RecommendationEvent
|
||||||
|
import com.project.movienight.domain.model.RecommendationEventType
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class RecommendationEventResponse(
|
||||||
|
val id: UUID,
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
val eventType: RecommendationEventType,
|
||||||
|
val score: Double?,
|
||||||
|
val relevanceScore: Double?,
|
||||||
|
val qualityScore: Double?,
|
||||||
|
val contextScore: Double?,
|
||||||
|
val noveltyScore: Double?,
|
||||||
|
val diversityScore: Double?,
|
||||||
|
val createdAt: LocalDateTime,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromDomain(event: RecommendationEvent): RecommendationEventResponse =
|
||||||
|
RecommendationEventResponse(
|
||||||
|
id = event.id,
|
||||||
|
userId = event.userId,
|
||||||
|
filmId = event.filmId,
|
||||||
|
eventType = event.eventType,
|
||||||
|
score = event.score,
|
||||||
|
relevanceScore = event.relevanceScore,
|
||||||
|
qualityScore = event.qualityScore,
|
||||||
|
contextScore = event.contextScore,
|
||||||
|
noveltyScore = event.noveltyScore,
|
||||||
|
diversityScore = event.diversityScore,
|
||||||
|
createdAt = event.createdAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.response
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.input.RecommendationOnboardingResult
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class RecommendationOnboardingResponse(
|
||||||
|
val userId: UUID,
|
||||||
|
val preferences: UserPreferencesResponse,
|
||||||
|
val weights: UserRecommendationWeightsResponse,
|
||||||
|
val likedFilmsCount: Int,
|
||||||
|
val dislikedFilmsCount: Int,
|
||||||
|
val libraryFilmsCount: Int,
|
||||||
|
val watchedFilmsCount: Int,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromApplication(result: RecommendationOnboardingResult): RecommendationOnboardingResponse =
|
||||||
|
RecommendationOnboardingResponse(
|
||||||
|
userId = result.userId,
|
||||||
|
preferences = UserPreferencesResponse.fromDomain(result.preferences),
|
||||||
|
weights = UserRecommendationWeightsResponse.fromDomain(result.weights),
|
||||||
|
likedFilmsCount = result.likedFilmsCount,
|
||||||
|
dislikedFilmsCount = result.dislikedFilmsCount,
|
||||||
|
libraryFilmsCount = result.libraryFilmsCount,
|
||||||
|
watchedFilmsCount = result.watchedFilmsCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.response
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.RecommendationResult
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class RecommendationResponse(
|
||||||
|
val filmId: UUID,
|
||||||
|
val title: String,
|
||||||
|
val score: Double,
|
||||||
|
val reasons: List<String>,
|
||||||
|
val jellyfinItemId: String?,
|
||||||
|
val watchUrl: String?,
|
||||||
|
val film: FilmResponse,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromDomain(
|
||||||
|
recommendation: RecommendationResult,
|
||||||
|
watchUrl: String?,
|
||||||
|
): RecommendationResponse {
|
||||||
|
val film = recommendation.film
|
||||||
|
return RecommendationResponse(
|
||||||
|
filmId = film.id,
|
||||||
|
title = film.title,
|
||||||
|
score = recommendation.score,
|
||||||
|
reasons = recommendation.reasons,
|
||||||
|
jellyfinItemId = film.jellyfinItemId,
|
||||||
|
watchUrl = watchUrl,
|
||||||
|
film = FilmResponse.fromDomain(film),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.response
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class UserPreferencesResponse(
|
||||||
|
val userId: UUID,
|
||||||
|
val weightedGenres: Map<String, Int>,
|
||||||
|
val plotTypes: List<String>,
|
||||||
|
val eras: List<String>,
|
||||||
|
val castAndDirectors: List<String>,
|
||||||
|
val moods: List<String>,
|
||||||
|
val contentTypes: List<ContentType>,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromDomain(preferences: UserPreferences): UserPreferencesResponse =
|
||||||
|
UserPreferencesResponse(
|
||||||
|
userId = preferences.userId,
|
||||||
|
weightedGenres = preferences.weightedGenres,
|
||||||
|
plotTypes = preferences.plotTypes,
|
||||||
|
eras = preferences.eras,
|
||||||
|
castAndDirectors = preferences.castAndDirectors,
|
||||||
|
moods = preferences.moods,
|
||||||
|
contentTypes = preferences.contentTypes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.response
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class UserRecommendationWeightsResponse(
|
||||||
|
val userId: UUID,
|
||||||
|
val relevanceWeight: Double,
|
||||||
|
val qualityWeight: Double,
|
||||||
|
val contextWeight: Double,
|
||||||
|
val noveltyWeight: Double,
|
||||||
|
val diversityWeight: Double,
|
||||||
|
val genreVectorWeight: Double,
|
||||||
|
val plotVectorWeight: Double,
|
||||||
|
val moodVectorWeight: Double,
|
||||||
|
val eraVectorWeight: Double,
|
||||||
|
val peopleVectorWeight: Double,
|
||||||
|
val contentTypeVectorWeight: Double,
|
||||||
|
val updatedAt: LocalDateTime,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromDomain(weights: UserRecommendationWeights): UserRecommendationWeightsResponse =
|
||||||
|
UserRecommendationWeightsResponse(
|
||||||
|
userId = weights.userId,
|
||||||
|
relevanceWeight = weights.relevanceWeight,
|
||||||
|
qualityWeight = weights.qualityWeight,
|
||||||
|
contextWeight = weights.contextWeight,
|
||||||
|
noveltyWeight = weights.noveltyWeight,
|
||||||
|
diversityWeight = weights.diversityWeight,
|
||||||
|
genreVectorWeight = weights.genreVectorWeight,
|
||||||
|
plotVectorWeight = weights.plotVectorWeight,
|
||||||
|
moodVectorWeight = weights.moodVectorWeight,
|
||||||
|
eraVectorWeight = weights.eraVectorWeight,
|
||||||
|
peopleVectorWeight = weights.peopleVectorWeight,
|
||||||
|
contentTypeVectorWeight = weights.contentTypeVectorWeight,
|
||||||
|
updatedAt = weights.updatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ data class UserResponse(
|
|||||||
val id: UUID,
|
val id: UUID,
|
||||||
val name: String,
|
val name: String,
|
||||||
val email: String,
|
val email: String,
|
||||||
|
val jellyfinUserId: String?,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromDomain(user: User): UserResponse =
|
fun fromDomain(user: User): UserResponse =
|
||||||
@@ -14,6 +15,7 @@ data class UserResponse(
|
|||||||
id = user.id,
|
id = user.id,
|
||||||
name = user.name,
|
name = user.name,
|
||||||
email = user.email,
|
email = user.email,
|
||||||
|
jellyfinUserId = user.jellyfinUserId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.project.movienight.application.ports.input
|
package com.project.movienight.application.ports.input
|
||||||
|
|
||||||
import com.project.movienight.domain.model.FilmLibrary
|
import com.project.movienight.domain.model.FilmLibrary
|
||||||
|
import java.time.LocalDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
interface CreateFilmLibraryUseCase {
|
interface CreateFilmLibraryUseCase {
|
||||||
@@ -21,6 +22,16 @@ data class AddFilmToLibraryCommand(
|
|||||||
val filmId: UUID,
|
val filmId: UUID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface MarkFilmViewedUseCase {
|
||||||
|
fun markViewed(command: MarkFilmViewedCommand): FilmLibrary
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MarkFilmViewedCommand(
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
val watchedAt: LocalDateTime? = null,
|
||||||
|
)
|
||||||
|
|
||||||
interface RemoveFilmFromLibraryUseCase {
|
interface RemoveFilmFromLibraryUseCase {
|
||||||
fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary
|
fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary
|
||||||
}
|
}
|
||||||
@@ -38,3 +49,7 @@ interface GetFilmLibraryUseCase {
|
|||||||
data class GetFilmLibraryQuery(
|
data class GetFilmLibraryQuery(
|
||||||
val userId: UUID,
|
val userId: UUID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface ListFilmLibraryEntriesUseCase {
|
||||||
|
fun list(userId: UUID): List<FilmLibrary>
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.project.movienight.application.ports.input
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.FilmRating
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface RateFilmUseCase {
|
||||||
|
fun rate(command: RateFilmCommand): FilmRating
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RateFilmCommand(
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
val score: Int,
|
||||||
|
val note: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface GetFilmRatingsUseCase {
|
||||||
|
fun getRatings(userId: UUID): List<FilmRating>
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.project.movienight.application.ports.input
|
package com.project.movienight.application.ports.input
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
import com.project.movienight.domain.model.Film
|
import com.project.movienight.domain.model.Film
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -10,6 +11,16 @@ interface CreateFilmUseCase {
|
|||||||
data class CreateFilmCommand(
|
data class CreateFilmCommand(
|
||||||
val title: String,
|
val title: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
|
val contentType: ContentType = ContentType.FILM,
|
||||||
|
val releaseYear: Int? = null,
|
||||||
|
val genres: List<String> = emptyList(),
|
||||||
|
val cast: List<String> = emptyList(),
|
||||||
|
val directors: List<String> = emptyList(),
|
||||||
|
val imdbRating: Double? = null,
|
||||||
|
val platformRating: Double? = null,
|
||||||
|
val externalUrl: String? = null,
|
||||||
|
val jellyfinItemId: String? = null,
|
||||||
|
val jellyfinLibraryId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface EditFilmUseCase {
|
interface EditFilmUseCase {
|
||||||
@@ -22,8 +33,30 @@ interface EditFilmUseCase {
|
|||||||
data class EditFilmCommand(
|
data class EditFilmCommand(
|
||||||
val title: String,
|
val title: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
|
val contentType: ContentType = ContentType.FILM,
|
||||||
|
val releaseYear: Int? = null,
|
||||||
|
val genres: List<String> = emptyList(),
|
||||||
|
val cast: List<String> = emptyList(),
|
||||||
|
val directors: List<String> = emptyList(),
|
||||||
|
val imdbRating: Double? = null,
|
||||||
|
val platformRating: Double? = null,
|
||||||
|
val externalUrl: String? = null,
|
||||||
|
val jellyfinItemId: String? = null,
|
||||||
|
val jellyfinLibraryId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface DeleteFilmUseCase {
|
interface DeleteFilmUseCase {
|
||||||
fun delete(id: UUID)
|
fun delete(id: UUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GetFilmByIdUseCase {
|
||||||
|
fun getById(id: UUID): Film
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetAllFilmsUseCase {
|
||||||
|
fun getAll(): List<Film>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchFilmByTitleUseCase {
|
||||||
|
fun searchByTitle(title: String): Film?
|
||||||
|
}
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.project.movienight.application.ports.input
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import com.project.movienight.domain.model.RecommendationEvent
|
||||||
|
import com.project.movienight.domain.model.RecommendationResult
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface GetRecommendationsUseCase {
|
||||||
|
fun recommend(query: RecommendationQuery): List<RecommendationResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RecommendationQuery(
|
||||||
|
val userId: UUID,
|
||||||
|
val contentType: ContentType? = null,
|
||||||
|
val mood: String? = null,
|
||||||
|
val libraryOnly: Boolean = false,
|
||||||
|
val limit: Int = 10,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface AcceptRecommendationUseCase {
|
||||||
|
fun accept(command: AcceptRecommendationCommand): RecommendationEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AcceptRecommendationCommand(
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface RejectRecommendationUseCase {
|
||||||
|
fun reject(command: RejectRecommendationCommand): RecommendationEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RejectRecommendationCommand(
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
)
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.project.movienight.application.ports.input
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import com.project.movienight.domain.model.RecommendationStyle
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface CompleteRecommendationOnboardingUseCase {
|
||||||
|
fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CompleteRecommendationOnboardingCommand(
|
||||||
|
val userId: UUID,
|
||||||
|
val weightedGenres: Map<String, Int> = emptyMap(),
|
||||||
|
val plotTypes: List<String> = emptyList(),
|
||||||
|
val eras: List<String> = emptyList(),
|
||||||
|
val castAndDirectors: List<String> = emptyList(),
|
||||||
|
val moods: List<String> = emptyList(),
|
||||||
|
val contentTypes: List<ContentType> = emptyList(),
|
||||||
|
val likedFilmIds: List<UUID> = emptyList(),
|
||||||
|
val dislikedFilmIds: List<UUID> = emptyList(),
|
||||||
|
val libraryFilmIds: List<UUID> = emptyList(),
|
||||||
|
val watchedFilmIds: List<UUID> = emptyList(),
|
||||||
|
val recommendationStyle: RecommendationStyle = RecommendationStyle.BALANCED,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RecommendationOnboardingResult(
|
||||||
|
val userId: UUID,
|
||||||
|
val preferences: UserPreferences,
|
||||||
|
val weights: UserRecommendationWeights,
|
||||||
|
val likedFilmsCount: Int,
|
||||||
|
val dislikedFilmsCount: Int,
|
||||||
|
val libraryFilmsCount: Int,
|
||||||
|
val watchedFilmsCount: Int,
|
||||||
|
)
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package com.project.movienight.application.ports.input
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface UpsertUserPreferencesUseCase {
|
||||||
|
fun upsert(command: UpsertUserPreferencesCommand): UserPreferences
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UpsertUserPreferencesCommand(
|
||||||
|
val userId: UUID,
|
||||||
|
val weightedGenres: Map<String, Int> = emptyMap(),
|
||||||
|
val plotTypes: List<String> = emptyList(),
|
||||||
|
val eras: List<String> = emptyList(),
|
||||||
|
val castAndDirectors: List<String> = emptyList(),
|
||||||
|
val moods: List<String> = emptyList(),
|
||||||
|
val contentTypes: List<ContentType> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface GetUserPreferencesUseCase {
|
||||||
|
fun get(userId: UUID): UserPreferences?
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.project.movienight.application.ports.input
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface GetUserRecommendationWeightsUseCase {
|
||||||
|
fun get(userId: UUID): UserRecommendationWeights
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateUserRecommendationWeightsUseCase {
|
||||||
|
fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UpdateUserRecommendationWeightsCommand(
|
||||||
|
val userId: UUID,
|
||||||
|
val relevanceWeight: Double,
|
||||||
|
val qualityWeight: Double,
|
||||||
|
val contextWeight: Double,
|
||||||
|
val noveltyWeight: Double,
|
||||||
|
val diversityWeight: Double,
|
||||||
|
val genreVectorWeight: Double,
|
||||||
|
val plotVectorWeight: Double,
|
||||||
|
val moodVectorWeight: Double,
|
||||||
|
val eraVectorWeight: Double,
|
||||||
|
val peopleVectorWeight: Double,
|
||||||
|
val contentTypeVectorWeight: Double,
|
||||||
|
)
|
||||||
@@ -21,8 +21,17 @@ interface EditUserUseCase {
|
|||||||
|
|
||||||
data class EditUserCommand(
|
data class EditUserCommand(
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val jellyfinUserId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface DeleteUserUseCase {
|
interface DeleteUserUseCase {
|
||||||
fun delete(id: UUID)
|
fun delete(id: UUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GetUserByIdUseCase {
|
||||||
|
fun getById(id: UUID): User
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetAllUsersUseCase {
|
||||||
|
fun getAll(): List<User>
|
||||||
|
}
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.project.movienight.application.ports.input.security
|
||||||
|
|
||||||
|
interface OAuth2UserInfo {
|
||||||
|
fun getProviderId(): String
|
||||||
|
|
||||||
|
fun getEmail(): String
|
||||||
|
|
||||||
|
fun getName(): String
|
||||||
|
|
||||||
|
fun getProvider(): String
|
||||||
|
|
||||||
|
fun getAttributes(): Map<String, Any>
|
||||||
|
}
|
||||||
+5
@@ -8,6 +8,11 @@ interface FilmLibraryRepositoryPort {
|
|||||||
|
|
||||||
fun findById(id: UUID): FilmLibrary?
|
fun findById(id: UUID): FilmLibrary?
|
||||||
|
|
||||||
|
fun findByUserIdAndFilmId(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
): FilmLibrary?
|
||||||
|
|
||||||
fun findAll(): List<FilmLibrary>
|
fun findAll(): List<FilmLibrary>
|
||||||
|
|
||||||
fun deleteById(id: UUID)
|
fun deleteById(id: UUID)
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.project.movienight.application.ports.output
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.FilmRating
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface FilmRatingRepositoryPort {
|
||||||
|
fun save(rating: FilmRating): FilmRating
|
||||||
|
|
||||||
|
fun findByUserId(userId: UUID): List<FilmRating>
|
||||||
|
|
||||||
|
fun findByUserIdAndFilmId(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
): FilmRating?
|
||||||
|
}
|
||||||
@@ -8,7 +8,13 @@ interface FilmRepositoryPort {
|
|||||||
|
|
||||||
fun findById(id: UUID): Film?
|
fun findById(id: UUID): Film?
|
||||||
|
|
||||||
|
fun findByJellyfinItemId(jellyfinItemId: String): Film?
|
||||||
|
|
||||||
|
fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film?
|
||||||
|
|
||||||
fun findAll(): List<Film>
|
fun findAll(): List<Film>
|
||||||
|
|
||||||
|
fun findByTitle(title: String): Film?
|
||||||
|
|
||||||
fun deleteById(id: UUID)
|
fun deleteById(id: UUID)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package com.project.movienight.application.ports.output
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncState
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface JellyfinSyncStateRepositoryPort {
|
||||||
|
fun save(state: JellyfinSyncState): JellyfinSyncState
|
||||||
|
|
||||||
|
fun findByUserId(userId: UUID): JellyfinSyncState?
|
||||||
|
|
||||||
|
fun findAll(): List<JellyfinSyncState>
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.project.movienight.application.ports.output
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.RecommendationEvent
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface RecommendationEventRepositoryPort {
|
||||||
|
fun save(event: RecommendationEvent): RecommendationEvent
|
||||||
|
|
||||||
|
fun findByUserId(userId: UUID): List<RecommendationEvent>
|
||||||
|
|
||||||
|
fun findLatestRecommended(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
): RecommendationEvent?
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package com.project.movienight.application.ports.output
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface UserPreferencesRepositoryPort {
|
||||||
|
fun save(preferences: UserPreferences): UserPreferences
|
||||||
|
|
||||||
|
fun findByUserId(userId: UUID): UserPreferences?
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package com.project.movienight.application.ports.output
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface UserRecommendationWeightsRepositoryPort {
|
||||||
|
fun findByUserId(userId: UUID): UserRecommendationWeights?
|
||||||
|
|
||||||
|
fun save(weights: UserRecommendationWeights): UserRecommendationWeights
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ interface UserRepositoryPort {
|
|||||||
|
|
||||||
fun findById(id: UUID): User?
|
fun findById(id: UUID): User?
|
||||||
|
|
||||||
|
fun findByEmail(email: String): User?
|
||||||
|
|
||||||
fun findAll(): List<User>
|
fun findAll(): List<User>
|
||||||
|
|
||||||
fun deleteById(id: UUID)
|
fun deleteById(id: UUID)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package com.project.movienight.application.services
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||||
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
|
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
|
||||||
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
|
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
|
||||||
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
|
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
|
||||||
import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase
|
import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase
|
||||||
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
|
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
|
||||||
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
|
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
|
||||||
|
import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase
|
||||||
|
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
||||||
|
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
|
||||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
|
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
|
||||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
|
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
|
||||||
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
||||||
@@ -20,70 +24,105 @@ import java.util.UUID
|
|||||||
class FilmLibraryService(
|
class FilmLibraryService(
|
||||||
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||||
private val idGenerator: IdGenerator,
|
private val idGenerator: IdGenerator,
|
||||||
|
private val businessMetricsService: BusinessMetricsService,
|
||||||
) : CreateFilmLibraryUseCase,
|
) : CreateFilmLibraryUseCase,
|
||||||
AddFilmToLibraryUseCase,
|
AddFilmToLibraryUseCase,
|
||||||
|
MarkFilmViewedUseCase,
|
||||||
RemoveFilmFromLibraryUseCase,
|
RemoveFilmFromLibraryUseCase,
|
||||||
GetFilmLibraryUseCase {
|
GetFilmLibraryUseCase,
|
||||||
|
ListFilmLibraryEntriesUseCase {
|
||||||
override fun create(command: CreateFilmLibraryCommand): FilmLibrary {
|
override fun create(command: CreateFilmLibraryCommand): FilmLibrary {
|
||||||
val existingLibrary = findByUserId(command.userId)
|
findByUserId(command.userId)?.let { return it }
|
||||||
if (existingLibrary != null) {
|
throw EntityNotFoundException(entity = "Film library", id = command.userId.toString())
|
||||||
return existingLibrary
|
|
||||||
}
|
|
||||||
|
|
||||||
return filmLibraryRepository.save(
|
|
||||||
FilmLibrary(
|
|
||||||
id = idGenerator.generateId(),
|
|
||||||
userId = command.userId,
|
|
||||||
filmId = idGenerator.generateId(),
|
|
||||||
comment = command.name,
|
|
||||||
isViewed = false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary {
|
override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary {
|
||||||
val existingLibrary = findByUserId(command.userId)
|
val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
|
||||||
if (existingLibrary == null) {
|
if (existingEntry != null) {
|
||||||
return filmLibraryRepository.save(
|
val saved =
|
||||||
|
filmLibraryRepository.save(
|
||||||
|
existingEntry.copy(
|
||||||
|
isViewed = false,
|
||||||
|
watchedAt = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
businessMetricsService.recordLibraryEvent()
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
|
||||||
|
val saved =
|
||||||
|
filmLibraryRepository.save(
|
||||||
FilmLibrary(
|
FilmLibrary(
|
||||||
id = idGenerator.generateId(),
|
id = idGenerator.generateId(),
|
||||||
userId = command.userId,
|
userId = command.userId,
|
||||||
filmId = command.filmId,
|
filmId = command.filmId,
|
||||||
comment = null,
|
comment = null,
|
||||||
isViewed = false,
|
isViewed = false,
|
||||||
|
watchedAt = null,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
businessMetricsService.recordLibraryEvent()
|
||||||
|
return saved
|
||||||
return filmLibraryRepository.save(
|
|
||||||
existingLibrary.copy(
|
|
||||||
filmId = command.filmId,
|
|
||||||
isViewed = false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary {
|
override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary {
|
||||||
val existingLibrary =
|
val existingLibrary =
|
||||||
findByUserId(command.userId)
|
if (command.libraryId != null) {
|
||||||
?: throw EntityNotFoundException(entity = "Film library", id = command.userId.toString())
|
filmLibraryRepository.findById(command.libraryId)
|
||||||
|
?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString())
|
||||||
|
} else {
|
||||||
|
findByUserAndFilmId(command.userId, command.filmId)
|
||||||
|
?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
if (command.libraryId != null && command.libraryId != existingLibrary.id) {
|
if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) {
|
||||||
throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingLibrary.filmId != command.filmId) {
|
|
||||||
throw DomainException("Film with id ${command.filmId} not found in user's library")
|
throw DomainException("Film with id ${command.filmId} not found in user's library")
|
||||||
}
|
}
|
||||||
|
|
||||||
filmLibraryRepository.deleteById(existingLibrary.id)
|
filmLibraryRepository.deleteById(existingLibrary.id)
|
||||||
|
businessMetricsService.recordLibraryEvent()
|
||||||
return existingLibrary
|
return existingLibrary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary {
|
||||||
|
val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
|
||||||
|
val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now()
|
||||||
|
|
||||||
|
val saved =
|
||||||
|
if (existingEntry == null) {
|
||||||
|
filmLibraryRepository.save(
|
||||||
|
FilmLibrary(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
userId = command.userId,
|
||||||
|
filmId = command.filmId,
|
||||||
|
comment = null,
|
||||||
|
isViewed = true,
|
||||||
|
watchedAt = watchedAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
filmLibraryRepository.save(
|
||||||
|
existingEntry.copy(
|
||||||
|
isViewed = true,
|
||||||
|
watchedAt = watchedAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
businessMetricsService.recordLibraryEvent()
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
|
||||||
override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary =
|
override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary =
|
||||||
findByUserId(query.userId)
|
findByUserId(query.userId)
|
||||||
?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString())
|
?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString())
|
||||||
|
|
||||||
|
override fun list(userId: UUID): List<FilmLibrary> = filmLibraryRepository.findAll().filter { it.userId == userId }
|
||||||
|
|
||||||
private fun findByUserId(userId: UUID): FilmLibrary? =
|
private fun findByUserId(userId: UUID): FilmLibrary? =
|
||||||
filmLibraryRepository.findAll().firstOrNull { it.userId == userId }
|
filmLibraryRepository.findAll().firstOrNull { it.userId == userId }
|
||||||
|
|
||||||
|
private fun findByUserAndFilmId(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||||
|
import com.project.movienight.application.ports.input.GetFilmRatingsUseCase
|
||||||
|
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||||
|
import com.project.movienight.application.ports.input.RateFilmUseCase
|
||||||
|
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.IdGenerator
|
||||||
|
import com.project.movienight.domain.exception.DomainException
|
||||||
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
|
import com.project.movienight.domain.model.FilmRating
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class FilmRatingService(
|
||||||
|
private val filmRepository: FilmRepositoryPort,
|
||||||
|
private val filmRatingRepository: FilmRatingRepositoryPort,
|
||||||
|
private val idGenerator: IdGenerator,
|
||||||
|
private val businessMetricsService: BusinessMetricsService,
|
||||||
|
) : RateFilmUseCase,
|
||||||
|
GetFilmRatingsUseCase {
|
||||||
|
override fun rate(command: RateFilmCommand): FilmRating {
|
||||||
|
if (command.score !in 1..10) {
|
||||||
|
throw DomainException("Film rating score must be between 1 and 10")
|
||||||
|
}
|
||||||
|
|
||||||
|
filmRepository.findById(command.filmId)
|
||||||
|
?: throw EntityNotFoundException(entity = "Film", id = command.filmId.toString())
|
||||||
|
|
||||||
|
val existingRating = filmRatingRepository.findByUserIdAndFilmId(command.userId, command.filmId)
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
|
||||||
|
val rating =
|
||||||
|
if (existingRating == null) {
|
||||||
|
FilmRating(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
userId = command.userId,
|
||||||
|
filmId = command.filmId,
|
||||||
|
score = command.score,
|
||||||
|
note = command.note,
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
existingRating.copy(score = command.score, note = command.note, updatedAt = now)
|
||||||
|
}
|
||||||
|
|
||||||
|
val savedRating = filmRatingRepository.save(rating)
|
||||||
|
businessMetricsService.recordRatingSubmitted()
|
||||||
|
return savedRating
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getRatings(userId: UUID): List<FilmRating> = filmRatingRepository.findByUserId(userId)
|
||||||
|
}
|
||||||
@@ -5,12 +5,19 @@ import com.project.movienight.application.ports.input.CreateFilmUseCase
|
|||||||
import com.project.movienight.application.ports.input.DeleteFilmUseCase
|
import com.project.movienight.application.ports.input.DeleteFilmUseCase
|
||||||
import com.project.movienight.application.ports.input.EditFilmCommand
|
import com.project.movienight.application.ports.input.EditFilmCommand
|
||||||
import com.project.movienight.application.ports.input.EditFilmUseCase
|
import com.project.movienight.application.ports.input.EditFilmUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetAllFilmsUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetFilmByIdUseCase
|
||||||
|
import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase
|
||||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||||
import com.project.movienight.application.ports.output.IdGenerator
|
import com.project.movienight.application.ports.output.IdGenerator
|
||||||
import com.project.movienight.config.FilmServiceProperties
|
import com.project.movienight.config.FilmServiceProperties
|
||||||
import com.project.movienight.domain.exception.BlockedValueException
|
import com.project.movienight.domain.exception.BlockedValueException
|
||||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
import com.project.movienight.domain.model.Film
|
import com.project.movienight.domain.model.Film
|
||||||
|
import io.micrometer.core.instrument.Counter
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry
|
||||||
|
import io.micrometer.core.instrument.Timer
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -19,47 +26,181 @@ class FilmService(
|
|||||||
private val filmRepository: FilmRepositoryPort,
|
private val filmRepository: FilmRepositoryPort,
|
||||||
private val idGenerator: IdGenerator,
|
private val idGenerator: IdGenerator,
|
||||||
private val filmConfig: FilmServiceProperties,
|
private val filmConfig: FilmServiceProperties,
|
||||||
|
private val meterRegistry: MeterRegistry,
|
||||||
) : CreateFilmUseCase,
|
) : CreateFilmUseCase,
|
||||||
EditFilmUseCase,
|
EditFilmUseCase,
|
||||||
DeleteFilmUseCase {
|
DeleteFilmUseCase,
|
||||||
override fun create(command: CreateFilmCommand): Film {
|
GetFilmByIdUseCase,
|
||||||
if (filmConfig.isBlocked(command.title)) {
|
GetAllFilmsUseCase,
|
||||||
throw BlockedValueException(target = "Film", field = "title")
|
SearchFilmByTitleUseCase {
|
||||||
}
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
if (filmConfig.isBlocked(command.description)) {
|
|
||||||
throw BlockedValueException(target = "Film", field = "description")
|
|
||||||
}
|
|
||||||
|
|
||||||
val film =
|
override fun create(command: CreateFilmCommand): Film {
|
||||||
Film(
|
val sample = Timer.start(meterRegistry)
|
||||||
id = idGenerator.generateId(),
|
|
||||||
title = command.title,
|
try {
|
||||||
description = command.description,
|
log.debug(
|
||||||
|
"Create film request received: title='{}', descriptionLength={}",
|
||||||
|
command.title,
|
||||||
|
command.description.length,
|
||||||
)
|
)
|
||||||
return filmRepository.save(film)
|
|
||||||
|
if (filmConfig.isBlocked(command.title)) {
|
||||||
|
log.debug("Create film blocked by title policy: title='{}'", command.title)
|
||||||
|
filmBlockedCounter.increment()
|
||||||
|
throw BlockedValueException(target = "Film", field = "title")
|
||||||
|
}
|
||||||
|
if (filmConfig.isBlocked(command.description)) {
|
||||||
|
log.debug("Create film blocked by description policy")
|
||||||
|
filmBlockedCounter.increment()
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
val saved = filmRepository.save(film)
|
||||||
|
filmCreatedCounter.increment()
|
||||||
|
return saved
|
||||||
|
} finally {
|
||||||
|
sample.stop(createFilmTimer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun edit(
|
override fun edit(
|
||||||
id: UUID,
|
id: UUID,
|
||||||
command: EditFilmCommand,
|
command: EditFilmCommand,
|
||||||
): Film {
|
): Film {
|
||||||
if (filmConfig.isBlocked(command.title)) {
|
val sample = Timer.start(meterRegistry)
|
||||||
throw BlockedValueException(target = "Film", field = "title")
|
|
||||||
|
try {
|
||||||
|
log.debug("Edit film with id: {}", id)
|
||||||
|
|
||||||
|
if (filmConfig.isBlocked(command.title)) {
|
||||||
|
log.debug("Edit film blocked by title policy: title='{}'", command.title)
|
||||||
|
filmBlockedCounter.increment()
|
||||||
|
throw BlockedValueException(target = "Film", field = "title")
|
||||||
|
}
|
||||||
|
if (filmConfig.isBlocked(command.description)) {
|
||||||
|
log.debug("Edit film blocked by description policy")
|
||||||
|
filmBlockedCounter.increment()
|
||||||
|
throw BlockedValueException(target = "Film", field = "description")
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
val saved = filmRepository.save(film)
|
||||||
|
filmEditedCounter.increment()
|
||||||
|
return saved
|
||||||
|
} finally {
|
||||||
|
sample.stop(editFilmTimer)
|
||||||
}
|
}
|
||||||
if (filmConfig.isBlocked(command.description)) {
|
|
||||||
throw BlockedValueException(target = "Film", field = "description")
|
|
||||||
}
|
|
||||||
|
|
||||||
var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
|
|
||||||
|
|
||||||
film = film.copy(title = command.title, description = command.description)
|
|
||||||
|
|
||||||
return filmRepository.save(film)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete(id: UUID) {
|
override fun delete(id: UUID) {
|
||||||
|
val sample = Timer.start(meterRegistry)
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.debug("Delete film with id: {}", id)
|
||||||
|
|
||||||
|
val film = filmRepository.findById(id)
|
||||||
|
|
||||||
|
if (film == null) {
|
||||||
|
log.debug("Film not found for delete: id='{}'", id)
|
||||||
|
throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
filmRepository.deleteById(id)
|
||||||
|
|
||||||
|
filmDeletedCounter.increment()
|
||||||
|
|
||||||
|
log.info("Film deleted: id='{}'", id)
|
||||||
|
} finally {
|
||||||
|
sample.stop(deleteFilmTimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getById(id: UUID): Film =
|
||||||
filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
|
filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||||
|
|
||||||
filmRepository.deleteById(id)
|
override fun getAll(): List<Film> = filmRepository.findAll()
|
||||||
}
|
|
||||||
|
override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title)
|
||||||
|
|
||||||
|
private val filmCreatedCounter =
|
||||||
|
Counter
|
||||||
|
.builder("film_created_total")
|
||||||
|
.description("Total number of created films")
|
||||||
|
.register(meterRegistry)
|
||||||
|
|
||||||
|
private val filmEditedCounter =
|
||||||
|
Counter
|
||||||
|
.builder("film_edited_total")
|
||||||
|
.description("Total number of successfully edited films")
|
||||||
|
.register(meterRegistry)
|
||||||
|
|
||||||
|
private val filmDeletedCounter =
|
||||||
|
Counter
|
||||||
|
.builder("film_deleted_total")
|
||||||
|
.description("Total number of successfully deleted films")
|
||||||
|
.register(meterRegistry)
|
||||||
|
|
||||||
|
private val filmBlockedCounter =
|
||||||
|
Counter
|
||||||
|
.builder("films.blocked")
|
||||||
|
.description("Total blocked film operations")
|
||||||
|
.register(meterRegistry)
|
||||||
|
|
||||||
|
private val createFilmTimer =
|
||||||
|
Timer
|
||||||
|
.builder("films.create.duration")
|
||||||
|
.description("Film creation duration")
|
||||||
|
.register(meterRegistry)
|
||||||
|
|
||||||
|
private val editFilmTimer =
|
||||||
|
Timer
|
||||||
|
.builder("films.edit.duration")
|
||||||
|
.description("Film edit duration")
|
||||||
|
.register(meterRegistry)
|
||||||
|
|
||||||
|
private val deleteFilmTimer =
|
||||||
|
Timer
|
||||||
|
.builder("films.delete.duration")
|
||||||
|
.description("Film deletion duration")
|
||||||
|
.register(meterRegistry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||||
|
import com.project.movienight.adapters.persistence.jdbc.JellyfinEventRepository
|
||||||
|
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
||||||
|
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
|
||||||
|
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class JellyfinEventService(
|
||||||
|
private val jellyfinEventRepository: JellyfinEventRepository,
|
||||||
|
private val userRepository: UserRepositoryPort,
|
||||||
|
private val filmRepository: FilmRepositoryPort,
|
||||||
|
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val businessMetricsService: BusinessMetricsService,
|
||||||
|
) {
|
||||||
|
private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed")
|
||||||
|
|
||||||
|
fun handleEvent(
|
||||||
|
eventId: String,
|
||||||
|
serverId: String?,
|
||||||
|
eventType: String,
|
||||||
|
occurredAt: OffsetDateTime,
|
||||||
|
jellyfinUserId: String,
|
||||||
|
itemId: String,
|
||||||
|
payload: Map<String, Any>?,
|
||||||
|
) {
|
||||||
|
val payloadJson = payload?.let { objectMapper.writeValueAsString(it) }
|
||||||
|
val inserted =
|
||||||
|
jellyfinEventRepository.save(
|
||||||
|
eventId = eventId,
|
||||||
|
serverId = serverId,
|
||||||
|
eventType = eventType,
|
||||||
|
occurredAt = occurredAt,
|
||||||
|
jellyfinUserId = jellyfinUserId,
|
||||||
|
jellyfinItemId = itemId,
|
||||||
|
payload = payloadJson,
|
||||||
|
)
|
||||||
|
if (inserted != 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (playbackEventTypes.contains(eventType)) {
|
||||||
|
val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId }
|
||||||
|
if (localUser == null) {
|
||||||
|
jellyfinEventRepository.delete(eventId)
|
||||||
|
businessMetricsService.recordJellyfinUnmappedUser()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val film = filmRepository.findByJellyfinItemId(itemId)
|
||||||
|
if (film == null) {
|
||||||
|
jellyfinEventRepository.delete(eventId)
|
||||||
|
businessMetricsService.recordBackendWriteFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
markFilmViewedUseCase.markViewed(
|
||||||
|
MarkFilmViewedCommand(
|
||||||
|
userId = localUser.id,
|
||||||
|
filmId = film.id,
|
||||||
|
watchedAt = occurredAt.toLocalDateTime(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
businessMetricsService.recordLibraryEvent()
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
|
||||||
|
) {
|
||||||
|
jellyfinEventRepository.delete(eventId)
|
||||||
|
businessMetricsService.recordBackendWriteFailure()
|
||||||
|
throw ex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.jellyfin.JellyfinApiClient
|
||||||
|
import com.project.movienight.adapters.jellyfin.JellyfinLibraryItemSnapshot
|
||||||
|
import com.project.movienight.adapters.jellyfin.JellyfinRemoteUser
|
||||||
|
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||||
|
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.IdGenerator
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
|
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import com.project.movienight.domain.model.Film
|
||||||
|
import com.project.movienight.domain.model.FilmLibrary
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncState
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class JellyfinSyncService(
|
||||||
|
private val properties: JellyfinIntegrationProperties,
|
||||||
|
private val jellyfinApiClient: JellyfinApiClient,
|
||||||
|
private val userRepository: UserRepositoryPort,
|
||||||
|
private val filmRepository: FilmRepositoryPort,
|
||||||
|
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||||
|
private val syncStateRepository: JellyfinSyncStateRepositoryPort,
|
||||||
|
private val idGenerator: IdGenerator,
|
||||||
|
private val businessMetricsService: BusinessMetricsService,
|
||||||
|
) {
|
||||||
|
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
|
||||||
|
fun scheduledSync() {
|
||||||
|
if (properties.enabled) {
|
||||||
|
syncNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncNow(): JellyfinSyncSummary {
|
||||||
|
if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) {
|
||||||
|
return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val startedAt = Instant.now()
|
||||||
|
val remoteUsers = jellyfinApiClient.fetchUsers()
|
||||||
|
val localUsersByJellyfinId =
|
||||||
|
userRepository
|
||||||
|
.findAll()
|
||||||
|
.mapNotNull { user ->
|
||||||
|
user.jellyfinUserId?.let { it to user }
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
var syncedUsers = 0
|
||||||
|
var skippedUsers = 0
|
||||||
|
var syncedItems = 0
|
||||||
|
|
||||||
|
remoteUsers.forEach { remoteUser ->
|
||||||
|
val localUser = localUsersByJellyfinId[remoteUser.id]
|
||||||
|
if (localUser == null) {
|
||||||
|
skippedUsers += 1
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
val items = jellyfinApiClient.fetchLibraryItems(remoteUser.id)
|
||||||
|
items.forEach { item ->
|
||||||
|
syncItem(localUser.id, item)
|
||||||
|
syncedItems += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
syncStateRepository.save(
|
||||||
|
JellyfinSyncState(
|
||||||
|
userId = localUser.id,
|
||||||
|
lastSyncedAt = now,
|
||||||
|
lastSuccessfulSyncAt = now,
|
||||||
|
lastError = null,
|
||||||
|
syncedItemCount = items.size,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
syncedUsers += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val summary =
|
||||||
|
JellyfinSyncSummary(
|
||||||
|
syncedUsers = syncedUsers,
|
||||||
|
skippedUsers = skippedUsers,
|
||||||
|
syncedItems = syncedItems,
|
||||||
|
durationMs = Duration.between(startedAt, Instant.now()).toMillis(),
|
||||||
|
)
|
||||||
|
businessMetricsService.recordJellyfinSync(summary)
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
|
||||||
|
|
||||||
|
private fun syncItem(
|
||||||
|
userId: java.util.UUID,
|
||||||
|
item: JellyfinLibraryItemSnapshot,
|
||||||
|
) {
|
||||||
|
val film =
|
||||||
|
filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy(
|
||||||
|
title = item.title,
|
||||||
|
description = item.description,
|
||||||
|
contentType = item.contentType,
|
||||||
|
releaseYear = item.releaseYear,
|
||||||
|
genres = item.genres,
|
||||||
|
cast = item.cast,
|
||||||
|
directors = item.directors,
|
||||||
|
imdbRating = item.imdbRating,
|
||||||
|
platformRating = item.platformRating,
|
||||||
|
externalUrl = item.externalUrl,
|
||||||
|
jellyfinItemId = item.jellyfinItemId,
|
||||||
|
jellyfinLibraryId = item.jellyfinLibraryId,
|
||||||
|
) ?: Film(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
title = item.title,
|
||||||
|
description = item.description,
|
||||||
|
contentType = item.contentType,
|
||||||
|
releaseYear = item.releaseYear,
|
||||||
|
genres = item.genres,
|
||||||
|
cast = item.cast,
|
||||||
|
directors = item.directors,
|
||||||
|
imdbRating = item.imdbRating,
|
||||||
|
platformRating = item.platformRating,
|
||||||
|
externalUrl = item.externalUrl,
|
||||||
|
jellyfinItemId = item.jellyfinItemId,
|
||||||
|
jellyfinLibraryId = item.jellyfinLibraryId,
|
||||||
|
)
|
||||||
|
|
||||||
|
val savedFilm = filmRepository.save(film)
|
||||||
|
|
||||||
|
if (item.isPlayed) {
|
||||||
|
val watchedAt = LocalDateTime.now()
|
||||||
|
val existingEntry = filmLibraryRepository.findByUserIdAndFilmId(userId, savedFilm.id)
|
||||||
|
filmLibraryRepository.save(
|
||||||
|
existingEntry?.copy(
|
||||||
|
isViewed = true,
|
||||||
|
watchedAt = watchedAt,
|
||||||
|
) ?: FilmLibrary(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
userId = userId,
|
||||||
|
filmId = savedFilm.id,
|
||||||
|
comment = null,
|
||||||
|
isViewed = true,
|
||||||
|
watchedAt = watchedAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
|
||||||
|
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
|
||||||
|
import com.project.movienight.application.ports.input.RecommendationOnboardingResult
|
||||||
|
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.IdGenerator
|
||||||
|
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
|
import com.project.movienight.domain.model.FilmLibrary
|
||||||
|
import com.project.movienight.domain.model.FilmRating
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class RecommendationOnboardingService(
|
||||||
|
private val userRepository: UserRepositoryPort,
|
||||||
|
private val filmRepository: FilmRepositoryPort,
|
||||||
|
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
||||||
|
private val filmRatingRepository: FilmRatingRepositoryPort,
|
||||||
|
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||||
|
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
|
||||||
|
private val idGenerator: IdGenerator,
|
||||||
|
) : CompleteRecommendationOnboardingUseCase {
|
||||||
|
override fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult {
|
||||||
|
userRepository.findById(command.userId)
|
||||||
|
?: throw EntityNotFoundException(entity = "User", id = command.userId.toString())
|
||||||
|
|
||||||
|
val filmIds =
|
||||||
|
(
|
||||||
|
command.likedFilmIds +
|
||||||
|
command.dislikedFilmIds +
|
||||||
|
command.libraryFilmIds +
|
||||||
|
command.watchedFilmIds
|
||||||
|
).distinct()
|
||||||
|
ensureFilmsExist(filmIds)
|
||||||
|
|
||||||
|
val preferences =
|
||||||
|
userPreferencesRepository.save(
|
||||||
|
UserPreferences(
|
||||||
|
userId = command.userId,
|
||||||
|
weightedGenres = command.weightedGenres,
|
||||||
|
plotTypes = command.plotTypes,
|
||||||
|
eras = command.eras,
|
||||||
|
castAndDirectors = command.castAndDirectors,
|
||||||
|
moods = command.moods,
|
||||||
|
contentTypes = command.contentTypes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
command.likedFilmIds.distinct().forEach { filmId ->
|
||||||
|
saveRating(userId = command.userId, filmId = filmId, score = LIKED_SCORE, note = ONBOARDING_LIKED_NOTE)
|
||||||
|
}
|
||||||
|
command.dislikedFilmIds.distinct().forEach { filmId ->
|
||||||
|
saveRating(
|
||||||
|
userId = command.userId,
|
||||||
|
filmId = filmId,
|
||||||
|
score = DISLIKED_SCORE,
|
||||||
|
note = ONBOARDING_DISLIKED_NOTE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
command.libraryFilmIds.distinct().forEach { filmId ->
|
||||||
|
saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = false)
|
||||||
|
}
|
||||||
|
command.watchedFilmIds.distinct().forEach { filmId ->
|
||||||
|
saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val weights =
|
||||||
|
userRecommendationWeightsRepository.save(
|
||||||
|
UserRecommendationWeights.forStyle(
|
||||||
|
userId = command.userId,
|
||||||
|
style = command.recommendationStyle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return RecommendationOnboardingResult(
|
||||||
|
userId = command.userId,
|
||||||
|
preferences = preferences,
|
||||||
|
weights = weights,
|
||||||
|
likedFilmsCount = command.likedFilmIds.distinct().size,
|
||||||
|
dislikedFilmsCount = command.dislikedFilmIds.distinct().size,
|
||||||
|
libraryFilmsCount = command.libraryFilmIds.distinct().size,
|
||||||
|
watchedFilmsCount = command.watchedFilmIds.distinct().size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureFilmsExist(filmIds: List<UUID>) {
|
||||||
|
filmIds.forEach { filmId ->
|
||||||
|
filmRepository.findById(filmId)
|
||||||
|
?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveRating(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
score: Int,
|
||||||
|
note: String,
|
||||||
|
): FilmRating {
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
val existing = filmRatingRepository.findByUserIdAndFilmId(userId, filmId)
|
||||||
|
return filmRatingRepository.save(
|
||||||
|
existing?.copy(score = score, note = note, updatedAt = now)
|
||||||
|
?: FilmRating(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
score = score,
|
||||||
|
note = note,
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveLibraryEntry(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
isViewed: Boolean,
|
||||||
|
): FilmLibrary {
|
||||||
|
val watchedAt = LocalDateTime.now().takeIf { isViewed }
|
||||||
|
val existing = filmLibraryRepository.findByUserIdAndFilmId(userId, filmId)
|
||||||
|
return filmLibraryRepository.save(
|
||||||
|
existing?.copy(isViewed = isViewed, watchedAt = watchedAt)
|
||||||
|
?: FilmLibrary(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
comment = null,
|
||||||
|
isViewed = isViewed,
|
||||||
|
watchedAt = watchedAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val LIKED_SCORE = 10
|
||||||
|
private const val DISLIKED_SCORE = 2
|
||||||
|
private const val ONBOARDING_LIKED_NOTE = "Onboarding liked"
|
||||||
|
private const val ONBOARDING_DISLIKED_NOTE = "Onboarding disliked"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||||
|
import com.project.movienight.application.ports.input.AcceptRecommendationCommand
|
||||||
|
import com.project.movienight.application.ports.input.AcceptRecommendationUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
|
||||||
|
import com.project.movienight.application.ports.input.RecommendationQuery
|
||||||
|
import com.project.movienight.application.ports.input.RejectRecommendationCommand
|
||||||
|
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
|
||||||
|
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.IdGenerator
|
||||||
|
import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
|
import com.project.movienight.domain.model.Film
|
||||||
|
import com.project.movienight.domain.model.FilmLibrary
|
||||||
|
import com.project.movienight.domain.model.FilmRating
|
||||||
|
import com.project.movienight.domain.model.RecommendationEvent
|
||||||
|
import com.project.movienight.domain.model.RecommendationEventType
|
||||||
|
import com.project.movienight.domain.model.RecommendationResult
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class RecommendationService(
|
||||||
|
private val filmRepository: FilmRepositoryPort,
|
||||||
|
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||||
|
private val filmRatingRepository: FilmRatingRepositoryPort,
|
||||||
|
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
||||||
|
private val userRepository: UserRepositoryPort,
|
||||||
|
private val recommendationEventRepository: RecommendationEventRepositoryPort,
|
||||||
|
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
|
||||||
|
private val idGenerator: IdGenerator,
|
||||||
|
private val businessMetricsService: BusinessMetricsService,
|
||||||
|
) : GetRecommendationsUseCase,
|
||||||
|
AcceptRecommendationUseCase,
|
||||||
|
RejectRecommendationUseCase {
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
override fun recommend(query: RecommendationQuery): List<RecommendationResult> {
|
||||||
|
businessMetricsService.recordRecommendationRequest()
|
||||||
|
userRepository.findById(query.userId)
|
||||||
|
?: throw EntityNotFoundException(entity = "User", id = query.userId.toString())
|
||||||
|
|
||||||
|
val preferences = userPreferencesRepository.findByUserId(query.userId)
|
||||||
|
val ratings = filmRatingRepository.findByUserId(query.userId)
|
||||||
|
val libraryEntries = filmLibraryRepository.findAll().filter { it.userId == query.userId }
|
||||||
|
val libraryFilmIds = libraryEntries.map { it.filmId }.toSet()
|
||||||
|
val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet()
|
||||||
|
val films = filmRepository.findAll()
|
||||||
|
val filmsById = films.associateBy { it.id }
|
||||||
|
val weights = findWeights(query.userId)
|
||||||
|
val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById, weights)
|
||||||
|
|
||||||
|
val candidates =
|
||||||
|
films
|
||||||
|
.asSequence()
|
||||||
|
.filter { film -> query.contentType == null || film.contentType == query.contentType }
|
||||||
|
.filter { film -> film.id !in watchedFilmIds }
|
||||||
|
.filter { film -> !query.libraryOnly || film.id in libraryFilmIds }
|
||||||
|
.toList()
|
||||||
|
val scoredCandidates =
|
||||||
|
candidates.map { film ->
|
||||||
|
scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds, weights)
|
||||||
|
}
|
||||||
|
val recommendationComparator =
|
||||||
|
compareByDescending<ScoredRecommendation> { it.result.score }.thenBy {
|
||||||
|
it.result.film.title
|
||||||
|
}
|
||||||
|
val scoredRecommendations =
|
||||||
|
scoredCandidates
|
||||||
|
.sortedWith(recommendationComparator)
|
||||||
|
.take(query.limit.coerceAtLeast(1))
|
||||||
|
|
||||||
|
scoredRecommendations.forEach { recommendation ->
|
||||||
|
saveEvent(
|
||||||
|
userId = query.userId,
|
||||||
|
filmId = recommendation.result.film.id,
|
||||||
|
eventType = RecommendationEventType.RECOMMENDED,
|
||||||
|
score = recommendation.result.score,
|
||||||
|
relevanceScore = recommendation.relevanceScore,
|
||||||
|
qualityScore = recommendation.qualityScore,
|
||||||
|
contextScore = recommendation.contextScore,
|
||||||
|
noveltyScore = recommendation.noveltyScore,
|
||||||
|
diversityScore = recommendation.diversityScore,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
RECOMMENDATION_COMPLETED_LOG,
|
||||||
|
query.userId,
|
||||||
|
query.contentType,
|
||||||
|
!query.mood.isNullOrBlank(),
|
||||||
|
query.libraryOnly,
|
||||||
|
query.limit,
|
||||||
|
candidates.size,
|
||||||
|
scoredRecommendations.size,
|
||||||
|
)
|
||||||
|
if (log.isDebugEnabled) {
|
||||||
|
log.debug(
|
||||||
|
"Recommendation top results: userId='{}', results='{}'",
|
||||||
|
query.userId,
|
||||||
|
scoredRecommendations.joinToString(separator = ",") { "${it.result.film.id}:${it.result.score}" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scoredRecommendations.map { it.result }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun accept(command: AcceptRecommendationCommand): RecommendationEvent =
|
||||||
|
saveFeedbackEvent(
|
||||||
|
userId = command.userId,
|
||||||
|
filmId = command.filmId,
|
||||||
|
eventType = RecommendationEventType.ACCEPTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun reject(command: RejectRecommendationCommand): RecommendationEvent =
|
||||||
|
saveFeedbackEvent(
|
||||||
|
userId = command.userId,
|
||||||
|
filmId = command.filmId,
|
||||||
|
eventType = RecommendationEventType.REJECTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun saveFeedbackEvent(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
eventType: RecommendationEventType,
|
||||||
|
): RecommendationEvent {
|
||||||
|
userRepository.findById(userId)
|
||||||
|
?: throw EntityNotFoundException(entity = "User", id = userId.toString())
|
||||||
|
filmRepository.findById(filmId)
|
||||||
|
?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
|
||||||
|
|
||||||
|
val lastRecommendation = recommendationEventRepository.findLatestRecommended(userId, filmId)
|
||||||
|
val event =
|
||||||
|
saveEvent(
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
eventType = eventType,
|
||||||
|
score = lastRecommendation?.score,
|
||||||
|
relevanceScore = lastRecommendation?.relevanceScore,
|
||||||
|
qualityScore = lastRecommendation?.qualityScore,
|
||||||
|
contextScore = lastRecommendation?.contextScore,
|
||||||
|
noveltyScore = lastRecommendation?.noveltyScore,
|
||||||
|
diversityScore = lastRecommendation?.diversityScore,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (lastRecommendation != null) {
|
||||||
|
updateRecommendationWeights(
|
||||||
|
userId = userId,
|
||||||
|
eventType = eventType,
|
||||||
|
recommendation = lastRecommendation,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
log.info(
|
||||||
|
"Recommendation feedback saved without weight update: userId='{}', filmId='{}', eventType='{}'",
|
||||||
|
userId,
|
||||||
|
filmId,
|
||||||
|
eventType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
RECOMMENDATION_FEEDBACK_SAVED_LOG,
|
||||||
|
userId,
|
||||||
|
filmId,
|
||||||
|
eventType,
|
||||||
|
)
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveEvent(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
eventType: RecommendationEventType,
|
||||||
|
score: Double?,
|
||||||
|
relevanceScore: Double? = null,
|
||||||
|
qualityScore: Double? = null,
|
||||||
|
contextScore: Double? = null,
|
||||||
|
noveltyScore: Double? = null,
|
||||||
|
diversityScore: Double? = null,
|
||||||
|
): RecommendationEvent =
|
||||||
|
recommendationEventRepository.save(
|
||||||
|
RecommendationEvent(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
eventType = eventType,
|
||||||
|
score = score,
|
||||||
|
relevanceScore = relevanceScore,
|
||||||
|
qualityScore = qualityScore,
|
||||||
|
contextScore = contextScore,
|
||||||
|
noveltyScore = noveltyScore,
|
||||||
|
diversityScore = diversityScore,
|
||||||
|
createdAt = LocalDateTime.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun findWeights(userId: UUID): UserRecommendationWeights =
|
||||||
|
(
|
||||||
|
userRecommendationWeightsRepository.findByUserId(userId)
|
||||||
|
?: UserRecommendationWeights.defaultFor(userId)
|
||||||
|
).normalized()
|
||||||
|
|
||||||
|
private fun updateRecommendationWeights(
|
||||||
|
userId: UUID,
|
||||||
|
eventType: RecommendationEventType,
|
||||||
|
recommendation: RecommendationEvent,
|
||||||
|
) {
|
||||||
|
val current = findWeights(userId)
|
||||||
|
val contributions = scoreContributions(recommendation, current) ?: return
|
||||||
|
val direction =
|
||||||
|
when (eventType) {
|
||||||
|
RecommendationEventType.ACCEPTED -> 1.0
|
||||||
|
RecommendationEventType.REJECTED -> -1.0
|
||||||
|
RecommendationEventType.RECOMMENDED -> return
|
||||||
|
}
|
||||||
|
|
||||||
|
val updated =
|
||||||
|
current
|
||||||
|
.copy(
|
||||||
|
relevanceWeight = current.relevanceWeight + direction * LEARNING_RATE * contributions.relevance,
|
||||||
|
qualityWeight = current.qualityWeight + direction * LEARNING_RATE * contributions.quality,
|
||||||
|
contextWeight = current.contextWeight + direction * LEARNING_RATE * contributions.context,
|
||||||
|
noveltyWeight = current.noveltyWeight + direction * LEARNING_RATE * contributions.novelty,
|
||||||
|
diversityWeight = current.diversityWeight + direction * LEARNING_RATE * contributions.diversity,
|
||||||
|
).normalized(updatedAt = LocalDateTime.now())
|
||||||
|
|
||||||
|
val saved = userRecommendationWeightsRepository.save(updated)
|
||||||
|
businessMetricsService.recordRecommendationWeightsUpdated(eventType)
|
||||||
|
log.info(
|
||||||
|
RECOMMENDATION_WEIGHTS_UPDATED_LOG,
|
||||||
|
userId,
|
||||||
|
eventType,
|
||||||
|
current.hashCode(),
|
||||||
|
saved.hashCode(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scoreContributions(
|
||||||
|
recommendation: RecommendationEvent,
|
||||||
|
weights: UserRecommendationWeights,
|
||||||
|
): ScoreContributions? {
|
||||||
|
val rawContributions =
|
||||||
|
listOf(
|
||||||
|
weights.relevanceWeight to recommendation.relevanceScore,
|
||||||
|
weights.qualityWeight to recommendation.qualityScore,
|
||||||
|
weights.contextWeight to recommendation.contextScore,
|
||||||
|
weights.noveltyWeight to recommendation.noveltyScore,
|
||||||
|
weights.diversityWeight to recommendation.diversityScore,
|
||||||
|
).map { (weight, score) ->
|
||||||
|
weight * (score?.takeIf { value -> value.isFinite() }?.coerceAtLeast(0.0) ?: 0.0)
|
||||||
|
}
|
||||||
|
val total = rawContributions.sum()
|
||||||
|
if (total <= 0.0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ScoreContributions(
|
||||||
|
relevance = rawContributions[0] / total,
|
||||||
|
quality = rawContributions[1] / total,
|
||||||
|
context = rawContributions[2] / total,
|
||||||
|
novelty = rawContributions[3] / total,
|
||||||
|
diversity = rawContributions[4] / total,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildUserProfile(
|
||||||
|
preferences: UserPreferences?,
|
||||||
|
ratings: List<FilmRating>,
|
||||||
|
libraryEntries: List<FilmLibrary>,
|
||||||
|
filmsById: Map<UUID, Film>,
|
||||||
|
weights: UserRecommendationWeights,
|
||||||
|
): SparseVector {
|
||||||
|
val profile = MutableSparseVector()
|
||||||
|
|
||||||
|
preferences?.weightedGenres.orEmpty().forEach { (genre, weight) ->
|
||||||
|
profile.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) }
|
||||||
|
}
|
||||||
|
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
|
||||||
|
?.contentTypes
|
||||||
|
.orEmpty()
|
||||||
|
.forEach {
|
||||||
|
profile.add(
|
||||||
|
feature("type", it.name),
|
||||||
|
PREFERENCE_CONTENT_TYPE_WEIGHT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ratings.forEach { rating ->
|
||||||
|
val film = filmsById[rating.filmId] ?: return@forEach
|
||||||
|
val signal = ratingSignal(rating.score)
|
||||||
|
profile.add(buildFilmVector(film, weights).scale(signal))
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryEntries.filterNot { it.isViewed }.forEach { entry ->
|
||||||
|
val film = filmsById[entry.filmId] ?: return@forEach
|
||||||
|
profile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT))
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile.toSparseVector()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scoreFilm(
|
||||||
|
film: Film,
|
||||||
|
query: RecommendationQuery,
|
||||||
|
preferences: UserPreferences?,
|
||||||
|
userProfile: SparseVector,
|
||||||
|
inLibrary: Boolean,
|
||||||
|
weights: UserRecommendationWeights,
|
||||||
|
): ScoredRecommendation {
|
||||||
|
val reasons = mutableListOf<String>()
|
||||||
|
val filmVector = buildFilmVector(film, weights)
|
||||||
|
val preferenceScore = cosineSimilarity(userProfile, filmVector)
|
||||||
|
val qualityScore = qualityScore(film)
|
||||||
|
val contextScore = contextScore(film, query, preferences)
|
||||||
|
val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE
|
||||||
|
val diversityScore = diversityScore(film, preferences)
|
||||||
|
val score =
|
||||||
|
weights.relevanceWeight * preferenceScore +
|
||||||
|
weights.qualityWeight * qualityScore +
|
||||||
|
weights.contextWeight * contextScore +
|
||||||
|
weights.noveltyWeight * noveltyScore +
|
||||||
|
weights.diversityWeight * diversityScore
|
||||||
|
|
||||||
|
if (preferenceScore > STRONG_REASON_THRESHOLD) {
|
||||||
|
reasons += "Similar to user preferences and rating history"
|
||||||
|
}
|
||||||
|
matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre ->
|
||||||
|
reasons += "Matches preferred genre: $genre"
|
||||||
|
}
|
||||||
|
matchingPeople(film, preferences).take(MAX_REASON_ITEMS).forEach { person ->
|
||||||
|
reasons += "Matches preferred cast or director: $person"
|
||||||
|
}
|
||||||
|
query.mood?.takeIf { inferredMoods(film).contains(normalize(it)) }?.let { mood ->
|
||||||
|
reasons += "Matches requested mood: $mood"
|
||||||
|
}
|
||||||
|
film.releaseYear?.let { year ->
|
||||||
|
if (preferences?.eras.orEmpty().any { normalize(it) == normalize(decadeOf(year)) }) {
|
||||||
|
reasons += "Matches preferred era: ${decadeOf(year)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (qualityScore >= QUALITY_REASON_THRESHOLD) {
|
||||||
|
reasons += "High rating signal"
|
||||||
|
}
|
||||||
|
if (inLibrary) {
|
||||||
|
reasons += "Already in user library"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasons.isEmpty()) {
|
||||||
|
reasons += "Baseline recommendation from catalog quality"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScoredRecommendation(
|
||||||
|
result = RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()),
|
||||||
|
relevanceScore = preferenceScore,
|
||||||
|
qualityScore = qualityScore,
|
||||||
|
contextScore = contextScore,
|
||||||
|
noveltyScore = noveltyScore,
|
||||||
|
diversityScore = diversityScore,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildFilmVector(
|
||||||
|
film: Film,
|
||||||
|
weights: UserRecommendationWeights,
|
||||||
|
): SparseVector {
|
||||||
|
val vector = MutableSparseVector()
|
||||||
|
val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() }
|
||||||
|
val plotTokens = tokenize("${film.title} ${film.description}")
|
||||||
|
val moods = inferredMoods(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, "mood", moods, weights.moodVectorWeight)
|
||||||
|
film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) }
|
||||||
|
distribute(vector, "person", people, weights.peopleVectorWeight)
|
||||||
|
|
||||||
|
return vector.toSparseVector()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contextScore(
|
||||||
|
film: Film,
|
||||||
|
query: RecommendationQuery,
|
||||||
|
preferences: UserPreferences?,
|
||||||
|
): Double {
|
||||||
|
var score = 0.0
|
||||||
|
var checks = 0
|
||||||
|
|
||||||
|
query.mood?.let {
|
||||||
|
checks += 1
|
||||||
|
if (inferredMoods(film).contains(normalize(it))) {
|
||||||
|
score += 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preferences?.contentTypes?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
checks += 1
|
||||||
|
if (film.contentType in it) {
|
||||||
|
score += 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preferences?.eras?.takeIf { it.isNotEmpty() }?.let { eras ->
|
||||||
|
film.releaseYear?.let {
|
||||||
|
checks += 1
|
||||||
|
if (eras.any { era -> normalize(era) == normalize(decadeOf(it)) }) {
|
||||||
|
score += 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (checks == 0) BASE_CONTEXT_SCORE else score / checks
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun qualityScore(film: Film): Double {
|
||||||
|
val normalizedRatings =
|
||||||
|
listOfNotNull(
|
||||||
|
film.imdbRating?.let { normalizeRating(it) },
|
||||||
|
film.platformRating?.let { normalizeRating(it) },
|
||||||
|
)
|
||||||
|
return normalizedRatings.averageOrNull() ?: BASE_QUALITY_SCORE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun diversityScore(
|
||||||
|
film: Film,
|
||||||
|
preferences: UserPreferences?,
|
||||||
|
): Double {
|
||||||
|
val preferredGenres =
|
||||||
|
preferences
|
||||||
|
?.weightedGenres
|
||||||
|
.orEmpty()
|
||||||
|
.keys
|
||||||
|
.map(::normalize)
|
||||||
|
.toSet()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inferredMoods(film: Film): Set<String> {
|
||||||
|
val text = normalize("${film.title} ${film.description} ${film.genres.joinToString(" ")}")
|
||||||
|
return moodLexicon
|
||||||
|
.filterValues { keywords -> keywords.any { keyword -> text.contains(keyword) } }
|
||||||
|
.keys
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchingGenres(
|
||||||
|
film: Film,
|
||||||
|
preferences: UserPreferences?,
|
||||||
|
): List<String> {
|
||||||
|
val filmGenres = film.genres.associateBy { normalize(it) }
|
||||||
|
return preferences
|
||||||
|
?.weightedGenres
|
||||||
|
.orEmpty()
|
||||||
|
.keys
|
||||||
|
.map(::normalize)
|
||||||
|
.mapNotNull { filmGenres[it] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchingPeople(
|
||||||
|
film: Film,
|
||||||
|
preferences: UserPreferences?,
|
||||||
|
): List<String> {
|
||||||
|
val people = (film.cast + film.directors).associateBy { normalize(it) }
|
||||||
|
return preferences
|
||||||
|
?.castAndDirectors
|
||||||
|
.orEmpty()
|
||||||
|
.map(::normalize)
|
||||||
|
.mapNotNull { people[it] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun distribute(
|
||||||
|
vector: MutableSparseVector,
|
||||||
|
namespace: String,
|
||||||
|
values: Collection<String>,
|
||||||
|
totalWeight: Double,
|
||||||
|
) {
|
||||||
|
val uniqueValues = values.map(::normalize).filter { it.isNotBlank() }.distinct()
|
||||||
|
if (uniqueValues.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val itemWeight = totalWeight / uniqueValues.size
|
||||||
|
uniqueValues.forEach { vector.add(feature(namespace, it), itemWeight) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ratingSignal(score: Int): Double =
|
||||||
|
when (score.coerceIn(MIN_USER_RATING, MAX_USER_RATING)) {
|
||||||
|
10 -> 1.0
|
||||||
|
9 -> 0.9
|
||||||
|
8 -> 0.7
|
||||||
|
7 -> 0.4
|
||||||
|
6 -> 0.1
|
||||||
|
5 -> 0.0
|
||||||
|
4 -> -0.3
|
||||||
|
3 -> -0.5
|
||||||
|
2 -> -0.8
|
||||||
|
else -> -1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeRating(rating: Double): Double = (rating / MAX_RATING_VALUE).coerceIn(0.0, 1.0)
|
||||||
|
|
||||||
|
private fun decadeOf(year: Int): String = "${year / 10 * 10}s"
|
||||||
|
|
||||||
|
private fun tokenize(text: String): List<String> =
|
||||||
|
normalize(text)
|
||||||
|
.split(tokenSeparatorRegex)
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.length >= MIN_TOKEN_LENGTH }
|
||||||
|
.filterNot { it in stopWords }
|
||||||
|
.distinct()
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
private fun feature(
|
||||||
|
namespace: String,
|
||||||
|
value: String,
|
||||||
|
): String = "$namespace:${normalize(value)}"
|
||||||
|
|
||||||
|
private fun normalize(value: String): String =
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.lowercase(Locale.getDefault())
|
||||||
|
|
||||||
|
private fun cosineSimilarity(
|
||||||
|
left: SparseVector,
|
||||||
|
right: SparseVector,
|
||||||
|
): Double {
|
||||||
|
if (left.values.isEmpty() || right.values.isEmpty()) {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
val dot =
|
||||||
|
left.values
|
||||||
|
.entries
|
||||||
|
.sumOf { (feature, weight) -> weight * (right.values[feature] ?: 0.0) }
|
||||||
|
val leftNorm = sqrt(left.values.values.sumOf { it * it })
|
||||||
|
val rightNorm = sqrt(right.values.values.sumOf { it * it })
|
||||||
|
if (leftNorm == 0.0 || rightNorm == 0.0) {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return dot / (leftNorm * rightNorm)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun roundScore(score: Double): Double =
|
||||||
|
kotlin.math.round(score * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR
|
||||||
|
|
||||||
|
private fun Iterable<Double>.averageOrNull(): Double? {
|
||||||
|
val values = toList()
|
||||||
|
return values.takeIf { it.isNotEmpty() }?.average()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ScoredRecommendation(
|
||||||
|
val result: RecommendationResult,
|
||||||
|
val relevanceScore: Double,
|
||||||
|
val qualityScore: Double,
|
||||||
|
val contextScore: Double,
|
||||||
|
val noveltyScore: Double,
|
||||||
|
val diversityScore: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ScoreContributions(
|
||||||
|
val relevance: Double,
|
||||||
|
val quality: Double,
|
||||||
|
val context: Double,
|
||||||
|
val novelty: Double,
|
||||||
|
val diversity: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class SparseVector(
|
||||||
|
val values: Map<String, Double>,
|
||||||
|
) {
|
||||||
|
fun scale(weight: Double): SparseVector = SparseVector(values.mapValues { it.value * weight })
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MutableSparseVector {
|
||||||
|
private val values = mutableMapOf<String, Double>()
|
||||||
|
|
||||||
|
fun add(
|
||||||
|
feature: String,
|
||||||
|
weight: Double,
|
||||||
|
) {
|
||||||
|
if (weight == 0.0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values[feature] = (values[feature] ?: 0.0) + weight
|
||||||
|
}
|
||||||
|
|
||||||
|
fun add(vector: SparseVector) {
|
||||||
|
vector.values.forEach { (feature, weight) -> add(feature, weight) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toSparseVector(): SparseVector = SparseVector(values.filterValues { it != 0.0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val RECOMMENDATION_COMPLETED_LOG =
|
||||||
|
"Recommendation request completed: userId='{}', contentType='{}', moodPresent={}, " +
|
||||||
|
"libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}"
|
||||||
|
private const val RECOMMENDATION_FEEDBACK_SAVED_LOG =
|
||||||
|
"Recommendation feedback saved: userId='{}', filmId='{}', eventType='{}'"
|
||||||
|
private const val RECOMMENDATION_WEIGHTS_UPDATED_LOG =
|
||||||
|
"Recommendation weights updated: userId='{}', eventType='{}', oldWeightsHash={}, newWeightsHash={}"
|
||||||
|
|
||||||
|
private const val MAX_PREFERENCE_WEIGHT = 5.0
|
||||||
|
private const val MAX_RATING_VALUE = 10.0
|
||||||
|
private const val MIN_USER_RATING = 1
|
||||||
|
private const val MAX_USER_RATING = 10
|
||||||
|
private const val MIN_TOKEN_LENGTH = 3
|
||||||
|
private const val MAX_REASON_ITEMS = 2
|
||||||
|
private const val SCORE_ROUNDING_FACTOR = 1000.0
|
||||||
|
|
||||||
|
private const val PREFERENCE_PLOT_WEIGHT = 0.6
|
||||||
|
private const val PREFERENCE_ERA_WEIGHT = 0.7
|
||||||
|
private const val PREFERENCE_PERSON_WEIGHT = 0.8
|
||||||
|
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 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 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 STRONG_REASON_THRESHOLD = 0.15
|
||||||
|
private const val QUALITY_REASON_THRESHOLD = 0.75
|
||||||
|
|
||||||
|
private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+")
|
||||||
|
private val stopWords =
|
||||||
|
setOf(
|
||||||
|
"and",
|
||||||
|
"the",
|
||||||
|
"for",
|
||||||
|
"with",
|
||||||
|
"about",
|
||||||
|
"into",
|
||||||
|
"from",
|
||||||
|
)
|
||||||
|
private val moodLexicon =
|
||||||
|
mapOf(
|
||||||
|
"tense" to listOf("thriller", "suspense", "tension", "rescue", "crime"),
|
||||||
|
"slow-burn" to listOf("slow", "meditative", "grounded"),
|
||||||
|
"feel-good" to listOf("comedy", "family", "summer", "kind", "warm"),
|
||||||
|
"dark" to listOf("dark", "noir", "horror", "murder", "crime"),
|
||||||
|
"romantic" to listOf("romance", "love", "relationship"),
|
||||||
|
"focused" to listOf("science", "mission", "detective", "investigation", "sci-fi"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase
|
||||||
|
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand
|
||||||
|
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase
|
||||||
|
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class UserRecommendationWeightsService(
|
||||||
|
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
|
||||||
|
private val userRepository: UserRepositoryPort,
|
||||||
|
) : GetUserRecommendationWeightsUseCase,
|
||||||
|
UpdateUserRecommendationWeightsUseCase {
|
||||||
|
override fun get(userId: UUID): UserRecommendationWeights {
|
||||||
|
ensureUserExists(userId)
|
||||||
|
return (
|
||||||
|
userRecommendationWeightsRepository.findByUserId(userId)
|
||||||
|
?: UserRecommendationWeights.defaultFor(userId)
|
||||||
|
).normalized()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights {
|
||||||
|
ensureUserExists(command.userId)
|
||||||
|
return userRecommendationWeightsRepository.save(
|
||||||
|
UserRecommendationWeights(
|
||||||
|
userId = command.userId,
|
||||||
|
relevanceWeight = command.relevanceWeight,
|
||||||
|
qualityWeight = command.qualityWeight,
|
||||||
|
contextWeight = command.contextWeight,
|
||||||
|
noveltyWeight = command.noveltyWeight,
|
||||||
|
diversityWeight = command.diversityWeight,
|
||||||
|
genreVectorWeight = command.genreVectorWeight,
|
||||||
|
plotVectorWeight = command.plotVectorWeight,
|
||||||
|
moodVectorWeight = command.moodVectorWeight,
|
||||||
|
eraVectorWeight = command.eraVectorWeight,
|
||||||
|
peopleVectorWeight = command.peopleVectorWeight,
|
||||||
|
contentTypeVectorWeight = command.contentTypeVectorWeight,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureUserExists(userId: UUID) {
|
||||||
|
userRepository.findById(userId)
|
||||||
|
?: throw EntityNotFoundException(entity = "User", id = userId.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import com.project.movienight.application.ports.input.CreateUserUseCase
|
|||||||
import com.project.movienight.application.ports.input.DeleteUserUseCase
|
import com.project.movienight.application.ports.input.DeleteUserUseCase
|
||||||
import com.project.movienight.application.ports.input.EditUserCommand
|
import com.project.movienight.application.ports.input.EditUserCommand
|
||||||
import com.project.movienight.application.ports.input.EditUserUseCase
|
import com.project.movienight.application.ports.input.EditUserUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetAllUsersUseCase
|
||||||
|
import com.project.movienight.application.ports.input.GetUserByIdUseCase
|
||||||
import com.project.movienight.application.ports.output.IdGenerator
|
import com.project.movienight.application.ports.output.IdGenerator
|
||||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
import com.project.movienight.config.UserServiceProperties
|
import com.project.movienight.config.UserServiceProperties
|
||||||
@@ -21,7 +23,9 @@ class UserService(
|
|||||||
private val userConfig: UserServiceProperties,
|
private val userConfig: UserServiceProperties,
|
||||||
) : CreateUserUseCase,
|
) : CreateUserUseCase,
|
||||||
EditUserUseCase,
|
EditUserUseCase,
|
||||||
DeleteUserUseCase {
|
DeleteUserUseCase,
|
||||||
|
GetUserByIdUseCase,
|
||||||
|
GetAllUsersUseCase {
|
||||||
override fun create(command: CreateUserCommand): User {
|
override fun create(command: CreateUserCommand): User {
|
||||||
if (userConfig.isBlocked(command.name)) {
|
if (userConfig.isBlocked(command.name)) {
|
||||||
throw BlockedValueException(target = "User", field = "name")
|
throw BlockedValueException(target = "User", field = "name")
|
||||||
@@ -33,6 +37,7 @@ class UserService(
|
|||||||
name = command.name,
|
name = command.name,
|
||||||
email = command.email,
|
email = command.email,
|
||||||
library = null,
|
library = null,
|
||||||
|
jellyfinUserId = null,
|
||||||
)
|
)
|
||||||
return userRepository.save(user)
|
return userRepository.save(user)
|
||||||
}
|
}
|
||||||
@@ -47,14 +52,22 @@ class UserService(
|
|||||||
|
|
||||||
var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
|
var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
|
||||||
|
|
||||||
user = user.copy(name = command.name)
|
user =
|
||||||
|
user.copy(
|
||||||
|
name = command.name,
|
||||||
|
jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId,
|
||||||
|
)
|
||||||
|
|
||||||
return userRepository.save(user)
|
return userRepository.save(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete(id: UUID) {
|
override fun delete(id: UUID) {
|
||||||
userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
|
userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
|
||||||
|
|
||||||
userRepository.deleteById(id)
|
userRepository.deleteById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getById(id: UUID): User =
|
||||||
|
userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
|
||||||
|
|
||||||
|
override fun getAll(): List<User> = userRepository.findAll()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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 webUrl: String = "",
|
||||||
|
val apiKey: String = "",
|
||||||
|
val syncIntervalMs: Long = 1_800_000,
|
||||||
|
val requestTimeoutMs: Long = 20_000,
|
||||||
|
val pluginToken: String = "",
|
||||||
|
)
|
||||||
@@ -6,4 +6,21 @@ data class Film(
|
|||||||
val id: UUID,
|
val id: UUID,
|
||||||
val title: String,
|
val title: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
|
val contentType: ContentType = ContentType.FILM,
|
||||||
|
val releaseYear: Int? = null,
|
||||||
|
val genres: List<String> = emptyList(),
|
||||||
|
val cast: List<String> = emptyList(),
|
||||||
|
val directors: List<String> = emptyList(),
|
||||||
|
val imdbRating: Double? = null,
|
||||||
|
val platformRating: Double? = null,
|
||||||
|
val externalUrl: String? = null,
|
||||||
|
val jellyfinItemId: String? = null,
|
||||||
|
val jellyfinLibraryId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class ContentType {
|
||||||
|
FILM,
|
||||||
|
SERIES,
|
||||||
|
EPISODE,
|
||||||
|
OTHER,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.project.movienight.domain.model
|
package com.project.movienight.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class FilmLibrary(
|
data class FilmLibrary(
|
||||||
@@ -8,4 +9,5 @@ data class FilmLibrary(
|
|||||||
val filmId: UUID,
|
val filmId: UUID,
|
||||||
val comment: String?,
|
val comment: String?,
|
||||||
val isViewed: Boolean,
|
val isViewed: Boolean,
|
||||||
|
val watchedAt: LocalDateTime? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.project.movienight.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class FilmRating(
|
||||||
|
val id: UUID,
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
val score: Int,
|
||||||
|
val note: String? = null,
|
||||||
|
val createdAt: LocalDateTime = LocalDateTime.now(),
|
||||||
|
val updatedAt: LocalDateTime = createdAt,
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.project.movienight.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class JellyfinSyncState(
|
||||||
|
val userId: UUID,
|
||||||
|
val lastSyncedAt: LocalDateTime? = null,
|
||||||
|
val lastSuccessfulSyncAt: LocalDateTime? = null,
|
||||||
|
val lastError: String? = null,
|
||||||
|
val syncedItemCount: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class JellyfinSyncSummary(
|
||||||
|
val syncedUsers: Int,
|
||||||
|
val skippedUsers: Int,
|
||||||
|
val syncedItems: Int,
|
||||||
|
val durationMs: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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 libraryOnly: Boolean = false,
|
||||||
|
val limit: Int = 10,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RecommendationResult(
|
||||||
|
val film: Film,
|
||||||
|
val score: Double,
|
||||||
|
val reasons: List<String>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.project.movienight.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class RecommendationEvent(
|
||||||
|
val id: UUID,
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
val eventType: RecommendationEventType,
|
||||||
|
val score: Double? = null,
|
||||||
|
val relevanceScore: Double? = null,
|
||||||
|
val qualityScore: Double? = null,
|
||||||
|
val contextScore: Double? = null,
|
||||||
|
val noveltyScore: Double? = null,
|
||||||
|
val diversityScore: Double? = null,
|
||||||
|
val createdAt: LocalDateTime = LocalDateTime.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class RecommendationEventType {
|
||||||
|
RECOMMENDED,
|
||||||
|
ACCEPTED,
|
||||||
|
REJECTED,
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.project.movienight.domain.model
|
||||||
|
|
||||||
|
enum class RecommendationStyle {
|
||||||
|
BALANCED,
|
||||||
|
QUALITY_FIRST,
|
||||||
|
MOOD_FIRST,
|
||||||
|
DISCOVERY,
|
||||||
|
SIMILAR_TO_FAVORITES,
|
||||||
|
}
|
||||||
@@ -7,4 +7,6 @@ data class User(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val email: String,
|
val email: String,
|
||||||
val library: FilmLibrary?,
|
val library: FilmLibrary?,
|
||||||
|
val preferences: UserPreferences? = null,
|
||||||
|
val jellyfinUserId: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.project.movienight.domain.model
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class UserPreferences(
|
||||||
|
val userId: UUID,
|
||||||
|
val weightedGenres: Map<String, Int> = emptyMap(),
|
||||||
|
val plotTypes: List<String> = emptyList(),
|
||||||
|
val eras: List<String> = emptyList(),
|
||||||
|
val castAndDirectors: List<String> = emptyList(),
|
||||||
|
val moods: List<String> = emptyList(),
|
||||||
|
val contentTypes: List<ContentType> = emptyList(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package com.project.movienight.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class UserRecommendationWeights(
|
||||||
|
val userId: UUID,
|
||||||
|
val relevanceWeight: Double = DEFAULT_RELEVANCE_WEIGHT,
|
||||||
|
val qualityWeight: Double = DEFAULT_QUALITY_WEIGHT,
|
||||||
|
val contextWeight: Double = DEFAULT_CONTEXT_WEIGHT,
|
||||||
|
val noveltyWeight: Double = DEFAULT_NOVELTY_WEIGHT,
|
||||||
|
val diversityWeight: Double = DEFAULT_DIVERSITY_WEIGHT,
|
||||||
|
val genreVectorWeight: Double = DEFAULT_GENRE_VECTOR_WEIGHT,
|
||||||
|
val plotVectorWeight: Double = DEFAULT_PLOT_VECTOR_WEIGHT,
|
||||||
|
val moodVectorWeight: Double = DEFAULT_MOOD_VECTOR_WEIGHT,
|
||||||
|
val eraVectorWeight: Double = DEFAULT_ERA_VECTOR_WEIGHT,
|
||||||
|
val peopleVectorWeight: Double = DEFAULT_PEOPLE_VECTOR_WEIGHT,
|
||||||
|
val contentTypeVectorWeight: Double = DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT,
|
||||||
|
val updatedAt: LocalDateTime = LocalDateTime.now(),
|
||||||
|
) {
|
||||||
|
fun normalized(updatedAt: LocalDateTime = this.updatedAt): UserRecommendationWeights {
|
||||||
|
val scoreWeights =
|
||||||
|
normalizeBounded(
|
||||||
|
values =
|
||||||
|
listOf(
|
||||||
|
relevanceWeight,
|
||||||
|
qualityWeight,
|
||||||
|
contextWeight,
|
||||||
|
noveltyWeight,
|
||||||
|
diversityWeight,
|
||||||
|
),
|
||||||
|
defaults = DEFAULT_SCORE_WEIGHTS,
|
||||||
|
min = MIN_SCORE_WEIGHT,
|
||||||
|
max = MAX_SCORE_WEIGHT,
|
||||||
|
)
|
||||||
|
val vectorWeights =
|
||||||
|
normalizeBounded(
|
||||||
|
values =
|
||||||
|
listOf(
|
||||||
|
genreVectorWeight,
|
||||||
|
plotVectorWeight,
|
||||||
|
moodVectorWeight,
|
||||||
|
eraVectorWeight,
|
||||||
|
peopleVectorWeight,
|
||||||
|
contentTypeVectorWeight,
|
||||||
|
),
|
||||||
|
defaults = DEFAULT_VECTOR_WEIGHTS,
|
||||||
|
min = MIN_VECTOR_WEIGHT,
|
||||||
|
max = MAX_VECTOR_WEIGHT,
|
||||||
|
)
|
||||||
|
|
||||||
|
return copy(
|
||||||
|
relevanceWeight = scoreWeights[0],
|
||||||
|
qualityWeight = scoreWeights[1],
|
||||||
|
contextWeight = scoreWeights[2],
|
||||||
|
noveltyWeight = scoreWeights[3],
|
||||||
|
diversityWeight = scoreWeights[4],
|
||||||
|
genreVectorWeight = vectorWeights[0],
|
||||||
|
plotVectorWeight = vectorWeights[1],
|
||||||
|
moodVectorWeight = vectorWeights[2],
|
||||||
|
eraVectorWeight = vectorWeights[3],
|
||||||
|
peopleVectorWeight = vectorWeights[4],
|
||||||
|
contentTypeVectorWeight = vectorWeights[5],
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_RELEVANCE_WEIGHT = 0.55
|
||||||
|
const val DEFAULT_QUALITY_WEIGHT = 0.15
|
||||||
|
const val DEFAULT_CONTEXT_WEIGHT = 0.10
|
||||||
|
const val DEFAULT_NOVELTY_WEIGHT = 0.10
|
||||||
|
const val DEFAULT_DIVERSITY_WEIGHT = 0.10
|
||||||
|
|
||||||
|
const val DEFAULT_GENRE_VECTOR_WEIGHT = 0.25
|
||||||
|
const val DEFAULT_PLOT_VECTOR_WEIGHT = 0.35
|
||||||
|
const val DEFAULT_MOOD_VECTOR_WEIGHT = 0.15
|
||||||
|
const val DEFAULT_ERA_VECTOR_WEIGHT = 0.10
|
||||||
|
const val DEFAULT_PEOPLE_VECTOR_WEIGHT = 0.10
|
||||||
|
const val DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT = 0.05
|
||||||
|
|
||||||
|
const val MIN_SCORE_WEIGHT = 0.05
|
||||||
|
const val MAX_SCORE_WEIGHT = 0.75
|
||||||
|
const val MIN_VECTOR_WEIGHT = 0.03
|
||||||
|
const val MAX_VECTOR_WEIGHT = 0.60
|
||||||
|
|
||||||
|
private val DEFAULT_SCORE_WEIGHTS =
|
||||||
|
listOf(
|
||||||
|
DEFAULT_RELEVANCE_WEIGHT,
|
||||||
|
DEFAULT_QUALITY_WEIGHT,
|
||||||
|
DEFAULT_CONTEXT_WEIGHT,
|
||||||
|
DEFAULT_NOVELTY_WEIGHT,
|
||||||
|
DEFAULT_DIVERSITY_WEIGHT,
|
||||||
|
)
|
||||||
|
private val DEFAULT_VECTOR_WEIGHTS =
|
||||||
|
listOf(
|
||||||
|
DEFAULT_GENRE_VECTOR_WEIGHT,
|
||||||
|
DEFAULT_PLOT_VECTOR_WEIGHT,
|
||||||
|
DEFAULT_MOOD_VECTOR_WEIGHT,
|
||||||
|
DEFAULT_ERA_VECTOR_WEIGHT,
|
||||||
|
DEFAULT_PEOPLE_VECTOR_WEIGHT,
|
||||||
|
DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun defaultFor(userId: UUID): UserRecommendationWeights = UserRecommendationWeights(userId = userId)
|
||||||
|
|
||||||
|
fun forStyle(
|
||||||
|
userId: UUID,
|
||||||
|
style: RecommendationStyle,
|
||||||
|
): UserRecommendationWeights =
|
||||||
|
when (style) {
|
||||||
|
RecommendationStyle.BALANCED -> {
|
||||||
|
defaultFor(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecommendationStyle.QUALITY_FIRST -> {
|
||||||
|
UserRecommendationWeights(
|
||||||
|
userId = userId,
|
||||||
|
relevanceWeight = 0.40,
|
||||||
|
qualityWeight = 0.35,
|
||||||
|
contextWeight = 0.10,
|
||||||
|
noveltyWeight = 0.05,
|
||||||
|
diversityWeight = 0.10,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecommendationStyle.MOOD_FIRST -> {
|
||||||
|
UserRecommendationWeights(
|
||||||
|
userId = userId,
|
||||||
|
relevanceWeight = 0.45,
|
||||||
|
qualityWeight = 0.10,
|
||||||
|
contextWeight = 0.25,
|
||||||
|
noveltyWeight = 0.10,
|
||||||
|
diversityWeight = 0.10,
|
||||||
|
moodVectorWeight = 0.30,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecommendationStyle.DISCOVERY -> {
|
||||||
|
UserRecommendationWeights(
|
||||||
|
userId = userId,
|
||||||
|
relevanceWeight = 0.30,
|
||||||
|
qualityWeight = 0.10,
|
||||||
|
contextWeight = 0.10,
|
||||||
|
noveltyWeight = 0.25,
|
||||||
|
diversityWeight = 0.25,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecommendationStyle.SIMILAR_TO_FAVORITES -> {
|
||||||
|
UserRecommendationWeights(
|
||||||
|
userId = userId,
|
||||||
|
relevanceWeight = 0.70,
|
||||||
|
qualityWeight = 0.10,
|
||||||
|
contextWeight = 0.10,
|
||||||
|
noveltyWeight = 0.05,
|
||||||
|
diversityWeight = 0.05,
|
||||||
|
genreVectorWeight = 0.30,
|
||||||
|
plotVectorWeight = 0.40,
|
||||||
|
peopleVectorWeight = 0.15,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.normalized()
|
||||||
|
|
||||||
|
private fun normalizeBounded(
|
||||||
|
values: List<Double>,
|
||||||
|
defaults: List<Double>,
|
||||||
|
min: Double,
|
||||||
|
max: Double,
|
||||||
|
): List<Double> {
|
||||||
|
val sanitized = values.map { value -> if (value.isFinite() && value > 0.0) value else 0.0 }
|
||||||
|
val source = sanitized.takeIf { it.sum() > 0.0 } ?: defaults
|
||||||
|
val normalized = source.map { it / source.sum() }
|
||||||
|
return projectToBounds(normalized, min, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun projectToBounds(
|
||||||
|
values: List<Double>,
|
||||||
|
min: Double,
|
||||||
|
max: Double,
|
||||||
|
): List<Double> {
|
||||||
|
val result = values.map { it.coerceIn(min, max) }.toMutableList()
|
||||||
|
var iterations = 0
|
||||||
|
var adjusting = true
|
||||||
|
|
||||||
|
while (iterations < values.size * 2 && adjusting) {
|
||||||
|
iterations += 1
|
||||||
|
val diff = 1.0 - result.sum()
|
||||||
|
if (kotlin.math.abs(diff) <= NORMALIZATION_EPSILON) {
|
||||||
|
adjusting = false
|
||||||
|
} else {
|
||||||
|
adjusting = redistribute(result, diff, min, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun redistribute(
|
||||||
|
result: MutableList<Double>,
|
||||||
|
diff: Double,
|
||||||
|
min: Double,
|
||||||
|
max: Double,
|
||||||
|
): Boolean =
|
||||||
|
if (diff > 0.0) {
|
||||||
|
val candidates = result.indices.filter { result[it] < max }
|
||||||
|
val capacity = candidates.sumOf { max - result[it] }
|
||||||
|
if (capacity > 0.0) {
|
||||||
|
candidates.forEach { index ->
|
||||||
|
val increment = diff * ((max - result[index]) / capacity)
|
||||||
|
result[index] = (result[index] + increment).coerceAtMost(max)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val candidates = result.indices.filter { result[it] > min }
|
||||||
|
val capacity = candidates.sumOf { result[it] - min }
|
||||||
|
if (capacity > 0.0) {
|
||||||
|
candidates.forEach { index ->
|
||||||
|
val decrement = -diff * ((result[index] - min) / capacity)
|
||||||
|
result[index] = (result[index] - decrement).coerceAtLeast(min)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val NORMALIZATION_EPSILON = 0.0000001
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user