Add related movies on Series page. Uses heuristics to show serie movies only.
This commit is contained in:
@@ -157,6 +157,7 @@
|
||||
v-if="detailItemForRender"
|
||||
v-show="isDetailOpen"
|
||||
:item="detailItemForRender"
|
||||
:all-movies="mediaIndex?.movies ?? []"
|
||||
:focus-episode="focusEpisode"
|
||||
:has-resume-position="hasResumePosition"
|
||||
:get-root-name="getRootName"
|
||||
@@ -164,6 +165,7 @@
|
||||
@play="handlePlay"
|
||||
@open-folder="handleOpenFolder"
|
||||
@search-actor="handleActorSearch"
|
||||
@select-movie="handleSelectMovieFromDetail"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
@@ -762,6 +764,11 @@ function handleActorSearch(actorName: string) {
|
||||
updateSearchQuery(actorName)
|
||||
}
|
||||
|
||||
function handleSelectMovieFromDetail(movieId: string) {
|
||||
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null
|
||||
router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined })
|
||||
}
|
||||
|
||||
function focusDetailEntryTarget(item: MediaItem): boolean {
|
||||
const detailPanel = document.querySelector('[data-nav-scope="detail"]') as HTMLElement | null
|
||||
if (!detailPanel) return false
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
<SeriesFullView
|
||||
v-if="item.type === 'series'"
|
||||
:series="item.data as Series"
|
||||
:all-movies="allMovies"
|
||||
:focus-episode="focusEpisode"
|
||||
:has-resume-position="hasResumePosition"
|
||||
:get-root-name="getRootName"
|
||||
@close="$emit('close')"
|
||||
@play="handlePlay"
|
||||
@openFolder="handleOpenFolder"
|
||||
@select-movie="handleSelectMovie"
|
||||
/>
|
||||
|
||||
<!-- Full page view for movies -->
|
||||
@@ -225,6 +227,7 @@ import {
|
||||
|
||||
const props = defineProps<{
|
||||
item: MediaItem
|
||||
allMovies: MovieUi[]
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
|
||||
hasResumePosition: (filePath: string | null) => boolean
|
||||
getRootName: (rootId: string | null | undefined) => string | null
|
||||
@@ -234,6 +237,7 @@ const emit = defineEmits<{
|
||||
play: [string]
|
||||
openFolder: [string, string | null | undefined]
|
||||
searchActor: [string]
|
||||
selectMovie: [string]
|
||||
}>()
|
||||
|
||||
// Track expanded episode for showing multiple releases
|
||||
@@ -747,6 +751,10 @@ function handleCastSelect(castName: string) {
|
||||
emit("searchActor", name)
|
||||
}
|
||||
|
||||
function handleSelectMovie(movieId: string) {
|
||||
emit("selectMovie", movieId)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("keydown", handleMovieMenuKeydown, true)
|
||||
})
|
||||
|
||||
@@ -130,6 +130,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="matchingSeriesMovies.length > 0" class="linked-movies-section">
|
||||
<h2 class="linked-movies-title">Related Movies In Library</h2>
|
||||
<div class="linked-movies-grid">
|
||||
<button
|
||||
v-for="(movie, movieIndex) in matchingSeriesMovies"
|
||||
:key="movie.id"
|
||||
type="button"
|
||||
class="linked-movie-card"
|
||||
v-bind="navAttrs(series.seasons.length + 2, movieIndex)"
|
||||
@click="emit('selectMovie', movie.id)"
|
||||
>
|
||||
<img
|
||||
v-if="movie.cover_path"
|
||||
:src="getCoverUrl(movie.cover_path, movie.root_id)"
|
||||
class="linked-movie-poster"
|
||||
:alt="movie.title || 'Movie'"
|
||||
/>
|
||||
<div v-else class="linked-movie-poster linked-movie-poster-fallback"></div>
|
||||
<div class="linked-movie-meta">
|
||||
<span class="linked-movie-name">{{ movie.title }}</span>
|
||||
<span class="linked-movie-sub">
|
||||
{{ movie.year || movie.info?.release_date?.slice(0, 4) || "Unknown Year" }}
|
||||
<template v-if="movie.info?.rating"> • ★ {{ movie.info.rating.toFixed(1) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Context menu -->
|
||||
@@ -184,7 +213,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, nextTick, watch, onMounted, onUnmounted } from "vue"
|
||||
import type { Series, Season, Episode, Torrent } from "../types"
|
||||
import type { Series, Season, Episode, Torrent, MovieUi } from "../types"
|
||||
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api"
|
||||
import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
|
||||
@@ -193,6 +222,7 @@ import { sortTorrentsByPreference } from "../composables/useSettings"
|
||||
|
||||
const props = defineProps<{
|
||||
series: Series
|
||||
allMovies: MovieUi[]
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
|
||||
hasResumePosition: (filePath: string | null) => boolean
|
||||
getRootName: (rootId: string | null | undefined) => string | null
|
||||
@@ -202,6 +232,7 @@ const emit = defineEmits<{
|
||||
close: []
|
||||
play: [string]
|
||||
openFolder: [string, string | null | undefined]
|
||||
selectMovie: [string]
|
||||
}>()
|
||||
|
||||
const seriesRootRef = ref<HTMLElement | null>(null)
|
||||
@@ -268,6 +299,67 @@ const versionActionMenu = ref<{
|
||||
|
||||
const releaseMenuOriginElement = ref<HTMLElement | null>(null)
|
||||
|
||||
function normalizeMatchText(value: string | null | undefined): string {
|
||||
return (value || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
const seriesMatchNeedles = computed(() => {
|
||||
const candidates = new Set<string>()
|
||||
const push = (value: string | null | undefined) => {
|
||||
const normalized = normalizeMatchText(value)
|
||||
if (normalized.length >= 3) {
|
||||
candidates.add(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
push(props.series.title)
|
||||
push(props.series.info?.title)
|
||||
push(props.series.info?.original_title)
|
||||
for (const alt of props.series.alternative_titles || []) {
|
||||
push(alt)
|
||||
}
|
||||
for (const alt of props.series.info?.alternative_titles || []) {
|
||||
push(alt)
|
||||
}
|
||||
|
||||
return Array.from(candidates)
|
||||
})
|
||||
|
||||
const matchingSeriesMovies = computed(() => {
|
||||
const needles = seriesMatchNeedles.value
|
||||
if (needles.length === 0) return []
|
||||
|
||||
return (props.allMovies || [])
|
||||
.filter((movie) => {
|
||||
const keywords = (movie.info?.keywords || []).map((k) => normalizeMatchText(k))
|
||||
if (!keywords.includes("based on tv series")) {
|
||||
return false
|
||||
}
|
||||
|
||||
const movieTitle = normalizeMatchText(movie.title || movie.info?.title || "")
|
||||
if (!movieTitle) {
|
||||
return false
|
||||
}
|
||||
|
||||
return needles.some((needle) => movieTitle.includes(needle))
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const yearA = a.year || Number(a.info?.release_date?.slice(0, 4) || 0)
|
||||
const yearB = b.year || Number(b.info?.release_date?.slice(0, 4) || 0)
|
||||
if (yearA !== yearB) return yearA - yearB
|
||||
|
||||
const dateA = a.info?.release_date || ""
|
||||
const dateB = b.info?.release_date || ""
|
||||
if (dateA !== dateB) return dateA.localeCompare(dateB)
|
||||
|
||||
return (a.title || "").localeCompare(b.title || "")
|
||||
})
|
||||
})
|
||||
|
||||
// Show context menu on right-click
|
||||
function handleContextMenu(event: MouseEvent, episode: Episode) {
|
||||
event.preventDefault()
|
||||
@@ -683,6 +775,68 @@ onUnmounted(() => {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.linked-movies-section {
|
||||
margin: 40px 30px 20px;
|
||||
}
|
||||
|
||||
.linked-movies-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.linked-movies-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.linked-movie-card {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.linked-movie-card:hover,
|
||||
.linked-movie-card:focus-visible {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.linked-movie-poster {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.linked-movie-poster-fallback {
|
||||
background: linear-gradient(135deg, #272b34, #181c24);
|
||||
}
|
||||
|
||||
.linked-movie-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.linked-movie-name {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.linked-movie-sub {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
/* Hero section */
|
||||
.series-hero {
|
||||
position: relative;
|
||||
|
||||
Reference in New Issue
Block a user