38 Commits
Author SHA1 Message Date
LeoVasanko 603884c5a2 Live log view over WebSocket, full log, scroll pinned to bottom 2026-09-23 17:31:13 +00:00
LeoVasanko 2c4baf693c Diagnostics panel: log view only, full width
Drop the version/browser rows; the log pre now fills the layout width
instead of a fixed 80ch that overflowed once the scrollbar appeared.
2026-09-23 17:24:02 +00:00
LeoVasanko 0a0c812efd Merge installers: Velopack packaging on all platforms
Per-user installers with in-app auto-updates (Velopack Setup.exe / macOS
pkg / Linux AppImage) built by the Gitea release workflow. macOS GUI
switched to Qt6 WebEngine (modern Chromium). Diagnostics section in
settings (version, browser engine, log view, client error capture).
2026-09-23 17:19:56 +00:00
LeoVasanko 508dae02b3 Rely on pywebview[qt] for Qt6 on all Qt platforms
Its qt extra already pins QtPy + PyQt6 + PyQt6-WebEngine; the explicit
Darwin lines were redundant. Qt5 would only come from the unused qt5
extra.
2026-09-23 16:57:48 +00:00
LeoVasanko f27bb4e7f3 Revert "macOS: WKWebView system backend (variant B)"
This reverts commit 5b40e306d1.
2026-09-23 16:56:50 +00:00
LeoVasanko 5b40e306d1 macOS: WKWebView system backend (variant B)
release / gui-build (linux, bash) (push) Successful in 45s
release / gui-build (windows, cmd) (push) Successful in 1m14s
release / gui-build (macos, bash) (push) Successful in 55s
Default pywebview backend on macOS, no bundled Qt/Chromium — much
smaller installer and updates.
2026-09-23 03:52:40 +00:00
LeoVasanko aea73ccd36 macOS: PyQt6-WebEngine backend (modern Chromium ~122, variant A)
release / gui-build (linux, bash) (push) Successful in 57s
release / gui-build (windows, cmd) (push) Successful in 1m20s
release / gui-build (macos, bash) (push) Successful in 1m57s
PyQt5's QtWebEngine is Chromium 87, too old for aspect-ratio and other
modern CSS. Qt6 WebEngine tracks current Chromium.
2026-09-23 03:51:32 +00:00
LeoVasanko 9e0ccb13bd Tolerate expanded-component pkg layout on newer macOS
release / gui-build (linux, bash) (push) Successful in 44s
release / gui-build (windows, cmd) (push) Successful in 1m16s
release / gui-build (macos, bash) (push) Successful in 1m22s
pkgutil --expand may yield the component as an already-expanded
directory rather than an archived file; handle both. List the expanded
contents in the error message if the layout is unexpected.
2026-09-23 03:33:06 +00:00
LeoVasanko aeb6958aab Add Diagnostics section to settings: version, browser engine, log view
release / gui-build (linux, bash) (push) Successful in 56s
release / gui-build (windows, cmd) (push) Successful in 1m22s
release / gui-build (macos, bash) (push) Failing after 1m29s
For triaging webview rendering issues without devtools: settings panel
now shows app version, the webview user agent, and the application log
(pre-wrap, 80ch, scrollable, refreshable). New endpoints /api/version,
/api/log; client-side JS errors are captured globally and posted to
/api/client-log (client-errors.log in the log dir).
2026-09-23 03:29:12 +00:00
LeoVasanko 3711d3dd3c Fix macOS pkg post-processing breaking the payload
pkgutil --expand-full flattens the component payload to loose files that
--flatten cannot repack, producing a pkg Installer accepts but installs
nothing from (also breaking the postinstall auto-open). Use nested
regular --expand: product for the Distribution domains edit, component
for the postinstall sudo-prefix removal.
2026-09-23 03:03:53 +00:00
LeoVasanko 039824f236 Upload only the consumed feed files; uniform nupkg names
release.py no longer uploads the legacy RELEASES and assets.*.json
manifests — the in-app updater reads only releases.<channel>.json plus
the nupkg it references. The Windows feed nupkg gains its -win channel
marker (vpk omits it for the legacy default channel), so all platforms
are uniformly mediahive-{ver}-{channel}-full.nupkg. The -full suffix is
Velopack's asset type and stays.
2026-09-23 02:37:59 +00:00
LeoVasanko f078e9bec0 Simplify release workflow; node and .NET are runner prerequisites
Drop the per-job uv-installed nodejs-wheel steps — runner hosts must
have git, uv, node/npm and the .NET SDK installed system-wide.
2026-09-23 02:32:14 +00:00
LeoVasanko ff493506ba Rewrite platform naming around a single descriptor
release / gui-build (linux, bash) (push) Successful in 56s
release / gui-build (windows, cmd) (push) Successful in 1m19s
release / gui-build (macos, bash) (push) Successful in 2m57s
Replace _platform_zip_suffix() and its overrides with a _Platform
NamedTuple (tag/rid/dist_dir/icon/main_exe/setup_ext) plus
setup_artifact_name(). create_zip() is Windows-only in practice, so it
is now create_portable_zip() with a fixed -win64-portable.zip name
instead of a generic name patched by string replace.
2026-09-23 02:22:18 +00:00
LeoVasanko 5f3ba47098 Simplify artifact names: -macos-setup.pkg, -linux-setup.AppImage
Arch markers dropped except win64: macOS builds are arm64-only and we
ship a single Linux flavor.
2026-09-23 02:15:21 +00:00
LeoVasanko 01c353502c Lowercase update-feed nupkgs in release assets
MediaHive-*-full.nupkg sorted between the capitalized Setup/AppImage
user downloads on the Gitea release page. Renamed to mediahive-* they
group with the wheel/sdist below the downloads; feed manifests
(RELEASES, releases.*.json, assets.*.json) are patched to match.
2026-09-23 02:08:33 +00:00
LeoVasanko 65fdae1546 Make macOS pkg per-user only (~/Applications)
System installs land in /Applications where Velopack's UpdateMac may not
be able to replace the .app during auto-update. Restricting the
distribution domains to currentUserHome skips the Destination Select
page and needs no admin rights; the postinstall script's sudo prefixes
are dropped since it already runs as the installing user.
2026-09-23 01:54:42 +00:00
LeoVasanko 832da6c315 Keep both macOS install domains (system and per-user)
Collapsing <domains> to localSystem did not remove the Destination
Select page (Installer shows it regardless) and only removed the
~/Applications option. Restore Velopack's default of both domains.
2026-09-23 01:52:18 +00:00
LeoVasanko 3142bc2cb2 Fix doubled platformdirs paths; pick newest dotnet runtime
release / gui-build (linux, bash) (push) Successful in 54s
release / gui-build (windows, cmd) (push) Successful in 1m24s
release / gui-build (macos, bash) (push) Successful in 1m47s
appauthor=False/opinion=False avoids mediahive\mediahive\Cache style
paths on Windows. fetch_dotnet now probes all known dotnet locations and
selects the highest Microsoft.NETCore.App major, requiring the version
vpk's TFM targets (10) — PATH on the Windows CI runner resolves to a
runtime-only .NET 8 while scoop holds the SDK 10.
2026-09-23 00:49:38 +00:00
LeoVasanko e514829737 Top-level imports; catch only expected exceptions
release / gui-build (linux, bash) (push) Successful in 56s
release / gui-build (windows, cmd) (push) Failing after 1m37s
release / gui-build (macos, bash) (push) Failing after 13m21s
velopack/tracerite/fastapi-vue are always available in the GUI build, so
drop the in-function import guards. The update checker now catches only
RuntimeError (not a Velopack install: dev/portable) and OSError
(network). Build script and PyInstaller spec use platformdirs for the
persistent tool cache.
2026-09-23 00:44:10 +00:00
LeoVasanko 50420984f1 Use platformdirs for config and log locations
Config and logs now live in local (non-roaming) app data on Windows
(%LOCALAPPDATA%\mediahive) instead of roaming. Existing files in the
old roaming location are left untouched; no migration.
2026-09-23 00:44:10 +00:00
LeoVasanko f9002b19a2 Require system .NET SDK; cache vpk/ffmpeg persistently
release / gui-build (linux, bash) (push) Failing after 50s
release / gui-build (macos, bash) (push) Successful in 1m39s
release / gui-build (windows, cmd) (push) Failing after 1m40s
Per-build downloads of the dotnet runtime (~80 MB) were slow and flaky
(IncompleteRead killed a CI run). The .NET SDK is now a build-host
prerequisite; vpk and ffmpeg download once into a user-level cache
(~/.cache/mediahive-build, %LOCALAPPDATA%\mediahive-build) that
survives the per-run build dir.
2026-09-23 00:28:11 +00:00
LeoVasanko 37f786f5ed Add welcome page text to macOS pkg installer
release / gui-build (linux, bash) (push) Failing after 51s
release / gui-build (macos, bash) (push) Successful in 1m43s
release / gui-build (windows, cmd) (push) Successful in 1m48s
Tells the user to allow the permission prompts macOS may show during
install and first launch. License page remains absent; Destination
Select was already stripped, leaving Introduction, Install, Summary.
2026-09-23 00:17:51 +00:00
LeoVasanko c64f54fe0f Strip Destination Select page from macOS pkg installer
release / gui-build (linux, bash) (push) Successful in 55s
release / gui-build (windows, cmd) (push) Successful in 1m39s
release / gui-build (macos, bash) (push) Successful in 1m36s
Collapse the Velopack-generated distribution.xml to a single install
domain so macOS Installer skips Destination Select, leaving Apple's
minimum pages (Introduction, Install, Summary). Welcome/license/readme/
conclusion pages are already absent (no --inst* options passed).
2026-09-23 00:12:32 +00:00
LeoVasanko bb88ab7d98 Fix tarfile.open call in dotnet bootstrap (fileobj=)
release / gui-build (linux, bash) (push) Successful in 1m4s
release / gui-build (windows, cmd) (push) Successful in 1m43s
release / gui-build (macos, bash) (push) Successful in 1m42s
2026-09-22 23:52:52 +00:00
LeoVasanko 303aabc181 Velopack packaging on all platforms with in-app auto-updates
release / gui-build (linux, bash) (push) Failing after 50s
release / gui-build (macos, bash) (push) Successful in 1m44s
release / gui-build (windows, cmd) (push) Successful in 1m48s
- macOS: .pkg installer replaces the DMG; Linux: .AppImage replaces the ZIP
- winmain runs velopack.App() first (proper hook handling) and checks for
  updates in the background; downloads are applied on next launch
- release.py uploads the vpk update feed (releases.<channel>.json, nupkgs)
  so GiteaSource finds updates on the latest release
- guibuild.py bootstraps a .NET runtime into build/dotnet when the runner
  host lacks one
2026-09-22 23:50:50 +00:00
LeoVasanko e8d2c5773a Exit fast on Velopack hook args; name ZIP artifact win64-portable
release / gui-build (macos, bash) (push) Successful in 1m14s
release / gui-build (linux, bash) (push) Successful in 1m13s
release / gui-build (windows, cmd) (push) Successful in 1m39s
Velopack runs the app with --veloapp-install/-updated/-uninstall during
those operations; without fast-exit handling the full GUI booted
mid-install (also clobbering config via the first-run setup flow) and
the installer reported hook errors. The post-install launch has no hook
args and still starts the app normally.
2026-09-22 23:28:19 +00:00
LeoVasanko 59d2f157eb Probe scoop dotnet-sdk path explicitly (runner service has stale env)
release / gui-build (linux, bash) (push) Successful in 1m3s
release / gui-build (macos, bash) (push) Successful in 1m5s
release / gui-build (windows, cmd) (push) Successful in 1m34s
2026-09-22 23:14:08 +00:00
LeoVasanko 3377702e98 Build Windows installer with Velopack instead of WiX
release / gui-build (linux, bash) (push) Successful in 1m13s
release / gui-build (windows, cmd) (push) Failing after 1m34s
release / gui-build (macos, bash) (push) Successful in 1m13s
vpk runs on the machine's modern .NET runtime (WiX 3.11's .NET Framework
shim fails under the SYSTEM account) and produces a per-user Setup.exe
that needs no runtime on end-user machines.
2026-09-22 23:11:30 +00:00
LeoVasanko 6dee65b4c6 Remove temporary Windows host probe workflow 2026-09-22 22:40:37 +00:00
LeoVasanko c59040ce52 Surface WiX tool stdout/stderr on MSI build failure
release / gui-build (linux, bash) (push) Successful in 1m11s
release / gui-build (windows, cmd) (push) Failing after 1m31s
release / gui-build (macos, bash) (push) Failing after 11m44s
2026-09-22 22:38:14 +00:00
LeoVasanko 734b7993a2 Add per-user MSI installer for Windows; strip Mark-of-the-Web in frozen ZIP builds
release / gui-build (linux, bash) (push) Successful in 1m12s
release / gui-build (macos, bash) (push) Successful in 1m14s
release / gui-build (windows, cmd) (push) Failing after 1m31s
Files extracted from a downloaded ZIP carry a Zone.Identifier stream and the
.NET Framework CLR refuses to load such assemblies, so pythonnet failed with
'Failed to resolve Python.Runtime.Loader.Initialize'. MSI-installed files have
no MOTW; the winmain strip fixes the ZIP distribution.
2026-09-22 22:28:16 +00:00
LeoVasanko b01e8a5c3d Exclude Python.Runtime.dll from UPX (packing corrupts CLR metadata)
release / gui-build (macos, bash) (push) Successful in 1m14s
release / gui-build (linux, bash) (push) Successful in 1m13s
release / gui-build (windows, cmd) (push) Successful in 1m21s
2026-09-22 22:00:57 +00:00
LeoVasanko b231f450bf Temporary Windows host probe workflow
release / gui-build (linux, bash) (push) Successful in 1m2s
release / gui-build (macos, bash) (push) Successful in 1m2s
release / gui-build (windows, cmd) (push) Successful in 1m42s
2026-09-22 04:59:53 +00:00
LeoVasanko 67489e05bc Use cmd shell for Windows steps; powershell is blocked by execution policy under SYSTEM
release / gui-build (windows, cmd) (push) Failing after 0s
release / gui-build (macos, bash) (push) Successful in 1m0s
release / gui-build (linux, bash) (push) Successful in 1m2s
2026-09-22 04:26:30 +00:00
LeoVasanko 3ae4ce9f9f Fix Windows checkout (powershell, not WSL bash) and install node per-job via uv
release / gui-build (windows) (push) Failing after 0s
release / gui-build (macos) (push) Successful in 1m12s
release / gui-build (linux) (push) Successful in 1m15s
2026-09-22 04:23:48 +00:00
LeoVasanko 99e757bc6f Checkout with plain git clone; JS actions need node on host runners
release / gui-build (windows) (push) Failing after 1s
release / gui-build (macos) (push) Successful in 1m8s
release / gui-build (linux) (push) Successful in 1m11s
2026-09-22 04:13:21 +00:00
LeoVasanko 5300fd0c9c Bundle PyQtWebEngine on macOS and ship the app as a DMG
release / gui-build (linux) (push) Successful in 1m13s
release / gui-build (macos) (push) Successful in 1m15s
release / gui-build (windows) (push) Failing after 1s
pywebview[qt] no longer depends on PyQtWebEngine, so fresh CI builds
produced a macOS app without its Chromium engine that crashed on launch.
Package MediaHive.app as a compressed DMG instead of zipping the raw
onedir folder; release.py accepts .dmg artifacts.
2026-09-22 04:07:30 +00:00
LeoVasanko ff10c86e84 Add Gitea Actions cross-platform release workflow
Build the GUI app on macos/windows/linux host runners on v* tag pushes.
All platform jobs converge on one Gitea release; release.py now reuses an
existing release, skips duplicate assets, and supports --no-dist so only
the linux job uploads the wheel/sdist.
2026-09-22 03:54:05 +00:00
44 changed files with 951 additions and 1875 deletions
-3
View File
@@ -1,3 +0,0 @@
# Mass lint/format commits that add noise to git blame.
# Enable locally with: git config blame.ignoreRevsFile .git-blame-ignore-revs
38f3cce # Apply linters and formatters
+1 -27
View File
@@ -29,11 +29,9 @@ jobs:
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
run: uv run --extra gui scripts/guibuild.py
# Every platform converges on the one release for the tag; release.py
# reuses an existing release and skips already-uploaded assets.
@@ -51,27 +49,3 @@ jobs:
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
-2
View File
@@ -20,5 +20,3 @@ package-lock.json
.*
!.gitignore
!.gitea/
!.git-blame-ignore-revs
!.pre-commit-config.yaml
+19 -20
View File
@@ -1,20 +1,16 @@
![MediaHive](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/mediahive.avif)
![MediaHive](docs/mediahive.avif)
# MediaHive
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
## Downloads
**[Windows, Mac and Linux downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
- **Windows**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-win64-setup.exe) · [Portable ZIP](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-win64-portable.zip)
- **macOS**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-macos.pkg)
- **Linux**: [AppImage](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage)
## Getting Started
You may also run or install with [UV](https://docs.astral.sh/uv/getting-started/installation/):
```
uvx --from mediahive[gui] mediahive
```
- Windows: Download `*-win64-setup.exe` from the releases page and run it (no admin needed; auto-updates included). A `-win64-portable.zip` is also available.
- macOS: Download `*-macos-setup.pkg` and install (auto-updates included).
- Linux: Download the `.AppImage`, `chmod +x` it, and run. Alternatively install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run directly with `uvx --from mediahive[gui] mediahive`.
## What It Does
@@ -24,7 +20,7 @@ uvx --from mediahive[gui] mediahive
- Remembers per-episode playback positions and offers series continue points
- Hand off playback to your preferred system player
On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
Windows and macOS builds are currently portable-only (no installer). On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
Note that `.mediahive` folder is created in your media folder to hold all the metadata and preview clips, avoiding the lengthy processing that you will see on initial startup.
@@ -32,10 +28,11 @@ 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.
**Keyboard:** Arrow keys, Enter and Escape to navigate, `/` to search and the usual ones you already know.
![Controller bindings](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/controls.avif)
*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.*
| Input | Controls |
| --- | --- |
| 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. |
| 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
@@ -45,10 +42,12 @@ 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.
- `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
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:
![Series view](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/seriesview.avif)
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.
+1 -4
View File
@@ -11,9 +11,6 @@ All media paths are scoped to a **root**, identified by a friendly `root_id`
| `GET` | `/api/health` | Lightweight health check. |
| `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": [...] }`. |
| `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). |
| `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 }`. |
@@ -35,7 +32,7 @@ All media paths are scoped to a **root**, identified by a friendly `root_id`
- `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` is constrained to `<root>/.mediahive/{asset_type}` where `asset_type` is one of `movies`, `series`, `people`.
- `GET /api/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`.
- `GET /api/player/status` returns `{ "remote": true|false }`.
- Roots may also be provided at startup via CLI arguments (`mediahive /path/to/media ...`), which are passed to the server through fastapi-vue's env config (`mediahive.config.config`) and override the persisted configuration.
- Roots may also be provided at startup via the `MEDIAHIVE_ROOTS` environment variable (JSON dict of name → path), which overrides the persisted configuration.
- Assumed playback: after `POST /api/play/{root_id}` the launched item is assumed to be playing while the frontend reports no input activity. On the next `POST /api/activity` the guessed position (`resume base + elapsed`, capped at the TMDb runtime) is written once; watches under 5 minutes are discarded (a peek is not progress). A resume entry written by another tracker (e.g. the GUI's MPC-BE tracker) during the session overrides the guess. The MPC-BE tracker likewise ignores sessions shorter than 5 minutes.
## WebSocket
Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

+166 -59
View File
@@ -11,19 +11,12 @@
<span>Library activity</span>
<span v-if="!wsConnected" class="activity-connection">Reconnecting...</span>
</div>
<div
v-for="root in progressRoots"
:key="root.rootId"
class="activity-root"
:class="root.toneClass"
>
<div v-for="root in progressRoots" :key="root.rootId" class="activity-root" :class="root.toneClass">
<div class="activity-root-title">{{ root.rootLabel }}</div>
<div v-if="root.scanTarget" class="activity-root-target">{{ root.scanTarget }}</div>
<div class="activity-phase-row">
<span class="activity-phase">{{ root.phaseLabel }}</span>
<span v-if="root.progressLabel" class="activity-progress-label">{{
root.progressLabel
}}</span>
<span v-if="root.progressLabel" class="activity-progress-label">{{ root.progressLabel }}</span>
</div>
<div class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }">
<div
@@ -45,9 +38,6 @@
:current-view="headerCurrentView"
:search-query="searchQuery"
:roots="headerRoots"
:scan-tasks="tasks"
:scan-connected="wsConnected"
:initial-scan-mode="isInitialScanMode"
:mpc-be-connected="mpcBeConnected"
:nav-row="1"
:position="headerPosition"
@@ -212,6 +202,7 @@ import type {
SeriesUi,
MediaItem,
EpisodeWithSeries,
TaskInfo,
SeriesResumePoint,
} from "./types"
import {
@@ -224,14 +215,9 @@ import {
type EpisodeWatchEntry,
} from "./api"
import { useSettings } from "./composables/useSettings"
import {
useKeyboardNavigation,
setActiveNavigationScope,
} from "./composables/useKeyboardNavigation"
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
import { useMediaWebSocket } from "./composables/useMediaWebSocket"
import { computeProgressRoots, type RootTaskInfo } from "./composables/useScanProgress"
import { useSettingsOpen } from "./composables/useSettingsOpen"
import Header from "./components/Header.vue"
import CollageHero from "./components/CollageHero.vue"
import MediaRow from "./components/MediaRow.vue"
@@ -296,6 +282,20 @@ const {
roots: rootStatuses,
} = 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()))
function getRootName(rootId: string | null | undefined): string | null {
@@ -303,15 +303,131 @@ function getRootName(rootId: string | null | undefined): string | null {
return rootStatuses.value.get(rootId)?.root_id || null
}
const progressRoots = computed(() =>
computeProgressRoots(
activeTasks.value,
(rootId) => rootStatuses.value.get(rootId)?.path || null,
isInitialScanMode.value,
),
)
function normalizePosixPath(value: string): string {
return value.replace(/\\/g, "/")
}
const isSettingsView = useSettingsOpen()
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(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(() => {
if (!mediaIndex.value) return false
return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0
@@ -326,8 +442,10 @@ const headerRoots = computed(() =>
)
const showProgressPanel = computed(() => {
if (isSettingsView.value) return false
if (!isInitialScanMode.value) return false
if (isInitialScanMode.value) {
return !wsConnected.value || progressRoots.value.length > 0
}
if (!isSettingsView.value) return false
return !wsConnected.value || progressRoots.value.length > 0
})
@@ -354,11 +472,7 @@ watch(
const key = `${task.root_id}:${task.id}`
nextSeen.set(key, task.status)
const previousStatus = seenTaskStates.get(key)
if (
task.id.startsWith("scan-") &&
task.status === "completed" &&
previousStatus !== "completed"
) {
if (task.id.startsWith("scan-") && task.status === "completed" && previousStatus !== "completed") {
const detail = (task.detail || "").trim()
const doneMatch = detail.match(/^Done\s+[\u2014-]\s+(\d+)\s+movies,\s+(\d+)\s+series$/i)
if (doneMatch) {
@@ -366,9 +480,7 @@ watch(
const series = Number(doneMatch[2] || "0")
if (movies > 0 || series > 0) {
const rootName = getRootName(task.root_id) || task.root_id
showLibraryUpdateToast(
`Library updated in ${rootName}: ${movies} movies, ${series} series`,
)
showLibraryUpdateToast(`Library updated in ${rootName}: ${movies} movies, ${series} series`)
}
}
}
@@ -466,7 +578,9 @@ function getResumePoint(mediaId: string | null): SeriesResumePoint | null {
}
}
function getResumeEpisodes(mediaId: string | null): Record<string, EpisodeWatchEntry> | null {
function getResumeEpisodes(
mediaId: string | null,
): Record<string, EpisodeWatchEntry> | null {
if (!mediaId) return null
return resumePositions.value[mediaId]?.episodes ?? null
}
@@ -561,9 +675,7 @@ function showAdjacentDetail(offset: -1 | 1): boolean {
const sequence = getDetailAdjacentSequence(current)
if (sequence.length < 2) return false
const currentIndex = sequence.findIndex(
(item) => item.id === current.id && item.type === current.type,
)
const currentIndex = sequence.findIndex((item) => item.id === current.id && item.type === current.type)
if (currentIndex < 0) return false
const nextIndex = currentIndex + offset
@@ -659,10 +771,7 @@ function restoreBrowseFocus(path: string) {
}, 100)
}
function getSearchExitTargetFromFocusedCard(): {
path: "/movies" | "/series"
itemId: string
} | null {
function getSearchExitTargetFromFocusedCard(): { path: "/movies" | "/series"; itemId: string } | null {
const active = document.activeElement as HTMLElement | null
const focusedCard = active?.closest("[data-item-id]") as HTMLElement | null
if (!focusedCard) return null
@@ -723,7 +832,9 @@ function clearSearch(options: { preferBack?: boolean; targetPath?: string } = {}
? normalizeHistoryPath(window.history.state.back)
: ""
const canRestoreWithBack =
options.preferBack !== false && searchReturnPath.value === targetPath && backPath === targetPath
options.preferBack !== false &&
searchReturnPath.value === targetPath &&
backPath === targetPath
searchReturnPath.value = null
@@ -946,7 +1057,7 @@ const activePanelScrollTop = computed(() => {
})
const headerStyle = computed(() => {
if (isSettingsView.value) {
if (route.path === "/settings") {
return {}
}
return {
@@ -994,18 +1105,14 @@ function showDetail(item: MediaItem) {
handlePlay(playableFile)
} else {
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null
router.push({
path: `/series/${epData.series.id}`,
state: searchPath ? { searchPath } : undefined,
})
router.push({ path: `/series/${epData.series.id}`, state: searchPath ? { searchPath } : undefined })
}
} else {
const detailSearchPath = getDetailSearchPath()
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
router.push({
path: `/${item.type}/${item.id}`,
state: searchPath ? { searchPath } : undefined,
})
const searchPath = searchQuery.value
? getSearchPath(searchQuery.value)
: detailSearchPath
router.push({ path: `/${item.type}/${item.id}`, state: searchPath ? { searchPath } : undefined })
}
}
@@ -1026,7 +1133,9 @@ function handleActorSearch(actorName: string) {
function handleSelectMovieFromDetail(movieId: string) {
const detailSearchPath = getDetailSearchPath()
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
const searchPath = searchQuery.value
? getSearchPath(searchQuery.value)
: detailSearchPath
router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined })
}
@@ -1065,9 +1174,7 @@ function focusDetailEntryTarget(item: MediaItem): boolean {
return true
}
const firstFocusable = detailPanel.querySelector(
'[data-nav-focusable="true"]',
) as HTMLElement | null
const firstFocusable = detailPanel.querySelector('[data-nav-focusable="true"]') as HTMLElement | null
if (firstFocusable) {
focusElement(firstFocusable)
return true
+3 -32
View File
@@ -426,42 +426,13 @@ export function getCoverUrl(coverPath: string | null, rootId?: string | null): s
}
/**
* Invoke the native OS folder picker via pywebview.
* Only works inside the packaged desktop app; returns null elsewhere.
* Invoke the native OS folder picker via pywebview, then add the selected
* folder to the server's root list. Only works inside the packaged desktop app.
*/
export async function pickFolder(): Promise<string | null> {
export async function pickFolderAndAddRoot(): Promise<string | null> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const api = (window as any).pywebview?.api
if (!api) return null
const folder: string | null = await api.pick_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}`)
}
+7 -1
View File
@@ -594,7 +594,13 @@ function activateItem(item: MediaItem) {
function handleItemClick(event: MouseEvent, item: MediaItem) {
// Let modified clicks navigate natively
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return
}
event.preventDefault()
@@ -65,7 +65,11 @@ function getPlayLabel(filePath: string | null | undefined): string {
function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return []
return Array.from(menuRef.value.querySelectorAll<HTMLElement>(".ctx-btn:not(:disabled)"))
return Array.from(
menuRef.value.querySelectorAll<HTMLElement>(
'.ctx-btn:not(:disabled)'
)
)
}
function focusNext(delta: number) {
@@ -126,7 +130,9 @@ watch(
await nextTick()
clampToViewport()
// Focus first action button for keyboard navigation
const firstBtn = menuRef.value?.querySelector(".ctx-btn:not(:disabled)") as HTMLElement | null
const firstBtn = menuRef.value?.querySelector(
".ctx-btn:not(:disabled)",
) as HTMLElement | null
firstBtn?.focus()
},
{ immediate: true },
File diff suppressed because it is too large Load Diff
+21 -51
View File
@@ -1,13 +1,7 @@
<template>
<Teleport to="body">
<Transition name="hex-keyboard-fade">
<div
v-if="visible"
ref="keyboardRef"
class="hex-keyboard"
@click.stop
@keydown="handleKeyDown"
>
<div v-if="visible" ref="keyboardRef" class="hex-keyboard" @click.stop @keydown="handleKeyDown">
<div ref="gridRef" class="hex-keyboard-grid">
<div
v-for="(row, rowIndex) in rows"
@@ -32,9 +26,7 @@
tabindex="-1"
@click="handleKeyClick(key)"
>
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{
key.label
}}</span>
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{ key.label }}</span>
</button>
</div>
<!-- Green focus outline rendered separately on top -->
@@ -182,16 +174,11 @@ function getCoord(index: number): { row: number; col: number } {
function getRowRange(row: number): { start: number; count: number } {
switch (row) {
case 0:
return { start: ROW_0_START, count: ROW_0_COUNT }
case 1:
return { start: ROW_1_START, count: ROW_1_COUNT }
case 2:
return { start: ROW_2_START, count: ROW_2_COUNT }
case 3:
return { start: ROW_3_START, count: ROW_3_COUNT }
default:
return { start: 0, count: 0 }
case 0: return { start: ROW_0_START, count: ROW_0_COUNT }
case 1: return { start: ROW_1_START, count: ROW_1_COUNT }
case 2: return { start: ROW_2_START, count: ROW_2_COUNT }
case 3: return { start: ROW_3_START, count: ROW_3_COUNT }
default: return { start: 0, count: 0 }
}
}
@@ -260,7 +247,10 @@ function close() {
emit("close")
}
function findNext(currentIdx: number, direction: "up" | "down" | "left" | "right"): number | null {
function findNext(
currentIdx: number,
direction: "up" | "down" | "left" | "right",
): number | null {
const current = getCoord(currentIdx)
if (direction === "left") {
@@ -520,18 +510,10 @@ onUnmounted(() => {
}
/* Row horizontal offsets for honeycomb staggering */
.hex-keyboard-row-0 {
margin-left: 0;
}
.hex-keyboard-row-1 {
margin-left: calc(var(--key-w) * 0.5);
}
.hex-keyboard-row-2 {
margin-left: calc(var(--key-w) * 1);
}
.hex-keyboard-row-3 {
margin-left: calc(var(--key-w) * 1.5);
}
.hex-keyboard-row-0 { margin-left: 0; }
.hex-keyboard-row-1 { margin-left: calc(var(--key-w) * 0.5); }
.hex-keyboard-row-2 { margin-left: calc(var(--key-w) * 1.0); }
.hex-keyboard-row-3 { margin-left: calc(var(--key-w) * 1.5); }
.hex-key {
position: relative;
@@ -547,9 +529,7 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
transition:
transform 0.15s ease,
color 0.3s ease;
transition: transform 0.15s ease, color 0.3s ease;
outline: none;
transform: scale(0.97);
}
@@ -558,8 +538,7 @@ onUnmounted(() => {
.hex-key-row-0 {
background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%);
}
.hex-key-row-1,
.hex-key-row-3 {
.hex-key-row-1, .hex-key-row-3 {
background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%);
}
.hex-key-row-2 {
@@ -574,12 +553,8 @@ onUnmounted(() => {
}
@keyframes hex-label-fade {
0% {
color: #22c55e;
}
100% {
color: #ffffff;
}
0% { color: #22c55e; }
100% { color: #ffffff; }
}
/* Special key backgrounds override row gradients */
@@ -625,13 +600,8 @@ onUnmounted(() => {
}
@keyframes hex-outline-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Transition */
+8 -4
View File
@@ -80,9 +80,7 @@
</div>
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
<div v-if="directorAndCast" class="media-card-detail person-list">
<span v-if="director" class="director-name person-token">{{
formatPersonLabel(director)
}}</span>
<span v-if="director" class="director-name person-token">{{ formatPersonLabel(director) }}</span>
<span
v-for="(castName, castIndex) in formattedCastNames"
:key="`${castName}-${castIndex}`"
@@ -114,7 +112,13 @@ const emit = defineEmits<{
function handleClick(event: MouseEvent) {
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return
}
// Prevent default navigation for plain left-clicks and synthetic clicks
+71 -84
View File
@@ -2,7 +2,6 @@
<!-- Full screen view for series -->
<SeriesFullView
v-if="item.type === 'series'"
:key="item.id"
:series="item.data as Series"
:all-movies="allMovies"
:focus-episode="focusEpisode"
@@ -186,11 +185,7 @@
v-if="item.type === 'movies' && collectionMovies.length > 1"
class="similar-movies-section"
>
<div
class="similar-movies-grid"
data-sync-scroll-row="true"
data-sync-scroll-group="similar"
>
<div class="similar-movies-grid" data-sync-scroll-row="true" data-sync-scroll-group="similar">
<a
v-for="(movie, collectionIndex) in collectionMovies"
:key="movie.localId"
@@ -239,15 +234,7 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
import type {
CastMember,
MediaItem,
Movie,
MovieUi,
Series,
SeriesResumePoint,
Torrent,
} from "../types"
import type { CastMember, MediaItem, Movie, MovieUi, Series, SeriesResumePoint, Torrent } from "../types"
import type { EpisodeWatchEntry } from "../api"
import {
getCoverUrl,
@@ -328,9 +315,7 @@ function getReleaseAtRow(row: number): HTMLElement | null {
}
function getLastReleaseRowBefore(castRow: number): number | null {
const releases = Array.from(
document.querySelectorAll<HTMLElement>('[data-nav-release-item="true"]'),
)
const releases = Array.from(document.querySelectorAll<HTMLElement>('[data-nav-release-item="true"]'))
let best: number | null = null
for (const release of releases) {
const row = parseInt(release.getAttribute("data-nav-row") || "", 10)
@@ -357,7 +342,11 @@ function registerMovieOutOfBoundsShortcut() {
return null
}
if (direction === "left" && current.hasAttribute("data-nav-cast-item") && currentCol === 0) {
if (
direction === "left" &&
current.hasAttribute("data-nav-cast-item") &&
currentCol === 0
) {
const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow)
if (targetRow === null) return null
return getReleaseAtRow(targetRow)
@@ -766,8 +755,23 @@ const castNavRow = computed(() => {
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
})
const collectionMovies = computed(
(): Array<{
const collectionMovies = computed((): Array<{
title: string
localId: string
coverPath: string | null
rootId: string | null
year: string | null
hyphenLang: string | null
isCurrent: boolean
}> => {
if (props.item.type !== "movies") return []
const movie = props.item.data as Movie
const collectionName = movie.info?.collection?.trim()
if (!collectionName) return []
const normalizedCollectionName = collectionName.toLowerCase()
const matches: Array<{
title: string
localId: string
coverPath: string | null
@@ -775,77 +779,60 @@ const collectionMovies = computed(
year: string | null
hyphenLang: string | null
isCurrent: boolean
}> => {
if (props.item.type !== "movies") return []
}> = []
const movie = props.item.data as Movie
const collectionName = movie.info?.collection?.trim()
if (!collectionName) return []
const normalizedCollectionName = collectionName.toLowerCase()
let hasCurrentInMatches = false
const matches: Array<{
title: string
localId: string
coverPath: string | null
rootId: string | null
year: string | null
hyphenLang: string | null
isCurrent: boolean
}> = []
for (const libraryMovie of props.allMovies || []) {
const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
if (otherCollectionName !== normalizedCollectionName) continue
let hasCurrentInMatches = false
const title = libraryMovie.title || libraryMovie.info?.title
if (!title) continue
for (const libraryMovie of props.allMovies || []) {
const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
if (otherCollectionName !== normalizedCollectionName) continue
const isCurrent = libraryMovie.id === props.item.id
if (isCurrent) hasCurrentInMatches = true
const title = libraryMovie.title || libraryMovie.info?.title
if (!title) continue
matches.push({
title,
localId: libraryMovie.id,
coverPath: libraryMovie.cover_path || null,
rootId: libraryMovie.root_id || null,
year: libraryMovie.year
? String(libraryMovie.year)
: libraryMovie.info?.release_date?.slice(0, 4) || null,
hyphenLang: normalizeHyphenationLang(libraryMovie.info?.original_language),
isCurrent,
})
}
const isCurrent = libraryMovie.id === props.item.id
if (isCurrent) hasCurrentInMatches = true
if (!hasCurrentInMatches) {
matches.push({
title: props.item.title || (props.item.data as Movie).info?.title || "Current movie",
localId: props.item.id,
coverPath: props.item.cover_path || null,
rootId: props.item.root_id || null,
year: props.item.year
? String(props.item.year)
: (props.item.data as Movie).info?.release_date?.slice(0, 4) || null,
hyphenLang: normalizeHyphenationLang((props.item.data as Movie).info?.original_language),
isCurrent: true,
})
}
matches.push({
title,
localId: libraryMovie.id,
coverPath: libraryMovie.cover_path || null,
rootId: libraryMovie.root_id || null,
year: libraryMovie.year
? String(libraryMovie.year)
: libraryMovie.info?.release_date?.slice(0, 4) || null,
hyphenLang: normalizeHyphenationLang(libraryMovie.info?.original_language),
isCurrent,
})
}
return matches
.sort((a, b) => {
const yearA = parseInt(a.year || "", 10)
const yearB = parseInt(b.year || "", 10)
const hasYearA = Number.isFinite(yearA)
const hasYearB = Number.isFinite(yearB)
if (!hasCurrentInMatches) {
matches.push({
title: props.item.title || (props.item.data as Movie).info?.title || "Current movie",
localId: props.item.id,
coverPath: props.item.cover_path || null,
rootId: props.item.root_id || null,
year: props.item.year
? String(props.item.year)
: (props.item.data as Movie).info?.release_date?.slice(0, 4) || null,
hyphenLang: normalizeHyphenationLang((props.item.data as Movie).info?.original_language),
isCurrent: true,
})
}
return matches
.sort((a, b) => {
const yearA = parseInt(a.year || "", 10)
const yearB = parseInt(b.year || "", 10)
const hasYearA = Number.isFinite(yearA)
const hasYearB = Number.isFinite(yearB)
if (hasYearA && hasYearB && yearA !== yearB) return yearA - yearB
if (hasYearA !== hasYearB) return hasYearA ? -1 : 1
return a.title.localeCompare(b.title)
})
.slice(0, 24)
},
)
if (hasYearA && hasYearB && yearA !== yearB) return yearA - yearB
if (hasYearA !== hasYearB) return hasYearA ? -1 : 1
return a.title.localeCompare(b.title)
})
.slice(0, 24)
})
function normalizeHyphenationLang(language: string | null | undefined): string | null {
if (!language) return null
@@ -84,7 +84,7 @@ const disabled = computed(() => !props.filePath)
function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return []
return Array.from(
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)"),
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)")
)
}
@@ -336,12 +336,7 @@ const hdr10PlusPattern = /hdr10\+|hdr10plus/i
const hasHdr10Plus = computed(() => {
if (props.torrent.hdr10plus) return true
const text = [
props.torrent.title,
props.torrent.quality,
props.torrent.codec,
props.torrent.audio,
]
const text = [props.torrent.title, props.torrent.quality, props.torrent.codec, props.torrent.audio]
.filter(Boolean)
.join(" ")
return hdr10PlusPattern.test(text)
+30 -47
View File
@@ -96,9 +96,7 @@
formatDate(selectedSeason.air_date)
}}</span>
<span
>{{
selectedSeason.episode_count ?? selectedSeason.episodes.length
}}
>{{ selectedSeason.episode_count ?? selectedSeason.episodes.length }}
Episodes</span
>
</div>
@@ -114,7 +112,7 @@
<div class="episodes-grid">
<div
v-for="(episode, eIndex) in selectedSeason?.episodes || []"
:key="`${seriesIdentity}-${selectedSeasonIndex}-${episode.episode_number}`"
:key="`${selectedSeasonIndex}-${episode.episode_number}`"
class="episode-tile"
:class="{ 'episode-tile--ahead': isEpisodeAhead(eIndex) }"
v-bind="getEpisodeNavAttrs(selectedSeasonIndex, eIndex)"
@@ -126,7 +124,9 @@
@contextmenu="handleContextMenu($event, episode)"
>
<!-- SVG focus outline -->
<div class="tile-focus-outline"></div>
<svg class="tile-focus-outline" viewBox="0 0 100 100" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="100" />
</svg>
<!-- Episode preview media -->
<div class="tile-media">
@@ -161,7 +161,9 @@
<span
v-if="episodeWatchIndicator(episode)"
class="ep-watch"
:title="episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'"
:title="
episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'
"
>{{ episodeWatchIndicator(episode) }}</span
>
<div class="tile-play"></div>
@@ -256,7 +258,7 @@ import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
import { sortTorrentsByPreference } from "../composables/useSettings"
const props = defineProps<{
series: Series & { id?: string; root_id?: string | null }
series: Series & { root_id?: string | null }
allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
resumePoint?: SeriesResumePoint | null
@@ -295,15 +297,6 @@ const selectedSeason = computed<Season | null>(
() => props.series.seasons[selectedSeasonIndex.value] || null,
)
// Stable identity of the displayed series. Episode tiles carry it in their
// :key so swapping to another series remounts the tiles (and their <video>
// elements) instead of patching <source> children — which browsers ignore,
// leaving the previous series' preview reels playing.
const seriesIdentity = computed(() => {
const s = props.series
return s.id ?? `${s.root_id ?? ""}:${s.title ?? ""}`
})
function selectSeason(index: number) {
if (index < 0 || index >= props.series.seasons.length) return
if (selectedSeasonIndex.value === index) return
@@ -365,7 +358,9 @@ const cursorGlobalIndex = computed(() =>
const resumePointGlobalIndex = computed(() => {
const point = props.resumePoint
if (!point) return null
const seasonIndex = props.series.seasons.findIndex((s) => s.season_number === point.seasonNumber)
const seasonIndex = props.series.seasons.findIndex(
(s) => s.season_number === point.seasonNumber,
)
if (seasonIndex < 0) return null
const episodeIndex = props.series.seasons[seasonIndex]?.episodes.findIndex(
(e) => e.episode_number === point.episodeNumber,
@@ -553,9 +548,6 @@ function getEpisodeNavAttrs(seasonIndex: number, episodeIndex: number) {
...navAttrs(coords.row, coords.col),
"data-season-index": seasonIndex,
"data-episode-index": episodeIndex,
// Entering the episode grid from above (the season selector) always
// lands on the first episode instead of the visually closest tile.
...(episodeIndex === 0 ? { "data-nav-entry-col-from-above": "0" } : {}),
}
}
@@ -646,7 +638,10 @@ watch(
episodeFocusTarget,
(ep) => {
if (!ep) return
if (!props.focusEpisode && (seasonUserInteracted.value || episodeCursorIndex.value !== null)) {
if (
!props.focusEpisode &&
(seasonUserInteracted.value || episodeCursorIndex.value !== null)
) {
return
}
const seasonIndex =
@@ -1411,17 +1406,6 @@ watch(
},
)
// Series swapped under a reused component instance: tiles remount via the
// :key, but per-episode state keyed by episode index alone (audio owner,
// cursor, startup timers) survives — reset it so the old series' playback
// and audio don't bleed into the new one.
watch(seriesIdentity, () => {
episodeCursorIndex.value = null
setAudioOwner(null)
stopEpisodePreviews()
scheduleEpisodeMediaReady()
})
// Episodes past the spoiler threshold (cursor or continue point) are faded;
// stop their playback too.
watch([episodeCursorIndex, resumePointGlobalIndex], () => {
@@ -1843,21 +1827,25 @@ html:not(.mouse-active) .season-poster-card.nav-focused,
}
}
/* 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). */
/* SVG focus outline styles for tiles */
.tile-focus-outline {
position: absolute;
inset: 0;
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: inherit;
box-sizing: border-box;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 5;
opacity: 0;
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 */
html.mouse-active .episode-tile:hover .tile-focus-outline,
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
@@ -1866,9 +1854,9 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
}
/* Brighter outline for keyboard focus */
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
border-color: #ffffff;
border-width: 2.5px;
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
stroke: #ffffff;
stroke-width: 5;
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
}
@@ -1945,12 +1933,7 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
rgba(255, 255, 255, 0.025) 30%,
rgba(255, 255, 255, 0) 55%
),
linear-gradient(
to bottom,
rgba(20, 20, 28, 0.5) 0%,
rgba(0, 0, 0, 0) 40%,
rgba(0, 0, 0, 0.45) 100%
);
linear-gradient(to bottom, rgba(20, 20, 28, 0.5) 0%, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.45) 100%);
}
.episode-tile--ahead::after {
@@ -110,9 +110,7 @@ function getDigitalRepeatIntervalMs(holdMs: number): number {
function getAnalogRepeatIntervalMs(intensity: number): number {
const normalized = Math.min(Math.max(intensity, 0), 1)
return Math.round(
ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized,
)
return Math.round(ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized)
}
function normalizeAxisIntensity(rawValue: number): number {
+1 -10
View File
@@ -2,10 +2,10 @@ import { reportUserActivity } from "../api"
type InputModality = "mouse" | "keyboard" | "gamepad"
const MOUSE_IDLE_MS = 1400
const MOUSE_INTENT_DISTANCE_PX = 28
const MOUSE_INTENT_WINDOW_MS = 700
const MOUSE_OVER_INTENT_RECENCY_MS = 500
const MOUSE_INTENT_SELECTOR = [
"[data-nav-focusable]",
"button",
@@ -28,7 +28,6 @@ let mouseIdleTimer: number | null = null
let pointerVisible = false
let mouseTravelPx = 0
let lastMouseMoveAt = 0
let lastAnyMouseMoveAt = 0
function clearMouseIdleTimer() {
if (mouseIdleTimer !== null) {
@@ -96,7 +95,6 @@ function registerMouseIntentTravel(event: MouseEvent): boolean {
function handleMouseMove(event: MouseEvent) {
reportUserActivity()
showPointerFromMotion()
lastAnyMouseMoveAt = performance.now()
if (modality === "mouse") {
applyInputState(true)
@@ -113,13 +111,6 @@ function handleMouseOver(event: MouseEvent) {
if (modality === "mouse") return
if (!isMouseIntentTarget(event.target)) return
// Browsers fire mouseover/mouseenter when scrolling or re-rendering moves
// content under a stationary cursor (e.g. sideways season browsing while
// the pointer happens to rest over the episode grid). Without recent real
// pointer motion this is not mouse intent — activating mouse input here
// would let hover handlers steal keyboard/gamepad focus.
if (performance.now() - lastAnyMouseMoveAt > MOUSE_OVER_INTENT_RECENCY_MS) return
// Entering an interactive target indicates likely mouse intent.
activateMouseInput()
}
@@ -36,10 +36,6 @@ const FOCUSABLE_ATTR = "data-nav-focusable"
const ROW_ATTR = "data-nav-row"
const COL_ATTR = "data-nav-col"
const ENTRY_COL_ATTR = "data-nav-entry-col"
// Like ENTRY_COL_ATTR, but only applies when entering the row from above
// (ArrowDown). Lets a row declare a fixed landing column for downward entry
// without hijacking upward or horizontal moves.
const ENTRY_COL_FROM_ABOVE_ATTR = "data-nav-entry-col-from-above"
const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row"
const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group"
const DEFAULT_SYNC_SCROLL_GROUP = "browse"
@@ -103,10 +99,7 @@ function measureGlobalMetrics(group: string): boolean {
const rowStyle = window.getComputedStyle(row)
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
const viewportWidth = row.clientWidth
const deadzoneInset = Math.max(
paddingLeft,
(viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO,
)
const deadzoneInset = Math.max(paddingLeft, (viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO)
const leftDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_LEFT_DEADZONE_VAR).trim()
const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw))
? Math.max(0, parseFloat(leftDeadzoneRaw))
@@ -178,7 +171,10 @@ function getRowMaxScroll(row: HTMLElement): number {
if (n === 0) return 0
const lastCol = n - 1
const lastItemLeft = m.paddingLeft + lastCol * m.stride
const maxVisibleLeft = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
const maxVisibleLeft = Math.max(
m.paddingLeft,
m.viewportWidth - m.cardWidth - m.rightDeadzone,
)
return Math.max(0, lastItemLeft - maxVisibleLeft)
}
@@ -280,8 +276,14 @@ function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null
if (!m) return
const itemLeft = m.paddingLeft + anchorCol * m.stride
const leftVisibleLimit = Math.max(m.paddingLeft, m.leftDeadzone)
const rightVisibleLimit = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
const leftVisibleLimit = Math.max(
m.paddingLeft,
m.leftDeadzone,
)
const rightVisibleLimit = Math.max(
m.paddingLeft,
m.viewportWidth - m.cardWidth - m.rightDeadzone,
)
// Keep focus inside the deadzone: no scroll while the focused item remains
// between left and right limits.
@@ -348,9 +350,7 @@ function getLocalSyncedRowCol(
element: HTMLElement,
requestedCol: number,
): number {
const cards = Array.from(
anchorRow.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`),
)
const cards = Array.from(anchorRow.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`))
if (cards.length === 0) return Math.max(0, requestedCol)
const cardCols = cards
@@ -462,7 +462,9 @@ function ensureElementVisibleVertically(element: HTMLElement) {
// Element finding / navigation (unchanged logic, uses getMetrics() now)
// ---------------------------------------------------------------------------
function resolveOutOfBoundsNavigation(context: OutOfBoundsNavigationContext): HTMLElement | null {
function resolveOutOfBoundsNavigation(
context: OutOfBoundsNavigationContext,
): HTMLElement | null {
const handlers = Array.from(outOfBoundsHandlers)
for (let i = handlers.length - 1; i >= 0; i--) {
const result = handlers[i]?.(context)
@@ -617,7 +619,10 @@ function findElementClosestToLogicalViewportX(
return nearest
}
function findNextElement(current: HTMLElement, direction: NavDirection): HTMLElement | null {
function findNextElement(
current: HTMLElement,
direction: NavDirection,
): HTMLElement | null {
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10)
const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10)
const byRow = getElementsByRow()
@@ -667,21 +672,8 @@ function findNextElement(current: HTMLElement, direction: NavDirection): HTMLEle
desiredCol.value = currentCol
}
const targetRowElements = byRow.get(targetRow) ?? []
if (direction === "down") {
for (const el of targetRowElements) {
const fromAboveCol = el.element.getAttribute(ENTRY_COL_FROM_ABOVE_ATTR)
if (fromAboveCol === null) continue
const fromAboveTarget = targetRowElements.find((e) => e.col === parseInt(fromAboveCol, 10))
if (fromAboveTarget) {
desiredCol.value = fromAboveTarget.col
return fromAboveTarget.element
}
}
}
const entryTarget = findElementAt(targetRow, targetCol, true)
const targetRowElements = byRow.get(targetRow) ?? []
const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR))
if (hasEntryOverride) {
return entryTarget?.element || null
+7 -17
View File
@@ -159,7 +159,10 @@ export function useMediaWebSocket() {
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
}
function normalizeCastMember(member: unknown, people: Map<number, Person>): CastMember {
function normalizeCastMember(
member: unknown,
people: Map<number, Person>,
): CastMember {
if (!Array.isArray(member)) {
return {
name: "",
@@ -343,10 +346,7 @@ export function useMediaWebSocket() {
}
}
function mergeItemsByHash<T extends MovieUi | SeriesUi>(
items: T[],
mergeFn: (a: T, b: T) => T,
): T[] {
function mergeItemsByHash<T extends MovieUi | SeriesUi>(items: T[], mergeFn: (a: T, b: T) => T): T[] {
const map = new Map<string, T[]>()
for (const item of items) {
const hash = getContentHash(item.id)
@@ -436,14 +436,7 @@ export function useMediaWebSocket() {
connected.value = wsRef.value?.readyState === WebSocket.OPEN
}
function applyRootInit(
rootId: string,
rootData: {
movies: Record<string, Movie>
series: Record<string, Series>
people?: Record<string, unknown>
},
) {
function applyRootInit(rootId: string, rootData: { movies: Record<string, Movie>; series: Record<string, Series>; people?: Record<string, unknown> }) {
const state = ensureRootState(rootId)
state.peopleMap.clear()
@@ -509,10 +502,7 @@ export function useMediaWebSocket() {
const parsed = Number(id)
const normalized = normalizePerson(person)
if (Number.isFinite(parsed)) {
state.peopleMap.set(
parsed,
normalized || { name: "", profile_path: null, gender: null },
)
state.peopleMap.set(parsed, normalized || { name: "", profile_path: null, gender: null })
}
}
}
-128
View File
@@ -1,128 +0,0 @@
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))
}
+4 -10
View File
@@ -17,9 +17,9 @@ const STORAGE_KEY = "MediaHive"
const RESOLUTION_PRIORITY: Record<string, number> = {
"8K": 5,
"4K": 4,
FHD: 3,
HD: 2,
SD: 1,
"FHD": 3,
"HD": 2,
"SD": 1,
}
// Max resolution priority allowed for each preference level
@@ -59,13 +59,7 @@ function loadSettings(): MediaHiveSettings {
} catch {
// ignore parse errors
}
return {
preferredResolution: "rmax",
preferredHdr: "none",
playerId: "default",
playerCustomCmd: null,
playerMpcPort: null,
}
return { preferredResolution: "rmax", preferredHdr: "none", playerId: "default", playerCustomCmd: null, playerMpcPort: null }
}
const settings = reactive<MediaHiveSettings>(loadSettings())
@@ -1,9 +0,0 @@
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
}
+2 -2
View File
@@ -37,9 +37,9 @@ const router = createRouter({
component: EmptyRouteComponent,
},
{
// Settings is now an overlay with no URL of its own; keep old links working.
path: "/settings",
redirect: "/movies",
name: "settings",
component: EmptyRouteComponent,
},
{
path: "/series",
+12 -6
View File
@@ -1,7 +1,13 @@
// Search Web Worker - runs search off the main thread
// This file is loaded as a Web Worker, not imported as a module.
import type { MovieUi, SeriesUi, MatchedPerson, MatchedEpisode, SearchMatchInfo } from "./types"
import type {
MovieUi,
SeriesUi,
MatchedPerson,
MatchedEpisode,
SearchMatchInfo,
} from "./types"
// ---------------------------------------------------------------------------
// Message types
@@ -59,7 +65,7 @@ let series: SeriesUi[] = []
function normalizeSearchText(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9-]+/g, " ")
.replace(/[^a-z0-9\-]+/g, " ")
.trim()
.replace(/\s+/g, " ")
}
@@ -271,9 +277,7 @@ function getMatchedWordIndexes(queryWords: string[], value: string): number[] {
}
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void {
const existing = target.find(
(person) => person.name.toLowerCase() === candidate.name.toLowerCase(),
)
const existing = target.find((person) => person.name.toLowerCase() === candidate.name.toLowerCase())
if (existing) {
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
if (candidate.highlightRoles) existing.highlightRoles = true
@@ -288,7 +292,9 @@ function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): vo
}
function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
const sorted = indexes.filter((index) => availableIndexes.has(index)).sort((a, b) => a - b)
const sorted = indexes
.filter((index) => availableIndexes.has(index))
.sort((a, b) => a - b)
if (sorted.length === 0) return []
-5
View File
@@ -106,11 +106,6 @@ html.mouse-active ::-webkit-scrollbar-thumb:hover {
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 {
content: "";
position: absolute;
+6 -5
View File
@@ -566,7 +566,9 @@ export function formatLanguageFlagTitle(
): string {
const names: string[] = []
const variants: string[] = []
const external = new Set((externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)))
const external = new Set(
(externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)),
)
let hasExternal = false
for (const code of entry.sourceCodes) {
const normalized = resolveLanguageIdentifier(code)
@@ -576,10 +578,9 @@ export function formatLanguageFlagTitle(
// Explicit region tags (en-us, es-419) become parenthesized variants;
// plain codes contribute their host country.
const suffix = normalized.split("-").pop() ?? ""
const region =
/^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
? suffix.toUpperCase()
: mapLanguageToCountry(code)
const region = /^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
? suffix.toUpperCase()
: mapLanguageToCountry(code)
const regionName = region ? toRegionName(region) : null
const variant = external.has(normalized)
? regionName
+5 -5
View File
@@ -8,11 +8,11 @@
* - Disables Vite's screen clearing on startup
*
* Options:
* paths - Array of paths to proxy (default: ['/api'])
* paths - Array of paths to proxy (default: ["/api"])
*/
export default function fastapiVue({ paths = ['/api'] } = {}) {
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || 'http://localhost:8421'
export default function fastapiVue({ paths = ["/api"] } = {}) {
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8421"
// Build proxy configuration for each path
const proxy = {}
@@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ['/api'] } = {}) {
}
return {
name: 'vite-plugin-fastapi-mediahive',
name: "vite-plugin-fastapi-mediahive",
config: () => ({
clearScreen: false,
server: { proxy },
build: {
outDir: '../mediahive/frontend-build',
outDir: "../mediahive/frontend-build",
emptyOutDir: true,
},
}),
-19
View File
@@ -1,19 +0,0 @@
pre-commit:
parallel: true
commands:
ruff-check:
glob: "*.py"
run: .venv/bin/ruff check --fix {staged_files}
stage_fixed: true
ruff-format:
glob: "*.py"
run: .venv/bin/ruff format {staged_files}
stage_fixed: true
oxlint:
glob: "frontend/src/**"
run: npm --prefix frontend run lint
stage_fixed: true
oxfmt:
glob: "frontend/src/**"
run: npm --prefix frontend run format
stage_fixed: true
+7 -17
View File
@@ -1,20 +1,16 @@
"""MediaHive CLI entrypoint."""
import os
# Must be set before fastapi_vue env bindings are created (mediahive.config).
os.environ["FASTAPI_VUE"] = "MEDIAHIVE"
import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from fastapi_vue import env, server
from mediahive.config import config
from fastapi_vue import server
DEFAULT_PORT = 8420
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
def _configure_windows_event_loop_policy() -> None:
@@ -144,11 +140,10 @@ def main() -> None:
name = f"{base_name}{suffix}"
suffix += 1
roots[name] = p.as_posix()
# Teleported to the server process by fastapi-vue's server.run().
config.roots = roots
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots)
if (
env.dev
DEVMODE
and sys.platform == "win32"
and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1"
):
@@ -161,12 +156,7 @@ def main() -> None:
default_port=DEFAULT_PORT,
server_header=False,
loop="none" if sys.platform == "win32" else "auto",
reload=Path(__file__).parent if env.dev and sys.platform != "win32" else False,
# fastapi-vue routes the root logger at INFO in dev / WARNING in prod;
# keep our own loggers visible in production too.
log_config={
"loggers": {"mediahive": {"level": "DEBUG" if env.dev else "INFO"}}
},
reload=Path(__file__).parent if DEVMODE and sys.platform != "win32" else False,
)
+16 -9
View File
@@ -13,19 +13,12 @@ from pathlib import Path
import msgspec
import msgspec.toml
from fastapi_vue import env
from platformdirs import user_config_path, user_log_path
class Config(msgspec.Struct, omit_defaults=True):
media_folder: str | None = None
roots: dict[str, str] | None = None
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:
@@ -44,11 +37,25 @@ def config_path() -> Path:
return config_dir() / "config.toml"
def _migrate_legacy_media_folder(cfg: Config) -> Config:
"""If roots is empty but media_folder exists, seed roots with it."""
if cfg.roots:
return cfg
if not cfg.media_folder:
return cfg
path = Path(cfg.media_folder)
name = path.name or path.anchor.strip("/\\").lower() or "media"
# Resolve collisions simply by using the basename; if user had weird layout
# they can rename via the UI later.
return msgspec.structs.replace(cfg, roots={name: cfg.media_folder})
def load_config() -> Config:
path = config_path()
if path.exists():
try:
return msgspec.toml.decode(path.read_bytes(), type=Config)
cfg = msgspec.toml.decode(path.read_bytes(), type=Config)
return _migrate_legacy_media_folder(cfg)
except OSError, msgspec.DecodeError, msgspec.ValidationError:
return Config()
return Config()
+6 -9
View File
@@ -1,18 +1,13 @@
"""Hivescan CLI entrypoint."""
import os
# Must be set before fastapi_vue env bindings are created (mediahive.config).
os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE")
import argparse
import asyncio
import json
import logging
import os
import sys
from pathlib import Path
from mediahive.config import config
def _configure_windows_event_loop_policy() -> None:
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
@@ -60,9 +55,11 @@ The server exposes a unified endpoint:
args = parser.parse_args()
# Defer filesystem validation to the server; pass raw path via env config.
# Defer filesystem validation to the server; pass raw path via env.
media_root = Path(args.media_folder).expanduser()
config.roots = {media_root.name or "media": media_root.as_posix()}
os.environ["MEDIAHIVE_ROOTS"] = json.dumps({
media_root.name or "media": media_root.as_posix()
})
logging.basicConfig(
level=logging.INFO,
+6 -27
View File
@@ -37,33 +37,12 @@ _SUBTITLE_FLAG_TOKENS = {"forced", "sdh", "cc", "hi", "dhi", "commentary", "sign
# ISO 639-1 -> ISO 639-2/B for common sidecar language tags, so they merge
# with the codes ffmpeg reports for embedded tracks.
_ISO_639_1_TO_639_2 = {
"ar": "ara",
"cs": "ces",
"da": "dan",
"de": "deu",
"el": "ell",
"en": "eng",
"es": "esp",
"fi": "fin",
"fr": "fra",
"he": "heb",
"hi": "hin",
"hu": "hun",
"id": "ind",
"it": "ita",
"ja": "jpn",
"ko": "kor",
"nl": "nld",
"no": "nor",
"pl": "pol",
"pt": "por",
"ru": "rus",
"sv": "swe",
"th": "tha",
"tr": "tur",
"uk": "ukr",
"vi": "vie",
"zh": "zho",
"ar": "ara", "cs": "ces", "da": "dan", "de": "deu", "el": "ell",
"en": "eng", "es": "esp", "fi": "fin", "fr": "fra", "he": "heb",
"hi": "hin", "hu": "hun", "id": "ind", "it": "ita", "ja": "jpn",
"ko": "kor", "nl": "nld", "no": "nor", "pl": "pol", "pt": "por",
"ru": "rus", "sv": "swe", "th": "tha", "tr": "tur", "uk": "ukr",
"vi": "vie", "zh": "zho",
}
# Caches for expensive operations. These are per-scan only: the scanner
+20 -52
View File
@@ -29,7 +29,6 @@ from pathlib import Path
import aiofiles
import msgspec
import msgspec.structs
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import (
@@ -38,10 +37,10 @@ from fastapi.responses import (
Response,
StreamingResponse,
)
from fastapi_vue import Frontend, env
from fastapi_vue import Frontend
from mediahive import updater
from mediahive.config import config, load_config, log_dir, save_config
from mediahive.__main__ import DEVMODE
from mediahive.config import load_config, log_dir
from mediahive.hivescan.images import close_image_client
from mediahive.hivescan.scanner import RootScanner
from mediahive.hivescan.tmdb_client import close_http_client
@@ -975,14 +974,23 @@ async def _activate_all_roots() -> None:
"""
desired: dict[str, str] = {}
# 1. CLI roots (teleported via fastapi-vue's env config) take precedence
# 1. CLI roots via MEDIAHIVE_ROOTS (JSON dict)
env_roots_raw = os.environ.get("MEDIAHIVE_ROOTS")
env_roots: dict[str, str] | None = None
if env_roots_raw:
try:
parsed = json.loads(env_roots_raw)
if isinstance(parsed, dict):
env_roots = parsed
except Exception:
logger.exception("Failed to parse MEDIAHIVE_ROOTS")
# 2. Persisted config roots (used only when CLI roots are not provided)
if config.roots:
desired.update(config.roots)
else:
cfg = load_config()
if cfg.roots:
desired.update(cfg.roots)
cfg = load_config()
if env_roots is not None:
desired.update(env_roots)
elif cfg.roots:
desired.update(cfg.roots)
if not desired:
logger.info("No roots configured; waiting for PUT /api/config/roots")
@@ -1048,7 +1056,7 @@ async def lifespan(_app: FastAPI):
await close_image_client()
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=env.dev)
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE)
# Allow CORS for development
app.add_middleware(
@@ -1081,46 +1089,6 @@ async def get_version():
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"
-82
View File
@@ -1,82 +0,0 @@
"""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)
+24 -31
View File
@@ -24,21 +24,15 @@ import urllib.request
from concurrent.futures import Future, ThreadPoolExecutor
from pathlib import Path
# Must be set before fastapi_vue env bindings are created (mediahive.config);
# this module is the PyInstaller entry point and may run without __main__.
os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE")
import msgspec.structs
import uvicorn
import velopack
import webview
from fastapi_vue import env
from fastapi_vue.logging import patch_log_config
from fastapi_vue.startupbox import print_box
from tracerite.html import html_traceback
from mediahive import updater
from mediahive.config import config, load_config, log_dir, save_config
from mediahive.config import load_config, log_dir, save_config
from mediahive.volume_control import get_volume, set_volume, volume_max
logger = logging.getLogger("mediahive.winmain")
@@ -49,6 +43,7 @@ HEALTH_TIMEOUT = 2 # seconds
BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds
BACKEND_HEALTH_POLL_SECONDS = 0.25
MPC_BE_URL = "http://127.0.0.1:13579"
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
GAMEPAD_REPEAT_SECONDS = 0.008
GAMEPAD_POLL_SECONDS = 0.008
MPC_BE_FRAME_REPEAT_SECONDS = 0.016
@@ -989,14 +984,25 @@ def _velopack_startup() -> None:
def _check_for_updates() -> None:
"""Download available updates in the background (unless disabled in config).
"""Download available updates in the background.
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()
try:
mgr = velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
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)
def gui_main() -> None:
@@ -1022,16 +1028,6 @@ class JsApi:
result = self._window.create_file_dialog(webview.FOLDER_DIALOG)
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:
"""Set system master volume from slider position ``x`` (0.0 .. 1.5)."""
# Clamp to the platform's maximum so the slider never exceeds what
@@ -1206,6 +1202,10 @@ def winmain() -> None:
initial_roots[name] = p.as_posix()
elif cfg.roots:
initial_roots = cfg.roots
elif cfg.media_folder:
p = _normalize_media_root_input(cfg.media_folder)
name = p.name or "media"
initial_roots[name] = p.as_posix()
if not initial_roots:
folder = _run_initial_setup()
@@ -1219,9 +1219,8 @@ def winmain() -> None:
if cfg.roots != initial_roots:
save_config(msgspec.structs.replace(cfg, roots=initial_roots))
# Pass roots to the in-process server via the shared env config
# (validation deferred to server startup)
config.roots = initial_roots
# Pass roots to the server via env (validation deferred to server startup)
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(initial_roots)
backend_port = _reserve_backend_port()
backend_url = f"http://{BACKEND_HOST}:{backend_port}"
@@ -1239,13 +1238,7 @@ def winmain() -> None:
# log config wires up its access-log middleware, emoji level prefixes and
# tracerite tracebacks (colors are auto-disabled when stderr is not a tty,
# e.g. redirected to the log file in frozen builds).
log_config = patch_log_config(uvicorn.config.LOGGING_CONFIG)
# fastapi-vue routes the root logger at INFO in dev / WARNING in prod;
# keep our own loggers visible in production too.
log_config.setdefault("loggers", {})["mediahive"] = {
"level": "DEBUG" if env.dev else "INFO"
}
uvicorn_config = uvicorn.Config(
config = uvicorn.Config(
"mediahive.server:app",
host=BACKEND_HOST,
port=backend_port,
@@ -1253,9 +1246,9 @@ def winmain() -> None:
server_header=False,
timeout_graceful_shutdown=0,
access_log=False, # fastapi-vue's middleware replaces uvicorn's
log_config=log_config,
log_config=patch_log_config(uvicorn.config.LOGGING_CONFIG),
)
server = uvicorn.Server(uvicorn_config)
server = uvicorn.Server(config)
backend_thread = threading.Thread(
target=server.run, daemon=True, name="mediahive-backend"
)
+39 -42
View File
@@ -8,7 +8,7 @@ dependencies = [
"aiofiles>=25.1.0",
"aiopathlib>=0.6.0",
"bencodepy>=0.9.5",
"fastapi-vue~=1.7.2",
"fastapi-vue>=1.4.1",
"fastapi[standard]>=0.128.0",
"httpx[http2]>=0.28.1",
"msgspec>=0.19",
@@ -58,14 +58,11 @@ gui = [
"velopack>=1.2",
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
"pyinstaller>=6.0",
# scripts/guibuild.py reads the version with it (same logic as hatch-vcs).
"setuptools_scm>=8",
]
[dependency-groups]
dev = [
"httpx>=0.28.1",
"lefthook>=2.1.14",
"ruff>=0.15.14",
"setuptools-scm>=8",
]
@@ -76,52 +73,52 @@ preview = true
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"incorrect-blank-line-before-class",
"multi-line-summary-second-line",
"docstring-missing-returns",
"missing-trailing-comma",
"print",
"D203",
"D213",
"DOC201",
"COM812",
"T201",
"EM",
"TC",
"raise-vanilla-args",
"TRY003",
"S",
"missing-copyright-notice",
"CPY001",
"PLR",
"PLW",
# TEMP suppressions - revisit and remove after focused cleanup passes.
"complex-structure",
"docstring-missing-exception",
"missing-return-type-undocumented-public-function",
"undocumented-public-function",
"boolean-type-hint-positional-argument",
"missing-type-function-argument",
"undocumented-public-method",
"try-consider-else",
"undocumented-public-init",
"missing-return-type-private-function",
"raise-without-from-inside-except",
"create-subprocess-in-async-function",
"line-too-long",
"implicit-namespace-package",
"boolean-default-value-positional-argument",
"import-outside-top-level",
"undocumented-public-class",
"asyncio-dangling-task",
"private-member-access",
"blocking-path-method-in-async-function",
"invalid-class-name",
"collapsible-if",
"call-datetime-now-without-tzinfo",
"useless-if-else",
"missing-terminal-punctuation",
"missing-trailing-period",
"any-type",
"C901",
"DOC501",
"ANN201",
"D103",
"FBT001",
"ANN001",
"D102",
"TRY300",
"D107",
"ANN202",
"B904",
"ASYNC220",
"E501",
"INP001",
"FBT002",
"PLC0415",
"D101",
"RUF006",
"SLF001",
"ASYNC240",
"N801",
"SIM102",
"DTZ005",
"RUF034",
"D415",
"D400",
"ANN401",
# Allow en-dash in docstrings (used for list formatting)
"ambiguous-unicode-character-docstring",
"RUF002",
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
"non-lowercase-variable-in-function",
"N806",
# Allow inline comments that describe output formats
"commented-out-code",
"ERA001",
# Allow unused local variables in ctypes COM boilerplate
"unused-variable",
"F841",
]
+5 -9
View File
@@ -5,8 +5,8 @@
import argparse
import asyncio
import os
import subprocess
import sys
from contextlib import suppress
from pathlib import Path
import tracerite
@@ -48,11 +48,11 @@ async def run_devserver(
os.environ["MEDIAHIVE_DEV"] = "1"
async with ProcessGroup() as pg:
pg.create_task(check_ports_free(viteurl, backurl))
npm_i = await pg.spawn(*npm_install, cwd=front)
await pg.spawn(*mediahive, *(extra_args or []), vital=True)
await check_ports_free(viteurl, backurl)
await pg.spawn(*mediahive, *(extra_args or []))
await pg.wait(npm_i, ready(backurl, path=HEALTH))
await pg.spawn(*vite, cwd=front, vital=True)
await pg.spawn(*vite, cwd=front)
def main() -> None:
@@ -75,12 +75,8 @@ def main() -> None:
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
)
args, extra_args = parser.parse_known_args()
try:
with suppress(KeyboardInterrupt):
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
except* KeyboardInterrupt:
pass # user stopped the devserver: normal exit
except* subprocess.SubprocessError, RuntimeError:
raise SystemExit(1) from None # logged in devutil already; exit 1
HELP_EPILOG = """
+4 -11
View File
@@ -10,27 +10,20 @@ from pathlib import Path
MIN_NODE_VERSION = 20
class _Formatter(logging.Formatter):
"""Prefix formatter, intentionally different from fastapi_vue.logging.
INFO and below pass through unprefixed so messages can use their own
markings (>>>, ###); WARNING and above get an emoji prefix.
"""
class _PrefixFormatter(logging.Formatter):
"""Formatter that adds prefix based on log level."""
def format(self, record: logging.LogRecord) -> str:
if record.levelno >= logging.ERROR:
return f"🛑 {record.getMessage()}"
if record.levelno >= logging.WARNING:
return f"💣 {record.getMessage()}"
return f"⚠️ {record.getMessage()}"
return record.getMessage()
_handler = logging.StreamHandler()
_handler.setFormatter(_Formatter())
_handler.setFormatter(_PrefixFormatter())
logger = logging.getLogger("fastapi-vue")
logger.addHandler(_handler)
logger.setLevel(logging.INFO)
logger.propagate = False # own handler; do not double-print via a configured root
def _check_node_version(node_path: str) -> None:
+92 -72
View File
@@ -1,89 +1,110 @@
"""Utilities meant for devserver script, used only in source repository with dev deps."""
from __future__ import annotations
import asyncio
import subprocess
import sys
from asyncio.subprocess import Process
from contextlib import suppress
from pathlib import Path
from subprocess import CalledProcessError
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Self
from urllib.parse import urlsplit
from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint
if TYPE_CHECKING:
from collections.abc import Awaitable
from collections.abc import Coroutine
class ProcessGroup(asyncio.TaskGroup):
"""TaskGroup with structured ownership of async subprocesses."""
class ProcessGroup:
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
def __init__(self, *, terminate_timeout: float = 10) -> None:
"""Set the grace period before terminate() escalates to kill()."""
super().__init__()
self._terminate_timeout = terminate_timeout
self._cmds: dict[Process, tuple[str, ...]] = {}
def __init__(self) -> None:
"""Initialize empty process tracking."""
self._procs: list[asyncio.subprocess.Process] = []
self._cmds: dict[int, str] = {} # pid -> command name
async def spawn(
self, *cmd: str, cwd: str | None = None, vital: bool = False
) -> Process:
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
self,
*cmd: str,
cwd: str | None = None,
) -> asyncio.subprocess.Process:
"""Spawn a subprocess and track it."""
cmd_name = Path(cmd[0]).stem
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._procs.append(proc)
self._cmds[proc.pid] = cmd_name
return proc
async def run() -> None:
name = Path(cmd[0]).stem
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
try:
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._cmds[proc] = cmd
started.set_result(proc)
except Exception as e: # ruff: ignore[blind-except]
started.set_exception(e)
return
async def wait(
self,
*waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any],
) -> None:
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
try:
returncode = await proc.wait()
finally:
with suppress(ProcessLookupError):
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
except TimeoutError:
with suppress(ProcessLookupError):
proc.kill()
await proc.wait()
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
returncode = await proc.wait()
if returncode != 0:
cmd_name = self._cmds.get(proc.pid, "unknown")
raise subprocess.CalledProcessError(returncode, cmd_name)
if vital:
logger.warning("Vital process %s exited", name)
raise CalledProcessError(returncode, cmd)
tasks = [
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
for w in waitables
]
try:
await asyncio.gather(*tasks)
except subprocess.CalledProcessError as e:
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
raise SystemExit(1) from None
started = asyncio.get_running_loop().create_future()
self.create_task(run())
return await asyncio.shield(started)
async def __aenter__(self) -> Self:
"""Enter the async context manager."""
return self
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
"""Wait concurrently and return results in argument order."""
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
"""Wait for one process to exit, terminate others, then wait for all."""
await self._cleanup(immediate=exc_type is not None)
async def task(w: Process | Awaitable) -> Any:
if not isinstance(w, Process):
return await w
if retcode := await w.wait():
cmd = self._cmds[w]
logger.warning(
"Process %s exited with status %d", Path(cmd[0]).stem, retcode
async def _cleanup(self, *, immediate: bool = False) -> None:
running = [p for p in self._procs if p.returncode is None]
if not running:
return
if not immediate:
# Wait for any one process to exit
with suppress(asyncio.CancelledError):
await asyncio.wait(
[asyncio.create_task(p.wait()) for p in running],
return_when=asyncio.FIRST_COMPLETED,
)
raise CalledProcessError(retcode, cmd)
return retcode
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(task(w)) for w in waitables]
# Terminate remaining processes
for p in self._procs:
if p.returncode is None:
with suppress(ProcessLookupError):
p.terminate()
return tuple(task.result() for task in tasks)
# Wait for all to finish (with overall timeout), shielded from cancellation
still_running = [p for p in self._procs if p.returncode is None]
if still_running:
with suppress(asyncio.CancelledError):
try:
await asyncio.shield(
asyncio.wait_for(
asyncio.gather(*[p.wait() for p in still_running]),
timeout=10,
),
)
except TimeoutError:
for p in self._procs:
if p.returncode is None:
with suppress(ProcessLookupError):
p.kill()
await p.wait()
async def http_get_server(url: str, timeout: float) -> str | None: # ruff: ignore[async-function-with-timeout]
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
"""GET url with plain asyncio streams, return the response Server header.
Returns an empty string when the server responds without a Server header,
@@ -106,43 +127,42 @@ async def http_get_server(url: str, timeout: float) -> str | None: # ruff: igno
writer.close()
except OSError, EOFError, ValueError, TimeoutError:
return None
for line in data.decode(errors="replace").split("\r\n"):
for line in data.decode("latin-1").split("\r\n"):
if line.lower().startswith("server:"):
return line[7:].strip()
return line.split(":", 1)[1].strip()
return ""
async def check_ports_free(*urls: str) -> None:
"""Verify URLs are not responding (ports are free).
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
Meant to run as a task inside a TaskGroup. Logs the conflict and raises
RuntimeError (handled like a failed process) if any URL responds.
"""
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
for url, server in zip(urls, servers, strict=True):
async def check(url: str) -> None:
server = await http_get_server(url, timeout=0.1)
if server is not None:
logger.error(
logger.warning(
"Conflicting %s already running at %s", server or "server", url
)
raise RuntimeError(url)
raise SystemExit(1)
await asyncio.gather(*[check(url) for url in urls])
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
"""Wait for the server to be ready by polling an endpoint.
Use empty path to disable the check and make this return immediately.
Logs, then raises RuntimeError if the server doesn't start in time.
Raises SystemExit(1) if server doesn't start in time.
"""
if not path:
return
for attempt in range(max_attempts):
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
logger.info("🟢 Backend ready!")
logger.info(" Backend ready!")
return
if attempt == max_attempts - 1:
logger.error("Backend at %s didn't start in time", url)
raise RuntimeError(url)
logger.warning("Backend didn't start in time")
raise SystemExit(1)
await asyncio.sleep(0.1)
+15 -57
View File
@@ -1,20 +1,11 @@
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "mediahive[gui]",
# ]
#
# [tool.uv.sources]
# mediahive = { path = "../" }
# ///
"""Build the desktop GUI application and package it with Velopack.
Usage:
uv run scripts/guibuild.py
Self-contained: inline script dependencies above make uv resolve the
package (with the gui extra) plus this script's own direct imports.
This runs in the project environment where dependencies
are available via pyproject.toml.
This script:
1. Reads the version from pyproject.toml
@@ -91,42 +82,15 @@ class _Platform(NamedTuple):
def _platform() -> _Platform:
if sys.platform == "win32":
return _Platform(
"win64",
"win",
"win-x64",
"MediaHive",
"mediahive.ico",
"MediaHive.exe",
".exe",
)
return _Platform("win64", "win", "win-x64", "MediaHive", "mediahive.ico", "MediaHive.exe", ".exe")
if sys.platform == "darwin":
return _Platform(
"macos",
"osx",
"osx-arm64",
"MediaHive.app",
"mediahive.icns",
"MediaHive",
".pkg",
)
return _Platform(
"linux",
"linux",
"linux-x64",
"MediaHive",
"mediahive.png",
"MediaHive",
".AppImage",
)
return _Platform("macos", "osx", "osx-arm64", "MediaHive.app", "mediahive.icns", "MediaHive", ".pkg")
return _Platform("linux", "linux", "linux-x64", "MediaHive", "mediahive.png", "MediaHive", ".AppImage")
def setup_artifact_name() -> str:
"""Versionless name so releases/download/latest/<name> links stay valid."""
def setup_artifact_name(version: str) -> str:
p = _platform()
# Windows keeps the -setup suffix: a bare .exe isn't self-explanatory.
suffix = "-setup" if sys.platform == "win32" else ""
return f"MediaHive-{p.tag}{suffix}{p.setup_ext}"
return f"MediaHive-{version}-{p.tag}-setup{p.setup_ext}"
def fetch_ffmpeg() -> Path:
@@ -286,7 +250,7 @@ def _dotnet_runtime_major(exe: Path) -> int | None:
result = subprocess.run(
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
)
except OSError, subprocess.TimeoutExpired:
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
@@ -417,7 +381,7 @@ def build_velopack(version: str) -> Path:
raise RuntimeError(f"vpk produced no *{plat.setup_ext} in {releases_dir}")
if sys.platform == "darwin":
force_macos_user_install(setup)
artifact = _REPO_ROOT / "build" / setup_artifact_name()
artifact = _REPO_ROOT / "build" / setup_artifact_name(version)
artifact.unlink(missing_ok=True)
setup.rename(artifact)
rename_feed_package(releases_dir, version, plat.channel)
@@ -488,26 +452,20 @@ def force_macos_user_install(pkg: Path) -> None:
components = list(expanded.glob("*.pkg"))
if len(components) != 1:
contents = sorted(p.name for p in expanded.iterdir())
raise RuntimeError(
f"Unexpected pkg layout: components={components} in {contents}"
)
raise RuntimeError(f"Unexpected pkg layout: components={components} in {contents}")
component = components[0]
if component.is_dir():
comp_dir = component
else:
comp_dir = expanded / (component.stem + "-component")
subprocess.run(
["pkgutil", "--expand", str(component), str(comp_dir)], check=True
)
subprocess.run(["pkgutil", "--expand", str(component), str(comp_dir)], check=True)
postinstall = comp_dir / "Scripts" / "postinstall"
script = postinstall.read_text()
if 'sudo -u "$USER" ' not in script:
raise RuntimeError("Unexpected postinstall script: sudo prefix not found")
postinstall.write_text(script.replace('sudo -u "$USER" ', ""))
if comp_dir is not component:
subprocess.run(
["pkgutil", "--flatten", str(comp_dir), str(component)], check=True
)
subprocess.run(["pkgutil", "--flatten", str(comp_dir), str(component)], check=True)
shutil.rmtree(comp_dir)
subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True)
@@ -551,7 +509,7 @@ def build_executable() -> None:
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
def create_portable_zip() -> Path:
def create_portable_zip(version: str) -> Path:
"""Create the Windows portable ZIP of the build/MediaHive folder.
Velopack-less plain-folder distribution for users who cannot or do not
@@ -562,7 +520,7 @@ def create_portable_zip() -> Path:
if not dist_folder.exists():
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
zip_path = _REPO_ROOT / "build" / "MediaHive-win64-portable.zip"
zip_path = _REPO_ROOT / "build" / f"MediaHive-{version}-win64-portable.zip"
print(f"Creating {zip_path}...")
shutil.make_archive(
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
@@ -595,7 +553,7 @@ def main() -> None:
artifacts = [build_velopack(version)]
if sys.platform == "win32":
artifacts.append(create_portable_zip())
artifacts.append(create_portable_zip(version))
for artifact_path in artifacts:
print(f"✓ Built successfully: {artifact_path}")
+37 -41
View File
@@ -9,9 +9,8 @@ Reads from [project.urls] Repository in pyproject.toml.
Token: GITEA_TOKEN environment variable
Steps:
1. Read the clean tag version via setuptools_scm, find platform artifacts
in build/ and matching dist/ wheels/sdists
2. Abort if any dist files are missing
1. Find clean-versioned platform artifacts in build/ and matching dist/ wheels/sdists
2. Abort if any dist files are missing for a found artifact version
3. Create a Gitea release for each version (or reuse the existing one
for the tag, skipping already-uploaded assets) and upload all assets
4. Remind the user to run: uv publish
@@ -29,7 +28,6 @@ from pathlib import Path
from urllib.parse import urlparse
import httpx
import setuptools_scm
REPO_ROOT = Path(__file__).parent.parent
@@ -69,31 +67,23 @@ def load_token() -> str:
# ZIP + dist helpers
# ---------------------------------------------------------------------------
# Installer artifacts are versionless (MediaHive-win64-setup.exe,
# MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage,
# MediaHive-win64-portable.zip) so /releases/download/latest/<name> links
# stay valid. The version comes from setuptools_scm instead.
_ARTIFACT_RE = re.compile(
r"^MediaHive-(?!\d)[A-Za-z0-9._-]+\.(?:zip|dmg|exe|pkg|AppImage)$"
# Matches MediaHive-1.2.3-win64-portable.zip, MediaHive-1.2.3-win64-setup.exe,
# MediaHive-1.2.3-macos-setup.pkg, MediaHive-1.2.3-linux-setup.AppImage, etc.
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64-portable.zip
_CLEAN_ARTIFACT_RE = re.compile(
r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.(?:zip|dmg|exe|pkg|AppImage)$"
)
def read_version() -> str:
"""Read version via setuptools_scm, refusing dev/dirty versions."""
version = setuptools_scm.get_version(root=str(REPO_ROOT))
if not re.fullmatch(r"\d+(?:\.\d+)*", version):
raise RuntimeError(
f"Refusing to release non-clean version {version!r}. Tag a release first."
)
return version
def find_releasable_artifacts() -> list[Path]:
"""Return platform artifact paths in build/."""
def find_releasable_artifacts() -> list[tuple[Path, str, str]]:
"""Return (path, version, platform_tag) for clean-versioned artifacts in build/."""
build_dir = REPO_ROOT / "build"
return [
p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)
]
results = []
for p in sorted(build_dir.glob("MediaHive-*")):
m = _CLEAN_ARTIFACT_RE.match(p.name)
if m:
results.append((p, m.group(1), m.group(2)))
return results
def find_dist_files(version: str) -> list[Path]:
@@ -254,40 +244,46 @@ def main() -> None:
try:
cfg = load_gitea_config()
token = load_token()
version = read_version()
artifacts = find_releasable_artifacts()
if not artifacts:
print(
"No platform artifacts found in build/.\n"
"No clean-versioned platform artifacts found in build/.\n"
"Run scripts/guibuild.py first.",
file=sys.stderr,
)
sys.exit(1)
# Validate all dist files exist before touching Gitea
dist_files: list[Path] = [] if args.no_dist else find_dist_files(version)
dist_files: dict[str, list[Path]] = {}
if not args.no_dist:
for _, version, _platform_tag in artifacts:
dist_files[version] = find_dist_files(version)
base_url = cfg["url"].rstrip("/")
repo = cfg["repo"]
with httpx.Client(headers=gitea_headers(token)) as client:
print(f"\nReleasing {version} ...")
tag = f"v{version}"
release_id, uploaded = create_release(
client, base_url, repo, tag, version, args.notes, args.draft
)
for path in dist_files:
if path.name in uploaded:
print(f"Skipping {path.name}, already on the release.")
continue
upload_asset(client, base_url, repo, release_id, path)
releases: dict[str, tuple[int, set[str]]] = {}
for artifact_path, version, platform_tag in artifacts:
print(f"\nReleasing {version} ...")
tag = f"v{version}"
if version not in releases:
releases[version] = create_release(
client, base_url, repo, tag, version, args.notes, args.draft
)
release_id, uploaded = releases[version]
for path in dist_files.get(version, []):
if path.name in uploaded:
print(f"Skipping {path.name}, already on the release.")
continue
upload_asset(client, base_url, repo, release_id, path)
for artifact_path in artifacts:
release_id, uploaded = releases[version]
if artifact_path.name in uploaded:
print(f"Skipping {artifact_path.name}, already on the release.")
continue
print(f"Uploading platform artifact: {artifact_path.name}")
print(f"Uploading platform artifact: {platform_tag}")
upload_asset(client, base_url, repo, release_id, artifact_path)
uploaded.add(artifact_path.name)
for feed_file in find_velopack_feed_files():
@@ -296,7 +292,7 @@ def main() -> None:
continue
upload_asset(client, base_url, repo, release_id, feed_file)
uploaded.add(feed_file.name)
print(f"{tag} published")
print(f"{tag} published")
print("\nDone. To publish to PyPI, run:")
print(" uv publish")