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:
2026-05-21 00:43:09 +00:00
parent 7aeec90e4f
commit e40c953617
9 changed files with 389 additions and 58 deletions
@@ -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

+208 -48
View File
@@ -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 {
+3
View File
@@ -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 {
+25 -1
View File
@@ -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}")
+41 -5
View File
@@ -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
+19 -4
View File
@@ -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
] ]
+1
View File
@@ -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.