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="modal-body-inner">
|
||||||
<div class="content-layout">
|
<div class="content-layout">
|
||||||
<!-- Left sidebar - Synopsis -->
|
<!-- 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">
|
<div class="synopsis-box">
|
||||||
<h3 class="sidebar-title">Synopsis</h3>
|
<img
|
||||||
<p class="synopsis-text">{{ overview }}</p>
|
:src="synopsisPosterUrl"
|
||||||
|
:alt="`${item.title} poster`"
|
||||||
|
class="synopsis-poster"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-if="movieVersions.length > 0" class="versions-list versions-list-sidebar">
|
||||||
|
|
||||||
<!-- Main content - Formats/Versions -->
|
|
||||||
<div class="content-main">
|
|
||||||
<div v-if="movieVersions.length > 0" class="versions-list">
|
|
||||||
<div
|
<div
|
||||||
v-for="(version, index) in movieVersions"
|
v-for="(version, index) in movieVersions"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -107,27 +106,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Right sidebar - Metadata -->
|
||||||
<div v-if="item.type === 'movies'" class="content-sidebar sidebar-right">
|
<div v-if="item.type === 'movies'" class="content-sidebar sidebar-right">
|
||||||
<div class="movie-metadata">
|
<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">
|
<div v-if="movieDirector" class="meta-row">
|
||||||
<span class="meta-label">Director</span>
|
<span class="meta-label">Director</span>
|
||||||
<span class="meta-value">{{ movieDirector }}</span>
|
<span class="meta-value">{{ movieDirector }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="movieCast && movieCast.length > 0" class="meta-row">
|
<div v-if="movieStatus || movieReleaseDate" class="meta-summary">
|
||||||
<span class="meta-label">Cast</span>
|
<span v-if="movieStatus" class="meta-summary-item">{{ movieStatus }}</span>
|
||||||
<span class="meta-value">{{ movieCast.map(c => c.name).join(', ') }}</span>
|
<span v-if="movieReleaseDate" class="meta-summary-item">{{ movieReleaseDate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="movieReleaseDate" class="meta-row">
|
<div v-if="movieKeywords && movieKeywords.length > 0" class="meta-keywords-section">
|
||||||
<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>
|
|
||||||
<span class="meta-value keywords">{{ movieKeywords.join(', ') }}</span>
|
<span class="meta-value keywords">{{ movieKeywords.join(', ') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,8 +160,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted } from 'vue';
|
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 { 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 SeriesFullView from './SeriesFullView.vue';
|
||||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||||
|
|
||||||
@@ -151,7 +172,6 @@ const props = defineProps<{
|
|||||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
|
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
|
||||||
hasResumePosition: (filePath: string | null) => boolean;
|
hasResumePosition: (filePath: string | null) => boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: [];
|
close: [];
|
||||||
play: [string];
|
play: [string];
|
||||||
@@ -300,6 +320,11 @@ const backdropStyle = computed(() => {
|
|||||||
return {};
|
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)
|
// Check if a specific version is a disc format (Blu-ray disc has index.bdmv)
|
||||||
function isVersionDisc(version: Torrent): boolean {
|
function isVersionDisc(version: Torrent): boolean {
|
||||||
if (!version.playable_file) return false;
|
if (!version.playable_file) return false;
|
||||||
@@ -324,7 +349,7 @@ const movieDirector = computed(() => {
|
|||||||
|
|
||||||
const movieCast = computed(() => {
|
const movieCast = computed(() => {
|
||||||
if (props.item.type !== 'movies') return null;
|
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(() => {
|
const movieRuntime = computed(() => {
|
||||||
@@ -347,6 +372,10 @@ const movieKeywords = computed(() => {
|
|||||||
return (props.item.data as Movie).info?.keywords;
|
return (props.item.data as Movie).info?.keywords;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getCastPlaceholderUrl(gender?: CastMember['gender']): string {
|
||||||
|
return gender === 'female' ? castPlaceholderFemaleUrl : castPlaceholderMaleUrl;
|
||||||
|
}
|
||||||
|
|
||||||
function formatRuntime(minutes: number): string {
|
function formatRuntime(minutes: number): string {
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const mins = minutes % 60;
|
const mins = minutes % 60;
|
||||||
@@ -447,29 +476,34 @@ function handleOpenFolder(folderPath: string) {
|
|||||||
|
|
||||||
/* Three-column layout */
|
/* Three-column layout */
|
||||||
.content-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;
|
gap: 32px;
|
||||||
justify-content: space-between;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-main {
|
.content-main {
|
||||||
flex: 0 0 500px;
|
grid-area: main;
|
||||||
width: 500px;
|
min-width: 0;
|
||||||
order: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-sidebar {
|
.content-sidebar {
|
||||||
flex: 0 1 400px;
|
min-width: 0;
|
||||||
min-width: 200px;
|
|
||||||
max-width: 400px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-left {
|
.sidebar-left {
|
||||||
order: -1;
|
grid-area: left;
|
||||||
|
align-self: start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-right {
|
.sidebar-right {
|
||||||
order: 1;
|
grid-area: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Synopsis box */
|
/* Synopsis box */
|
||||||
@@ -478,15 +512,14 @@ function handleOpenFolder(folderPath: string) {
|
|||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-title {
|
.synopsis-poster {
|
||||||
font-size: 0.75rem;
|
display: block;
|
||||||
text-transform: uppercase;
|
width: 100%;
|
||||||
letter-spacing: 0.5px;
|
height: auto;
|
||||||
color: var(--text-muted);
|
object-fit: contain;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.synopsis-text {
|
.synopsis-text {
|
||||||
@@ -613,7 +646,7 @@ function handleOpenFolder(folderPath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta-label {
|
.meta-label {
|
||||||
color: var(--text-muted);
|
color: #77d38a;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -625,9 +658,122 @@ function handleOpenFolder(folderPath: string) {
|
|||||||
line-height: 1.4;
|
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 {
|
.meta-value.keywords {
|
||||||
color: var(--text-muted);
|
color: var(--text-primary);
|
||||||
font-size: 0.8rem;
|
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 */
|
/* Showreel gallery */
|
||||||
@@ -1169,6 +1315,10 @@ function handleOpenFolder(folderPath: string) {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.versions-list-sidebar .version-row {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.version-row {
|
.version-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1238,13 +1388,23 @@ function handleOpenFolder(folderPath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.v-badge.res {
|
.v-badge.res {
|
||||||
background: rgba(66, 133, 244, 0.2);
|
background: #1d4ed8;
|
||||||
color: #4285f4;
|
color: #eff6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-badge.qual {
|
.v-badge.qual {
|
||||||
background: rgba(156, 39, 176, 0.2);
|
background: #7c3aed;
|
||||||
color: #ce93d8;
|
color: #f5f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-badge.codec {
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ecfeff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-badge.audio {
|
||||||
|
background: #b45309;
|
||||||
|
color: #fffbeb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-sub {
|
.version-sub {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
// Type definitions for the media browser
|
// Type definitions for the media browser
|
||||||
|
|
||||||
|
export type CastGender = 'female' | 'male' | 'non_binary' | 'unknown';
|
||||||
|
|
||||||
export interface CastMember {
|
export interface CastMember {
|
||||||
name: string;
|
name: string;
|
||||||
character?: string | null;
|
character?: string | null;
|
||||||
profile_path: string | null;
|
profile_path: string | null;
|
||||||
|
gender?: CastGender | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimilarMedia {
|
export interface SimilarMedia {
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ from typing import Optional
|
|||||||
|
|
||||||
from aiopathlib import AsyncPath
|
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 configuration
|
||||||
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
|
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
|
||||||
DEFAULT_POSTER_SIZE = "w500"
|
DEFAULT_POSTER_SIZE = "w500"
|
||||||
DEFAULT_BACKDROP_SIZE = "w1280"
|
DEFAULT_BACKDROP_SIZE = "w1280"
|
||||||
|
DEFAULT_PROFILE_SIZE = "w185"
|
||||||
|
|
||||||
# Shared async HTTP client (created lazily)
|
# Shared async HTTP client (created lazily)
|
||||||
_image_client: Optional[httpx.AsyncClient] = None
|
_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)
|
await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True)
|
||||||
url = f"{TMDB_IMAGE_BASE}/{DEFAULT_POSTER_SIZE}{poster_path}"
|
url = f"{TMDB_IMAGE_BASE}/{DEFAULT_POSTER_SIZE}{poster_path}"
|
||||||
return await _download_image(url, output_path, f"season {season_num} poster")
|
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,
|
Series,
|
||||||
Torrent,
|
Torrent,
|
||||||
)
|
)
|
||||||
from mediahive.models.tmdb import EpisodeInfo, Info, SeasonInfo
|
from mediahive.models.tmdb import CastMember, EpisodeInfo, Info, SeasonInfo
|
||||||
from mediahive.hivescan.tmdb_client import (
|
from mediahive.hivescan.tmdb_client import (
|
||||||
fetch_movie_info,
|
fetch_movie_info,
|
||||||
fetch_series_info,
|
fetch_series_info,
|
||||||
@@ -31,8 +31,9 @@ from mediahive.hivescan.scanning import (
|
|||||||
find_playable_file,
|
find_playable_file,
|
||||||
)
|
)
|
||||||
from mediahive.hivescan.images import (
|
from mediahive.hivescan.images import (
|
||||||
download_cover_image,
|
|
||||||
download_backdrop_image,
|
download_backdrop_image,
|
||||||
|
download_cast_profile,
|
||||||
|
download_cover_image,
|
||||||
download_season_poster,
|
download_season_poster,
|
||||||
)
|
)
|
||||||
from mediahive.hivescan.utils import (
|
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(
|
async def _collect_episode_files(
|
||||||
items: List[ParsedContent],
|
items: List[ParsedContent],
|
||||||
) -> Dict[Tuple[int, int], List[Dict]]:
|
) -> Dict[Tuple[int, int], List[Dict]]:
|
||||||
@@ -385,6 +421,7 @@ async def _process_movies(
|
|||||||
|
|
||||||
display_title = tmdb_info.title
|
display_title = tmdb_info.title
|
||||||
item_id = hashlib.md5(f"movie:{tmdb_id}".encode()).hexdigest()[:12]
|
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
|
# Find/download cover
|
||||||
cover_path = None
|
cover_path = None
|
||||||
@@ -399,6 +436,7 @@ async def _process_movies(
|
|||||||
cover_path = await download_cover_image(
|
cover_path = await download_cover_image(
|
||||||
tmdb_info.poster_path, display_title, year, "movie", cover_dir
|
tmdb_info.poster_path, display_title, year, "movie", cover_dir
|
||||||
)
|
)
|
||||||
|
tmdb_info = await _cache_cast_profiles(tmdb_info, media_folder, media_root)
|
||||||
|
|
||||||
torrents = {}
|
torrents = {}
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -428,9 +466,6 @@ async def _process_movies(
|
|||||||
if media_root
|
if media_root
|
||||||
else best_version.playable_file
|
else best_version.playable_file
|
||||||
)
|
)
|
||||||
media_folder = get_media_folder_path(
|
|
||||||
display_title, year, "movie", cover_dir
|
|
||||||
)
|
|
||||||
showreel_paths = get_expected_showreel_paths(
|
showreel_paths = get_expected_showreel_paths(
|
||||||
media_folder, media_root=Path(media_root) if media_root else None
|
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(
|
cover_path = await download_cover_image(
|
||||||
tmdb_info.poster_path, display_title, None, "series", cover_dir
|
tmdb_info.poster_path, display_title, None, "series", cover_dir
|
||||||
)
|
)
|
||||||
|
tmdb_info = await _cache_cast_profiles(tmdb_info, series_folder, media_root)
|
||||||
|
|
||||||
# Download backdrop
|
# Download backdrop
|
||||||
backdrop_path = None
|
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]:
|
def _generate_title_variants(words: list[str], min_words: int = 2) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Generate title variants by progressively removing words from both ends.
|
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)
|
alt_titles_set.discard(orig_title)
|
||||||
alternative_titles = sorted(alt_titles_set) if alt_titles_set else None
|
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", {})
|
credits = details.get("credits", {})
|
||||||
cast_data = credits.get("cast", [])[:10]
|
cast_data = credits.get("cast", [])
|
||||||
cast = [
|
cast = [
|
||||||
CastMember(
|
CastMember(
|
||||||
name=c["name"],
|
name=c["name"],
|
||||||
character=c.get("character", ""),
|
character=c.get("character", ""),
|
||||||
profile_path=c.get("profile_path"),
|
profile_path=c.get("profile_path"),
|
||||||
|
gender=_map_person_gender(c.get("gender")),
|
||||||
)
|
)
|
||||||
for c in cast_data
|
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_data = details.get("keywords", {}).get("results", [])
|
||||||
keywords = [k["name"] for k in keywords_data]
|
keywords = [k["name"] for k in keywords_data]
|
||||||
|
|
||||||
# Extract top cast (limit to 10)
|
# Extract full cast
|
||||||
credits = details.get("credits", {})
|
credits = details.get("credits", {})
|
||||||
cast_data = credits.get("cast", [])[:10]
|
cast_data = credits.get("cast", [])
|
||||||
cast = [
|
cast = [
|
||||||
CastMember(
|
CastMember(
|
||||||
name=c["name"],
|
name=c["name"],
|
||||||
character=c.get("character", ""),
|
character=c.get("character", ""),
|
||||||
profile_path=c.get("profile_path"),
|
profile_path=c.get("profile_path"),
|
||||||
|
gender=_map_person_gender(c.get("gender")),
|
||||||
)
|
)
|
||||||
for c in cast_data
|
for c in cast_data
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class CastMember(msgspec.Struct):
|
|||||||
name: str
|
name: str
|
||||||
character: str | None = None
|
character: str | None = None
|
||||||
profile_path: str | None = None
|
profile_path: str | None = None
|
||||||
|
gender: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SimilarMedia(msgspec.Struct):
|
class SimilarMedia(msgspec.Struct):
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user