Hotfix/merge conflicts #60

Merged
devitq merged 2 commits from hotfix/merge-conflicts into develop 2026-05-22 13:50:18 +00:00
15 changed files with 368 additions and 64 deletions
+182
View File
@@ -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.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.domain.model.ContentType
import org.springframework.stereotype.Service
@@ -11,39 +14,18 @@ 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,
) {
) : JellyfinSyncSourcePort {
private val httpClient: HttpClient =
HttpClient
.newBuilder()
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
.build()
fun fetchUsers(): List<JellyfinRemoteUser> =
override fun fetchUsers(): List<JellyfinRemoteUser> =
request("Users")
.asItems()
.mapNotNull { node ->
@@ -51,7 +33,7 @@ class JellyfinApiClient(
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
}
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
override 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",
@@ -1,5 +1,6 @@
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.RecommendationEventType
import io.micrometer.core.instrument.Counter
@@ -33,7 +34,7 @@ class BusinessMetricsService(
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
fun recordRecommendationRequest() {
override fun recordRecommendationRequest() {
recommendationRequests.increment()
}
@@ -49,11 +50,11 @@ class BusinessMetricsService(
ratingsSubmitted.increment()
}
fun recordLibraryEvent() {
override fun recordLibraryEvent() {
libraryEvents.increment()
}
fun recordJellyfinSync(summary: JellyfinSyncSummary) {
override fun recordJellyfinSync(summary: JellyfinSyncSummary) {
jellyfinSyncRuns.increment()
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
@@ -61,15 +62,15 @@ class BusinessMetricsService(
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
}
fun recordJellyfinSyncFailure() {
override fun recordJellyfinSyncFailure() {
jellyfinSyncFailures.increment()
}
fun recordJellyfinUnmappedUser() {
override fun recordJellyfinUnmappedUser() {
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
}
fun recordBackendWriteFailure() {
override fun recordBackendWriteFailure() {
backendWriteFailures.increment()
}
}
@@ -1,5 +1,6 @@
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.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
@@ -7,8 +8,8 @@ import org.springframework.stereotype.Repository
@Repository
class JellyfinEventRepository(
private val jdbc: NamedParameterJdbcTemplate,
) {
fun save(
) : JellyfinEventRepositoryPort {
override fun save(
eventId: String,
serverId: String?,
eventType: String,
@@ -37,7 +38,7 @@ class JellyfinEventRepository(
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 params = MapSqlParameterSource().addValue("eventId", eventId)
jdbc.update(sql, params)
@@ -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()
}
@@ -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)
}
@@ -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
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
@@ -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.RemoveFilmFromLibraryCommand
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.IdGenerator
import com.project.movienight.domain.exception.DomainException
@@ -25,7 +25,7 @@ import java.util.UUID
class FilmLibraryService(
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
private val businessMetricsService: BusinessMetricsPort,
) : CreateFilmLibraryUseCase,
AddFilmToLibraryUseCase,
MarkFilmViewedUseCase,
@@ -1,9 +1,9 @@
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.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
@@ -19,7 +19,7 @@ class FilmRatingService(
private val filmRepository: FilmRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
private val businessMetricsService: BusinessMetricsPort,
) : RateFilmUseCase,
GetFilmRatingsUseCase {
override fun rate(command: RateFilmCommand): FilmRating {
@@ -1,10 +1,10 @@
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.BusinessMetricsPort
import com.project.movienight.application.ports.output.JellyfinEventRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.UserRepositoryPort
import org.springframework.stereotype.Service
@@ -12,12 +12,12 @@ import java.time.OffsetDateTime
@Service
class JellyfinEventService(
private val jellyfinEventRepository: JellyfinEventRepository,
private val jellyfinEventRepository: JellyfinEventRepositoryPort,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
private val objectMapper: ObjectMapper,
private val businessMetricsService: BusinessMetricsService,
private val businessMetricsService: BusinessMetricsPort,
) {
private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed")
@@ -1,13 +1,13 @@
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.BusinessMetricsPort
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.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.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType
@@ -24,13 +24,13 @@ import java.time.LocalDateTime
@Service
class JellyfinSyncService(
private val properties: JellyfinIntegrationProperties,
private val jellyfinApiClient: JellyfinApiClient,
private val jellyfinApiClient: JellyfinSyncSourcePort,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val syncStateRepository: JellyfinSyncStateRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
private val businessMetricsService: BusinessMetricsPort,
) {
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
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() }
}
}
}
@@ -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)
}
}