Add related movies on Series page. Uses heuristics to show serie movies only.

This commit is contained in:
2026-05-30 03:36:35 +00:00
parent c2ee25ba3d
commit 46eecd8dbf
3 changed files with 170 additions and 1 deletions
+7
View File
@@ -157,6 +157,7 @@
v-if="detailItemForRender" v-if="detailItemForRender"
v-show="isDetailOpen" v-show="isDetailOpen"
:item="detailItemForRender" :item="detailItemForRender"
:all-movies="mediaIndex?.movies ?? []"
:focus-episode="focusEpisode" :focus-episode="focusEpisode"
:has-resume-position="hasResumePosition" :has-resume-position="hasResumePosition"
:get-root-name="getRootName" :get-root-name="getRootName"
@@ -164,6 +165,7 @@
@play="handlePlay" @play="handlePlay"
@open-folder="handleOpenFolder" @open-folder="handleOpenFolder"
@search-actor="handleActorSearch" @search-actor="handleActorSearch"
@select-movie="handleSelectMovieFromDetail"
/> />
</main> </main>
</div> </div>
@@ -762,6 +764,11 @@ function handleActorSearch(actorName: string) {
updateSearchQuery(actorName) 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 { function focusDetailEntryTarget(item: MediaItem): boolean {
const detailPanel = document.querySelector('[data-nav-scope="detail"]') as HTMLElement | null const detailPanel = document.querySelector('[data-nav-scope="detail"]') as HTMLElement | null
if (!detailPanel) return false if (!detailPanel) return false
+8
View File
@@ -3,12 +3,14 @@
<SeriesFullView <SeriesFullView
v-if="item.type === 'series'" v-if="item.type === 'series'"
:series="item.data as Series" :series="item.data as Series"
:all-movies="allMovies"
:focus-episode="focusEpisode" :focus-episode="focusEpisode"
:has-resume-position="hasResumePosition" :has-resume-position="hasResumePosition"
:get-root-name="getRootName" :get-root-name="getRootName"
@close="$emit('close')" @close="$emit('close')"
@play="handlePlay" @play="handlePlay"
@openFolder="handleOpenFolder" @openFolder="handleOpenFolder"
@select-movie="handleSelectMovie"
/> />
<!-- Full page view for movies --> <!-- Full page view for movies -->
@@ -225,6 +227,7 @@ import {
const props = defineProps<{ const props = defineProps<{
item: MediaItem item: MediaItem
allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
hasResumePosition: (filePath: string | null) => boolean hasResumePosition: (filePath: string | null) => boolean
getRootName: (rootId: string | null | undefined) => string | null getRootName: (rootId: string | null | undefined) => string | null
@@ -234,6 +237,7 @@ const emit = defineEmits<{
play: [string] play: [string]
openFolder: [string, string | null | undefined] openFolder: [string, string | null | undefined]
searchActor: [string] searchActor: [string]
selectMovie: [string]
}>() }>()
// Track expanded episode for showing multiple releases // Track expanded episode for showing multiple releases
@@ -747,6 +751,10 @@ function handleCastSelect(castName: string) {
emit("searchActor", name) emit("searchActor", name)
} }
function handleSelectMovie(movieId: string) {
emit("selectMovie", movieId)
}
onMounted(() => { onMounted(() => {
document.addEventListener("keydown", handleMovieMenuKeydown, true) document.addEventListener("keydown", handleMovieMenuKeydown, true)
}) })
+155 -1
View File
@@ -130,6 +130,35 @@
</div> </div>
</div> </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> </section>
<!-- Context menu --> <!-- Context menu -->
@@ -184,7 +213,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, nextTick, watch, onMounted, onUnmounted } from "vue" 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 { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api"
import { navAttrs } from "../composables/useKeyboardNavigation" import { navAttrs } from "../composables/useKeyboardNavigation"
import ReleaseVersionCard from "./ReleaseVersionCard.vue" import ReleaseVersionCard from "./ReleaseVersionCard.vue"
@@ -193,6 +222,7 @@ import { sortTorrentsByPreference } from "../composables/useSettings"
const props = defineProps<{ const props = defineProps<{
series: Series series: Series
allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
hasResumePosition: (filePath: string | null) => boolean hasResumePosition: (filePath: string | null) => boolean
getRootName: (rootId: string | null | undefined) => string | null getRootName: (rootId: string | null | undefined) => string | null
@@ -202,6 +232,7 @@ const emit = defineEmits<{
close: [] close: []
play: [string] play: [string]
openFolder: [string, string | null | undefined] openFolder: [string, string | null | undefined]
selectMovie: [string]
}>() }>()
const seriesRootRef = ref<HTMLElement | null>(null) const seriesRootRef = ref<HTMLElement | null>(null)
@@ -268,6 +299,67 @@ const versionActionMenu = ref<{
const releaseMenuOriginElement = ref<HTMLElement | null>(null) 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 // Show context menu on right-click
function handleContextMenu(event: MouseEvent, episode: Episode) { function handleContextMenu(event: MouseEvent, episode: Episode) {
event.preventDefault() event.preventDefault()
@@ -683,6 +775,68 @@ onUnmounted(() => {
overflow-x: hidden; 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 */ /* Hero section */
.series-hero { .series-hero {
position: relative; position: relative;