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-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
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user