From 1383fc8da3e6257120b05d3f7a7175a53cbeb64c Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 27 May 2026 21:52:36 +0000 Subject: [PATCH] Add preferred format settings and release preference sorting --- frontend/src/components/Header.vue | 140 ++++++++++++++++++ frontend/src/components/MediaDetail.vue | 3 +- frontend/src/components/SeriesFullView.vue | 3 +- frontend/src/composables/useSettings.ts | 156 +++++++++++++++++++++ 4 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 frontend/src/composables/useSettings.ts diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 27c6737..065913d 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -141,6 +141,96 @@ + +
+

Preferred Format

+

Preferred format when multiple versions are available.

+ +
+
+ + + + +
+ +
+ + + +
+
+
@@ -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([]) +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); +} diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index a4380c6..4ae9e05 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -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 diff --git a/frontend/src/components/SeriesFullView.vue b/frontend/src/components/SeriesFullView.vue index 3dc9fc9..182b1fd 100644 --- a/frontend/src/components/SeriesFullView.vue +++ b/frontend/src/components/SeriesFullView.vue @@ -150,7 +150,7 @@
= { + "8K": 5, + "4K": 4, + "FHD": 3, + "HD": 2, + "SD": 1, +} + +// Max resolution priority allowed for each preference level +const RESOLUTION_CAP: Record = { + 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 + 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(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) + }) +}