From 6d0e6d41a76bb8f5fda143564692b07a624c33e5 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 31 May 2026 00:12:54 +0000 Subject: [PATCH] feat(media): use collection-based recommendations and refine poster display --- frontend/src/components/MediaDetail.vue | 197 ++++++++++++------ frontend/src/composables/useMediaWebSocket.ts | 16 +- frontend/src/search-worker.ts | 3 +- frontend/src/types.ts | 8 +- mediahive/hivescan/tmdb_client.py | 33 +-- mediahive/models/tmdb.py | 10 +- 6 files changed, 155 insertions(+), 112 deletions(-) diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index fa58a8a..443db5c 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -180,31 +180,29 @@ -
-

Similar In Library

+
- +
+
@@ -656,73 +654,102 @@ const movieKeywords = computed(() => { const viewportWidth = ref(typeof window !== "undefined" ? window.innerWidth : 1920) -const similarNavRow = computed(() => 3 + movieVersions.value.length) +const collectionNavRow = computed(() => 3 + movieVersions.value.length) const castNavRow = computed(() => { const hasDesktopSimilarShortcut = - viewportWidth.value > DESKTOP_NAV_SHORTCUT_MIN_WIDTH && similarMovies.value.length > 0 + viewportWidth.value > DESKTOP_NAV_SHORTCUT_MIN_WIDTH && collectionMovies.value.length > 0 // Desktop with similar row: keep visual cast placement but move it below similar in nav rows. // Narrow layout (or no similar): preserve existing cast row directly after releases. return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length }) -const similarMovies = computed((): Array<{ - tmdbId: number +const collectionMovies = computed((): Array<{ title: string localId: string coverPath: string | null rootId: string | null year: string | null + hyphenLang: string | null + isCurrent: boolean }> => { if (props.item.type !== "movies") return [] const movie = props.item.data as Movie - const similar = movie.info?.similar || [] - if (similar.length === 0) return [] - - const byTmdbId = new Map() - for (const libraryMovie of props.allMovies || []) { - const tmdbId = libraryMovie.info?.tmdb_id - if (libraryMovie.id === props.item.id) continue - - if (typeof tmdbId === "number" && !byTmdbId.has(tmdbId)) { - byTmdbId.set(tmdbId, libraryMovie) - } - } + const collectionName = movie.info?.collection?.trim() + if (!collectionName) return [] + const normalizedCollectionName = collectionName.toLowerCase() const matches: Array<{ - tmdbId: number title: string localId: string coverPath: string | null rootId: string | null year: string | null + hyphenLang: string | null + isCurrent: boolean }> = [] - const seenTmdbIds = new Set() - for (const similarEntry of similar) { - if (seenTmdbIds.has(similarEntry.id)) continue - seenTmdbIds.add(similarEntry.id) + let hasCurrentInMatches = false - const matched = byTmdbId.get(similarEntry.id) - if (!matched) continue + for (const libraryMovie of props.allMovies || []) { + const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase() + if (otherCollectionName !== normalizedCollectionName) continue - const title = matched.title || matched.info?.title || similarEntry.title + const title = libraryMovie.title || libraryMovie.info?.title if (!title) continue + const isCurrent = libraryMovie.id === props.item.id + if (isCurrent) hasCurrentInMatches = true + matches.push({ - tmdbId: similarEntry.id, title, - localId: matched.id, - coverPath: matched.cover_path || null, - rootId: matched.root_id || null, - year: matched.year ? String(matched.year) : matched.info?.release_date?.slice(0, 4) || null, + localId: libraryMovie.id, + coverPath: libraryMovie.cover_path || null, + rootId: libraryMovie.root_id || null, + year: libraryMovie.year + ? String(libraryMovie.year) + : libraryMovie.info?.release_date?.slice(0, 4) || null, + hyphenLang: normalizeHyphenationLang(libraryMovie.info?.original_language), + isCurrent, }) } - return matches.slice(0, 24) + if (!hasCurrentInMatches) { + matches.push({ + title: props.item.title || (props.item.data as Movie).info?.title || "Current movie", + localId: props.item.id, + coverPath: props.item.cover_path || null, + rootId: props.item.root_id || null, + year: props.item.year + ? String(props.item.year) + : (props.item.data as Movie).info?.release_date?.slice(0, 4) || null, + hyphenLang: normalizeHyphenationLang((props.item.data as Movie).info?.original_language), + isCurrent: true, + }) + } + + return matches + .sort((a, b) => { + const yearA = parseInt(a.year || "", 10) + const yearB = parseInt(b.year || "", 10) + const hasYearA = Number.isFinite(yearA) + const hasYearB = Number.isFinite(yearB) + + if (hasYearA && hasYearB && yearA !== yearB) return yearA - yearB + if (hasYearA !== hasYearB) return hasYearA ? -1 : 1 + return a.title.localeCompare(b.title) + }) + .slice(0, 24) }) +function normalizeHyphenationLang(language: string | null | undefined): string | null { + if (!language) return null + const normalized = language.trim() + if (!/^[A-Za-z]{2,3}(?:-[A-Za-z]{2,4})?$/.test(normalized)) return null + return normalized.toLowerCase() +} + function formatKeywordLabel(keyword: string): string { // Keep multi-word keywords together while visually narrowing internal spacing. return keyword.trim().replace(/\s+/g, "\u202F") @@ -899,6 +926,11 @@ function handleSelectMovie(movieId: string) { emit("selectMovie", movieId) } +function handleSelectCollectionMovie(movieId: string, isCurrent: boolean) { + if (isCurrent) return + handleSelectMovie(movieId) +} + function handleResize() { viewportWidth.value = window.innerWidth } @@ -939,6 +971,9 @@ onUnmounted(() => { .similar-movies-section { margin-top: 20px; + position: relative; + left: calc(-50vw + 50%); + width: 100vw; } .similar-movies-title { @@ -949,7 +984,12 @@ onUnmounted(() => { .similar-movies-grid { --sync-row-tail: 0px; - --sync-row-right-deadzone: 32px; + --similar-safe-start: 32px; + --similar-safe-end: 32px; + --sync-row-left-deadzone: var(--similar-safe-start); + --sync-row-right-deadzone: var(--similar-safe-end); + margin: 0; + padding: 0 calc(var(--similar-safe-end) + var(--sync-row-tail)) 0 var(--similar-safe-start); display: flex; flex-wrap: nowrap; gap: 6px; @@ -969,33 +1009,52 @@ onUnmounted(() => { color: inherit; text-align: left; cursor: pointer; + position: relative; + border-radius: 0; + /* Keep poster clipping local to the poster element. */ + overflow: visible; +} + +.similar-movie-card--current { + cursor: default; +} + +.similar-movie-card::after { + content: ""; + position: absolute; + inset: 0; + border: 0 solid rgba(255, 255, 255, 0.95); + pointer-events: none; + transition: border-width 120ms ease; +} + +.similar-movie-card:focus-visible, +html:not(.mouse-active) .similar-movie-card.nav-focused { + outline: none; +} + +.similar-movie-card:focus-visible::after, +html:not(.mouse-active) .similar-movie-card.nav-focused::after { + border-width: 2px; } .similar-movie-poster { width: 100%; height: 100%; + overflow: hidden; + border-radius: 0; + box-shadow: 0 0 0.4rem black; + transition: filter 140ms ease; +} + +.similar-movie-card--current .similar-movie-poster { + filter: sepia(0.85); } .similar-movie-poster-fallback { background: linear-gradient(135deg, #282d3a, #171b24); } -.similar-movie-meta { - inset: auto 0 0 0; -} - -.similar-movie-name { - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.similar-movie-sub { - white-space: nowrap; -} - .movie-menu-backdrop { position: fixed; inset: 0; @@ -1116,6 +1175,7 @@ onUnmounted(() => { -webkit-backdrop-filter: blur(12px); border-radius: 12px; overflow: hidden; + box-shadow: 0 0 0.4rem black; } .synopsis-poster { @@ -1459,6 +1519,13 @@ html:not(.mouse-active) .cast-card.nav-focused::after { margin-left: 0; margin-top: 0; } + + .similar-movies-grid { + --similar-safe-start: 32px; + --similar-safe-end: 32px; + --sync-row-left-deadzone: 32px; + --sync-row-right-deadzone: 32px; + } } /* Showreel gallery */ diff --git a/frontend/src/composables/useMediaWebSocket.ts b/frontend/src/composables/useMediaWebSocket.ts index 13aace0..e812542 100644 --- a/frontend/src/composables/useMediaWebSocket.ts +++ b/frontend/src/composables/useMediaWebSocket.ts @@ -190,16 +190,6 @@ export function useMediaWebSocket() { } } - function normalizeSimilarMember(member: unknown): { id: number; title: string } { - if (!Array.isArray(member)) { - return { id: 0, title: "" } - } - return { - id: typeof member[0] === "number" ? member[0] : 0, - title: typeof member[1] === "string" ? member[1] : "", - } - } - function normalizePerson(member: unknown): Person | null { if (!Array.isArray(member)) return null const gender = normalizeCastGender(member[2]) @@ -223,7 +213,7 @@ export function useMediaWebSocket() { } } - function normalizeInfo( + function normalizeInfo( info: T | null, people: Map, ): T | null { @@ -235,10 +225,6 @@ export function useMediaWebSocket() { .filter((member) => member.name.length > 0) next = { ...next, cast } as T } - if (Array.isArray((info as { similar?: unknown }).similar)) { - const similar = ((info as { similar?: unknown[] }).similar || []).map(normalizeSimilarMember) - next = { ...next, similar } as T - } return next } diff --git a/frontend/src/search-worker.ts b/frontend/src/search-worker.ts index d38ab91..f9de951 100644 --- a/frontend/src/search-worker.ts +++ b/frontend/src/search-worker.ts @@ -621,7 +621,7 @@ async function performSearch( movie.info?.keywords?.join(" "), movie.info?.overview, movie.info?.tagline, - movie.info?.similar?.map((s) => s.title).join(" "), + movie.info?.collection, ), getMoviePathScore(movie, query), ) @@ -719,7 +719,6 @@ async function performSearch( seriesItem.info?.keywords?.join(" "), seriesItem.info?.overview, seriesItem.info?.tagline, - seriesItem.info?.similar?.map((s) => s.title).join(" "), seriesItem.info?.networks?.join(" "), ), getSeriesPathScore(seriesItem, query), diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ee101c3..ca10260 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -19,15 +19,11 @@ export interface Person { gender?: CastGender | null } -export interface SimilarMedia { - id: number - title: string -} - export interface Info { tmdb_id: number title: string | null original_title: string | null + original_language: string | null alternative_titles: string[] | null rating: number | null vote_count: number | null @@ -35,9 +31,9 @@ export interface Info { genres: string[] | null release_date: string | null runtime: number | null + collection: string | null status: string | null tagline: string | null - similar: SimilarMedia[] | null keywords: string[] | null cast: CastMember[] | null director: string | null diff --git a/mediahive/hivescan/tmdb_client.py b/mediahive/hivescan/tmdb_client.py index 740a710..d616b88 100644 --- a/mediahive/hivescan/tmdb_client.py +++ b/mediahive/hivescan/tmdb_client.py @@ -18,7 +18,6 @@ from mediahive.models.tmdb import ( Info, Person, SeasonInfo, - SimilarMedia, ) # TMDb API configuration @@ -148,19 +147,19 @@ async def tmdb_api_request( async def fetch_movie_details(movie_id: int) -> dict | None: - """Fetch movie info including credits, similar, keywords, and alt titles.""" + """Fetch movie info including credits, keywords, alt titles, and collection.""" # Use append_to_response to get multiple data in one request return await tmdb_api_request( f"/movie/{movie_id}", - {"append_to_response": "credits,similar,keywords,alternative_titles"}, + {"append_to_response": "credits,keywords,alternative_titles"}, ) async def fetch_series_details(series_id: int) -> dict | None: - """Fetch detailed TV series info including credits, similar, and keywords.""" + """Fetch detailed TV series info including credits and keywords.""" # Use append_to_response to get multiple data in one request return await tmdb_api_request( - f"/tv/{series_id}", {"append_to_response": "credits,similar,keywords"} + f"/tv/{series_id}", {"append_to_response": "credits,keywords"} ) @@ -385,7 +384,7 @@ async def fetch_movie_info( result = data["results"][0] movie_id = result["id"] - # Fetch full details with credits, similar movies, and keywords + # Fetch full details with credits, keywords, alt titles, and collection details = await fetch_movie_details(movie_id) if not details: # Fall back to basic info from search @@ -394,6 +393,7 @@ async def fetch_movie_info( tmdb_id=movie_id, title=result.get("title"), original_title=result.get("original_title"), + original_language=result.get("original_language"), rating=result.get("vote_average"), vote_count=result.get("vote_count"), overview=result.get("overview"), @@ -450,15 +450,19 @@ async def fetch_movie_info( directors = [c["name"] for c in crew if c.get("job") == "Director"] director = directors[0] if directors else None - # Extract similar movies (limit to 10) - similar_data = details.get("similar", {}).get("results", [])[:10] - similar = [SimilarMedia(id=s["id"], title=s["title"]) for s in similar_data] + collection_data = details.get("belongs_to_collection") + collection = None + if isinstance(collection_data, dict): + collection_name = collection_data.get("name") + if isinstance(collection_name, str): + collection = collection_name or None return ( Info( tmdb_id=movie_id, title=details.get("title"), original_title=details.get("original_title"), + original_language=details.get("original_language"), alternative_titles=alternative_titles, rating=details.get("vote_average"), vote_count=details.get("vote_count"), @@ -466,9 +470,9 @@ async def fetch_movie_info( genres=genres or None, release_date=details.get("release_date"), runtime=details.get("runtime"), + collection=collection, status=details.get("status"), tagline=details.get("tagline"), - similar=similar or None, keywords=keywords or None, cast=cast or None, director=director, @@ -513,7 +517,7 @@ async def fetch_series_info( result = data["results"][0] series_id = result["id"] - # Fetch full details with credits, similar shows, and keywords + # Fetch full details with credits and keywords details = await fetch_series_details(series_id) if not details: # Fall back to basic info from search @@ -522,6 +526,7 @@ async def fetch_series_info( tmdb_id=series_id, title=result.get("name"), original_title=result.get("original_name"), + original_language=result.get("original_language"), rating=result.get("vote_average"), vote_count=result.get("vote_count"), overview=result.get("overview"), @@ -564,10 +569,6 @@ async def fetch_series_info( # Extract networks networks = [n["name"] for n in details.get("networks", [])] - # Extract similar series (limit to 10) - similar_data = details.get("similar", {}).get("results", [])[:10] - similar = [SimilarMedia(id=s["id"], title=s["name"]) for s in similar_data] - # Get first air date first_air_date = details.get("first_air_date") @@ -576,6 +577,7 @@ async def fetch_series_info( tmdb_id=series_id, title=details.get("name"), original_title=details.get("original_name"), + original_language=details.get("original_language"), rating=details.get("vote_average"), vote_count=details.get("vote_count"), overview=details.get("overview"), @@ -583,7 +585,6 @@ async def fetch_series_info( release_date=first_air_date, status=details.get("status"), tagline=details.get("tagline"), - similar=similar or None, keywords=keywords or None, cast=cast or None, creators=creators or None, diff --git a/mediahive/models/tmdb.py b/mediahive/models/tmdb.py index bf6b66a..3a01fa8 100644 --- a/mediahive/models/tmdb.py +++ b/mediahive/models/tmdb.py @@ -27,13 +27,6 @@ class Person(msgspec.Struct, array_like=True): gender: str | None = None -class SimilarMedia(msgspec.Struct, array_like=True): - """Pointer to a similar movie/series on TMDb.""" - - id: int - title: str - - # --------------------------------------------------------------------------- # TMDb result types # --------------------------------------------------------------------------- @@ -72,6 +65,7 @@ class Info(msgspec.Struct): tmdb_id: int title: str | None = None original_title: str | None = None + original_language: str | None = None alternative_titles: list[str] | None = None rating: float | None = None vote_count: int | None = None @@ -79,9 +73,9 @@ class Info(msgspec.Struct): genres: list[str] | None = None release_date: str | None = None runtime: int | None = None + collection: str | None = None status: str | None = None tagline: str | None = None - similar: list[SimilarMedia] | None = None keywords: list[str] | None = None cast: list[CastCredit] | None = None director: str | None = None