feat(ui): redesign release card presentation

This commit is contained in:
2026-05-22 18:26:29 +00:00
parent e23ea7d14e
commit a748adf87e
13 changed files with 1014 additions and 55 deletions
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"country-flag-icons": "^1.6.17",
"vue": "^3.4.0",
"vue-router": "^4.6.4"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+66
View File
@@ -0,0 +1,66 @@
<template>
<div v-if="showDolbyLogo" class="dolby-badges" :class="{ compact }">
<img
:src="dolbyLogoSrc"
:alt="dolbyLogoAlt"
class="dolby-logo"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import dolbyAtmosUrl from '../assets/dolby-atmos.webp';
import dolbyVisionUrl from '../assets/dolby-vision.webp';
import dolbyVisionAtmosUrl from '../assets/dolby-vision-atmos.webp';
const props = defineProps<{
hasDolbyVision?: boolean | null;
hasDolbyAtmos?: boolean | null;
isHdr?: boolean | null;
compact?: boolean;
}>();
const hasDolbyVision = computed(() => props.hasDolbyVision === true);
const hasDolbyAtmos = computed(() => props.hasDolbyAtmos === true);
const compact = computed(() => props.compact === true);
const showDolbyLogo = computed(() => hasDolbyVision.value || hasDolbyAtmos.value);
const dolbyLogoSrc = computed(() => {
if (hasDolbyVision.value && hasDolbyAtmos.value) return dolbyVisionAtmosUrl;
if (hasDolbyVision.value) return dolbyVisionUrl;
return dolbyAtmosUrl;
});
const dolbyLogoAlt = computed(() => {
if (hasDolbyVision.value && hasDolbyAtmos.value) return 'Dolby Vision + Dolby Atmos';
if (hasDolbyVision.value) return 'Dolby Vision';
return 'Dolby Atmos';
});
</script>
<style scoped>
.dolby-badges {
display: flex;
align-items: center;
justify-content: flex-end;
height: 100%;
width: max-content;
}
.dolby-logo {
height: 100%;
max-height: 44px;
min-height: 30px;
width: auto;
display: block;
border-radius: 3px;
filter: invert(1) brightness(1.1) contrast(1.05);
}
.dolby-badges.compact .dolby-logo {
max-height: 34px;
min-height: 24px;
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<template>
<div v-if="hasContent" class="language-flags" :class="{ compact }">
<span v-if="label" class="language-flags-label">{{ label }}</span>
<span class="language-flag-list">
<span
v-for="entry in flagEntries"
:key="entry.countryCode"
class="language-flag"
:title="`${entry.countryCode}: ${entry.sourceCodes.join(', ')}`"
v-html="entry.svg"
></span>
<span
v-for="code in unmappedCodes"
:key="`raw-${code}`"
class="language-code-fallback"
:title="code"
>{{ code }}</span>
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { buildLanguageFlags } from '../utils/languageFlags';
const props = defineProps<{
label?: string;
codes: string[] | null | undefined;
compact?: boolean;
}>();
const mapped = computed(() => buildLanguageFlags(props.codes));
const flagEntries = computed(() => mapped.value.flags);
const unmappedCodes = computed(() => mapped.value.unmappedCodes);
const hasContent = computed(() => flagEntries.value.length > 0 || unmappedCodes.value.length > 0);
const compact = computed(() => props.compact === true);
</script>
<style scoped>
.language-flags {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
white-space: nowrap;
vertical-align: middle;
}
.language-flags-label {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.7);
flex: 0 0 auto;
}
.language-flag-list {
display: inline-flex;
align-items: center;
gap: 0.25rem;
min-width: 0;
flex-wrap: nowrap;
white-space: nowrap;
}
.language-flag {
display: inline-flex;
flex: 0 0 auto;
width: 18px;
height: 12px;
border-radius: 2px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.28);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35) inset;
}
.language-flag :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
.language-code-fallback {
flex: 0 0 auto;
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 3px;
padding: 0.08rem 0.25rem;
text-transform: uppercase;
}
.language-flags.compact .language-flags-label {
font-size: 0.58rem;
}
.language-flags.compact .language-flag {
width: 16px;
height: 11px;
}
.language-flags.compact .language-code-fallback {
font-size: 0.52rem;
}
</style>
+222 -45
View File
@@ -91,29 +91,43 @@
v-for="(version, index) in movieVersions"
:key="index"
class="version-row"
:class="{ 'version-best': index === 0 }"
:class="{
'version-best': index === 0,
'version-selectable': !!version.playable_file,
'version-disabled': !version.playable_file,
}"
tabindex="0"
v-bind="navAttrs(2, index)"
@click="handleVersionActivate(version, $event)"
@keydown.enter.prevent="handleVersionActivate(version, $event)"
@keydown.space.prevent="handleVersionActivate(version, $event)"
:title="version.playable_file ? 'Click to play/continue. Alt+Click to open folder.' : 'No playable file'"
>
<div class="version-info">
<div class="version-main">
<div class="version-badges">
<span v-if="version.resolution" class="v-badge res">{{ version.resolution }}</span>
<span v-if="version.quality" class="v-badge qual">{{ version.quality }}</span>
<span v-if="version.codec" class="v-badge codec">{{ version.codec }}</span>
<span v-if="version.audio" class="v-badge audio">{{ version.audio }}</span>
<span v-if="getDisplayQualityBadge(version)" class="v-badge qual">{{ getDisplayQualityBadge(version) }}</span>
<span v-if="getDisplayCodecBadge(version)" class="v-badge codec">{{ getDisplayCodecBadge(version) }}</span>
<span v-if="getShowHdrBadge(version)" class="v-badge hdr">HDR</span>
<span v-if="getDisplayAudioBadge(version)" class="v-badge audio">{{ getDisplayAudioBadge(version) }}</span>
<span v-if="isVersionDisc(version)" class="v-disc">💿</span>
</div>
<div class="version-language-flags">
<LanguageFlags class="language-flags-audio" :codes="version.audio_languages" />
<span
v-if="hasLanguageDisplay(version.audio_languages) && hasLanguageDisplay(version.subtitle_languages)"
class="language-separator"
></span>
<LanguageFlags class="language-flags-subs" :codes="version.subtitle_languages" />
</div>
<div class="version-actions">
<button
class="btn btn-small btn-primary"
v-bind="navAttrs(2, index * 2)"
@click="handlePlay(version.playable_file)"
:disabled="!version.playable_file"
> {{ getPlayLabel(version.playable_file) }}</button>
<button
class="btn btn-small btn-secondary"
v-bind="navAttrs(2, index * 2 + 1)"
@click="handleOpenFolder(version.playable_file || '')"
>📁</button>
</div>
<div class="version-dolby-cell">
<DolbyBadges
class="version-dolby"
:has-dolby-vision="getHasDolbyVision(version)"
:has-dolby-atmos="getHasDolbyAtmos(version)"
:is-hdr="getHasHdr(version)"
/>
</div>
</div>
</div>
@@ -178,7 +192,10 @@ import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrow
import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg';
import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg';
import SeriesFullView from './SeriesFullView.vue';
import LanguageFlags from './LanguageFlags.vue';
import DolbyBadges from './DolbyBadges.vue';
import { navAttrs } from '../composables/useKeyboardNavigation';
import { buildLanguageFlags } from '../utils/languageFlags';
const props = defineProps<{
item: MediaItem;
@@ -385,6 +402,74 @@ const synopsisPosterUrl = computed(() => {
return getCoverUrl(props.item.cover_path);
});
const dolbyTagPattern = /\b(dolby|atmos|vision|dovi|dv)\b/i;
const dolbyVisionPattern = /\b(dolby\s*vision|dovi|\bdv\b)\b/i;
const dolbyAtmosPattern = /\b(dolby\s*atmos|atmos)\b/i;
const hdrPattern = /\bhdr\b|smpte\s*2084|bt\s*2020|hlg/i;
const blurayTagPattern = /\bblu[\s.-]*ray\b/i;
function hasDolbyTag(value: string | null | undefined): boolean {
return Boolean(value && dolbyTagPattern.test(value));
}
function hasAnyTag(
pattern: RegExp,
...values: Array<string | null | undefined>
): boolean {
return values.some((value) => Boolean(value && pattern.test(value)));
}
function getHasDolbyVision(version: Torrent): boolean {
return (
version.has_dolby_vision === true
|| hasAnyTag(dolbyVisionPattern, version.quality, version.codec, version.audio, version.title)
);
}
function getHasDolbyAtmos(version: Torrent): boolean {
return (
version.has_dolby_atmos === true
|| hasAnyTag(dolbyAtmosPattern, version.quality, version.codec, version.audio, version.title)
);
}
function getHasHdr(version: Torrent): boolean {
return (
version.is_hdr === true
|| hasAnyTag(hdrPattern, version.quality, version.codec, version.audio, version.title)
);
}
function getDisplayQualityBadge(version: Torrent): string | null {
if (!version.quality || hasDolbyTag(version.quality)) return null;
if (blurayTagPattern.test(version.quality) && !isVersionDisc(version)) return null;
return version.quality;
}
function getDisplayCodecBadge(version: Torrent): string | null {
if (!version.codec || hasDolbyTag(version.codec)) return null;
return version.codec;
}
function getDisplayAudioBadge(version: Torrent): string | null {
if (!version.audio || hasDolbyTag(version.audio)) return null;
return version.audio;
}
function hasLanguageDisplay(codes: string[] | null | undefined): boolean {
const mapped = buildLanguageFlags(codes);
return mapped.flags.length > 0 || mapped.unmappedCodes.length > 0;
}
function hasHdrTag(value: string | null | undefined): boolean {
return Boolean(value && hdrPattern.test(value));
}
function getShowHdrBadge(version: Torrent): boolean {
if (!getHasHdr(version)) return false;
return !hasHdrTag(version.quality) && !hasHdrTag(version.codec) && !hasHdrTag(version.audio);
}
// 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;
@@ -485,8 +570,13 @@ function handlePlay(filePath: string | null) {
}
}
function getPlayLabel(filePath: string | null): string {
return props.hasResumePosition(filePath) ? 'Continue' : 'Play';
function handleVersionActivate(version: Torrent, event: MouseEvent | KeyboardEvent) {
if (!version.playable_file) return;
if (event.altKey) {
handleOpenFolder(version.playable_file);
return;
}
handlePlay(version.playable_file);
}
function handleOpenFolder(folderPath: string) {
@@ -938,6 +1028,75 @@ function handleOpenFolder(folderPath: string) {
gap: 6px;
}
.version-language-flags {
display: inline-flex;
align-items: center;
gap: 2px;
width: 100%;
white-space: nowrap;
overflow: hidden;
min-width: 0;
}
.version-language-flags > .language-flags-audio {
flex: 0 0 auto;
}
.version-language-flags > .language-flags-subs {
flex: 1 1 auto;
min-width: 0;
-webkit-mask-image: linear-gradient(to right, black calc(100% - 18px), transparent);
mask-image: linear-gradient(to right, black calc(100% - 18px), transparent);
}
.version-language-flags > .language-flags-subs :deep(.language-flags) {
display: inline-flex;
max-width: 100%;
overflow: hidden;
}
.version-language-flags > .language-flags-subs :deep(.language-flag-list) {
width: max-content;
max-width: none;
overflow: hidden;
}
.language-separator {
color: rgba(255, 255, 255, 0.8);
font-size: 0.98rem;
font-weight: 700;
line-height: 1;
margin: 0;
}
.version-meta-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
grid-template-rows: auto auto;
column-gap: 8px;
row-gap: 6px;
align-items: stretch;
}
.version-meta-grid .version-badges {
grid-column: 1;
grid-row: 1;
}
.version-meta-grid .version-language-flags {
grid-column: 1;
grid-row: 2;
}
.version-dolby {
grid-column: 2;
grid-row: 1 / span 2;
align-self: stretch;
justify-self: end;
display: flex;
min-width: 0;
}
.version-badge {
font-size: 0.75rem;
padding: 2px 8px;
@@ -983,7 +1142,7 @@ function handleOpenFolder(folderPath: string) {
display: flex;
gap: 8px;
flex-shrink: 0;
margin-left: 16px;
margin-left: 0;
}
/* Season header styling */
@@ -1402,12 +1561,13 @@ function handleOpenFolder(folderPath: string) {
}
.version-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: stretch;
column-gap: 8px;
row-gap: 6px;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.4);
background: rgba(10, 14, 22, 0.2);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 8px;
@@ -1416,28 +1576,53 @@ function handleOpenFolder(folderPath: string) {
}
.version-row:hover {
background: rgba(0, 0, 0, 0.5);
background: rgba(10, 14, 22, 0.28);
border-color: rgba(255, 255, 255, 0.2);
}
.version-row.version-best {
border-color: rgba(70, 211, 105, 0.5);
background: rgba(70, 211, 105, 0.15);
border-color: rgba(255, 255, 255, 0.1);
background: rgba(10, 14, 22, 0.2);
}
.version-row.version-best:hover {
background: rgba(70, 211, 105, 0.25);
background: rgba(10, 14, 22, 0.28);
border-color: rgba(255, 255, 255, 0.2);
}
.version-info {
flex: 1;
.version-row.version-selectable {
cursor: pointer;
}
.version-row.version-selectable:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: 2px;
}
.version-row.version-disabled {
cursor: not-allowed;
opacity: 0.75;
}
.version-main {
grid-column: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
min-width: 0;
}
.version-actions {
.version-dolby-cell {
grid-column: 2;
display: flex;
gap: 8px;
flex-shrink: 0;
align-items: stretch;
justify-content: flex-end;
min-width: 0;
}
.version-dolby {
align-self: stretch;
}
.btn-small {
@@ -1489,17 +1674,9 @@ function handleOpenFolder(folderPath: string) {
color: #fffbeb;
}
.version-sub {
font-size: 0.65rem;
color: var(--text-muted, rgba(255, 255, 255, 0.5));
.v-badge.hdr {
background: #166534;
color: #dcfce7;
}
/* Collage images for header */
.collage-images {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px;
}
</style>
+140 -8
View File
@@ -138,7 +138,25 @@
:key="index"
class="context-menu-version"
>
<div class="version-main">
<div class="version-label">{{ getVersionLabel(torrent) }}</div>
<div class="version-language-flags">
<LanguageFlags class="language-flags-audio" :codes="torrent.audio_languages" compact />
<span
v-if="hasLanguageDisplay(torrent.audio_languages) && hasLanguageDisplay(torrent.subtitle_languages)"
class="language-separator"
></span>
<LanguageFlags class="language-flags-subs" :codes="torrent.subtitle_languages" compact />
</div>
</div>
<div class="version-dolby-cell">
<DolbyBadges
class="version-dolby"
:has-dolby-vision="getHasDolbyVision(torrent)"
:has-dolby-atmos="getHasDolbyAtmos(torrent)"
:is-hdr="getHasHdr(torrent)"
/>
</div>
<div class="version-actions">
<button
class="ctx-btn ctx-btn-play"
@@ -167,6 +185,9 @@ import { computed, ref, nextTick, watch } from 'vue';
import type { Series, Season, Episode, Torrent } from '../types';
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from '../api';
import { navAttrs } from '../composables/useKeyboardNavigation';
import LanguageFlags from './LanguageFlags.vue';
import DolbyBadges from './DolbyBadges.vue';
import { buildLanguageFlags } from '../utils/languageFlags';
const props = defineProps<{
series: Series;
@@ -320,13 +341,59 @@ function handleOpenFolder(folderPath: string) {
closeContextMenu();
}
const dolbyTagPattern = /\b(dolby|atmos|vision|dovi|dv)\b/i;
const dolbyVisionPattern = /\b(dolby\s*vision|dovi|\bdv\b)\b/i;
const dolbyAtmosPattern = /\b(dolby\s*atmos|atmos)\b/i;
const hdrPattern = /\bhdr\b|smpte\s*2084|bt\s*2020|hlg/i;
function hasDolbyTag(value: string | null | undefined): boolean {
return Boolean(value && dolbyTagPattern.test(value));
}
function hasAnyTag(
pattern: RegExp,
...values: Array<string | null | undefined>
): boolean {
return values.some((value) => Boolean(value && pattern.test(value)));
}
function getHasDolbyVision(torrent: Torrent): boolean {
return (
torrent.has_dolby_vision === true
|| hasAnyTag(dolbyVisionPattern, torrent.quality, torrent.codec, torrent.audio, torrent.title)
);
}
function getHasDolbyAtmos(torrent: Torrent): boolean {
return (
torrent.has_dolby_atmos === true
|| hasAnyTag(dolbyAtmosPattern, torrent.quality, torrent.codec, torrent.audio, torrent.title)
);
}
function getHasHdr(torrent: Torrent): boolean {
return (
torrent.is_hdr === true
|| hasAnyTag(hdrPattern, torrent.quality, torrent.codec, torrent.audio, torrent.title)
);
}
function hasLanguageDisplay(codes: string[] | null | undefined): boolean {
const mapped = buildLanguageFlags(codes);
return mapped.flags.length > 0 || mapped.unmappedCodes.length > 0;
}
// Get version display label
function getVersionLabel(torrent: Torrent): string {
const parts: string[] = [];
if (torrent.resolution) parts.push(torrent.resolution);
if (torrent.quality) parts.push(torrent.quality);
if (torrent.codec) parts.push(torrent.codec);
if (torrent.audio) parts.push(torrent.audio);
if (torrent.quality && !hasDolbyTag(torrent.quality)) parts.push(torrent.quality);
if (torrent.codec && !hasDolbyTag(torrent.codec)) parts.push(torrent.codec);
if (torrent.audio && !hasDolbyTag(torrent.audio)) parts.push(torrent.audio);
const hasExistingHdr = [torrent.quality, torrent.codec, torrent.audio].some(
(value) => Boolean(value && hdrPattern.test(value))
);
if (getHasHdr(torrent) && !hasExistingHdr) parts.push('HDR');
return parts.length > 0 ? parts.join(' • ') : 'Unknown';
}
@@ -931,11 +998,12 @@ function handlePlay(episode: Episode) {
}
.context-menu-version {
display: flex;
align-items: center;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) max-content max-content;
align-items: stretch;
padding: 10px 16px;
gap: 12px;
column-gap: 8px;
row-gap: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background-color 0.15s ease;
}
@@ -947,15 +1015,79 @@ function handlePlay(episode: Episode) {
.version-label {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.8);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.version-main {
grid-column: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
min-width: 0;
}
.version-dolby-cell {
grid-column: 2;
display: flex;
align-items: stretch;
justify-content: flex-end;
min-width: 0;
}
.version-dolby {
align-self: stretch;
}
.version-language-flags {
display: inline-flex;
align-items: center;
gap: 2px;
width: 100%;
white-space: nowrap;
overflow: hidden;
min-width: 0;
}
.version-language-flags > .language-flags-audio {
flex: 0 0 auto;
}
.version-language-flags > .language-flags-subs {
flex: 1 1 auto;
min-width: 0;
-webkit-mask-image: linear-gradient(to right, black calc(100% - 14px), transparent);
mask-image: linear-gradient(to right, black calc(100% - 14px), transparent);
}
.version-language-flags > .language-flags-subs :deep(.language-flags) {
display: inline-flex;
max-width: 100%;
overflow: hidden;
}
.version-language-flags > .language-flags-subs :deep(.language-flag-list) {
width: max-content;
max-width: none;
overflow: hidden;
}
.language-separator {
color: rgba(255, 255, 255, 0.8);
font-size: 0.9rem;
font-weight: 700;
line-height: 1;
margin: 0;
}
.version-actions {
grid-column: 3;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
+3
View File
@@ -49,6 +49,9 @@ export interface Torrent {
audio: string | null;
audio_languages: string[] | null;
subtitle_languages: string[] | null;
is_hdr: boolean;
has_dolby_vision: boolean;
has_dolby_atmos: boolean;
encoder: string | null;
size: number | null;
added_at: number | null;
+446
View File
@@ -0,0 +1,446 @@
import * as flagSvgs from 'country-flag-icons/string/3x2';
export interface LanguageFlagEntry {
countryCode: string;
svg: string;
sourceCodes: string[];
}
const FLAGS = flagSvgs as Record<string, string>;
const LANGUAGE_TO_COUNTRY: Record<string, string> = {
// English
en: 'GB',
eng: 'GB',
// Spanish (including LATAM variants collapsed to Spain flag)
es: 'ES',
spa: 'ES',
esl: 'ES',
spl: 'ES',
'es-es': 'ES',
'es-419': 'ES',
'spa-la': 'ES',
// Portuguese
pt: 'PT',
por: 'PT',
'pt-pt': 'PT',
'pt-br': 'BR',
// Major European languages
fr: 'FR',
fra: 'FR',
fre: 'FR',
de: 'DE',
deu: 'DE',
ger: 'DE',
it: 'IT',
ita: 'IT',
nl: 'NL',
nld: 'NL',
dut: 'NL',
sv: 'SE',
swe: 'SE',
no: 'NO',
nor: 'NO',
da: 'DK',
dan: 'DK',
fi: 'FI',
fin: 'FI',
pl: 'PL',
: 'PL',
cs: 'CZ',
ces: 'CZ',
cze: 'CZ',
hu: 'HU',
hun: 'HU',
ro: 'RO',
ron: 'RO',
rum: 'RO',
el: 'GR',
gre: 'GR',
ell: 'GR',
tr: 'TR',
tur: 'TR',
// Slavic / Eurasian
ru: 'RU',
rus: 'RU',
uk: 'UA',
ukr: 'UA',
bg: 'BG',
bul: 'BG',
sr: 'RS',
srp: 'RS',
hr: 'HR',
hrv: 'HR',
sl: 'SI',
slv: 'SI',
sk: 'SK',
slk: 'SK',
slo: 'SK',
// East / South / SE Asia
ja: 'JP',
jpn: 'JP',
ko: 'KR',
kor: 'KR',
zh: 'CN',
zho: 'CN',
chi: 'CN',
yue: 'HK',
th: 'TH',
tha: 'TH',
vi: 'VN',
vie: 'VN',
id: 'ID',
ind: 'ID',
ms: 'MY',
msa: 'MY',
may: 'MY',
hi: 'IN',
hin: 'IN',
// Middle East / Africa
ar: 'SA',
ara: 'SA',
he: 'IL',
heb: 'IL',
fa: 'IR',
fas: 'IR',
per: 'IR',
ur: 'PK',
urd: 'PK',
sw: 'TZ',
swa: 'TZ',
// Other common
ca: 'ES',
cat: 'ES',
eu: 'ES',
baq: 'ES',
eus: 'ES',
};
function normalizeLanguageCode(code: string): string {
return code.trim().toLowerCase().replace('_', '-');
}
function normalizeLanguageName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ');
}
let _browserLanguagePreferences: string[] | null = null;
let _browserPreferenceRanks: Map<string, number> | null = null;
const LANGUAGE_NAME_TO_CODE: Record<string, string> = {
english: 'en',
spanish: 'es',
portuguese: 'pt',
french: 'fr',
german: 'de',
italian: 'it',
dutch: 'nl',
swedish: 'sv',
norwegian: 'no',
danish: 'da',
finnish: 'fi',
polish: 'pl',
czech: 'cs',
hungarian: 'hu',
romanian: 'ro',
greek: 'el',
turkish: 'tr',
russian: 'ru',
ukrainian: 'uk',
bulgarian: 'bg',
serbian: 'sr',
croatian: 'hr',
slovenian: 'sl',
slovak: 'sk',
japanese: 'ja',
korean: 'ko',
chinese: 'zh',
cantonese: 'yue',
thai: 'th',
vietnamese: 'vi',
indonesian: 'id',
malay: 'ms',
hindi: 'hi',
arabic: 'ar',
hebrew: 'he',
persian: 'fa',
urdu: 'ur',
swahili: 'sw',
catalan: 'ca',
basque: 'eu',
};
function getBrowserLanguagePreferences(): string[] {
if (_browserLanguagePreferences) return _browserLanguagePreferences;
const preferences: string[] = [];
if (typeof navigator !== 'undefined') {
if (Array.isArray(navigator.languages)) {
for (const lang of navigator.languages) {
if (typeof lang === 'string' && lang.trim()) {
preferences.push(normalizeLanguageCode(lang));
}
}
}
if (typeof navigator.language === 'string' && navigator.language.trim()) {
preferences.push(normalizeLanguageCode(navigator.language));
}
}
const deduped = Array.from(new Set(preferences));
_browserLanguagePreferences = deduped;
return deduped;
}
function getBrowserPreferenceRanks(): Map<string, number> {
if (_browserPreferenceRanks) return _browserPreferenceRanks;
const preferences = getBrowserLanguagePreferences();
const ranks = new Map<string, number>();
const display = new Intl.DisplayNames(['en'], { type: 'language' });
for (const [index, pref] of preferences.entries()) {
const base = pref.split('-', 1)[0];
if (!ranks.has(pref)) ranks.set(pref, index);
if (!ranks.has(base)) ranks.set(base, index);
const prefName = display.of(pref);
if (prefName) {
const key = normalizeLanguageName(prefName);
if (!ranks.has(key)) ranks.set(key, index);
}
const baseName = display.of(base);
if (baseName) {
const key = normalizeLanguageName(baseName);
if (!ranks.has(key)) ranks.set(key, index);
}
}
_browserPreferenceRanks = ranks;
return ranks;
}
function resolveLanguageIdentifier(value: string): string {
const normalized = normalizeLanguageCode(value);
if (/^[a-z]{2,3}(?:-[a-z0-9]{2,})?$/i.test(normalized)) {
return normalized;
}
const nameKey = normalizeLanguageName(value);
const byName = LANGUAGE_NAME_TO_CODE[nameKey];
if (byName) return byName;
return normalized;
}
function getPreferenceRank(value: string, preferenceRanks: Map<string, number>): number {
const normalized = resolveLanguageIdentifier(value);
const exact = preferenceRanks.get(normalized);
if (exact !== undefined) return exact;
const base = normalized.split('-', 1)[0];
const baseRank = preferenceRanks.get(base);
if (baseRank !== undefined) return baseRank;
const nameKey = normalizeLanguageName(toLanguageName(normalized));
const nameRank = preferenceRanks.get(nameKey);
if (nameRank !== undefined) return nameRank;
const rawNameRank = preferenceRanks.get(normalizeLanguageName(value));
if (rawNameRank !== undefined) return rawNameRank;
return Number.MAX_SAFE_INTEGER;
}
export function mapLanguageToCountry(code: string): string | null {
const normalized = resolveLanguageIdentifier(code);
const direct = LANGUAGE_TO_COUNTRY[normalized];
if (direct) return direct;
// region-tag style code like en-us / pt-br / es-mx
const hyphenParts = normalized.split('-');
if (hyphenParts.length >= 2) {
const region = hyphenParts[hyphenParts.length - 1];
if (/^[a-z]{2}$/i.test(region)) {
return region.toUpperCase();
}
}
return null;
}
export function buildLanguageFlags(codes: string[] | null | undefined): {
flags: LanguageFlagEntry[];
unmappedCodes: string[];
} {
if (!codes || codes.length === 0) {
return { flags: [], unmappedCodes: [] };
}
const byCountry = new Map<string, string[]>();
const unmapped: string[] = [];
const seenUnmapped = new Set<string>();
const preferenceRanks = getBrowserPreferenceRanks();
const ordered = codes
.map((raw, index) => ({ raw, index }))
.filter((v) => Boolean(v.raw && String(v.raw).trim()))
.sort((a, b) => {
const aRank = getPreferenceRank(String(a.raw), preferenceRanks);
const bRank = getPreferenceRank(String(b.raw), preferenceRanks);
if (aRank !== bRank) return aRank - bRank;
return a.index - b.index;
});
for (const { raw } of ordered) {
if (!raw) continue;
const countryCode = mapLanguageToCountry(raw);
if (!countryCode) {
const upper = raw.toUpperCase();
if (!seenUnmapped.has(upper)) {
seenUnmapped.add(upper);
unmapped.push(upper);
}
continue;
}
if (!FLAGS[countryCode]) {
const upper = raw.toUpperCase();
if (!seenUnmapped.has(upper)) {
seenUnmapped.add(upper);
unmapped.push(upper);
}
continue;
}
const existing = byCountry.get(countryCode) || [];
existing.push(raw);
byCountry.set(countryCode, existing);
}
const flags: LanguageFlagEntry[] = Array.from(byCountry.entries()).map(
([countryCode, sourceCodes]) => ({
countryCode,
svg: FLAGS[countryCode],
sourceCodes,
})
);
return {
flags,
unmappedCodes: unmapped,
};
}
const LANGUAGE_NAME_OVERRIDES: Record<string, string> = {
eng: 'English',
spa: 'Spanish',
'spa-la': 'Spanish',
esl: 'Spanish',
spl: 'Spanish',
por: 'Portuguese',
fre: 'French',
fra: 'French',
ger: 'German',
deu: 'German',
ita: 'Italian',
nld: 'Dutch',
dut: 'Dutch',
swe: 'Swedish',
nor: 'Norwegian',
dan: 'Danish',
fin: 'Finnish',
pol: 'Polish',
ces: 'Czech',
cze: 'Czech',
hun: 'Hungarian',
ron: 'Romanian',
rum: 'Romanian',
ell: 'Greek',
gre: 'Greek',
tur: 'Turkish',
rus: 'Russian',
ukr: 'Ukrainian',
bul: 'Bulgarian',
srp: 'Serbian',
hrv: 'Croatian',
slv: 'Slovenian',
slk: 'Slovak',
slo: 'Slovak',
jpn: 'Japanese',
kor: 'Korean',
zho: 'Chinese',
chi: 'Chinese',
yue: 'Cantonese',
tha: 'Thai',
vie: 'Vietnamese',
ind: 'Indonesian',
msa: 'Malay',
may: 'Malay',
hin: 'Hindi',
ara: 'Arabic',
heb: 'Hebrew',
fas: 'Persian',
per: 'Persian',
urd: 'Urdu',
swa: 'Swahili',
cat: 'Catalan',
eus: 'Basque',
baq: 'Basque',
};
function toLanguageName(code: string): string {
const normalized = normalizeLanguageCode(code);
const override = LANGUAGE_NAME_OVERRIDES[normalized];
if (override) return override;
const display = new Intl.DisplayNames(['en'], { type: 'language' });
const candidate = display.of(normalized);
if (candidate) return candidate;
const base = normalized.split('-', 1)[0];
const baseOverride = LANGUAGE_NAME_OVERRIDES[base];
if (baseOverride) return baseOverride;
const baseCandidate = display.of(base);
if (baseCandidate) return baseCandidate;
return code.toUpperCase();
}
function summarizeLanguageCodes(codes: string[] | null | undefined): string {
if (!codes || codes.length === 0) return '';
const names: string[] = [];
const seen = new Set<string>();
for (const raw of codes) {
if (!raw) continue;
const name = toLanguageName(raw).trim();
if (!name) continue;
const key = name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
names.push(name);
}
return names.join(', ');
}
export function formatAudioSubtitleSummary(
audioCodes: string[] | null | undefined,
subtitleCodes: string[] | null | undefined
): string {
const audio = summarizeLanguageCodes(audioCodes);
const subs = summarizeLanguageCodes(subtitleCodes);
if (audio && subs) return `${audio} / ${subs}`;
return audio || subs;
}
+12
View File
@@ -73,6 +73,9 @@ async def _build_torrent_info(
audio=item.audio,
audio_languages=probe_info.audio_languages if probe_info else None,
subtitle_languages=probe_info.subtitle_languages if probe_info else None,
is_hdr=probe_info.is_hdr if probe_info else False,
has_dolby_vision=probe_info.has_dolby_vision if probe_info else False,
has_dolby_atmos=probe_info.has_dolby_atmos if probe_info else False,
encoder=item.encoder,
size=size,
added_at=added_at,
@@ -149,6 +152,9 @@ async def _collect_episode_files(
"probed_resolution": probe.resolution,
"audio_languages": probe.audio_languages,
"subtitle_languages": probe.subtitle_languages,
"is_hdr": probe.is_hdr,
"has_dolby_vision": probe.has_dolby_vision,
"has_dolby_atmos": probe.has_dolby_atmos,
"resolution": item.resolution,
"quality": item.quality,
"codec": item.codec,
@@ -193,6 +199,9 @@ async def _collect_episode_files(
"probed_resolution": probe.resolution,
"audio_languages": probe.audio_languages,
"subtitle_languages": probe.subtitle_languages,
"is_hdr": probe.is_hdr,
"has_dolby_vision": probe.has_dolby_vision,
"has_dolby_atmos": probe.has_dolby_atmos,
"resolution": item.resolution,
"quality": item.quality,
"codec": item.codec,
@@ -258,6 +267,9 @@ def _build_episodes_data(
audio=f.get("audio"),
audio_languages=f.get("audio_languages"),
subtitle_languages=f.get("subtitle_languages"),
is_hdr=bool(f.get("is_hdr")),
has_dolby_vision=bool(f.get("has_dolby_vision")),
has_dolby_atmos=bool(f.get("has_dolby_atmos")),
encoder=f.get("encoder"),
size=f.get("size"),
)
+11 -1
View File
@@ -334,6 +334,8 @@ class MediaProbeInfo:
height: int | None = None
is_hdr: bool = False
dovi_profile: int | None = None
has_dolby_vision: bool = False
has_dolby_atmos: bool = False
resolution: str | None = None
audio_languages: list[str] | None = None
subtitle_languages: list[str] | None = None
@@ -405,12 +407,20 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
info.dovi_profile = int(dovi_match.group(1))
elif "dvhe" in lower_text or "dvh1" in lower_text or "dav1" in lower_text:
info.dovi_profile = 7
info.has_dolby_vision = info.dovi_profile is not None
audio_languages: list[str] = []
for match in _audio_stream_re.finditer(text):
for line in text.splitlines():
if "Stream #" not in line or "Audio:" not in line:
continue
match = _audio_stream_re.search(line)
if not match:
continue
lang = _lang_code(match.group(1))
if lang and lang not in audio_languages:
audio_languages.append(lang)
if "atmos" in line.lower():
info.has_dolby_atmos = True
info.audio_languages = audio_languages or None
subtitle_languages: list[str] = []
+3
View File
@@ -26,6 +26,9 @@ class Torrent(msgspec.Struct):
audio: str | None = None
audio_languages: list[str] | None = None
subtitle_languages: list[str] | None = None
is_hdr: bool = False
has_dolby_vision: bool = False
has_dolby_atmos: bool = False
encoder: str | None = None
size: int | None = None
added_at: int | None = None