Hotfix/merge conflicts #60
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import random
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(sys.stdout)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_DATASET_URL = "https://raw.githubusercontent.com/sidooms/MovieTweetings/master/latest/movies.dat"
|
||||||
|
DEFAULT_OUTPUT_DIR = "./Jellyfin_Movies"
|
||||||
|
DEFAULT_COUNT = 1000
|
||||||
|
|
||||||
|
def download_dataset(url):
|
||||||
|
logger.info(f"Downloading dataset from {url}...")
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
data = response.read().decode('utf-8')
|
||||||
|
logger.info("Dataset downloaded successfully.")
|
||||||
|
return data.splitlines()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to download dataset: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def parse_movies(lines):
|
||||||
|
"""
|
||||||
|
Parse the movies.dat file.
|
||||||
|
Format: IMDbID::Title (Year)::Genres
|
||||||
|
Example: 0000008::Edison Kinetoscopic Record of a Sneeze (1894)::Documentary|Short
|
||||||
|
"""
|
||||||
|
movies = []
|
||||||
|
# Regex to extract Title and Year from "Title (Year)"
|
||||||
|
title_year_pattern = re.compile(r'(.*)\s+\((\d{4})\)$')
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split('::')
|
||||||
|
if len(parts) >= 2:
|
||||||
|
imdb_id_raw = parts[0]
|
||||||
|
title_year_raw = parts[1]
|
||||||
|
|
||||||
|
# Format IMDb ID to ttXXXXXXX
|
||||||
|
if imdb_id_raw.isdigit():
|
||||||
|
imdb_id = f"tt{imdb_id_raw.zfill(7)}"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
match = title_year_pattern.match(title_year_raw)
|
||||||
|
if match:
|
||||||
|
title = match.group(1).strip()
|
||||||
|
year = match.group(2)
|
||||||
|
|
||||||
|
# Clean title for filesystem (remove invalid characters)
|
||||||
|
safe_title = re.sub(r'[\\/*?:"<>|]', "", title)
|
||||||
|
safe_title = safe_title.strip()
|
||||||
|
|
||||||
|
if safe_title:
|
||||||
|
movies.append({
|
||||||
|
'imdb_id': imdb_id,
|
||||||
|
'title': safe_title,
|
||||||
|
'year': year
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Parsed {len(movies)} valid movies from dataset.")
|
||||||
|
return movies
|
||||||
|
|
||||||
|
def create_dummy_video(filepath):
|
||||||
|
"""Create a minimal valid dummy video file (mp4)."""
|
||||||
|
try:
|
||||||
|
mp4_header = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom\x00\x00\x00\x00moov\x00\x00\x00\x08mvhd"
|
||||||
|
with open(filepath, 'wb') as f:
|
||||||
|
f.write(mp4_header)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create dummy video {filepath}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def generate_library(movies, output_dir, count):
|
||||||
|
"""Generate the folder structure and dummy files."""
|
||||||
|
if not os.path.exists(output_dir):
|
||||||
|
os.makedirs(output_dir)
|
||||||
|
logger.info(f"Created output directory: {output_dir}")
|
||||||
|
|
||||||
|
generated_imdb_ids = set()
|
||||||
|
created_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
logger.info(f"Starting generation of up to {count} movies...")
|
||||||
|
|
||||||
|
# Shuffle to get diverse movies
|
||||||
|
random.shuffle(movies)
|
||||||
|
|
||||||
|
for movie in movies:
|
||||||
|
if created_count >= count:
|
||||||
|
break
|
||||||
|
|
||||||
|
if movie['imdb_id'] in generated_imdb_ids:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Jellyfin naming convention: Movie Name (year) [imdbid-tt1234567]
|
||||||
|
folder_name = f"{movie['title']} ({movie['year']}) [imdbid-{movie['imdb_id']}]"
|
||||||
|
folder_path = os.path.join(output_dir, folder_name)
|
||||||
|
|
||||||
|
file_name = f"{folder_name}.mp4"
|
||||||
|
file_path = os.path.join(folder_path, file_name)
|
||||||
|
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
skipped_count += 1
|
||||||
|
generated_imdb_ids.add(movie['imdb_id'])
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(folder_path, exist_ok=True)
|
||||||
|
if create_dummy_video(file_path):
|
||||||
|
created_count += 1
|
||||||
|
generated_imdb_ids.add(movie['imdb_id'])
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing {folder_name}: {e}")
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
logger.info("--- Generation Summary ---")
|
||||||
|
logger.info(f"Target count: {count}")
|
||||||
|
logger.info(f"Successfully created: {created_count}")
|
||||||
|
logger.info(f"Skipped (already exists or duplicate): {skipped_count}")
|
||||||
|
logger.info(f"Failed: {failed_count}")
|
||||||
|
|
||||||
|
return created_count > 0
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Generate a dummy Jellyfin movie library.")
|
||||||
|
parser.add_argument("--output-dir", type=str, default=DEFAULT_OUTPUT_DIR,
|
||||||
|
help=f"Directory to create the library in (default: {DEFAULT_OUTPUT_DIR})")
|
||||||
|
parser.add_argument("--count", type=int, default=DEFAULT_COUNT,
|
||||||
|
help=f"Number of movies to generate (default: {DEFAULT_COUNT})")
|
||||||
|
parser.add_argument("--dataset-url", type=str, default=DEFAULT_DATASET_URL,
|
||||||
|
help="URL to the movies.dat file")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
lines = download_dataset(args.dataset_url)
|
||||||
|
if not lines:
|
||||||
|
logger.error("No dataset lines to process.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
movies = parse_movies(lines)
|
||||||
|
|
||||||
|
if not movies:
|
||||||
|
logger.error("No movies parsed from the dataset.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if len(movies) < args.count:
|
||||||
|
logger.warning(f"Requested {args.count} movies, but only {len(movies)} available.")
|
||||||
|
args.count = len(movies)
|
||||||
|
|
||||||
|
success = generate_library(movies, args.output_dir, args.count)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Library generation complete. You can now mount '{os.path.abspath(args.output_dir)}' into Jellyfin.")
|
||||||
|
else:
|
||||||
|
logger.error("Library generation failed.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -2,6 +2,9 @@ package com.project.movienight.adapters.jellyfin
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinRemoteUser
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinSyncSourcePort
|
||||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||||
import com.project.movienight.domain.model.ContentType
|
import com.project.movienight.domain.model.ContentType
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -11,39 +14,18 @@ import java.net.http.HttpRequest
|
|||||||
import java.net.http.HttpResponse
|
import java.net.http.HttpResponse
|
||||||
import java.time.Duration
|
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
|
@Service
|
||||||
class JellyfinApiClient(
|
class JellyfinApiClient(
|
||||||
private val properties: JellyfinIntegrationProperties,
|
private val properties: JellyfinIntegrationProperties,
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
) {
|
) : JellyfinSyncSourcePort {
|
||||||
private val httpClient: HttpClient =
|
private val httpClient: HttpClient =
|
||||||
HttpClient
|
HttpClient
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
|
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun fetchUsers(): List<JellyfinRemoteUser> =
|
override fun fetchUsers(): List<JellyfinRemoteUser> =
|
||||||
request("Users")
|
request("Users")
|
||||||
.asItems()
|
.asItems()
|
||||||
.mapNotNull { node ->
|
.mapNotNull { node ->
|
||||||
@@ -51,7 +33,7 @@ class JellyfinApiClient(
|
|||||||
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
|
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
|
override fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
request(
|
request(
|
||||||
"Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData",
|
"Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.project.movienight.adapters.metrics
|
package com.project.movienight.adapters.metrics
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||||
import com.project.movienight.domain.model.RecommendationEventType
|
import com.project.movienight.domain.model.RecommendationEventType
|
||||||
import io.micrometer.core.instrument.Counter
|
import io.micrometer.core.instrument.Counter
|
||||||
@@ -33,7 +34,7 @@ class BusinessMetricsService(
|
|||||||
|
|
||||||
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
|
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
|
||||||
|
|
||||||
fun recordRecommendationRequest() {
|
override fun recordRecommendationRequest() {
|
||||||
recommendationRequests.increment()
|
recommendationRequests.increment()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,11 +50,11 @@ class BusinessMetricsService(
|
|||||||
ratingsSubmitted.increment()
|
ratingsSubmitted.increment()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun recordLibraryEvent() {
|
override fun recordLibraryEvent() {
|
||||||
libraryEvents.increment()
|
libraryEvents.increment()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun recordJellyfinSync(summary: JellyfinSyncSummary) {
|
override fun recordJellyfinSync(summary: JellyfinSyncSummary) {
|
||||||
jellyfinSyncRuns.increment()
|
jellyfinSyncRuns.increment()
|
||||||
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
|
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
|
||||||
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
|
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
|
||||||
@@ -61,15 +62,15 @@ class BusinessMetricsService(
|
|||||||
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
|
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun recordJellyfinSyncFailure() {
|
override fun recordJellyfinSyncFailure() {
|
||||||
jellyfinSyncFailures.increment()
|
jellyfinSyncFailures.increment()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun recordJellyfinUnmappedUser() {
|
override fun recordJellyfinUnmappedUser() {
|
||||||
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
|
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun recordBackendWriteFailure() {
|
override fun recordBackendWriteFailure() {
|
||||||
backendWriteFailures.increment()
|
backendWriteFailures.increment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -1,5 +1,6 @@
|
|||||||
package com.project.movienight.adapters.persistence.jdbc
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinEventRepositoryPort
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
|
||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
@@ -7,8 +8,8 @@ import org.springframework.stereotype.Repository
|
|||||||
@Repository
|
@Repository
|
||||||
class JellyfinEventRepository(
|
class JellyfinEventRepository(
|
||||||
private val jdbc: NamedParameterJdbcTemplate,
|
private val jdbc: NamedParameterJdbcTemplate,
|
||||||
) {
|
) : JellyfinEventRepositoryPort {
|
||||||
fun save(
|
override fun save(
|
||||||
eventId: String,
|
eventId: String,
|
||||||
serverId: String?,
|
serverId: String?,
|
||||||
eventType: String,
|
eventType: String,
|
||||||
@@ -37,7 +38,7 @@ class JellyfinEventRepository(
|
|||||||
return jdbc.update(sql, params)
|
return jdbc.update(sql, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delete(eventId: String) {
|
override fun delete(eventId: String) {
|
||||||
val sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId"
|
val sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId"
|
||||||
val params = MapSqlParameterSource().addValue("eventId", eventId)
|
val params = MapSqlParameterSource().addValue("eventId", eventId)
|
||||||
jdbc.update(sql, params)
|
jdbc.update(sql, params)
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package com.project.movienight.application.ports.output
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||||
|
|
||||||
|
interface BusinessMetricsPort {
|
||||||
|
fun recordRecommendationRequest()
|
||||||
|
|
||||||
|
fun recordRatingSubmitted()
|
||||||
|
|
||||||
|
fun recordLibraryEvent()
|
||||||
|
|
||||||
|
fun recordJellyfinSync(summary: JellyfinSyncSummary)
|
||||||
|
|
||||||
|
fun recordJellyfinSyncFailure()
|
||||||
|
|
||||||
|
fun recordJellyfinUnmappedUser()
|
||||||
|
|
||||||
|
fun recordBackendWriteFailure()
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.project.movienight.application.ports.output
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
interface JellyfinEventRepositoryPort {
|
||||||
|
fun save(
|
||||||
|
eventId: String,
|
||||||
|
serverId: String?,
|
||||||
|
eventType: String,
|
||||||
|
occurredAt: OffsetDateTime?,
|
||||||
|
jellyfinUserId: String?,
|
||||||
|
jellyfinItemId: String?,
|
||||||
|
payload: String?,
|
||||||
|
): Int
|
||||||
|
|
||||||
|
fun delete(eventId: String)
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
package com.project.movienight.application.ports.output
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface JellyfinSyncSourcePort {
|
||||||
|
fun fetchUsers(): List<JellyfinRemoteUser>
|
||||||
|
|
||||||
|
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot>
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -12,6 +11,7 @@ import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
|||||||
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
|
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.BusinessMetricsPort
|
||||||
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
||||||
import com.project.movienight.application.ports.output.IdGenerator
|
import com.project.movienight.application.ports.output.IdGenerator
|
||||||
import com.project.movienight.domain.exception.DomainException
|
import com.project.movienight.domain.exception.DomainException
|
||||||
@@ -25,7 +25,7 @@ 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,
|
private val businessMetricsService: BusinessMetricsPort,
|
||||||
) : CreateFilmLibraryUseCase,
|
) : CreateFilmLibraryUseCase,
|
||||||
AddFilmToLibraryUseCase,
|
AddFilmToLibraryUseCase,
|
||||||
MarkFilmViewedUseCase,
|
MarkFilmViewedUseCase,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
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.GetFilmRatingsUseCase
|
import com.project.movienight.application.ports.input.GetFilmRatingsUseCase
|
||||||
import com.project.movienight.application.ports.input.RateFilmCommand
|
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||||
import com.project.movienight.application.ports.input.RateFilmUseCase
|
import com.project.movienight.application.ports.input.RateFilmUseCase
|
||||||
|
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||||
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||||
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
|
||||||
@@ -19,7 +19,7 @@ class FilmRatingService(
|
|||||||
private val filmRepository: FilmRepositoryPort,
|
private val filmRepository: FilmRepositoryPort,
|
||||||
private val filmRatingRepository: FilmRatingRepositoryPort,
|
private val filmRatingRepository: FilmRatingRepositoryPort,
|
||||||
private val idGenerator: IdGenerator,
|
private val idGenerator: IdGenerator,
|
||||||
private val businessMetricsService: BusinessMetricsService,
|
private val businessMetricsService: BusinessMetricsPort,
|
||||||
) : RateFilmUseCase,
|
) : RateFilmUseCase,
|
||||||
GetFilmRatingsUseCase {
|
GetFilmRatingsUseCase {
|
||||||
override fun rate(command: RateFilmCommand): FilmRating {
|
override fun rate(command: RateFilmCommand): FilmRating {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.project.movienight.application.services
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
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.MarkFilmViewedCommand
|
||||||
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
|
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
|
||||||
|
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinEventRepositoryPort
|
||||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -12,12 +12,12 @@ import java.time.OffsetDateTime
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
class JellyfinEventService(
|
class JellyfinEventService(
|
||||||
private val jellyfinEventRepository: JellyfinEventRepository,
|
private val jellyfinEventRepository: JellyfinEventRepositoryPort,
|
||||||
private val userRepository: UserRepositoryPort,
|
private val userRepository: UserRepositoryPort,
|
||||||
private val filmRepository: FilmRepositoryPort,
|
private val filmRepository: FilmRepositoryPort,
|
||||||
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
|
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
private val businessMetricsService: BusinessMetricsService,
|
private val businessMetricsService: BusinessMetricsPort,
|
||||||
) {
|
) {
|
||||||
private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed")
|
private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed")
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package com.project.movienight.application.services
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
import com.project.movienight.adapters.jellyfin.JellyfinApiClient
|
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||||
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.FilmLibraryRepositoryPort
|
||||||
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.application.ports.output.JellyfinSyncStateRepositoryPort
|
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinRemoteUser
|
||||||
|
import com.project.movienight.application.ports.output.JellyfinSyncSourcePort
|
||||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||||
import com.project.movienight.domain.model.ContentType
|
import com.project.movienight.domain.model.ContentType
|
||||||
@@ -24,13 +24,13 @@ import java.time.LocalDateTime
|
|||||||
@Service
|
@Service
|
||||||
class JellyfinSyncService(
|
class JellyfinSyncService(
|
||||||
private val properties: JellyfinIntegrationProperties,
|
private val properties: JellyfinIntegrationProperties,
|
||||||
private val jellyfinApiClient: JellyfinApiClient,
|
private val jellyfinApiClient: JellyfinSyncSourcePort,
|
||||||
private val userRepository: UserRepositoryPort,
|
private val userRepository: UserRepositoryPort,
|
||||||
private val filmRepository: FilmRepositoryPort,
|
private val filmRepository: FilmRepositoryPort,
|
||||||
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||||
private val syncStateRepository: JellyfinSyncStateRepositoryPort,
|
private val syncStateRepository: JellyfinSyncStateRepositoryPort,
|
||||||
private val idGenerator: IdGenerator,
|
private val idGenerator: IdGenerator,
|
||||||
private val businessMetricsService: BusinessMetricsService,
|
private val businessMetricsService: BusinessMetricsPort,
|
||||||
) {
|
) {
|
||||||
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
|
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
|
||||||
fun scheduledSync() {
|
fun scheduledSync() {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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,7 @@
|
|||||||
|
package com.project.movienight.domain.model
|
||||||
|
|
||||||
|
data class RecommendationResult(
|
||||||
|
val film: Film,
|
||||||
|
val score: Double,
|
||||||
|
val reasons: List<String>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.get
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc(addFilters = false)
|
||||||
|
class RecommendationControllerTest {
|
||||||
|
@Autowired
|
||||||
|
private lateinit var mockMvc: MockMvc
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invalid content type should return bad request`() {
|
||||||
|
mockMvc
|
||||||
|
.get("/api/users/${UUID.randomUUID()}/recommendations") {
|
||||||
|
param("contentType", "INVALID")
|
||||||
|
}.andExpect {
|
||||||
|
status { isBadRequest() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.input.RecommendationQuery
|
||||||
|
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||||
|
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.Film
|
||||||
|
import com.project.movienight.domain.model.FilmRating
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class RecommendationServiceTest {
|
||||||
|
private val filmRepository: FilmRepositoryPort = mockk()
|
||||||
|
private val filmLibraryRepository: FilmLibraryRepositoryPort = mockk()
|
||||||
|
private val filmRatingRepository: FilmRatingRepositoryPort = mockk()
|
||||||
|
private val userPreferencesRepository: UserPreferencesRepositoryPort = mockk()
|
||||||
|
private val businessMetricsPort: BusinessMetricsPort = mockk(relaxed = true)
|
||||||
|
|
||||||
|
private val recommendationService =
|
||||||
|
RecommendationService(
|
||||||
|
filmRepository = filmRepository,
|
||||||
|
filmLibraryRepository = filmLibraryRepository,
|
||||||
|
filmRatingRepository = filmRatingRepository,
|
||||||
|
userPreferencesRepository = userPreferencesRepository,
|
||||||
|
businessMetricsService = businessMetricsPort,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `should rank highly rated films above poorly rated films`() {
|
||||||
|
val userId = UUID.randomUUID()
|
||||||
|
val lowRatedFilmId = UUID.randomUUID()
|
||||||
|
val highRatedFilmId = UUID.randomUUID()
|
||||||
|
|
||||||
|
val lowRatedFilm = Film(id = lowRatedFilmId, title = "Low", description = "Low")
|
||||||
|
val highRatedFilm = Film(id = highRatedFilmId, title = "High", description = "High")
|
||||||
|
|
||||||
|
every { userPreferencesRepository.findByUserId(userId) } returns UserPreferences(userId = userId)
|
||||||
|
every { filmRatingRepository.findByUserId(userId) } returns
|
||||||
|
listOf(
|
||||||
|
FilmRating(id = UUID.randomUUID(), userId = userId, filmId = lowRatedFilmId, score = 1),
|
||||||
|
FilmRating(id = UUID.randomUUID(), userId = userId, filmId = highRatedFilmId, score = 10),
|
||||||
|
)
|
||||||
|
every { filmLibraryRepository.findAll() } returns emptyList()
|
||||||
|
every { filmRepository.findAll() } returns listOf(lowRatedFilm, highRatedFilm)
|
||||||
|
|
||||||
|
val result = recommendationService.recommend(RecommendationQuery(userId = userId))
|
||||||
|
|
||||||
|
assertEquals(highRatedFilmId, result.first().film.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user