Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1223b8cee7 | ||
|
|
7e99ec2f16 | ||
|
|
38a10725e0 | ||
|
|
8b0c7a7af1 | ||
|
|
d01bf1d88f | ||
|
|
9b12e53039 | ||
|
|
7e2fcc05f6 | ||
|
|
ecd5d3bc9e | ||
|
|
82d5eb28fb | ||
|
|
1149e7cdfd | ||
|
|
38f3ccebd3 | ||
|
|
c41504e70c | ||
|
|
bb70279e61 | ||
|
|
931f00f647 | ||
|
|
9041061e86 | ||
|
|
5fc20c5578 | ||
|
|
550131d43b | ||
|
|
f2fc6f657f | ||
|
|
1477c240a1 | ||
|
|
c891bc84a8 | ||
|
|
c11cbd4250 |
@@ -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
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
# Runner host prerequisites: git, uv, node/npm, .NET SDK.
|
||||||
|
jobs:
|
||||||
|
gui-build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: macos
|
||||||
|
shell: bash
|
||||||
|
- os: windows
|
||||||
|
shell: cmd
|
||||||
|
- os: linux
|
||||||
|
shell: bash
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
# Plain git clone: full history so setuptools_scm sees tags, and both
|
||||||
|
# shells work. Windows uses cmd: bash resolves to WSL (refuses SYSTEM
|
||||||
|
# accounts) and powershell hits the script execution policy under SYSTEM.
|
||||||
|
- name: Checkout
|
||||||
|
shell: ${{ matrix.shell }}
|
||||||
|
run: |
|
||||||
|
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||||
|
git checkout -f "${{ gitea.sha }}"
|
||||||
|
|
||||||
|
# guibuild.py carries its own inline (PEP 723) deps — mediahive[gui]
|
||||||
|
# from the local checkout — so no project sync or --extra is needed.
|
||||||
|
- name: Build GUI app and dist packages
|
||||||
|
shell: ${{ matrix.shell }}
|
||||||
|
run: uv run scripts/guibuild.py
|
||||||
|
|
||||||
|
# Every platform converges on the one release for the tag; release.py
|
||||||
|
# reuses an existing release and skips already-uploaded assets.
|
||||||
|
# Only the linux job publishes the wheel/sdist (identical across platforms).
|
||||||
|
- name: Create Gitea release and upload assets
|
||||||
|
if: matrix.os == 'linux'
|
||||||
|
shell: ${{ matrix.shell }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: uv run scripts/release.py
|
||||||
|
|
||||||
|
- name: Attach platform artifact to the Gitea release
|
||||||
|
if: matrix.os != 'linux'
|
||||||
|
shell: ${{ matrix.shell }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: uv run scripts/release.py --no-dist
|
||||||
|
|
||||||
|
# Publish the Python package to PyPI only once every platform's GUI build
|
||||||
|
# has succeeded. Jobs don't share a workspace, so the platform-independent
|
||||||
|
# wheel/sdist are rebuilt here (same tag version) instead of being passed
|
||||||
|
# around as artifacts.
|
||||||
|
publish-pypi:
|
||||||
|
needs: gui-build
|
||||||
|
runs-on: linux
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||||
|
git checkout -f "${{ gitea.sha }}"
|
||||||
|
|
||||||
|
- name: Build wheel and sdist
|
||||||
|
shell: bash
|
||||||
|
run: uv build
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||||
|
run: uv publish
|
||||||
@@ -19,3 +19,6 @@ package-lock.json
|
|||||||
# Dotfiles
|
# Dotfiles
|
||||||
.*
|
.*
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
!.gitea/
|
||||||
|
!.git-blame-ignore-revs
|
||||||
|
!.pre-commit-config.yaml
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||

|

|
||||||
|
|
||||||
# MediaHive
|
# MediaHive
|
||||||
|
|
||||||
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
|
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
|
||||||
|
|
||||||
**[Windows and Mac portable ZIP 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 and macOS: Download the portable ZIP from the releases page, extract it anywhere, and run `MediaHive`.
|
You may also run or install with [UV](https://docs.astral.sh/uv/getting-started/installation/):
|
||||||
- Linux and other platforms: Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run directly with `uvx --from mediahive[gui] mediahive`.
|
|
||||||
|
```
|
||||||
|
uvx --from mediahive[gui] mediahive
|
||||||
|
```
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
@@ -19,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
|
- Remembers per-episode playback positions and offers series continue points
|
||||||
- Hand off playback to your preferred system player
|
- 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.
|
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.
|
||||||
|
|
||||||
@@ -27,11 +32,10 @@ Note that `.mediahive` folder is created in your media folder to hold all the me
|
|||||||
|
|
||||||
MediaHive is designed to work with a mouse, keyboard, or gamepad.
|
MediaHive is designed to work with a mouse, keyboard, or gamepad.
|
||||||
|
|
||||||
| Input | Controls |
|
**Keyboard:** Arrow keys, Enter and Escape to navigate, `/` to search and the usual ones you already know.
|
||||||
| --- | --- |
|
|
||||||
| Mouse | Click posters, rows, search, play, and folder actions directly. |
|

|
||||||
| Keyboard | Arrow keys move focus, `Enter` activates the focused item, `Escape` goes back, and `/` or `Ctrl`/`Cmd`+`F` jumps to search. |
|
*Navigational controls work across the application. Search bar offers OSD keyboard. In-player controls are currently available only on MPC-BE, with its WebUI enabled.*
|
||||||
| Gamepad | D-pad or left stick moves focus, `A` selects or plays, and `B` goes back. `RB`/`LB` browses adjacent items, and the Search bar has an OSD keyboard. Player controls during playback. |
|
|
||||||
|
|
||||||
## Recommended Players
|
## Recommended Players
|
||||||
|
|
||||||
@@ -41,12 +45,10 @@ MediaHive is designed to work with a mouse, keyboard, or gamepad.
|
|||||||
|
|
||||||
MediaHive opens files with the OS default player, but one specific player may be configured via settings. You are of course free to use any player instead.
|
MediaHive opens files with the OS default player, but one specific player may be configured via settings. You are of course free to use any player instead.
|
||||||
|
|
||||||
- `A` toggles play and pause.
|
|
||||||
- `B` closes the player.
|
|
||||||
- `Y` toggles mute.
|
|
||||||
- D-pad up and down change volume.
|
|
||||||
- D-pad left and right seek during playback, or step frames while paused.
|
|
||||||
|
|
||||||
## Background
|
## Background
|
||||||
|
|
||||||
This project started as a personal project that I have used for browsing my warez for some time now. It is still in early development, but I have just now made it public for a wider audience.
|
This project started as a personal project that I have used for browsing my warez for some time now. After it grew in number of users, I've put serious development effort into it to provide a truly polished view, while responding to user needs.
|
||||||
|
|
||||||
|
Little details include flags for audio and subtitle languages (also srt) and a series view with per episode video previews while avoiding spoilers of the episodes you haven't gotten to yet:
|
||||||
|
|
||||||
|

|
||||||
|
|||||||
+4
-1
@@ -11,6 +11,9 @@ All media paths are scoped to a **root**, identified by a friendly `root_id`
|
|||||||
| `GET` | `/api/health` | Lightweight health check. |
|
| `GET` | `/api/health` | Lightweight health check. |
|
||||||
| `GET` | `/api/config` | Returns the current root configuration. |
|
| `GET` | `/api/config` | Returns the current root configuration. |
|
||||||
| `PUT` | `/api/config/roots` | Atomically replace the full root set. Returns `{ "status": "ok", "accepted": [{path, root_id}], "failed": [...] }`. |
|
| `PUT` | `/api/config/roots` | Atomically replace the full root set. Returns `{ "status": "ok", "accepted": [{path, root_id}], "failed": [...] }`. |
|
||||||
|
| `GET` | `/api/update` | Returns `{ "version", "auto_update", "pending_version" }` — installed version, auto-update preference, and any downloaded update staged for the next launch (Velopack GUI builds only; `null` elsewhere). |
|
||||||
|
| `PUT` | `/api/config/auto-update` | Enable/disable automatic update downloads. Body `{ "enabled": bool }`, persisted in config. |
|
||||||
|
| `POST` | `/api/update/restart` | Applies the staged update and restarts into it. `404` when no update is pending. |
|
||||||
| `POST` | `/api/play/{root_id}` | Opens a media file with a media player. Also starts an assumed-playback session (see notes). |
|
| `POST` | `/api/play/{root_id}` | Opens a media file with a media player. Also starts an assumed-playback session (see notes). |
|
||||||
| `GET` | `/api/players` | Lists detected media players. Returns `{ "players": [{id, name, family, path}] }`, including synthetic `default` and `custom` entries. |
|
| `GET` | `/api/players` | Lists detected media players. Returns `{ "players": [{id, name, family, path}] }`, including synthetic `default` and `custom` entries. |
|
||||||
| `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. |
|
| `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. |
|
||||||
@@ -32,7 +35,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/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/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`.
|
||||||
- `GET /api/player/status` returns `{ "remote": true|false }`.
|
- `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.
|
- 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
|
## WebSocket
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
# Development
|
# Development
|
||||||
|
|
||||||
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) covers end-user startup across platforms (portable ZIPs on Windows/macOS, `uvx --from mediahive[gui] mediahive` on Linux/other).
|
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) covers end-user startup across platforms (installer/AppImage downloads, `uvx --from mediahive[gui] mediahive` on Linux/other).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -44,8 +44,8 @@ This launches the same pywebview-based desktop flow used by the Windows build.
|
|||||||
|
|
||||||
The helper scripts are directly executable via their `uv run` shebang (on Windows, run them with `uv run scripts/<name>.py`):
|
The helper scripts are directly executable via their `uv run` shebang (on Windows, run them with `uv run scripts/<name>.py`):
|
||||||
|
|
||||||
- `./scripts/guibuild.py` builds the PyInstaller desktop app and a versioned portable ZIP under `build/`.
|
- `./scripts/guibuild.py` builds the PyInstaller desktop app and packages it with Velopack under `build/`: per-user `Setup.exe` (Windows), `.pkg` installer (macOS), `.AppImage` (Linux), plus the update feed in `build/velopack/`. On Windows it also creates a `-win64-portable.zip` (no auto-updates). Requires node/npm and the .NET SDK (>= 10 runtime) installed on the build host; `vpk` and ffmpeg are downloaded once into a persistent user cache (`~/.cache/mediahive-build`, `%LOCALAPPDATA%\mediahive-build` on Windows).
|
||||||
- `./scripts/release.py` publishes a release to the Gitea releases page.
|
- `./scripts/release.py` publishes a release to the Gitea releases page, uploading the platform artifacts and the Velopack update feed files — installed apps auto-update from the latest release.
|
||||||
|
|
||||||
Python packaging builds the frontend automatically through the hatch build hook `scripts/fastapi-vue/buildhook.py` (see `pyproject.toml`), so wheels and sdists always ship a fresh `mediahive/frontend-build`.
|
Python packaging builds the frontend automatically through the hatch build hook `scripts/fastapi-vue/buildhook.py` (see `pyproject.toml`), so wheels and sdists always ship a fresh `mediahive/frontend-build`.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
+59
-166
@@ -11,12 +11,19 @@
|
|||||||
<span>Library activity</span>
|
<span>Library activity</span>
|
||||||
<span v-if="!wsConnected" class="activity-connection">Reconnecting...</span>
|
<span v-if="!wsConnected" class="activity-connection">Reconnecting...</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="root in progressRoots" :key="root.rootId" class="activity-root" :class="root.toneClass">
|
<div
|
||||||
|
v-for="root in progressRoots"
|
||||||
|
:key="root.rootId"
|
||||||
|
class="activity-root"
|
||||||
|
:class="root.toneClass"
|
||||||
|
>
|
||||||
<div class="activity-root-title">{{ root.rootLabel }}</div>
|
<div class="activity-root-title">{{ root.rootLabel }}</div>
|
||||||
<div v-if="root.scanTarget" class="activity-root-target">{{ root.scanTarget }}</div>
|
<div v-if="root.scanTarget" class="activity-root-target">{{ root.scanTarget }}</div>
|
||||||
<div class="activity-phase-row">
|
<div class="activity-phase-row">
|
||||||
<span class="activity-phase">{{ root.phaseLabel }}</span>
|
<span class="activity-phase">{{ root.phaseLabel }}</span>
|
||||||
<span v-if="root.progressLabel" class="activity-progress-label">{{ root.progressLabel }}</span>
|
<span v-if="root.progressLabel" class="activity-progress-label">{{
|
||||||
|
root.progressLabel
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }">
|
<div class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }">
|
||||||
<div
|
<div
|
||||||
@@ -38,6 +45,9 @@
|
|||||||
:current-view="headerCurrentView"
|
:current-view="headerCurrentView"
|
||||||
:search-query="searchQuery"
|
:search-query="searchQuery"
|
||||||
:roots="headerRoots"
|
:roots="headerRoots"
|
||||||
|
:scan-tasks="tasks"
|
||||||
|
:scan-connected="wsConnected"
|
||||||
|
:initial-scan-mode="isInitialScanMode"
|
||||||
:mpc-be-connected="mpcBeConnected"
|
:mpc-be-connected="mpcBeConnected"
|
||||||
:nav-row="1"
|
:nav-row="1"
|
||||||
:position="headerPosition"
|
:position="headerPosition"
|
||||||
@@ -202,7 +212,6 @@ import type {
|
|||||||
SeriesUi,
|
SeriesUi,
|
||||||
MediaItem,
|
MediaItem,
|
||||||
EpisodeWithSeries,
|
EpisodeWithSeries,
|
||||||
TaskInfo,
|
|
||||||
SeriesResumePoint,
|
SeriesResumePoint,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import {
|
import {
|
||||||
@@ -215,9 +224,14 @@ import {
|
|||||||
type EpisodeWatchEntry,
|
type EpisodeWatchEntry,
|
||||||
} from "./api"
|
} from "./api"
|
||||||
import { useSettings } from "./composables/useSettings"
|
import { useSettings } from "./composables/useSettings"
|
||||||
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
|
import {
|
||||||
|
useKeyboardNavigation,
|
||||||
|
setActiveNavigationScope,
|
||||||
|
} from "./composables/useKeyboardNavigation"
|
||||||
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
|
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
|
||||||
import { useMediaWebSocket } from "./composables/useMediaWebSocket"
|
import { useMediaWebSocket } from "./composables/useMediaWebSocket"
|
||||||
|
import { computeProgressRoots, type RootTaskInfo } from "./composables/useScanProgress"
|
||||||
|
import { useSettingsOpen } from "./composables/useSettingsOpen"
|
||||||
import Header from "./components/Header.vue"
|
import Header from "./components/Header.vue"
|
||||||
import CollageHero from "./components/CollageHero.vue"
|
import CollageHero from "./components/CollageHero.vue"
|
||||||
import MediaRow from "./components/MediaRow.vue"
|
import MediaRow from "./components/MediaRow.vue"
|
||||||
@@ -282,20 +296,6 @@ const {
|
|||||||
roots: rootStatuses,
|
roots: rootStatuses,
|
||||||
} = useMediaWebSocket()
|
} = useMediaWebSocket()
|
||||||
|
|
||||||
type RootTaskInfo = TaskInfo & { root_id: string }
|
|
||||||
|
|
||||||
interface ProgressRootState {
|
|
||||||
rootId: string
|
|
||||||
rootLabel: string
|
|
||||||
scanTarget: string | null
|
|
||||||
phaseLabel: string
|
|
||||||
phaseDetail: string | null
|
|
||||||
progressPercent: number
|
|
||||||
progressLabel: string | null
|
|
||||||
isDeterminate: boolean
|
|
||||||
toneClass: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeTasks = computed<RootTaskInfo[]>(() => Array.from(tasks.value.values()))
|
const activeTasks = computed<RootTaskInfo[]>(() => Array.from(tasks.value.values()))
|
||||||
|
|
||||||
function getRootName(rootId: string | null | undefined): string | null {
|
function getRootName(rootId: string | null | undefined): string | null {
|
||||||
@@ -303,131 +303,15 @@ function getRootName(rootId: string | null | undefined): string | null {
|
|||||||
return rootStatuses.value.get(rootId)?.root_id || null
|
return rootStatuses.value.get(rootId)?.root_id || null
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePosixPath(value: string): string {
|
const progressRoots = computed(() =>
|
||||||
return value.replace(/\\/g, "/")
|
computeProgressRoots(
|
||||||
}
|
activeTasks.value,
|
||||||
|
(rootId) => rootStatuses.value.get(rootId)?.path || null,
|
||||||
|
isInitialScanMode.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
function extractScanPath(detail: string): string | null {
|
const isSettingsView = useSettingsOpen()
|
||||||
if (!detail.startsWith("Scanning:")) return null
|
|
||||||
let value = detail.replace(/^Scanning:\s*/i, "").trim()
|
|
||||||
value = value.replace(/\s*\(\d+\s+found\)\s*$/i, "").trim()
|
|
||||||
return value || null
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildScanTarget(rootId: string, rootPath: string | null, detail: string): string | null {
|
|
||||||
const rawPath = extractScanPath(detail)
|
|
||||||
if (!rawPath) return null
|
|
||||||
|
|
||||||
const posixRaw = normalizePosixPath(rawPath)
|
|
||||||
const posixRoot = rootPath ? normalizePosixPath(rootPath) : null
|
|
||||||
|
|
||||||
let relative = posixRaw
|
|
||||||
if (posixRoot) {
|
|
||||||
const lowRaw = posixRaw.toLowerCase()
|
|
||||||
const lowRoot = posixRoot.toLowerCase()
|
|
||||||
if (lowRaw === lowRoot) {
|
|
||||||
relative = ""
|
|
||||||
} else if (lowRaw.startsWith(`${lowRoot}/`)) {
|
|
||||||
relative = posixRaw.slice(posixRoot.length).replace(/^\/+/, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!relative) return rootId
|
|
||||||
if (relative.toLowerCase().startsWith(`${rootId.toLowerCase()}/`)) return relative
|
|
||||||
return `${rootId}/${relative}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeRootProgress(
|
|
||||||
rootId: string,
|
|
||||||
rootPath: string | null,
|
|
||||||
tasksForRoot: RootTaskInfo[],
|
|
||||||
isInitialScanMode: boolean,
|
|
||||||
): ProgressRootState | null {
|
|
||||||
const running = tasksForRoot.filter((task) => task.status === "running")
|
|
||||||
const latestError = [...tasksForRoot].reverse().find((task) => task.status === "error") || null
|
|
||||||
|
|
||||||
if (running.length === 0 && !latestError) return null
|
|
||||||
|
|
||||||
const scanTask = running.find((task) => task.id.startsWith("scan-")) || null
|
|
||||||
const showreelCount = running.filter((task) => task.id.startsWith("showreel-")).length
|
|
||||||
const otherRunningCount = running.length - (scanTask ? 1 : 0) - showreelCount
|
|
||||||
|
|
||||||
let phaseLabel = "Processing media"
|
|
||||||
let phaseDetail: string | null = null
|
|
||||||
let scanTarget: string | null = null
|
|
||||||
let isDeterminate = false
|
|
||||||
let progressPercent = 0
|
|
||||||
let progressLabel: string | null = null
|
|
||||||
let toneClass = ""
|
|
||||||
|
|
||||||
if (scanTask) {
|
|
||||||
const detail = (scanTask.detail || "").trim()
|
|
||||||
scanTarget = buildScanTarget(rootId, rootPath, detail)
|
|
||||||
if (detail.startsWith("Scanning:")) {
|
|
||||||
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
|
|
||||||
phaseDetail = isInitialScanMode ? "Looking for new files" : "Running background scan"
|
|
||||||
} else if (/^Processing\s+\d+\s+(items|movies|series)/i.test(detail)) {
|
|
||||||
phaseLabel = "Preparing titles"
|
|
||||||
phaseDetail = "Matching files and grouping releases"
|
|
||||||
} else {
|
|
||||||
phaseLabel = "Fetching metadata"
|
|
||||||
phaseDetail = detail ? `Current title: ${detail}` : "Updating titles and artwork"
|
|
||||||
}
|
|
||||||
if (scanTask.progress > 0 && scanTask.progress <= 1) {
|
|
||||||
isDeterminate = true
|
|
||||||
progressPercent = Math.max(1, Math.round(scanTask.progress * 100))
|
|
||||||
progressLabel = `${progressPercent}%`
|
|
||||||
}
|
|
||||||
} else if (showreelCount > 0) {
|
|
||||||
phaseLabel = "Generating previews"
|
|
||||||
phaseDetail = showreelCount === 1 ? "Building 1 preview reel" : `Building ${showreelCount} preview reels`
|
|
||||||
} else if (otherRunningCount > 0) {
|
|
||||||
phaseLabel = "Finalizing updates"
|
|
||||||
phaseDetail = "Applying library changes"
|
|
||||||
} else if (latestError) {
|
|
||||||
phaseLabel = "Needs attention"
|
|
||||||
phaseDetail = latestError.detail || "A background task failed"
|
|
||||||
toneClass = "activity-root-error"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showreelCount > 0 && scanTask) {
|
|
||||||
phaseDetail = phaseDetail
|
|
||||||
? `${phaseDetail}. Preview generation is running in parallel.`
|
|
||||||
: "Preview generation is running in parallel"
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
rootId,
|
|
||||||
rootLabel: rootId,
|
|
||||||
scanTarget,
|
|
||||||
phaseLabel,
|
|
||||||
phaseDetail,
|
|
||||||
progressPercent,
|
|
||||||
progressLabel,
|
|
||||||
isDeterminate,
|
|
||||||
toneClass,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressRoots = computed<ProgressRootState[]>(() => {
|
|
||||||
const byRoot = new Map<string, RootTaskInfo[]>()
|
|
||||||
for (const task of activeTasks.value) {
|
|
||||||
const list = byRoot.get(task.root_id) || []
|
|
||||||
list.push(task)
|
|
||||||
byRoot.set(task.root_id, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows: ProgressRootState[] = []
|
|
||||||
const initial = isInitialScanMode.value
|
|
||||||
for (const [rootId, rootTasks] of byRoot) {
|
|
||||||
const rootPath = rootStatuses.value.get(rootId)?.path || null
|
|
||||||
const row = describeRootProgress(rootId, rootPath, rootTasks, initial)
|
|
||||||
if (row) rows.push(row)
|
|
||||||
}
|
|
||||||
return rows.sort((a, b) => a.rootLabel.localeCompare(b.rootLabel))
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSettingsView = computed(() => route.path === "/settings")
|
|
||||||
const hasLibraryItems = computed(() => {
|
const hasLibraryItems = computed(() => {
|
||||||
if (!mediaIndex.value) return false
|
if (!mediaIndex.value) return false
|
||||||
return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0
|
return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0
|
||||||
@@ -442,10 +326,8 @@ const headerRoots = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const showProgressPanel = computed(() => {
|
const showProgressPanel = computed(() => {
|
||||||
if (isInitialScanMode.value) {
|
if (isSettingsView.value) return false
|
||||||
return !wsConnected.value || progressRoots.value.length > 0
|
if (!isInitialScanMode.value) return false
|
||||||
}
|
|
||||||
if (!isSettingsView.value) return false
|
|
||||||
return !wsConnected.value || progressRoots.value.length > 0
|
return !wsConnected.value || progressRoots.value.length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -472,7 +354,11 @@ watch(
|
|||||||
const key = `${task.root_id}:${task.id}`
|
const key = `${task.root_id}:${task.id}`
|
||||||
nextSeen.set(key, task.status)
|
nextSeen.set(key, task.status)
|
||||||
const previousStatus = seenTaskStates.get(key)
|
const previousStatus = seenTaskStates.get(key)
|
||||||
if (task.id.startsWith("scan-") && task.status === "completed" && previousStatus !== "completed") {
|
if (
|
||||||
|
task.id.startsWith("scan-") &&
|
||||||
|
task.status === "completed" &&
|
||||||
|
previousStatus !== "completed"
|
||||||
|
) {
|
||||||
const detail = (task.detail || "").trim()
|
const detail = (task.detail || "").trim()
|
||||||
const doneMatch = detail.match(/^Done\s+[\u2014-]\s+(\d+)\s+movies,\s+(\d+)\s+series$/i)
|
const doneMatch = detail.match(/^Done\s+[\u2014-]\s+(\d+)\s+movies,\s+(\d+)\s+series$/i)
|
||||||
if (doneMatch) {
|
if (doneMatch) {
|
||||||
@@ -480,7 +366,9 @@ watch(
|
|||||||
const series = Number(doneMatch[2] || "0")
|
const series = Number(doneMatch[2] || "0")
|
||||||
if (movies > 0 || series > 0) {
|
if (movies > 0 || series > 0) {
|
||||||
const rootName = getRootName(task.root_id) || task.root_id
|
const rootName = getRootName(task.root_id) || task.root_id
|
||||||
showLibraryUpdateToast(`Library updated in ${rootName}: ${movies} movies, ${series} series`)
|
showLibraryUpdateToast(
|
||||||
|
`Library updated in ${rootName}: ${movies} movies, ${series} series`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -578,9 +466,7 @@ function getResumePoint(mediaId: string | null): SeriesResumePoint | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResumeEpisodes(
|
function getResumeEpisodes(mediaId: string | null): Record<string, EpisodeWatchEntry> | null {
|
||||||
mediaId: string | null,
|
|
||||||
): Record<string, EpisodeWatchEntry> | null {
|
|
||||||
if (!mediaId) return null
|
if (!mediaId) return null
|
||||||
return resumePositions.value[mediaId]?.episodes ?? null
|
return resumePositions.value[mediaId]?.episodes ?? null
|
||||||
}
|
}
|
||||||
@@ -675,7 +561,9 @@ function showAdjacentDetail(offset: -1 | 1): boolean {
|
|||||||
const sequence = getDetailAdjacentSequence(current)
|
const sequence = getDetailAdjacentSequence(current)
|
||||||
if (sequence.length < 2) return false
|
if (sequence.length < 2) return false
|
||||||
|
|
||||||
const currentIndex = sequence.findIndex((item) => item.id === current.id && item.type === current.type)
|
const currentIndex = sequence.findIndex(
|
||||||
|
(item) => item.id === current.id && item.type === current.type,
|
||||||
|
)
|
||||||
if (currentIndex < 0) return false
|
if (currentIndex < 0) return false
|
||||||
|
|
||||||
const nextIndex = currentIndex + offset
|
const nextIndex = currentIndex + offset
|
||||||
@@ -771,7 +659,10 @@ function restoreBrowseFocus(path: string) {
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSearchExitTargetFromFocusedCard(): { path: "/movies" | "/series"; itemId: string } | null {
|
function getSearchExitTargetFromFocusedCard(): {
|
||||||
|
path: "/movies" | "/series"
|
||||||
|
itemId: string
|
||||||
|
} | null {
|
||||||
const active = document.activeElement as HTMLElement | null
|
const active = document.activeElement as HTMLElement | null
|
||||||
const focusedCard = active?.closest("[data-item-id]") as HTMLElement | null
|
const focusedCard = active?.closest("[data-item-id]") as HTMLElement | null
|
||||||
if (!focusedCard) return null
|
if (!focusedCard) return null
|
||||||
@@ -832,9 +723,7 @@ function clearSearch(options: { preferBack?: boolean; targetPath?: string } = {}
|
|||||||
? normalizeHistoryPath(window.history.state.back)
|
? normalizeHistoryPath(window.history.state.back)
|
||||||
: ""
|
: ""
|
||||||
const canRestoreWithBack =
|
const canRestoreWithBack =
|
||||||
options.preferBack !== false &&
|
options.preferBack !== false && searchReturnPath.value === targetPath && backPath === targetPath
|
||||||
searchReturnPath.value === targetPath &&
|
|
||||||
backPath === targetPath
|
|
||||||
|
|
||||||
searchReturnPath.value = null
|
searchReturnPath.value = null
|
||||||
|
|
||||||
@@ -1057,7 +946,7 @@ const activePanelScrollTop = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const headerStyle = computed(() => {
|
const headerStyle = computed(() => {
|
||||||
if (route.path === "/settings") {
|
if (isSettingsView.value) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1105,14 +994,18 @@ function showDetail(item: MediaItem) {
|
|||||||
handlePlay(playableFile)
|
handlePlay(playableFile)
|
||||||
} else {
|
} else {
|
||||||
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null
|
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null
|
||||||
router.push({ path: `/series/${epData.series.id}`, state: searchPath ? { searchPath } : undefined })
|
router.push({
|
||||||
|
path: `/series/${epData.series.id}`,
|
||||||
|
state: searchPath ? { searchPath } : undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const detailSearchPath = getDetailSearchPath()
|
const detailSearchPath = getDetailSearchPath()
|
||||||
const searchPath = searchQuery.value
|
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
|
||||||
? getSearchPath(searchQuery.value)
|
router.push({
|
||||||
: detailSearchPath
|
path: `/${item.type}/${item.id}`,
|
||||||
router.push({ path: `/${item.type}/${item.id}`, state: searchPath ? { searchPath } : undefined })
|
state: searchPath ? { searchPath } : undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,9 +1026,7 @@ function handleActorSearch(actorName: string) {
|
|||||||
|
|
||||||
function handleSelectMovieFromDetail(movieId: string) {
|
function handleSelectMovieFromDetail(movieId: string) {
|
||||||
const detailSearchPath = getDetailSearchPath()
|
const detailSearchPath = getDetailSearchPath()
|
||||||
const searchPath = searchQuery.value
|
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
|
||||||
? getSearchPath(searchQuery.value)
|
|
||||||
: detailSearchPath
|
|
||||||
router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined })
|
router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,7 +1065,9 @@ function focusDetailEntryTarget(item: MediaItem): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstFocusable = detailPanel.querySelector('[data-nav-focusable="true"]') as HTMLElement | null
|
const firstFocusable = detailPanel.querySelector(
|
||||||
|
'[data-nav-focusable="true"]',
|
||||||
|
) as HTMLElement | null
|
||||||
if (firstFocusable) {
|
if (firstFocusable) {
|
||||||
focusElement(firstFocusable)
|
focusElement(firstFocusable)
|
||||||
return true
|
return true
|
||||||
|
|||||||
+33
-6
@@ -190,9 +190,7 @@ export async function fetchResumePositions(): Promise<Record<string, ResumePosit
|
|||||||
const rawEpisodes = (entry as { episodes?: unknown }).episodes
|
const rawEpisodes = (entry as { episodes?: unknown }).episodes
|
||||||
if (rawEpisodes && typeof rawEpisodes === "object") {
|
if (rawEpisodes && typeof rawEpisodes === "object") {
|
||||||
const watches: Record<string, EpisodeWatchEntry> = {}
|
const watches: Record<string, EpisodeWatchEntry> = {}
|
||||||
for (const [key, watch] of Object.entries(
|
for (const [key, watch] of Object.entries(rawEpisodes as Record<string, unknown>)) {
|
||||||
rawEpisodes as Record<string, unknown>,
|
|
||||||
)) {
|
|
||||||
if (!watch || typeof watch !== "object") continue
|
if (!watch || typeof watch !== "object") continue
|
||||||
const w = watch as { pos?: unknown; done?: unknown }
|
const w = watch as { pos?: unknown; done?: unknown }
|
||||||
if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue
|
if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue
|
||||||
@@ -428,13 +426,42 @@ export function getCoverUrl(coverPath: string | null, rootId?: string | null): s
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invoke the native OS folder picker via pywebview, then add the selected
|
* Invoke the native OS folder picker via pywebview.
|
||||||
* folder to the server's root list. Only works inside the packaged desktop app.
|
* Only works inside the packaged desktop app; returns null elsewhere.
|
||||||
*/
|
*/
|
||||||
export async function pickFolderAndAddRoot(): Promise<string | null> {
|
export async function pickFolder(): Promise<string | null> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const api = (window as any).pywebview?.api
|
const api = (window as any).pywebview?.api
|
||||||
if (!api) return null
|
if (!api) return null
|
||||||
const folder: string | null = await api.pick_folder()
|
const folder: string | null = await api.pick_folder()
|
||||||
return folder
|
return folder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateStatus {
|
||||||
|
version: string
|
||||||
|
auto_update: boolean
|
||||||
|
pending_version: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch version, auto-update preference, and any downloaded pending update. */
|
||||||
|
export async function fetchUpdateStatus(): Promise<UpdateStatus> {
|
||||||
|
const response = await fetch("/api/update")
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch update status: ${response.status}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enable or disable automatic update downloads (persisted server-side). */
|
||||||
|
export async function setAutoUpdate(enabled: boolean): Promise<void> {
|
||||||
|
const response = await fetch("/api/config/auto-update", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`Failed to save auto-update setting: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a downloaded update and restart the app into it. */
|
||||||
|
export async function restartForUpdate(): Promise<void> {
|
||||||
|
const response = await fetch("/api/update/restart", { method: "POST" })
|
||||||
|
if (!response.ok) throw new Error(`Failed to restart for update: ${response.status}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -594,13 +594,7 @@ function activateItem(item: MediaItem) {
|
|||||||
|
|
||||||
function handleItemClick(event: MouseEvent, item: MediaItem) {
|
function handleItemClick(event: MouseEvent, item: MediaItem) {
|
||||||
// Let modified clicks navigate natively
|
// Let modified clicks navigate natively
|
||||||
if (
|
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||||
event.button !== 0 ||
|
|
||||||
event.ctrlKey ||
|
|
||||||
event.metaKey ||
|
|
||||||
event.shiftKey ||
|
|
||||||
event.altKey
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -65,11 +65,7 @@ function getPlayLabel(filePath: string | null | undefined): string {
|
|||||||
|
|
||||||
function getFocusableElements(): HTMLElement[] {
|
function getFocusableElements(): HTMLElement[] {
|
||||||
if (!menuRef.value) return []
|
if (!menuRef.value) return []
|
||||||
return Array.from(
|
return Array.from(menuRef.value.querySelectorAll<HTMLElement>(".ctx-btn:not(:disabled)"))
|
||||||
menuRef.value.querySelectorAll<HTMLElement>(
|
|
||||||
'.ctx-btn:not(:disabled)'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusNext(delta: number) {
|
function focusNext(delta: number) {
|
||||||
@@ -130,9 +126,7 @@ watch(
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
clampToViewport()
|
clampToViewport()
|
||||||
// Focus first action button for keyboard navigation
|
// Focus first action button for keyboard navigation
|
||||||
const firstBtn = menuRef.value?.querySelector(
|
const firstBtn = menuRef.value?.querySelector(".ctx-btn:not(:disabled)") as HTMLElement | null
|
||||||
".ctx-btn:not(:disabled)",
|
|
||||||
) as HTMLElement | null
|
|
||||||
firstBtn?.focus()
|
firstBtn?.focus()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="header" :class="[`header-${position}`]">
|
<header class="header" :class="[`header-${position}`, { 'header--gui': isDesktopApp }]">
|
||||||
|
<button v-if="isDesktopApp" class="app-exit-btn" title="Exit MediaHive" @click="exitApp">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<RouterLink to="/" class="header-logo-link" aria-label="Go to front page">
|
<RouterLink to="/" class="header-logo-link" aria-label="Go to front page">
|
||||||
<img :src="logoUrl" alt="MediaHive" class="header-logo" />
|
<img :src="logoUrl" alt="MediaHive" class="header-logo" />
|
||||||
@@ -29,7 +32,9 @@
|
|||||||
<!-- Detail mode: show current category + Details -->
|
<!-- Detail mode: show current category + Details -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button class="header-nav-item" v-bind="navAttrs(navRow, 0)" @focus="goToCategory">
|
<button class="header-nav-item" v-bind="navAttrs(navRow, 0)" @focus="goToCategory">
|
||||||
{{ currentView === "search" ? "Search" : currentView === "movies" ? "Movies" : "Series" }}
|
{{
|
||||||
|
currentView === "search" ? "Search" : currentView === "movies" ? "Movies" : "Series"
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
<button class="header-nav-item active" v-bind="navAttrs(navRow, 1, 1)">Details</button>
|
<button class="header-nav-item active" v-bind="navAttrs(navRow, 1, 1)">Details</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -86,63 +91,63 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Full-screen settings view -->
|
<!-- Floating settings/log overlay -->
|
||||||
<div v-if="showSettings" class="settings-view">
|
<div v-if="showSettings" class="settings-view" @click.self="closeSettings">
|
||||||
<div class="settings-header">
|
<!-- Floating log window (left side) -->
|
||||||
<button class="settings-back" @click="closeSettings">
|
<div v-if="showLog" ref="logWindowEl" class="log-window" :style="logDragStyle">
|
||||||
<svg
|
<div class="dialog-header" @pointerdown="onLogDragStart">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<h1 class="settings-title">Application log</h1>
|
||||||
width="24"
|
<div class="log-actions">
|
||||||
height="24"
|
<button class="log-copy-btn" title="Copy entire log" @click="copyLog">
|
||||||
viewBox="0 0 24 24"
|
{{ logCopied ? "Copied" : "Copy" }}
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
<span>Back</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<button class="dialog-close" title="Close log" @click="closeLog">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre ref="logEl" class="diag-log" @scroll="onLogScroll">{{ appLog }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating settings window (right side) -->
|
||||||
|
<div ref="settingsWindowEl" class="settings-window" :style="settingsDragStyle">
|
||||||
|
<div class="dialog-header" @pointerdown="onSettingsDragStart">
|
||||||
<h1 class="settings-title">Settings</h1>
|
<h1 class="settings-title">Settings</h1>
|
||||||
<div class="settings-header-spacer"></div>
|
<button class="dialog-close" title="Close settings" @click="closeSettings">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-content scrollbar-hidden">
|
<div class="settings-scroll">
|
||||||
|
<div class="settings-body">
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="settings-section-title">Media Roots</h2>
|
<div class="update-row">
|
||||||
<p class="settings-section-desc">Folders scanned and indexed by MediaHive.</p>
|
<span class="update-version"> MediaHive {{ updateStatus?.version ?? "…" }} </span>
|
||||||
|
|
||||||
<div class="roots-list">
|
|
||||||
<div
|
|
||||||
v-for="root in roots"
|
|
||||||
:key="root.root_id"
|
|
||||||
class="roots-item"
|
|
||||||
:class="`roots-item--${root.status}`"
|
|
||||||
>
|
|
||||||
<div class="roots-item-info">
|
|
||||||
<span class="roots-item-name">{{ root.root_id }}</span>
|
|
||||||
<span class="roots-item-path">{{ root.path }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="roots-item-meta">
|
|
||||||
<span class="roots-item-status">{{ root.status }}</span>
|
|
||||||
<button
|
<button
|
||||||
v-if="roots.length > 1"
|
v-if="isDesktopApp"
|
||||||
class="roots-item-remove"
|
class="log-open-btn"
|
||||||
@click="removeRoot(root.root_id)"
|
title="View application log"
|
||||||
title="Remove root"
|
@click="openLog"
|
||||||
>
|
>
|
||||||
×
|
Log
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<label class="update-auto">
|
||||||
</div>
|
<input
|
||||||
</div>
|
type="checkbox"
|
||||||
|
:checked="updateStatus?.auto_update ?? true"
|
||||||
<div class="roots-actions">
|
:disabled="!updateStatus"
|
||||||
<button v-if="isDesktopApp" class="roots-add-btn" @click="addRoot">
|
@change="toggleAutoUpdate"
|
||||||
+ Add Folder…
|
/>
|
||||||
|
Automatic updates
|
||||||
|
</label>
|
||||||
|
<template v-if="updateStatus?.pending_version">
|
||||||
|
<span class="update-pending">
|
||||||
|
Update {{ updateStatus.pending_version }} downloaded
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="update-restart-btn"
|
||||||
|
:disabled="updateRestarting"
|
||||||
|
@click="restartToUpdate"
|
||||||
|
>
|
||||||
|
{{ updateRestarting ? "Restarting…" : "Restart to update" }}
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -208,7 +213,9 @@
|
|||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="settings-section-title">Preferred Format</h2>
|
<h2 class="settings-section-title">Preferred Format</h2>
|
||||||
<p class="settings-section-desc">Preferred format when multiple versions are available.</p>
|
<p class="settings-section-desc">
|
||||||
|
Preferred format when multiple versions are available.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="format-grid">
|
<div class="format-grid">
|
||||||
<div class="format-row format-row-stack">
|
<div class="format-row format-row-stack">
|
||||||
@@ -295,6 +302,86 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="settings-section-title">Media folders</h2>
|
||||||
|
<p class="settings-section-desc">Folders scanned and indexed by MediaHive.</p>
|
||||||
|
|
||||||
|
<div class="roots-list">
|
||||||
|
<div
|
||||||
|
v-for="root in roots"
|
||||||
|
:key="root.root_id"
|
||||||
|
class="roots-item"
|
||||||
|
:class="[
|
||||||
|
`roots-item--${root.status}`,
|
||||||
|
{ 'roots-item--clickable': isDesktopApp },
|
||||||
|
]"
|
||||||
|
:title="isDesktopApp ? 'Click to choose a different folder' : undefined"
|
||||||
|
@click="changeRootFolder(root.root_id)"
|
||||||
|
>
|
||||||
|
<div class="roots-item-info">
|
||||||
|
<span class="roots-item-name">{{ root.root_id }}</span>
|
||||||
|
<span class="roots-item-path">{{ root.path }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="roots-item-meta">
|
||||||
|
<span class="roots-item-status">{{ root.status }}</span>
|
||||||
|
<button
|
||||||
|
v-if="roots.length > 1"
|
||||||
|
class="roots-item-remove"
|
||||||
|
@click.stop="removeRoot(root.root_id)"
|
||||||
|
title="Remove folder"
|
||||||
|
>
|
||||||
|
❌
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="roots-actions">
|
||||||
|
<button v-if="isDesktopApp" class="roots-add-btn" @click="addRoot">
|
||||||
|
+ Add Folder…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-activity">
|
||||||
|
<div v-if="!scanConnected" class="activity-connection">Reconnecting...</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="activityRoots.length === 0" class="activity-idle">
|
||||||
|
<template v-if="libraryStats">{{ libraryStats }} in library · </template>Rescan will
|
||||||
|
commence shortly
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="root in activityRoots"
|
||||||
|
:key="root.rootId"
|
||||||
|
class="activity-root"
|
||||||
|
:class="root.toneClass"
|
||||||
|
>
|
||||||
|
<div class="activity-root-title" :title="root.rootLabel">{{ root.rootLabel }}</div>
|
||||||
|
<div v-if="root.scanTarget" class="activity-root-target" :title="root.scanTarget">
|
||||||
|
{{ 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>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="activity-bar"
|
||||||
|
:class="{ 'activity-bar-indeterminate': !root.isDeterminate }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="activity-bar-fill"
|
||||||
|
:style="root.isDeterminate ? { width: `${root.progressPercent}%` } : undefined"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="root.phaseDetail" class="activity-detail">{{ root.phaseDetail }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,12 +389,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed, onMounted, onUnmounted } from "vue"
|
import { ref, watch, computed, onMounted, onUnmounted, nextTick } from "vue"
|
||||||
import { useRouter, useRoute } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import { navAttrs } from "../composables/useKeyboardNavigation"
|
import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||||
|
import { computeProgressRoots, type RootTaskInfo } from "../composables/useScanProgress"
|
||||||
|
import { useSettingsOpen } from "../composables/useSettingsOpen"
|
||||||
import logoUrl from "../assets/mediahive.webp"
|
import logoUrl from "../assets/mediahive.webp"
|
||||||
import { replaceRoots, pickFolderAndAddRoot, fetchPlayers } from "../api"
|
import { replaceRoots, pickFolder, fetchPlayers } from "../api"
|
||||||
import type { PlayerInfo } from "../api"
|
import type { PlayerInfo } from "../api"
|
||||||
|
import { fetchUpdateStatus, setAutoUpdate, restartForUpdate } from "../api"
|
||||||
|
import type { UpdateStatus } from "../api"
|
||||||
import HexKeyboard from "./HexKeyboard.vue"
|
import HexKeyboard from "./HexKeyboard.vue"
|
||||||
import {
|
import {
|
||||||
useSettings,
|
useSettings,
|
||||||
@@ -329,12 +420,17 @@ interface RootEntry {
|
|||||||
root_id: string
|
root_id: string
|
||||||
path: string
|
path: string
|
||||||
status: string
|
status: string
|
||||||
|
movies?: number
|
||||||
|
series?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
currentView: "movies" | "series" | "search"
|
currentView: "movies" | "series" | "search"
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
roots: RootEntry[]
|
roots: RootEntry[]
|
||||||
|
scanTasks: ReadonlyMap<string, RootTaskInfo>
|
||||||
|
scanConnected: boolean
|
||||||
|
initialScanMode: boolean
|
||||||
mpcBeConnected: boolean
|
mpcBeConnected: boolean
|
||||||
navRow: number
|
navRow: number
|
||||||
position: "top" | "after-hero" | "after-movie-header" | "after-series-hero"
|
position: "top" | "after-hero" | "after-movie-header" | "after-series-hero"
|
||||||
@@ -346,7 +442,6 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
|
||||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const localSearch = ref(props.searchQuery)
|
const localSearch = ref(props.searchQuery)
|
||||||
|
|
||||||
@@ -357,23 +452,104 @@ function _onPywebviewReady() {
|
|||||||
window.addEventListener("pywebviewready", _onPywebviewReady, { once: true })
|
window.addEventListener("pywebviewready", _onPywebviewReady, { once: true })
|
||||||
onUnmounted(() => window.removeEventListener("pywebviewready", _onPywebviewReady))
|
onUnmounted(() => window.removeEventListener("pywebviewready", _onPywebviewReady))
|
||||||
|
|
||||||
const showSettings = computed(() => route.path === "/settings")
|
function exitApp() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
void (window as any).pywebview?.api?.exit_app()
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSettings = useSettingsOpen()
|
||||||
const roots = computed(() => props.roots)
|
const roots = computed(() => props.roots)
|
||||||
|
|
||||||
|
const activityRoots = computed(() =>
|
||||||
|
computeProgressRoots(
|
||||||
|
props.scanTasks.values(),
|
||||||
|
(rootId) => roots.value.find((r) => r.root_id === rootId)?.path || null,
|
||||||
|
props.initialScanMode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const libraryStats = computed(() => {
|
||||||
|
let movies = 0
|
||||||
|
let series = 0
|
||||||
|
for (const root of roots.value) {
|
||||||
|
movies += root.movies ?? 0
|
||||||
|
series += root.series ?? 0
|
||||||
|
}
|
||||||
|
if (movies === 0 && series === 0) return null
|
||||||
|
const parts: string[] = []
|
||||||
|
if (movies > 0) parts.push(`${movies} movie${movies === 1 ? "" : "s"}`)
|
||||||
|
if (series > 0) parts.push(`${series} series`)
|
||||||
|
return parts.join(", ")
|
||||||
|
})
|
||||||
|
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
if (showSettings.value) return
|
showSettings.value = true
|
||||||
void router.push("/settings")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSettings() {
|
function closeSettings() {
|
||||||
if (!showSettings.value) return
|
showSettings.value = false
|
||||||
if (window.history.length > 1) {
|
showLog.value = false
|
||||||
router.back()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void router.replace("/movies")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showLog = ref(false)
|
||||||
|
|
||||||
|
function openLog() {
|
||||||
|
showLog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLog() {
|
||||||
|
showLog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draggable floating windows: pointer drag on the dialog header repositions
|
||||||
|
// the window by switching it to fixed positioning at the dragged offset.
|
||||||
|
function useDraggable() {
|
||||||
|
const el = ref<HTMLElement | null>(null)
|
||||||
|
const pos = ref<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
|
function onDragStart(e: PointerEvent) {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) return
|
||||||
|
const rect = el.value?.getBoundingClientRect()
|
||||||
|
if (!rect) return
|
||||||
|
e.preventDefault()
|
||||||
|
const grabX = e.clientX - rect.left
|
||||||
|
const grabY = e.clientY - rect.top
|
||||||
|
pos.value = { x: rect.left, y: rect.top }
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
pos.value = {
|
||||||
|
x: Math.min(Math.max(ev.clientX - grabX, 0), window.innerWidth - 80),
|
||||||
|
y: Math.min(Math.max(ev.clientY - grabY, 0), window.innerHeight - 40),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener("pointermove", onMove)
|
||||||
|
window.removeEventListener("pointerup", onUp)
|
||||||
|
}
|
||||||
|
window.addEventListener("pointermove", onMove)
|
||||||
|
window.addEventListener("pointerup", onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragStyle = computed(() =>
|
||||||
|
pos.value
|
||||||
|
? {
|
||||||
|
position: "fixed" as const,
|
||||||
|
left: `${pos.value.x}px`,
|
||||||
|
top: `${pos.value.y}px`,
|
||||||
|
margin: "0",
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { el, dragStyle, onDragStart }
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
el: settingsWindowEl,
|
||||||
|
dragStyle: settingsDragStyle,
|
||||||
|
onDragStart: onSettingsDragStart,
|
||||||
|
} = useDraggable()
|
||||||
|
const { el: logWindowEl, dragStyle: logDragStyle, onDragStart: onLogDragStart } = useDraggable()
|
||||||
|
|
||||||
function setPreferredResolution(value: ResolutionPreference) {
|
function setPreferredResolution(value: ResolutionPreference) {
|
||||||
settings.preferredResolution = value
|
settings.preferredResolution = value
|
||||||
}
|
}
|
||||||
@@ -409,35 +585,155 @@ async function refreshPlayers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateStatus = ref<UpdateStatus | null>(null)
|
||||||
|
const updateRestarting = ref(false)
|
||||||
|
|
||||||
|
async function refreshUpdateStatus() {
|
||||||
|
try {
|
||||||
|
updateStatus.value = await fetchUpdateStatus()
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch update status:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAutoUpdate(event: Event) {
|
||||||
|
const enabled = (event.target as HTMLInputElement).checked
|
||||||
|
const previous = updateStatus.value?.auto_update ?? true
|
||||||
|
if (updateStatus.value) updateStatus.value.auto_update = enabled
|
||||||
|
try {
|
||||||
|
await setAutoUpdate(enabled)
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save auto-update setting:", e)
|
||||||
|
if (updateStatus.value) updateStatus.value.auto_update = previous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartToUpdate() {
|
||||||
|
updateRestarting.value = true
|
||||||
|
try {
|
||||||
|
await restartForUpdate()
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to restart for update:", e)
|
||||||
|
updateRestarting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appLog = ref("")
|
||||||
|
const logEl = ref<HTMLElement | null>(null)
|
||||||
|
let logSocket: WebSocket | null = null
|
||||||
|
let pinnedToBottom = true
|
||||||
|
|
||||||
|
function onLogScroll() {
|
||||||
|
const el = logEl.value
|
||||||
|
if (!el) return
|
||||||
|
pinnedToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyLogText(text: string) {
|
||||||
|
appLog.value = text
|
||||||
|
await nextTick()
|
||||||
|
const el = logEl.value
|
||||||
|
if (el && pinnedToBottom) el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
const logCopied = ref(false)
|
||||||
|
let logCopiedTimer: number | null = null
|
||||||
|
|
||||||
|
async function copyLog() {
|
||||||
|
const text = appLog.value
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch {
|
||||||
|
// Fallback for contexts where the async clipboard API is unavailable.
|
||||||
|
const ta = document.createElement("textarea")
|
||||||
|
ta.value = text
|
||||||
|
ta.style.position = "fixed"
|
||||||
|
ta.style.opacity = "0"
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand("copy")
|
||||||
|
ta.remove()
|
||||||
|
}
|
||||||
|
logCopied.value = true
|
||||||
|
if (logCopiedTimer !== null) window.clearTimeout(logCopiedTimer)
|
||||||
|
logCopiedTimer = window.setTimeout(() => (logCopied.value = false), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectLogSocket() {
|
||||||
|
if (logSocket) return
|
||||||
|
const proto = location.protocol === "https:" ? "wss" : "ws"
|
||||||
|
const ws = new WebSocket(`${proto}://${location.host}/api/log/ws`)
|
||||||
|
logSocket = ws
|
||||||
|
pinnedToBottom = true
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
void applyLogText(String(ev.data))
|
||||||
|
}
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (logSocket === ws) logSocket = null
|
||||||
|
if (showLog.value) setTimeout(connectLogSocket, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectLogSocket() {
|
||||||
|
const ws = logSocket
|
||||||
|
logSocket = null
|
||||||
|
ws?.close()
|
||||||
|
}
|
||||||
|
|
||||||
async function removeRoot(rootId: string) {
|
async function removeRoot(rootId: string) {
|
||||||
const filtered = roots.value.filter((r) => r.root_id !== rootId)
|
const filtered = roots.value.filter((r) => r.root_id !== rootId)
|
||||||
const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path]))
|
const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path]))
|
||||||
try {
|
try {
|
||||||
await replaceRoots(newRoots)
|
await replaceRoots(newRoots)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to remove root:", e)
|
console.error("Failed to remove folder:", e)
|
||||||
alert("Failed to remove root")
|
alert("Failed to remove folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeRootFolder(rootId: string) {
|
||||||
|
if (!isDesktopApp.value) return
|
||||||
|
const folder = await pickFolder()
|
||||||
|
if (!folder || folder === roots.value.find((r) => r.root_id === rootId)?.path) return
|
||||||
|
const newRoots = Object.fromEntries(roots.value.map((r) => [r.root_id, r.path]))
|
||||||
|
newRoots[rootId] = folder
|
||||||
|
try {
|
||||||
|
await replaceRoots(newRoots)
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to change folder:", e)
|
||||||
|
alert("Failed to change folder")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRoot() {
|
async function addRoot() {
|
||||||
const folder = await pickFolderAndAddRoot()
|
const folder = await pickFolder()
|
||||||
if (!folder) return
|
if (!folder) return
|
||||||
const suggestedId = folder.split("/").pop() || folder.split("\\").pop() || "media"
|
const suggestedId = folder.split("/").pop() || folder.split("\\").pop() || "media"
|
||||||
const newRoots = Object.fromEntries(roots.value.map((r) => [r.root_id, r.path]))
|
const newRoots = Object.fromEntries(roots.value.map((r) => [r.root_id, r.path]))
|
||||||
newRoots[suggestedId] = folder
|
newRoots[suggestedId] = folder
|
||||||
try {
|
try {
|
||||||
await replaceRoots(newRoots)
|
await replaceRoots(newRoots)
|
||||||
closeSettings()
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to add root:", e)
|
console.error("Failed to add folder:", e)
|
||||||
alert("Failed to add root")
|
alert("Failed to add folder")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(showSettings, (visible) => {
|
watch(showSettings, (visible) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
void refreshPlayers()
|
void refreshPlayers()
|
||||||
|
void refreshUpdateStatus()
|
||||||
|
} else {
|
||||||
|
showLog.value = false
|
||||||
|
disconnectLogSocket()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showLog, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
connectLogSocket()
|
||||||
|
} else {
|
||||||
|
disconnectLogSocket()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -525,6 +821,17 @@ function focusSearchInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
// Fullscreen toggle works even while typing (Alt+Enter / F11 are never text input).
|
||||||
|
if (
|
||||||
|
isDesktopApp.value &&
|
||||||
|
(e.key === "F11" || (e.key === "Enter" && e.altKey && !e.ctrlKey && !e.metaKey))
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
void (window as any).pywebview?.api?.toggle_fullscreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const target = e.target as HTMLElement | null
|
const target = e.target as HTMLElement | null
|
||||||
const isTypingTarget = Boolean(
|
const isTypingTarget = Boolean(
|
||||||
target &&
|
target &&
|
||||||
@@ -551,10 +858,34 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener("keydown", handleKeydown)
|
window.removeEventListener("keydown", handleKeydown)
|
||||||
window.removeEventListener("mediahive:gamepad-action", onGamepadAction)
|
window.removeEventListener("mediahive:gamepad-action", onGamepadAction)
|
||||||
|
disconnectLogSocket()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.app-exit-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 3000;
|
||||||
|
width: 2.875rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-exit-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
.player-indicator {
|
.player-indicator {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -582,37 +913,100 @@ onUnmounted(() => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: var(--bg-primary);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
z-index: 2000;
|
z-index: 2000;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2.5rem;
|
||||||
|
padding-left: 5vw;
|
||||||
|
padding-right: 15vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-window {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 94vw;
|
||||||
|
height: min(85vh, 56.25rem);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header {
|
.log-window {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 20rem;
|
||||||
|
max-width: 75rem;
|
||||||
|
height: min(85vh, 56.25rem);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 43.75rem) {
|
||||||
|
.settings-view {
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-window,
|
||||||
|
.log-window {
|
||||||
|
width: 100vw;
|
||||||
|
max-width: none;
|
||||||
|
height: 100vh;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-window {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-back {
|
.dialog-header:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-close {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: center;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-secondary);
|
color: #fff;
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 8px 0;
|
opacity: 0.7;
|
||||||
transition: color 0.2s;
|
transition: opacity 0.15s;
|
||||||
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-back:hover {
|
.dialog-close:hover {
|
||||||
color: var(--text-primary);
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-title {
|
.settings-title {
|
||||||
@@ -621,17 +1015,16 @@ onUnmounted(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header-spacer {
|
.settings-scroll {
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-content {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 32px 24px;
|
}
|
||||||
max-width: 720px;
|
|
||||||
|
.settings-body {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
padding: 2rem 1.5rem;
|
||||||
|
width: 32rem;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section {
|
.settings-section {
|
||||||
@@ -650,6 +1043,54 @@ onUnmounted(() => {
|
|||||||
margin: 0 0 20px;
|
margin: 0 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.update-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted, var(--text-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-auto {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-pending {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-restart-btn {
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.45);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #22c55e;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-restart-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-restart-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.roots-list {
|
.roots-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -721,21 +1162,29 @@ onUnmounted(() => {
|
|||||||
.roots-item-remove {
|
.roots-item-remove {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-secondary);
|
font-size: 1.25rem;
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 4px;
|
padding: 0 0.25rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.roots-item-remove:hover {
|
.roots-item:hover .roots-item-remove,
|
||||||
color: #ef4444;
|
.roots-item-remove:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roots-item--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roots-item--clickable:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.09);
|
||||||
}
|
}
|
||||||
|
|
||||||
.roots-actions {
|
.roots-actions {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.roots-add-btn {
|
.roots-add-btn {
|
||||||
@@ -868,4 +1317,176 @@ onUnmounted(() => {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #22c55e;
|
color: #22c55e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diag-log {
|
||||||
|
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: pre;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.625rem 1.5rem 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
user-select: text;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-copy-btn {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: var(--text-muted, var(--text-secondary));
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-copy-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-open-btn {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: var(--text-muted, var(--text-secondary));
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-open-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Library activity footer strip */
|
||||||
|
.settings-activity {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 0;
|
||||||
|
min-width: 100%;
|
||||||
|
height: 11rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.5rem 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-connection {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: auto 0;
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-idle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: auto 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-root {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-root-error {
|
||||||
|
border-color: rgba(248, 113, 113, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-root-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-root-target {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(147, 197, 253, 0.95);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-phase-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-phase {
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-progress-label {
|
||||||
|
color: #60a5fa;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-bar {
|
||||||
|
position: relative;
|
||||||
|
height: 0.375rem;
|
||||||
|
border-radius: 6.25rem;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #22d3ee);
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-bar-indeterminate .activity-bar-fill {
|
||||||
|
width: 45%;
|
||||||
|
animation: activity-indeterminate 1.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-detail {
|
||||||
|
color: rgba(255, 255, 255, 0.58);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes activity-indeterminate {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-120%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(250%);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="hex-keyboard-fade">
|
<Transition name="hex-keyboard-fade">
|
||||||
<div v-if="visible" ref="keyboardRef" class="hex-keyboard" @click.stop @keydown="handleKeyDown">
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
ref="keyboardRef"
|
||||||
|
class="hex-keyboard"
|
||||||
|
@click.stop
|
||||||
|
@keydown="handleKeyDown"
|
||||||
|
>
|
||||||
<div ref="gridRef" class="hex-keyboard-grid">
|
<div ref="gridRef" class="hex-keyboard-grid">
|
||||||
<div
|
<div
|
||||||
v-for="(row, rowIndex) in rows"
|
v-for="(row, rowIndex) in rows"
|
||||||
@@ -26,7 +32,9 @@
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@click="handleKeyClick(key)"
|
@click="handleKeyClick(key)"
|
||||||
>
|
>
|
||||||
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{ key.label }}</span>
|
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{
|
||||||
|
key.label
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Green focus outline rendered separately on top -->
|
<!-- Green focus outline rendered separately on top -->
|
||||||
@@ -174,11 +182,16 @@ function getCoord(index: number): { row: number; col: number } {
|
|||||||
|
|
||||||
function getRowRange(row: number): { start: number; count: number } {
|
function getRowRange(row: number): { start: number; count: number } {
|
||||||
switch (row) {
|
switch (row) {
|
||||||
case 0: return { start: ROW_0_START, count: ROW_0_COUNT }
|
case 0:
|
||||||
case 1: return { start: ROW_1_START, count: ROW_1_COUNT }
|
return { start: ROW_0_START, count: ROW_0_COUNT }
|
||||||
case 2: return { start: ROW_2_START, count: ROW_2_COUNT }
|
case 1:
|
||||||
case 3: return { start: ROW_3_START, count: ROW_3_COUNT }
|
return { start: ROW_1_START, count: ROW_1_COUNT }
|
||||||
default: return { start: 0, count: 0 }
|
case 2:
|
||||||
|
return { start: ROW_2_START, count: ROW_2_COUNT }
|
||||||
|
case 3:
|
||||||
|
return { start: ROW_3_START, count: ROW_3_COUNT }
|
||||||
|
default:
|
||||||
|
return { start: 0, count: 0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,10 +260,7 @@ function close() {
|
|||||||
emit("close")
|
emit("close")
|
||||||
}
|
}
|
||||||
|
|
||||||
function findNext(
|
function findNext(currentIdx: number, direction: "up" | "down" | "left" | "right"): number | null {
|
||||||
currentIdx: number,
|
|
||||||
direction: "up" | "down" | "left" | "right",
|
|
||||||
): number | null {
|
|
||||||
const current = getCoord(currentIdx)
|
const current = getCoord(currentIdx)
|
||||||
|
|
||||||
if (direction === "left") {
|
if (direction === "left") {
|
||||||
@@ -510,10 +520,18 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Row horizontal offsets for honeycomb staggering */
|
/* Row horizontal offsets for honeycomb staggering */
|
||||||
.hex-keyboard-row-0 { margin-left: 0; }
|
.hex-keyboard-row-0 {
|
||||||
.hex-keyboard-row-1 { margin-left: calc(var(--key-w) * 0.5); }
|
margin-left: 0;
|
||||||
.hex-keyboard-row-2 { margin-left: calc(var(--key-w) * 1.0); }
|
}
|
||||||
.hex-keyboard-row-3 { margin-left: calc(var(--key-w) * 1.5); }
|
.hex-keyboard-row-1 {
|
||||||
|
margin-left: calc(var(--key-w) * 0.5);
|
||||||
|
}
|
||||||
|
.hex-keyboard-row-2 {
|
||||||
|
margin-left: calc(var(--key-w) * 1);
|
||||||
|
}
|
||||||
|
.hex-keyboard-row-3 {
|
||||||
|
margin-left: calc(var(--key-w) * 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
.hex-key {
|
.hex-key {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -529,7 +547,9 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||||
transition: transform 0.15s ease, color 0.3s ease;
|
transition:
|
||||||
|
transform 0.15s ease,
|
||||||
|
color 0.3s ease;
|
||||||
outline: none;
|
outline: none;
|
||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
@@ -538,7 +558,8 @@ onUnmounted(() => {
|
|||||||
.hex-key-row-0 {
|
.hex-key-row-0 {
|
||||||
background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%);
|
background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%);
|
||||||
}
|
}
|
||||||
.hex-key-row-1, .hex-key-row-3 {
|
.hex-key-row-1,
|
||||||
|
.hex-key-row-3 {
|
||||||
background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%);
|
background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%);
|
||||||
}
|
}
|
||||||
.hex-key-row-2 {
|
.hex-key-row-2 {
|
||||||
@@ -553,8 +574,12 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes hex-label-fade {
|
@keyframes hex-label-fade {
|
||||||
0% { color: #22c55e; }
|
0% {
|
||||||
100% { color: #ffffff; }
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Special key backgrounds override row gradients */
|
/* Special key backgrounds override row gradients */
|
||||||
@@ -600,8 +625,13 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes hex-outline-blink {
|
@keyframes hex-outline-blink {
|
||||||
0%, 100% { opacity: 1; }
|
0%,
|
||||||
50% { opacity: 0.4; }
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Transition */
|
/* Transition */
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
v-for="entry in flagEntries"
|
v-for="entry in flagEntries"
|
||||||
:key="entry.countryCode"
|
:key="entry.countryCode"
|
||||||
class="language-flag"
|
class="language-flag"
|
||||||
:title="`${entry.countryCode}: ${entry.sourceCodes.join(', ')}`"
|
:title="formatLanguageFlagTitle(entry, externalCodes)"
|
||||||
v-html="entry.svg"
|
v-html="entry.svg"
|
||||||
></span>
|
></span>
|
||||||
<span
|
<span
|
||||||
@@ -22,11 +22,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
import { buildLanguageFlags } from "../utils/languageFlags"
|
import { buildLanguageFlags, formatLanguageFlagTitle } from "../utils/languageFlags"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
label?: string
|
label?: string
|
||||||
codes: string[] | null | undefined
|
codes: string[] | null | undefined
|
||||||
|
externalCodes?: string[] | null
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
||||||
<div v-if="directorAndCast" class="media-card-detail person-list">
|
<div v-if="directorAndCast" class="media-card-detail person-list">
|
||||||
<span v-if="director" class="director-name person-token">{{ formatPersonLabel(director) }}</span>
|
<span v-if="director" class="director-name person-token">{{
|
||||||
|
formatPersonLabel(director)
|
||||||
|
}}</span>
|
||||||
<span
|
<span
|
||||||
v-for="(castName, castIndex) in formattedCastNames"
|
v-for="(castName, castIndex) in formattedCastNames"
|
||||||
:key="`${castName}-${castIndex}`"
|
:key="`${castName}-${castIndex}`"
|
||||||
@@ -112,13 +114,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
function handleClick(event: MouseEvent) {
|
function handleClick(event: MouseEvent) {
|
||||||
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
|
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
|
||||||
if (
|
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||||
event.button !== 0 ||
|
|
||||||
event.ctrlKey ||
|
|
||||||
event.metaKey ||
|
|
||||||
event.shiftKey ||
|
|
||||||
event.altKey
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Prevent default navigation for plain left-clicks and synthetic clicks
|
// Prevent default navigation for plain left-clicks and synthetic clicks
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<!-- Full screen view for series -->
|
<!-- Full screen view for series -->
|
||||||
<SeriesFullView
|
<SeriesFullView
|
||||||
v-if="item.type === 'series'"
|
v-if="item.type === 'series'"
|
||||||
|
:key="item.id"
|
||||||
:series="item.data as Series"
|
:series="item.data as Series"
|
||||||
:all-movies="allMovies"
|
:all-movies="allMovies"
|
||||||
:focus-episode="focusEpisode"
|
:focus-episode="focusEpisode"
|
||||||
@@ -185,7 +186,11 @@
|
|||||||
v-if="item.type === 'movies' && collectionMovies.length > 1"
|
v-if="item.type === 'movies' && collectionMovies.length > 1"
|
||||||
class="similar-movies-section"
|
class="similar-movies-section"
|
||||||
>
|
>
|
||||||
<div class="similar-movies-grid" data-sync-scroll-row="true" data-sync-scroll-group="similar">
|
<div
|
||||||
|
class="similar-movies-grid"
|
||||||
|
data-sync-scroll-row="true"
|
||||||
|
data-sync-scroll-group="similar"
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
v-for="(movie, collectionIndex) in collectionMovies"
|
v-for="(movie, collectionIndex) in collectionMovies"
|
||||||
:key="movie.localId"
|
:key="movie.localId"
|
||||||
@@ -234,7 +239,15 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
|
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
|
||||||
import type { CastMember, MediaItem, Movie, MovieUi, Series, SeriesResumePoint, Torrent } from "../types"
|
import type {
|
||||||
|
CastMember,
|
||||||
|
MediaItem,
|
||||||
|
Movie,
|
||||||
|
MovieUi,
|
||||||
|
Series,
|
||||||
|
SeriesResumePoint,
|
||||||
|
Torrent,
|
||||||
|
} from "../types"
|
||||||
import type { EpisodeWatchEntry } from "../api"
|
import type { EpisodeWatchEntry } from "../api"
|
||||||
import {
|
import {
|
||||||
getCoverUrl,
|
getCoverUrl,
|
||||||
@@ -315,7 +328,9 @@ function getReleaseAtRow(row: number): HTMLElement | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getLastReleaseRowBefore(castRow: number): number | null {
|
function getLastReleaseRowBefore(castRow: number): number | null {
|
||||||
const releases = Array.from(document.querySelectorAll<HTMLElement>('[data-nav-release-item="true"]'))
|
const releases = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-nav-release-item="true"]'),
|
||||||
|
)
|
||||||
let best: number | null = null
|
let best: number | null = null
|
||||||
for (const release of releases) {
|
for (const release of releases) {
|
||||||
const row = parseInt(release.getAttribute("data-nav-row") || "", 10)
|
const row = parseInt(release.getAttribute("data-nav-row") || "", 10)
|
||||||
@@ -342,11 +357,7 @@ function registerMovieOutOfBoundsShortcut() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (direction === "left" && current.hasAttribute("data-nav-cast-item") && currentCol === 0) {
|
||||||
direction === "left" &&
|
|
||||||
current.hasAttribute("data-nav-cast-item") &&
|
|
||||||
currentCol === 0
|
|
||||||
) {
|
|
||||||
const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow)
|
const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow)
|
||||||
if (targetRow === null) return null
|
if (targetRow === null) return null
|
||||||
return getReleaseAtRow(targetRow)
|
return getReleaseAtRow(targetRow)
|
||||||
@@ -755,7 +766,8 @@ const castNavRow = computed(() => {
|
|||||||
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
|
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
|
||||||
})
|
})
|
||||||
|
|
||||||
const collectionMovies = computed((): Array<{
|
const collectionMovies = computed(
|
||||||
|
(): Array<{
|
||||||
title: string
|
title: string
|
||||||
localId: string
|
localId: string
|
||||||
coverPath: string | null
|
coverPath: string | null
|
||||||
@@ -763,7 +775,7 @@ const collectionMovies = computed((): Array<{
|
|||||||
year: string | null
|
year: string | null
|
||||||
hyphenLang: string | null
|
hyphenLang: string | null
|
||||||
isCurrent: boolean
|
isCurrent: boolean
|
||||||
}> => {
|
}> => {
|
||||||
if (props.item.type !== "movies") return []
|
if (props.item.type !== "movies") return []
|
||||||
|
|
||||||
const movie = props.item.data as Movie
|
const movie = props.item.data as Movie
|
||||||
@@ -832,7 +844,8 @@ const collectionMovies = computed((): Array<{
|
|||||||
return a.title.localeCompare(b.title)
|
return a.title.localeCompare(b.title)
|
||||||
})
|
})
|
||||||
.slice(0, 24)
|
.slice(0, 24)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
function normalizeHyphenationLang(language: string | null | undefined): string | null {
|
function normalizeHyphenationLang(language: string | null | undefined): string | null {
|
||||||
if (!language) return null
|
if (!language) return null
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const disabled = computed(() => !props.filePath)
|
|||||||
function getFocusableElements(): HTMLElement[] {
|
function getFocusableElements(): HTMLElement[] {
|
||||||
if (!menuRef.value) return []
|
if (!menuRef.value) return []
|
||||||
return Array.from(
|
return Array.from(
|
||||||
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)")
|
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
<LanguageFlags
|
<LanguageFlags
|
||||||
class="language-flags-subs"
|
class="language-flags-subs"
|
||||||
:codes="torrent.subtitle_languages"
|
:codes="torrent.subtitle_languages"
|
||||||
|
:external-codes="torrent.external_subtitle_languages"
|
||||||
:compact="compactFlags"
|
:compact="compactFlags"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,7 +336,12 @@ const hdr10PlusPattern = /hdr10\+|hdr10plus/i
|
|||||||
|
|
||||||
const hasHdr10Plus = computed(() => {
|
const hasHdr10Plus = computed(() => {
|
||||||
if (props.torrent.hdr10plus) return true
|
if (props.torrent.hdr10plus) return true
|
||||||
const text = [props.torrent.title, props.torrent.quality, props.torrent.codec, props.torrent.audio]
|
const text = [
|
||||||
|
props.torrent.title,
|
||||||
|
props.torrent.quality,
|
||||||
|
props.torrent.codec,
|
||||||
|
props.torrent.audio,
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
return hdr10PlusPattern.test(text)
|
return hdr10PlusPattern.test(text)
|
||||||
|
|||||||
@@ -96,7 +96,9 @@
|
|||||||
formatDate(selectedSeason.air_date)
|
formatDate(selectedSeason.air_date)
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
>{{ selectedSeason.episode_count ?? selectedSeason.episodes.length }}
|
>{{
|
||||||
|
selectedSeason.episode_count ?? selectedSeason.episodes.length
|
||||||
|
}}
|
||||||
Episodes</span
|
Episodes</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +114,7 @@
|
|||||||
<div class="episodes-grid">
|
<div class="episodes-grid">
|
||||||
<div
|
<div
|
||||||
v-for="(episode, eIndex) in selectedSeason?.episodes || []"
|
v-for="(episode, eIndex) in selectedSeason?.episodes || []"
|
||||||
:key="`${selectedSeasonIndex}-${episode.episode_number}`"
|
:key="`${seriesIdentity}-${selectedSeasonIndex}-${episode.episode_number}`"
|
||||||
class="episode-tile"
|
class="episode-tile"
|
||||||
:class="{ 'episode-tile--ahead': isEpisodeAhead(eIndex) }"
|
:class="{ 'episode-tile--ahead': isEpisodeAhead(eIndex) }"
|
||||||
v-bind="getEpisodeNavAttrs(selectedSeasonIndex, eIndex)"
|
v-bind="getEpisodeNavAttrs(selectedSeasonIndex, eIndex)"
|
||||||
@@ -124,9 +126,7 @@
|
|||||||
@contextmenu="handleContextMenu($event, episode)"
|
@contextmenu="handleContextMenu($event, episode)"
|
||||||
>
|
>
|
||||||
<!-- SVG focus outline -->
|
<!-- SVG focus outline -->
|
||||||
<svg class="tile-focus-outline" viewBox="0 0 100 100" preserveAspectRatio="none">
|
<div class="tile-focus-outline"></div>
|
||||||
<rect x="0" y="0" width="100" height="100" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Episode preview media -->
|
<!-- Episode preview media -->
|
||||||
<div class="tile-media">
|
<div class="tile-media">
|
||||||
@@ -161,9 +161,7 @@
|
|||||||
<span
|
<span
|
||||||
v-if="episodeWatchIndicator(episode)"
|
v-if="episodeWatchIndicator(episode)"
|
||||||
class="ep-watch"
|
class="ep-watch"
|
||||||
:title="
|
:title="episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'"
|
||||||
episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'
|
|
||||||
"
|
|
||||||
>{{ episodeWatchIndicator(episode) }}</span
|
>{{ episodeWatchIndicator(episode) }}</span
|
||||||
>
|
>
|
||||||
<div class="tile-play">▶</div>
|
<div class="tile-play">▶</div>
|
||||||
@@ -258,7 +256,7 @@ import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
|
|||||||
import { sortTorrentsByPreference } from "../composables/useSettings"
|
import { sortTorrentsByPreference } from "../composables/useSettings"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
series: Series & { root_id?: string | null }
|
series: Series & { id?: string; root_id?: string | null }
|
||||||
allMovies: MovieUi[]
|
allMovies: MovieUi[]
|
||||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
|
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
|
||||||
resumePoint?: SeriesResumePoint | null
|
resumePoint?: SeriesResumePoint | null
|
||||||
@@ -297,6 +295,15 @@ const selectedSeason = computed<Season | null>(
|
|||||||
() => props.series.seasons[selectedSeasonIndex.value] || null,
|
() => props.series.seasons[selectedSeasonIndex.value] || null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Stable identity of the displayed series. Episode tiles carry it in their
|
||||||
|
// :key so swapping to another series remounts the tiles (and their <video>
|
||||||
|
// elements) instead of patching <source> children — which browsers ignore,
|
||||||
|
// leaving the previous series' preview reels playing.
|
||||||
|
const seriesIdentity = computed(() => {
|
||||||
|
const s = props.series
|
||||||
|
return s.id ?? `${s.root_id ?? ""}:${s.title ?? ""}`
|
||||||
|
})
|
||||||
|
|
||||||
function selectSeason(index: number) {
|
function selectSeason(index: number) {
|
||||||
if (index < 0 || index >= props.series.seasons.length) return
|
if (index < 0 || index >= props.series.seasons.length) return
|
||||||
if (selectedSeasonIndex.value === index) return
|
if (selectedSeasonIndex.value === index) return
|
||||||
@@ -358,9 +365,7 @@ const cursorGlobalIndex = computed(() =>
|
|||||||
const resumePointGlobalIndex = computed(() => {
|
const resumePointGlobalIndex = computed(() => {
|
||||||
const point = props.resumePoint
|
const point = props.resumePoint
|
||||||
if (!point) return null
|
if (!point) return null
|
||||||
const seasonIndex = props.series.seasons.findIndex(
|
const seasonIndex = props.series.seasons.findIndex((s) => s.season_number === point.seasonNumber)
|
||||||
(s) => s.season_number === point.seasonNumber,
|
|
||||||
)
|
|
||||||
if (seasonIndex < 0) return null
|
if (seasonIndex < 0) return null
|
||||||
const episodeIndex = props.series.seasons[seasonIndex]?.episodes.findIndex(
|
const episodeIndex = props.series.seasons[seasonIndex]?.episodes.findIndex(
|
||||||
(e) => e.episode_number === point.episodeNumber,
|
(e) => e.episode_number === point.episodeNumber,
|
||||||
@@ -548,6 +553,9 @@ function getEpisodeNavAttrs(seasonIndex: number, episodeIndex: number) {
|
|||||||
...navAttrs(coords.row, coords.col),
|
...navAttrs(coords.row, coords.col),
|
||||||
"data-season-index": seasonIndex,
|
"data-season-index": seasonIndex,
|
||||||
"data-episode-index": episodeIndex,
|
"data-episode-index": episodeIndex,
|
||||||
|
// Entering the episode grid from above (the season selector) always
|
||||||
|
// lands on the first episode instead of the visually closest tile.
|
||||||
|
...(episodeIndex === 0 ? { "data-nav-entry-col-from-above": "0" } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,10 +646,7 @@ watch(
|
|||||||
episodeFocusTarget,
|
episodeFocusTarget,
|
||||||
(ep) => {
|
(ep) => {
|
||||||
if (!ep) return
|
if (!ep) return
|
||||||
if (
|
if (!props.focusEpisode && (seasonUserInteracted.value || episodeCursorIndex.value !== null)) {
|
||||||
!props.focusEpisode &&
|
|
||||||
(seasonUserInteracted.value || episodeCursorIndex.value !== null)
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const seasonIndex =
|
const seasonIndex =
|
||||||
@@ -1406,6 +1411,17 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Series swapped under a reused component instance: tiles remount via the
|
||||||
|
// :key, but per-episode state keyed by episode index alone (audio owner,
|
||||||
|
// cursor, startup timers) survives — reset it so the old series' playback
|
||||||
|
// and audio don't bleed into the new one.
|
||||||
|
watch(seriesIdentity, () => {
|
||||||
|
episodeCursorIndex.value = null
|
||||||
|
setAudioOwner(null)
|
||||||
|
stopEpisodePreviews()
|
||||||
|
scheduleEpisodeMediaReady()
|
||||||
|
})
|
||||||
|
|
||||||
// Episodes past the spoiler threshold (cursor or continue point) are faded;
|
// Episodes past the spoiler threshold (cursor or continue point) are faded;
|
||||||
// stop their playback too.
|
// stop their playback too.
|
||||||
watch([episodeCursorIndex, resumePointGlobalIndex], () => {
|
watch([episodeCursorIndex, resumePointGlobalIndex], () => {
|
||||||
@@ -1827,25 +1843,21 @@ html:not(.mouse-active) .season-poster-card.nav-focused,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SVG focus outline styles for tiles */
|
/* Focus outline for tiles; border width is half the SVG stroke width used on
|
||||||
|
poster cards, since a CSS border paints fully inside while an SVG stroke
|
||||||
|
is centered on the path (half of it clipped away). */
|
||||||
.tile-focus-outline {
|
.tile-focus-outline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||||
height: 100%;
|
border-radius: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-focus-outline rect {
|
|
||||||
fill: none;
|
|
||||||
stroke: rgba(255, 255, 255, 0.9);
|
|
||||||
stroke-width: 4;
|
|
||||||
vector-effect: non-scaling-stroke;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Show outline on hover and focus */
|
/* Show outline on hover and focus */
|
||||||
html.mouse-active .episode-tile:hover .tile-focus-outline,
|
html.mouse-active .episode-tile:hover .tile-focus-outline,
|
||||||
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
|
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
|
||||||
@@ -1854,9 +1866,9 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Brighter outline for keyboard focus */
|
/* Brighter outline for keyboard focus */
|
||||||
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
|
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
|
||||||
stroke: #ffffff;
|
border-color: #ffffff;
|
||||||
stroke-width: 5;
|
border-width: 2.5px;
|
||||||
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
|
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1933,7 +1945,12 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
|
|||||||
rgba(255, 255, 255, 0.025) 30%,
|
rgba(255, 255, 255, 0.025) 30%,
|
||||||
rgba(255, 255, 255, 0) 55%
|
rgba(255, 255, 255, 0) 55%
|
||||||
),
|
),
|
||||||
linear-gradient(to bottom, rgba(20, 20, 28, 0.5) 0%, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.45) 100%);
|
linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(20, 20, 28, 0.5) 0%,
|
||||||
|
rgba(0, 0, 0, 0) 40%,
|
||||||
|
rgba(0, 0, 0, 0.45) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-tile--ahead::after {
|
.episode-tile--ahead::after {
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ function getDigitalRepeatIntervalMs(holdMs: number): number {
|
|||||||
|
|
||||||
function getAnalogRepeatIntervalMs(intensity: number): number {
|
function getAnalogRepeatIntervalMs(intensity: number): number {
|
||||||
const normalized = Math.min(Math.max(intensity, 0), 1)
|
const normalized = Math.min(Math.max(intensity, 0), 1)
|
||||||
return Math.round(ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized)
|
return Math.round(
|
||||||
|
ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAxisIntensity(rawValue: number): number {
|
function normalizeAxisIntensity(rawValue: number): number {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { reportUserActivity } from "../api"
|
|||||||
|
|
||||||
type InputModality = "mouse" | "keyboard" | "gamepad"
|
type InputModality = "mouse" | "keyboard" | "gamepad"
|
||||||
|
|
||||||
|
|
||||||
const MOUSE_IDLE_MS = 1400
|
const MOUSE_IDLE_MS = 1400
|
||||||
const MOUSE_INTENT_DISTANCE_PX = 28
|
const MOUSE_INTENT_DISTANCE_PX = 28
|
||||||
const MOUSE_INTENT_WINDOW_MS = 700
|
const MOUSE_INTENT_WINDOW_MS = 700
|
||||||
|
const MOUSE_OVER_INTENT_RECENCY_MS = 500
|
||||||
const MOUSE_INTENT_SELECTOR = [
|
const MOUSE_INTENT_SELECTOR = [
|
||||||
"[data-nav-focusable]",
|
"[data-nav-focusable]",
|
||||||
"button",
|
"button",
|
||||||
@@ -28,6 +28,7 @@ let mouseIdleTimer: number | null = null
|
|||||||
let pointerVisible = false
|
let pointerVisible = false
|
||||||
let mouseTravelPx = 0
|
let mouseTravelPx = 0
|
||||||
let lastMouseMoveAt = 0
|
let lastMouseMoveAt = 0
|
||||||
|
let lastAnyMouseMoveAt = 0
|
||||||
|
|
||||||
function clearMouseIdleTimer() {
|
function clearMouseIdleTimer() {
|
||||||
if (mouseIdleTimer !== null) {
|
if (mouseIdleTimer !== null) {
|
||||||
@@ -95,6 +96,7 @@ function registerMouseIntentTravel(event: MouseEvent): boolean {
|
|||||||
function handleMouseMove(event: MouseEvent) {
|
function handleMouseMove(event: MouseEvent) {
|
||||||
reportUserActivity()
|
reportUserActivity()
|
||||||
showPointerFromMotion()
|
showPointerFromMotion()
|
||||||
|
lastAnyMouseMoveAt = performance.now()
|
||||||
|
|
||||||
if (modality === "mouse") {
|
if (modality === "mouse") {
|
||||||
applyInputState(true)
|
applyInputState(true)
|
||||||
@@ -111,6 +113,13 @@ function handleMouseOver(event: MouseEvent) {
|
|||||||
if (modality === "mouse") return
|
if (modality === "mouse") return
|
||||||
if (!isMouseIntentTarget(event.target)) return
|
if (!isMouseIntentTarget(event.target)) return
|
||||||
|
|
||||||
|
// Browsers fire mouseover/mouseenter when scrolling or re-rendering moves
|
||||||
|
// content under a stationary cursor (e.g. sideways season browsing while
|
||||||
|
// the pointer happens to rest over the episode grid). Without recent real
|
||||||
|
// pointer motion this is not mouse intent — activating mouse input here
|
||||||
|
// would let hover handlers steal keyboard/gamepad focus.
|
||||||
|
if (performance.now() - lastAnyMouseMoveAt > MOUSE_OVER_INTENT_RECENCY_MS) return
|
||||||
|
|
||||||
// Entering an interactive target indicates likely mouse intent.
|
// Entering an interactive target indicates likely mouse intent.
|
||||||
activateMouseInput()
|
activateMouseInput()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ const FOCUSABLE_ATTR = "data-nav-focusable"
|
|||||||
const ROW_ATTR = "data-nav-row"
|
const ROW_ATTR = "data-nav-row"
|
||||||
const COL_ATTR = "data-nav-col"
|
const COL_ATTR = "data-nav-col"
|
||||||
const ENTRY_COL_ATTR = "data-nav-entry-col"
|
const ENTRY_COL_ATTR = "data-nav-entry-col"
|
||||||
|
// Like ENTRY_COL_ATTR, but only applies when entering the row from above
|
||||||
|
// (ArrowDown). Lets a row declare a fixed landing column for downward entry
|
||||||
|
// without hijacking upward or horizontal moves.
|
||||||
|
const ENTRY_COL_FROM_ABOVE_ATTR = "data-nav-entry-col-from-above"
|
||||||
const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row"
|
const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row"
|
||||||
const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group"
|
const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group"
|
||||||
const DEFAULT_SYNC_SCROLL_GROUP = "browse"
|
const DEFAULT_SYNC_SCROLL_GROUP = "browse"
|
||||||
@@ -99,7 +103,10 @@ function measureGlobalMetrics(group: string): boolean {
|
|||||||
const rowStyle = window.getComputedStyle(row)
|
const rowStyle = window.getComputedStyle(row)
|
||||||
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
|
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
|
||||||
const viewportWidth = row.clientWidth
|
const viewportWidth = row.clientWidth
|
||||||
const deadzoneInset = Math.max(paddingLeft, (viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO)
|
const deadzoneInset = Math.max(
|
||||||
|
paddingLeft,
|
||||||
|
(viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO,
|
||||||
|
)
|
||||||
const leftDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_LEFT_DEADZONE_VAR).trim()
|
const leftDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_LEFT_DEADZONE_VAR).trim()
|
||||||
const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw))
|
const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw))
|
||||||
? Math.max(0, parseFloat(leftDeadzoneRaw))
|
? Math.max(0, parseFloat(leftDeadzoneRaw))
|
||||||
@@ -171,10 +178,7 @@ function getRowMaxScroll(row: HTMLElement): number {
|
|||||||
if (n === 0) return 0
|
if (n === 0) return 0
|
||||||
const lastCol = n - 1
|
const lastCol = n - 1
|
||||||
const lastItemLeft = m.paddingLeft + lastCol * m.stride
|
const lastItemLeft = m.paddingLeft + lastCol * m.stride
|
||||||
const maxVisibleLeft = Math.max(
|
const maxVisibleLeft = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
|
||||||
m.paddingLeft,
|
|
||||||
m.viewportWidth - m.cardWidth - m.rightDeadzone,
|
|
||||||
)
|
|
||||||
return Math.max(0, lastItemLeft - maxVisibleLeft)
|
return Math.max(0, lastItemLeft - maxVisibleLeft)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,14 +280,8 @@ function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null
|
|||||||
if (!m) return
|
if (!m) return
|
||||||
|
|
||||||
const itemLeft = m.paddingLeft + anchorCol * m.stride
|
const itemLeft = m.paddingLeft + anchorCol * m.stride
|
||||||
const leftVisibleLimit = Math.max(
|
const leftVisibleLimit = Math.max(m.paddingLeft, m.leftDeadzone)
|
||||||
m.paddingLeft,
|
const rightVisibleLimit = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
|
||||||
m.leftDeadzone,
|
|
||||||
)
|
|
||||||
const rightVisibleLimit = Math.max(
|
|
||||||
m.paddingLeft,
|
|
||||||
m.viewportWidth - m.cardWidth - m.rightDeadzone,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Keep focus inside the deadzone: no scroll while the focused item remains
|
// Keep focus inside the deadzone: no scroll while the focused item remains
|
||||||
// between left and right limits.
|
// between left and right limits.
|
||||||
@@ -350,7 +348,9 @@ function getLocalSyncedRowCol(
|
|||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
requestedCol: number,
|
requestedCol: number,
|
||||||
): number {
|
): number {
|
||||||
const cards = Array.from(anchorRow.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`))
|
const cards = Array.from(
|
||||||
|
anchorRow.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`),
|
||||||
|
)
|
||||||
if (cards.length === 0) return Math.max(0, requestedCol)
|
if (cards.length === 0) return Math.max(0, requestedCol)
|
||||||
|
|
||||||
const cardCols = cards
|
const cardCols = cards
|
||||||
@@ -462,9 +462,7 @@ function ensureElementVisibleVertically(element: HTMLElement) {
|
|||||||
// Element finding / navigation (unchanged logic, uses getMetrics() now)
|
// Element finding / navigation (unchanged logic, uses getMetrics() now)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function resolveOutOfBoundsNavigation(
|
function resolveOutOfBoundsNavigation(context: OutOfBoundsNavigationContext): HTMLElement | null {
|
||||||
context: OutOfBoundsNavigationContext,
|
|
||||||
): HTMLElement | null {
|
|
||||||
const handlers = Array.from(outOfBoundsHandlers)
|
const handlers = Array.from(outOfBoundsHandlers)
|
||||||
for (let i = handlers.length - 1; i >= 0; i--) {
|
for (let i = handlers.length - 1; i >= 0; i--) {
|
||||||
const result = handlers[i]?.(context)
|
const result = handlers[i]?.(context)
|
||||||
@@ -619,10 +617,7 @@ function findElementClosestToLogicalViewportX(
|
|||||||
return nearest
|
return nearest
|
||||||
}
|
}
|
||||||
|
|
||||||
function findNextElement(
|
function findNextElement(current: HTMLElement, direction: NavDirection): HTMLElement | null {
|
||||||
current: HTMLElement,
|
|
||||||
direction: NavDirection,
|
|
||||||
): HTMLElement | null {
|
|
||||||
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10)
|
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10)
|
||||||
const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10)
|
const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10)
|
||||||
const byRow = getElementsByRow()
|
const byRow = getElementsByRow()
|
||||||
@@ -672,8 +667,21 @@ function findNextElement(
|
|||||||
desiredCol.value = currentCol
|
desiredCol.value = currentCol
|
||||||
}
|
}
|
||||||
|
|
||||||
const entryTarget = findElementAt(targetRow, targetCol, true)
|
|
||||||
const targetRowElements = byRow.get(targetRow) ?? []
|
const targetRowElements = byRow.get(targetRow) ?? []
|
||||||
|
|
||||||
|
if (direction === "down") {
|
||||||
|
for (const el of targetRowElements) {
|
||||||
|
const fromAboveCol = el.element.getAttribute(ENTRY_COL_FROM_ABOVE_ATTR)
|
||||||
|
if (fromAboveCol === null) continue
|
||||||
|
const fromAboveTarget = targetRowElements.find((e) => e.col === parseInt(fromAboveCol, 10))
|
||||||
|
if (fromAboveTarget) {
|
||||||
|
desiredCol.value = fromAboveTarget.col
|
||||||
|
return fromAboveTarget.element
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryTarget = findElementAt(targetRow, targetCol, true)
|
||||||
const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR))
|
const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR))
|
||||||
if (hasEntryOverride) {
|
if (hasEntryOverride) {
|
||||||
return entryTarget?.element || null
|
return entryTarget?.element || null
|
||||||
|
|||||||
@@ -159,10 +159,7 @@ export function useMediaWebSocket() {
|
|||||||
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
|
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCastMember(
|
function normalizeCastMember(member: unknown, people: Map<number, Person>): CastMember {
|
||||||
member: unknown,
|
|
||||||
people: Map<number, Person>,
|
|
||||||
): CastMember {
|
|
||||||
if (!Array.isArray(member)) {
|
if (!Array.isArray(member)) {
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -346,7 +343,10 @@ export function useMediaWebSocket() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeItemsByHash<T extends MovieUi | SeriesUi>(items: T[], mergeFn: (a: T, b: T) => T): T[] {
|
function mergeItemsByHash<T extends MovieUi | SeriesUi>(
|
||||||
|
items: T[],
|
||||||
|
mergeFn: (a: T, b: T) => T,
|
||||||
|
): T[] {
|
||||||
const map = new Map<string, T[]>()
|
const map = new Map<string, T[]>()
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const hash = getContentHash(item.id)
|
const hash = getContentHash(item.id)
|
||||||
@@ -436,7 +436,14 @@ export function useMediaWebSocket() {
|
|||||||
connected.value = wsRef.value?.readyState === WebSocket.OPEN
|
connected.value = wsRef.value?.readyState === WebSocket.OPEN
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRootInit(rootId: string, rootData: { movies: Record<string, Movie>; series: Record<string, Series>; people?: Record<string, unknown> }) {
|
function applyRootInit(
|
||||||
|
rootId: string,
|
||||||
|
rootData: {
|
||||||
|
movies: Record<string, Movie>
|
||||||
|
series: Record<string, Series>
|
||||||
|
people?: Record<string, unknown>
|
||||||
|
},
|
||||||
|
) {
|
||||||
const state = ensureRootState(rootId)
|
const state = ensureRootState(rootId)
|
||||||
|
|
||||||
state.peopleMap.clear()
|
state.peopleMap.clear()
|
||||||
@@ -502,7 +509,10 @@ export function useMediaWebSocket() {
|
|||||||
const parsed = Number(id)
|
const parsed = Number(id)
|
||||||
const normalized = normalizePerson(person)
|
const normalized = normalizePerson(person)
|
||||||
if (Number.isFinite(parsed)) {
|
if (Number.isFinite(parsed)) {
|
||||||
state.peopleMap.set(parsed, normalized || { name: "", profile_path: null, gender: null })
|
state.peopleMap.set(
|
||||||
|
parsed,
|
||||||
|
normalized || { name: "", profile_path: null, gender: null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { TaskInfo } from "../types"
|
||||||
|
|
||||||
|
export type RootTaskInfo = TaskInfo & { root_id: string }
|
||||||
|
|
||||||
|
export interface ProgressRootState {
|
||||||
|
rootId: string
|
||||||
|
rootLabel: string
|
||||||
|
scanTarget: string | null
|
||||||
|
phaseLabel: string
|
||||||
|
phaseDetail: string | null
|
||||||
|
progressPercent: number
|
||||||
|
progressLabel: string | null
|
||||||
|
isDeterminate: boolean
|
||||||
|
toneClass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePosixPath(value: string): string {
|
||||||
|
return value.replace(/\\/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractScanPath(detail: string): string | null {
|
||||||
|
if (!detail.startsWith("Scanning:")) return null
|
||||||
|
let value = detail.replace(/^Scanning:\s*/i, "").trim()
|
||||||
|
value = value.replace(/\s*\(\d+\s+found\)\s*$/i, "").trim()
|
||||||
|
return value || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScanTarget(rootPath: string | null, detail: string): string | null {
|
||||||
|
const rawPath = extractScanPath(detail)
|
||||||
|
if (!rawPath) return null
|
||||||
|
|
||||||
|
const posixRaw = normalizePosixPath(rawPath)
|
||||||
|
const posixRoot = rootPath ? normalizePosixPath(rootPath) : null
|
||||||
|
|
||||||
|
if (posixRoot) {
|
||||||
|
const lowRaw = posixRaw.toLowerCase()
|
||||||
|
const lowRoot = posixRoot.toLowerCase()
|
||||||
|
if (lowRaw === lowRoot) return posixRoot
|
||||||
|
if (lowRaw.startsWith(`${lowRoot}/`)) {
|
||||||
|
return posixRaw.slice(posixRoot.length).replace(/^\/+/, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return posixRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeRootProgress(
|
||||||
|
rootId: string,
|
||||||
|
rootPath: string | null,
|
||||||
|
tasksForRoot: RootTaskInfo[],
|
||||||
|
isInitialScanMode: boolean,
|
||||||
|
): ProgressRootState | null {
|
||||||
|
const running = tasksForRoot.filter((task) => task.status === "running")
|
||||||
|
const latestError = [...tasksForRoot].reverse().find((task) => task.status === "error") || null
|
||||||
|
|
||||||
|
if (running.length === 0 && !latestError) return null
|
||||||
|
|
||||||
|
const scanTask = running.find((task) => task.id.startsWith("scan-")) || null
|
||||||
|
const showreelCount = running.filter((task) => task.id.startsWith("showreel-")).length
|
||||||
|
const otherRunningCount = running.length - (scanTask ? 1 : 0) - showreelCount
|
||||||
|
|
||||||
|
let phaseLabel = "Processing media"
|
||||||
|
let phaseDetail: string | null = null
|
||||||
|
let scanTarget: string | null = null
|
||||||
|
let isDeterminate = false
|
||||||
|
let progressPercent = 0
|
||||||
|
let progressLabel: string | null = null
|
||||||
|
let toneClass = ""
|
||||||
|
|
||||||
|
if (scanTask) {
|
||||||
|
const detail = (scanTask.detail || "").trim()
|
||||||
|
scanTarget = buildScanTarget(rootPath, detail)
|
||||||
|
if (detail.startsWith("Scanning:")) {
|
||||||
|
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
|
||||||
|
} else if (/^Processing\s+\d+\s+(items|movies|series)/i.test(detail)) {
|
||||||
|
phaseLabel = "Preparing titles"
|
||||||
|
} else if (/^(Starting scan|No new items|Done|Scan cancelled)/i.test(detail)) {
|
||||||
|
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
|
||||||
|
} else {
|
||||||
|
phaseLabel = "Fetching metadata"
|
||||||
|
phaseDetail = detail || null
|
||||||
|
}
|
||||||
|
if (scanTask.progress > 0 && scanTask.progress <= 1) {
|
||||||
|
isDeterminate = true
|
||||||
|
progressPercent = Math.max(1, Math.round(scanTask.progress * 100))
|
||||||
|
progressLabel = `${progressPercent}%`
|
||||||
|
}
|
||||||
|
} else if (showreelCount > 0) {
|
||||||
|
phaseLabel = "Generating previews"
|
||||||
|
} else if (otherRunningCount > 0) {
|
||||||
|
phaseLabel = "Finalizing updates"
|
||||||
|
} else if (latestError) {
|
||||||
|
phaseLabel = "Needs attention"
|
||||||
|
phaseDetail = latestError.detail || "A background task failed"
|
||||||
|
toneClass = "activity-root-error"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootId,
|
||||||
|
rootLabel: rootId,
|
||||||
|
scanTarget,
|
||||||
|
phaseLabel,
|
||||||
|
phaseDetail,
|
||||||
|
progressPercent,
|
||||||
|
progressLabel,
|
||||||
|
isDeterminate,
|
||||||
|
toneClass,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeProgressRoots(
|
||||||
|
tasks: Iterable<RootTaskInfo>,
|
||||||
|
getRootPath: (rootId: string) => string | null,
|
||||||
|
isInitialScanMode: boolean,
|
||||||
|
): ProgressRootState[] {
|
||||||
|
const byRoot = new Map<string, RootTaskInfo[]>()
|
||||||
|
for (const task of tasks) {
|
||||||
|
const list = byRoot.get(task.root_id) || []
|
||||||
|
list.push(task)
|
||||||
|
byRoot.set(task.root_id, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: ProgressRootState[] = []
|
||||||
|
for (const [rootId, rootTasks] of byRoot) {
|
||||||
|
const row = describeRootProgress(rootId, getRootPath(rootId), rootTasks, isInitialScanMode)
|
||||||
|
if (row) rows.push(row)
|
||||||
|
}
|
||||||
|
return rows.sort((a, b) => a.rootLabel.localeCompare(b.rootLabel))
|
||||||
|
}
|
||||||
@@ -17,9 +17,9 @@ const STORAGE_KEY = "MediaHive"
|
|||||||
const RESOLUTION_PRIORITY: Record<string, number> = {
|
const RESOLUTION_PRIORITY: Record<string, number> = {
|
||||||
"8K": 5,
|
"8K": 5,
|
||||||
"4K": 4,
|
"4K": 4,
|
||||||
"FHD": 3,
|
FHD: 3,
|
||||||
"HD": 2,
|
HD: 2,
|
||||||
"SD": 1,
|
SD: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max resolution priority allowed for each preference level
|
// Max resolution priority allowed for each preference level
|
||||||
@@ -59,7 +59,13 @@ function loadSettings(): MediaHiveSettings {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
return { preferredResolution: "rmax", preferredHdr: "none", playerId: "default", playerCustomCmd: null, playerMpcPort: null }
|
return {
|
||||||
|
preferredResolution: "rmax",
|
||||||
|
preferredHdr: "none",
|
||||||
|
playerId: "default",
|
||||||
|
playerCustomCmd: null,
|
||||||
|
playerMpcPort: null,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = reactive<MediaHiveSettings>(loadSettings())
|
const settings = reactive<MediaHiveSettings>(loadSettings())
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
// Settings is an overlay, not a route: opening it must not change the URL or
|
||||||
|
// the view behind it, so the open state is plain shared local state.
|
||||||
|
const settingsOpen = ref(false)
|
||||||
|
|
||||||
|
export function useSettingsOpen() {
|
||||||
|
return settingsOpen
|
||||||
|
}
|
||||||
@@ -6,6 +6,38 @@ import { installKeyboardNavigation } from "./composables/useKeyboardNavigation"
|
|||||||
import { installGamepadNavigation } from "./composables/useGamepadNavigation"
|
import { installGamepadNavigation } from "./composables/useGamepadNavigation"
|
||||||
import { installInputModalityTracking } from "./composables/useInputModality"
|
import { installInputModalityTracking } from "./composables/useInputModality"
|
||||||
|
|
||||||
|
function postClientError(payload: {
|
||||||
|
message: string
|
||||||
|
stack: string | null
|
||||||
|
source: string | null
|
||||||
|
}) {
|
||||||
|
fetch("/api/client-log", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function installErrorCapture() {
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
const source =
|
||||||
|
event.filename != null ? `${event.filename}:${event.lineno ?? 0}:${event.colno ?? 0}` : null
|
||||||
|
postClientError({
|
||||||
|
message: event.message || String(event.error ?? "Unknown error"),
|
||||||
|
stack: event.error?.stack ?? null,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
const reason = event.reason
|
||||||
|
postClientError({
|
||||||
|
message: reason instanceof Error ? reason.message : `Unhandled rejection: ${String(reason)}`,
|
||||||
|
stack: reason instanceof Error ? (reason.stack ?? null) : null,
|
||||||
|
source: "unhandledrejection",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function installReloadShortcut() {
|
function installReloadShortcut() {
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
"keydown",
|
"keydown",
|
||||||
@@ -27,6 +59,7 @@ installInputModalityTracking()
|
|||||||
installKeyboardNavigation()
|
installKeyboardNavigation()
|
||||||
installGamepadNavigation()
|
installGamepadNavigation()
|
||||||
installReloadShortcut()
|
installReloadShortcut()
|
||||||
|
installErrorCapture()
|
||||||
|
|
||||||
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
|
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ const router = createRouter({
|
|||||||
component: EmptyRouteComponent,
|
component: EmptyRouteComponent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Settings is now an overlay with no URL of its own; keep old links working.
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
name: "settings",
|
redirect: "/movies",
|
||||||
component: EmptyRouteComponent,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/series",
|
path: "/series",
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
// Search Web Worker - runs search off the main thread
|
// Search Web Worker - runs search off the main thread
|
||||||
// This file is loaded as a Web Worker, not imported as a module.
|
// This file is loaded as a Web Worker, not imported as a module.
|
||||||
|
|
||||||
import type {
|
import type { MovieUi, SeriesUi, MatchedPerson, MatchedEpisode, SearchMatchInfo } from "./types"
|
||||||
MovieUi,
|
|
||||||
SeriesUi,
|
|
||||||
MatchedPerson,
|
|
||||||
MatchedEpisode,
|
|
||||||
SearchMatchInfo,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Message types
|
// Message types
|
||||||
@@ -65,7 +59,7 @@ let series: SeriesUi[] = []
|
|||||||
function normalizeSearchText(value: string): string {
|
function normalizeSearchText(value: string): string {
|
||||||
return value
|
return value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9\-]+/g, " ")
|
.replace(/[^a-z0-9-]+/g, " ")
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
}
|
}
|
||||||
@@ -277,7 +271,9 @@ function getMatchedWordIndexes(queryWords: string[], value: string): number[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void {
|
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void {
|
||||||
const existing = target.find((person) => person.name.toLowerCase() === candidate.name.toLowerCase())
|
const existing = target.find(
|
||||||
|
(person) => person.name.toLowerCase() === candidate.name.toLowerCase(),
|
||||||
|
)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
|
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
|
||||||
if (candidate.highlightRoles) existing.highlightRoles = true
|
if (candidate.highlightRoles) existing.highlightRoles = true
|
||||||
@@ -292,9 +288,7 @@ function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
|
function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
|
||||||
const sorted = indexes
|
const sorted = indexes.filter((index) => availableIndexes.has(index)).sort((a, b) => a - b)
|
||||||
.filter((index) => availableIndexes.has(index))
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
|
|
||||||
if (sorted.length === 0) return []
|
if (sorted.length === 0) return []
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ html.mouse-active ::-webkit-scrollbar-thumb:hover {
|
|||||||
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Leave room for the fixed app-exit button in desktop (pywebview) mode. */
|
||||||
|
.header--gui {
|
||||||
|
padding-right: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.header::before {
|
.header::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export interface Torrent {
|
|||||||
audio: string | null
|
audio: string | null
|
||||||
audio_languages: string[] | null
|
audio_languages: string[] | null
|
||||||
subtitle_languages: string[] | null
|
subtitle_languages: string[] | null
|
||||||
|
external_subtitle_languages?: string[] | null
|
||||||
hdr?: boolean
|
hdr?: boolean
|
||||||
dovi?: boolean
|
dovi?: boolean
|
||||||
atmos?: boolean
|
atmos?: boolean
|
||||||
|
|||||||
@@ -16,17 +16,18 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
|
|||||||
// Spanish (including LATAM variants collapsed to Spain flag)
|
// Spanish (including LATAM variants collapsed to Spain flag)
|
||||||
es: "ES",
|
es: "ES",
|
||||||
spa: "ES",
|
spa: "ES",
|
||||||
|
esp: "ES",
|
||||||
esl: "ES",
|
esl: "ES",
|
||||||
spl: "ES",
|
spl: "ES",
|
||||||
"es-es": "ES",
|
"es-es": "ES",
|
||||||
"es-419": "ES",
|
"es-419": "ES",
|
||||||
"spa-la": "ES",
|
"spa-la": "ES",
|
||||||
|
|
||||||
// Portuguese
|
// Portuguese (Brazilian variant collapses to Portugal flag)
|
||||||
pt: "PT",
|
pt: "PT",
|
||||||
por: "PT",
|
por: "PT",
|
||||||
"pt-pt": "PT",
|
"pt-pt": "PT",
|
||||||
"pt-br": "BR",
|
"pt-br": "PT",
|
||||||
|
|
||||||
// Major European languages
|
// Major European languages
|
||||||
fr: "FR",
|
fr: "FR",
|
||||||
@@ -49,7 +50,7 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
|
|||||||
fi: "FI",
|
fi: "FI",
|
||||||
fin: "FI",
|
fin: "FI",
|
||||||
pl: "PL",
|
pl: "PL",
|
||||||
पोल: "PL",
|
pol: "PL",
|
||||||
cs: "CZ",
|
cs: "CZ",
|
||||||
ces: "CZ",
|
ces: "CZ",
|
||||||
cze: "CZ",
|
cze: "CZ",
|
||||||
@@ -121,6 +122,113 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
|
|||||||
eu: "ES",
|
eu: "ES",
|
||||||
baq: "ES",
|
baq: "ES",
|
||||||
eus: "ES",
|
eus: "ES",
|
||||||
|
gl: "ES",
|
||||||
|
glg: "ES",
|
||||||
|
|
||||||
|
// Additional ISO 639-2 codes (bibliographic + terminology)
|
||||||
|
mk: "MK",
|
||||||
|
mkd: "MK",
|
||||||
|
mac: "MK",
|
||||||
|
et: "EE",
|
||||||
|
est: "EE",
|
||||||
|
lv: "LV",
|
||||||
|
lav: "LV",
|
||||||
|
lt: "LT",
|
||||||
|
lit: "LT",
|
||||||
|
is: "IS",
|
||||||
|
isl: "IS",
|
||||||
|
ice: "IS",
|
||||||
|
ga: "IE",
|
||||||
|
gle: "IE",
|
||||||
|
cy: "GB",
|
||||||
|
cym: "GB",
|
||||||
|
wel: "GB",
|
||||||
|
gd: "GB",
|
||||||
|
gla: "GB",
|
||||||
|
mt: "MT",
|
||||||
|
mlt: "MT",
|
||||||
|
sq: "AL",
|
||||||
|
sqi: "AL",
|
||||||
|
alb: "AL",
|
||||||
|
be: "BY",
|
||||||
|
bel: "BY",
|
||||||
|
bs: "BA",
|
||||||
|
bos: "BA",
|
||||||
|
scc: "RS",
|
||||||
|
scr: "HR",
|
||||||
|
nb: "NO",
|
||||||
|
nob: "NO",
|
||||||
|
nn: "NO",
|
||||||
|
nno: "NO",
|
||||||
|
kk: "KZ",
|
||||||
|
kaz: "KZ",
|
||||||
|
az: "AZ",
|
||||||
|
aze: "AZ",
|
||||||
|
hy: "AM",
|
||||||
|
hye: "AM",
|
||||||
|
arm: "AM",
|
||||||
|
ka: "GE",
|
||||||
|
kat: "GE",
|
||||||
|
geo: "GE",
|
||||||
|
uz: "UZ",
|
||||||
|
uzb: "UZ",
|
||||||
|
tk: "TM",
|
||||||
|
tuk: "TM",
|
||||||
|
tg: "TJ",
|
||||||
|
tgk: "TJ",
|
||||||
|
ky: "KG",
|
||||||
|
kir: "KG",
|
||||||
|
mn: "MN",
|
||||||
|
mon: "MN",
|
||||||
|
bo: "CN",
|
||||||
|
bod: "CN",
|
||||||
|
tib: "CN",
|
||||||
|
my: "MM",
|
||||||
|
mya: "MM",
|
||||||
|
bur: "MM",
|
||||||
|
km: "KH",
|
||||||
|
khm: "KH",
|
||||||
|
lo: "LA",
|
||||||
|
lao: "LA",
|
||||||
|
si: "LK",
|
||||||
|
sin: "LK",
|
||||||
|
ne: "NP",
|
||||||
|
nep: "NP",
|
||||||
|
bn: "BD",
|
||||||
|
ben: "BD",
|
||||||
|
ta: "IN",
|
||||||
|
tam: "IN",
|
||||||
|
te: "IN",
|
||||||
|
tel: "IN",
|
||||||
|
kn: "IN",
|
||||||
|
kan: "IN",
|
||||||
|
ml: "IN",
|
||||||
|
mal: "IN",
|
||||||
|
mr: "IN",
|
||||||
|
mar: "IN",
|
||||||
|
gu: "IN",
|
||||||
|
guj: "IN",
|
||||||
|
pa: "IN",
|
||||||
|
pan: "IN",
|
||||||
|
tl: "PH",
|
||||||
|
tgl: "PH",
|
||||||
|
fil: "PH",
|
||||||
|
af: "ZA",
|
||||||
|
afr: "ZA",
|
||||||
|
am: "ET",
|
||||||
|
amh: "ET",
|
||||||
|
so: "SO",
|
||||||
|
som: "SO",
|
||||||
|
ha: "NG",
|
||||||
|
hau: "NG",
|
||||||
|
yo: "NG",
|
||||||
|
yor: "NG",
|
||||||
|
ig: "NG",
|
||||||
|
ibo: "NG",
|
||||||
|
ku: "TR",
|
||||||
|
kur: "TR",
|
||||||
|
ps: "AF",
|
||||||
|
pus: "AF",
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLanguageCode(code: string): string {
|
function normalizeLanguageCode(code: string): string {
|
||||||
@@ -265,9 +373,13 @@ export function mapLanguageToCountry(code: string): string | null {
|
|||||||
const direct = LANGUAGE_TO_COUNTRY[normalized]
|
const direct = LANGUAGE_TO_COUNTRY[normalized]
|
||||||
if (direct) return direct
|
if (direct) return direct
|
||||||
|
|
||||||
// region-tag style code like en-us / pt-br / es-mx
|
// region-tag style code like en-us / pt-br / es-mx: variants collapse to
|
||||||
|
// the base language's host-country flag; only fall back to the region
|
||||||
|
// itself when the base language is unmapped.
|
||||||
const hyphenParts = normalized.split("-")
|
const hyphenParts = normalized.split("-")
|
||||||
if (hyphenParts.length >= 2) {
|
if (hyphenParts.length >= 2) {
|
||||||
|
const base = LANGUAGE_TO_COUNTRY[hyphenParts[0]]
|
||||||
|
if (base) return base
|
||||||
const region = hyphenParts[hyphenParts.length - 1]
|
const region = hyphenParts[hyphenParts.length - 1]
|
||||||
if (/^[a-z]{2}$/i.test(region)) {
|
if (/^[a-z]{2}$/i.test(region)) {
|
||||||
return region.toUpperCase()
|
return region.toUpperCase()
|
||||||
@@ -341,10 +453,15 @@ export function buildLanguageFlags(codes: string[] | null | undefined): {
|
|||||||
const LANGUAGE_NAME_OVERRIDES: Record<string, string> = {
|
const LANGUAGE_NAME_OVERRIDES: Record<string, string> = {
|
||||||
eng: "English",
|
eng: "English",
|
||||||
spa: "Spanish",
|
spa: "Spanish",
|
||||||
|
esp: "Spanish",
|
||||||
"spa-la": "Spanish",
|
"spa-la": "Spanish",
|
||||||
|
"es-419": "Spanish",
|
||||||
esl: "Spanish",
|
esl: "Spanish",
|
||||||
spl: "Spanish",
|
spl: "Spanish",
|
||||||
por: "Portuguese",
|
por: "Portuguese",
|
||||||
|
"pt-br": "Portuguese",
|
||||||
|
nob: "Norwegian",
|
||||||
|
nno: "Norwegian",
|
||||||
fre: "French",
|
fre: "French",
|
||||||
fra: "French",
|
fra: "French",
|
||||||
ger: "German",
|
ger: "German",
|
||||||
@@ -431,6 +548,52 @@ function summarizeLanguageCodes(codes: string[] | null | undefined): string {
|
|||||||
return names.join(", ")
|
return names.join(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REGION_NAME_OVERRIDES: Record<string, string> = {
|
||||||
|
GB: "UK",
|
||||||
|
US: "US",
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRegionName(countryCode: string): string {
|
||||||
|
const override = REGION_NAME_OVERRIDES[countryCode]
|
||||||
|
if (override) return override
|
||||||
|
const display = new Intl.DisplayNames(["en"], { type: "region" })
|
||||||
|
return display.of(countryCode) ?? countryCode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLanguageFlagTitle(
|
||||||
|
entry: LanguageFlagEntry,
|
||||||
|
externalCodes?: string[] | null,
|
||||||
|
): string {
|
||||||
|
const names: string[] = []
|
||||||
|
const variants: string[] = []
|
||||||
|
const external = new Set((externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)))
|
||||||
|
let hasExternal = false
|
||||||
|
for (const code of entry.sourceCodes) {
|
||||||
|
const normalized = resolveLanguageIdentifier(code)
|
||||||
|
const base = normalized.split("-", 1)[0]
|
||||||
|
const name = toLanguageName(base)
|
||||||
|
if (!names.includes(name)) names.push(name)
|
||||||
|
// 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 regionName = region ? toRegionName(region) : null
|
||||||
|
const variant = external.has(normalized)
|
||||||
|
? regionName
|
||||||
|
? `${regionName} srt`
|
||||||
|
: "srt"
|
||||||
|
: regionName
|
||||||
|
if (variant && !variants.includes(variant)) variants.push(variant)
|
||||||
|
if (external.has(normalized)) hasExternal = true
|
||||||
|
}
|
||||||
|
const title = names.join(" / ")
|
||||||
|
if (variants.length > 1 || hasExternal) return `${title} (${variants.join(", ")})`
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAudioSubtitleSummary(
|
export function formatAudioSubtitleSummary(
|
||||||
audioCodes: string[] | null | undefined,
|
audioCodes: string[] | null | undefined,
|
||||||
subtitleCodes: string[] | null | undefined,
|
subtitleCodes: string[] | null | undefined,
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
* - Disables Vite's screen clearing on startup
|
* - Disables Vite's screen clearing on startup
|
||||||
*
|
*
|
||||||
* Options:
|
* Options:
|
||||||
* paths - Array of paths to proxy (default: ["/api"])
|
* paths - Array of paths to proxy (default: ['/api'])
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
export default function fastapiVue({ paths = ['/api'] } = {}) {
|
||||||
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8421"
|
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || 'http://localhost:8421'
|
||||||
|
|
||||||
// Build proxy configuration for each path
|
// Build proxy configuration for each path
|
||||||
const proxy = {}
|
const proxy = {}
|
||||||
@@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "vite-plugin-fastapi-mediahive",
|
name: 'vite-plugin-fastapi-mediahive',
|
||||||
config: () => ({
|
config: () => ({
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: { proxy },
|
server: { proxy },
|
||||||
build: {
|
build: {
|
||||||
outDir: "../mediahive/frontend-build",
|
outDir: '../mediahive/frontend-build',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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
@@ -1,16 +1,20 @@
|
|||||||
"""MediaHive CLI entrypoint."""
|
"""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 argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi_vue import server
|
from fastapi_vue import env, server
|
||||||
|
|
||||||
|
from mediahive.config import config
|
||||||
|
|
||||||
DEFAULT_PORT = 8420
|
DEFAULT_PORT = 8420
|
||||||
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_windows_event_loop_policy() -> None:
|
def _configure_windows_event_loop_policy() -> None:
|
||||||
@@ -140,10 +144,11 @@ def main() -> None:
|
|||||||
name = f"{base_name}{suffix}"
|
name = f"{base_name}{suffix}"
|
||||||
suffix += 1
|
suffix += 1
|
||||||
roots[name] = p.as_posix()
|
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 (
|
if (
|
||||||
DEVMODE
|
env.dev
|
||||||
and sys.platform == "win32"
|
and sys.platform == "win32"
|
||||||
and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1"
|
and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1"
|
||||||
):
|
):
|
||||||
@@ -156,7 +161,12 @@ def main() -> None:
|
|||||||
default_port=DEFAULT_PORT,
|
default_port=DEFAULT_PORT,
|
||||||
server_header=False,
|
server_header=False,
|
||||||
loop="none" if sys.platform == "win32" else "auto",
|
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"}}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Welcome to the MediaHive installer.
|
||||||
|
|
||||||
|
During installation and on first launch, macOS may ask you to allow
|
||||||
|
permissions (for example, access to your media folders or the local
|
||||||
|
network). Please allow these so MediaHive can find and play your media.
|
||||||
|
|
||||||
|
Click Continue to begin.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
+25
-28
@@ -1,57 +1,54 @@
|
|||||||
r"""Platform-appropriate config persistence for MediaHive.
|
r"""Platform-appropriate config persistence for MediaHive.
|
||||||
|
|
||||||
Config file location:
|
Locations (via platformdirs):
|
||||||
Windows: %APPDATA%\mediahive\config.toml
|
Config — Windows: %LOCALAPPDATA%\mediahive\config.toml
|
||||||
macOS: ~/Library/Application Support/mediahive/config.toml
|
macOS: ~/Library/Application Support/mediahive/config.toml
|
||||||
Linux: $XDG_CONFIG_HOME/mediahive/config.toml (~/.config/mediahive/config.toml)
|
Linux: $XDG_CONFIG_HOME/mediahive/config.toml
|
||||||
|
Logs — Windows: %LOCALAPPDATA%\mediahive\mediahive.log
|
||||||
|
macOS: ~/Library/Logs/mediahive/mediahive.log
|
||||||
|
Linux: $XDG_STATE_HOME/mediahive/mediahive.log
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
import msgspec.toml
|
import msgspec.toml
|
||||||
|
from fastapi_vue import env
|
||||||
|
from platformdirs import user_config_path, user_log_path
|
||||||
|
|
||||||
|
|
||||||
class Config(msgspec.Struct, omit_defaults=True):
|
class Config(msgspec.Struct, omit_defaults=True):
|
||||||
media_folder: str | None = None
|
|
||||||
roots: dict[str, str] | None = None
|
roots: dict[str, str] | None = None
|
||||||
|
auto_update: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
# 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:
|
def config_dir() -> Path:
|
||||||
if sys.platform == "win32":
|
# appauthor=False: avoid the doubled %LOCALAPPDATA%\mediahive\mediahive.
|
||||||
base = Path(os.environ.get("APPDATA") or Path.home())
|
# roaming=False: config is machine-specific state, not something to sync
|
||||||
elif sys.platform == "darwin":
|
# across a domain profile.
|
||||||
base = Path.home() / "Library" / "Application Support"
|
return user_config_path("mediahive", appauthor=False, roaming=False)
|
||||||
else:
|
|
||||||
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
|
||||||
return base / "mediahive"
|
def log_dir() -> Path:
|
||||||
|
# opinion=False: no extra Logs/ subdir; mediahive.log sits beside config.
|
||||||
|
return user_log_path("mediahive", appauthor=False, opinion=False)
|
||||||
|
|
||||||
|
|
||||||
def config_path() -> Path:
|
def config_path() -> Path:
|
||||||
return config_dir() / "config.toml"
|
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:
|
def load_config() -> Config:
|
||||||
path = config_path()
|
path = config_path()
|
||||||
if path.exists():
|
if path.exists():
|
||||||
try:
|
try:
|
||||||
cfg = msgspec.toml.decode(path.read_bytes(), type=Config)
|
return msgspec.toml.decode(path.read_bytes(), type=Config)
|
||||||
return _migrate_legacy_media_folder(cfg)
|
|
||||||
except OSError, msgspec.DecodeError, msgspec.ValidationError:
|
except OSError, msgspec.DecodeError, msgspec.ValidationError:
|
||||||
return Config()
|
return Config()
|
||||||
return Config()
|
return Config()
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
"""Hivescan CLI entrypoint."""
|
"""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 argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mediahive.config import config
|
||||||
|
|
||||||
|
|
||||||
def _configure_windows_event_loop_policy() -> None:
|
def _configure_windows_event_loop_policy() -> None:
|
||||||
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
|
"""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()
|
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()
|
media_root = Path(args.media_folder).expanduser()
|
||||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps({
|
config.roots = {media_root.name or "media": media_root.as_posix()}
|
||||||
media_root.name or "media": media_root.as_posix()
|
|
||||||
})
|
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from mediahive.hivescan.models import ContentType, ParsedContent
|
|||||||
from mediahive.hivescan.scanning import (
|
from mediahive.hivescan.scanning import (
|
||||||
find_cover_image,
|
find_cover_image,
|
||||||
find_episode_files,
|
find_episode_files,
|
||||||
|
find_external_subtitle_languages,
|
||||||
find_metadata_probe_file,
|
find_metadata_probe_file,
|
||||||
find_playable_file,
|
find_playable_file,
|
||||||
)
|
)
|
||||||
@@ -61,6 +62,18 @@ def _infer_hdr10plus(*values: str | None) -> bool:
|
|||||||
return bool(_HDR10PLUS_RE.search(text))
|
return bool(_HDR10PLUS_RE.search(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_subtitle_languages(
|
||||||
|
probed: list[str] | None,
|
||||||
|
external: list[str],
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""Union embedded subtitle languages with sidecar-subtitle languages."""
|
||||||
|
merged = list(probed or [])
|
||||||
|
for lang in external:
|
||||||
|
if lang not in merged:
|
||||||
|
merged.append(lang)
|
||||||
|
return merged or None
|
||||||
|
|
||||||
|
|
||||||
def _compact_playable_file(file_key: str, playable_file: str | None) -> str | None:
|
def _compact_playable_file(file_key: str, playable_file: str | None) -> str | None:
|
||||||
"""Store playable paths compactly relative to the file key when possible."""
|
"""Store playable paths compactly relative to the file key when possible."""
|
||||||
if not playable_file:
|
if not playable_file:
|
||||||
@@ -99,6 +112,7 @@ async def _build_torrent_info(
|
|||||||
probe_target = await find_metadata_probe_file(playable_file)
|
probe_target = await find_metadata_probe_file(playable_file)
|
||||||
if probe_target:
|
if probe_target:
|
||||||
probe_info = await probe_media_info(str(probe_target))
|
probe_info = await probe_media_info(str(probe_target))
|
||||||
|
external_subs = await find_external_subtitle_languages(playable_file)
|
||||||
|
|
||||||
if item.content_hash and item.content_hash.size == 0:
|
if item.content_hash and item.content_hash.size == 0:
|
||||||
item.content_hash.size = await asyncio.to_thread(
|
item.content_hash.size = await asyncio.to_thread(
|
||||||
@@ -125,7 +139,11 @@ async def _build_torrent_info(
|
|||||||
codec=item.codec,
|
codec=item.codec,
|
||||||
audio=item.audio,
|
audio=item.audio,
|
||||||
audio_languages=probe_info.audio_languages if probe_info else None,
|
audio_languages=probe_info.audio_languages if probe_info else None,
|
||||||
subtitle_languages=probe_info.subtitle_languages if probe_info else None,
|
subtitle_languages=_merge_subtitle_languages(
|
||||||
|
probe_info.subtitle_languages if probe_info else None,
|
||||||
|
external_subs,
|
||||||
|
),
|
||||||
|
external_subtitle_languages=external_subs or None,
|
||||||
hdr=probe_info.hdr if probe_info else False,
|
hdr=probe_info.hdr if probe_info else False,
|
||||||
dovi=probe_info.dovi if probe_info else False,
|
dovi=probe_info.dovi if probe_info else False,
|
||||||
atmos=probe_info.atmos if probe_info else False,
|
atmos=probe_info.atmos if probe_info else False,
|
||||||
@@ -207,12 +225,16 @@ async def _collect_episode_files(
|
|||||||
all_episode_files[key] = []
|
all_episode_files[key] = []
|
||||||
for file_path, file_size in files:
|
for file_path, file_size in files:
|
||||||
probe = await get_probe(file_path)
|
probe = await get_probe(file_path)
|
||||||
|
external_subs = await find_external_subtitle_languages(file_path)
|
||||||
all_episode_files[key].append({
|
all_episode_files[key].append({
|
||||||
"path": file_path,
|
"path": file_path,
|
||||||
"size": file_size,
|
"size": file_size,
|
||||||
"probed_resolution": probe.resolution,
|
"probed_resolution": probe.resolution,
|
||||||
"audio_languages": probe.audio_languages,
|
"audio_languages": probe.audio_languages,
|
||||||
"subtitle_languages": probe.subtitle_languages,
|
"subtitle_languages": _merge_subtitle_languages(
|
||||||
|
probe.subtitle_languages, external_subs
|
||||||
|
),
|
||||||
|
"external_subtitle_languages": external_subs or None,
|
||||||
"hdr": probe.hdr,
|
"hdr": probe.hdr,
|
||||||
"dovi": probe.dovi,
|
"dovi": probe.dovi,
|
||||||
"atmos": probe.atmos,
|
"atmos": probe.atmos,
|
||||||
@@ -255,12 +277,18 @@ async def _collect_episode_files(
|
|||||||
item.content_hash.path,
|
item.content_hash.path,
|
||||||
)
|
)
|
||||||
size = item.content_hash.size if item.content_hash else 0
|
size = item.content_hash.size if item.content_hash else 0
|
||||||
|
external_subs = await find_external_subtitle_languages(
|
||||||
|
playable
|
||||||
|
)
|
||||||
all_episode_files[key].append({
|
all_episode_files[key].append({
|
||||||
"path": playable,
|
"path": playable,
|
||||||
"size": size,
|
"size": size,
|
||||||
"probed_resolution": probe.resolution,
|
"probed_resolution": probe.resolution,
|
||||||
"audio_languages": probe.audio_languages,
|
"audio_languages": probe.audio_languages,
|
||||||
"subtitle_languages": probe.subtitle_languages,
|
"subtitle_languages": _merge_subtitle_languages(
|
||||||
|
probe.subtitle_languages, external_subs
|
||||||
|
),
|
||||||
|
"external_subtitle_languages": external_subs or None,
|
||||||
"hdr": probe.hdr,
|
"hdr": probe.hdr,
|
||||||
"dovi": probe.dovi,
|
"dovi": probe.dovi,
|
||||||
"atmos": probe.atmos,
|
"atmos": probe.atmos,
|
||||||
@@ -343,6 +371,7 @@ def _build_episodes_data(
|
|||||||
audio=f.get("audio"),
|
audio=f.get("audio"),
|
||||||
audio_languages=f.get("audio_languages"),
|
audio_languages=f.get("audio_languages"),
|
||||||
subtitle_languages=f.get("subtitle_languages"),
|
subtitle_languages=f.get("subtitle_languages"),
|
||||||
|
external_subtitle_languages=f.get("external_subtitle_languages"),
|
||||||
hdr=bool(f.get("hdr")),
|
hdr=bool(f.get("hdr")),
|
||||||
dovi=bool(f.get("dovi")),
|
dovi=bool(f.get("dovi")),
|
||||||
atmos=bool(f.get("atmos")),
|
atmos=bool(f.get("atmos")),
|
||||||
|
|||||||
@@ -28,6 +28,44 @@ VIDEO_EXTENSIONS = {
|
|||||||
".m2ts",
|
".m2ts",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# External subtitle file extensions
|
||||||
|
SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub"}
|
||||||
|
|
||||||
|
# Non-language tokens that may follow the language in a sidecar filename
|
||||||
|
_SUBTITLE_FLAG_TOKENS = {"forced", "sdh", "cc", "hi", "dhi", "commentary", "signs"}
|
||||||
|
|
||||||
|
# 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",
|
||||||
|
}
|
||||||
|
|
||||||
# Caches for expensive operations. These are per-scan only: the scanner
|
# Caches for expensive operations. These are per-scan only: the scanner
|
||||||
# clears them at the start of every scan. Caching across scans is wrong —
|
# clears them at the start of every scan. Caching across scans is wrong —
|
||||||
# an empty result recorded before a download finished (or during a transient
|
# an empty result recorded before a download finished (or during a transient
|
||||||
@@ -322,6 +360,56 @@ async def find_playable_file(path: Path) -> str | None:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_subtitle_language(video_stem: str, filename: str) -> str | None:
|
||||||
|
"""Language tag from a sidecar subtitle name like `<stem>.esp.srt`, if any."""
|
||||||
|
if not filename.startswith(video_stem + "."):
|
||||||
|
return None
|
||||||
|
suffix = Path(filename).suffix.lower()
|
||||||
|
if suffix not in SUBTITLE_EXTENSIONS:
|
||||||
|
return None
|
||||||
|
middle = filename[len(video_stem) + 1 : -len(suffix)]
|
||||||
|
tokens = [t for t in middle.split(".") if t]
|
||||||
|
while tokens and tokens[-1].lower() in _SUBTITLE_FLAG_TOKENS:
|
||||||
|
tokens.pop()
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
code = tokens[-1].lower()
|
||||||
|
if not code.isalpha() or not 2 <= len(code) <= 3:
|
||||||
|
return None
|
||||||
|
code = _ISO_639_1_TO_639_2.get(code, code)
|
||||||
|
return None if code == "und" else code
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_external_subtitle_languages(video_path: Path) -> list[str]:
|
||||||
|
languages: list[str] = []
|
||||||
|
with os.scandir(video_path.parent) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.is_file(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
lang = _sidecar_subtitle_language(video_path.stem, entry.name)
|
||||||
|
if lang and lang not in languages:
|
||||||
|
languages.append(lang)
|
||||||
|
return languages
|
||||||
|
|
||||||
|
|
||||||
|
async def find_external_subtitle_languages(video_path: str | None) -> list[str]:
|
||||||
|
"""Languages of external subtitle files sitting next to a video file.
|
||||||
|
|
||||||
|
Matches sidecars named `<stem>.<lang>.<ext>` (e.g. `Movie.esp.srt` ->
|
||||||
|
``esp``), optionally with flags like ``forced``/``sdh`` after the language.
|
||||||
|
Bare `<stem>.<ext>` files carry no language tag and are ignored.
|
||||||
|
"""
|
||||||
|
if not video_path or "://" in video_path or video_path.startswith("concat:"):
|
||||||
|
return []
|
||||||
|
path = Path(video_path)
|
||||||
|
if path.suffix.lower() not in VIDEO_EXTENSIONS:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_scan_external_subtitle_languages, path)
|
||||||
|
except OSError, PermissionError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def find_metadata_probe_file(playable_path: str | None) -> str | None:
|
async def find_metadata_probe_file(playable_path: str | None) -> str | None:
|
||||||
"""Resolve a path suitable for ffmpeg stream metadata probing.
|
"""Resolve a path suitable for ffmpeg stream metadata probing.
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class Torrent(msgspec.Struct, omit_defaults=True):
|
|||||||
audio: str | None = None
|
audio: str | None = None
|
||||||
audio_languages: list[str] | None = None
|
audio_languages: list[str] | None = None
|
||||||
subtitle_languages: list[str] | None = None
|
subtitle_languages: list[str] | None = None
|
||||||
|
external_subtitle_languages: list[str] | None = None
|
||||||
hdr: bool = False
|
hdr: bool = False
|
||||||
dovi: bool = False
|
dovi: bool = False
|
||||||
atmos: bool = False
|
atmos: bool = False
|
||||||
|
|||||||
+125
-19
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import importlib.metadata
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -28,13 +29,19 @@ from pathlib import Path
|
|||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import msgspec
|
import msgspec
|
||||||
|
import msgspec.structs
|
||||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
from fastapi.responses import (
|
||||||
from fastapi_vue import Frontend
|
FileResponse,
|
||||||
|
PlainTextResponse,
|
||||||
|
Response,
|
||||||
|
StreamingResponse,
|
||||||
|
)
|
||||||
|
from fastapi_vue import Frontend, env
|
||||||
|
|
||||||
from mediahive.__main__ import DEVMODE
|
from mediahive import updater
|
||||||
from mediahive.config import load_config
|
from mediahive.config import config, load_config, log_dir, save_config
|
||||||
from mediahive.hivescan.images import close_image_client
|
from mediahive.hivescan.images import close_image_client
|
||||||
from mediahive.hivescan.scanner import RootScanner
|
from mediahive.hivescan.scanner import RootScanner
|
||||||
from mediahive.hivescan.tmdb_client import close_http_client
|
from mediahive.hivescan.tmdb_client import close_http_client
|
||||||
@@ -968,22 +975,13 @@ async def _activate_all_roots() -> None:
|
|||||||
"""
|
"""
|
||||||
desired: dict[str, str] = {}
|
desired: dict[str, str] = {}
|
||||||
|
|
||||||
# 1. CLI roots via MEDIAHIVE_ROOTS (JSON dict)
|
# 1. CLI roots (teleported via fastapi-vue's env config) take precedence
|
||||||
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)
|
# 2. Persisted config roots (used only when CLI roots are not provided)
|
||||||
|
if config.roots:
|
||||||
|
desired.update(config.roots)
|
||||||
|
else:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
if env_roots is not None:
|
if cfg.roots:
|
||||||
desired.update(env_roots)
|
|
||||||
elif cfg.roots:
|
|
||||||
desired.update(cfg.roots)
|
desired.update(cfg.roots)
|
||||||
|
|
||||||
if not desired:
|
if not desired:
|
||||||
@@ -1050,7 +1048,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
await close_image_client()
|
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
|
# Allow CORS for development
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -1073,6 +1071,114 @@ async def health_check():
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
async def get_version():
|
||||||
|
"""Return the installed MediaHive package version."""
|
||||||
|
try:
|
||||||
|
version = importlib.metadata.version("mediahive")
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
version = "dev"
|
||||||
|
return {"version": version}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auto-update (Velopack, GUI builds only) ---
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/update")
|
||||||
|
async def get_update_status():
|
||||||
|
"""Version, auto-update preference, and any staged (downloaded) update."""
|
||||||
|
version = (await get_version())["version"]
|
||||||
|
cfg = load_config()
|
||||||
|
pending = await asyncio.to_thread(updater.pending_update)
|
||||||
|
return {
|
||||||
|
"version": version,
|
||||||
|
"auto_update": cfg.auto_update,
|
||||||
|
"pending_version": pending,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/config/auto-update")
|
||||||
|
async def put_auto_update(request: Request):
|
||||||
|
"""Enable/disable automatic update downloads (persisted in config)."""
|
||||||
|
body = msgspec.json.decode(await request.body())
|
||||||
|
enabled = bool(body.get("enabled", True))
|
||||||
|
cfg = load_config()
|
||||||
|
save_config(msgspec.structs.replace(cfg, auto_update=enabled))
|
||||||
|
with suppress(AttributeError, TypeError):
|
||||||
|
config.auto_update = enabled
|
||||||
|
if enabled:
|
||||||
|
# Catch up on anything missed while updates were disabled.
|
||||||
|
asyncio.create_task(asyncio.to_thread(updater.check_and_download))
|
||||||
|
return {"auto_update": enabled}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/update/restart")
|
||||||
|
async def restart_for_update():
|
||||||
|
"""Apply the staged update and restart into it (never returns on success)."""
|
||||||
|
applied = await asyncio.to_thread(updater.apply_pending_and_restart)
|
||||||
|
if not applied:
|
||||||
|
raise HTTPException(status_code=404, detail="No downloaded update to apply")
|
||||||
|
return {"status": "restarting"}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_log() -> str:
|
||||||
|
"""Return the full application log file."""
|
||||||
|
path = log_dir() / "mediahive.log"
|
||||||
|
if not path.exists():
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/log")
|
||||||
|
async def get_log():
|
||||||
|
"""Return the full application log file."""
|
||||||
|
return PlainTextResponse(_read_log())
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/api/log/ws")
|
||||||
|
async def ws_log(ws: WebSocket) -> None:
|
||||||
|
"""Stream the application log: full log on connect and on every change."""
|
||||||
|
await ws.accept()
|
||||||
|
last_sent: str | None = None
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
current = _read_log()
|
||||||
|
if current != last_sent:
|
||||||
|
last_sent = current
|
||||||
|
await ws.send_text(current)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
except WebSocketDisconnect, OSError, RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/client-log", status_code=204)
|
||||||
|
async def post_client_log(request: Request):
|
||||||
|
"""Append a client-side (webview) error report to client-errors.log."""
|
||||||
|
try:
|
||||||
|
payload = msgspec.json.decode(await request.body())
|
||||||
|
except msgspec.DecodeError:
|
||||||
|
payload = {}
|
||||||
|
message = str(payload.get("message") or "")
|
||||||
|
stack = payload.get("stack")
|
||||||
|
source = payload.get("source")
|
||||||
|
try:
|
||||||
|
dirpath = log_dir()
|
||||||
|
dirpath.mkdir(parents=True, exist_ok=True)
|
||||||
|
with (dirpath / "client-errors.log").open("a", encoding="utf-8") as f:
|
||||||
|
timestamp = datetime.now().isoformat(timespec="seconds")
|
||||||
|
f.write(f"[{timestamp}] {message}\n")
|
||||||
|
if source:
|
||||||
|
f.write(f" source: {source}\n")
|
||||||
|
if stack:
|
||||||
|
f.write(f" stack: {stack}\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config")
|
@app.get("/api/config")
|
||||||
async def get_config():
|
async def get_config():
|
||||||
"""Return current server configuration."""
|
"""Return current server configuration."""
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Optional Velopack auto-update integration (GUI builds only).
|
||||||
|
|
||||||
|
In development and portable-ZIP runs Velopack is either not installed or the
|
||||||
|
app is not a Velopack installation; every helper degrades to a no-op then, so
|
||||||
|
callers never need to special-case those environments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mediahive.config import load_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
|
||||||
|
|
||||||
|
|
||||||
|
def _manager():
|
||||||
|
"""Return a Velopack UpdateManager, or None when updates are unavailable."""
|
||||||
|
try:
|
||||||
|
import velopack
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
|
||||||
|
except RuntimeError, OSError:
|
||||||
|
# Not a Velopack installation (dev / portable run).
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pending_update() -> str | None:
|
||||||
|
"""Version of a downloaded update staged for the next launch, if any."""
|
||||||
|
mgr = _manager()
|
||||||
|
if mgr is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
asset = mgr.get_update_pending_restart()
|
||||||
|
except RuntimeError, OSError:
|
||||||
|
return None
|
||||||
|
return str(asset.Version) if asset is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_pending_and_restart() -> bool:
|
||||||
|
"""Apply the staged update and restart into it. False when nothing pending."""
|
||||||
|
mgr = _manager()
|
||||||
|
if mgr is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
asset = mgr.get_update_pending_restart()
|
||||||
|
if asset is None:
|
||||||
|
return False
|
||||||
|
logger.info("Velopack: applying staged update %s and restarting", asset.Version)
|
||||||
|
mgr.apply_updates_and_restart(asset)
|
||||||
|
except (RuntimeError, OSError) as exc:
|
||||||
|
logger.warning("Velopack: failed to apply staged update: %s", exc)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_download() -> None:
|
||||||
|
"""Download available updates in the background, unless disabled in config.
|
||||||
|
|
||||||
|
Downloaded updates are applied automatically by Velopack on the next app
|
||||||
|
start, so the running session is never interrupted. Network failures and
|
||||||
|
non-Velopack runs are expected and skipped quietly.
|
||||||
|
"""
|
||||||
|
if not load_config().auto_update:
|
||||||
|
logger.info("Velopack: automatic updates disabled, skipping check")
|
||||||
|
return
|
||||||
|
mgr = _manager()
|
||||||
|
if mgr is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
info = mgr.check_for_updates()
|
||||||
|
if info is None:
|
||||||
|
logger.info("Velopack: no update available")
|
||||||
|
return
|
||||||
|
version = info.TargetFullRelease.Version
|
||||||
|
logger.info("Velopack: downloading update %s", version)
|
||||||
|
mgr.download_updates(info)
|
||||||
|
logger.info("Velopack: update %s staged, applies on next launch", version)
|
||||||
|
except (RuntimeError, OSError) as exc:
|
||||||
|
logger.info("Velopack update check skipped: %s", exc)
|
||||||
+84
-29
@@ -24,11 +24,21 @@ import urllib.request
|
|||||||
from concurrent.futures import Future, ThreadPoolExecutor
|
from concurrent.futures import Future, ThreadPoolExecutor
|
||||||
from pathlib import Path
|
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 msgspec.structs
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
import velopack
|
||||||
import webview
|
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, save_config
|
from mediahive import updater
|
||||||
|
from mediahive.config import config, load_config, log_dir, save_config
|
||||||
from mediahive.volume_control import get_volume, set_volume, volume_max
|
from mediahive.volume_control import get_volume, set_volume, volume_max
|
||||||
|
|
||||||
logger = logging.getLogger("mediahive.winmain")
|
logger = logging.getLogger("mediahive.winmain")
|
||||||
@@ -871,18 +881,16 @@ def _wait_for_previous_instance(log_path: Path, timeout: float = 15.0):
|
|||||||
|
|
||||||
|
|
||||||
def _setup_logging() -> Path:
|
def _setup_logging() -> Path:
|
||||||
"""Redirect stdout/stderr and configure logging to a file in %APPDATA%/mediahive/.
|
"""Redirect stdout/stderr and configure logging to a file in the platform log dir.
|
||||||
|
|
||||||
In a PyInstaller --windowed build there is no console, so any print() or
|
In a PyInstaller --windowed build there is no console, so any print() or
|
||||||
unhandled exception traceback would be lost. This ensures everything ends
|
unhandled exception traceback would be lost. This ensures everything ends
|
||||||
up in a persistent log file the user can send for bug reports.
|
up in a persistent log file the user can send for bug reports.
|
||||||
Returns the path to the log file.
|
Returns the path to the log file.
|
||||||
"""
|
"""
|
||||||
from mediahive.config import config_dir
|
log_directory = log_dir()
|
||||||
|
log_directory.mkdir(parents=True, exist_ok=True)
|
||||||
log_dir = config_dir()
|
log_path = log_directory / "mediahive.log"
|
||||||
log_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
log_path = log_dir / "mediahive.log"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
log_file = _rotate_and_open_log(log_path)
|
log_file = _rotate_and_open_log(log_path)
|
||||||
@@ -899,7 +907,7 @@ def _setup_logging() -> Path:
|
|||||||
log_file = _wait_for_previous_instance(log_path)
|
log_file = _wait_for_previous_instance(log_path)
|
||||||
if log_file is None:
|
if log_file is None:
|
||||||
# Never fail startup over logging: fall back to a per-process file.
|
# Never fail startup over logging: fall back to a per-process file.
|
||||||
log_path = log_dir / f"mediahive-{os.getpid()}.log"
|
log_path = log_directory / f"mediahive-{os.getpid()}.log"
|
||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
|
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
|
||||||
log_file = os.fdopen(fd, "w", encoding="utf-8", buffering=1)
|
log_file = os.fdopen(fd, "w", encoding="utf-8", buffering=1)
|
||||||
@@ -958,26 +966,42 @@ def _show_fatal_error(exc: BaseException) -> None:
|
|||||||
Frozen --windowed builds otherwise surface crashes only as PyInstaller's
|
Frozen --windowed builds otherwise surface crashes only as PyInstaller's
|
||||||
plain-text error dialog (or nothing at all).
|
plain-text error dialog (or nothing at all).
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
from tracerite.html import html_traceback
|
|
||||||
|
|
||||||
fragment = str(html_traceback(exc))
|
fragment = str(html_traceback(exc))
|
||||||
except Exception: # noqa: BLE001 - error reporting must never raise
|
|
||||||
return
|
|
||||||
page = (
|
page = (
|
||||||
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
||||||
"<title>MediaHive — Error</title></head>"
|
"<title>MediaHive — Error</title></head>"
|
||||||
f"<body style='margin:1.5rem'>{fragment}</body></html>"
|
f"<body style='margin:1.5rem'>{fragment}</body></html>"
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
webview.create_window("MediaHive — Error", html=page, width=1100, height=750)
|
webview.create_window("MediaHive — Error", html=page, width=1100, height=750)
|
||||||
webview.start(icon=_icon_path(), **_webview_start_kwargs())
|
webview.start(icon=_icon_path(), **_webview_start_kwargs())
|
||||||
except Exception:
|
|
||||||
logger.exception("Could not display the error window")
|
|
||||||
|
def _velopack_startup() -> None:
|
||||||
|
"""Handle Velopack install/update/uninstall hooks and pending updates.
|
||||||
|
|
||||||
|
Must be the first thing at startup: when Velopack launches the app with
|
||||||
|
--veloapp-* hook arguments (during install/update/uninstall), run()
|
||||||
|
executes the hook and exits the process, so the GUI never starts.
|
||||||
|
Applies downloaded-but-pending updates. No-op in development and
|
||||||
|
portable-ZIP runs.
|
||||||
|
"""
|
||||||
|
velopack.App().run()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_for_updates() -> None:
|
||||||
|
"""Download available updates in the background (unless disabled in config).
|
||||||
|
|
||||||
|
Downloaded updates are applied automatically by Velopack on the next app
|
||||||
|
start (via _velopack_startup), so the running session is never
|
||||||
|
interrupted. Not a Velopack install (dev/portable) and network failures
|
||||||
|
are expected and skipped quietly.
|
||||||
|
"""
|
||||||
|
updater.check_and_download()
|
||||||
|
|
||||||
|
|
||||||
def gui_main() -> None:
|
def gui_main() -> None:
|
||||||
"""Run the GUI, rendering fatal exceptions as a TraceRite HTML window."""
|
"""Run the GUI, rendering fatal exceptions as a TraceRite HTML window."""
|
||||||
|
_velopack_startup()
|
||||||
try:
|
try:
|
||||||
winmain()
|
winmain()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -998,6 +1022,16 @@ class JsApi:
|
|||||||
result = self._window.create_file_dialog(webview.FOLDER_DIALOG)
|
result = self._window.create_file_dialog(webview.FOLDER_DIALOG)
|
||||||
return result[0] if result else None
|
return result[0] if result else None
|
||||||
|
|
||||||
|
def exit_app(self) -> None:
|
||||||
|
"""Close the window, shutting the app down (like the OS close button)."""
|
||||||
|
if self._window:
|
||||||
|
self._window.destroy()
|
||||||
|
|
||||||
|
def toggle_fullscreen(self) -> None:
|
||||||
|
"""Switch between fullscreen and windowed mode in place."""
|
||||||
|
if self._window:
|
||||||
|
self._window.toggle_fullscreen()
|
||||||
|
|
||||||
def set_volume(self, x: float) -> None:
|
def set_volume(self, x: float) -> None:
|
||||||
"""Set system master volume from slider position ``x`` (0.0 .. 1.5)."""
|
"""Set system master volume from slider position ``x`` (0.0 .. 1.5)."""
|
||||||
# Clamp to the platform's maximum so the slider never exceeds what
|
# Clamp to the platform's maximum so the slider never exceeds what
|
||||||
@@ -1126,8 +1160,26 @@ def _configure_windows_event_loop_policy() -> None:
|
|||||||
asyncio.set_event_loop_policy(policy_cls())
|
asyncio.set_event_loop_policy(policy_cls())
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_mark_of_the_web() -> None:
|
||||||
|
"""Remove Zone.Identifier streams from bundled DLLs (frozen Windows only).
|
||||||
|
|
||||||
|
Files extracted from a downloaded ZIP carry the Mark-of-the-Web, and the
|
||||||
|
.NET Framework CLR refuses to load such assemblies — pythonnet then fails
|
||||||
|
with "Failed to resolve Python.Runtime.Loader.Initialize from
|
||||||
|
.../Python.Runtime.dll". Strip the mark from the bundled DLLs before
|
||||||
|
pywebview loads the CLR.
|
||||||
|
"""
|
||||||
|
if not getattr(sys, "frozen", False) or sys.platform != "win32":
|
||||||
|
return
|
||||||
|
meipass = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||||
|
for dll in meipass.rglob("*.dll"):
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
Path(f"{dll}:Zone.Identifier").unlink()
|
||||||
|
|
||||||
|
|
||||||
def winmain() -> None:
|
def winmain() -> None:
|
||||||
_configure_windows_event_loop_policy()
|
_configure_windows_event_loop_policy()
|
||||||
|
_strip_mark_of_the_web()
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="MediaHive")
|
parser = argparse.ArgumentParser(description="MediaHive")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -1154,10 +1206,6 @@ def winmain() -> None:
|
|||||||
initial_roots[name] = p.as_posix()
|
initial_roots[name] = p.as_posix()
|
||||||
elif cfg.roots:
|
elif cfg.roots:
|
||||||
initial_roots = 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:
|
if not initial_roots:
|
||||||
folder = _run_initial_setup()
|
folder = _run_initial_setup()
|
||||||
@@ -1171,8 +1219,9 @@ def winmain() -> None:
|
|||||||
if cfg.roots != initial_roots:
|
if cfg.roots != initial_roots:
|
||||||
save_config(msgspec.structs.replace(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)
|
# Pass roots to the in-process server via the shared env config
|
||||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(initial_roots)
|
# (validation deferred to server startup)
|
||||||
|
config.roots = initial_roots
|
||||||
|
|
||||||
backend_port = _reserve_backend_port()
|
backend_port = _reserve_backend_port()
|
||||||
backend_url = f"http://{BACKEND_HOST}:{backend_port}"
|
backend_url = f"http://{BACKEND_HOST}:{backend_port}"
|
||||||
@@ -1180,8 +1229,6 @@ def winmain() -> None:
|
|||||||
|
|
||||||
# Startup banner, same as fastapi-vue's server.run() prints in CLI mode.
|
# Startup banner, same as fastapi-vue's server.run() prints in CLI mode.
|
||||||
# Goes to stderr, which frozen builds redirect to the log file.
|
# Goes to stderr, which frozen builds redirect to the log file.
|
||||||
from fastapi_vue.startupbox import print_box
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
version = importlib.metadata.version("mediahive")
|
version = importlib.metadata.version("mediahive")
|
||||||
except importlib.metadata.PackageNotFoundError:
|
except importlib.metadata.PackageNotFoundError:
|
||||||
@@ -1192,9 +1239,13 @@ def winmain() -> None:
|
|||||||
# log config wires up its access-log middleware, emoji level prefixes and
|
# log config wires up its access-log middleware, emoji level prefixes and
|
||||||
# tracerite tracebacks (colors are auto-disabled when stderr is not a tty,
|
# tracerite tracebacks (colors are auto-disabled when stderr is not a tty,
|
||||||
# e.g. redirected to the log file in frozen builds).
|
# e.g. redirected to the log file in frozen builds).
|
||||||
from fastapi_vue.logging import patch_log_config
|
log_config = patch_log_config(uvicorn.config.LOGGING_CONFIG)
|
||||||
|
# fastapi-vue routes the root logger at INFO in dev / WARNING in prod;
|
||||||
config = uvicorn.Config(
|
# 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",
|
"mediahive.server:app",
|
||||||
host=BACKEND_HOST,
|
host=BACKEND_HOST,
|
||||||
port=backend_port,
|
port=backend_port,
|
||||||
@@ -1202,9 +1253,9 @@ def winmain() -> None:
|
|||||||
server_header=False,
|
server_header=False,
|
||||||
timeout_graceful_shutdown=0,
|
timeout_graceful_shutdown=0,
|
||||||
access_log=False, # fastapi-vue's middleware replaces uvicorn's
|
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(
|
backend_thread = threading.Thread(
|
||||||
target=server.run, daemon=True, name="mediahive-backend"
|
target=server.run, daemon=True, name="mediahive-backend"
|
||||||
)
|
)
|
||||||
@@ -1228,6 +1279,10 @@ def winmain() -> None:
|
|||||||
server.should_exit = True
|
server.should_exit = True
|
||||||
raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s")
|
raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s")
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=_check_for_updates, daemon=True, name="mediahive-update-check"
|
||||||
|
).start()
|
||||||
|
|
||||||
api = JsApi()
|
api = JsApi()
|
||||||
logger.info("Configured pywebview backend: %s", _selected_webview_backend())
|
logger.info("Configured pywebview backend: %s", _selected_webview_backend())
|
||||||
window = webview.create_window(
|
window = webview.create_window(
|
||||||
|
|||||||
+46
-41
@@ -8,11 +8,12 @@ dependencies = [
|
|||||||
"aiofiles>=25.1.0",
|
"aiofiles>=25.1.0",
|
||||||
"aiopathlib>=0.6.0",
|
"aiopathlib>=0.6.0",
|
||||||
"bencodepy>=0.9.5",
|
"bencodepy>=0.9.5",
|
||||||
"fastapi-vue>=1.4.1",
|
"fastapi-vue~=1.7.2",
|
||||||
"fastapi[standard]>=0.128.0",
|
"fastapi[standard]>=0.128.0",
|
||||||
"httpx[http2]>=0.28.1",
|
"httpx[http2]>=0.28.1",
|
||||||
"msgspec>=0.19",
|
"msgspec>=0.19",
|
||||||
"parse-torrent-title>=2.8.1",
|
"parse-torrent-title>=2.8.1",
|
||||||
|
"platformdirs>=4.0",
|
||||||
"tomli-w>=1.2.0",
|
"tomli-w>=1.2.0",
|
||||||
"uvicorn[standard]>=0.40.0",
|
"uvicorn[standard]>=0.40.0",
|
||||||
]
|
]
|
||||||
@@ -50,17 +51,21 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
gui = [
|
gui = [
|
||||||
|
# pywebview's qt extra is Qt6-only (QtPy + PyQt6 + PyQt6-WebEngine);
|
||||||
|
# Qt5 would come from its separate qt5 extra, which we do not use.
|
||||||
"pywebview[qt]>=6.2.1; platform_system != 'Windows'",
|
"pywebview[qt]>=6.2.1; platform_system != 'Windows'",
|
||||||
"pywebview>=6.2.1; platform_system == 'Windows'",
|
"pywebview>=6.2.1; platform_system == 'Windows'",
|
||||||
"qtpy>=2.4.1; platform_system == 'Darwin'",
|
"velopack>=1.2",
|
||||||
"PyQt5>=5.15.11; platform_system == 'Darwin'",
|
|
||||||
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
|
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
|
||||||
"pyinstaller>=6.0",
|
"pyinstaller>=6.0",
|
||||||
|
# scripts/guibuild.py reads the version with it (same logic as hatch-vcs).
|
||||||
|
"setuptools_scm>=8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"lefthook>=2.1.14",
|
||||||
"ruff>=0.15.14",
|
"ruff>=0.15.14",
|
||||||
"setuptools-scm>=8",
|
"setuptools-scm>=8",
|
||||||
]
|
]
|
||||||
@@ -71,52 +76,52 @@ preview = true
|
|||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["ALL"]
|
select = ["ALL"]
|
||||||
ignore = [
|
ignore = [
|
||||||
"D203",
|
"incorrect-blank-line-before-class",
|
||||||
"D213",
|
"multi-line-summary-second-line",
|
||||||
"DOC201",
|
"docstring-missing-returns",
|
||||||
"COM812",
|
"missing-trailing-comma",
|
||||||
"T201",
|
"print",
|
||||||
"EM",
|
"EM",
|
||||||
"TC",
|
"TC",
|
||||||
"TRY003",
|
"raise-vanilla-args",
|
||||||
"S",
|
"S",
|
||||||
"CPY001",
|
"missing-copyright-notice",
|
||||||
"PLR",
|
"PLR",
|
||||||
"PLW",
|
"PLW",
|
||||||
# TEMP suppressions - revisit and remove after focused cleanup passes.
|
# TEMP suppressions - revisit and remove after focused cleanup passes.
|
||||||
"C901",
|
"complex-structure",
|
||||||
"DOC501",
|
"docstring-missing-exception",
|
||||||
"ANN201",
|
"missing-return-type-undocumented-public-function",
|
||||||
"D103",
|
"undocumented-public-function",
|
||||||
"FBT001",
|
"boolean-type-hint-positional-argument",
|
||||||
"ANN001",
|
"missing-type-function-argument",
|
||||||
"D102",
|
"undocumented-public-method",
|
||||||
"TRY300",
|
"try-consider-else",
|
||||||
"D107",
|
"undocumented-public-init",
|
||||||
"ANN202",
|
"missing-return-type-private-function",
|
||||||
"B904",
|
"raise-without-from-inside-except",
|
||||||
"ASYNC220",
|
"create-subprocess-in-async-function",
|
||||||
"E501",
|
"line-too-long",
|
||||||
"INP001",
|
"implicit-namespace-package",
|
||||||
"FBT002",
|
"boolean-default-value-positional-argument",
|
||||||
"PLC0415",
|
"import-outside-top-level",
|
||||||
"D101",
|
"undocumented-public-class",
|
||||||
"RUF006",
|
"asyncio-dangling-task",
|
||||||
"SLF001",
|
"private-member-access",
|
||||||
"ASYNC240",
|
"blocking-path-method-in-async-function",
|
||||||
"N801",
|
"invalid-class-name",
|
||||||
"SIM102",
|
"collapsible-if",
|
||||||
"DTZ005",
|
"call-datetime-now-without-tzinfo",
|
||||||
"RUF034",
|
"useless-if-else",
|
||||||
"D415",
|
"missing-terminal-punctuation",
|
||||||
"D400",
|
"missing-trailing-period",
|
||||||
"ANN401",
|
"any-type",
|
||||||
# Allow en-dash in docstrings (used for list formatting)
|
# Allow en-dash in docstrings (used for list formatting)
|
||||||
"RUF002",
|
"ambiguous-unicode-character-docstring",
|
||||||
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
|
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
|
||||||
"N806",
|
"non-lowercase-variable-in-function",
|
||||||
# Allow inline comments that describe output formats
|
# Allow inline comments that describe output formats
|
||||||
"ERA001",
|
"commented-out-code",
|
||||||
# Allow unused local variables in ctypes COM boilerplate
|
# Allow unused local variables in ctypes COM boilerplate
|
||||||
"F841",
|
"unused-variable",
|
||||||
]
|
]
|
||||||
|
|||||||
+22
-8
@@ -5,7 +5,7 @@
|
|||||||
# pyinstaller --noconfirm --clean scripts/MediaHive.spec
|
# pyinstaller --noconfirm --clean scripts/MediaHive.spec
|
||||||
#
|
#
|
||||||
# Or use the build script (recommended—handles versioning and packaging):
|
# Or use the build script (recommended—handles versioning and packaging):
|
||||||
# uv run scripts/winbuild.py
|
# uv run scripts/guibuild.py
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import mediahive.winmain
|
import mediahive.winmain
|
||||||
@@ -20,7 +20,15 @@ _frontend_build = _pkg / "frontend-build"
|
|||||||
_logo_webp = _pkg / "assets" / "mediahive.webp"
|
_logo_webp = _pkg / "assets" / "mediahive.webp"
|
||||||
_icon_win = _pkg / "assets" / "mediahive.ico"
|
_icon_win = _pkg / "assets" / "mediahive.ico"
|
||||||
_icon_mac = _pkg / "assets" / "mediahive.icns"
|
_icon_mac = _pkg / "assets" / "mediahive.icns"
|
||||||
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
|
# ffmpeg staging lives in the persistent build cache (same logic as
|
||||||
|
# scripts/guibuild.py); fall back to the legacy build/ffmpeg location.
|
||||||
|
from platformdirs import user_cache_path
|
||||||
|
|
||||||
|
_tools_dir = (
|
||||||
|
user_cache_path("mediahive-build", appauthor=False, opinion=False) / "ffmpeg"
|
||||||
|
)
|
||||||
|
if not _tools_dir.exists():
|
||||||
|
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
|
||||||
_tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg"]
|
_tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg"]
|
||||||
|
|
||||||
_binaries = []
|
_binaries = []
|
||||||
@@ -80,11 +88,12 @@ if sys.platform == "darwin":
|
|||||||
# pywebview Qt backend selected dynamically via webview.start(gui="qt")
|
# pywebview Qt backend selected dynamically via webview.start(gui="qt")
|
||||||
"webview.platforms.qt",
|
"webview.platforms.qt",
|
||||||
"qtpy",
|
"qtpy",
|
||||||
"PyQt5",
|
"PyQt6",
|
||||||
"PyQt5.QtCore",
|
"PyQt6.QtCore",
|
||||||
"PyQt5.QtGui",
|
"PyQt6.QtGui",
|
||||||
"PyQt5.QtWidgets",
|
"PyQt6.QtWidgets",
|
||||||
"PyQt5.QtWebEngineWidgets",
|
"PyQt6.QtWebEngineCore",
|
||||||
|
"PyQt6.QtWebEngineWidgets",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,6 +132,11 @@ exe = EXE(
|
|||||||
windowed=True,
|
windowed=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# UPX breaks .NET assemblies: packing Python.Runtime.dll strips/corrupts its
|
||||||
|
# CLR metadata and pythonnet then fails with "Failed to resolve
|
||||||
|
# Python.Runtime.Loader.Initialize from .../Python.Runtime.dll".
|
||||||
|
_upx_exclude = ["Python.Runtime.dll"] if sys.platform == "win32" else []
|
||||||
|
|
||||||
coll = COLLECT(
|
coll = COLLECT(
|
||||||
exe,
|
exe,
|
||||||
a.binaries,
|
a.binaries,
|
||||||
@@ -130,7 +144,7 @@ coll = COLLECT(
|
|||||||
a.datas,
|
a.datas,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
upx=True,
|
||||||
upx_exclude=[],
|
upx_exclude=_upx_exclude,
|
||||||
name="MediaHive",
|
name="MediaHive",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import tracerite
|
import tracerite
|
||||||
@@ -48,11 +48,11 @@ async def run_devserver(
|
|||||||
os.environ["MEDIAHIVE_DEV"] = "1"
|
os.environ["MEDIAHIVE_DEV"] = "1"
|
||||||
|
|
||||||
async with ProcessGroup() as pg:
|
async with ProcessGroup() as pg:
|
||||||
|
pg.create_task(check_ports_free(viteurl, backurl))
|
||||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||||
await check_ports_free(viteurl, backurl)
|
await pg.spawn(*mediahive, *(extra_args or []), vital=True)
|
||||||
await pg.spawn(*mediahive, *(extra_args or []))
|
|
||||||
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
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:
|
def main() -> None:
|
||||||
@@ -75,8 +75,12 @@ def main() -> None:
|
|||||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||||
)
|
)
|
||||||
args, extra_args = parser.parse_known_args()
|
args, extra_args = parser.parse_known_args()
|
||||||
with suppress(KeyboardInterrupt):
|
try:
|
||||||
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
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 = """
|
HELP_EPILOG = """
|
||||||
|
|||||||
@@ -10,20 +10,27 @@ from pathlib import Path
|
|||||||
MIN_NODE_VERSION = 20
|
MIN_NODE_VERSION = 20
|
||||||
|
|
||||||
|
|
||||||
class _PrefixFormatter(logging.Formatter):
|
class _Formatter(logging.Formatter):
|
||||||
"""Formatter that adds prefix based on log level."""
|
"""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:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
if record.levelno >= logging.ERROR:
|
||||||
|
return f"🛑 {record.getMessage()}"
|
||||||
if record.levelno >= logging.WARNING:
|
if record.levelno >= logging.WARNING:
|
||||||
return f"⚠️ {record.getMessage()}"
|
return f"💣 {record.getMessage()}"
|
||||||
return record.getMessage()
|
return record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
_handler = logging.StreamHandler()
|
_handler = logging.StreamHandler()
|
||||||
_handler.setFormatter(_PrefixFormatter())
|
_handler.setFormatter(_Formatter())
|
||||||
logger = logging.getLogger("fastapi-vue")
|
logger = logging.getLogger("fastapi-vue")
|
||||||
logger.addHandler(_handler)
|
logger.addHandler(_handler)
|
||||||
logger.setLevel(logging.INFO)
|
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:
|
def _check_node_version(node_path: str) -> None:
|
||||||
|
|||||||
@@ -1,110 +1,89 @@
|
|||||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
|
from asyncio.subprocess import Process
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
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 urllib.parse import urlsplit
|
||||||
|
|
||||||
from buildutil import find_dev_tool, find_install_tool, logger
|
from buildutil import find_dev_tool, find_install_tool, logger
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Coroutine
|
from collections.abc import Awaitable
|
||||||
|
|
||||||
|
|
||||||
class ProcessGroup:
|
class ProcessGroup(asyncio.TaskGroup):
|
||||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
"""TaskGroup with structured ownership of async subprocesses."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, *, terminate_timeout: float = 10) -> None:
|
||||||
"""Initialize empty process tracking."""
|
"""Set the grace period before terminate() escalates to kill()."""
|
||||||
self._procs: list[asyncio.subprocess.Process] = []
|
super().__init__()
|
||||||
self._cmds: dict[int, str] = {} # pid -> command name
|
self._terminate_timeout = terminate_timeout
|
||||||
|
self._cmds: dict[Process, tuple[str, ...]] = {}
|
||||||
|
|
||||||
async def spawn(
|
async def spawn(
|
||||||
self,
|
self, *cmd: str, cwd: str | None = None, vital: bool = False
|
||||||
*cmd: str,
|
) -> Process:
|
||||||
cwd: str | None = None,
|
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
|
||||||
) -> 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 wait(
|
async def run() -> None:
|
||||||
self,
|
name = Path(cmd[0]).stem
|
||||||
*waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any],
|
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
|
||||||
) -> None:
|
|
||||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
|
||||||
|
|
||||||
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:
|
try:
|
||||||
await asyncio.gather(*tasks)
|
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||||
except subprocess.CalledProcessError as e:
|
self._cmds[proc] = cmd
|
||||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
started.set_result(proc)
|
||||||
raise SystemExit(1) from None
|
except Exception as e: # ruff: ignore[blind-except]
|
||||||
|
started.set_exception(e)
|
||||||
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
|
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:
|
|
||||||
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):
|
|
||||||
try:
|
try:
|
||||||
await asyncio.shield(
|
returncode = await proc.wait()
|
||||||
asyncio.wait_for(
|
finally:
|
||||||
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):
|
with suppress(ProcessLookupError):
|
||||||
p.kill()
|
proc.terminate()
|
||||||
await p.wait()
|
try:
|
||||||
|
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
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.
|
"""GET url with plain asyncio streams, return the response Server header.
|
||||||
|
|
||||||
Returns an empty string when the server responds without a 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()
|
writer.close()
|
||||||
except OSError, EOFError, ValueError, TimeoutError:
|
except OSError, EOFError, ValueError, TimeoutError:
|
||||||
return None
|
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:"):
|
if line.lower().startswith("server:"):
|
||||||
return line.split(":", 1)[1].strip()
|
return line[7:].strip()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def check_ports_free(*urls: str) -> None:
|
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:
|
Meant to run as a task inside a TaskGroup. Logs the conflict and raises
|
||||||
server = await http_get_server(url, timeout=0.1)
|
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:
|
if server is not None:
|
||||||
logger.warning(
|
logger.error(
|
||||||
"Conflicting %s already running at %s", server or "server", url
|
"Conflicting %s already running at %s", server or "server", url
|
||||||
)
|
)
|
||||||
raise SystemExit(1)
|
raise RuntimeError(url)
|
||||||
|
|
||||||
await asyncio.gather(*[check(url) for url in urls])
|
|
||||||
|
|
||||||
|
|
||||||
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||||
"""Wait for the server to be ready by polling an endpoint.
|
"""Wait for the server to be ready by polling an endpoint.
|
||||||
|
|
||||||
Use empty path to disable the check and make this return immediately.
|
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:
|
if not path:
|
||||||
return
|
return
|
||||||
|
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
||||||
logger.info("✓ Backend ready!")
|
logger.info("🟢 Backend ready!")
|
||||||
return
|
return
|
||||||
if attempt == max_attempts - 1:
|
if attempt == max_attempts - 1:
|
||||||
logger.warning("Backend didn't start in time")
|
logger.error("Backend at %s didn't start in time", url)
|
||||||
raise SystemExit(1)
|
raise RuntimeError(url)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+370
-32
@@ -1,23 +1,36 @@
|
|||||||
#!/usr/bin/env -S uv run
|
#!/usr/bin/env -S uv run
|
||||||
"""Build the desktop GUI application and package it as a version-numbered ZIP.
|
# /// script
|
||||||
|
# requires-python = ">=3.14"
|
||||||
|
# dependencies = [
|
||||||
|
# "mediahive[gui]",
|
||||||
|
# ]
|
||||||
|
#
|
||||||
|
# [tool.uv.sources]
|
||||||
|
# mediahive = { path = "../" }
|
||||||
|
# ///
|
||||||
|
"""Build the desktop GUI application and package it with Velopack.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run scripts/winbuild.py
|
uv run scripts/guibuild.py
|
||||||
|
|
||||||
This runs in the project environment where dependencies
|
Self-contained: inline script dependencies above make uv resolve the
|
||||||
are available via pyproject.toml.
|
package (with the gui extra) plus this script's own direct imports.
|
||||||
|
|
||||||
This script:
|
This script:
|
||||||
1. Reads the version from pyproject.toml
|
1. Reads the version from pyproject.toml
|
||||||
2. Runs `uv build` to produce the wheel/sdist
|
2. Runs `uv build` to produce the wheel/sdist
|
||||||
3. On Windows, downloads the latest ffmpeg.exe for bundling
|
3. On Windows/macOS, downloads the ffmpeg binary for bundling
|
||||||
4. On macOS arm64, downloads a prebuilt ffmpeg binary for bundling
|
|
||||||
4. Builds MediaHive using PyInstaller
|
4. Builds MediaHive using PyInstaller
|
||||||
5. Creates a ZIP file with the version number
|
5. Packages with Velopack: Setup.exe (Windows), .pkg (macOS),
|
||||||
|
.AppImage (Linux), plus the update feed in build/velopack/
|
||||||
|
that release.py uploads for in-app auto-updates
|
||||||
|
6. On Windows, also creates a portable ZIP (no auto-updates)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -25,8 +38,10 @@ import sys
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
import setuptools_scm
|
import setuptools_scm
|
||||||
|
from platformdirs import user_cache_path
|
||||||
|
|
||||||
# BtbN automated builds always publish a 'latest' tag with this asset.
|
# BtbN automated builds always publish a 'latest' tag with this asset.
|
||||||
_FFMPEG_URL = (
|
_FFMPEG_URL = (
|
||||||
@@ -36,29 +51,86 @@ _FFMPEG_URL = (
|
|||||||
_MACOS_ARM64_TOOL_URLS = {
|
_MACOS_ARM64_TOOL_URLS = {
|
||||||
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
|
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
|
||||||
}
|
}
|
||||||
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
|
|
||||||
_REPO_ROOT = Path(__file__).parent.parent
|
_REPO_ROOT = Path(__file__).parent.parent
|
||||||
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
|
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
|
||||||
|
|
||||||
|
|
||||||
def _platform_zip_suffix() -> str:
|
def _build_cache_dir() -> Path:
|
||||||
machine = platform.machine().lower()
|
"""Return the persistent cross-build cache dir for downloaded tools (CI wipes build/)."""
|
||||||
arch = {
|
return user_cache_path("mediahive-build", appauthor=False, opinion=False)
|
||||||
"x86_64": "x64",
|
|
||||||
"amd64": "x64",
|
|
||||||
"arm64": "arm64",
|
|
||||||
"aarch64": "arm64",
|
|
||||||
}.get(machine, machine or "unknown")
|
|
||||||
|
|
||||||
|
|
||||||
|
_FFMPEG_STAGING = _build_cache_dir() / "ffmpeg"
|
||||||
|
|
||||||
|
# Velopack CLI (dotnet tool package). Runs on the machine's .NET runtime; the
|
||||||
|
# produced Setup.exe/Update.exe are native and need no runtime on end-user
|
||||||
|
# machines. Pin a version whose tools target an installed .NET major.
|
||||||
|
_VPK_VERSION = "1.2.158"
|
||||||
|
_VPK_URL = (
|
||||||
|
f"https://api.nuget.org/v3-flatcontainer/vpk/{_VPK_VERSION}"
|
||||||
|
f"/vpk.{_VPK_VERSION}.nupkg"
|
||||||
|
)
|
||||||
|
_VPK_STAGING = _build_cache_dir() / f"vpk-{_VPK_VERSION}"
|
||||||
|
|
||||||
|
|
||||||
|
class _Platform(NamedTuple):
|
||||||
|
"""Per-platform naming/packaging constants.
|
||||||
|
|
||||||
|
tag is the release artifact suffix. Only Windows keeps an arch marker
|
||||||
|
(win64); macOS builds are arm64-only and we ship one Linux flavor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tag: str # win64 / macos / linux
|
||||||
|
channel: str # Velopack update channel: win / osx / linux
|
||||||
|
rid: str # Velopack runtime id
|
||||||
|
dist_dir: str # PyInstaller output dir under build/
|
||||||
|
icon: str # file in mediahive/assets
|
||||||
|
main_exe: str
|
||||||
|
setup_ext: str
|
||||||
|
|
||||||
|
|
||||||
|
def _platform() -> _Platform:
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
return "win64"
|
return _Platform(
|
||||||
|
"win64",
|
||||||
|
"win",
|
||||||
|
"win-x64",
|
||||||
|
"MediaHive",
|
||||||
|
"mediahive.ico",
|
||||||
|
"MediaHive.exe",
|
||||||
|
".exe",
|
||||||
|
)
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
return f"macos-{arch}"
|
return _Platform(
|
||||||
return f"linux-{arch}"
|
"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."""
|
||||||
|
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}"
|
||||||
|
|
||||||
|
|
||||||
def fetch_ffmpeg() -> Path:
|
def fetch_ffmpeg() -> Path:
|
||||||
"""Download latest ffmpeg.exe from BtbN builds into build/ffmpeg/."""
|
"""Download latest ffmpeg.exe from BtbN builds into the persistent build cache."""
|
||||||
dest = _FFMPEG_STAGING / "ffmpeg.exe"
|
dest = _FFMPEG_STAGING / "ffmpeg.exe"
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
print(f"ffmpeg already staged at {dest}, skipping download.")
|
print(f"ffmpeg already staged at {dest}, skipping download.")
|
||||||
@@ -83,7 +155,7 @@ def fetch_ffmpeg() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def fetch_macos_arm64_binaries() -> dict[str, Path]:
|
def fetch_macos_arm64_binaries() -> dict[str, Path]:
|
||||||
"""Download prebuilt macOS arm64 ffmpeg binary into build/ffmpeg/."""
|
"""Download prebuilt macOS arm64 ffmpeg binary into the persistent build cache."""
|
||||||
if sys.platform != "darwin" or platform.machine().lower() not in {
|
if sys.platform != "darwin" or platform.machine().lower() not in {
|
||||||
"arm64",
|
"arm64",
|
||||||
"aarch64",
|
"aarch64",
|
||||||
@@ -180,6 +252,268 @@ def ensure_macos_icon() -> Path:
|
|||||||
return icon_icns
|
return icon_icns
|
||||||
|
|
||||||
|
|
||||||
|
_VPK_TFM = "net10.0"
|
||||||
|
_VPK_REQUIRED_DOTNET_MAJOR = int(re.fullmatch(r"net(\d+)\.0", _VPK_TFM).group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_vpk() -> Path:
|
||||||
|
"""Download the Velopack CLI package into the persistent build cache.
|
||||||
|
|
||||||
|
Returns the path to vpk.dll, runnable with `dotnet vpk.dll ...`.
|
||||||
|
"""
|
||||||
|
vpk_dll = _VPK_STAGING / "tools" / _VPK_TFM / "any" / "vpk.dll"
|
||||||
|
if vpk_dll.exists():
|
||||||
|
print(f"vpk already staged at {_VPK_STAGING}, skipping download.")
|
||||||
|
return vpk_dll
|
||||||
|
|
||||||
|
_VPK_STAGING.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"Downloading vpk from {_VPK_URL} ...")
|
||||||
|
with urllib.request.urlopen(_VPK_URL) as resp:
|
||||||
|
data = resp.read()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
zf.extractall(_VPK_STAGING)
|
||||||
|
|
||||||
|
if not vpk_dll.exists():
|
||||||
|
raise RuntimeError(f"vpk.dll not found in package at {vpk_dll}")
|
||||||
|
print(f"vpk staged at {_VPK_STAGING}")
|
||||||
|
return vpk_dll
|
||||||
|
|
||||||
|
|
||||||
|
def _dotnet_runtime_major(exe: Path) -> int | None:
|
||||||
|
"""Return the highest installed Microsoft.NETCore.App major version, or None."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
except OSError, subprocess.TimeoutExpired:
|
||||||
|
return None
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
majors = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2 and parts[0] == "Microsoft.NETCore.App":
|
||||||
|
try:
|
||||||
|
majors.append(int(parts[1].split(".")[0]))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return max(majors, default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_dotnet() -> str:
|
||||||
|
"""Resolve a system dotnet host able to run vpk (needs .NET >= 10).
|
||||||
|
|
||||||
|
The .NET SDK is a build prerequisite installed on the build machine —
|
||||||
|
downloading a runtime per build is slow and flaky. Several dotnet
|
||||||
|
installations may coexist (PATH may resolve to a runtime-only .NET 8
|
||||||
|
while scoop holds the SDK 10), so probe known locations and pick the
|
||||||
|
newest runtime rather than the first that runs.
|
||||||
|
"""
|
||||||
|
exe_name = "dotnet.exe" if sys.platform == "win32" else "dotnet"
|
||||||
|
candidates: list[Path] = []
|
||||||
|
root = os.environ.get("DOTNET_ROOT")
|
||||||
|
if root:
|
||||||
|
candidates.append(Path(root) / exe_name)
|
||||||
|
which = shutil.which("dotnet")
|
||||||
|
if which:
|
||||||
|
candidates.append(Path(which))
|
||||||
|
if sys.platform == "win32":
|
||||||
|
candidates += [
|
||||||
|
Path(r"C:\ProgramData\scoop\apps\dotnet-sdk\current") / exe_name,
|
||||||
|
Path(r"C:\Program Files\dotnet") / exe_name,
|
||||||
|
]
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
candidates += [
|
||||||
|
Path("/opt/homebrew/bin") / exe_name,
|
||||||
|
Path("/usr/local/share/dotnet") / exe_name,
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
candidates += [
|
||||||
|
Path("/usr/share/dotnet") / exe_name,
|
||||||
|
Path("/usr/lib/dotnet") / exe_name,
|
||||||
|
Path.home() / ".dotnet" / exe_name,
|
||||||
|
]
|
||||||
|
|
||||||
|
best: tuple[int, Path] | None = None
|
||||||
|
for exe in candidates:
|
||||||
|
if not exe.exists():
|
||||||
|
continue
|
||||||
|
major = _dotnet_runtime_major(exe)
|
||||||
|
if major is not None and (best is None or major > best[0]):
|
||||||
|
best = (major, exe)
|
||||||
|
|
||||||
|
if best is not None and best[0] >= _VPK_REQUIRED_DOTNET_MAJOR:
|
||||||
|
print(f"Using dotnet at {best[1]} (.NET {best[0]})")
|
||||||
|
return str(best[1])
|
||||||
|
|
||||||
|
found = f"newest found is .NET {best[0]} at {best[1]}" if best else "none found"
|
||||||
|
raise RuntimeError(
|
||||||
|
f"vpk requires Microsoft.NETCore.App >= {_VPK_REQUIRED_DOTNET_MAJOR} ({found}). "
|
||||||
|
"Install the current .NET SDK on this build machine "
|
||||||
|
"(Windows: `scoop install dotnet-sdk`; macOS: `brew install dotnet-sdk`; "
|
||||||
|
"Linux: distro `dotnet-sdk` package or the dotnet-install script)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_velopack(version: str) -> Path:
|
||||||
|
"""Build the Velopack installer/bundle for this platform.
|
||||||
|
|
||||||
|
Windows: per-user Setup.exe. macOS: .pkg installer. Linux: .AppImage.
|
||||||
|
Also produces the update feed (releases.<channel>.json, *.nupkg) in
|
||||||
|
build/velopack/ for release.py to upload — in-app auto-updates read it
|
||||||
|
from the Gitea release. Velopack installs carry no Mark-of-the-Web, so
|
||||||
|
the .NET CLR loads pythonnet/pywebview assemblies that it refuses from
|
||||||
|
a downloaded ZIP.
|
||||||
|
"""
|
||||||
|
plat = _platform()
|
||||||
|
dist_folder = _REPO_ROOT / "build" / plat.dist_dir
|
||||||
|
if not dist_folder.exists():
|
||||||
|
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||||
|
|
||||||
|
vpk_dll = fetch_vpk()
|
||||||
|
releases_dir = _REPO_ROOT / "build" / "velopack"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
fetch_dotnet(),
|
||||||
|
str(vpk_dll),
|
||||||
|
"pack",
|
||||||
|
"--packId",
|
||||||
|
"MediaHive",
|
||||||
|
"--packVersion",
|
||||||
|
version,
|
||||||
|
"--packDir",
|
||||||
|
str(dist_folder),
|
||||||
|
"--mainExe",
|
||||||
|
plat.main_exe,
|
||||||
|
"--packAuthors",
|
||||||
|
"MediaHive",
|
||||||
|
"--packTitle",
|
||||||
|
"MediaHive",
|
||||||
|
"--icon",
|
||||||
|
str(_ASSETS_DIR / plat.icon),
|
||||||
|
"--runtime",
|
||||||
|
plat.rid,
|
||||||
|
"--outputDir",
|
||||||
|
str(releases_dir),
|
||||||
|
]
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
cmd += ["--instWelcome", str(_ASSETS_DIR / "macos-installer-welcome.txt")]
|
||||||
|
print(f"Running: {' '.join(cmd)}")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, cwd=_REPO_ROOT, capture_output=True, text=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"vpk failed to start: {exc}") from exc
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"vpk pack failed with exit code {result.returncode}\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
setup = next(iter(sorted(releases_dir.glob(f"*Setup*{plat.setup_ext}"))), None)
|
||||||
|
if setup is None:
|
||||||
|
setup = next(iter(sorted(releases_dir.glob(f"*{plat.setup_ext}"))), None)
|
||||||
|
if setup is None:
|
||||||
|
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.unlink(missing_ok=True)
|
||||||
|
setup.rename(artifact)
|
||||||
|
rename_feed_package(releases_dir, version, plat.channel)
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
|
||||||
|
def rename_feed_package(releases_dir: Path, version: str, channel: str) -> None:
|
||||||
|
"""Rename this platform's update-feed nupkg in place.
|
||||||
|
|
||||||
|
vpk hardcodes MediaHive-{ver}[-{channel}]-full.nupkg (Windows, the legacy
|
||||||
|
default channel, gets no marker). Rename all to the uniform
|
||||||
|
mediahive-{ver}-{channel}-full.nupkg: lowercase groups them with the
|
||||||
|
wheel/sdist below the capitalized user downloads on the release page,
|
||||||
|
and every platform carries its channel. releases.<channel>.json
|
||||||
|
references the filename, so patch it too.
|
||||||
|
"""
|
||||||
|
old_name = f"MediaHive-{version}-full.nupkg"
|
||||||
|
if not (releases_dir / old_name).exists():
|
||||||
|
old_name = f"MediaHive-{version}-{channel}-full.nupkg"
|
||||||
|
nupkg = releases_dir / old_name
|
||||||
|
if not nupkg.exists():
|
||||||
|
raise RuntimeError(f"vpk produced no {old_name} in {releases_dir}")
|
||||||
|
|
||||||
|
new_name = f"mediahive-{version}-{channel}-full.nupkg"
|
||||||
|
manifest = releases_dir / f"releases.{channel}.json"
|
||||||
|
text = manifest.read_text()
|
||||||
|
if old_name not in text:
|
||||||
|
raise RuntimeError(f"{manifest.name} does not reference {old_name}")
|
||||||
|
manifest.write_text(text.replace(old_name, new_name))
|
||||||
|
nupkg.rename(nupkg.with_name(new_name))
|
||||||
|
|
||||||
|
|
||||||
|
def force_macos_user_install(pkg: Path) -> None:
|
||||||
|
"""Restrict the Velopack-generated pkg to per-user installs (~/Applications).
|
||||||
|
|
||||||
|
Velopack hardcodes two install domains (currentUserHome + localSystem) in
|
||||||
|
the distribution XML. System installs land in /Applications, which the
|
||||||
|
user may not own — Velopack's UpdateMac then cannot replace the .app on
|
||||||
|
auto-update. With a single domain, macOS Installer skips the Destination
|
||||||
|
Select page and installs to ~/Applications without admin rights.
|
||||||
|
|
||||||
|
Also drops the `sudo -u "$USER"` prefix from Velopack's postinstall
|
||||||
|
script: under a per-user install the script already runs as the
|
||||||
|
installing user, and sudo would fail for lack of a tty.
|
||||||
|
|
||||||
|
NB: only ever use `pkgutil --expand` (which keeps component Payloads
|
||||||
|
archived) — `--expand-full` flattens payloads to loose files that
|
||||||
|
`--flatten` cannot repack, producing a pkg that "installs" nothing.
|
||||||
|
"""
|
||||||
|
expanded = pkg.with_name(pkg.stem + "-expanded")
|
||||||
|
shutil.rmtree(expanded, ignore_errors=True)
|
||||||
|
subprocess.run(["pkgutil", "--expand", str(pkg), str(expanded)], check=True)
|
||||||
|
|
||||||
|
dist_xml = expanded / "Distribution"
|
||||||
|
xml = dist_xml.read_text()
|
||||||
|
new_xml, count = re.subn(
|
||||||
|
r"<domains [^>]*/>",
|
||||||
|
'<domains enable_anywhere="false" enable_currentUserHome="true" enable_localSystem="false" />',
|
||||||
|
xml,
|
||||||
|
)
|
||||||
|
if count != 1:
|
||||||
|
raise RuntimeError("Unexpected distribution.xml: <domains> not found")
|
||||||
|
dist_xml.write_text(new_xml)
|
||||||
|
|
||||||
|
# Edit postinstall inside the component pkg. Depending on the macOS
|
||||||
|
# version, --expand leaves the component as an archived file (needs a
|
||||||
|
# nested expand/flatten round) or as an already-expanded directory.
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
shutil.rmtree(comp_dir)
|
||||||
|
|
||||||
|
subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True)
|
||||||
|
shutil.rmtree(expanded)
|
||||||
|
|
||||||
|
|
||||||
def read_version() -> str:
|
def read_version() -> str:
|
||||||
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
|
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
|
||||||
return setuptools_scm.get_version(root=str(_REPO_ROOT))
|
return setuptools_scm.get_version(root=str(_REPO_ROOT))
|
||||||
@@ -217,18 +551,18 @@ def build_executable() -> None:
|
|||||||
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
|
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
|
||||||
|
|
||||||
|
|
||||||
def create_zip(version: str) -> Path:
|
def create_portable_zip() -> Path:
|
||||||
"""Create a version-numbered ZIP file of the build/MediaHive folder."""
|
"""Create the Windows portable ZIP of the build/MediaHive folder.
|
||||||
repo_root = _REPO_ROOT
|
|
||||||
dist_folder = repo_root / "build" / "MediaHive"
|
|
||||||
|
|
||||||
|
Velopack-less plain-folder distribution for users who cannot or do not
|
||||||
|
want to run Setup.exe. No auto-updates; the app strips Mark-of-the-Web
|
||||||
|
from bundled DLLs at first run instead.
|
||||||
|
"""
|
||||||
|
dist_folder = _REPO_ROOT / "build" / "MediaHive"
|
||||||
if not dist_folder.exists():
|
if not dist_folder.exists():
|
||||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||||
|
|
||||||
zip_name = f"MediaHive-{version}-{_platform_zip_suffix()}.zip"
|
zip_path = _REPO_ROOT / "build" / "MediaHive-win64-portable.zip"
|
||||||
zip_path = repo_root / "build" / zip_name
|
|
||||||
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
print(f"Creating {zip_path}...")
|
print(f"Creating {zip_path}...")
|
||||||
shutil.make_archive(
|
shutil.make_archive(
|
||||||
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
|
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
|
||||||
@@ -258,10 +592,14 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
build_wheel()
|
build_wheel()
|
||||||
build_executable()
|
build_executable()
|
||||||
zip_path = create_zip(version)
|
|
||||||
|
|
||||||
print(f"✓ Built successfully: {zip_path}")
|
artifacts = [build_velopack(version)]
|
||||||
print(f" Size: {zip_path.stat().st_size / (1024 * 1024):.1f} MB")
|
if sys.platform == "win32":
|
||||||
|
artifacts.append(create_portable_zip())
|
||||||
|
|
||||||
|
for artifact_path in artifacts:
|
||||||
|
print(f"✓ Built successfully: {artifact_path}")
|
||||||
|
print(f" Size: {artifact_path.stat().st_size / (1024 * 1024):.1f} MB")
|
||||||
except (FileNotFoundError, OSError, RuntimeError, ValueError) as e:
|
except (FileNotFoundError, OSError, RuntimeError, ValueError) as e:
|
||||||
print(f"✗ Build failed: {e}", file=sys.stderr)
|
print(f"✗ Build failed: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
+110
-34
@@ -9,10 +9,15 @@ Reads from [project.urls] Repository in pyproject.toml.
|
|||||||
Token: GITEA_TOKEN environment variable
|
Token: GITEA_TOKEN environment variable
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Find clean-versioned ZIPs in build/ and matching dist/ wheels/sdists
|
1. Read the clean tag version via setuptools_scm, find platform artifacts
|
||||||
2. Abort if any dist files are missing for a found ZIP version
|
in build/ and matching dist/ wheels/sdists
|
||||||
3. Create a Gitea release for each version and upload all assets
|
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
|
4. Remind the user to run: uv publish
|
||||||
|
|
||||||
|
Parallel CI platform builds converge on one release per tag; pass --no-dist
|
||||||
|
on all but one platform so only it uploads the wheel/sdist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -24,6 +29,7 @@ from pathlib import Path
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import setuptools_scm
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).parent.parent
|
REPO_ROOT = Path(__file__).parent.parent
|
||||||
|
|
||||||
@@ -63,20 +69,31 @@ def load_token() -> str:
|
|||||||
# ZIP + dist helpers
|
# ZIP + dist helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-macos-arm64.zip, etc.
|
# Installer artifacts are versionless (MediaHive-win64-setup.exe,
|
||||||
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip
|
# MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage,
|
||||||
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.zip$")
|
# 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_zips() -> list[tuple[Path, str, str]]:
|
def read_version() -> str:
|
||||||
"""Return (path, version, platform_tag) for clean-versioned ZIPs in build/."""
|
"""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"
|
build_dir = REPO_ROOT / "build"
|
||||||
results = []
|
return [
|
||||||
for p in sorted(build_dir.glob("MediaHive-*.zip")):
|
p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)
|
||||||
m = _CLEAN_ZIP_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]:
|
def find_dist_files(version: str) -> list[Path]:
|
||||||
@@ -108,6 +125,23 @@ def find_dist_files(version: str) -> list[Path]:
|
|||||||
return [wheel, sdist]
|
return [wheel, sdist]
|
||||||
|
|
||||||
|
|
||||||
|
def find_velopack_feed_files() -> list[Path]:
|
||||||
|
"""Velopack update feed files produced by vpk pack in build/velopack/.
|
||||||
|
|
||||||
|
Only what the in-app updater (GiteaSource) reads from the latest
|
||||||
|
release: this channel's releases.<channel>.json index and the nupkg
|
||||||
|
payload it points to. The legacy RELEASES and assets.*.json manifests
|
||||||
|
(Squirrel compat / setup bootstrap) are not uploaded.
|
||||||
|
"""
|
||||||
|
releases_dir = REPO_ROOT / "build" / "velopack"
|
||||||
|
if not releases_dir.exists():
|
||||||
|
return []
|
||||||
|
files: list[Path] = []
|
||||||
|
for pattern in ("releases.*.json", "*.nupkg"):
|
||||||
|
files.extend(sorted(releases_dir.glob(pattern)))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Gitea API helpers
|
# Gitea API helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -117,6 +151,18 @@ def gitea_headers(token: str) -> dict:
|
|||||||
return {"Authorization": f"token {token}", "Accept": "application/json"}
|
return {"Authorization": f"token {token}", "Accept": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_release_by_tag(
|
||||||
|
client: httpx.Client, base_url: str, repo: str, tag: str
|
||||||
|
) -> dict | None:
|
||||||
|
"""Return the existing release for a tag, or None."""
|
||||||
|
url = f"{base_url}/api/v1/repos/{repo}/releases/tags/{tag}"
|
||||||
|
resp = client.get(url)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
def create_release(
|
def create_release(
|
||||||
client: httpx.Client,
|
client: httpx.Client,
|
||||||
base_url: str,
|
base_url: str,
|
||||||
@@ -125,8 +171,12 @@ def create_release(
|
|||||||
version: str,
|
version: str,
|
||||||
notes: str,
|
notes: str,
|
||||||
draft: bool,
|
draft: bool,
|
||||||
) -> int:
|
) -> tuple[int, set[str]]:
|
||||||
"""Create a Gitea release and return its id."""
|
"""Create a Gitea release, or reuse the existing one for the tag.
|
||||||
|
|
||||||
|
Returns (release_id, names of assets already attached), so parallel
|
||||||
|
platform builds can converge on one release without conflicts.
|
||||||
|
"""
|
||||||
url = f"{base_url}/api/v1/repos/{repo}/releases"
|
url = f"{base_url}/api/v1/repos/{repo}/releases"
|
||||||
payload = {
|
payload = {
|
||||||
"tag_name": tag,
|
"tag_name": tag,
|
||||||
@@ -137,11 +187,17 @@ def create_release(
|
|||||||
}
|
}
|
||||||
resp = client.post(url, json=payload)
|
resp = client.post(url, json=payload)
|
||||||
if resp.status_code == 409:
|
if resp.status_code == 409:
|
||||||
raise RuntimeError(f"A release for tag '{tag}' already exists on Gitea.")
|
existing = get_release_by_tag(client, base_url, repo, tag)
|
||||||
|
if existing is None:
|
||||||
|
raise RuntimeError(f"Release for tag '{tag}' conflicts but cannot be read.")
|
||||||
|
release_id = existing["id"]
|
||||||
|
assets = {a["name"] for a in existing.get("assets", [])}
|
||||||
|
print(f"Release for tag '{tag}' already exists (id={release_id}), reusing it.")
|
||||||
|
return release_id, assets
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
release_id = resp.json()["id"]
|
release_id = resp.json()["id"]
|
||||||
print(f"Created release id={release_id} (draft={draft})")
|
print(f"Created release id={release_id} (draft={draft})")
|
||||||
return release_id
|
return release_id, set()
|
||||||
|
|
||||||
|
|
||||||
def upload_asset(
|
def upload_asset(
|
||||||
@@ -154,7 +210,10 @@ def upload_asset(
|
|||||||
"""Upload a file to the release and return the download URL."""
|
"""Upload a file to the release and return the download URL."""
|
||||||
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
|
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
|
||||||
size_mb = path.stat().st_size / (1024 * 1024)
|
size_mb = path.stat().st_size / (1024 * 1024)
|
||||||
mime = "application/zip" if path.suffix == ".zip" else "application/octet-stream"
|
mime = {
|
||||||
|
".zip": "application/zip",
|
||||||
|
".dmg": "application/x-apple-diskimage",
|
||||||
|
}.get(path.suffix, "application/octet-stream")
|
||||||
print(f"Uploading {path.name} ({size_mb:.1f} MB) ...")
|
print(f"Uploading {path.name} ({size_mb:.1f} MB) ...")
|
||||||
with Path(path).open("rb") as fh:
|
with Path(path).open("rb") as fh:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
@@ -174,6 +233,10 @@ def upload_asset(
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
# Windows consoles default to cp1252, which can't encode ✓/✗
|
||||||
|
sys.stdout.reconfigure(errors="replace")
|
||||||
|
sys.stderr.reconfigure(errors="replace")
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea")
|
parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--draft", action="store_true", help="Create as a draft release"
|
"--draft", action="store_true", help="Create as a draft release"
|
||||||
@@ -181,45 +244,58 @@ def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--notes", default="", metavar="TEXT", help="Release notes body"
|
"--notes", default="", metavar="TEXT", help="Release notes body"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-dist",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip wheel/sdist upload (for parallel platform builds; one job uploads them)",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cfg = load_gitea_config()
|
cfg = load_gitea_config()
|
||||||
token = load_token()
|
token = load_token()
|
||||||
|
version = read_version()
|
||||||
|
|
||||||
zips = find_releasable_zips()
|
artifacts = find_releasable_artifacts()
|
||||||
if not zips:
|
if not artifacts:
|
||||||
print(
|
print(
|
||||||
"No clean-versioned ZIPs found in build/.\n"
|
"No platform artifacts found in build/.\n"
|
||||||
"Run scripts/guibuild.py first.",
|
"Run scripts/guibuild.py first.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Validate all dist files exist before touching Gitea
|
# Validate all dist files exist before touching Gitea
|
||||||
dist_files: dict[str, list[Path]] = {}
|
dist_files: list[Path] = [] if args.no_dist else find_dist_files(version)
|
||||||
for _, version, _platform_tag in zips:
|
|
||||||
dist_files[version] = find_dist_files(version)
|
|
||||||
|
|
||||||
base_url = cfg["url"].rstrip("/")
|
base_url = cfg["url"].rstrip("/")
|
||||||
repo = cfg["repo"]
|
repo = cfg["repo"]
|
||||||
|
|
||||||
with httpx.Client(headers=gitea_headers(token)) as client:
|
with httpx.Client(headers=gitea_headers(token)) as client:
|
||||||
release_ids_by_version: dict[str, int] = {}
|
|
||||||
for zip_path, version, platform_tag in zips:
|
|
||||||
print(f"\nReleasing {version} ...")
|
print(f"\nReleasing {version} ...")
|
||||||
tag = f"v{version}"
|
tag = f"v{version}"
|
||||||
release_id = release_ids_by_version.get(version)
|
release_id, uploaded = create_release(
|
||||||
if release_id is None:
|
|
||||||
release_id = create_release(
|
|
||||||
client, base_url, repo, tag, version, args.notes, args.draft
|
client, base_url, repo, tag, version, args.notes, args.draft
|
||||||
)
|
)
|
||||||
release_ids_by_version[version] = release_id
|
for path in dist_files:
|
||||||
for path in dist_files[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)
|
upload_asset(client, base_url, repo, release_id, path)
|
||||||
|
|
||||||
print(f"Uploading platform artifact: {platform_tag}")
|
for artifact_path in artifacts:
|
||||||
upload_asset(client, base_url, repo, release_id, zip_path)
|
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}")
|
||||||
|
upload_asset(client, base_url, repo, release_id, artifact_path)
|
||||||
|
uploaded.add(artifact_path.name)
|
||||||
|
for feed_file in find_velopack_feed_files():
|
||||||
|
if feed_file.name in uploaded:
|
||||||
|
print(f"Skipping {feed_file.name}, already on the release.")
|
||||||
|
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("\nDone. To publish to PyPI, run:")
|
||||||
|
|||||||
Reference in New Issue
Block a user