10 Commits
Author SHA1 Message Date
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
LeoVasanko 5fc20c5578 Versionless installer asset names, stable latest-download README links
release / gui-build (linux, bash) (push) Successful in 1m0s
release / gui-build (windows, cmd) (push) Successful in 1m18s
release / gui-build (macos, bash) (push) Successful in 1m41s
2026-09-23 19:29:02 +00:00
LeoVasanko 550131d43b Publish wheel/sdist to PyPI from the linux release job 2026-09-23 18:56:33 +00:00
LeoVasanko f2fc6f657f Updated fastapi-vue-setup 1.7.2
release / gui-build (linux, bash) (push) Successful in 55s
release / gui-build (windows, cmd) (push) Successful in 1m19s
release / gui-build (macos, bash) (push) Failing after 1m14s
- DEVMODE removed; FASTAPI_VUE=MEDIAHIVE is set at entrypoints and
  fastapi_vue.env drives dev-mode checks
- CLI roots now reach the server via fastapi-vue's env(Config) teleport
  (mediahive.config.config) instead of MEDIAHIVE_ROOTS
- Legacy media_folder config field and migration removed
- mediahive.* logger level set via log_config (DEBUG in dev, INFO in prod)
  in both server.run() and winmain's patched uvicorn config
2026-09-23 18:41:51 +00:00
LeoVasanko 1477c240a1 Installers for all platforms and related fixes (#1)
- Windows portable ZIP remains, but has been fixed so that it isn't poisoned by being a web download
- Velopack installer offered on all platforms: Linux AppImage, Windows setup.exe, Mac setup.pkg
- Mac updated to Qt6 which has a modern Chromium as opposed to Qt5 that didn't
- Application log now shown on settings panel
- Automated cross-platform builds via Gitea actions

Reviewed-on: #1
2026-09-23 17:41:21 +00:00
35 changed files with 674 additions and 479 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
+9
View File
@@ -43,6 +43,15 @@ jobs:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: uv run scripts/release.py
# Wheel/sdist are platform-independent; the linux job also pushes them
# to PyPI. Token is the PYPI_TOKEN repository secret.
- name: Publish to PyPI
if: matrix.os == 'linux'
shell: ${{ matrix.shell }}
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: uv publish
- name: Attach platform artifact to the Gitea release
if: matrix.os != 'linux'
shell: ${{ matrix.shell }}
+2
View File
@@ -20,3 +20,5 @@ package-lock.json
.*
!.gitignore
!.gitea/
!.git-blame-ignore-revs
!.pre-commit-config.yaml
+11 -7
View File
@@ -1,16 +1,20 @@
![MediaHive](docs/mediahive.avif)
![MediaHive](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/mediahive.avif)
# MediaHive
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
**[Windows, Mac and Linux downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
## Downloads
## Getting Started
- **Windows**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-win64-setup.exe) · [Portable ZIP](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-win64-portable.zip)
- **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)
- Windows: Download `*-win64-setup.exe` from the releases page and run it (no admin needed; auto-updates included). A `-win64-portable.zip` is also available.
- macOS: Download `*-macos-setup.pkg` and install (auto-updates included).
- Linux: Download the `.AppImage`, `chmod +x` it, and run. Alternatively install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run directly with `uvx --from mediahive[gui] mediahive`.
You may also run or install with [UV](https://docs.astral.sh/uv/getting-started/installation/):
```
uvx --from mediahive[gui] mediahive
```
## What It Does
@@ -20,7 +24,7 @@ Netflix style browsing of your local media archive. Supports keyboard, mouse and
- Remembers per-episode playback positions and offers series continue points
- Hand off playback to your preferred system player
Windows and macOS builds are currently portable-only (no installer). On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
Note that `.mediahive` folder is created in your media folder to hold all the metadata and preview clips, avoiding the lengthy processing that you will see on initial startup.
+1 -1
View File
@@ -32,7 +32,7 @@ All media paths are scoped to a **root**, identified by a friendly `root_id`
- `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` is constrained to `<root>/.mediahive/{asset_type}` where `asset_type` is one of `movies`, `series`, `people`.
- `GET /api/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`.
- `GET /api/player/status` returns `{ "remote": true|false }`.
- Roots may also be provided at startup via the `MEDIAHIVE_ROOTS` environment variable (JSON dict of name → path), which overrides the persisted configuration.
- Roots may also be provided at startup via CLI arguments (`mediahive /path/to/media ...`), which are passed to the server through fastapi-vue's env config (`mediahive.config.config`) and override the persisted configuration.
- Assumed playback: after `POST /api/play/{root_id}` the launched item is assumed to be playing while the frontend reports no input activity. On the next `POST /api/activity` the guessed position (`resume base + elapsed`, capped at the TMDb runtime) is written once; watches under 5 minutes are discarded (a peek is not progress). A resume entry written by another tracker (e.g. the GUI's MPC-BE tracker) during the session overrides the guess. The MPC-BE tracker likewise ignores sessions shorter than 5 minutes.
## WebSocket
+45 -23
View File
@@ -11,12 +11,19 @@
<span>Library activity</span>
<span v-if="!wsConnected" class="activity-connection">Reconnecting...</span>
</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 v-if="root.scanTarget" class="activity-root-target">{{ root.scanTarget }}</div>
<div class="activity-phase-row">
<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 class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }">
<div
@@ -215,7 +222,10 @@ import {
type EpisodeWatchEntry,
} from "./api"
import { useSettings } from "./composables/useSettings"
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
import {
useKeyboardNavigation,
setActiveNavigationScope,
} from "./composables/useKeyboardNavigation"
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
import { useMediaWebSocket } from "./composables/useMediaWebSocket"
import Header from "./components/Header.vue"
@@ -380,7 +390,8 @@ function describeRootProgress(
}
} else if (showreelCount > 0) {
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) {
phaseLabel = "Finalizing updates"
phaseDetail = "Applying library changes"
@@ -472,7 +483,11 @@ watch(
const key = `${task.root_id}:${task.id}`
nextSeen.set(key, task.status)
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 doneMatch = detail.match(/^Done\s+[\u2014-]\s+(\d+)\s+movies,\s+(\d+)\s+series$/i)
if (doneMatch) {
@@ -480,7 +495,9 @@ watch(
const series = Number(doneMatch[2] || "0")
if (movies > 0 || series > 0) {
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(
mediaId: string | null,
): Record<string, EpisodeWatchEntry> | null {
function getResumeEpisodes(mediaId: string | null): Record<string, EpisodeWatchEntry> | null {
if (!mediaId) return null
return resumePositions.value[mediaId]?.episodes ?? null
}
@@ -675,7 +690,9 @@ function showAdjacentDetail(offset: -1 | 1): boolean {
const sequence = getDetailAdjacentSequence(current)
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
const nextIndex = currentIndex + offset
@@ -771,7 +788,10 @@ function restoreBrowseFocus(path: string) {
}, 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 focusedCard = active?.closest("[data-item-id]") as HTMLElement | null
if (!focusedCard) return null
@@ -832,9 +852,7 @@ function clearSearch(options: { preferBack?: boolean; targetPath?: string } = {}
? normalizeHistoryPath(window.history.state.back)
: ""
const canRestoreWithBack =
options.preferBack !== false &&
searchReturnPath.value === targetPath &&
backPath === targetPath
options.preferBack !== false && searchReturnPath.value === targetPath && backPath === targetPath
searchReturnPath.value = null
@@ -1105,14 +1123,18 @@ function showDetail(item: MediaItem) {
handlePlay(playableFile)
} else {
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 {
const detailSearchPath = getDetailSearchPath()
const searchPath = searchQuery.value
? getSearchPath(searchQuery.value)
: detailSearchPath
router.push({ path: `/${item.type}/${item.id}`, state: searchPath ? { searchPath } : undefined })
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
router.push({
path: `/${item.type}/${item.id}`,
state: searchPath ? { searchPath } : undefined,
})
}
}
@@ -1133,9 +1155,7 @@ function handleActorSearch(actorName: string) {
function handleSelectMovieFromDetail(movieId: string) {
const detailSearchPath = getDetailSearchPath()
const searchPath = searchQuery.value
? getSearchPath(searchQuery.value)
: detailSearchPath
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined })
}
@@ -1174,7 +1194,9 @@ function focusDetailEntryTarget(item: MediaItem): boolean {
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) {
focusElement(firstFocusable)
return true
+1 -7
View File
@@ -594,13 +594,7 @@ function activateItem(item: MediaItem) {
function handleItemClick(event: MouseEvent, item: MediaItem) {
// Let modified clicks navigate natively
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
return
}
event.preventDefault()
@@ -65,11 +65,7 @@ function getPlayLabel(filePath: string | null | undefined): string {
function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return []
return Array.from(
menuRef.value.querySelectorAll<HTMLElement>(
'.ctx-btn:not(:disabled)'
)
)
return Array.from(menuRef.value.querySelectorAll<HTMLElement>(".ctx-btn:not(:disabled)"))
}
function focusNext(delta: number) {
@@ -130,9 +126,7 @@ watch(
await nextTick()
clampToViewport()
// Focus first action button for keyboard navigation
const firstBtn = menuRef.value?.querySelector(
".ctx-btn:not(:disabled)",
) as HTMLElement | null
const firstBtn = menuRef.value?.querySelector(".ctx-btn:not(:disabled)") as HTMLElement | null
firstBtn?.focus()
},
{ immediate: true },
+51 -21
View File
@@ -1,7 +1,13 @@
<template>
<Teleport to="body">
<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
v-for="(row, rowIndex) in rows"
@@ -26,7 +32,9 @@
tabindex="-1"
@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>
</div>
<!-- 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 } {
switch (row) {
case 0: return { start: ROW_0_START, count: ROW_0_COUNT }
case 1: return { start: ROW_1_START, count: ROW_1_COUNT }
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 }
case 0:
return { start: ROW_0_START, count: ROW_0_COUNT }
case 1:
return { start: ROW_1_START, count: ROW_1_COUNT }
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")
}
function findNext(
currentIdx: number,
direction: "up" | "down" | "left" | "right",
): number | null {
function findNext(currentIdx: number, direction: "up" | "down" | "left" | "right"): number | null {
const current = getCoord(currentIdx)
if (direction === "left") {
@@ -510,10 +520,18 @@ onUnmounted(() => {
}
/* Row horizontal offsets for honeycomb staggering */
.hex-keyboard-row-0 { margin-left: 0; }
.hex-keyboard-row-1 { margin-left: calc(var(--key-w) * 0.5); }
.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-0 {
margin-left: 0;
}
.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 {
position: relative;
@@ -529,7 +547,9 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
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;
transform: scale(0.97);
}
@@ -538,7 +558,8 @@ onUnmounted(() => {
.hex-key-row-0 {
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%);
}
.hex-key-row-2 {
@@ -553,8 +574,12 @@ onUnmounted(() => {
}
@keyframes hex-label-fade {
0% { color: #22c55e; }
100% { color: #ffffff; }
0% {
color: #22c55e;
}
100% {
color: #ffffff;
}
}
/* Special key backgrounds override row gradients */
@@ -600,8 +625,13 @@ onUnmounted(() => {
}
@keyframes hex-outline-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* Transition */
+4 -8
View File
@@ -80,7 +80,9 @@
</div>
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
<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
v-for="(castName, castIndex) in formattedCastNames"
:key="`${castName}-${castIndex}`"
@@ -112,13 +114,7 @@ const emit = defineEmits<{
function handleClick(event: MouseEvent) {
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
return
}
// Prevent default navigation for plain left-clicks and synthetic clicks
+84 -71
View File
@@ -2,6 +2,7 @@
<!-- Full screen view for series -->
<SeriesFullView
v-if="item.type === 'series'"
:key="item.id"
:series="item.data as Series"
:all-movies="allMovies"
:focus-episode="focusEpisode"
@@ -185,7 +186,11 @@
v-if="item.type === 'movies' && collectionMovies.length > 1"
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
v-for="(movie, collectionIndex) in collectionMovies"
:key="movie.localId"
@@ -234,7 +239,15 @@
<script setup lang="ts">
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 {
getCoverUrl,
@@ -315,7 +328,9 @@ function getReleaseAtRow(row: number): HTMLElement | 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
for (const release of releases) {
const row = parseInt(release.getAttribute("data-nav-row") || "", 10)
@@ -342,11 +357,7 @@ function registerMovieOutOfBoundsShortcut() {
return null
}
if (
direction === "left" &&
current.hasAttribute("data-nav-cast-item") &&
currentCol === 0
) {
if (direction === "left" && current.hasAttribute("data-nav-cast-item") && currentCol === 0) {
const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow)
if (targetRow === null) return null
return getReleaseAtRow(targetRow)
@@ -755,23 +766,8 @@ const castNavRow = computed(() => {
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
})
const collectionMovies = computed((): Array<{
title: string
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<{
const collectionMovies = computed(
(): Array<{
title: string
localId: string
coverPath: string | null
@@ -779,60 +775,77 @@ const collectionMovies = computed((): Array<{
year: string | null
hyphenLang: string | null
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 otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
if (otherCollectionName !== normalizedCollectionName) continue
const matches: Array<{
title: string
localId: string
coverPath: string | null
rootId: string | null
year: string | null
hyphenLang: string | null
isCurrent: boolean
}> = []
const title = libraryMovie.title || libraryMovie.info?.title
if (!title) continue
let hasCurrentInMatches = false
const isCurrent = libraryMovie.id === props.item.id
if (isCurrent) hasCurrentInMatches = true
for (const libraryMovie of props.allMovies || []) {
const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
if (otherCollectionName !== normalizedCollectionName) continue
matches.push({
title,
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,
})
}
const title = libraryMovie.title || libraryMovie.info?.title
if (!title) continue
if (!hasCurrentInMatches) {
matches.push({
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,
})
}
const isCurrent = libraryMovie.id === props.item.id
if (isCurrent) hasCurrentInMatches = 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)
matches.push({
title,
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 (hasYearA && hasYearB && yearA !== yearB) return yearA - yearB
if (hasYearA !== hasYearB) return hasYearA ? -1 : 1
return a.title.localeCompare(b.title)
})
.slice(0, 24)
})
if (!hasCurrentInMatches) {
matches.push({
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
.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 {
if (!language) return null
@@ -84,7 +84,7 @@ const disabled = computed(() => !props.filePath)
function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return []
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(() => {
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)
.join(" ")
return hdr10PlusPattern.test(text)
+37 -14
View File
@@ -96,7 +96,9 @@
formatDate(selectedSeason.air_date)
}}</span>
<span
>{{ selectedSeason.episode_count ?? selectedSeason.episodes.length }}
>{{
selectedSeason.episode_count ?? selectedSeason.episodes.length
}}
Episodes</span
>
</div>
@@ -112,7 +114,7 @@
<div class="episodes-grid">
<div
v-for="(episode, eIndex) in selectedSeason?.episodes || []"
:key="`${selectedSeasonIndex}-${episode.episode_number}`"
:key="`${seriesIdentity}-${selectedSeasonIndex}-${episode.episode_number}`"
class="episode-tile"
:class="{ 'episode-tile--ahead': isEpisodeAhead(eIndex) }"
v-bind="getEpisodeNavAttrs(selectedSeasonIndex, eIndex)"
@@ -161,9 +163,7 @@
<span
v-if="episodeWatchIndicator(episode)"
class="ep-watch"
:title="
episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'
"
:title="episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'"
>{{ episodeWatchIndicator(episode) }}</span
>
<div class="tile-play"></div>
@@ -258,7 +258,7 @@ import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
import { sortTorrentsByPreference } from "../composables/useSettings"
const props = defineProps<{
series: Series & { root_id?: string | null }
series: Series & { id?: string; root_id?: string | null }
allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
resumePoint?: SeriesResumePoint | null
@@ -297,6 +297,15 @@ const selectedSeason = computed<Season | 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) {
if (index < 0 || index >= props.series.seasons.length) return
if (selectedSeasonIndex.value === index) return
@@ -358,9 +367,7 @@ const cursorGlobalIndex = computed(() =>
const resumePointGlobalIndex = computed(() => {
const point = props.resumePoint
if (!point) return null
const seasonIndex = props.series.seasons.findIndex(
(s) => s.season_number === point.seasonNumber,
)
const seasonIndex = props.series.seasons.findIndex((s) => s.season_number === point.seasonNumber)
if (seasonIndex < 0) return null
const episodeIndex = props.series.seasons[seasonIndex]?.episodes.findIndex(
(e) => e.episode_number === point.episodeNumber,
@@ -548,6 +555,9 @@ function getEpisodeNavAttrs(seasonIndex: number, episodeIndex: number) {
...navAttrs(coords.row, coords.col),
"data-season-index": seasonIndex,
"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,
(ep) => {
if (!ep) return
if (
!props.focusEpisode &&
(seasonUserInteracted.value || episodeCursorIndex.value !== null)
) {
if (!props.focusEpisode && (seasonUserInteracted.value || episodeCursorIndex.value !== null)) {
return
}
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;
// stop their playback too.
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) 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 {
@@ -110,7 +110,9 @@ function getDigitalRepeatIntervalMs(holdMs: number): number {
function getAnalogRepeatIntervalMs(intensity: number): number {
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 {
+10 -1
View File
@@ -2,10 +2,10 @@ import { reportUserActivity } from "../api"
type InputModality = "mouse" | "keyboard" | "gamepad"
const MOUSE_IDLE_MS = 1400
const MOUSE_INTENT_DISTANCE_PX = 28
const MOUSE_INTENT_WINDOW_MS = 700
const MOUSE_OVER_INTENT_RECENCY_MS = 500
const MOUSE_INTENT_SELECTOR = [
"[data-nav-focusable]",
"button",
@@ -28,6 +28,7 @@ let mouseIdleTimer: number | null = null
let pointerVisible = false
let mouseTravelPx = 0
let lastMouseMoveAt = 0
let lastAnyMouseMoveAt = 0
function clearMouseIdleTimer() {
if (mouseIdleTimer !== null) {
@@ -95,6 +96,7 @@ function registerMouseIntentTravel(event: MouseEvent): boolean {
function handleMouseMove(event: MouseEvent) {
reportUserActivity()
showPointerFromMotion()
lastAnyMouseMoveAt = performance.now()
if (modality === "mouse") {
applyInputState(true)
@@ -111,6 +113,13 @@ function handleMouseOver(event: MouseEvent) {
if (modality === "mouse") 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.
activateMouseInput()
}
@@ -36,6 +36,10 @@ const FOCUSABLE_ATTR = "data-nav-focusable"
const ROW_ATTR = "data-nav-row"
const COL_ATTR = "data-nav-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_GROUP_ATTR = "data-sync-scroll-group"
const DEFAULT_SYNC_SCROLL_GROUP = "browse"
@@ -99,7 +103,10 @@ function measureGlobalMetrics(group: string): boolean {
const rowStyle = window.getComputedStyle(row)
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
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 leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw))
? Math.max(0, parseFloat(leftDeadzoneRaw))
@@ -171,10 +178,7 @@ function getRowMaxScroll(row: HTMLElement): number {
if (n === 0) return 0
const lastCol = n - 1
const lastItemLeft = m.paddingLeft + lastCol * m.stride
const maxVisibleLeft = Math.max(
m.paddingLeft,
m.viewportWidth - m.cardWidth - m.rightDeadzone,
)
const maxVisibleLeft = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
return Math.max(0, lastItemLeft - maxVisibleLeft)
}
@@ -276,14 +280,8 @@ function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null
if (!m) return
const itemLeft = m.paddingLeft + anchorCol * m.stride
const leftVisibleLimit = Math.max(
m.paddingLeft,
m.leftDeadzone,
)
const rightVisibleLimit = Math.max(
m.paddingLeft,
m.viewportWidth - m.cardWidth - m.rightDeadzone,
)
const leftVisibleLimit = Math.max(m.paddingLeft, 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
// between left and right limits.
@@ -350,7 +348,9 @@ function getLocalSyncedRowCol(
element: HTMLElement,
requestedCol: 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)
const cardCols = cards
@@ -462,9 +462,7 @@ function ensureElementVisibleVertically(element: HTMLElement) {
// Element finding / navigation (unchanged logic, uses getMetrics() now)
// ---------------------------------------------------------------------------
function resolveOutOfBoundsNavigation(
context: OutOfBoundsNavigationContext,
): HTMLElement | null {
function resolveOutOfBoundsNavigation(context: OutOfBoundsNavigationContext): HTMLElement | null {
const handlers = Array.from(outOfBoundsHandlers)
for (let i = handlers.length - 1; i >= 0; i--) {
const result = handlers[i]?.(context)
@@ -619,10 +617,7 @@ function findElementClosestToLogicalViewportX(
return nearest
}
function findNextElement(
current: HTMLElement,
direction: NavDirection,
): HTMLElement | null {
function findNextElement(current: HTMLElement, direction: NavDirection): HTMLElement | null {
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10)
const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10)
const byRow = getElementsByRow()
@@ -672,8 +667,21 @@ function findNextElement(
desiredCol.value = currentCol
}
const entryTarget = findElementAt(targetRow, targetCol, true)
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))
if (hasEntryOverride) {
return entryTarget?.element || null
+17 -7
View File
@@ -159,10 +159,7 @@ export function useMediaWebSocket() {
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
}
function normalizeCastMember(
member: unknown,
people: Map<number, Person>,
): CastMember {
function normalizeCastMember(member: unknown, people: Map<number, Person>): CastMember {
if (!Array.isArray(member)) {
return {
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[]>()
for (const item of items) {
const hash = getContentHash(item.id)
@@ -436,7 +436,14 @@ export function useMediaWebSocket() {
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)
state.peopleMap.clear()
@@ -502,7 +509,10 @@ export function useMediaWebSocket() {
const parsed = Number(id)
const normalized = normalizePerson(person)
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> = {
"8K": 5,
"4K": 4,
"FHD": 3,
"HD": 2,
"SD": 1,
FHD: 3,
HD: 2,
SD: 1,
}
// Max resolution priority allowed for each preference level
@@ -59,7 +59,13 @@ function loadSettings(): MediaHiveSettings {
} catch {
// 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())
+6 -12
View File
@@ -1,13 +1,7 @@
// Search Web Worker - runs search off the main thread
// This file is loaded as a Web Worker, not imported as a module.
import type {
MovieUi,
SeriesUi,
MatchedPerson,
MatchedEpisode,
SearchMatchInfo,
} from "./types"
import type { MovieUi, SeriesUi, MatchedPerson, MatchedEpisode, SearchMatchInfo } from "./types"
// ---------------------------------------------------------------------------
// Message types
@@ -65,7 +59,7 @@ let series: SeriesUi[] = []
function normalizeSearchText(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9\-]+/g, " ")
.replace(/[^a-z0-9-]+/g, " ")
.trim()
.replace(/\s+/g, " ")
}
@@ -277,7 +271,9 @@ function getMatchedWordIndexes(queryWords: string[], value: string): number[] {
}
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.roles.includes(candidate.role)) existing.roles.push(candidate.role)
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[] {
const sorted = indexes
.filter((index) => availableIndexes.has(index))
.sort((a, b) => a - b)
const sorted = indexes.filter((index) => availableIndexes.has(index)).sort((a, b) => a - b)
if (sorted.length === 0) return []
+5 -6
View File
@@ -566,9 +566,7 @@ export function formatLanguageFlagTitle(
): string {
const names: string[] = []
const variants: string[] = []
const external = new Set(
(externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)),
)
const external = new Set((externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)))
let hasExternal = false
for (const code of entry.sourceCodes) {
const normalized = resolveLanguageIdentifier(code)
@@ -578,9 +576,10 @@ export function formatLanguageFlagTitle(
// Explicit region tags (en-us, es-419) become parenthesized variants;
// plain codes contribute their host country.
const suffix = normalized.split("-").pop() ?? ""
const region = /^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
? suffix.toUpperCase()
: mapLanguageToCountry(code)
const region =
/^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
? suffix.toUpperCase()
: mapLanguageToCountry(code)
const regionName = region ? toRegionName(region) : null
const variant = external.has(normalized)
? regionName
+5 -5
View File
@@ -8,11 +8,11 @@
* - Disables Vite's screen clearing on startup
*
* Options:
* paths - Array of paths to proxy (default: ["/api"])
* paths - Array of paths to proxy (default: ['/api'])
*/
export default function fastapiVue({ paths = ["/api"] } = {}) {
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8421"
export default function fastapiVue({ paths = ['/api'] } = {}) {
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || 'http://localhost:8421'
// Build proxy configuration for each path
const proxy = {}
@@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
}
return {
name: "vite-plugin-fastapi-mediahive",
name: 'vite-plugin-fastapi-mediahive',
config: () => ({
clearScreen: false,
server: { proxy },
build: {
outDir: "../mediahive/frontend-build",
outDir: '../mediahive/frontend-build',
emptyOutDir: true,
},
}),
+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
+17 -7
View File
@@ -1,16 +1,20 @@
"""MediaHive CLI entrypoint."""
import os
# Must be set before fastapi_vue env bindings are created (mediahive.config).
os.environ["FASTAPI_VUE"] = "MEDIAHIVE"
import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from fastapi_vue import server
from fastapi_vue import env, server
from mediahive.config import config
DEFAULT_PORT = 8420
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
def _configure_windows_event_loop_policy() -> None:
@@ -140,10 +144,11 @@ def main() -> None:
name = f"{base_name}{suffix}"
suffix += 1
roots[name] = p.as_posix()
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots)
# Teleported to the server process by fastapi-vue's server.run().
config.roots = roots
if (
DEVMODE
env.dev
and sys.platform == "win32"
and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1"
):
@@ -156,7 +161,12 @@ def main() -> None:
default_port=DEFAULT_PORT,
server_header=False,
loop="none" if sys.platform == "win32" else "auto",
reload=Path(__file__).parent if DEVMODE and sys.platform != "win32" else False,
reload=Path(__file__).parent if env.dev and sys.platform != "win32" else False,
# fastapi-vue routes the root logger at INFO in dev / WARNING in prod;
# keep our own loggers visible in production too.
log_config={
"loggers": {"mediahive": {"level": "DEBUG" if env.dev else "INFO"}}
},
)
+8 -16
View File
@@ -13,14 +13,20 @@ from pathlib import Path
import msgspec
import msgspec.toml
from fastapi_vue import env
from platformdirs import user_config_path, user_log_path
class Config(msgspec.Struct, omit_defaults=True):
media_folder: str | None = None
roots: dict[str, str] | None = None
# Runtime config shared between the CLI entrypoint and the server process via
# fastapi-vue's env teleport (MEDIAHIVE_CONFIG). Values set here take
# precedence over the persisted config file.
config = env(Config)
def config_dir() -> Path:
# appauthor=False: avoid the doubled %LOCALAPPDATA%\mediahive\mediahive.
# roaming=False: config is machine-specific state, not something to sync
@@ -37,25 +43,11 @@ def config_path() -> Path:
return config_dir() / "config.toml"
def _migrate_legacy_media_folder(cfg: Config) -> Config:
"""If roots is empty but media_folder exists, seed roots with it."""
if cfg.roots:
return cfg
if not cfg.media_folder:
return cfg
path = Path(cfg.media_folder)
name = path.name or path.anchor.strip("/\\").lower() or "media"
# Resolve collisions simply by using the basename; if user had weird layout
# they can rename via the UI later.
return msgspec.structs.replace(cfg, roots={name: cfg.media_folder})
def load_config() -> Config:
path = config_path()
if path.exists():
try:
cfg = msgspec.toml.decode(path.read_bytes(), type=Config)
return _migrate_legacy_media_folder(cfg)
return msgspec.toml.decode(path.read_bytes(), type=Config)
except OSError, msgspec.DecodeError, msgspec.ValidationError:
return Config()
return Config()
+9 -6
View File
@@ -1,13 +1,18 @@
"""Hivescan CLI entrypoint."""
import os
# Must be set before fastapi_vue env bindings are created (mediahive.config).
os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE")
import argparse
import asyncio
import json
import logging
import os
import sys
from pathlib import Path
from mediahive.config import config
def _configure_windows_event_loop_policy() -> None:
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
@@ -55,11 +60,9 @@ The server exposes a unified endpoint:
args = parser.parse_args()
# Defer filesystem validation to the server; pass raw path via env.
# Defer filesystem validation to the server; pass raw path via env config.
media_root = Path(args.media_folder).expanduser()
os.environ["MEDIAHIVE_ROOTS"] = json.dumps({
media_root.name or "media": media_root.as_posix()
})
config.roots = {media_root.name or "media": media_root.as_posix()}
logging.basicConfig(
level=logging.INFO,
+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
# with the codes ffmpeg reports for embedded tracks.
_ISO_639_1_TO_639_2 = {
"ar": "ara", "cs": "ces", "da": "dan", "de": "deu", "el": "ell",
"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",
"ar": "ara",
"cs": "ces",
"da": "dan",
"de": "deu",
"el": "ell",
"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
+10 -20
View File
@@ -37,10 +37,9 @@ from fastapi.responses import (
Response,
StreamingResponse,
)
from fastapi_vue import Frontend
from fastapi_vue import Frontend, env
from mediahive.__main__ import DEVMODE
from mediahive.config import load_config, log_dir
from mediahive.config import config, load_config, log_dir
from mediahive.hivescan.images import close_image_client
from mediahive.hivescan.scanner import RootScanner
from mediahive.hivescan.tmdb_client import close_http_client
@@ -974,23 +973,14 @@ async def _activate_all_roots() -> None:
"""
desired: dict[str, str] = {}
# 1. CLI roots via MEDIAHIVE_ROOTS (JSON dict)
env_roots_raw = os.environ.get("MEDIAHIVE_ROOTS")
env_roots: dict[str, str] | None = None
if env_roots_raw:
try:
parsed = json.loads(env_roots_raw)
if isinstance(parsed, dict):
env_roots = parsed
except Exception:
logger.exception("Failed to parse MEDIAHIVE_ROOTS")
# 1. CLI roots (teleported via fastapi-vue's env config) take precedence
# 2. Persisted config roots (used only when CLI roots are not provided)
cfg = load_config()
if env_roots is not None:
desired.update(env_roots)
elif cfg.roots:
desired.update(cfg.roots)
if config.roots:
desired.update(config.roots)
else:
cfg = load_config()
if cfg.roots:
desired.update(cfg.roots)
if not desired:
logger.info("No roots configured; waiting for PUT /api/config/roots")
@@ -1056,7 +1046,7 @@ async def lifespan(_app: FastAPI):
await close_image_client()
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE)
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=env.dev)
# Allow CORS for development
app.add_middleware(
+18 -10
View File
@@ -24,15 +24,20 @@ import urllib.request
from concurrent.futures import Future, ThreadPoolExecutor
from pathlib import Path
# Must be set before fastapi_vue env bindings are created (mediahive.config);
# this module is the PyInstaller entry point and may run without __main__.
os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE")
import msgspec.structs
import uvicorn
import velopack
import webview
from fastapi_vue import env
from fastapi_vue.logging import patch_log_config
from fastapi_vue.startupbox import print_box
from tracerite.html import html_traceback
from mediahive.config import load_config, log_dir, save_config
from mediahive.config import config, load_config, log_dir, save_config
from mediahive.volume_control import get_volume, set_volume, volume_max
logger = logging.getLogger("mediahive.winmain")
@@ -1202,10 +1207,6 @@ def winmain() -> None:
initial_roots[name] = p.as_posix()
elif cfg.roots:
initial_roots = cfg.roots
elif cfg.media_folder:
p = _normalize_media_root_input(cfg.media_folder)
name = p.name or "media"
initial_roots[name] = p.as_posix()
if not initial_roots:
folder = _run_initial_setup()
@@ -1219,8 +1220,9 @@ def winmain() -> None:
if cfg.roots != initial_roots:
save_config(msgspec.structs.replace(cfg, roots=initial_roots))
# Pass roots to the server via env (validation deferred to server startup)
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(initial_roots)
# Pass roots to the in-process server via the shared env config
# (validation deferred to server startup)
config.roots = initial_roots
backend_port = _reserve_backend_port()
backend_url = f"http://{BACKEND_HOST}:{backend_port}"
@@ -1238,7 +1240,13 @@ def winmain() -> None:
# log config wires up its access-log middleware, emoji level prefixes and
# tracerite tracebacks (colors are auto-disabled when stderr is not a tty,
# e.g. redirected to the log file in frozen builds).
config = uvicorn.Config(
log_config = patch_log_config(uvicorn.config.LOGGING_CONFIG)
# fastapi-vue routes the root logger at INFO in dev / WARNING in prod;
# keep our own loggers visible in production too.
log_config.setdefault("loggers", {})["mediahive"] = {
"level": "DEBUG" if env.dev else "INFO"
}
uvicorn_config = uvicorn.Config(
"mediahive.server:app",
host=BACKEND_HOST,
port=backend_port,
@@ -1246,9 +1254,9 @@ def winmain() -> None:
server_header=False,
timeout_graceful_shutdown=0,
access_log=False, # fastapi-vue's middleware replaces uvicorn's
log_config=patch_log_config(uvicorn.config.LOGGING_CONFIG),
log_config=log_config,
)
server = uvicorn.Server(config)
server = uvicorn.Server(uvicorn_config)
backend_thread = threading.Thread(
target=server.run, daemon=True, name="mediahive-backend"
)
+40 -39
View File
@@ -8,7 +8,7 @@ dependencies = [
"aiofiles>=25.1.0",
"aiopathlib>=0.6.0",
"bencodepy>=0.9.5",
"fastapi-vue>=1.4.1",
"fastapi-vue~=1.7.2",
"fastapi[standard]>=0.128.0",
"httpx[http2]>=0.28.1",
"msgspec>=0.19",
@@ -63,6 +63,7 @@ gui = [
[dependency-groups]
dev = [
"httpx>=0.28.1",
"lefthook>=2.1.14",
"ruff>=0.15.14",
"setuptools-scm>=8",
]
@@ -73,52 +74,52 @@ preview = true
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"D203",
"D213",
"DOC201",
"COM812",
"T201",
"incorrect-blank-line-before-class",
"multi-line-summary-second-line",
"docstring-missing-returns",
"missing-trailing-comma",
"print",
"EM",
"TC",
"TRY003",
"raise-vanilla-args",
"S",
"CPY001",
"missing-copyright-notice",
"PLR",
"PLW",
# TEMP suppressions - revisit and remove after focused cleanup passes.
"C901",
"DOC501",
"ANN201",
"D103",
"FBT001",
"ANN001",
"D102",
"TRY300",
"D107",
"ANN202",
"B904",
"ASYNC220",
"E501",
"INP001",
"FBT002",
"PLC0415",
"D101",
"RUF006",
"SLF001",
"ASYNC240",
"N801",
"SIM102",
"DTZ005",
"RUF034",
"D415",
"D400",
"ANN401",
"complex-structure",
"docstring-missing-exception",
"missing-return-type-undocumented-public-function",
"undocumented-public-function",
"boolean-type-hint-positional-argument",
"missing-type-function-argument",
"undocumented-public-method",
"try-consider-else",
"undocumented-public-init",
"missing-return-type-private-function",
"raise-without-from-inside-except",
"create-subprocess-in-async-function",
"line-too-long",
"implicit-namespace-package",
"boolean-default-value-positional-argument",
"import-outside-top-level",
"undocumented-public-class",
"asyncio-dangling-task",
"private-member-access",
"blocking-path-method-in-async-function",
"invalid-class-name",
"collapsible-if",
"call-datetime-now-without-tzinfo",
"useless-if-else",
"missing-terminal-punctuation",
"missing-trailing-period",
"any-type",
# Allow en-dash in docstrings (used for list formatting)
"RUF002",
"ambiguous-unicode-character-docstring",
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
"N806",
"non-lowercase-variable-in-function",
# Allow inline comments that describe output formats
"ERA001",
"commented-out-code",
# Allow unused local variables in ctypes COM boilerplate
"F841",
"unused-variable",
]
+9 -5
View File
@@ -5,8 +5,8 @@
import argparse
import asyncio
import os
import subprocess
import sys
from contextlib import suppress
from pathlib import Path
import tracerite
@@ -48,11 +48,11 @@ async def run_devserver(
os.environ["MEDIAHIVE_DEV"] = "1"
async with ProcessGroup() as pg:
pg.create_task(check_ports_free(viteurl, backurl))
npm_i = await pg.spawn(*npm_install, cwd=front)
await check_ports_free(viteurl, backurl)
await pg.spawn(*mediahive, *(extra_args or []))
await pg.spawn(*mediahive, *(extra_args or []), vital=True)
await pg.wait(npm_i, ready(backurl, path=HEALTH))
await pg.spawn(*vite, cwd=front)
await pg.spawn(*vite, cwd=front, vital=True)
def main() -> None:
@@ -75,8 +75,12 @@ def main() -> None:
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
)
args, extra_args = parser.parse_known_args()
with suppress(KeyboardInterrupt):
try:
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
except* KeyboardInterrupt:
pass # user stopped the devserver: normal exit
except* subprocess.SubprocessError, RuntimeError:
raise SystemExit(1) from None # logged in devutil already; exit 1
HELP_EPILOG = """
+11 -4
View File
@@ -10,20 +10,27 @@ from pathlib import Path
MIN_NODE_VERSION = 20
class _PrefixFormatter(logging.Formatter):
"""Formatter that adds prefix based on log level."""
class _Formatter(logging.Formatter):
"""Prefix formatter, intentionally different from fastapi_vue.logging.
INFO and below pass through unprefixed so messages can use their own
markings (>>>, ###); WARNING and above get an emoji prefix.
"""
def format(self, record: logging.LogRecord) -> str:
if record.levelno >= logging.ERROR:
return f"🛑 {record.getMessage()}"
if record.levelno >= logging.WARNING:
return f"⚠️ {record.getMessage()}"
return f"💣 {record.getMessage()}"
return record.getMessage()
_handler = logging.StreamHandler()
_handler.setFormatter(_PrefixFormatter())
_handler.setFormatter(_Formatter())
logger = logging.getLogger("fastapi-vue")
logger.addHandler(_handler)
logger.setLevel(logging.INFO)
logger.propagate = False # own handler; do not double-print via a configured root
def _check_node_version(node_path: str) -> None:
+76 -96
View File
@@ -1,110 +1,89 @@
"""Utilities meant for devserver script, used only in source repository with dev deps."""
from __future__ import annotations
import asyncio
import subprocess
import sys
from asyncio.subprocess import Process
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any, Self
from subprocess import CalledProcessError
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit
from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint
if TYPE_CHECKING:
from collections.abc import Coroutine
from collections.abc import Awaitable
class ProcessGroup:
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
class ProcessGroup(asyncio.TaskGroup):
"""TaskGroup with structured ownership of async subprocesses."""
def __init__(self) -> None:
"""Initialize empty process tracking."""
self._procs: list[asyncio.subprocess.Process] = []
self._cmds: dict[int, str] = {} # pid -> command name
def __init__(self, *, terminate_timeout: float = 10) -> None:
"""Set the grace period before terminate() escalates to kill()."""
super().__init__()
self._terminate_timeout = terminate_timeout
self._cmds: dict[Process, tuple[str, ...]] = {}
async def spawn(
self,
*cmd: str,
cwd: str | None = None,
) -> asyncio.subprocess.Process:
"""Spawn a subprocess and track it."""
cmd_name = Path(cmd[0]).stem
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._procs.append(proc)
self._cmds[proc.pid] = cmd_name
return proc
self, *cmd: str, cwd: str | None = None, vital: bool = False
) -> Process:
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
async def wait(
self,
*waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any],
) -> None:
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
async def run() -> None:
name = Path(cmd[0]).stem
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
try:
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._cmds[proc] = cmd
started.set_result(proc)
except Exception as e: # ruff: ignore[blind-except]
started.set_exception(e)
return
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
returncode = await proc.wait()
if returncode != 0:
cmd_name = self._cmds.get(proc.pid, "unknown")
raise subprocess.CalledProcessError(returncode, cmd_name)
tasks = [
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
for w in waitables
]
try:
await asyncio.gather(*tasks)
except subprocess.CalledProcessError as e:
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
raise SystemExit(1) from None
async def __aenter__(self) -> Self:
"""Enter the async context manager."""
return self
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
"""Wait for one process to exit, terminate others, then wait for all."""
await self._cleanup(immediate=exc_type is not None)
async def _cleanup(self, *, immediate: bool = False) -> None:
running = [p for p in self._procs if p.returncode is None]
if not running:
return
if not immediate:
# Wait for any one process to exit
with suppress(asyncio.CancelledError):
await asyncio.wait(
[asyncio.create_task(p.wait()) for p in running],
return_when=asyncio.FIRST_COMPLETED,
)
# Terminate remaining processes
for p in self._procs:
if p.returncode is None:
try:
returncode = await proc.wait()
finally:
with suppress(ProcessLookupError):
p.terminate()
# Wait for all to finish (with overall timeout), shielded from cancellation
still_running = [p for p in self._procs if p.returncode is None]
if still_running:
with suppress(asyncio.CancelledError):
proc.terminate()
try:
await asyncio.shield(
asyncio.wait_for(
asyncio.gather(*[p.wait() for p in still_running]),
timeout=10,
),
)
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
except TimeoutError:
for p in self._procs:
if p.returncode is None:
with suppress(ProcessLookupError):
p.kill()
await p.wait()
with suppress(ProcessLookupError):
proc.kill()
await proc.wait()
if vital:
logger.warning("Vital process %s exited", name)
raise CalledProcessError(returncode, cmd)
started = asyncio.get_running_loop().create_future()
self.create_task(run())
return await asyncio.shield(started)
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
"""Wait concurrently and return results in argument order."""
async def task(w: Process | Awaitable) -> Any:
if not isinstance(w, Process):
return await w
if retcode := await w.wait():
cmd = self._cmds[w]
logger.warning(
"Process %s exited with status %d", Path(cmd[0]).stem, retcode
)
raise CalledProcessError(retcode, cmd)
return retcode
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(task(w)) for w in waitables]
return tuple(task.result() for task in tasks)
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
async def http_get_server(url: str, timeout: float) -> str | None: # ruff: ignore[async-function-with-timeout]
"""GET url with plain asyncio streams, return the response Server header.
Returns an empty string when the server responds without a Server header,
@@ -127,42 +106,43 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN
writer.close()
except OSError, EOFError, ValueError, TimeoutError:
return None
for line in data.decode("latin-1").split("\r\n"):
for line in data.decode(errors="replace").split("\r\n"):
if line.lower().startswith("server:"):
return line.split(":", 1)[1].strip()
return line[7:].strip()
return ""
async def check_ports_free(*urls: str) -> None:
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
"""Verify URLs are not responding (ports are free).
async def check(url: str) -> None:
server = await http_get_server(url, timeout=0.1)
Meant to run as a task inside a TaskGroup. Logs the conflict and raises
RuntimeError (handled like a failed process) if any URL responds.
"""
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
for url, server in zip(urls, servers, strict=True):
if server is not None:
logger.warning(
logger.error(
"Conflicting %s already running at %s", server or "server", url
)
raise SystemExit(1)
await asyncio.gather(*[check(url) for url in urls])
raise RuntimeError(url)
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
"""Wait for the server to be ready by polling an endpoint.
Use empty path to disable the check and make this return immediately.
Raises SystemExit(1) if server doesn't start in time.
Logs, then raises RuntimeError if the server doesn't start in time.
"""
if not path:
return
for attempt in range(max_attempts):
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
logger.info(" Backend ready!")
logger.info("🟢 Backend ready!")
return
if attempt == max_attempts - 1:
logger.warning("Backend didn't start in time")
raise SystemExit(1)
logger.error("Backend at %s didn't start in time", url)
raise RuntimeError(url)
await asyncio.sleep(0.1)
+46 -13
View File
@@ -82,15 +82,42 @@ class _Platform(NamedTuple):
def _platform() -> _Platform:
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":
return _Platform("macos", "osx", "osx-arm64", "MediaHive.app", "mediahive.icns", "MediaHive", ".pkg")
return _Platform("linux", "linux", "linux-x64", "MediaHive", "mediahive.png", "MediaHive", ".AppImage")
return _Platform(
"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(version: str) -> str:
def setup_artifact_name() -> str:
"""Versionless name so releases/download/latest/<name> links stay valid."""
p = _platform()
return f"MediaHive-{version}-{p.tag}-setup{p.setup_ext}"
# Windows keeps the -setup suffix: a bare .exe isn't self-explanatory.
suffix = "-setup" if sys.platform == "win32" else ""
return f"MediaHive-{p.tag}{suffix}{p.setup_ext}"
def fetch_ffmpeg() -> Path:
@@ -250,7 +277,7 @@ def _dotnet_runtime_major(exe: Path) -> int | None:
result = subprocess.run(
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
)
except (OSError, subprocess.TimeoutExpired):
except OSError, subprocess.TimeoutExpired:
return None
if result.returncode != 0:
return None
@@ -381,7 +408,7 @@ def build_velopack(version: str) -> Path:
raise RuntimeError(f"vpk produced no *{plat.setup_ext} in {releases_dir}")
if sys.platform == "darwin":
force_macos_user_install(setup)
artifact = _REPO_ROOT / "build" / setup_artifact_name(version)
artifact = _REPO_ROOT / "build" / setup_artifact_name()
artifact.unlink(missing_ok=True)
setup.rename(artifact)
rename_feed_package(releases_dir, version, plat.channel)
@@ -452,20 +479,26 @@ def force_macos_user_install(pkg: Path) -> None:
components = list(expanded.glob("*.pkg"))
if len(components) != 1:
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]
if component.is_dir():
comp_dir = component
else:
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"
script = postinstall.read_text()
if 'sudo -u "$USER" ' not in script:
raise RuntimeError("Unexpected postinstall script: sudo prefix not found")
postinstall.write_text(script.replace('sudo -u "$USER" ', ""))
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)
subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True)
@@ -509,7 +542,7 @@ def build_executable() -> None:
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
def create_portable_zip(version: str) -> Path:
def create_portable_zip() -> Path:
"""Create the Windows portable ZIP of the build/MediaHive folder.
Velopack-less plain-folder distribution for users who cannot or do not
@@ -520,7 +553,7 @@ def create_portable_zip(version: str) -> Path:
if not dist_folder.exists():
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
zip_path = _REPO_ROOT / "build" / f"MediaHive-{version}-win64-portable.zip"
zip_path = _REPO_ROOT / "build" / "MediaHive-win64-portable.zip"
print(f"Creating {zip_path}...")
shutil.make_archive(
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
@@ -553,7 +586,7 @@ def main() -> None:
artifacts = [build_velopack(version)]
if sys.platform == "win32":
artifacts.append(create_portable_zip(version))
artifacts.append(create_portable_zip())
for artifact_path in artifacts:
print(f"✓ Built successfully: {artifact_path}")
+41 -37
View File
@@ -9,8 +9,9 @@ Reads from [project.urls] Repository in pyproject.toml.
Token: GITEA_TOKEN environment variable
Steps:
1. Find clean-versioned platform artifacts in build/ and matching dist/ wheels/sdists
2. Abort if any dist files are missing for a found artifact version
1. Read the clean tag version via setuptools_scm, find platform artifacts
in build/ and matching dist/ wheels/sdists
2. Abort if any dist files are missing
3. Create a Gitea release for each version (or reuse the existing one
for the tag, skipping already-uploaded assets) and upload all assets
4. Remind the user to run: uv publish
@@ -28,6 +29,7 @@ from pathlib import Path
from urllib.parse import urlparse
import httpx
import setuptools_scm
REPO_ROOT = Path(__file__).parent.parent
@@ -67,23 +69,31 @@ def load_token() -> str:
# ZIP + dist helpers
# ---------------------------------------------------------------------------
# Matches MediaHive-1.2.3-win64-portable.zip, MediaHive-1.2.3-win64-setup.exe,
# MediaHive-1.2.3-macos-setup.pkg, MediaHive-1.2.3-linux-setup.AppImage, etc.
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64-portable.zip
_CLEAN_ARTIFACT_RE = re.compile(
r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.(?:zip|dmg|exe|pkg|AppImage)$"
# Installer artifacts are versionless (MediaHive-win64-setup.exe,
# MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage,
# MediaHive-win64-portable.zip) so /releases/download/latest/<name> links
# 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)$"
)
def find_releasable_artifacts() -> list[tuple[Path, str, str]]:
"""Return (path, version, platform_tag) for clean-versioned artifacts in build/."""
def read_version() -> str:
"""Read version via setuptools_scm, refusing dev/dirty versions."""
version = setuptools_scm.get_version(root=str(REPO_ROOT))
if not re.fullmatch(r"\d+(?:\.\d+)*", version):
raise RuntimeError(
f"Refusing to release non-clean version {version!r}. Tag a release first."
)
return version
def find_releasable_artifacts() -> list[Path]:
"""Return platform artifact paths in build/."""
build_dir = REPO_ROOT / "build"
results = []
for p in sorted(build_dir.glob("MediaHive-*")):
m = _CLEAN_ARTIFACT_RE.match(p.name)
if m:
results.append((p, m.group(1), m.group(2)))
return results
return [
p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)
]
def find_dist_files(version: str) -> list[Path]:
@@ -244,46 +254,40 @@ def main() -> None:
try:
cfg = load_gitea_config()
token = load_token()
version = read_version()
artifacts = find_releasable_artifacts()
if not artifacts:
print(
"No clean-versioned platform artifacts found in build/.\n"
"No platform artifacts found in build/.\n"
"Run scripts/guibuild.py first.",
file=sys.stderr,
)
sys.exit(1)
# Validate all dist files exist before touching Gitea
dist_files: dict[str, list[Path]] = {}
if not args.no_dist:
for _, version, _platform_tag in artifacts:
dist_files[version] = find_dist_files(version)
dist_files: list[Path] = [] if args.no_dist else find_dist_files(version)
base_url = cfg["url"].rstrip("/")
repo = cfg["repo"]
with httpx.Client(headers=gitea_headers(token)) as client:
releases: dict[str, tuple[int, set[str]]] = {}
for artifact_path, version, platform_tag in artifacts:
print(f"\nReleasing {version} ...")
tag = f"v{version}"
if version not in releases:
releases[version] = create_release(
client, base_url, repo, tag, version, args.notes, args.draft
)
release_id, uploaded = releases[version]
for path in dist_files.get(version, []):
if path.name in uploaded:
print(f"Skipping {path.name}, already on the release.")
continue
upload_asset(client, base_url, repo, release_id, path)
print(f"\nReleasing {version} ...")
tag = f"v{version}"
release_id, uploaded = create_release(
client, base_url, repo, tag, version, args.notes, args.draft
)
for path in dist_files:
if path.name in uploaded:
print(f"Skipping {path.name}, already on the release.")
continue
upload_asset(client, base_url, repo, release_id, path)
release_id, uploaded = releases[version]
for artifact_path in artifacts:
if artifact_path.name in uploaded:
print(f"Skipping {artifact_path.name}, already on the release.")
continue
print(f"Uploading platform artifact: {platform_tag}")
print(f"Uploading platform artifact: {artifact_path.name}")
upload_asset(client, base_url, repo, release_id, artifact_path)
uploaded.add(artifact_path.name)
for feed_file in find_velopack_feed_files():
@@ -292,7 +296,7 @@ def main() -> None:
continue
upload_asset(client, base_url, repo, release_id, feed_file)
uploaded.add(feed_file.name)
print(f"{tag} published")
print(f"{tag} published")
print("\nDone. To publish to PyPI, run:")
print(" uv publish")