Compare commits
38
Commits
v0.6.1
..
603884c5a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603884c5a2 | ||
|
|
2c4baf693c | ||
|
|
0a0c812efd | ||
|
|
508dae02b3 | ||
|
|
f27bb4e7f3 | ||
|
|
5b40e306d1 | ||
|
|
aea73ccd36 | ||
|
|
9e0ccb13bd | ||
|
|
aeb6958aab | ||
|
|
3711d3dd3c | ||
|
|
039824f236 | ||
|
|
f078e9bec0 | ||
|
|
ff493506ba | ||
|
|
5f3ba47098 | ||
|
|
01c353502c | ||
|
|
65fdae1546 | ||
|
|
832da6c315 | ||
|
|
3142bc2cb2 | ||
|
|
e514829737 | ||
|
|
50420984f1 | ||
|
|
f9002b19a2 | ||
|
|
37f786f5ed | ||
|
|
c64f54fe0f | ||
|
|
bb88ab7d98 | ||
|
|
303aabc181 | ||
|
|
e8d2c5773a | ||
|
|
59d2f157eb | ||
|
|
3377702e98 | ||
|
|
6dee65b4c6 | ||
|
|
c59040ce52 | ||
|
|
734b7993a2 | ||
|
|
b01e8a5c3d | ||
|
|
b231f450bf | ||
|
|
67489e05bc | ||
|
|
3ae4ce9f9f | ||
|
|
99e757bc6f | ||
|
|
5300fd0c9c | ||
|
|
ff10c86e84 |
@@ -1,3 +0,0 @@
|
||||
# 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
|
||||
@@ -43,15 +43,6 @@ 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 }}
|
||||
|
||||
@@ -20,5 +20,3 @@ package-lock.json
|
||||
.*
|
||||
!.gitignore
|
||||
!.gitea/
|
||||
!.git-blame-ignore-revs
|
||||
!.pre-commit-config.yaml
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||

|
||||

|
||||
|
||||
# MediaHive
|
||||
|
||||
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
|
||||
|
||||
## Downloads
|
||||
**[Windows, Mac and Linux downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
|
||||
|
||||
- **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)
|
||||
## Getting Started
|
||||
|
||||
You may also run or install with [UV](https://docs.astral.sh/uv/getting-started/installation/):
|
||||
|
||||
```
|
||||
uvx --from mediahive[gui] mediahive
|
||||
```
|
||||
- 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`.
|
||||
|
||||
## What It Does
|
||||
|
||||
@@ -24,7 +20,7 @@ uvx --from mediahive[gui] mediahive
|
||||
- Remembers per-episode playback positions and offers series continue points
|
||||
- Hand off playback to your preferred system player
|
||||
|
||||
On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
|
||||
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.
|
||||
|
||||
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
@@ -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 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.
|
||||
- Roots may also be provided at startup via the `MEDIAHIVE_ROOTS` environment variable (JSON dict of name → path), which overrides 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
|
||||
|
||||
+23
-45
@@ -11,19 +11,12 @@
|
||||
<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
|
||||
@@ -222,10 +215,7 @@ 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"
|
||||
@@ -390,8 +380,7 @@ 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"
|
||||
@@ -483,11 +472,7 @@ 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) {
|
||||
@@ -495,9 +480,7 @@ 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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -595,7 +578,9 @@ 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
|
||||
}
|
||||
@@ -690,9 +675,7 @@ 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
|
||||
@@ -788,10 +771,7 @@ 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
|
||||
@@ -852,7 +832,9 @@ 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
|
||||
|
||||
@@ -1123,18 +1105,14 @@ 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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1155,7 +1133,9 @@ 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 })
|
||||
}
|
||||
|
||||
@@ -1194,9 +1174,7 @@ 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
|
||||
|
||||
@@ -594,7 +594,13 @@ 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,7 +65,11 @@ 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) {
|
||||
@@ -126,7 +130,9 @@ 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 },
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<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"
|
||||
@@ -32,9 +26,7 @@
|
||||
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 -->
|
||||
@@ -182,16 +174,11 @@ 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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +247,10 @@ 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") {
|
||||
@@ -520,18 +510,10 @@ 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);
|
||||
}
|
||||
.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.0); }
|
||||
.hex-keyboard-row-3 { margin-left: calc(var(--key-w) * 1.5); }
|
||||
|
||||
.hex-key {
|
||||
position: relative;
|
||||
@@ -547,9 +529,7 @@ 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);
|
||||
}
|
||||
@@ -558,8 +538,7 @@ 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 {
|
||||
@@ -574,12 +553,8 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
@keyframes hex-label-fade {
|
||||
0% {
|
||||
color: #22c55e;
|
||||
}
|
||||
100% {
|
||||
color: #ffffff;
|
||||
}
|
||||
0% { color: #22c55e; }
|
||||
100% { color: #ffffff; }
|
||||
}
|
||||
|
||||
/* Special key backgrounds override row gradients */
|
||||
@@ -625,13 +600,8 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
@keyframes hex-outline-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
|
||||
@@ -80,9 +80,7 @@
|
||||
</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}`"
|
||||
@@ -114,7 +112,13 @@ 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
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<!-- 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"
|
||||
@@ -186,11 +185,7 @@
|
||||
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"
|
||||
@@ -239,15 +234,7 @@
|
||||
|
||||
<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,
|
||||
@@ -328,9 +315,7 @@ 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)
|
||||
@@ -357,7 +342,11 @@ 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)
|
||||
@@ -766,8 +755,23 @@ const castNavRow = computed(() => {
|
||||
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
|
||||
})
|
||||
|
||||
const collectionMovies = computed(
|
||||
(): Array<{
|
||||
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<{
|
||||
title: string
|
||||
localId: string
|
||||
coverPath: string | null
|
||||
@@ -775,77 +779,60 @@ const collectionMovies = computed(
|
||||
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()
|
||||
let hasCurrentInMatches = false
|
||||
|
||||
const matches: Array<{
|
||||
title: string
|
||||
localId: string
|
||||
coverPath: string | null
|
||||
rootId: string | null
|
||||
year: string | null
|
||||
hyphenLang: string | null
|
||||
isCurrent: boolean
|
||||
}> = []
|
||||
for (const libraryMovie of props.allMovies || []) {
|
||||
const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
|
||||
if (otherCollectionName !== normalizedCollectionName) continue
|
||||
|
||||
let hasCurrentInMatches = false
|
||||
const title = libraryMovie.title || libraryMovie.info?.title
|
||||
if (!title) continue
|
||||
|
||||
for (const libraryMovie of props.allMovies || []) {
|
||||
const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
|
||||
if (otherCollectionName !== normalizedCollectionName) continue
|
||||
const isCurrent = libraryMovie.id === props.item.id
|
||||
if (isCurrent) hasCurrentInMatches = true
|
||||
|
||||
const title = libraryMovie.title || libraryMovie.info?.title
|
||||
if (!title) 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 isCurrent = libraryMovie.id === props.item.id
|
||||
if (isCurrent) hasCurrentInMatches = true
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
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 (!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)
|
||||
},
|
||||
)
|
||||
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,12 +336,7 @@ 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)
|
||||
|
||||
@@ -96,9 +96,7 @@
|
||||
formatDate(selectedSeason.air_date)
|
||||
}}</span>
|
||||
<span
|
||||
>{{
|
||||
selectedSeason.episode_count ?? selectedSeason.episodes.length
|
||||
}}
|
||||
>{{ selectedSeason.episode_count ?? selectedSeason.episodes.length }}
|
||||
Episodes</span
|
||||
>
|
||||
</div>
|
||||
@@ -114,7 +112,7 @@
|
||||
<div class="episodes-grid">
|
||||
<div
|
||||
v-for="(episode, eIndex) in selectedSeason?.episodes || []"
|
||||
:key="`${seriesIdentity}-${selectedSeasonIndex}-${episode.episode_number}`"
|
||||
:key="`${selectedSeasonIndex}-${episode.episode_number}`"
|
||||
class="episode-tile"
|
||||
:class="{ 'episode-tile--ahead': isEpisodeAhead(eIndex) }"
|
||||
v-bind="getEpisodeNavAttrs(selectedSeasonIndex, eIndex)"
|
||||
@@ -163,7 +161,9 @@
|
||||
<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 & { id?: string; root_id?: string | null }
|
||||
series: Series & { root_id?: string | null }
|
||||
allMovies: MovieUi[]
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
|
||||
resumePoint?: SeriesResumePoint | null
|
||||
@@ -297,15 +297,6 @@ 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
|
||||
@@ -367,7 +358,9 @@ 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,
|
||||
@@ -555,9 +548,6 @@ 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" } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,7 +638,10 @@ 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 =
|
||||
@@ -1413,17 +1406,6 @@ 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], () => {
|
||||
@@ -1951,12 +1933,7 @@ 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,9 +110,7 @@ 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 {
|
||||
|
||||
@@ -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,7 +28,6 @@ let mouseIdleTimer: number | null = null
|
||||
let pointerVisible = false
|
||||
let mouseTravelPx = 0
|
||||
let lastMouseMoveAt = 0
|
||||
let lastAnyMouseMoveAt = 0
|
||||
|
||||
function clearMouseIdleTimer() {
|
||||
if (mouseIdleTimer !== null) {
|
||||
@@ -96,7 +95,6 @@ function registerMouseIntentTravel(event: MouseEvent): boolean {
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
reportUserActivity()
|
||||
showPointerFromMotion()
|
||||
lastAnyMouseMoveAt = performance.now()
|
||||
|
||||
if (modality === "mouse") {
|
||||
applyInputState(true)
|
||||
@@ -113,13 +111,6 @@ 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,10 +36,6 @@ 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"
|
||||
@@ -103,10 +99,7 @@ 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))
|
||||
@@ -178,7 +171,10 @@ 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)
|
||||
}
|
||||
|
||||
@@ -280,8 +276,14 @@ 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.
|
||||
@@ -348,9 +350,7 @@ 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,7 +462,9 @@ 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)
|
||||
@@ -617,7 +619,10 @@ 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()
|
||||
@@ -667,21 +672,8 @@ function findNextElement(current: HTMLElement, direction: NavDirection): HTMLEle
|
||||
desiredCol.value = currentCol
|
||||
}
|
||||
|
||||
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 targetRowElements = byRow.get(targetRow) ?? []
|
||||
const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR))
|
||||
if (hasEntryOverride) {
|
||||
return entryTarget?.element || null
|
||||
|
||||
@@ -159,7 +159,10 @@ 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: "",
|
||||
@@ -343,10 +346,7 @@ 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,14 +436,7 @@ 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()
|
||||
@@ -509,10 +502,7 @@ 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,13 +59,7 @@ 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())
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
// 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
|
||||
@@ -59,7 +65,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, " ")
|
||||
}
|
||||
@@ -271,9 +277,7 @@ 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
|
||||
@@ -288,7 +292,9 @@ 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 []
|
||||
|
||||
|
||||
@@ -566,7 +566,9 @@ 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)
|
||||
@@ -576,10 +578,9 @@ 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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
+7
-17
@@ -1,20 +1,16 @@
|
||||
"""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 env, server
|
||||
|
||||
from mediahive.config import config
|
||||
from fastapi_vue import server
|
||||
|
||||
DEFAULT_PORT = 8420
|
||||
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
|
||||
|
||||
|
||||
def _configure_windows_event_loop_policy() -> None:
|
||||
@@ -144,11 +140,10 @@ def main() -> None:
|
||||
name = f"{base_name}{suffix}"
|
||||
suffix += 1
|
||||
roots[name] = p.as_posix()
|
||||
# Teleported to the server process by fastapi-vue's server.run().
|
||||
config.roots = roots
|
||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots)
|
||||
|
||||
if (
|
||||
env.dev
|
||||
DEVMODE
|
||||
and sys.platform == "win32"
|
||||
and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1"
|
||||
):
|
||||
@@ -161,12 +156,7 @@ def main() -> None:
|
||||
default_port=DEFAULT_PORT,
|
||||
server_header=False,
|
||||
loop="none" if sys.platform == "win32" else "auto",
|
||||
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"}}
|
||||
},
|
||||
reload=Path(__file__).parent if DEVMODE and sys.platform != "win32" else False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+16
-8
@@ -13,20 +13,14 @@ 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
|
||||
@@ -43,11 +37,25 @@ 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:
|
||||
return msgspec.toml.decode(path.read_bytes(), type=Config)
|
||||
cfg = msgspec.toml.decode(path.read_bytes(), type=Config)
|
||||
return _migrate_legacy_media_folder(cfg)
|
||||
except OSError, msgspec.DecodeError, msgspec.ValidationError:
|
||||
return Config()
|
||||
return Config()
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
"""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."""
|
||||
@@ -60,9 +55,11 @@ The server exposes a unified endpoint:
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Defer filesystem validation to the server; pass raw path via env config.
|
||||
# Defer filesystem validation to the server; pass raw path via env.
|
||||
media_root = Path(args.media_folder).expanduser()
|
||||
config.roots = {media_root.name or "media": media_root.as_posix()}
|
||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps({
|
||||
media_root.name or "media": media_root.as_posix()
|
||||
})
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
|
||||
@@ -37,33 +37,12 @@ _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
|
||||
|
||||
+20
-10
@@ -37,9 +37,10 @@ from fastapi.responses import (
|
||||
Response,
|
||||
StreamingResponse,
|
||||
)
|
||||
from fastapi_vue import Frontend, env
|
||||
from fastapi_vue import Frontend
|
||||
|
||||
from mediahive.config import config, load_config, log_dir
|
||||
from mediahive.__main__ import DEVMODE
|
||||
from mediahive.config import 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
|
||||
@@ -973,14 +974,23 @@ async def _activate_all_roots() -> None:
|
||||
"""
|
||||
desired: dict[str, str] = {}
|
||||
|
||||
# 1. CLI roots (teleported via fastapi-vue's env config) take precedence
|
||||
# 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")
|
||||
|
||||
# 2. Persisted config roots (used only when CLI roots are not provided)
|
||||
if config.roots:
|
||||
desired.update(config.roots)
|
||||
else:
|
||||
cfg = load_config()
|
||||
if cfg.roots:
|
||||
desired.update(cfg.roots)
|
||||
cfg = load_config()
|
||||
if env_roots is not None:
|
||||
desired.update(env_roots)
|
||||
elif cfg.roots:
|
||||
desired.update(cfg.roots)
|
||||
|
||||
if not desired:
|
||||
logger.info("No roots configured; waiting for PUT /api/config/roots")
|
||||
@@ -1046,7 +1056,7 @@ async def lifespan(_app: FastAPI):
|
||||
await close_image_client()
|
||||
|
||||
|
||||
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=env.dev)
|
||||
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE)
|
||||
|
||||
# Allow CORS for development
|
||||
app.add_middleware(
|
||||
|
||||
+10
-18
@@ -24,20 +24,15 @@ 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 config, load_config, log_dir, save_config
|
||||
from mediahive.config import load_config, log_dir, save_config
|
||||
from mediahive.volume_control import get_volume, set_volume, volume_max
|
||||
|
||||
logger = logging.getLogger("mediahive.winmain")
|
||||
@@ -1207,6 +1202,10 @@ 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()
|
||||
@@ -1220,9 +1219,8 @@ def winmain() -> None:
|
||||
if cfg.roots != initial_roots:
|
||||
save_config(msgspec.structs.replace(cfg, roots=initial_roots))
|
||||
|
||||
# Pass roots to the in-process server via the shared env config
|
||||
# (validation deferred to server startup)
|
||||
config.roots = initial_roots
|
||||
# Pass roots to the server via env (validation deferred to server startup)
|
||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(initial_roots)
|
||||
|
||||
backend_port = _reserve_backend_port()
|
||||
backend_url = f"http://{BACKEND_HOST}:{backend_port}"
|
||||
@@ -1240,13 +1238,7 @@ 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).
|
||||
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(
|
||||
config = uvicorn.Config(
|
||||
"mediahive.server:app",
|
||||
host=BACKEND_HOST,
|
||||
port=backend_port,
|
||||
@@ -1254,9 +1246,9 @@ def winmain() -> None:
|
||||
server_header=False,
|
||||
timeout_graceful_shutdown=0,
|
||||
access_log=False, # fastapi-vue's middleware replaces uvicorn's
|
||||
log_config=log_config,
|
||||
log_config=patch_log_config(uvicorn.config.LOGGING_CONFIG),
|
||||
)
|
||||
server = uvicorn.Server(uvicorn_config)
|
||||
server = uvicorn.Server(config)
|
||||
backend_thread = threading.Thread(
|
||||
target=server.run, daemon=True, name="mediahive-backend"
|
||||
)
|
||||
|
||||
+39
-40
@@ -8,7 +8,7 @@ dependencies = [
|
||||
"aiofiles>=25.1.0",
|
||||
"aiopathlib>=0.6.0",
|
||||
"bencodepy>=0.9.5",
|
||||
"fastapi-vue~=1.7.2",
|
||||
"fastapi-vue>=1.4.1",
|
||||
"fastapi[standard]>=0.128.0",
|
||||
"httpx[http2]>=0.28.1",
|
||||
"msgspec>=0.19",
|
||||
@@ -63,7 +63,6 @@ gui = [
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"httpx>=0.28.1",
|
||||
"lefthook>=2.1.14",
|
||||
"ruff>=0.15.14",
|
||||
"setuptools-scm>=8",
|
||||
]
|
||||
@@ -74,52 +73,52 @@ preview = true
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"incorrect-blank-line-before-class",
|
||||
"multi-line-summary-second-line",
|
||||
"docstring-missing-returns",
|
||||
"missing-trailing-comma",
|
||||
"print",
|
||||
"D203",
|
||||
"D213",
|
||||
"DOC201",
|
||||
"COM812",
|
||||
"T201",
|
||||
"EM",
|
||||
"TC",
|
||||
"raise-vanilla-args",
|
||||
"TRY003",
|
||||
"S",
|
||||
"missing-copyright-notice",
|
||||
"CPY001",
|
||||
"PLR",
|
||||
"PLW",
|
||||
# TEMP suppressions - revisit and remove after focused cleanup passes.
|
||||
"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",
|
||||
"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",
|
||||
# Allow en-dash in docstrings (used for list formatting)
|
||||
"ambiguous-unicode-character-docstring",
|
||||
"RUF002",
|
||||
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
|
||||
"non-lowercase-variable-in-function",
|
||||
"N806",
|
||||
# Allow inline comments that describe output formats
|
||||
"commented-out-code",
|
||||
"ERA001",
|
||||
# Allow unused local variables in ctypes COM boilerplate
|
||||
"unused-variable",
|
||||
"F841",
|
||||
]
|
||||
|
||||
@@ -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 pg.spawn(*mediahive, *(extra_args or []), vital=True)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
await pg.spawn(*mediahive, *(extra_args or []))
|
||||
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
||||
await pg.spawn(*vite, cwd=front, vital=True)
|
||||
await pg.spawn(*vite, cwd=front)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -75,12 +75,8 @@ def main() -> None:
|
||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||
)
|
||||
args, extra_args = parser.parse_known_args()
|
||||
try:
|
||||
with suppress(KeyboardInterrupt):
|
||||
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 = """
|
||||
|
||||
@@ -10,27 +10,20 @@ from pathlib import Path
|
||||
MIN_NODE_VERSION = 20
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
|
||||
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(_Formatter())
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
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:
|
||||
|
||||
@@ -1,89 +1,110 @@
|
||||
"""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 subprocess import CalledProcessError
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
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 Awaitable
|
||||
from collections.abc import Coroutine
|
||||
|
||||
|
||||
class ProcessGroup(asyncio.TaskGroup):
|
||||
"""TaskGroup with structured ownership of async subprocesses."""
|
||||
class ProcessGroup:
|
||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||
|
||||
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, ...]] = {}
|
||||
def __init__(self) -> None:
|
||||
"""Initialize empty process tracking."""
|
||||
self._procs: list[asyncio.subprocess.Process] = []
|
||||
self._cmds: dict[int, str] = {} # pid -> command name
|
||||
|
||||
async def spawn(
|
||||
self, *cmd: str, cwd: str | None = None, vital: bool = False
|
||||
) -> Process:
|
||||
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
|
||||
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
|
||||
|
||||
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(
|
||||
self,
|
||||
*waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any],
|
||||
) -> None:
|
||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||
|
||||
try:
|
||||
returncode = await proc.wait()
|
||||
finally:
|
||||
with suppress(ProcessLookupError):
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
|
||||
except TimeoutError:
|
||||
with suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
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)
|
||||
|
||||
if vital:
|
||||
logger.warning("Vital process %s exited", name)
|
||||
raise CalledProcessError(returncode, cmd)
|
||||
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
|
||||
|
||||
started = asyncio.get_running_loop().create_future()
|
||||
self.create_task(run())
|
||||
return await asyncio.shield(started)
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Enter the async context manager."""
|
||||
return self
|
||||
|
||||
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
|
||||
"""Wait concurrently and return results in argument order."""
|
||||
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 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
|
||||
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,
|
||||
)
|
||||
raise CalledProcessError(retcode, cmd)
|
||||
return retcode
|
||||
|
||||
async with asyncio.TaskGroup() as group:
|
||||
tasks = [group.create_task(task(w)) for w in waitables]
|
||||
# Terminate remaining processes
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
p.terminate()
|
||||
|
||||
return tuple(task.result() for task in tasks)
|
||||
# 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):
|
||||
try:
|
||||
await asyncio.shield(
|
||||
asyncio.wait_for(
|
||||
asyncio.gather(*[p.wait() for p in still_running]),
|
||||
timeout=10,
|
||||
),
|
||||
)
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
p.kill()
|
||||
await p.wait()
|
||||
|
||||
|
||||
async def http_get_server(url: str, timeout: float) -> str | None: # ruff: ignore[async-function-with-timeout]
|
||||
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
||||
"""GET url with plain asyncio streams, return the response Server header.
|
||||
|
||||
Returns an empty string when the server responds without a Server header,
|
||||
@@ -106,43 +127,42 @@ async def http_get_server(url: str, timeout: float) -> str | None: # ruff: igno
|
||||
writer.close()
|
||||
except OSError, EOFError, ValueError, TimeoutError:
|
||||
return None
|
||||
for line in data.decode(errors="replace").split("\r\n"):
|
||||
for line in data.decode("latin-1").split("\r\n"):
|
||||
if line.lower().startswith("server:"):
|
||||
return line[7:].strip()
|
||||
return line.split(":", 1)[1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
async def check_ports_free(*urls: str) -> None:
|
||||
"""Verify URLs are not responding (ports are free).
|
||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||
|
||||
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):
|
||||
async def check(url: str) -> None:
|
||||
server = await http_get_server(url, timeout=0.1)
|
||||
if server is not None:
|
||||
logger.error(
|
||||
logger.warning(
|
||||
"Conflicting %s already running at %s", server or "server", url
|
||||
)
|
||||
raise RuntimeError(url)
|
||||
raise SystemExit(1)
|
||||
|
||||
await asyncio.gather(*[check(url) for url in urls])
|
||||
|
||||
|
||||
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.
|
||||
Logs, then raises RuntimeError if the server doesn't start in time.
|
||||
Raises SystemExit(1) if 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.error("Backend at %s didn't start in time", url)
|
||||
raise RuntimeError(url)
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
|
||||
+13
-46
@@ -82,42 +82,15 @@ 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() -> str:
|
||||
"""Versionless name so releases/download/latest/<name> links stay valid."""
|
||||
def setup_artifact_name(version: str) -> str:
|
||||
p = _platform()
|
||||
# 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}"
|
||||
return f"MediaHive-{version}-{p.tag}-setup{p.setup_ext}"
|
||||
|
||||
|
||||
def fetch_ffmpeg() -> Path:
|
||||
@@ -277,7 +250,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
|
||||
@@ -408,7 +381,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()
|
||||
artifact = _REPO_ROOT / "build" / setup_artifact_name(version)
|
||||
artifact.unlink(missing_ok=True)
|
||||
setup.rename(artifact)
|
||||
rename_feed_package(releases_dir, version, plat.channel)
|
||||
@@ -479,26 +452,20 @@ 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)
|
||||
@@ -542,7 +509,7 @@ def build_executable() -> None:
|
||||
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
|
||||
|
||||
|
||||
def create_portable_zip() -> Path:
|
||||
def create_portable_zip(version: str) -> Path:
|
||||
"""Create the Windows portable ZIP of the build/MediaHive folder.
|
||||
|
||||
Velopack-less plain-folder distribution for users who cannot or do not
|
||||
@@ -553,7 +520,7 @@ def create_portable_zip() -> Path:
|
||||
if not dist_folder.exists():
|
||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||
|
||||
zip_path = _REPO_ROOT / "build" / "MediaHive-win64-portable.zip"
|
||||
zip_path = _REPO_ROOT / "build" / f"MediaHive-{version}-win64-portable.zip"
|
||||
print(f"Creating {zip_path}...")
|
||||
shutil.make_archive(
|
||||
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
|
||||
@@ -586,7 +553,7 @@ def main() -> None:
|
||||
|
||||
artifacts = [build_velopack(version)]
|
||||
if sys.platform == "win32":
|
||||
artifacts.append(create_portable_zip())
|
||||
artifacts.append(create_portable_zip(version))
|
||||
|
||||
for artifact_path in artifacts:
|
||||
print(f"✓ Built successfully: {artifact_path}")
|
||||
|
||||
+37
-41
@@ -9,9 +9,8 @@ Reads from [project.urls] Repository in pyproject.toml.
|
||||
Token: GITEA_TOKEN environment variable
|
||||
|
||||
Steps:
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -29,7 +28,6 @@ from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import setuptools_scm
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
@@ -69,31 +67,23 @@ def load_token() -> str:
|
||||
# ZIP + dist helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 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)$"
|
||||
# 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)$"
|
||||
)
|
||||
|
||||
|
||||
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/."""
|
||||
def find_releasable_artifacts() -> list[tuple[Path, str, str]]:
|
||||
"""Return (path, version, platform_tag) for clean-versioned artifacts in build/."""
|
||||
build_dir = REPO_ROOT / "build"
|
||||
return [
|
||||
p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)
|
||||
]
|
||||
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
|
||||
|
||||
|
||||
def find_dist_files(version: str) -> list[Path]:
|
||||
@@ -254,40 +244,46 @@ def main() -> None:
|
||||
try:
|
||||
cfg = load_gitea_config()
|
||||
token = load_token()
|
||||
version = read_version()
|
||||
|
||||
artifacts = find_releasable_artifacts()
|
||||
if not artifacts:
|
||||
print(
|
||||
"No platform artifacts found in build/.\n"
|
||||
"No clean-versioned 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: list[Path] = [] if args.no_dist else find_dist_files(version)
|
||||
dist_files: dict[str, list[Path]] = {}
|
||||
if not args.no_dist:
|
||||
for _, version, _platform_tag in artifacts:
|
||||
dist_files[version] = find_dist_files(version)
|
||||
|
||||
base_url = cfg["url"].rstrip("/")
|
||||
repo = cfg["repo"]
|
||||
|
||||
with httpx.Client(headers=gitea_headers(token)) as client:
|
||||
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)
|
||||
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)
|
||||
|
||||
for artifact_path in artifacts:
|
||||
release_id, uploaded = releases[version]
|
||||
if artifact_path.name in uploaded:
|
||||
print(f"Skipping {artifact_path.name}, already on the release.")
|
||||
continue
|
||||
print(f"Uploading platform artifact: {artifact_path.name}")
|
||||
print(f"Uploading platform artifact: {platform_tag}")
|
||||
upload_asset(client, base_url, repo, release_id, artifact_path)
|
||||
uploaded.add(artifact_path.name)
|
||||
for feed_file in find_velopack_feed_files():
|
||||
@@ -296,7 +292,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")
|
||||
|
||||
Reference in New Issue
Block a user