Movie details layout
- Grid layout: poster/releases left, cast spanning center+right top, versions center, metadata right - Cast gallery moved to wider center column with photo-card grid (2:3 aspect, text overlay) - Full cast now stored in index (removed 10-item truncation) - Cast gender field added to metadata model, TMDB client, indexer, and frontend types - Gender-specific cast placeholder SVGs (male suit silhouette, female portrait) - Synopsis text moved from left poster box into right metadata card - Format badge backgrounds made opaque (res/qual/codec/audio solid colors) - Releases list moved under poster image in left column - Cast photo and character font sizes reduced for compact display
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
<svg width="119" height="140" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<linearGradient id="imagebot_9">
|
||||
<stop stop-color="#b3925d" id="imagebot_65"/>
|
||||
<stop stop-color="#5d4014" id="imagebot_64" offset="1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="imagebot_24">
|
||||
<stop stop-color="#fff" id="imagebot_63"/>
|
||||
<stop stop-opacity="0" stop-color="#fff" offset="1" id="imagebot_62"/>
|
||||
</linearGradient>
|
||||
<linearGradient x1="3.83529" y2="0.26302" x2="3.83529" y1="-0.34629" id="imagebot_16" xlink:href="#imagebot_24"/>
|
||||
<linearGradient x1="2.18514" y2="0.37278" x2="2.18514" y1="-0.01478" id="imagebot_20" xlink:href="#imagebot_24"/>
|
||||
<linearGradient x1="-0.55279" y2="1.01379" x2="-0.55279" y1="-0.01379" id="imagebot_17" xlink:href="#imagebot_9"/>
|
||||
<linearGradient y2="1" x2="0.5" y1="0" x1="0.5" id="imagebot_82">
|
||||
<stop offset="0" stop-opacity="0.99219" stop-color="#542800"/>
|
||||
<stop offset="1" stop-opacity="0.99219" stop-color="#662000"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="imagebot_83" y2="1" x2="0.5" y1="0" x1="0.5">
|
||||
<stop offset="0" stop-opacity="0.99219" stop-color="#542800"/>
|
||||
<stop offset="1" stop-opacity="0.99219" stop-color="#662000"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g>
|
||||
<title>Layer 1</title>
|
||||
<path id="imagebot_79" stroke-width="0.5" stroke="#57401e" fill-rule="evenodd" fill="url(#imagebot_17)"/>
|
||||
<g transform="translate(0 3.06434)" id="imagebot_102">
|
||||
<path d="M 22.74422 74.74535044870376 C 31.91926 58.45145044870377 37.64859 41.81313044870377 42.85091 29.248270448703767 C 41.90924 13.406620448703766 52.72854 8.192040448703766 59.79554 8.192040448703766 C 66.86253 8.192040448703766 78.99256 12.669340448703766 76.0848 29.534990448703766 C 80.95944 42.30465044870377 86.3611 59.270660448703765 95.53614 76.05607044870376 C 115.8523 130.92610044870378 73.92952 131.87786044870376 59.79554 131.87786044870376 C 45.66155 131.87786044870376 3.4111000000000002 116.83586044870377 22.74422 74.74535044870376 z" id="imagebot_80" stroke-linecap="null" stroke-linejoin="null" stroke-dasharray="null" stroke="#000000" fill="url(#imagebot_83)"/>
|
||||
<path transform="translate(0 0.512) matrix(1 0 0 1 0 -37.4937)" id="imagebot_92" stroke-width="1.143" stroke="url(#imagebot_16)" fill="none" opacity="0.586" d="M 59.47936427844316 46.39889503216985 C 51.40949466195967 46.6597993524647 46.7912370464379 50.28419053324464 44.19117979551757 55.13965915058735 C 50.034283878930864 61.52322215922001 68.81172041094496 64.17701625988963 74.92515422056397 54.788942632751485 C 72.23901202492665 50.05236559006813 67.80056983488612 46.54991700945619 61.10049593995004 46.39889503216985 C 60.54621353239983 46.386403240385086 59.993902535797375 46.38226202213887 59.47936427844316 46.39889503216985 z"/>
|
||||
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="1.113" stroke="#323232" fill-rule="evenodd" fill="#000000" d="M 0.7986898918151866 174.22619862772942 C -0.097920108184816 164.68506862772944 0.9079298918151864 145.87931862772942 14.117579891815183 142.82343862772942 L 57.65248989181518 133.5089986277294 L 103.1211998918152 142.15179862772942 C 116.5566498918152 145.02410862772942 118.02105989181518 163.55207862772943 118.42437989181519 173.9827686277294 C 0.5719998918151816 174.42936862772942 118.47697989181518 173.89226862772944 0.7986898918151866 174.22619862772942 z" id="imagebot_2"/>
|
||||
<path stroke-width="1.113" stroke="url(#imagebot_20)" fill="#000000" opacity="0.593" d="M 57.1773 97.07883872083664 L 14.434240000000003 106.71762872083664 L 14.39866 106.71762872083664 C 8.436970000000002 108.11243872083665 5.2424800000000005 112.98359872083665 3.5303200000000032 118.97568872083664 C 1.9480099999999965 124.51329872083664 1.8499800000000022 130.70706872083665 2.1762299999999972 135.29601872083663 C 30.032970000000006 135.21761872083664 45.100809999999996 135.15751872083663 52.241870000000006 135.15347872083663 C 55.91150999999999 135.15147872083665 57.73647 135.18207872083664 58.655969999999996 135.18907872083665 C 59.11572000000001 135.19307872083664 59.32381000000001 135.18307872083665 59.47555 135.18907872083665 C 59.52641 135.19107872083663 59.61632 135.21627872083664 59.68934999999999 135.22467872083664 C 59.69961000000001 135.22501872083663 59.77909 135.22430872083663 59.7962 135.22467872083664 C 59.89894000000001 135.22667872083665 60.04916 135.22367872083663 60.43761000000001 135.22467872083664 C 61.34398999999999 135.22767872083665 63.17347000000001 135.23367872083665 66.85171 135.22467872083664 C 73.98958 135.20697872083664 89.13769000000002 135.15137872083665 116.88172 135.04650872083664 C 116.64753000000002 130.02106872083664 116.11918 123.72255872083664 114.28044 118.19166872083665 C 112.27774 112.16755872083664 108.95868999999999 107.39762872083665 102.87759 106.07614872083664 L 57.1773 97.07883872083664 z" id="imagebot_3"/>
|
||||
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="1.113" stroke="#000000" fill-rule="evenodd" fill="#ffcdb2" d="M 45.24016 124.68690958276748 C 45.47224 130.3192395827675 44.13723 133.56532958276748 41.49851000000001 138.3393595827675 C 46.63544 147.78121958276748 51.76527999999999 153.7549695827675 59.62002 160.2540295827675 C 67.47475 152.82669958276747 73.54878 146.3342795827675 77.70689 137.80701958276748 C 76.45166 134.35116958276748 75.19644 129.66651958276748 73.94121 124.57226958276749 L 45.24016 124.68690958276748 z" id="imagebot_4"/>
|
||||
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="0.976" stroke-linejoin="round" stroke="#000000" fill="#ffcdb2" d="M 87.18131 100.67885958276749 C 87.18131 119.35216958276749 75.33934 134.0977195827675 58.60526 138.1937195827675 C 45.14797999999999 134.0977195827675 32.48680999999999 119.35216958276749 32.48680999999999 100.67885958276749 C 32.48680999999999 82.00555958276749 44.73838000000001 66.85040958276748 59.834059999999994 66.85040958276748 C 74.92974 66.85040958276748 87.18131 82.00555958276749 87.18131 100.67885958276749 z" id="imagebot_5"/>
|
||||
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke="#000000" fill-rule="evenodd" fill="url(#imagebot_82)" d="M 58.73652 60.55180958276749 C 32.629369999999994 61.25627958276748 27.718890000000002 80.5286295827675 29.11278 95.02406958276748 C 29.329250000000002 97.27517958276749 34.27735 131.44748958276747 35.90773 133.26849958276748 C 37.13297 121.83706958276748 35.742279999999994 80.90836958276749 49.40835 74.09559958276749 L 59.11117 77.18910958276749 L 69.46934 74.38438958276748 C 83.18997 80.30845958276748 80.91984 128.0179995827675 81.90542 133.42349958276748 C 83.38494 131.42852958276748 88.61500000000001 96.54846958276748 89.01375000000002 93.86481958276748 C 91.08852000000002 79.90128958276749 83.21866 60.963189582767484 61.350390000000004 60.55180958276749 C 60.4636 60.535119582767486 59.57871 60.52907958276749 58.73652 60.55180958276749 z" id="imagebot_6"/>
|
||||
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="1.143" stroke="url(#imagebot_16)" fill="none" opacity="0.586" d="M 58.762820000000005 61.683199582767486 C 45.99126 62.027819582767485 38.6823 66.81515958276749 34.56739 73.22858958276748 C 43.81482 81.66042958276749 73.53242 85.16573958276749 83.20768 72.76533958276748 C 78.95653 66.50894958276749 71.93215000000001 61.88267958276749 61.32846000000001 61.683199582767486 C 60.45124 61.666699582767485 59.57714 61.66122958276749 58.762820000000005 61.683199582767486 z" id="imagebot_7"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0"?>
|
||||
<svg
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:ns1="http://sozi.baierouge.fr"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
id="svg2"
|
||||
viewBox="0 0 600 600"
|
||||
version="1.1"
|
||||
>
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-743.2 97.566)"
|
||||
>
|
||||
<path
|
||||
id="path2996"
|
||||
opacity=".5"
|
||||
d="m1026.5-34.044c-14.911 1.272-30.456 4.7566-42.996 12.924-10.972 7.1462-19.233 18.368-25.552 29.836-4.6901 8.5125-6.9924 18.276-8.5878 27.864-2.5245 15.17-1.4223 46.115-1.4223 46.115l-5.4062 8.8695c-1.3634 10.729 0.37951 21.116 0.77301 31.844 0.30666 8.362 1.245 16.724 2.9677 25.086l8.5076 9.971s1.979 11.852 5.4611 20.055c3.4821 8.2036 17.093 26.995 17.093 26.995-12.537 4.3352-31.155 8.049-37.59 13.009-4.6728 3.6014-5.7623 10.625-8.1937 16.387l-115.3 21.118-14.36 21.202v30.663l-58.708 194.54 600 0.00002-18.415-62.086-22.554-126.28-1.3515-25.257-8.8696-15.036-46.375-17.063-66.226-11.573c-7.0675-7.2927-16-17.43-21.202-21.878-5.1891-4.4364-28.664-10.925-42.996-16.387l-2.7031-9.5453s7.6682-9.2528 10.221-16.387c2.5528-7.1346 3.3788-25.933 3.3788-25.933l4.8149 1.3516 4.8149-7.518 2.0273-27.284s0.3969-32.34-1.3515-36.154c-1.7485-3.8134-5.4907-4.8149-5.4907-4.8149s0.9972-29.564-2.7031-43.672c-3.8499-14.678-10.425-29.076-19.851-40.969-7.7283-9.7506-17.535-18.652-28.986-23.516-16.34-6.9413-35.177-7.9803-52.867-6.4713zm-47.135 251.89 10.897 19.766 38.941 32.775 19.09 1.3515 44.432-23.905 19.766-27.284-2.0274 10.221-23.905 68.929-19.851 64.874-12.924 59.383-13.009 72.392-32.015-193.19-19.175-40.293zm84.649 145.37-16.383 81.916-6.8263 49.524-22.835-154.12 7.8543-20.737 20.183-2.001-19.196-0.32134-12.317-25.607-6.626 5.2982 17.919-23.38 24.575 1.3653 26.964 31.742-15.647-13.051-14.73 23.632 16.383 34.814z"
|
||||
fill="#999"
|
||||
/>
|
||||
</g>
|
||||
<metadata>
|
||||
<rdf:RDF>
|
||||
<cc:Work>
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
<cc:license rdf:resource="http://creativecommons.org/licenses/publicdomain/"/>
|
||||
<dc:publisher>
|
||||
<cc:Agent rdf:about="http://openclipart.org/">
|
||||
<dc:title>Openclipart</dc:title>
|
||||
</cc:Agent>
|
||||
</dc:publisher>
|
||||
<dc:title>Generic Profile Image Placeholder - Suit</dc:title>
|
||||
<dc:date>2013-04-26T23:09:54</dc:date>
|
||||
<dc:description>Generic Profile Image Placeholder - Suit</dc:description>
|
||||
<dc:source>https://openclipart.org/detail/177482/generic-profile-image-placeholder---suit-by-naught101-177482</dc:source>
|
||||
<dc:creator>
|
||||
<cc:Agent>
|
||||
<dc:title>naught101</dc:title>
|
||||
</cc:Agent>
|
||||
</dc:creator>
|
||||
</cc:Work>
|
||||
<cc:License rdf:about="http://creativecommons.org/licenses/publicdomain/">
|
||||
<cc:permits rdf:resource="http://creativecommons.org/ns#Reproduction"/>
|
||||
<cc:permits rdf:resource="http://creativecommons.org/ns#Distribution"/>
|
||||
<cc:permits rdf:resource="http://creativecommons.org/ns#DerivativeWorks"/>
|
||||
</cc:License>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -65,16 +65,15 @@
|
||||
<div class="modal-body-inner">
|
||||
<div class="content-layout">
|
||||
<!-- Left sidebar - Synopsis -->
|
||||
<div v-if="overview" class="content-sidebar sidebar-left">
|
||||
<div v-if="synopsisPosterUrl" class="content-sidebar sidebar-left">
|
||||
<div class="synopsis-box">
|
||||
<h3 class="sidebar-title">Synopsis</h3>
|
||||
<p class="synopsis-text">{{ overview }}</p>
|
||||
<img
|
||||
:src="synopsisPosterUrl"
|
||||
:alt="`${item.title} poster`"
|
||||
class="synopsis-poster"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content - Formats/Versions -->
|
||||
<div class="content-main">
|
||||
<div v-if="movieVersions.length > 0" class="versions-list">
|
||||
<div v-if="movieVersions.length > 0" class="versions-list versions-list-sidebar">
|
||||
<div
|
||||
v-for="(version, index) in movieVersions"
|
||||
:key="index"
|
||||
@@ -107,27 +106,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="movieCast && movieCast.length > 0" class="cast-gallery cast-gallery-wide">
|
||||
<div class="cast-list">
|
||||
<div
|
||||
v-for="castMember in movieCast"
|
||||
:key="`${castMember.name}-${castMember.character || ''}`"
|
||||
class="cast-card"
|
||||
>
|
||||
<img
|
||||
v-if="castMember.profile_path && !castMember.profile_path.startsWith('/')"
|
||||
:src="getCoverUrl(castMember.profile_path)"
|
||||
:alt="castMember.name"
|
||||
class="cast-photo"
|
||||
>
|
||||
<img v-else :src="getCastPlaceholderUrl(castMember.gender)" :alt="`${castMember.name} placeholder portrait`" class="cast-photo cast-photo-fallback">
|
||||
<div class="cast-copy">
|
||||
<span class="cast-name">{{ castMember.name }}</span>
|
||||
<span v-if="castMember.character" class="cast-character">{{ castMember.character }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="content-main">
|
||||
</div>
|
||||
|
||||
<!-- Right sidebar - Metadata -->
|
||||
<div v-if="item.type === 'movies'" class="content-sidebar sidebar-right">
|
||||
<div class="movie-metadata">
|
||||
<div v-if="overview" class="meta-row">
|
||||
<span class="meta-value meta-synopsis">{{ overview }}</span>
|
||||
</div>
|
||||
<div v-if="movieDirector" class="meta-row">
|
||||
<span class="meta-label">Director</span>
|
||||
<span class="meta-value">{{ movieDirector }}</span>
|
||||
</div>
|
||||
<div v-if="movieCast && movieCast.length > 0" class="meta-row">
|
||||
<span class="meta-label">Cast</span>
|
||||
<span class="meta-value">{{ movieCast.map(c => c.name).join(', ') }}</span>
|
||||
<div v-if="movieStatus || movieReleaseDate" class="meta-summary">
|
||||
<span v-if="movieStatus" class="meta-summary-item">{{ movieStatus }}</span>
|
||||
<span v-if="movieReleaseDate" class="meta-summary-item">{{ movieReleaseDate }}</span>
|
||||
</div>
|
||||
<div v-if="movieReleaseDate" class="meta-row">
|
||||
<span class="meta-label">Release</span>
|
||||
<span class="meta-value">{{ movieReleaseDate }}</span>
|
||||
</div>
|
||||
<div v-if="movieStatus" class="meta-row">
|
||||
<span class="meta-label">Status</span>
|
||||
<span class="meta-value">{{ movieStatus }}</span>
|
||||
</div>
|
||||
<div v-if="movieKeywords && movieKeywords.length > 0" class="meta-row">
|
||||
<span class="meta-label">Keywords</span>
|
||||
<div v-if="movieKeywords && movieKeywords.length > 0" class="meta-keywords-section">
|
||||
<span class="meta-value keywords">{{ movieKeywords.join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,8 +160,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import type { MediaItem, Movie, Series, Torrent } from '../types';
|
||||
import type { CastMember, MediaItem, Movie, Series, Torrent } from '../types';
|
||||
import { getCoverUrl } from '../api';
|
||||
import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg';
|
||||
import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg';
|
||||
import SeriesFullView from './SeriesFullView.vue';
|
||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
|
||||
@@ -151,7 +172,6 @@ const props = defineProps<{
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
|
||||
hasResumePosition: (filePath: string | null) => boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
play: [string];
|
||||
@@ -300,6 +320,11 @@ const backdropStyle = computed(() => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const synopsisPosterUrl = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return getCoverUrl(props.item.cover_path);
|
||||
});
|
||||
|
||||
// Check if a specific version is a disc format (Blu-ray disc has index.bdmv)
|
||||
function isVersionDisc(version: Torrent): boolean {
|
||||
if (!version.playable_file) return false;
|
||||
@@ -324,7 +349,7 @@ const movieDirector = computed(() => {
|
||||
|
||||
const movieCast = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return (props.item.data as Movie).info?.cast;
|
||||
return (props.item.data as Movie).info?.cast as CastMember[] | null;
|
||||
});
|
||||
|
||||
const movieRuntime = computed(() => {
|
||||
@@ -347,6 +372,10 @@ const movieKeywords = computed(() => {
|
||||
return (props.item.data as Movie).info?.keywords;
|
||||
});
|
||||
|
||||
function getCastPlaceholderUrl(gender?: CastMember['gender']): string {
|
||||
return gender === 'female' ? castPlaceholderFemaleUrl : castPlaceholderMaleUrl;
|
||||
}
|
||||
|
||||
function formatRuntime(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
@@ -447,29 +476,34 @@ function handleOpenFolder(folderPath: string) {
|
||||
|
||||
/* Three-column layout */
|
||||
.content-layout {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr) minmax(240px, 320px);
|
||||
grid-template-areas:
|
||||
'left cast cast'
|
||||
'left main right';
|
||||
gap: 32px;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.content-main {
|
||||
flex: 0 0 500px;
|
||||
width: 500px;
|
||||
order: 0;
|
||||
grid-area: main;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-sidebar {
|
||||
flex: 0 1 400px;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-left {
|
||||
order: -1;
|
||||
grid-area: left;
|
||||
align-self: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-right {
|
||||
order: 1;
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
/* Synopsis box */
|
||||
@@ -478,15 +512,14 @@ function handleOpenFolder(folderPath: string) {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
.synopsis-poster {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.synopsis-text {
|
||||
@@ -613,7 +646,7 @@ function handleOpenFolder(folderPath: string) {
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--text-muted);
|
||||
color: #77d38a;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -625,9 +658,122 @@ function handleOpenFolder(folderPath: string) {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.meta-synopsis {
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.meta-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
color: #8ee59b;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.meta-summary-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-keywords-section {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.meta-value.keywords {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cast-gallery {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cast-gallery-wide {
|
||||
grid-area: cast;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.cast-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(94px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cast-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.cast-photo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.cast-photo-fallback {
|
||||
filter: saturate(0.9) contrast(1.05);
|
||||
}
|
||||
|
||||
.cast-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
padding: 28px 8px 8px;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.78) 45%, rgba(0, 0, 0, 0.95) 100%);
|
||||
}
|
||||
|
||||
.cast-name {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.cast-character {
|
||||
color: #8ee59b;
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.25;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.content-layout {
|
||||
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
'left cast'
|
||||
'left main'
|
||||
'left right';
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.content-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
'left'
|
||||
'cast'
|
||||
'main'
|
||||
'right';
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.sidebar-left {
|
||||
align-self: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Showreel gallery */
|
||||
@@ -1169,6 +1315,10 @@ function handleOpenFolder(folderPath: string) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.versions-list-sidebar .version-row {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.version-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1238,13 +1388,23 @@ function handleOpenFolder(folderPath: string) {
|
||||
}
|
||||
|
||||
.v-badge.res {
|
||||
background: rgba(66, 133, 244, 0.2);
|
||||
color: #4285f4;
|
||||
background: #1d4ed8;
|
||||
color: #eff6ff;
|
||||
}
|
||||
|
||||
.v-badge.qual {
|
||||
background: rgba(156, 39, 176, 0.2);
|
||||
color: #ce93d8;
|
||||
background: #7c3aed;
|
||||
color: #f5f3ff;
|
||||
}
|
||||
|
||||
.v-badge.codec {
|
||||
background: #0f766e;
|
||||
color: #ecfeff;
|
||||
}
|
||||
|
||||
.v-badge.audio {
|
||||
background: #b45309;
|
||||
color: #fffbeb;
|
||||
}
|
||||
|
||||
.version-sub {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// Type definitions for the media browser
|
||||
|
||||
export type CastGender = 'female' | 'male' | 'non_binary' | 'unknown';
|
||||
|
||||
export interface CastMember {
|
||||
name: string;
|
||||
character?: string | null;
|
||||
profile_path: string | null;
|
||||
gender?: CastGender | null;
|
||||
}
|
||||
|
||||
export interface SimilarMedia {
|
||||
|
||||
@@ -6,13 +6,14 @@ from typing import Optional
|
||||
|
||||
from aiopathlib import AsyncPath
|
||||
|
||||
from mediahive.hivescan.utils import get_media_folder_path
|
||||
from mediahive.hivescan.utils import get_media_folder_path, sanitize_filename
|
||||
|
||||
|
||||
# TMDb image configuration
|
||||
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
|
||||
DEFAULT_POSTER_SIZE = "w500"
|
||||
DEFAULT_BACKDROP_SIZE = "w1280"
|
||||
DEFAULT_PROFILE_SIZE = "w185"
|
||||
|
||||
# Shared async HTTP client (created lazily)
|
||||
_image_client: Optional[httpx.AsyncClient] = None
|
||||
@@ -113,3 +114,26 @@ async def download_season_poster(
|
||||
await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True)
|
||||
url = f"{TMDB_IMAGE_BASE}/{DEFAULT_POSTER_SIZE}{poster_path}"
|
||||
return await _download_image(url, output_path, f"season {season_num} poster")
|
||||
|
||||
|
||||
async def download_cast_profile(
|
||||
profile_path: str,
|
||||
media_folder: Path,
|
||||
cast_name: str,
|
||||
cast_index: int,
|
||||
size: str = DEFAULT_PROFILE_SIZE,
|
||||
) -> Optional[str]:
|
||||
"""Download a cached cast profile image from TMDb."""
|
||||
if not profile_path:
|
||||
return None
|
||||
|
||||
cast_dir = media_folder / "cast"
|
||||
safe_name = sanitize_filename(cast_name) or f"cast-{cast_index + 1:02d}"
|
||||
output_path = cast_dir / f"{cast_index + 1:02d}-{safe_name}.jpg"
|
||||
|
||||
if await AsyncPath(output_path).exists():
|
||||
return str(output_path)
|
||||
|
||||
await AsyncPath(cast_dir).mkdir(parents=True, exist_ok=True)
|
||||
url = f"{TMDB_IMAGE_BASE}/{size}{profile_path}"
|
||||
return await _download_image(url, output_path, f"cast profile for {cast_name}")
|
||||
|
||||
@@ -17,7 +17,7 @@ from mediahive.models.data import (
|
||||
Series,
|
||||
Torrent,
|
||||
)
|
||||
from mediahive.models.tmdb import EpisodeInfo, Info, SeasonInfo
|
||||
from mediahive.models.tmdb import CastMember, EpisodeInfo, Info, SeasonInfo
|
||||
from mediahive.hivescan.tmdb_client import (
|
||||
fetch_movie_info,
|
||||
fetch_series_info,
|
||||
@@ -31,8 +31,9 @@ from mediahive.hivescan.scanning import (
|
||||
find_playable_file,
|
||||
)
|
||||
from mediahive.hivescan.images import (
|
||||
download_cover_image,
|
||||
download_backdrop_image,
|
||||
download_cast_profile,
|
||||
download_cover_image,
|
||||
download_season_poster,
|
||||
)
|
||||
from mediahive.hivescan.utils import (
|
||||
@@ -70,6 +71,41 @@ async def _build_torrent_info(
|
||||
)
|
||||
|
||||
|
||||
async def _cache_cast_profiles(
|
||||
info: Optional[Info],
|
||||
media_folder: Path,
|
||||
media_root: Optional[str] = None,
|
||||
) -> Optional[Info]:
|
||||
"""Replace TMDb cast profile paths with cached local image paths."""
|
||||
if not info or not info.cast:
|
||||
return info
|
||||
|
||||
cached_cast = []
|
||||
for index, member in enumerate(info.cast):
|
||||
local_profile_path = None
|
||||
if member.profile_path:
|
||||
downloaded_path = await download_cast_profile(
|
||||
member.profile_path,
|
||||
media_folder,
|
||||
member.name,
|
||||
index,
|
||||
)
|
||||
if downloaded_path:
|
||||
local_profile_path = make_relative_path(downloaded_path, media_root)
|
||||
|
||||
cached_cast.append(
|
||||
CastMember(
|
||||
name=member.name,
|
||||
character=member.character or None,
|
||||
profile_path=local_profile_path,
|
||||
gender=member.gender,
|
||||
)
|
||||
)
|
||||
|
||||
info.cast = cached_cast
|
||||
return info
|
||||
|
||||
|
||||
async def _collect_episode_files(
|
||||
items: List[ParsedContent],
|
||||
) -> Dict[Tuple[int, int], List[Dict]]:
|
||||
@@ -385,6 +421,7 @@ async def _process_movies(
|
||||
|
||||
display_title = tmdb_info.title
|
||||
item_id = hashlib.md5(f"movie:{tmdb_id}".encode()).hexdigest()[:12]
|
||||
media_folder = get_media_folder_path(display_title, year, "movie", cover_dir)
|
||||
|
||||
# Find/download cover
|
||||
cover_path = None
|
||||
@@ -399,6 +436,7 @@ async def _process_movies(
|
||||
cover_path = await download_cover_image(
|
||||
tmdb_info.poster_path, display_title, year, "movie", cover_dir
|
||||
)
|
||||
tmdb_info = await _cache_cast_profiles(tmdb_info, media_folder, media_root)
|
||||
|
||||
torrents = {}
|
||||
for item in items:
|
||||
@@ -428,9 +466,6 @@ async def _process_movies(
|
||||
if media_root
|
||||
else best_version.playable_file
|
||||
)
|
||||
media_folder = get_media_folder_path(
|
||||
display_title, year, "movie", cover_dir
|
||||
)
|
||||
showreel_paths = get_expected_showreel_paths(
|
||||
media_folder, media_root=Path(media_root) if media_root else None
|
||||
)
|
||||
@@ -634,6 +669,7 @@ async def _process_series(
|
||||
cover_path = await download_cover_image(
|
||||
tmdb_info.poster_path, display_title, None, "series", cover_dir
|
||||
)
|
||||
tmdb_info = await _cache_cast_profiles(tmdb_info, series_folder, media_root)
|
||||
|
||||
# Download backdrop
|
||||
backdrop_path = None
|
||||
|
||||
@@ -217,6 +217,19 @@ async def fetch_season_details(
|
||||
)
|
||||
|
||||
|
||||
def _map_person_gender(value: object) -> str | None:
|
||||
"""Map TMDb person gender codes to stable string values."""
|
||||
if value == 1:
|
||||
return "female"
|
||||
if value == 2:
|
||||
return "male"
|
||||
if value == 3:
|
||||
return "non_binary"
|
||||
if value == 0:
|
||||
return "unknown"
|
||||
return None
|
||||
|
||||
|
||||
def _generate_title_variants(words: list[str], min_words: int = 2) -> list[str]:
|
||||
"""
|
||||
Generate title variants by progressively removing words from both ends.
|
||||
@@ -410,14 +423,15 @@ async def fetch_movie_info(title: str, year: Optional[int] = None) -> Optional[I
|
||||
alt_titles_set.discard(orig_title)
|
||||
alternative_titles = sorted(alt_titles_set) if alt_titles_set else None
|
||||
|
||||
# Extract top cast (limit to 10)
|
||||
# Extract full cast
|
||||
credits = details.get("credits", {})
|
||||
cast_data = credits.get("cast", [])[:10]
|
||||
cast_data = credits.get("cast", [])
|
||||
cast = [
|
||||
CastMember(
|
||||
name=c["name"],
|
||||
character=c.get("character", ""),
|
||||
profile_path=c.get("profile_path"),
|
||||
gender=_map_person_gender(c.get("gender")),
|
||||
)
|
||||
for c in cast_data
|
||||
]
|
||||
@@ -511,14 +525,15 @@ async def fetch_series_info(title: str) -> Optional[Info]:
|
||||
keywords_data = details.get("keywords", {}).get("results", [])
|
||||
keywords = [k["name"] for k in keywords_data]
|
||||
|
||||
# Extract top cast (limit to 10)
|
||||
# Extract full cast
|
||||
credits = details.get("credits", {})
|
||||
cast_data = credits.get("cast", [])[:10]
|
||||
cast_data = credits.get("cast", [])
|
||||
cast = [
|
||||
CastMember(
|
||||
name=c["name"],
|
||||
character=c.get("character", ""),
|
||||
profile_path=c.get("profile_path"),
|
||||
gender=_map_person_gender(c.get("gender")),
|
||||
)
|
||||
for c in cast_data
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@ class CastMember(msgspec.Struct):
|
||||
name: str
|
||||
character: str | None = None
|
||||
profile_path: str | None = None
|
||||
gender: str | None = None
|
||||
|
||||
|
||||
class SimilarMedia(msgspec.Struct):
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user