7 Commits
Author SHA1 Message Date
LeoVasanko 82d5eb28fb Add screenshot of the new series view. 2026-09-24 00:52:04 +00:00
LeoVasanko 1149e7cdfd Add pre-commit hooks and blame-ignore for the lint/format commit
release / gui-build (linux, bash) (push) Successful in 56s
release / gui-build (windows, cmd) (push) Successful in 1m21s
release / gui-build (macos, bash) (push) Successful in 1m50s
2026-09-24 00:37:25 +00:00
LeoVasanko 38f3ccebd3 Apply linters and formatters
- frontend: oxfmt across src, fix no-useless-escape in search-worker
- ruff check --fix: replace numeric rule codes with text names in
  pyproject.toml lint.ignore
- ruff format: scanning.py, guibuild.py, release.py
2026-09-24 00:26:53 +00:00
LeoVasanko c41504e70c Fix stale preview reels when switching between series
Episode tiles were keyed by season/episode index only, so switching
series reused the DOM: text bindings updated but <video> elements were
kept, and browsers ignore patched <source> children without load() —
the previous series' reels kept playing.

- Key SeriesFullView by item id so a series switch fully remounts it.
- Include a seriesIdentity (id, else root_id+title) in episode tile
  keys so tiles/videos remount even on an in-place series swap.
- Reset episode cursor, audio owner and preview startup timers when
  the series identity changes, and re-gate video mounting.
2026-09-23 21:35:03 +00:00
LeoVasanko bb70279e61 Clean up README 2026-09-23 21:30:51 +00:00
LeoVasanko 931f00f647 Fix season browser focus: land on first episode on down, ignore parked-cursor hover
- Add data-nav-entry-col-from-above so moving down from the season
  selector always focuses the first episode instead of the tile
  visually closest to the selected season poster; moving back up
  still returns to the current season.
- Require recent real mouse movement before treating mouseover as
  mouse intent: scroll/re-render under a stationary cursor no longer
  activates mouse mode, so sideways season browsing no longer loses
  focus to the episode tile under the parked pointer.
2026-09-23 21:30:17 +00:00
LeoVasanko 9041061e86 Fix image link in README to work also on PyPI. 2026-09-23 20:43:25 +00:00
25 changed files with 449 additions and 269 deletions
+3
View File
@@ -0,0 +1,3 @@
# Mass lint/format commits that add noise to git blame.
# Enable locally with: git config blame.ignoreRevsFile .git-blame-ignore-revs
38f3cce # Apply linters and formatters
+2
View File
@@ -20,3 +20,5 @@ package-lock.json
.* .*
!.gitignore !.gitignore
!.gitea/ !.gitea/
!.git-blame-ignore-revs
!.pre-commit-config.yaml
+4 -9
View File
@@ -1,4 +1,4 @@
![MediaHive](docs/mediahive.avif) ![MediaHive](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/mediahive.avif)
# MediaHive # MediaHive
@@ -10,14 +10,7 @@ Netflix style browsing of your local media archive. Supports keyboard, mouse and
- **macOS**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-macos.pkg) - **macOS**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-macos.pkg)
- **Linux**: [AppImage](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage) - **Linux**: [AppImage](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage)
### Linux You may also run or install with [UV](https://docs.astral.sh/uv/getting-started/installation/):
```
wget https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage
chmod +x MediaHive-linux.AppImage && ./MediaHive-linux.AppImage
```
You may also run without installing via
``` ```
uvx --from mediahive[gui] mediahive uvx --from mediahive[gui] mediahive
@@ -62,3 +55,5 @@ MediaHive opens files with the OS default player, but one specific player may be
## Background ## Background
This project started as a personal project that I have used for browsing my warez for some time now. It is still in early development, but I have just now made it public for a wider audience. This project started as a personal project that I have used for browsing my warez for some time now. It is still in early development, but I have just now made it public for a wider audience.
![Series view](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/seriesview.avif)
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

+45 -23
View File
@@ -11,12 +11,19 @@
<span>Library activity</span> <span>Library activity</span>
<span v-if="!wsConnected" class="activity-connection">Reconnecting...</span> <span v-if="!wsConnected" class="activity-connection">Reconnecting...</span>
</div> </div>
<div v-for="root in progressRoots" :key="root.rootId" class="activity-root" :class="root.toneClass"> <div
v-for="root in progressRoots"
:key="root.rootId"
class="activity-root"
:class="root.toneClass"
>
<div class="activity-root-title">{{ root.rootLabel }}</div> <div class="activity-root-title">{{ root.rootLabel }}</div>
<div v-if="root.scanTarget" class="activity-root-target">{{ root.scanTarget }}</div> <div v-if="root.scanTarget" class="activity-root-target">{{ root.scanTarget }}</div>
<div class="activity-phase-row"> <div class="activity-phase-row">
<span class="activity-phase">{{ root.phaseLabel }}</span> <span class="activity-phase">{{ root.phaseLabel }}</span>
<span v-if="root.progressLabel" class="activity-progress-label">{{ root.progressLabel }}</span> <span v-if="root.progressLabel" class="activity-progress-label">{{
root.progressLabel
}}</span>
</div> </div>
<div class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }"> <div class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }">
<div <div
@@ -215,7 +222,10 @@ import {
type EpisodeWatchEntry, type EpisodeWatchEntry,
} from "./api" } from "./api"
import { useSettings } from "./composables/useSettings" import { useSettings } from "./composables/useSettings"
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation" import {
useKeyboardNavigation,
setActiveNavigationScope,
} from "./composables/useKeyboardNavigation"
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation" import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
import { useMediaWebSocket } from "./composables/useMediaWebSocket" import { useMediaWebSocket } from "./composables/useMediaWebSocket"
import Header from "./components/Header.vue" import Header from "./components/Header.vue"
@@ -380,7 +390,8 @@ function describeRootProgress(
} }
} else if (showreelCount > 0) { } else if (showreelCount > 0) {
phaseLabel = "Generating previews" phaseLabel = "Generating previews"
phaseDetail = showreelCount === 1 ? "Building 1 preview reel" : `Building ${showreelCount} preview reels` phaseDetail =
showreelCount === 1 ? "Building 1 preview reel" : `Building ${showreelCount} preview reels`
} else if (otherRunningCount > 0) { } else if (otherRunningCount > 0) {
phaseLabel = "Finalizing updates" phaseLabel = "Finalizing updates"
phaseDetail = "Applying library changes" phaseDetail = "Applying library changes"
@@ -472,7 +483,11 @@ watch(
const key = `${task.root_id}:${task.id}` const key = `${task.root_id}:${task.id}`
nextSeen.set(key, task.status) nextSeen.set(key, task.status)
const previousStatus = seenTaskStates.get(key) const previousStatus = seenTaskStates.get(key)
if (task.id.startsWith("scan-") && task.status === "completed" && previousStatus !== "completed") { if (
task.id.startsWith("scan-") &&
task.status === "completed" &&
previousStatus !== "completed"
) {
const detail = (task.detail || "").trim() const detail = (task.detail || "").trim()
const doneMatch = detail.match(/^Done\s+[\u2014-]\s+(\d+)\s+movies,\s+(\d+)\s+series$/i) const doneMatch = detail.match(/^Done\s+[\u2014-]\s+(\d+)\s+movies,\s+(\d+)\s+series$/i)
if (doneMatch) { if (doneMatch) {
@@ -480,7 +495,9 @@ watch(
const series = Number(doneMatch[2] || "0") const series = Number(doneMatch[2] || "0")
if (movies > 0 || series > 0) { if (movies > 0 || series > 0) {
const rootName = getRootName(task.root_id) || task.root_id const rootName = getRootName(task.root_id) || task.root_id
showLibraryUpdateToast(`Library updated in ${rootName}: ${movies} movies, ${series} series`) showLibraryUpdateToast(
`Library updated in ${rootName}: ${movies} movies, ${series} series`,
)
} }
} }
} }
@@ -578,9 +595,7 @@ function getResumePoint(mediaId: string | null): SeriesResumePoint | null {
} }
} }
function getResumeEpisodes( function getResumeEpisodes(mediaId: string | null): Record<string, EpisodeWatchEntry> | null {
mediaId: string | null,
): Record<string, EpisodeWatchEntry> | null {
if (!mediaId) return null if (!mediaId) return null
return resumePositions.value[mediaId]?.episodes ?? null return resumePositions.value[mediaId]?.episodes ?? null
} }
@@ -675,7 +690,9 @@ function showAdjacentDetail(offset: -1 | 1): boolean {
const sequence = getDetailAdjacentSequence(current) const sequence = getDetailAdjacentSequence(current)
if (sequence.length < 2) return false if (sequence.length < 2) return false
const currentIndex = sequence.findIndex((item) => item.id === current.id && item.type === current.type) const currentIndex = sequence.findIndex(
(item) => item.id === current.id && item.type === current.type,
)
if (currentIndex < 0) return false if (currentIndex < 0) return false
const nextIndex = currentIndex + offset const nextIndex = currentIndex + offset
@@ -771,7 +788,10 @@ function restoreBrowseFocus(path: string) {
}, 100) }, 100)
} }
function getSearchExitTargetFromFocusedCard(): { path: "/movies" | "/series"; itemId: string } | null { function getSearchExitTargetFromFocusedCard(): {
path: "/movies" | "/series"
itemId: string
} | null {
const active = document.activeElement as HTMLElement | null const active = document.activeElement as HTMLElement | null
const focusedCard = active?.closest("[data-item-id]") as HTMLElement | null const focusedCard = active?.closest("[data-item-id]") as HTMLElement | null
if (!focusedCard) return null if (!focusedCard) return null
@@ -832,9 +852,7 @@ function clearSearch(options: { preferBack?: boolean; targetPath?: string } = {}
? normalizeHistoryPath(window.history.state.back) ? normalizeHistoryPath(window.history.state.back)
: "" : ""
const canRestoreWithBack = const canRestoreWithBack =
options.preferBack !== false && options.preferBack !== false && searchReturnPath.value === targetPath && backPath === targetPath
searchReturnPath.value === targetPath &&
backPath === targetPath
searchReturnPath.value = null searchReturnPath.value = null
@@ -1105,14 +1123,18 @@ function showDetail(item: MediaItem) {
handlePlay(playableFile) handlePlay(playableFile)
} else { } else {
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null
router.push({ path: `/series/${epData.series.id}`, state: searchPath ? { searchPath } : undefined }) router.push({
path: `/series/${epData.series.id}`,
state: searchPath ? { searchPath } : undefined,
})
} }
} else { } else {
const detailSearchPath = getDetailSearchPath() const detailSearchPath = getDetailSearchPath()
const searchPath = searchQuery.value const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
? getSearchPath(searchQuery.value) router.push({
: detailSearchPath path: `/${item.type}/${item.id}`,
router.push({ path: `/${item.type}/${item.id}`, state: searchPath ? { searchPath } : undefined }) state: searchPath ? { searchPath } : undefined,
})
} }
} }
@@ -1133,9 +1155,7 @@ function handleActorSearch(actorName: string) {
function handleSelectMovieFromDetail(movieId: string) { function handleSelectMovieFromDetail(movieId: string) {
const detailSearchPath = getDetailSearchPath() const detailSearchPath = getDetailSearchPath()
const searchPath = searchQuery.value const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
? getSearchPath(searchQuery.value)
: detailSearchPath
router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined }) router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined })
} }
@@ -1174,7 +1194,9 @@ function focusDetailEntryTarget(item: MediaItem): boolean {
return true return true
} }
const firstFocusable = detailPanel.querySelector('[data-nav-focusable="true"]') as HTMLElement | null const firstFocusable = detailPanel.querySelector(
'[data-nav-focusable="true"]',
) as HTMLElement | null
if (firstFocusable) { if (firstFocusable) {
focusElement(firstFocusable) focusElement(firstFocusable)
return true return true
+1 -7
View File
@@ -594,13 +594,7 @@ function activateItem(item: MediaItem) {
function handleItemClick(event: MouseEvent, item: MediaItem) { function handleItemClick(event: MouseEvent, item: MediaItem) {
// Let modified clicks navigate natively // Let modified clicks navigate natively
if ( if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return return
} }
event.preventDefault() event.preventDefault()
@@ -65,11 +65,7 @@ function getPlayLabel(filePath: string | null | undefined): string {
function getFocusableElements(): HTMLElement[] { function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return [] if (!menuRef.value) return []
return Array.from( return Array.from(menuRef.value.querySelectorAll<HTMLElement>(".ctx-btn:not(:disabled)"))
menuRef.value.querySelectorAll<HTMLElement>(
'.ctx-btn:not(:disabled)'
)
)
} }
function focusNext(delta: number) { function focusNext(delta: number) {
@@ -130,9 +126,7 @@ watch(
await nextTick() await nextTick()
clampToViewport() clampToViewport()
// Focus first action button for keyboard navigation // Focus first action button for keyboard navigation
const firstBtn = menuRef.value?.querySelector( const firstBtn = menuRef.value?.querySelector(".ctx-btn:not(:disabled)") as HTMLElement | null
".ctx-btn:not(:disabled)",
) as HTMLElement | null
firstBtn?.focus() firstBtn?.focus()
}, },
{ immediate: true }, { immediate: true },
+51 -21
View File
@@ -1,7 +1,13 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="hex-keyboard-fade"> <Transition name="hex-keyboard-fade">
<div v-if="visible" ref="keyboardRef" class="hex-keyboard" @click.stop @keydown="handleKeyDown"> <div
v-if="visible"
ref="keyboardRef"
class="hex-keyboard"
@click.stop
@keydown="handleKeyDown"
>
<div ref="gridRef" class="hex-keyboard-grid"> <div ref="gridRef" class="hex-keyboard-grid">
<div <div
v-for="(row, rowIndex) in rows" v-for="(row, rowIndex) in rows"
@@ -26,7 +32,9 @@
tabindex="-1" tabindex="-1"
@click="handleKeyClick(key)" @click="handleKeyClick(key)"
> >
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{ key.label }}</span> <span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{
key.label
}}</span>
</button> </button>
</div> </div>
<!-- Green focus outline rendered separately on top --> <!-- Green focus outline rendered separately on top -->
@@ -174,11 +182,16 @@ function getCoord(index: number): { row: number; col: number } {
function getRowRange(row: number): { start: number; count: number } { function getRowRange(row: number): { start: number; count: number } {
switch (row) { switch (row) {
case 0: return { start: ROW_0_START, count: ROW_0_COUNT } case 0:
case 1: return { start: ROW_1_START, count: ROW_1_COUNT } return { start: ROW_0_START, count: ROW_0_COUNT }
case 2: return { start: ROW_2_START, count: ROW_2_COUNT } case 1:
case 3: return { start: ROW_3_START, count: ROW_3_COUNT } return { start: ROW_1_START, count: ROW_1_COUNT }
default: return { start: 0, count: 0 } case 2:
return { start: ROW_2_START, count: ROW_2_COUNT }
case 3:
return { start: ROW_3_START, count: ROW_3_COUNT }
default:
return { start: 0, count: 0 }
} }
} }
@@ -247,10 +260,7 @@ function close() {
emit("close") emit("close")
} }
function findNext( function findNext(currentIdx: number, direction: "up" | "down" | "left" | "right"): number | null {
currentIdx: number,
direction: "up" | "down" | "left" | "right",
): number | null {
const current = getCoord(currentIdx) const current = getCoord(currentIdx)
if (direction === "left") { if (direction === "left") {
@@ -510,10 +520,18 @@ onUnmounted(() => {
} }
/* Row horizontal offsets for honeycomb staggering */ /* Row horizontal offsets for honeycomb staggering */
.hex-keyboard-row-0 { margin-left: 0; } .hex-keyboard-row-0 {
.hex-keyboard-row-1 { margin-left: calc(var(--key-w) * 0.5); } margin-left: 0;
.hex-keyboard-row-2 { margin-left: calc(var(--key-w) * 1.0); } }
.hex-keyboard-row-3 { margin-left: calc(var(--key-w) * 1.5); } .hex-keyboard-row-1 {
margin-left: calc(var(--key-w) * 0.5);
}
.hex-keyboard-row-2 {
margin-left: calc(var(--key-w) * 1);
}
.hex-keyboard-row-3 {
margin-left: calc(var(--key-w) * 1.5);
}
.hex-key { .hex-key {
position: relative; position: relative;
@@ -529,7 +547,9 @@ onUnmounted(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
transition: transform 0.15s ease, color 0.3s ease; transition:
transform 0.15s ease,
color 0.3s ease;
outline: none; outline: none;
transform: scale(0.97); transform: scale(0.97);
} }
@@ -538,7 +558,8 @@ onUnmounted(() => {
.hex-key-row-0 { .hex-key-row-0 {
background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%); background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%);
} }
.hex-key-row-1, .hex-key-row-3 { .hex-key-row-1,
.hex-key-row-3 {
background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%); background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%);
} }
.hex-key-row-2 { .hex-key-row-2 {
@@ -553,8 +574,12 @@ onUnmounted(() => {
} }
@keyframes hex-label-fade { @keyframes hex-label-fade {
0% { color: #22c55e; } 0% {
100% { color: #ffffff; } color: #22c55e;
}
100% {
color: #ffffff;
}
} }
/* Special key backgrounds override row gradients */ /* Special key backgrounds override row gradients */
@@ -600,8 +625,13 @@ onUnmounted(() => {
} }
@keyframes hex-outline-blink { @keyframes hex-outline-blink {
0%, 100% { opacity: 1; } 0%,
50% { opacity: 0.4; } 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
} }
/* Transition */ /* Transition */
+4 -8
View File
@@ -80,7 +80,9 @@
</div> </div>
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div> <div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
<div v-if="directorAndCast" class="media-card-detail person-list"> <div v-if="directorAndCast" class="media-card-detail person-list">
<span v-if="director" class="director-name person-token">{{ formatPersonLabel(director) }}</span> <span v-if="director" class="director-name person-token">{{
formatPersonLabel(director)
}}</span>
<span <span
v-for="(castName, castIndex) in formattedCastNames" v-for="(castName, castIndex) in formattedCastNames"
:key="`${castName}-${castIndex}`" :key="`${castName}-${castIndex}`"
@@ -112,13 +114,7 @@ const emit = defineEmits<{
function handleClick(event: MouseEvent) { function handleClick(event: MouseEvent) {
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively // Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
if ( if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return return
} }
// Prevent default navigation for plain left-clicks and synthetic clicks // Prevent default navigation for plain left-clicks and synthetic clicks
+84 -71
View File
@@ -2,6 +2,7 @@
<!-- Full screen view for series --> <!-- Full screen view for series -->
<SeriesFullView <SeriesFullView
v-if="item.type === 'series'" v-if="item.type === 'series'"
:key="item.id"
:series="item.data as Series" :series="item.data as Series"
:all-movies="allMovies" :all-movies="allMovies"
:focus-episode="focusEpisode" :focus-episode="focusEpisode"
@@ -185,7 +186,11 @@
v-if="item.type === 'movies' && collectionMovies.length > 1" v-if="item.type === 'movies' && collectionMovies.length > 1"
class="similar-movies-section" class="similar-movies-section"
> >
<div class="similar-movies-grid" data-sync-scroll-row="true" data-sync-scroll-group="similar"> <div
class="similar-movies-grid"
data-sync-scroll-row="true"
data-sync-scroll-group="similar"
>
<a <a
v-for="(movie, collectionIndex) in collectionMovies" v-for="(movie, collectionIndex) in collectionMovies"
:key="movie.localId" :key="movie.localId"
@@ -234,7 +239,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue" import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
import type { CastMember, MediaItem, Movie, MovieUi, Series, SeriesResumePoint, Torrent } from "../types" import type {
CastMember,
MediaItem,
Movie,
MovieUi,
Series,
SeriesResumePoint,
Torrent,
} from "../types"
import type { EpisodeWatchEntry } from "../api" import type { EpisodeWatchEntry } from "../api"
import { import {
getCoverUrl, getCoverUrl,
@@ -315,7 +328,9 @@ function getReleaseAtRow(row: number): HTMLElement | null {
} }
function getLastReleaseRowBefore(castRow: number): number | null { function getLastReleaseRowBefore(castRow: number): number | null {
const releases = Array.from(document.querySelectorAll<HTMLElement>('[data-nav-release-item="true"]')) const releases = Array.from(
document.querySelectorAll<HTMLElement>('[data-nav-release-item="true"]'),
)
let best: number | null = null let best: number | null = null
for (const release of releases) { for (const release of releases) {
const row = parseInt(release.getAttribute("data-nav-row") || "", 10) const row = parseInt(release.getAttribute("data-nav-row") || "", 10)
@@ -342,11 +357,7 @@ function registerMovieOutOfBoundsShortcut() {
return null return null
} }
if ( if (direction === "left" && current.hasAttribute("data-nav-cast-item") && currentCol === 0) {
direction === "left" &&
current.hasAttribute("data-nav-cast-item") &&
currentCol === 0
) {
const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow) const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow)
if (targetRow === null) return null if (targetRow === null) return null
return getReleaseAtRow(targetRow) return getReleaseAtRow(targetRow)
@@ -755,23 +766,8 @@ const castNavRow = computed(() => {
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
}) })
const collectionMovies = computed((): Array<{ const collectionMovies = computed(
title: string (): Array<{
localId: string
coverPath: string | null
rootId: string | null
year: string | null
hyphenLang: string | null
isCurrent: boolean
}> => {
if (props.item.type !== "movies") return []
const movie = props.item.data as Movie
const collectionName = movie.info?.collection?.trim()
if (!collectionName) return []
const normalizedCollectionName = collectionName.toLowerCase()
const matches: Array<{
title: string title: string
localId: string localId: string
coverPath: string | null coverPath: string | null
@@ -779,60 +775,77 @@ const collectionMovies = computed((): Array<{
year: string | null year: string | null
hyphenLang: string | null hyphenLang: string | null
isCurrent: boolean isCurrent: boolean
}> = [] }> => {
if (props.item.type !== "movies") return []
let hasCurrentInMatches = false const movie = props.item.data as Movie
const collectionName = movie.info?.collection?.trim()
if (!collectionName) return []
const normalizedCollectionName = collectionName.toLowerCase()
for (const libraryMovie of props.allMovies || []) { const matches: Array<{
const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase() title: string
if (otherCollectionName !== normalizedCollectionName) continue localId: string
coverPath: string | null
rootId: string | null
year: string | null
hyphenLang: string | null
isCurrent: boolean
}> = []
const title = libraryMovie.title || libraryMovie.info?.title let hasCurrentInMatches = false
if (!title) continue
const isCurrent = libraryMovie.id === props.item.id for (const libraryMovie of props.allMovies || []) {
if (isCurrent) hasCurrentInMatches = true const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
if (otherCollectionName !== normalizedCollectionName) continue
matches.push({ const title = libraryMovie.title || libraryMovie.info?.title
title, if (!title) continue
localId: libraryMovie.id,
coverPath: libraryMovie.cover_path || null,
rootId: libraryMovie.root_id || null,
year: libraryMovie.year
? String(libraryMovie.year)
: libraryMovie.info?.release_date?.slice(0, 4) || null,
hyphenLang: normalizeHyphenationLang(libraryMovie.info?.original_language),
isCurrent,
})
}
if (!hasCurrentInMatches) { const isCurrent = libraryMovie.id === props.item.id
matches.push({ if (isCurrent) hasCurrentInMatches = true
title: props.item.title || (props.item.data as Movie).info?.title || "Current movie",
localId: props.item.id,
coverPath: props.item.cover_path || null,
rootId: props.item.root_id || null,
year: props.item.year
? String(props.item.year)
: (props.item.data as Movie).info?.release_date?.slice(0, 4) || null,
hyphenLang: normalizeHyphenationLang((props.item.data as Movie).info?.original_language),
isCurrent: true,
})
}
return matches matches.push({
.sort((a, b) => { title,
const yearA = parseInt(a.year || "", 10) localId: libraryMovie.id,
const yearB = parseInt(b.year || "", 10) coverPath: libraryMovie.cover_path || null,
const hasYearA = Number.isFinite(yearA) rootId: libraryMovie.root_id || null,
const hasYearB = Number.isFinite(yearB) year: libraryMovie.year
? String(libraryMovie.year)
: libraryMovie.info?.release_date?.slice(0, 4) || null,
hyphenLang: normalizeHyphenationLang(libraryMovie.info?.original_language),
isCurrent,
})
}
if (hasYearA && hasYearB && yearA !== yearB) return yearA - yearB if (!hasCurrentInMatches) {
if (hasYearA !== hasYearB) return hasYearA ? -1 : 1 matches.push({
return a.title.localeCompare(b.title) title: props.item.title || (props.item.data as Movie).info?.title || "Current movie",
}) localId: props.item.id,
.slice(0, 24) coverPath: props.item.cover_path || null,
}) rootId: props.item.root_id || null,
year: props.item.year
? String(props.item.year)
: (props.item.data as Movie).info?.release_date?.slice(0, 4) || null,
hyphenLang: normalizeHyphenationLang((props.item.data as Movie).info?.original_language),
isCurrent: true,
})
}
return matches
.sort((a, b) => {
const yearA = parseInt(a.year || "", 10)
const yearB = parseInt(b.year || "", 10)
const hasYearA = Number.isFinite(yearA)
const hasYearB = Number.isFinite(yearB)
if (hasYearA && hasYearB && yearA !== yearB) return yearA - yearB
if (hasYearA !== hasYearB) return hasYearA ? -1 : 1
return a.title.localeCompare(b.title)
})
.slice(0, 24)
},
)
function normalizeHyphenationLang(language: string | null | undefined): string | null { function normalizeHyphenationLang(language: string | null | undefined): string | null {
if (!language) return null if (!language) return null
@@ -84,7 +84,7 @@ const disabled = computed(() => !props.filePath)
function getFocusableElements(): HTMLElement[] { function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return [] if (!menuRef.value) return []
return Array.from( return Array.from(
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)") menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)"),
) )
} }
@@ -336,7 +336,12 @@ const hdr10PlusPattern = /hdr10\+|hdr10plus/i
const hasHdr10Plus = computed(() => { const hasHdr10Plus = computed(() => {
if (props.torrent.hdr10plus) return true if (props.torrent.hdr10plus) return true
const text = [props.torrent.title, props.torrent.quality, props.torrent.codec, props.torrent.audio] const text = [
props.torrent.title,
props.torrent.quality,
props.torrent.codec,
props.torrent.audio,
]
.filter(Boolean) .filter(Boolean)
.join(" ") .join(" ")
return hdr10PlusPattern.test(text) return hdr10PlusPattern.test(text)
+37 -14
View File
@@ -96,7 +96,9 @@
formatDate(selectedSeason.air_date) formatDate(selectedSeason.air_date)
}}</span> }}</span>
<span <span
>{{ selectedSeason.episode_count ?? selectedSeason.episodes.length }} >{{
selectedSeason.episode_count ?? selectedSeason.episodes.length
}}
Episodes</span Episodes</span
> >
</div> </div>
@@ -112,7 +114,7 @@
<div class="episodes-grid"> <div class="episodes-grid">
<div <div
v-for="(episode, eIndex) in selectedSeason?.episodes || []" v-for="(episode, eIndex) in selectedSeason?.episodes || []"
:key="`${selectedSeasonIndex}-${episode.episode_number}`" :key="`${seriesIdentity}-${selectedSeasonIndex}-${episode.episode_number}`"
class="episode-tile" class="episode-tile"
:class="{ 'episode-tile--ahead': isEpisodeAhead(eIndex) }" :class="{ 'episode-tile--ahead': isEpisodeAhead(eIndex) }"
v-bind="getEpisodeNavAttrs(selectedSeasonIndex, eIndex)" v-bind="getEpisodeNavAttrs(selectedSeasonIndex, eIndex)"
@@ -161,9 +163,7 @@
<span <span
v-if="episodeWatchIndicator(episode)" v-if="episodeWatchIndicator(episode)"
class="ep-watch" class="ep-watch"
:title=" :title="episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'"
episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'
"
>{{ episodeWatchIndicator(episode) }}</span >{{ episodeWatchIndicator(episode) }}</span
> >
<div class="tile-play"></div> <div class="tile-play"></div>
@@ -258,7 +258,7 @@ import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
import { sortTorrentsByPreference } from "../composables/useSettings" import { sortTorrentsByPreference } from "../composables/useSettings"
const props = defineProps<{ const props = defineProps<{
series: Series & { root_id?: string | null } series: Series & { id?: string; root_id?: string | null }
allMovies: MovieUi[] allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
resumePoint?: SeriesResumePoint | null resumePoint?: SeriesResumePoint | null
@@ -297,6 +297,15 @@ const selectedSeason = computed<Season | null>(
() => props.series.seasons[selectedSeasonIndex.value] || null, () => props.series.seasons[selectedSeasonIndex.value] || null,
) )
// Stable identity of the displayed series. Episode tiles carry it in their
// :key so swapping to another series remounts the tiles (and their <video>
// elements) instead of patching <source> children — which browsers ignore,
// leaving the previous series' preview reels playing.
const seriesIdentity = computed(() => {
const s = props.series
return s.id ?? `${s.root_id ?? ""}:${s.title ?? ""}`
})
function selectSeason(index: number) { function selectSeason(index: number) {
if (index < 0 || index >= props.series.seasons.length) return if (index < 0 || index >= props.series.seasons.length) return
if (selectedSeasonIndex.value === index) return if (selectedSeasonIndex.value === index) return
@@ -358,9 +367,7 @@ const cursorGlobalIndex = computed(() =>
const resumePointGlobalIndex = computed(() => { const resumePointGlobalIndex = computed(() => {
const point = props.resumePoint const point = props.resumePoint
if (!point) return null if (!point) return null
const seasonIndex = props.series.seasons.findIndex( const seasonIndex = props.series.seasons.findIndex((s) => s.season_number === point.seasonNumber)
(s) => s.season_number === point.seasonNumber,
)
if (seasonIndex < 0) return null if (seasonIndex < 0) return null
const episodeIndex = props.series.seasons[seasonIndex]?.episodes.findIndex( const episodeIndex = props.series.seasons[seasonIndex]?.episodes.findIndex(
(e) => e.episode_number === point.episodeNumber, (e) => e.episode_number === point.episodeNumber,
@@ -548,6 +555,9 @@ function getEpisodeNavAttrs(seasonIndex: number, episodeIndex: number) {
...navAttrs(coords.row, coords.col), ...navAttrs(coords.row, coords.col),
"data-season-index": seasonIndex, "data-season-index": seasonIndex,
"data-episode-index": episodeIndex, "data-episode-index": episodeIndex,
// Entering the episode grid from above (the season selector) always
// lands on the first episode instead of the visually closest tile.
...(episodeIndex === 0 ? { "data-nav-entry-col-from-above": "0" } : {}),
} }
} }
@@ -638,10 +648,7 @@ watch(
episodeFocusTarget, episodeFocusTarget,
(ep) => { (ep) => {
if (!ep) return if (!ep) return
if ( if (!props.focusEpisode && (seasonUserInteracted.value || episodeCursorIndex.value !== null)) {
!props.focusEpisode &&
(seasonUserInteracted.value || episodeCursorIndex.value !== null)
) {
return return
} }
const seasonIndex = const seasonIndex =
@@ -1406,6 +1413,17 @@ watch(
}, },
) )
// Series swapped under a reused component instance: tiles remount via the
// :key, but per-episode state keyed by episode index alone (audio owner,
// cursor, startup timers) survives — reset it so the old series' playback
// and audio don't bleed into the new one.
watch(seriesIdentity, () => {
episodeCursorIndex.value = null
setAudioOwner(null)
stopEpisodePreviews()
scheduleEpisodeMediaReady()
})
// Episodes past the spoiler threshold (cursor or continue point) are faded; // Episodes past the spoiler threshold (cursor or continue point) are faded;
// stop their playback too. // stop their playback too.
watch([episodeCursorIndex, resumePointGlobalIndex], () => { watch([episodeCursorIndex, resumePointGlobalIndex], () => {
@@ -1933,7 +1951,12 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
rgba(255, 255, 255, 0.025) 30%, rgba(255, 255, 255, 0.025) 30%,
rgba(255, 255, 255, 0) 55% rgba(255, 255, 255, 0) 55%
), ),
linear-gradient(to bottom, rgba(20, 20, 28, 0.5) 0%, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.45) 100%); linear-gradient(
to bottom,
rgba(20, 20, 28, 0.5) 0%,
rgba(0, 0, 0, 0) 40%,
rgba(0, 0, 0, 0.45) 100%
);
} }
.episode-tile--ahead::after { .episode-tile--ahead::after {
@@ -110,7 +110,9 @@ function getDigitalRepeatIntervalMs(holdMs: number): number {
function getAnalogRepeatIntervalMs(intensity: number): number { function getAnalogRepeatIntervalMs(intensity: number): number {
const normalized = Math.min(Math.max(intensity, 0), 1) const normalized = Math.min(Math.max(intensity, 0), 1)
return Math.round(ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized) return Math.round(
ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized,
)
} }
function normalizeAxisIntensity(rawValue: number): number { function normalizeAxisIntensity(rawValue: number): number {
+10 -1
View File
@@ -2,10 +2,10 @@ import { reportUserActivity } from "../api"
type InputModality = "mouse" | "keyboard" | "gamepad" type InputModality = "mouse" | "keyboard" | "gamepad"
const MOUSE_IDLE_MS = 1400 const MOUSE_IDLE_MS = 1400
const MOUSE_INTENT_DISTANCE_PX = 28 const MOUSE_INTENT_DISTANCE_PX = 28
const MOUSE_INTENT_WINDOW_MS = 700 const MOUSE_INTENT_WINDOW_MS = 700
const MOUSE_OVER_INTENT_RECENCY_MS = 500
const MOUSE_INTENT_SELECTOR = [ const MOUSE_INTENT_SELECTOR = [
"[data-nav-focusable]", "[data-nav-focusable]",
"button", "button",
@@ -28,6 +28,7 @@ let mouseIdleTimer: number | null = null
let pointerVisible = false let pointerVisible = false
let mouseTravelPx = 0 let mouseTravelPx = 0
let lastMouseMoveAt = 0 let lastMouseMoveAt = 0
let lastAnyMouseMoveAt = 0
function clearMouseIdleTimer() { function clearMouseIdleTimer() {
if (mouseIdleTimer !== null) { if (mouseIdleTimer !== null) {
@@ -95,6 +96,7 @@ function registerMouseIntentTravel(event: MouseEvent): boolean {
function handleMouseMove(event: MouseEvent) { function handleMouseMove(event: MouseEvent) {
reportUserActivity() reportUserActivity()
showPointerFromMotion() showPointerFromMotion()
lastAnyMouseMoveAt = performance.now()
if (modality === "mouse") { if (modality === "mouse") {
applyInputState(true) applyInputState(true)
@@ -111,6 +113,13 @@ function handleMouseOver(event: MouseEvent) {
if (modality === "mouse") return if (modality === "mouse") return
if (!isMouseIntentTarget(event.target)) return if (!isMouseIntentTarget(event.target)) return
// Browsers fire mouseover/mouseenter when scrolling or re-rendering moves
// content under a stationary cursor (e.g. sideways season browsing while
// the pointer happens to rest over the episode grid). Without recent real
// pointer motion this is not mouse intent — activating mouse input here
// would let hover handlers steal keyboard/gamepad focus.
if (performance.now() - lastAnyMouseMoveAt > MOUSE_OVER_INTENT_RECENCY_MS) return
// Entering an interactive target indicates likely mouse intent. // Entering an interactive target indicates likely mouse intent.
activateMouseInput() activateMouseInput()
} }
@@ -36,6 +36,10 @@ const FOCUSABLE_ATTR = "data-nav-focusable"
const ROW_ATTR = "data-nav-row" const ROW_ATTR = "data-nav-row"
const COL_ATTR = "data-nav-col" const COL_ATTR = "data-nav-col"
const ENTRY_COL_ATTR = "data-nav-entry-col" const ENTRY_COL_ATTR = "data-nav-entry-col"
// Like ENTRY_COL_ATTR, but only applies when entering the row from above
// (ArrowDown). Lets a row declare a fixed landing column for downward entry
// without hijacking upward or horizontal moves.
const ENTRY_COL_FROM_ABOVE_ATTR = "data-nav-entry-col-from-above"
const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row" const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row"
const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group" const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group"
const DEFAULT_SYNC_SCROLL_GROUP = "browse" const DEFAULT_SYNC_SCROLL_GROUP = "browse"
@@ -99,7 +103,10 @@ function measureGlobalMetrics(group: string): boolean {
const rowStyle = window.getComputedStyle(row) const rowStyle = window.getComputedStyle(row)
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0") const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
const viewportWidth = row.clientWidth const viewportWidth = row.clientWidth
const deadzoneInset = Math.max(paddingLeft, (viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO) const deadzoneInset = Math.max(
paddingLeft,
(viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO,
)
const leftDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_LEFT_DEADZONE_VAR).trim() const leftDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_LEFT_DEADZONE_VAR).trim()
const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw)) const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw))
? Math.max(0, parseFloat(leftDeadzoneRaw)) ? Math.max(0, parseFloat(leftDeadzoneRaw))
@@ -171,10 +178,7 @@ function getRowMaxScroll(row: HTMLElement): number {
if (n === 0) return 0 if (n === 0) return 0
const lastCol = n - 1 const lastCol = n - 1
const lastItemLeft = m.paddingLeft + lastCol * m.stride const lastItemLeft = m.paddingLeft + lastCol * m.stride
const maxVisibleLeft = Math.max( const maxVisibleLeft = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
m.paddingLeft,
m.viewportWidth - m.cardWidth - m.rightDeadzone,
)
return Math.max(0, lastItemLeft - maxVisibleLeft) return Math.max(0, lastItemLeft - maxVisibleLeft)
} }
@@ -276,14 +280,8 @@ function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null
if (!m) return if (!m) return
const itemLeft = m.paddingLeft + anchorCol * m.stride const itemLeft = m.paddingLeft + anchorCol * m.stride
const leftVisibleLimit = Math.max( const leftVisibleLimit = Math.max(m.paddingLeft, m.leftDeadzone)
m.paddingLeft, const rightVisibleLimit = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
m.leftDeadzone,
)
const rightVisibleLimit = Math.max(
m.paddingLeft,
m.viewportWidth - m.cardWidth - m.rightDeadzone,
)
// Keep focus inside the deadzone: no scroll while the focused item remains // Keep focus inside the deadzone: no scroll while the focused item remains
// between left and right limits. // between left and right limits.
@@ -350,7 +348,9 @@ function getLocalSyncedRowCol(
element: HTMLElement, element: HTMLElement,
requestedCol: number, requestedCol: number,
): number { ): number {
const cards = Array.from(anchorRow.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`)) const cards = Array.from(
anchorRow.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`),
)
if (cards.length === 0) return Math.max(0, requestedCol) if (cards.length === 0) return Math.max(0, requestedCol)
const cardCols = cards const cardCols = cards
@@ -462,9 +462,7 @@ function ensureElementVisibleVertically(element: HTMLElement) {
// Element finding / navigation (unchanged logic, uses getMetrics() now) // Element finding / navigation (unchanged logic, uses getMetrics() now)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function resolveOutOfBoundsNavigation( function resolveOutOfBoundsNavigation(context: OutOfBoundsNavigationContext): HTMLElement | null {
context: OutOfBoundsNavigationContext,
): HTMLElement | null {
const handlers = Array.from(outOfBoundsHandlers) const handlers = Array.from(outOfBoundsHandlers)
for (let i = handlers.length - 1; i >= 0; i--) { for (let i = handlers.length - 1; i >= 0; i--) {
const result = handlers[i]?.(context) const result = handlers[i]?.(context)
@@ -619,10 +617,7 @@ function findElementClosestToLogicalViewportX(
return nearest return nearest
} }
function findNextElement( function findNextElement(current: HTMLElement, direction: NavDirection): HTMLElement | null {
current: HTMLElement,
direction: NavDirection,
): HTMLElement | null {
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10) const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10)
const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10) const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10)
const byRow = getElementsByRow() const byRow = getElementsByRow()
@@ -672,8 +667,21 @@ function findNextElement(
desiredCol.value = currentCol desiredCol.value = currentCol
} }
const entryTarget = findElementAt(targetRow, targetCol, true)
const targetRowElements = byRow.get(targetRow) ?? [] const targetRowElements = byRow.get(targetRow) ?? []
if (direction === "down") {
for (const el of targetRowElements) {
const fromAboveCol = el.element.getAttribute(ENTRY_COL_FROM_ABOVE_ATTR)
if (fromAboveCol === null) continue
const fromAboveTarget = targetRowElements.find((e) => e.col === parseInt(fromAboveCol, 10))
if (fromAboveTarget) {
desiredCol.value = fromAboveTarget.col
return fromAboveTarget.element
}
}
}
const entryTarget = findElementAt(targetRow, targetCol, true)
const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR)) const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR))
if (hasEntryOverride) { if (hasEntryOverride) {
return entryTarget?.element || null return entryTarget?.element || null
+17 -7
View File
@@ -159,10 +159,7 @@ export function useMediaWebSocket() {
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId } return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
} }
function normalizeCastMember( function normalizeCastMember(member: unknown, people: Map<number, Person>): CastMember {
member: unknown,
people: Map<number, Person>,
): CastMember {
if (!Array.isArray(member)) { if (!Array.isArray(member)) {
return { return {
name: "", name: "",
@@ -346,7 +343,10 @@ export function useMediaWebSocket() {
} }
} }
function mergeItemsByHash<T extends MovieUi | SeriesUi>(items: T[], mergeFn: (a: T, b: T) => T): T[] { function mergeItemsByHash<T extends MovieUi | SeriesUi>(
items: T[],
mergeFn: (a: T, b: T) => T,
): T[] {
const map = new Map<string, T[]>() const map = new Map<string, T[]>()
for (const item of items) { for (const item of items) {
const hash = getContentHash(item.id) const hash = getContentHash(item.id)
@@ -436,7 +436,14 @@ export function useMediaWebSocket() {
connected.value = wsRef.value?.readyState === WebSocket.OPEN connected.value = wsRef.value?.readyState === WebSocket.OPEN
} }
function applyRootInit(rootId: string, rootData: { movies: Record<string, Movie>; series: Record<string, Series>; people?: Record<string, unknown> }) { function applyRootInit(
rootId: string,
rootData: {
movies: Record<string, Movie>
series: Record<string, Series>
people?: Record<string, unknown>
},
) {
const state = ensureRootState(rootId) const state = ensureRootState(rootId)
state.peopleMap.clear() state.peopleMap.clear()
@@ -502,7 +509,10 @@ export function useMediaWebSocket() {
const parsed = Number(id) const parsed = Number(id)
const normalized = normalizePerson(person) const normalized = normalizePerson(person)
if (Number.isFinite(parsed)) { if (Number.isFinite(parsed)) {
state.peopleMap.set(parsed, normalized || { name: "", profile_path: null, gender: null }) state.peopleMap.set(
parsed,
normalized || { name: "", profile_path: null, gender: null },
)
} }
} }
} }
+10 -4
View File
@@ -17,9 +17,9 @@ const STORAGE_KEY = "MediaHive"
const RESOLUTION_PRIORITY: Record<string, number> = { const RESOLUTION_PRIORITY: Record<string, number> = {
"8K": 5, "8K": 5,
"4K": 4, "4K": 4,
"FHD": 3, FHD: 3,
"HD": 2, HD: 2,
"SD": 1, SD: 1,
} }
// Max resolution priority allowed for each preference level // Max resolution priority allowed for each preference level
@@ -59,7 +59,13 @@ function loadSettings(): MediaHiveSettings {
} catch { } catch {
// ignore parse errors // ignore parse errors
} }
return { preferredResolution: "rmax", preferredHdr: "none", playerId: "default", playerCustomCmd: null, playerMpcPort: null } return {
preferredResolution: "rmax",
preferredHdr: "none",
playerId: "default",
playerCustomCmd: null,
playerMpcPort: null,
}
} }
const settings = reactive<MediaHiveSettings>(loadSettings()) const settings = reactive<MediaHiveSettings>(loadSettings())
+6 -12
View File
@@ -1,13 +1,7 @@
// Search Web Worker - runs search off the main thread // Search Web Worker - runs search off the main thread
// This file is loaded as a Web Worker, not imported as a module. // This file is loaded as a Web Worker, not imported as a module.
import type { import type { MovieUi, SeriesUi, MatchedPerson, MatchedEpisode, SearchMatchInfo } from "./types"
MovieUi,
SeriesUi,
MatchedPerson,
MatchedEpisode,
SearchMatchInfo,
} from "./types"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Message types // Message types
@@ -65,7 +59,7 @@ let series: SeriesUi[] = []
function normalizeSearchText(value: string): string { function normalizeSearchText(value: string): string {
return value return value
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9\-]+/g, " ") .replace(/[^a-z0-9-]+/g, " ")
.trim() .trim()
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
} }
@@ -277,7 +271,9 @@ function getMatchedWordIndexes(queryWords: string[], value: string): number[] {
} }
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void { function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void {
const existing = target.find((person) => person.name.toLowerCase() === candidate.name.toLowerCase()) const existing = target.find(
(person) => person.name.toLowerCase() === candidate.name.toLowerCase(),
)
if (existing) { if (existing) {
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role) if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
if (candidate.highlightRoles) existing.highlightRoles = true if (candidate.highlightRoles) existing.highlightRoles = true
@@ -292,9 +288,7 @@ function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): vo
} }
function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] { function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
const sorted = indexes const sorted = indexes.filter((index) => availableIndexes.has(index)).sort((a, b) => a - b)
.filter((index) => availableIndexes.has(index))
.sort((a, b) => a - b)
if (sorted.length === 0) return [] if (sorted.length === 0) return []
+5 -6
View File
@@ -566,9 +566,7 @@ export function formatLanguageFlagTitle(
): string { ): string {
const names: string[] = [] const names: string[] = []
const variants: string[] = [] const variants: string[] = []
const external = new Set( const external = new Set((externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)))
(externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)),
)
let hasExternal = false let hasExternal = false
for (const code of entry.sourceCodes) { for (const code of entry.sourceCodes) {
const normalized = resolveLanguageIdentifier(code) const normalized = resolveLanguageIdentifier(code)
@@ -578,9 +576,10 @@ export function formatLanguageFlagTitle(
// Explicit region tags (en-us, es-419) become parenthesized variants; // Explicit region tags (en-us, es-419) become parenthesized variants;
// plain codes contribute their host country. // plain codes contribute their host country.
const suffix = normalized.split("-").pop() ?? "" const suffix = normalized.split("-").pop() ?? ""
const region = /^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-") const region =
? suffix.toUpperCase() /^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
: mapLanguageToCountry(code) ? suffix.toUpperCase()
: mapLanguageToCountry(code)
const regionName = region ? toRegionName(region) : null const regionName = region ? toRegionName(region) : null
const variant = external.has(normalized) const variant = external.has(normalized)
? regionName ? regionName
+19
View File
@@ -0,0 +1,19 @@
pre-commit:
parallel: true
commands:
ruff-check:
glob: "*.py"
run: .venv/bin/ruff check --fix {staged_files}
stage_fixed: true
ruff-format:
glob: "*.py"
run: .venv/bin/ruff format {staged_files}
stage_fixed: true
oxlint:
glob: "frontend/src/**"
run: npm --prefix frontend run lint
stage_fixed: true
oxfmt:
glob: "frontend/src/**"
run: npm --prefix frontend run format
stage_fixed: true
+27 -6
View File
@@ -37,12 +37,33 @@ _SUBTITLE_FLAG_TOKENS = {"forced", "sdh", "cc", "hi", "dhi", "commentary", "sign
# ISO 639-1 -> ISO 639-2/B for common sidecar language tags, so they merge # ISO 639-1 -> ISO 639-2/B for common sidecar language tags, so they merge
# with the codes ffmpeg reports for embedded tracks. # with the codes ffmpeg reports for embedded tracks.
_ISO_639_1_TO_639_2 = { _ISO_639_1_TO_639_2 = {
"ar": "ara", "cs": "ces", "da": "dan", "de": "deu", "el": "ell", "ar": "ara",
"en": "eng", "es": "esp", "fi": "fin", "fr": "fra", "he": "heb", "cs": "ces",
"hi": "hin", "hu": "hun", "id": "ind", "it": "ita", "ja": "jpn", "da": "dan",
"ko": "kor", "nl": "nld", "no": "nor", "pl": "pol", "pt": "por", "de": "deu",
"ru": "rus", "sv": "swe", "th": "tha", "tr": "tur", "uk": "ukr", "el": "ell",
"vi": "vie", "zh": "zho", "en": "eng",
"es": "esp",
"fi": "fin",
"fr": "fra",
"he": "heb",
"hi": "hin",
"hu": "hun",
"id": "ind",
"it": "ita",
"ja": "jpn",
"ko": "kor",
"nl": "nld",
"no": "nor",
"pl": "pol",
"pt": "por",
"ru": "rus",
"sv": "swe",
"th": "tha",
"tr": "tur",
"uk": "ukr",
"vi": "vie",
"zh": "zho",
} }
# Caches for expensive operations. These are per-scan only: the scanner # Caches for expensive operations. These are per-scan only: the scanner
+39 -38
View File
@@ -63,6 +63,7 @@ gui = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"httpx>=0.28.1", "httpx>=0.28.1",
"lefthook>=2.1.14",
"ruff>=0.15.14", "ruff>=0.15.14",
"setuptools-scm>=8", "setuptools-scm>=8",
] ]
@@ -73,52 +74,52 @@ preview = true
[tool.ruff.lint] [tool.ruff.lint]
select = ["ALL"] select = ["ALL"]
ignore = [ ignore = [
"D203", "incorrect-blank-line-before-class",
"D213", "multi-line-summary-second-line",
"DOC201", "docstring-missing-returns",
"COM812", "missing-trailing-comma",
"T201", "print",
"EM", "EM",
"TC", "TC",
"TRY003", "raise-vanilla-args",
"S", "S",
"CPY001", "missing-copyright-notice",
"PLR", "PLR",
"PLW", "PLW",
# TEMP suppressions - revisit and remove after focused cleanup passes. # TEMP suppressions - revisit and remove after focused cleanup passes.
"C901", "complex-structure",
"DOC501", "docstring-missing-exception",
"ANN201", "missing-return-type-undocumented-public-function",
"D103", "undocumented-public-function",
"FBT001", "boolean-type-hint-positional-argument",
"ANN001", "missing-type-function-argument",
"D102", "undocumented-public-method",
"TRY300", "try-consider-else",
"D107", "undocumented-public-init",
"ANN202", "missing-return-type-private-function",
"B904", "raise-without-from-inside-except",
"ASYNC220", "create-subprocess-in-async-function",
"E501", "line-too-long",
"INP001", "implicit-namespace-package",
"FBT002", "boolean-default-value-positional-argument",
"PLC0415", "import-outside-top-level",
"D101", "undocumented-public-class",
"RUF006", "asyncio-dangling-task",
"SLF001", "private-member-access",
"ASYNC240", "blocking-path-method-in-async-function",
"N801", "invalid-class-name",
"SIM102", "collapsible-if",
"DTZ005", "call-datetime-now-without-tzinfo",
"RUF034", "useless-if-else",
"D415", "missing-terminal-punctuation",
"D400", "missing-trailing-period",
"ANN401", "any-type",
# Allow en-dash in docstrings (used for list formatting) # Allow en-dash in docstrings (used for list formatting)
"RUF002", "ambiguous-unicode-character-docstring",
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.) # Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
"N806", "non-lowercase-variable-in-function",
# Allow inline comments that describe output formats # Allow inline comments that describe output formats
"ERA001", "commented-out-code",
# Allow unused local variables in ctypes COM boilerplate # Allow unused local variables in ctypes COM boilerplate
"F841", "unused-variable",
] ]
+37 -7
View File
@@ -82,10 +82,34 @@ class _Platform(NamedTuple):
def _platform() -> _Platform: def _platform() -> _Platform:
if sys.platform == "win32": if sys.platform == "win32":
return _Platform("win64", "win", "win-x64", "MediaHive", "mediahive.ico", "MediaHive.exe", ".exe") return _Platform(
"win64",
"win",
"win-x64",
"MediaHive",
"mediahive.ico",
"MediaHive.exe",
".exe",
)
if sys.platform == "darwin": if sys.platform == "darwin":
return _Platform("macos", "osx", "osx-arm64", "MediaHive.app", "mediahive.icns", "MediaHive", ".pkg") return _Platform(
return _Platform("linux", "linux", "linux-x64", "MediaHive", "mediahive.png", "MediaHive", ".AppImage") "macos",
"osx",
"osx-arm64",
"MediaHive.app",
"mediahive.icns",
"MediaHive",
".pkg",
)
return _Platform(
"linux",
"linux",
"linux-x64",
"MediaHive",
"mediahive.png",
"MediaHive",
".AppImage",
)
def setup_artifact_name() -> str: def setup_artifact_name() -> str:
@@ -253,7 +277,7 @@ def _dotnet_runtime_major(exe: Path) -> int | None:
result = subprocess.run( result = subprocess.run(
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30 [str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
) )
except (OSError, subprocess.TimeoutExpired): except OSError, subprocess.TimeoutExpired:
return None return None
if result.returncode != 0: if result.returncode != 0:
return None return None
@@ -455,20 +479,26 @@ def force_macos_user_install(pkg: Path) -> None:
components = list(expanded.glob("*.pkg")) components = list(expanded.glob("*.pkg"))
if len(components) != 1: if len(components) != 1:
contents = sorted(p.name for p in expanded.iterdir()) contents = sorted(p.name for p in expanded.iterdir())
raise RuntimeError(f"Unexpected pkg layout: components={components} in {contents}") raise RuntimeError(
f"Unexpected pkg layout: components={components} in {contents}"
)
component = components[0] component = components[0]
if component.is_dir(): if component.is_dir():
comp_dir = component comp_dir = component
else: else:
comp_dir = expanded / (component.stem + "-component") comp_dir = expanded / (component.stem + "-component")
subprocess.run(["pkgutil", "--expand", str(component), str(comp_dir)], check=True) subprocess.run(
["pkgutil", "--expand", str(component), str(comp_dir)], check=True
)
postinstall = comp_dir / "Scripts" / "postinstall" postinstall = comp_dir / "Scripts" / "postinstall"
script = postinstall.read_text() script = postinstall.read_text()
if 'sudo -u "$USER" ' not in script: if 'sudo -u "$USER" ' not in script:
raise RuntimeError("Unexpected postinstall script: sudo prefix not found") raise RuntimeError("Unexpected postinstall script: sudo prefix not found")
postinstall.write_text(script.replace('sudo -u "$USER" ', "")) postinstall.write_text(script.replace('sudo -u "$USER" ', ""))
if comp_dir is not component: if comp_dir is not component:
subprocess.run(["pkgutil", "--flatten", str(comp_dir), str(component)], check=True) subprocess.run(
["pkgutil", "--flatten", str(comp_dir), str(component)], check=True
)
shutil.rmtree(comp_dir) shutil.rmtree(comp_dir)
subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True) subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True)
+6 -2
View File
@@ -73,7 +73,9 @@ def load_token() -> str:
# MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage, # MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage,
# MediaHive-win64-portable.zip) so /releases/download/latest/<name> links # MediaHive-win64-portable.zip) so /releases/download/latest/<name> links
# stay valid. The version comes from setuptools_scm instead. # stay valid. The version comes from setuptools_scm instead.
_ARTIFACT_RE = re.compile(r"^MediaHive-(?!\d)[A-Za-z0-9._-]+\.(?:zip|dmg|exe|pkg|AppImage)$") _ARTIFACT_RE = re.compile(
r"^MediaHive-(?!\d)[A-Za-z0-9._-]+\.(?:zip|dmg|exe|pkg|AppImage)$"
)
def read_version() -> str: def read_version() -> str:
@@ -89,7 +91,9 @@ def read_version() -> str:
def find_releasable_artifacts() -> list[Path]: def find_releasable_artifacts() -> list[Path]:
"""Return platform artifact paths in build/.""" """Return platform artifact paths in build/."""
build_dir = REPO_ROOT / "build" build_dir = REPO_ROOT / "build"
return [p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)] return [
p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)
]
def find_dist_files(version: str) -> list[Path]: def find_dist_files(version: str) -> list[Path]: