Add preferred format settings and release preference sorting
This commit is contained in:
@@ -141,6 +141,96 @@
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2 class="settings-section-title">Preferred Format</h2>
|
||||
<p class="settings-section-desc">Preferred format when multiple versions are available.</p>
|
||||
|
||||
<div class="format-grid">
|
||||
<div class="format-row format-row-stack">
|
||||
<label class="format-radio-label" for="resolution-hd">
|
||||
<input
|
||||
id="resolution-hd"
|
||||
class="format-radio"
|
||||
type="radio"
|
||||
name="resolution-preference"
|
||||
:checked="settings.preferredResolution === 'r2'"
|
||||
@change="setPreferredResolution('r2')"
|
||||
/>
|
||||
HD or lower
|
||||
</label>
|
||||
<label class="format-radio-label" for="resolution-fhd">
|
||||
<input
|
||||
id="resolution-fhd"
|
||||
class="format-radio"
|
||||
type="radio"
|
||||
name="resolution-preference"
|
||||
:checked="settings.preferredResolution === 'r3'"
|
||||
@change="setPreferredResolution('r3')"
|
||||
/>
|
||||
Full HD
|
||||
</label>
|
||||
<label class="format-radio-label" for="resolution-4k">
|
||||
<input
|
||||
id="resolution-4k"
|
||||
class="format-radio"
|
||||
type="radio"
|
||||
name="resolution-preference"
|
||||
:checked="settings.preferredResolution === 'r4'"
|
||||
@change="setPreferredResolution('r4')"
|
||||
/>
|
||||
4K
|
||||
</label>
|
||||
<label class="format-radio-label" for="resolution-highest">
|
||||
<input
|
||||
id="resolution-highest"
|
||||
class="format-radio"
|
||||
type="radio"
|
||||
name="resolution-preference"
|
||||
:checked="settings.preferredResolution === 'rmax'"
|
||||
@change="setPreferredResolution('rmax')"
|
||||
/>
|
||||
Highest
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="format-row format-row-stack">
|
||||
<label class="format-radio-label" for="hdr-none">
|
||||
<input
|
||||
id="hdr-none"
|
||||
class="format-radio"
|
||||
type="radio"
|
||||
name="hdr-preference"
|
||||
:checked="settings.preferredHdr === 'none'"
|
||||
@change="setPreferredHdr('none')"
|
||||
/>
|
||||
No HDR
|
||||
</label>
|
||||
<label class="format-radio-label" for="hdr-hdr10plus">
|
||||
<input
|
||||
id="hdr-hdr10plus"
|
||||
class="format-radio"
|
||||
type="radio"
|
||||
name="hdr-preference"
|
||||
:checked="settings.preferredHdr === 'hdr10plus'"
|
||||
@change="setPreferredHdr('hdr10plus')"
|
||||
/>
|
||||
HDR10+
|
||||
</label>
|
||||
<label class="format-radio-label" for="hdr-dovi">
|
||||
<input
|
||||
id="hdr-dovi"
|
||||
class="format-radio"
|
||||
type="radio"
|
||||
name="hdr-preference"
|
||||
:checked="settings.preferredHdr === 'dovi'"
|
||||
@change="setPreferredHdr('dovi')"
|
||||
/>
|
||||
Dolby Vision
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,6 +244,13 @@ import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
import logoUrl from "../assets/mediahive.webp"
|
||||
import { fetchRoots, replaceRoots, pickFolderAndAddRoot } from "../api"
|
||||
import HexKeyboard from "./HexKeyboard.vue"
|
||||
import {
|
||||
useSettings,
|
||||
type ResolutionPreference,
|
||||
type HdrPreference,
|
||||
} from "../composables/useSettings"
|
||||
|
||||
const settings = useSettings()
|
||||
|
||||
interface RootEntry {
|
||||
root_id: string
|
||||
@@ -189,6 +286,14 @@ onUnmounted(() => window.removeEventListener("pywebviewready", _onPywebviewReady
|
||||
const showSettings = ref(false)
|
||||
const roots = ref<RootEntry[]>([])
|
||||
|
||||
function setPreferredResolution(value: ResolutionPreference) {
|
||||
settings.preferredResolution = value
|
||||
}
|
||||
|
||||
function setPreferredHdr(value: HdrPreference) {
|
||||
settings.preferredHdr = value
|
||||
}
|
||||
|
||||
async function refreshRoots() {
|
||||
try {
|
||||
const data = await fetchRoots()
|
||||
@@ -555,4 +660,39 @@ onUnmounted(() => {
|
||||
.roots-add-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.format-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.format-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 9.5em 9.5em;
|
||||
gap: 0.75em 1.25em;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.format-row-stack {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.format-radio-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.format-radio {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent, #3b82f6);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -213,6 +213,7 @@ import {
|
||||
import castPlaceholderFemaleUrl from "../assets/cast-placeholder-female.svg"
|
||||
import castPlaceholderMaleUrl from "../assets/cast-placeholder-male.svg"
|
||||
import SeriesFullView from "./SeriesFullView.vue"
|
||||
import { sortTorrentsByPreference } from "../composables/useSettings"
|
||||
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
|
||||
import ReleaseActionMenu from "./ReleaseActionMenu.vue"
|
||||
import {
|
||||
@@ -498,7 +499,7 @@ function getShowreelSourceAttributes(path: string): VideoSourceAttributes {
|
||||
const movieVersions = computed((): Torrent[] => {
|
||||
if (props.item.type !== "movies") return []
|
||||
const movie = props.item.data as Movie
|
||||
return Object.values(movie.torrents || {})
|
||||
return sortTorrentsByPreference(Object.values(movie.torrents || {}))
|
||||
})
|
||||
|
||||
// Page backdrop background
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
</div>
|
||||
<div v-if="Object.values(contextMenu.episode.torrents || {}).length > 0">
|
||||
<ReleaseVersionCard
|
||||
v-for="(torrent, index) in Object.values(contextMenu.episode.torrents || {})"
|
||||
v-for="(torrent, index) in sortTorrentsByPreference(Object.values(contextMenu.episode.torrents || {}))"
|
||||
:key="index"
|
||||
class="context-menu-version"
|
||||
:torrent="torrent"
|
||||
@@ -189,6 +189,7 @@ import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrow
|
||||
import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
|
||||
import ReleaseActionMenu from "./ReleaseActionMenu.vue"
|
||||
import { sortTorrentsByPreference } from "../composables/useSettings"
|
||||
|
||||
const props = defineProps<{
|
||||
series: Series
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { reactive, watch } from "vue"
|
||||
import type { Torrent } from "../types"
|
||||
|
||||
export type ResolutionPreference = "r2" | "r3" | "r4" | "rmax"
|
||||
export type HdrPreference = "none" | "hdr10plus" | "dovi"
|
||||
|
||||
export interface MediaHiveSettings {
|
||||
preferredResolution: ResolutionPreference
|
||||
preferredHdr: HdrPreference
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "MediaHive"
|
||||
|
||||
const RESOLUTION_PRIORITY: Record<string, number> = {
|
||||
"8K": 5,
|
||||
"4K": 4,
|
||||
"FHD": 3,
|
||||
"HD": 2,
|
||||
"SD": 1,
|
||||
}
|
||||
|
||||
// Max resolution priority allowed for each preference level
|
||||
const RESOLUTION_CAP: Record<ResolutionPreference, number> = {
|
||||
r2: 2,
|
||||
r3: 3,
|
||||
r4: 4,
|
||||
rmax: 999,
|
||||
}
|
||||
|
||||
const RESOLUTION_VALUES = ["r2", "r3", "r4", "rmax"] as const
|
||||
const HDR_VALUES = ["none", "hdr10plus", "dovi"] as const
|
||||
|
||||
function isResolutionPreference(value: unknown): value is ResolutionPreference {
|
||||
return typeof value === "string" && RESOLUTION_VALUES.includes(value as ResolutionPreference)
|
||||
}
|
||||
|
||||
function isHdrPreference(value: unknown): value is HdrPreference {
|
||||
return typeof value === "string" && HDR_VALUES.includes(value as HdrPreference)
|
||||
}
|
||||
|
||||
function loadSettings(): MediaHiveSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<MediaHiveSettings>
|
||||
return {
|
||||
preferredResolution: isResolutionPreference(parsed.preferredResolution)
|
||||
? parsed.preferredResolution
|
||||
: "rmax",
|
||||
preferredHdr: isHdrPreference(parsed.preferredHdr) ? parsed.preferredHdr : "none",
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return { preferredResolution: "rmax", preferredHdr: "none" }
|
||||
}
|
||||
|
||||
const settings = reactive<MediaHiveSettings>(loadSettings())
|
||||
|
||||
watch(
|
||||
() => ({ ...settings }),
|
||||
(value) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
export function useSettings() {
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a list of Torrent objects according to the current format preferences.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Compute the "effective priority" = min(actual priority, cap).
|
||||
* Releases exceeding the cap receive the capped priority.
|
||||
* 2. Higher effective priority wins.
|
||||
* 3. For equal effective priority, prefer HDR flavor per `preferredHdr`.
|
||||
* 4. Equal in all above: fall back to raw resolution priority (larger is better)
|
||||
* then size.
|
||||
*/
|
||||
export function sortTorrentsByPreference(torrents: Torrent[]): Torrent[] {
|
||||
const cap = RESOLUTION_CAP[settings.preferredResolution]
|
||||
|
||||
function effectivePriority(t: Torrent): number {
|
||||
const raw = RESOLUTION_PRIORITY[t.resolution ?? ""] ?? 0
|
||||
return Math.min(raw, cap)
|
||||
}
|
||||
|
||||
type HdrProfile = "sdr" | "hdr10" | "hdr10plus" | "dovi"
|
||||
|
||||
function detectHdrProfile(t: Torrent): HdrProfile {
|
||||
const text = [t.title, t.quality, t.codec, t.audio].filter(Boolean).join(" ")
|
||||
const hasDovi = t.has_dolby_vision || /dolby\s*vision|dovi|\bdv\b/i.test(text)
|
||||
const hasHdr10Plus = /hdr10\+|hdr10plus/i.test(text)
|
||||
const hasAnyHdr = t.is_hdr || hasHdr10Plus || hasDovi || /\bhdr\b/i.test(text)
|
||||
|
||||
if (hasDovi) return "dovi"
|
||||
if (hasHdr10Plus) return "hdr10plus"
|
||||
if (hasAnyHdr) return "hdr10"
|
||||
return "sdr"
|
||||
}
|
||||
|
||||
function scoreForPreference(profile: HdrProfile): number {
|
||||
if (settings.preferredHdr === "none") {
|
||||
// Prefer no HDR; HDR variants after SDR.
|
||||
if (profile === "sdr") return 4
|
||||
if (profile === "hdr10") return 3
|
||||
if (profile === "hdr10plus") return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
if (settings.preferredHdr === "hdr10plus") {
|
||||
// Requested behavior: HDR10+ best, HDR10 next, SDR then, DoVi last.
|
||||
if (profile === "hdr10plus") return 4
|
||||
if (profile === "hdr10") return 3
|
||||
if (profile === "sdr") return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
if (profile === "dovi") return 4
|
||||
if (profile === "hdr10plus") return 3
|
||||
if (profile === "hdr10") return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function hdrPreferenceScore(t: Torrent): number {
|
||||
return scoreForPreference(detectHdrProfile(t))
|
||||
}
|
||||
|
||||
return [...torrents].sort((a, b) => {
|
||||
const epA = effectivePriority(a)
|
||||
const epB = effectivePriority(b)
|
||||
if (epB !== epA) return epB - epA
|
||||
|
||||
// Same effective resolution bucket - apply HDR format preference
|
||||
const hdrScoreA = hdrPreferenceScore(a)
|
||||
const hdrScoreB = hdrPreferenceScore(b)
|
||||
if (hdrScoreB !== hdrScoreA) {
|
||||
return hdrScoreB - hdrScoreA
|
||||
}
|
||||
|
||||
// Fall back to raw resolution priority then size
|
||||
const rawA = RESOLUTION_PRIORITY[a.resolution ?? ""] ?? 0
|
||||
const rawB = RESOLUTION_PRIORITY[b.resolution ?? ""] ?? 0
|
||||
if (rawB !== rawA) return rawB - rawA
|
||||
|
||||
return (b.size ?? 0) - (a.size ?? 0)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user