Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1223b8cee7 | ||
|
|
7e99ec2f16 | ||
|
|
38a10725e0 | ||
|
|
8b0c7a7af1 | ||
|
|
d01bf1d88f | ||
|
|
9b12e53039 | ||
|
|
7e2fcc05f6 | ||
|
|
ecd5d3bc9e | ||
|
|
82d5eb28fb | ||
|
|
1149e7cdfd | ||
|
|
38f3ccebd3 | ||
|
|
c41504e70c | ||
|
|
bb70279e61 | ||
|
|
931f00f647 | ||
|
|
9041061e86 | ||
|
|
5fc20c5578 | ||
|
|
550131d43b | ||
|
|
f2fc6f657f | ||
|
|
1477c240a1 | ||
|
|
c891bc84a8 | ||
|
|
c11cbd4250 | ||
|
|
9779857dcd | ||
|
|
9ec4f877eb | ||
|
|
6a6a012efe | ||
|
|
2c28ab1f25 | ||
|
|
e6eadb2ecd | ||
|
|
ff6b195973 | ||
|
|
36e2fdd5ff | ||
|
|
8f462b9e1d | ||
|
|
838db5b55c | ||
|
|
e5300eaac0 | ||
|
|
d1f1b9ecb8 | ||
|
|
26af7c633b | ||
|
|
c072f15cb5 | ||
|
|
23030cd1c4 | ||
|
|
2a39e1f0ea | ||
|
|
d3addadf14 | ||
|
|
0a4d54c1b7 | ||
|
|
c2776d2e2d | ||
|
|
e5bc736ffa | ||
|
|
258fc79753 | ||
|
|
7a60ef5384 | ||
|
|
22454f2d29 | ||
|
|
589c789d4d | ||
|
|
5f2454d8e8 | ||
|
|
2fa15132fb | ||
|
|
76cd0224de | ||
|
|
b0d13a67a0 | ||
|
|
568605b09a | ||
|
|
b09118c8cb | ||
|
|
f79c044250 | ||
|
|
a4b0cd916a | ||
|
|
e1a961d21f | ||
|
|
1a6a3f0cf2 | ||
|
|
626fb9a0ae | ||
|
|
b6742f0f27 | ||
|
|
6d0e6d41a7 | ||
|
|
932c5af1ff | ||
|
|
eb49eb713e | ||
|
|
d7eea51334 | ||
|
|
01f424dbfb | ||
|
|
df9025760d | ||
|
|
09e51acd14 |
@@ -0,0 +1,3 @@
|
|||||||
|
# Mass lint/format commits that add noise to git blame.
|
||||||
|
# Enable locally with: git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||||
|
38f3cce # Apply linters and formatters
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
# Runner host prerequisites: git, uv, node/npm, .NET SDK.
|
||||||
|
jobs:
|
||||||
|
gui-build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: macos
|
||||||
|
shell: bash
|
||||||
|
- os: windows
|
||||||
|
shell: cmd
|
||||||
|
- os: linux
|
||||||
|
shell: bash
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
# Plain git clone: full history so setuptools_scm sees tags, and both
|
||||||
|
# shells work. Windows uses cmd: bash resolves to WSL (refuses SYSTEM
|
||||||
|
# accounts) and powershell hits the script execution policy under SYSTEM.
|
||||||
|
- name: Checkout
|
||||||
|
shell: ${{ matrix.shell }}
|
||||||
|
run: |
|
||||||
|
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||||
|
git checkout -f "${{ gitea.sha }}"
|
||||||
|
|
||||||
|
# guibuild.py carries its own inline (PEP 723) deps — mediahive[gui]
|
||||||
|
# from the local checkout — so no project sync or --extra is needed.
|
||||||
|
- name: Build GUI app and dist packages
|
||||||
|
shell: ${{ matrix.shell }}
|
||||||
|
run: uv run scripts/guibuild.py
|
||||||
|
|
||||||
|
# Every platform converges on the one release for the tag; release.py
|
||||||
|
# reuses an existing release and skips already-uploaded assets.
|
||||||
|
# Only the linux job publishes the wheel/sdist (identical across platforms).
|
||||||
|
- name: Create Gitea release and upload assets
|
||||||
|
if: matrix.os == 'linux'
|
||||||
|
shell: ${{ matrix.shell }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: uv run scripts/release.py
|
||||||
|
|
||||||
|
- name: Attach platform artifact to the Gitea release
|
||||||
|
if: matrix.os != 'linux'
|
||||||
|
shell: ${{ matrix.shell }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: uv run scripts/release.py --no-dist
|
||||||
|
|
||||||
|
# Publish the Python package to PyPI only once every platform's GUI build
|
||||||
|
# has succeeded. Jobs don't share a workspace, so the platform-independent
|
||||||
|
# wheel/sdist are rebuilt here (same tag version) instead of being passed
|
||||||
|
# around as artifacts.
|
||||||
|
publish-pypi:
|
||||||
|
needs: gui-build
|
||||||
|
runs-on: linux
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||||
|
git checkout -f "${{ gitea.sha }}"
|
||||||
|
|
||||||
|
- name: Build wheel and sdist
|
||||||
|
shell: bash
|
||||||
|
run: uv build
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||||
|
run: uv publish
|
||||||
@@ -19,3 +19,6 @@ package-lock.json
|
|||||||
# Dotfiles
|
# Dotfiles
|
||||||
.*
|
.*
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
!.gitea/
|
||||||
|
!.git-blame-ignore-revs
|
||||||
|
!.pre-commit-config.yaml
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# MediaHive agent notes
|
||||||
|
|
||||||
|
## Python 3.14 syntax: unparenthesized `except` is valid
|
||||||
|
|
||||||
|
This project targets Python **>= 3.14** (see `requires-python` in
|
||||||
|
`pyproject.toml`). Per [PEP 758](https://peps.python.org/pep-0758/) (Final,
|
||||||
|
Python 3.14), multiple exception types may be caught **without parentheses**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
except OSError, ValueError: # valid Python 3.14+, equivalent to except (OSError, ValueError):
|
||||||
|
```
|
||||||
|
|
||||||
|
Parentheses are still required when an `as` clause is used:
|
||||||
|
`except (OSError, ValueError) as e:`.
|
||||||
|
|
||||||
|
Do not "fix" these into tuple form, and do not flag them as Python 2 remnant
|
||||||
|
syntax errors — that rule is obsolete training data. Any syntax validation,
|
||||||
|
compilation check, or linting of this codebase must run under Python 3.14+
|
||||||
|
(e.g. `python -m py_compile` with a 3.14 interpreter); older interpreters
|
||||||
|
will report false SyntaxErrors on this and other 3.14-only constructs.
|
||||||
@@ -1,20 +1,30 @@
|
|||||||

|

|
||||||
|
|
||||||
# MediaHive
|
# MediaHive
|
||||||
|
|
||||||
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
|
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
|
||||||
|
|
||||||
**[Windows and Mac portable ZIP downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
|
## Downloads
|
||||||
|
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
You may also run or install with [UV](https://docs.astral.sh/uv/getting-started/installation/):
|
||||||
|
|
||||||
|
```
|
||||||
|
uvx --from mediahive[gui] mediahive
|
||||||
|
```
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
- Scans your chosen media folder for all movies and series that can be found
|
- Scans your chosen media folder for all movies and series that can be found
|
||||||
- Produces preview video clips and downloads metadata
|
- Produces preview video clips and downloads metadata
|
||||||
- Search on cast and character names, not just titles
|
- Search on names and other metadata, not just titles
|
||||||
|
- Remembers per-episode playback positions and offers series continue points
|
||||||
- Hand off playback to your preferred system player
|
- Hand off playback to your preferred system player
|
||||||
- Implement gamepad controls for MPC-BE on Windows (where needed)
|
|
||||||
|
|
||||||
Extract the ZIP in some place and run MediaHive.exe to start the app. Currently we have no installer, but you can pin to start/taskbar for easier access. On the first startup the app asks for your media folder, that can later be changed by clicking in-app folder icon.
|
On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
|
||||||
|
|
||||||
Note that `.mediahive` folder is created in your media folder to hold all the metadata and preview clips, avoiding the lengthy processing that you will see on initial startup.
|
Note that `.mediahive` folder is created in your media folder to hold all the metadata and preview clips, avoiding the lengthy processing that you will see on initial startup.
|
||||||
|
|
||||||
@@ -22,11 +32,10 @@ Note that `.mediahive` folder is created in your media folder to hold all the me
|
|||||||
|
|
||||||
MediaHive is designed to work with a mouse, keyboard, or gamepad.
|
MediaHive is designed to work with a mouse, keyboard, or gamepad.
|
||||||
|
|
||||||
| Input | Controls |
|
**Keyboard:** Arrow keys, Enter and Escape to navigate, `/` to search and the usual ones you already know.
|
||||||
| --- | --- |
|
|
||||||
| Mouse | Click posters, rows, search, play, and folder actions directly. |
|

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

|
||||||
|
|||||||
+31
-12
@@ -10,23 +10,42 @@ All media paths are scoped to a **root**, identified by a friendly `root_id`
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET` | `/api/health` | Lightweight health check. |
|
| `GET` | `/api/health` | Lightweight health check. |
|
||||||
| `GET` | `/api/config` | Returns the current root configuration. |
|
| `GET` | `/api/config` | Returns the current root configuration. |
|
||||||
| `GET` | `/api/roots` | List all active roots with status. |
|
| `PUT` | `/api/config/roots` | Atomically replace the full root set. Returns `{ "status": "ok", "accepted": [{path, root_id}], "failed": [...] }`. |
|
||||||
| `PUT` | `/api/roots` | Atomically replace the full root set. |
|
| `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). |
|
||||||
| `POST` | `/api/play/{root_id}` | Opens a media file with the system player. |
|
| `PUT` | `/api/config/auto-update` | Enable/disable automatic update downloads. Body `{ "enabled": bool }`, persisted in config. |
|
||||||
| `POST` | `/api/open-folder/{root_id}` | Opens a folder in the system file explorer. |
|
| `POST` | `/api/update/restart` | Applies the staged update and restarts into it. `404` when no update is pending. |
|
||||||
| `GET` | `/api/meta/{root_id}/{meta_key}` | Returns allowed metadata from `<root>/.mediahive`. |
|
| `POST` | `/api/play/{root_id}` | Opens a media file with a media player. Also starts an assumed-playback session (see notes). |
|
||||||
| `GET` | `/api/player/status` | Returns whether remote player control is currently available. |
|
| `GET` | `/api/players` | Lists detected media players. Returns `{ "players": [{id, name, family, path}] }`, including synthetic `default` and `custom` entries. |
|
||||||
| `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable. |
|
| `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. |
|
||||||
|
| `POST` | `/api/open-folder/{root_id}` | Opens a folder in the system file explorer; given a file path, selects the file instead. |
|
||||||
|
| `GET` | `/api/meta/{root_id}/{meta_key}` | Returns allowed metadata from `<root>/.mediahive`, as `{ "key": meta_key, "data": ... }`. |
|
||||||
|
| `GET` | `/api/meta/playback-state` | Returns merged resume positions across all roots, as `{ "key": "playback-state", "data": ... }`. Series entries carry one continue point per series (`season`/`episode` = last watched) plus a per-episode watch map (`episodes`: `"S<season>E<episode>"` → `{pos, ts, done}`); completing an episode marks it done and advances the point to the next episode. |
|
||||||
|
| `POST` | `/api/meta/playback-state` | Updates one resume entry (`root_id`, `file_path`, `pos`; null `pos` clears a movie or advances a series' continue point). Returns `{ "status": "ok", "slug", "pos" }` plus `season`/`episode` when the continue point advances. |
|
||||||
|
| `GET` | `/api/player/status` | Returns whether remote player control is currently available. Accepts an optional `?port=` override (default 13579). |
|
||||||
|
| `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable, as `{ "reachable": true|false }`. Accepts an optional `?port=` override; always `false` on non-Windows. |
|
||||||
| `GET` | `/api/media/{root_id}/{file_path:path}` | Serves files from the specified root. |
|
| `GET` | `/api/media/{root_id}/{file_path:path}` | Serves files from the specified root. |
|
||||||
| `GET` | `/api/assets/{root_id}/{asset_type}/{asset_path:path}` | Serves typed assets from `<root>/.mediahive`. |
|
| `GET` | `/api/assets/{root_id}/{asset_type}/{asset_path:path}` | Serves typed assets from `<root>/.mediahive`. |
|
||||||
| `WS` | `/api/ws/{root_id}` | Streams live index updates and task progress for one root. |
|
| `WS` | `/api/ws` | Streams roots, index updates, and task progress for all roots (see WebSocket notes). |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `PUT /api/roots` accepts `{ "roots": { "name": "/absolute/path", ... } }`, validates paths, and atomically swaps the active set.
|
- `PUT /api/config/roots` accepts `{ "roots": { "name": "/absolute/path", ... } }`, validates paths, and atomically swaps the active set.
|
||||||
- `POST /api/play/{root_id}` and `POST /api/open-folder/{root_id}` expect JSON request bodies with `file_path` / `folder_path` relative to the root.
|
- `POST /api/play/{root_id}` and `POST /api/open-folder/{root_id}` expect JSON request bodies with `file_path` / `folder_path` relative to the root. The play body additionally accepts `player_id` (a value from `GET /api/players`; unknown ids yield 400) and `player_custom_cmd` (command template used when `player_id` is `custom`).
|
||||||
- `GET /api/media/{root_id}/{file_path:path}` is constrained to the specified root; path traversal outside the root is rejected.
|
- `GET /api/media/{root_id}/{file_path:path}` is constrained to the specified root; path traversal outside the root is rejected. Single-range requests are supported (`206` with `Content-Range`, `416` on invalid ranges), responses carry a weak `ETag` (`If-None-Match` yields `304`) and `Cache-Control: public, max-age=604800, immutable`.
|
||||||
- `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` is constrained to `<root>/.mediahive/{asset_type}` where `asset_type` is one of `movies`, `series`, `people`.
|
- `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` is constrained to `<root>/.mediahive/{asset_type}` where `asset_type` is one of `movies`, `series`, `people`.
|
||||||
- `GET /api/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`.
|
- `GET /api/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`.
|
||||||
- `GET /api/player/status` returns `{ "remote": true|false }`.
|
- `GET /api/player/status` returns `{ "remote": true|false }`.
|
||||||
- `GET /api/mpcbe/status` returns `false` on non-Windows platforms.
|
- Roots may also be provided at startup via CLI arguments (`mediahive /path/to/media ...`), which are passed to the server through fastapi-vue's env config (`mediahive.config.config`) and override the persisted configuration.
|
||||||
|
- Assumed playback: after `POST /api/play/{root_id}` the launched item is assumed to be playing while the frontend reports no input activity. On the next `POST /api/activity` the guessed position (`resume base + elapsed`, capped at the TMDb runtime) is written once; watches under 5 minutes are discarded (a peek is not progress). A resume entry written by another tracker (e.g. the GUI's MPC-BE tracker) during the session overrides the guess. The MPC-BE tracker likewise ignores sessions shorter than 5 minutes.
|
||||||
|
|
||||||
|
## WebSocket
|
||||||
|
|
||||||
|
`GET /api/ws` sends tagged msgspec JSON messages as binary frames (message shapes are defined in `mediahive/models/protocol.py`):
|
||||||
|
|
||||||
|
- `roots` — full root list and per-root status: `{roots: [{root_id, path, status, error, snapshot_loaded, movies, series}]}`.
|
||||||
|
- `init` — full index payload `{roots: {root_id: {movies, series, people}}}`, re-sent when the root set changes or a snapshot finishes loading.
|
||||||
|
- `upsert` — single item inserted or updated: `{root_id, kind ("movie"|"series"), id, item, people?}`.
|
||||||
|
- `remove` — single item removed: `{root_id, kind, id}`.
|
||||||
|
- `task` — background task progress: `{root_id, data}`.
|
||||||
|
|
||||||
|
Clients must send (any) text frame to keep the receive loop alive.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
+8
-6
@@ -1,6 +1,6 @@
|
|||||||
# Development
|
# Development
|
||||||
|
|
||||||
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) is aimed at Windows end users.
|
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) covers end-user startup across platforms (installer/AppImage downloads, `uvx --from mediahive[gui] mediahive` on Linux/other).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -40,13 +40,14 @@ uv run --extra gui python -m mediahive.winmain /path/to/media/folder
|
|||||||
|
|
||||||
This launches the same pywebview-based desktop flow used by the Windows build.
|
This launches the same pywebview-based desktop flow used by the Windows build.
|
||||||
|
|
||||||
## Migrate Existing Index Snapshots
|
## Building And Releasing
|
||||||
|
|
||||||
```bash
|
The helper scripts are directly executable via their `uv run` shebang (on Windows, run them with `uv run scripts/<name>.py`):
|
||||||
uv run python scripts/indexmigr.py /path/to/media/root --write
|
|
||||||
```
|
|
||||||
|
|
||||||
This applies versioned snapshot migrations to `.mediahive/index.json` outside the main application. Use it before starting a newer build against an older index.
|
- `./scripts/guibuild.py` builds the PyInstaller desktop app and packages it with Velopack under `build/`: per-user `Setup.exe` (Windows), `.pkg` installer (macOS), `.AppImage` (Linux), plus the update feed in `build/velopack/`. On Windows it also creates a `-win64-portable.zip` (no auto-updates). Requires node/npm and the .NET SDK (>= 10 runtime) installed on the build host; `vpk` and ffmpeg are downloaded once into a persistent user cache (`~/.cache/mediahive-build`, `%LOCALAPPDATA%\mediahive-build` on Windows).
|
||||||
|
- `./scripts/release.py` publishes a release to the Gitea releases page, uploading the platform artifacts and the Velopack update feed files — installed apps auto-update from the latest release.
|
||||||
|
|
||||||
|
Python packaging builds the frontend automatically through the hatch build hook `scripts/fastapi-vue/buildhook.py` (see `pyproject.toml`), so wheels and sdists always ship a fresh `mediahive/frontend-build`.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
@@ -54,3 +55,4 @@ This applies versioned snapshot migrations to `.mediahive/index.json` outside th
|
|||||||
- The desktop app remembers the chosen folder between launches.
|
- The desktop app remembers the chosen folder between launches.
|
||||||
- HTTP and WebSocket endpoints are documented in [API.md](API.md).
|
- HTTP and WebSocket endpoints are documented in [API.md](API.md).
|
||||||
- MPC-BE integration details (Windows only) live in [mpc-be.md](mpc-be.md).
|
- MPC-BE integration details (Windows only) live in [mpc-be.md](mpc-be.md).
|
||||||
|
- Scanner/indexer design notes and the v0.5.0 rescan fixes are reviewed in [scanning-review.md](scanning-review.md).
|
||||||
|
|||||||
+3
-1
@@ -611,7 +611,9 @@ Current native command usage is centered on:
|
|||||||
|
|
||||||
- `889` for play/pause
|
- `889` for play/pause
|
||||||
- `816` for exit
|
- `816` for exit
|
||||||
- `-1&position=HH:MM:SS` for exact 4-second seeking
|
- `-1&position=HH:MM:SS` to seek to the stored resume position when playback starts
|
||||||
|
|
||||||
|
The GUI also polls `/variables.html` for the live position and duration and posts resume positions back to the MediaHive backend (`/api/meta/playback-state`), which is how per-episode resume positions and series continue points are tracked. Sessions shorter than 5 minutes are ignored.
|
||||||
|
|
||||||
## Guidance
|
## Guidance
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
# Multi-Root Implementation Notes
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
MediaHive now supports multiple independent media roots. Each root is a filesystem directory with its own index, scanner, and WebSocket stream. The frontend merges per-root state into a single reactive view.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Root Identity
|
|
||||||
|
|
||||||
- **Root ID**: friendly root name derived from configured path basename.
|
|
||||||
- **Name/ID collision handling**: suffixes `2`, `3`, … are appended to keep each root ID unique.
|
|
||||||
- **Path normalization**: lower-case Windows drive letter, strip trailing slashes, forward slashes only (`as_posix()`).
|
|
||||||
|
|
||||||
### Per-Root Runtime (`RootContext`)
|
|
||||||
|
|
||||||
Each active root gets an isolated `RootContext` managed by the `Supervisor`:
|
|
||||||
|
|
||||||
- `root_id`, `root_path` — stable identifiers
|
|
||||||
- `IndexStore` — owns snapshot at `<root>/.mediahive/index.json`
|
|
||||||
- `RootScanner` — per-root scanning instance (replaced legacy global scanner)
|
|
||||||
- `asyncio.Queue` + consumer task — bridges scanner events to WebSocket
|
|
||||||
- `status`: `idle` | `loading` | `ready` | `scanning` | `error`
|
|
||||||
|
|
||||||
### Supervisor
|
|
||||||
|
|
||||||
- Holds `dict[str, RootContext]` keyed by `root_id`.
|
|
||||||
- `replace_roots(new_roots)` atomically swaps the active set:
|
|
||||||
1. Validate & canonicalize paths.
|
|
||||||
2. Derive unique friendly `root_id` for each.
|
|
||||||
3. Prepare new `RootContext`s (load snapshots).
|
|
||||||
4. Swap dict atomically.
|
|
||||||
5. Stop removed contexts in background with bounded timeout.
|
|
||||||
- Exposes merged read helpers (`merged_index`, `all_statuses`).
|
|
||||||
|
|
||||||
### Item IDs
|
|
||||||
|
|
||||||
`root_id` is stored separately on each item.
|
|
||||||
|
|
||||||
- `Movie.id` uses a slug built from the movie title and year, for example `spider-man-no-way-home-2021`.
|
|
||||||
- `Series.id` uses a slug built from the series title, for example `lost`.
|
|
||||||
- Legacy snapshot migrations are handled by `scripts/indexmigr.py`, not during app startup.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
| Endpoint | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `GET /api/roots` | List all roots (name, path, root_id, status) |
|
|
||||||
| `PUT /api/roots` | Atomically replace full root map `{name: path}` |
|
|
||||||
| `WS /api/ws/{root_id}` | Per-root WebSocket (init/upsert/remove/task + status/task events) |
|
|
||||||
| `GET /api/media/{root_id}/{path:path}` | Serve media file scoped to root |
|
|
||||||
| `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` | Serve `.mediahive/{asset_type}` assets (`movies`, `series`, `people`) |
|
|
||||||
| `POST /api/play/{root_id}` | Play file within root |
|
|
||||||
| `POST /api/open-folder/{root_id}` | Open folder within root |
|
|
||||||
| `GET /api/meta/{root_id}/{meta_key}` | Per-root metadata (for example `playback-state`) |
|
|
||||||
| `POST /api/ui/pick-folder` | Native OS folder picker (returns path) |
|
|
||||||
|
|
||||||
> **Removed legacy endpoints**: `/api/change-folder`, `/api/index`, `/api/scan`, `/api/status`, `/api/playback/resume-positions`. No backwards compatibility is maintained.
|
|
||||||
|
|
||||||
## macOS Startup Safety
|
|
||||||
|
|
||||||
The server **must not** touch the filesystem during startup, because macOS may show permission dialogs that block the event loop and prevent the HTTP server from accepting requests.
|
|
||||||
|
|
||||||
- `lifespan()` creates a background task (`_activate_all_roots()`) and immediately yields.
|
|
||||||
- All filesystem validation (`exists()`, `is_dir()`, `resolve()`) runs in a thread pool via `asyncio.to_thread()`.
|
|
||||||
- CLI entry points (`__main__.py`, `winmain.py`, `hivescan/__main__.py`) pass raw paths via the `MEDIAHIVE_ROOTS` environment variable; they do **not** validate paths before starting the server.
|
|
||||||
|
|
||||||
## POSIX Path Enforcement
|
|
||||||
|
|
||||||
All stored and transmitted paths use forward slashes exclusively:
|
|
||||||
|
|
||||||
- `_normalize_path()` always returns POSIX paths.
|
|
||||||
- Config stores `p.as_posix()`.
|
|
||||||
- URLs use `/` separators.
|
|
||||||
- `Path(root_path) / relative_path` works correctly on Windows because `Path` accepts POSIX separators.
|
|
||||||
|
|
||||||
## Config Migration
|
|
||||||
|
|
||||||
- Old `media_folder` string is auto-migrated to `roots: {basename: path}` on load.
|
|
||||||
- `roots` is persisted back to TOML config.
|
|
||||||
|
|
||||||
## Scanner
|
|
||||||
|
|
||||||
- Legacy global module-level scanner API was removed from `hivescan/scanner.py`.
|
|
||||||
- `RootScanner` is the only scanning interface.
|
|
||||||
- Each `RootScanner` owns its own `showreel_queue`, `scan_task`, `rescan_worker_task`, and `_seen_mtimes`.
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
- `useMediaWebSocket.ts` manages one WebSocket per active root.
|
|
||||||
- `App.vue` merges per-root `movieMap`/`seriesMap` into a single `mediaIndex`.
|
|
||||||
- `Header.vue` provides add/remove root UI via `PUT /api/roots`.
|
|
||||||
- Playback URLs are root-qualified (`/api/media/{root_id}/...`).
|
|
||||||
- Metadata cache assets use typed root paths (`/api/assets/{root_id}/{asset_type}/...`) rather than exposing `.mediahive` in URLs.
|
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
# Scanner review — findings, fixes, and measured results
|
||||||
|
|
||||||
|
Date: 2026-09-03 (review); fixes implemented same day.
|
||||||
|
Scope: `mediahive/hivescan/*`, `mediahive/index_store.py`, `mediahive/root_registry.py`,
|
||||||
|
`mediahive/models/events.py`
|
||||||
|
Method: code review plus instrumented runs of the real `RootScanner` (monkeypatched
|
||||||
|
timers around every ffmpeg invocation, HTTP request, and filesystem primitive).
|
||||||
|
|
||||||
|
Structure of this document:
|
||||||
|
|
||||||
|
- **Section 1** records the findings as measured against the *pre-fix* code
|
||||||
|
(line references are from that revision).
|
||||||
|
- **Section 2** describes the fixes that were implemented for each finding.
|
||||||
|
- **Section 3** gives before/after measurements.
|
||||||
|
- **Appendix A** (blob storage options) is kept for reference only; it was
|
||||||
|
explicitly decided **not** to change the on-disk storage format for now.
|
||||||
|
|
||||||
|
Test environment (details omitted intentionally): the media library lives on a
|
||||||
|
network-mounted filesystem (SMB/CIFS). Two roots were measured:
|
||||||
|
|
||||||
|
- **Subset root**: 163 torrents (121 movies, 40 series entries), stale/empty index.
|
||||||
|
- **Full library root**: existing index with 1127 movies, 159 series,
|
||||||
|
1608 episodes, ~2983 video files, ~35k people records.
|
||||||
|
|
||||||
|
Environment characteristic that dominates several measurements:
|
||||||
|
`stat`/`scandir`/`exists` on the network mount are ~0.1 ms (attribute caching
|
||||||
|
works), but **every small file write costs ~0.2 s** (synchronous write-through).
|
||||||
|
The scanner writes thousands of small files into `.mediahive/` on that mount.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Findings (pre-fix)
|
||||||
|
|
||||||
|
### F1 — Partial rescan replaces whole entries (the "disappearing seasons" bug)
|
||||||
|
|
||||||
|
**This is a correctness bug, not a performance issue.**
|
||||||
|
|
||||||
|
Chain of events:
|
||||||
|
|
||||||
|
1. Discovery mtime-gates per torrent directory/file. Touching one season
|
||||||
|
directory of a series yields a `downloads` list containing *only that
|
||||||
|
season*.
|
||||||
|
2. `_process_series` rebuilds the `Series` object from only the items it was
|
||||||
|
given.
|
||||||
|
3. `IndexStore.upsert_series` **replaced the entire entry** and broadcast the
|
||||||
|
partial series to all connected clients.
|
||||||
|
|
||||||
|
Measured end-to-end on a 5-season series (5 separate season torrents,
|
||||||
|
110 episodes):
|
||||||
|
|
||||||
|
```
|
||||||
|
full scan -> index entry seasons [1,2,3,4,5] (110 episodes)
|
||||||
|
touch season 4 -> rescan 0.2 s, emits ONE upsert: seasons [4] (22 episodes)
|
||||||
|
-> index entry is now seasons [4] — seasons 1-3,5 gone
|
||||||
|
```
|
||||||
|
|
||||||
|
The missing seasons returned only when a scan happened to include all seasons
|
||||||
|
again — in practice the next **process restart**, because the seen-mtimes map
|
||||||
|
(F2) was memory-only and forced a full rediscovery at startup.
|
||||||
|
|
||||||
|
Same bug class, other variants:
|
||||||
|
|
||||||
|
- **Movies**: a touched version directory dropped the other versions of the
|
||||||
|
same movie from the listing.
|
||||||
|
- **TMDb dedupe collapse**: when a partial entry arrived under a *different*
|
||||||
|
item id for an already-known TMDb id, the old **complete** entry was
|
||||||
|
explicitly deleted.
|
||||||
|
- **Deletions were never detected**: discovery only ever added to
|
||||||
|
`_seen_mtimes`; nothing emitted removals. A torrent deleted from disk stayed
|
||||||
|
in the index forever.
|
||||||
|
|
||||||
|
### F2 — No persistent scan state: every restart was a full reprocess
|
||||||
|
|
||||||
|
All scanner state was process memory: `_seen_mtimes`, the ffmpeg probe cache,
|
||||||
|
and the episode/playable-file/bluray-probe caches.
|
||||||
|
|
||||||
|
Consequences measured:
|
||||||
|
|
||||||
|
- **Steady-state rescan within one process: 0.2 s** (subset root, nothing
|
||||||
|
changed) — mtime gating worked fine while the process lived.
|
||||||
|
- **Warm-restart scan (fresh process caches, all disk caches warm): 56.5 s**
|
||||||
|
for the same 163 items, of which **52.9 s (94 %) was re-running ffmpeg probes
|
||||||
|
on all 469 video files** (21 s `ffmpeg -i` + 32 s `showinfo` passes on HDR
|
||||||
|
files). TMDb was 100 % disk-cache hits and cost 0.8 s total.
|
||||||
|
- Scaled to the full library: every application restart re-probed **~2983
|
||||||
|
files ≈ 6–8 minutes** of sequential ffmpeg, during which the whole index was
|
||||||
|
re-derived and re-upserted item by item (see F4).
|
||||||
|
|
||||||
|
### F3 — Preview (showreel) generation: restart storms and infinite retries
|
||||||
|
|
||||||
|
- **Every scan that included an item enqueued all of its reel tasks**, whether
|
||||||
|
or not the reels existed. Existence was only checked later by the serial
|
||||||
|
worker. A full scan of the subset root enqueued **467** tasks; the full
|
||||||
|
library would enqueue ~2700.
|
||||||
|
- **Nothing was persisted about the queue.** A restart before the queue drained
|
||||||
|
started everything over.
|
||||||
|
- **Failures were never recorded.** In the drain test, 3 of 18 movies failed
|
||||||
|
deterministically (DoVi profile 7 titles require `libplacebo` tonemapping,
|
||||||
|
which fails on GPU-less machines; one file has a matroska demux error). The
|
||||||
|
same files were retried on every subsequent drain — ~1.5 s of probing plus
|
||||||
|
crop detection plus an error task broadcast to every client, **forever**.
|
||||||
|
- The reel-existence check required **all five** reels; short videos
|
||||||
|
legitimately produce fewer, so they were treated as "missing" and re-queued
|
||||||
|
on every scan, generating nothing new each time.
|
||||||
|
- Measured generation pace with software AV1 encoding: ~16–28 s per movie
|
||||||
|
(5 clips), ~4–5 s per episode clip.
|
||||||
|
- The reel worker rebroadcast **whole items** built from stale scan data,
|
||||||
|
clobbering newer store state.
|
||||||
|
|
||||||
|
### F4 — Degraded operation while a full scan is in progress
|
||||||
|
|
||||||
|
- Items were re-upserted one by one as processed; combined with F1, any
|
||||||
|
partial rescan interleaved with normal use made listings lose data until the
|
||||||
|
next restart.
|
||||||
|
- The showreel worker broadcast an **error task for every permanent failure on
|
||||||
|
every scan** (F3), producing user-visible noise.
|
||||||
|
- The index snapshot was rewritten every 5 s while dirty; with 1286+ items
|
||||||
|
that is a ~7.5 MB serialize + write per flush, continuously, for the
|
||||||
|
duration of a scan.
|
||||||
|
- `upsert_*` rebuilt the TMDb-id lookup maps on **every** upsert — O(n²) per
|
||||||
|
scan. Measured negligible; fixed anyway as part of the merge work.
|
||||||
|
|
||||||
|
### F5 — Cold-scan cost was serialized small-file I/O, not TMDb
|
||||||
|
|
||||||
|
Cold scan of the subset root: **975 s for 163 items**.
|
||||||
|
|
||||||
|
| Time | Share | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| 763 s | 78 % | `download_cast_profile`: 3494 cast images, strictly serialized; ≈0.22 s each ≈ 0.19 s network-mount write + 0.03 s HTTP |
|
||||||
|
| ~100 s | 10 % | ffmpeg probes (469 files, incl. 83 HDR `showinfo` passes) |
|
||||||
|
| 73 s | 7 % | TMDb API layer: 217 uncached requests (27 s HTTP) **plus ~41 s writing per-request cache JSON files** to the network mount |
|
||||||
|
| ~75 s | 8 % | covers / backdrops / season posters (same small-write cost) |
|
||||||
|
| 0.3 s | — | filesystem discovery walk |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The TMDb disk cache itself is fine: once warm it serves 282 requests in
|
||||||
|
0.8 s. Cache *reads* need no optimization.
|
||||||
|
- The full cast of every title was downloaded sequentially, one tiny file per
|
||||||
|
person (the full library has ~35k people records). Re-runs are cheap
|
||||||
|
(exists-check), so this was a cold-scan-only cost — but it made the first
|
||||||
|
scan of a new root take ~8× longer than everything else combined.
|
||||||
|
|
||||||
|
### F6 — Measured as noise (not worth effort)
|
||||||
|
|
||||||
|
- The 30-second rescan loop's tree walk: 3.5–6 s per pass over the full
|
||||||
|
library (~1900 directories). Continuous but light.
|
||||||
|
- `get_directory_size` per torrent: 0.1 s total in the scan.
|
||||||
|
- TMDb disk-cache reads: sub-second per scan.
|
||||||
|
- `trigger_scan` was dead code — nothing called it.
|
||||||
|
- `_seen_mtimes` was updated *before* processing; a cancelled/failed scan
|
||||||
|
permanently lost that update until the next restart.
|
||||||
|
- The in-process probe cache was keyed by path only; a replaced file kept
|
||||||
|
stale probe data until restart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Implemented fixes
|
||||||
|
|
||||||
|
All proposals P1–P5 from the review were implemented, keeping the existing
|
||||||
|
on-disk format unchanged (no blob storage — see Appendix A).
|
||||||
|
|
||||||
|
### F1 → merge-semantics upserts + deletion sync
|
||||||
|
|
||||||
|
- The `Upsert` event now carries `scanned: list[str]` — the media-root-relative
|
||||||
|
torrent paths whose content was (re)scanned to build the item
|
||||||
|
(`mediahive/models/events.py`; `_process_movies`/`_process_series` yield it).
|
||||||
|
- `IndexStore.upsert_movie/upsert_series` (`mediahive/index_store.py`) merge a
|
||||||
|
partial rebuild into the existing entry instead of replacing it:
|
||||||
|
file entries belonging to scanned torrents are replaced, everything else is
|
||||||
|
preserved, episodes/seasons emptied by the merge are dropped, same-episode
|
||||||
|
multi-release files are unioned, and non-None scalar fields from the fresh
|
||||||
|
scan win. The store broadcasts only when the merged result actually changed.
|
||||||
|
- The TMDb dedupe collapse now folds duplicates through the same merge (the
|
||||||
|
complete entry's scalars win), so a partial candidate can no longer delete a
|
||||||
|
complete entry; the reverse TMDb-id map is fixed up incrementally instead of
|
||||||
|
rebuilding both maps per upsert (also F4/P5).
|
||||||
|
- **Deletion sync**: discovery collects the full set of candidate torrent
|
||||||
|
paths; after a fully completed scan the scanner emits a new `Sync` event and
|
||||||
|
`IndexStore.sync_torrent_paths` drops file entries whose torrent path is
|
||||||
|
gone, cascading to empty episodes/seasons/items with proper removals.
|
||||||
|
|
||||||
|
### F2 → persisted scan state and probe cache
|
||||||
|
|
||||||
|
Three small JSON files under `.mediahive/` per root, loaded at scanner start
|
||||||
|
and written atomically (tmp + rename) **only when changed**:
|
||||||
|
|
||||||
|
- `scan-state.json`: relpath → mtime. A restart over an unchanged library now
|
||||||
|
discovers "0 new items" and finishes in walk time. Mtimes are committed
|
||||||
|
**after** the scan completes successfully (fixes the pre-commit nit from F6):
|
||||||
|
a cancelled/failed scan retries its items.
|
||||||
|
- `probe-cache.json`: path → {mtime, size, probe fields} for every probed
|
||||||
|
file, **failures included**. Keying by mtime+size makes it self-invalidating
|
||||||
|
when a file is replaced (also fixes the stale-probe nit from F6). Non-plain
|
||||||
|
paths (bluray:/concat: URIs) fail `stat` and stay memory-cached only.
|
||||||
|
- `reel-state.json`: see F3 below.
|
||||||
|
|
||||||
|
### F3 → reel-state persistence, backoff, and queue gating
|
||||||
|
|
||||||
|
- `reel-state.json` records, per media folder (movies) or per episode
|
||||||
|
(`folder#SxxEyy`), the video's mtime+size, status (`done`/`failed`),
|
||||||
|
attempt count, and last-attempt timestamp.
|
||||||
|
- `_reel_needed` gates both scan-time queueing and the worker:
|
||||||
|
`done` entries are skipped; `failed` entries back off exponentially
|
||||||
|
(6 h → 12 h → … capped at 1 week); entries with no record fall back to a
|
||||||
|
cheap on-disk existence check, and existing reels are silently recorded as
|
||||||
|
`done` so future scans take the cheap path. This ends both the infinite
|
||||||
|
retries of unreadable files and the re-queueing of short videos.
|
||||||
|
- The worker no longer rebroadcasts whole (stale) items: it sends narrow
|
||||||
|
`MovieShowreel` / `EpisodeReel` events, and the store updates only the reel
|
||||||
|
fields of the current entry (`set_movie_showreel` / `set_episode_reel`).
|
||||||
|
- Reel state is persisted at scan finalize, on scanner `stop()`, and as soon
|
||||||
|
as the reel queue drains (a crash between scans no longer loses records).
|
||||||
|
|
||||||
|
### F5 → bounded parallelism for downloads and TMDb fetches
|
||||||
|
|
||||||
|
- Cast-profile downloads: `asyncio.gather` with a semaphore of 8.
|
||||||
|
- TMDb title lookups (movies and series) are prefetched in parallel
|
||||||
|
(semaphore of 4) before the grouping loops, which then read the per-call
|
||||||
|
caches.
|
||||||
|
- Season-detail fetches are prefetched in parallel (semaphore of 4) before the
|
||||||
|
season loop.
|
||||||
|
- Per-item cover/backdrop/poster logic is unchanged (exists-check-fast when
|
||||||
|
warm).
|
||||||
|
|
||||||
|
### Housekeeping (P5)
|
||||||
|
|
||||||
|
- Dead `trigger_scan` removed (`is_scanning` kept).
|
||||||
|
- TMDb-id index bookkeeping is incremental (see F1 above).
|
||||||
|
- `_rebuild_tmdb_indexes` remains only for snapshot load and dedupe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Measured results
|
||||||
|
|
||||||
|
Subset root (163 items, 121 movies / 40 series entries, 469 video files),
|
||||||
|
same network mount:
|
||||||
|
|
||||||
|
| Scenario | Before | After |
|
||||||
|
|---|---|---|
|
||||||
|
| Touch one season of a 5-season series | other 4 seasons vanish until next full scan | 0.3 s rescan, one partial upsert (`scanned=[S04]`), store keeps all 5 seasons |
|
||||||
|
| Steady rescan, nothing changed (same process) | 0.2 s | 0.2 s, 0 upserts, no writes |
|
||||||
|
| Warm restart, unchanged library (fresh process) | 56.5 s (94 % ffmpeg re-probes) + 467 reel tasks queued | **0.2 s, 0 upserts, reel queue 0** |
|
||||||
|
| First scan with warm TMDb cache but no probe cache | 56.5 s | 51.6 s once — writes `probe-cache.json` (469 records, 207 KB); subsequent runs skip all probing |
|
||||||
|
| Permanently unreadable files (3 DoVi/libplacebo movies) | retried on every scan and every startup, error broadcast each time | recorded as failed once, skipped within backoff |
|
||||||
|
| Cold scan, nothing cached | 975 s | not re-measured end-to-end; the dominant terms are now 8-way parallel (cast images: 3494 downloads measured at 3.7 s when warm) |
|
||||||
|
| Deleted torrent | stayed in index forever | removed on the next completed scan via `Sync` |
|
||||||
|
|
||||||
|
Unit-level checks (synthetic `IndexStore` + scanner state, no filesystem
|
||||||
|
library involved): partial upsert preserves untouched seasons and replaces
|
||||||
|
rescanned torrent files; multi-release episode union; sync removal cascades;
|
||||||
|
single-episode reel updates; probe-cache save/load roundtrip; reel backoff
|
||||||
|
math; reel-state persistence roundtrip across scanner instances.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A — Blob storage options: scan-time vs runtime concerns
|
||||||
|
|
||||||
|
**Status: reference only — not implemented.** The current on-disk format
|
||||||
|
(one file per artifact) was deliberately kept. This appendix stays as
|
||||||
|
documentation of the options if write amplification or file counts ever
|
||||||
|
become an operational issue.
|
||||||
|
|
||||||
|
The two concerns have opposite constraints, so they should be decided
|
||||||
|
separately:
|
||||||
|
|
||||||
|
- **Scan-time data** is written and read only by the scanner. Nothing in the
|
||||||
|
server serves it. Storage format is therefore a pure implementation detail
|
||||||
|
and can be changed freely.
|
||||||
|
- **Runtime data** is delivered to the frontend as plain files via
|
||||||
|
`/api/assets/{root}/{movies|series|people}/{path}` (covers, posters,
|
||||||
|
backdrops, person photos) and `/api/media/{root}/{path}` (media files), with
|
||||||
|
etag/range streaming. Anything that replaces files here must keep an HTTP
|
||||||
|
serving story working.
|
||||||
|
|
||||||
|
### A.1 Artifact inventory (measured)
|
||||||
|
|
||||||
|
| Artifact | Class | Avg size | Count (full library) | Total | Written |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| TMDb response cache JSON | scan-time | 25 KB (median 9 KB) | ~8,500 | ~215 MB | on every uncached API request |
|
||||||
|
| Probe results / scan state / reel state | scan-time | ~0.5 KB/record | ~3,000 records | ~1.5 MB | per processed file |
|
||||||
|
| Person photos | runtime | 11.4 KB | ~22,500 | ~262 MB | once per person |
|
||||||
|
| `cover.jpg` / season posters | runtime | ~77 KB | ~2,100 | ~164 MB | once per title/season |
|
||||||
|
| `backdrop.jpg` | runtime | ~147 KB | ~1,460 | ~220 MB | once per title |
|
||||||
|
| Reel clips (WebM/AV1) | runtime | ~450 KB | ~8,700 | ~4.0 GB | once per title/episode |
|
||||||
|
| `index.json` snapshot | both | 7.5 MB | 1 | 7.5 MB | debounced, only when dirty |
|
||||||
|
|
||||||
|
Reference point for the write-amplification math: on the network mount one
|
||||||
|
small-file write costs ~0.2 s, while a single 7.5 MB sequential write costs
|
||||||
|
the same ~0.2 s (~40 MB/s). So ~31,000 tiny files ≈ 1.7 hours of serialized
|
||||||
|
write time, versus ~6 s for the same bytes as one bulk dump.
|
||||||
|
|
||||||
|
### A.2 Scan-time blob store (TMDb cache, probe cache, scan state)
|
||||||
|
|
||||||
|
Nothing here is served, so the only requirement is fast lookup + cheap
|
||||||
|
persistence. Two workable shapes:
|
||||||
|
|
||||||
|
- **RAM map + debounced atomic dump.** Plain dicts keyed by request hash /
|
||||||
|
file path, dumped as one binary file (length-prefixed msgspec or JSON blob,
|
||||||
|
optionally zstd-compressed) with tmp-write + rename, on the same
|
||||||
|
dirty-flag + debounce discipline `index.json` already uses. Effects: the
|
||||||
|
~8,500 individual cache writes collapse into a handful of bulk flushes;
|
||||||
|
warm lookups become dict hits with zero filesystem calls. TMDb JSON
|
||||||
|
compresses ~10× (215 MB → ~20–25 MB), so a full dump is a sub-second write.
|
||||||
|
Caveat: holding all responses parsed in RAM costs ~200 MB for the full
|
||||||
|
library; storing raw response *bytes* and parsing lazily, or capping to
|
||||||
|
entries referenced by known index items, keeps this modest.
|
||||||
|
- **SQLite (stdlib, WAL mode).** One database file, incremental commits, crash
|
||||||
|
safety without full dumps, and kernel page cache instead of explicit RAM
|
||||||
|
management. Better fit if the cache is allowed to grow unbounded, at the
|
||||||
|
price of slightly more code.
|
||||||
|
|
||||||
|
Either way, keep the existing cache *semantics* unchanged: cache HTTP-level
|
||||||
|
failures, never cache network errors. With persisted scan state in place the
|
||||||
|
TMDb cache becomes write-rarely (new items only), which further lowers the
|
||||||
|
value of elaborate engineering here — the simple dump is likely enough.
|
||||||
|
|
||||||
|
### A.3 Runtime-served artifacts
|
||||||
|
|
||||||
|
- **Reels, covers, backdrops, season posters: keep as files.** They are few
|
||||||
|
per title, tens-to-hundreds of KB, written exactly once, and benefit from
|
||||||
|
the existing etag/range file serving. No write-amplification problem.
|
||||||
|
- **Person photos** (~22.5k files × 11.4 KB) are the one runtime class where
|
||||||
|
tiny files hurt at scan time. Three options, in increasing invasiveness:
|
||||||
|
1. **Keep files, fix only the scan-time behavior** — bounded-parallel
|
||||||
|
downloads, optionally capped to top-N billed cast. Zero changes to
|
||||||
|
serving; the cold-scan cost drops ~8× but the file count stays.
|
||||||
|
*(This is the option currently implemented.)*
|
||||||
|
2. **Blob db + serve from the db.** Person photos move into the same store
|
||||||
|
as above; the assets handler gains one branch for the `people` asset
|
||||||
|
type that streams bytes from the db instead of the filesystem.
|
||||||
|
Eliminates all 22.5k tiny files. If full RAM residency (262 MB) is
|
||||||
|
undesirable, use SQLite and let the page cache handle it.
|
||||||
|
3. **Hybrid lazy materialization.** The db is authoritative at scan time
|
||||||
|
(no tiny writes during scans); the assets handler writes the photo to the
|
||||||
|
conventional path on first request and serves it as a file thereafter.
|
||||||
|
Serving logic and URLs stay unchanged; disk usage appears only for
|
||||||
|
people actually viewed.
|
||||||
|
|
||||||
|
Note that `index.json` itself is already the right shape: one atomic 7.5 MB
|
||||||
|
file, rewritten only when dirty. The goal for everything else is simply to
|
||||||
|
reach the same shape per concern.
|
||||||
|
|
||||||
|
### A.4 Recommendation (if revisited)
|
||||||
|
|
||||||
|
| Concern | Recommended treatment |
|
||||||
|
|---|---|
|
||||||
|
| TMDb response cache | RAM map + debounced single-file dump (SQLite if growth matters) |
|
||||||
|
| Probe cache, seen-mtimes, reel-failure records | same dump mechanism, separate small files *(currently: three small JSON files, written only when dirty)* |
|
||||||
|
| Person photos | option 1 now (parallel + lazy fetching); option 2 or 3 if file count becomes an operational issue |
|
||||||
|
| Covers, posters, backdrops, reels | unchanged — plain files |
|
||||||
|
| `index.json` | unchanged |
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
+152
-232
@@ -11,12 +11,19 @@
|
|||||||
<span>Library activity</span>
|
<span>Library activity</span>
|
||||||
<span v-if="!wsConnected" class="activity-connection">Reconnecting...</span>
|
<span v-if="!wsConnected" class="activity-connection">Reconnecting...</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="root in progressRoots" :key="root.rootId" class="activity-root" :class="root.toneClass">
|
<div
|
||||||
|
v-for="root in progressRoots"
|
||||||
|
:key="root.rootId"
|
||||||
|
class="activity-root"
|
||||||
|
:class="root.toneClass"
|
||||||
|
>
|
||||||
<div class="activity-root-title">{{ root.rootLabel }}</div>
|
<div class="activity-root-title">{{ root.rootLabel }}</div>
|
||||||
<div v-if="root.scanTarget" class="activity-root-target">{{ root.scanTarget }}</div>
|
<div v-if="root.scanTarget" class="activity-root-target">{{ root.scanTarget }}</div>
|
||||||
<div class="activity-phase-row">
|
<div class="activity-phase-row">
|
||||||
<span class="activity-phase">{{ root.phaseLabel }}</span>
|
<span class="activity-phase">{{ root.phaseLabel }}</span>
|
||||||
<span v-if="root.progressLabel" class="activity-progress-label">{{ root.progressLabel }}</span>
|
<span v-if="root.progressLabel" class="activity-progress-label">{{
|
||||||
|
root.progressLabel
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }">
|
<div class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }">
|
||||||
<div
|
<div
|
||||||
@@ -37,6 +44,10 @@
|
|||||||
<Header
|
<Header
|
||||||
:current-view="headerCurrentView"
|
:current-view="headerCurrentView"
|
||||||
:search-query="searchQuery"
|
:search-query="searchQuery"
|
||||||
|
:roots="headerRoots"
|
||||||
|
:scan-tasks="tasks"
|
||||||
|
:scan-connected="wsConnected"
|
||||||
|
:initial-scan-mode="isInitialScanMode"
|
||||||
:mpc-be-connected="mpcBeConnected"
|
:mpc-be-connected="mpcBeConnected"
|
||||||
:nav-row="1"
|
:nav-row="1"
|
||||||
:position="headerPosition"
|
:position="headerPosition"
|
||||||
@@ -70,7 +81,7 @@
|
|||||||
<!-- Browse/Search page (left panel) -->
|
<!-- Browse/Search page (left panel) -->
|
||||||
<main
|
<main
|
||||||
ref="browsePanelRef"
|
ref="browsePanelRef"
|
||||||
class="main-content page-slider-panel"
|
class="main-content page-slider-panel scrollbar-hidden"
|
||||||
data-nav-scope="browse"
|
data-nav-scope="browse"
|
||||||
@scroll.passive="handlePanelScroll('browse')"
|
@scroll.passive="handlePanelScroll('browse')"
|
||||||
>
|
>
|
||||||
@@ -165,7 +176,7 @@
|
|||||||
<!-- Detail page (right panel) -->
|
<!-- Detail page (right panel) -->
|
||||||
<main
|
<main
|
||||||
ref="detailPanelRef"
|
ref="detailPanelRef"
|
||||||
class="main-content page-slider-panel page-slider-detail-panel"
|
class="main-content page-slider-panel page-slider-detail-panel scrollbar-hidden"
|
||||||
data-nav-scope="detail"
|
data-nav-scope="detail"
|
||||||
@scroll.passive="handlePanelScroll('detail')"
|
@scroll.passive="handlePanelScroll('detail')"
|
||||||
>
|
>
|
||||||
@@ -176,6 +187,8 @@
|
|||||||
:all-movies="mediaIndex?.movies ?? []"
|
:all-movies="mediaIndex?.movies ?? []"
|
||||||
:focus-episode="focusEpisode"
|
:focus-episode="focusEpisode"
|
||||||
:has-resume-position="hasResumePosition"
|
:has-resume-position="hasResumePosition"
|
||||||
|
:get-resume-point="getResumePoint"
|
||||||
|
:get-resume-episodes="getResumeEpisodes"
|
||||||
:get-root-name="getRootName"
|
:get-root-name="getRootName"
|
||||||
@close="closeDetail"
|
@close="closeDetail"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@@ -199,20 +212,26 @@ import type {
|
|||||||
SeriesUi,
|
SeriesUi,
|
||||||
MediaItem,
|
MediaItem,
|
||||||
EpisodeWithSeries,
|
EpisodeWithSeries,
|
||||||
TaskInfo,
|
SeriesResumePoint,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import {
|
import {
|
||||||
playMedia,
|
playMedia,
|
||||||
openFolder,
|
openFolder,
|
||||||
isMpcBeReachable,
|
isMpcBeReachable,
|
||||||
fetchResumePositions,
|
fetchResumePositions,
|
||||||
normalizeMediaPath,
|
|
||||||
getPlayerStatus,
|
getPlayerStatus,
|
||||||
fetchRoots,
|
type ResumePositionEntry,
|
||||||
|
type EpisodeWatchEntry,
|
||||||
} from "./api"
|
} from "./api"
|
||||||
import { useSettings } from "./composables/useSettings"
|
import { useSettings } from "./composables/useSettings"
|
||||||
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
|
import {
|
||||||
|
useKeyboardNavigation,
|
||||||
|
setActiveNavigationScope,
|
||||||
|
} from "./composables/useKeyboardNavigation"
|
||||||
|
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
|
||||||
import { useMediaWebSocket } from "./composables/useMediaWebSocket"
|
import { useMediaWebSocket } from "./composables/useMediaWebSocket"
|
||||||
|
import { computeProgressRoots, type RootTaskInfo } from "./composables/useScanProgress"
|
||||||
|
import { useSettingsOpen } from "./composables/useSettingsOpen"
|
||||||
import Header from "./components/Header.vue"
|
import Header from "./components/Header.vue"
|
||||||
import CollageHero from "./components/CollageHero.vue"
|
import CollageHero from "./components/CollageHero.vue"
|
||||||
import MediaRow from "./components/MediaRow.vue"
|
import MediaRow from "./components/MediaRow.vue"
|
||||||
@@ -220,7 +239,13 @@ import MediaDetail from "./components/MediaDetail.vue"
|
|||||||
import type { SearchResultItem, SearchResponseMessage } from "./search-worker"
|
import type { SearchResultItem, SearchResponseMessage } from "./search-worker"
|
||||||
|
|
||||||
// Initialize keyboard navigation
|
// Initialize keyboard navigation
|
||||||
const { getFocusState, restoreFocusState, focusElement } = useKeyboardNavigation()
|
const {
|
||||||
|
getFocusState,
|
||||||
|
restoreFocusState,
|
||||||
|
focusElement,
|
||||||
|
snapshotSyncedRowScroll,
|
||||||
|
restoreSyncedRowScroll,
|
||||||
|
} = useKeyboardNavigation()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -268,174 +293,41 @@ const {
|
|||||||
error,
|
error,
|
||||||
connected: wsConnected,
|
connected: wsConnected,
|
||||||
tasks,
|
tasks,
|
||||||
setActiveRoots,
|
roots: rootStatuses,
|
||||||
} = useMediaWebSocket()
|
} = useMediaWebSocket()
|
||||||
|
|
||||||
type RootTaskInfo = TaskInfo & { root_id: string }
|
|
||||||
|
|
||||||
interface ProgressRootState {
|
|
||||||
rootId: string
|
|
||||||
rootLabel: string
|
|
||||||
scanTarget: string | null
|
|
||||||
phaseLabel: string
|
|
||||||
phaseDetail: string | null
|
|
||||||
progressPercent: number
|
|
||||||
progressLabel: string | null
|
|
||||||
isDeterminate: boolean
|
|
||||||
toneClass: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeTasks = computed<RootTaskInfo[]>(() => Array.from(tasks.value.values()))
|
const activeTasks = computed<RootTaskInfo[]>(() => Array.from(tasks.value.values()))
|
||||||
|
|
||||||
// Poll for active roots and connect WS to them
|
|
||||||
const rootStatuses = ref<
|
|
||||||
Map<string, { name: string; path: string; status: string; snapshotLoaded: boolean }>
|
|
||||||
>(new Map())
|
|
||||||
|
|
||||||
function getRootName(rootId: string | null | undefined): string | null {
|
function getRootName(rootId: string | null | undefined): string | null {
|
||||||
if (!rootId) return null
|
if (!rootId) return null
|
||||||
return rootStatuses.value.get(rootId)?.name || null
|
return rootStatuses.value.get(rootId)?.root_id || null
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePosixPath(value: string): string {
|
const progressRoots = computed(() =>
|
||||||
return value.replace(/\\/g, "/")
|
computeProgressRoots(
|
||||||
}
|
activeTasks.value,
|
||||||
|
(rootId) => rootStatuses.value.get(rootId)?.path || null,
|
||||||
|
isInitialScanMode.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
function extractScanPath(detail: string): string | null {
|
const isSettingsView = useSettingsOpen()
|
||||||
if (!detail.startsWith("Scanning:")) return null
|
|
||||||
let value = detail.replace(/^Scanning:\s*/i, "").trim()
|
|
||||||
value = value.replace(/\s*\(\d+\s+found\)\s*$/i, "").trim()
|
|
||||||
return value || null
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildScanTarget(rootId: string, rootPath: string | null, detail: string): string | null {
|
|
||||||
const rawPath = extractScanPath(detail)
|
|
||||||
if (!rawPath) return null
|
|
||||||
|
|
||||||
const posixRaw = normalizePosixPath(rawPath)
|
|
||||||
const posixRoot = rootPath ? normalizePosixPath(rootPath) : null
|
|
||||||
|
|
||||||
let relative = posixRaw
|
|
||||||
if (posixRoot) {
|
|
||||||
const lowRaw = posixRaw.toLowerCase()
|
|
||||||
const lowRoot = posixRoot.toLowerCase()
|
|
||||||
if (lowRaw === lowRoot) {
|
|
||||||
relative = ""
|
|
||||||
} else if (lowRaw.startsWith(`${lowRoot}/`)) {
|
|
||||||
relative = posixRaw.slice(posixRoot.length).replace(/^\/+/, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!relative) return rootId
|
|
||||||
if (relative.toLowerCase().startsWith(`${rootId.toLowerCase()}/`)) return relative
|
|
||||||
return `${rootId}/${relative}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeRootProgress(
|
|
||||||
rootId: string,
|
|
||||||
rootPath: string | null,
|
|
||||||
tasksForRoot: RootTaskInfo[],
|
|
||||||
isInitialScanMode: boolean,
|
|
||||||
): ProgressRootState | null {
|
|
||||||
const running = tasksForRoot.filter((task) => task.status === "running")
|
|
||||||
const latestError = [...tasksForRoot].reverse().find((task) => task.status === "error") || null
|
|
||||||
|
|
||||||
if (running.length === 0 && !latestError) return null
|
|
||||||
|
|
||||||
const scanTask = running.find((task) => task.id.startsWith("scan-")) || null
|
|
||||||
const showreelCount = running.filter((task) => task.id.startsWith("showreel-")).length
|
|
||||||
const otherRunningCount = running.length - (scanTask ? 1 : 0) - showreelCount
|
|
||||||
|
|
||||||
let phaseLabel = "Processing media"
|
|
||||||
let phaseDetail: string | null = null
|
|
||||||
let scanTarget: string | null = null
|
|
||||||
let isDeterminate = false
|
|
||||||
let progressPercent = 0
|
|
||||||
let progressLabel: string | null = null
|
|
||||||
let toneClass = ""
|
|
||||||
|
|
||||||
if (scanTask) {
|
|
||||||
const detail = (scanTask.detail || "").trim()
|
|
||||||
scanTarget = buildScanTarget(rootId, rootPath, detail)
|
|
||||||
if (detail.startsWith("Scanning:")) {
|
|
||||||
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
|
|
||||||
phaseDetail = isInitialScanMode ? "Looking for new files" : "Running background scan"
|
|
||||||
} else if (/^Processing\s+\d+\s+(items|movies|series)/i.test(detail)) {
|
|
||||||
phaseLabel = "Preparing titles"
|
|
||||||
phaseDetail = "Matching files and grouping releases"
|
|
||||||
} else {
|
|
||||||
phaseLabel = "Fetching metadata"
|
|
||||||
phaseDetail = detail ? `Current title: ${detail}` : "Updating titles and artwork"
|
|
||||||
}
|
|
||||||
if (scanTask.progress > 0 && scanTask.progress <= 1) {
|
|
||||||
isDeterminate = true
|
|
||||||
progressPercent = Math.max(1, Math.round(scanTask.progress * 100))
|
|
||||||
progressLabel = `${progressPercent}%`
|
|
||||||
}
|
|
||||||
} else if (showreelCount > 0) {
|
|
||||||
phaseLabel = "Generating previews"
|
|
||||||
phaseDetail = showreelCount === 1 ? "Building 1 preview reel" : `Building ${showreelCount} preview reels`
|
|
||||||
} else if (otherRunningCount > 0) {
|
|
||||||
phaseLabel = "Finalizing updates"
|
|
||||||
phaseDetail = "Applying library changes"
|
|
||||||
} else if (latestError) {
|
|
||||||
phaseLabel = "Needs attention"
|
|
||||||
phaseDetail = latestError.detail || "A background task failed"
|
|
||||||
toneClass = "activity-root-error"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showreelCount > 0 && scanTask) {
|
|
||||||
phaseDetail = phaseDetail
|
|
||||||
? `${phaseDetail}. Preview generation is running in parallel.`
|
|
||||||
: "Preview generation is running in parallel"
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
rootId,
|
|
||||||
rootLabel: rootId,
|
|
||||||
scanTarget,
|
|
||||||
phaseLabel,
|
|
||||||
phaseDetail,
|
|
||||||
progressPercent,
|
|
||||||
progressLabel,
|
|
||||||
isDeterminate,
|
|
||||||
toneClass,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressRoots = computed<ProgressRootState[]>(() => {
|
|
||||||
const byRoot = new Map<string, RootTaskInfo[]>()
|
|
||||||
for (const task of activeTasks.value) {
|
|
||||||
const list = byRoot.get(task.root_id) || []
|
|
||||||
list.push(task)
|
|
||||||
byRoot.set(task.root_id, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows: ProgressRootState[] = []
|
|
||||||
const initial = isInitialScanMode.value
|
|
||||||
for (const [rootId, rootTasks] of byRoot) {
|
|
||||||
const rootPath = rootStatuses.value.get(rootId)?.path || null
|
|
||||||
const row = describeRootProgress(rootId, rootPath, rootTasks, initial)
|
|
||||||
if (row) rows.push(row)
|
|
||||||
}
|
|
||||||
return rows.sort((a, b) => a.rootLabel.localeCompare(b.rootLabel))
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSettingsView = computed(() => route.path === "/settings")
|
|
||||||
const hasLibraryItems = computed(() => {
|
const hasLibraryItems = computed(() => {
|
||||||
if (!mediaIndex.value) return false
|
if (!mediaIndex.value) return false
|
||||||
return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0
|
return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0
|
||||||
})
|
})
|
||||||
const hasAnySnapshotLoaded = computed(() =>
|
const hasAnySnapshotLoaded = computed(() =>
|
||||||
Array.from(rootStatuses.value.values()).some((root) => root.snapshotLoaded),
|
Array.from(rootStatuses.value.values()).some((root) => root.snapshot_loaded),
|
||||||
)
|
)
|
||||||
const isInitialScanMode = computed(() => !hasLibraryItems.value && !hasAnySnapshotLoaded.value)
|
const isInitialScanMode = computed(() => !hasLibraryItems.value && !hasAnySnapshotLoaded.value)
|
||||||
|
|
||||||
|
const headerRoots = computed(() =>
|
||||||
|
Array.from(rootStatuses.value.values()).sort((a, b) => a.root_id.localeCompare(b.root_id)),
|
||||||
|
)
|
||||||
|
|
||||||
const showProgressPanel = computed(() => {
|
const showProgressPanel = computed(() => {
|
||||||
if (isInitialScanMode.value) {
|
if (isSettingsView.value) return false
|
||||||
return !wsConnected.value || progressRoots.value.length > 0
|
if (!isInitialScanMode.value) return false
|
||||||
}
|
|
||||||
if (!isSettingsView.value) return false
|
|
||||||
return !wsConnected.value || progressRoots.value.length > 0
|
return !wsConnected.value || progressRoots.value.length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -462,7 +354,11 @@ watch(
|
|||||||
const key = `${task.root_id}:${task.id}`
|
const key = `${task.root_id}:${task.id}`
|
||||||
nextSeen.set(key, task.status)
|
nextSeen.set(key, task.status)
|
||||||
const previousStatus = seenTaskStates.get(key)
|
const previousStatus = seenTaskStates.get(key)
|
||||||
if (task.id.startsWith("scan-") && task.status === "completed" && previousStatus !== "completed") {
|
if (
|
||||||
|
task.id.startsWith("scan-") &&
|
||||||
|
task.status === "completed" &&
|
||||||
|
previousStatus !== "completed"
|
||||||
|
) {
|
||||||
const detail = (task.detail || "").trim()
|
const detail = (task.detail || "").trim()
|
||||||
const doneMatch = detail.match(/^Done\s+[\u2014-]\s+(\d+)\s+movies,\s+(\d+)\s+series$/i)
|
const doneMatch = detail.match(/^Done\s+[\u2014-]\s+(\d+)\s+movies,\s+(\d+)\s+series$/i)
|
||||||
if (doneMatch) {
|
if (doneMatch) {
|
||||||
@@ -470,7 +366,9 @@ watch(
|
|||||||
const series = Number(doneMatch[2] || "0")
|
const series = Number(doneMatch[2] || "0")
|
||||||
if (movies > 0 || series > 0) {
|
if (movies > 0 || series > 0) {
|
||||||
const rootName = getRootName(task.root_id) || task.root_id
|
const rootName = getRootName(task.root_id) || task.root_id
|
||||||
showLibraryUpdateToast(`Library updated in ${rootName}: ${movies} movies, ${series} series`)
|
showLibraryUpdateToast(
|
||||||
|
`Library updated in ${rootName}: ${movies} movies, ${series} series`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -480,51 +378,7 @@ watch(
|
|||||||
{ deep: false },
|
{ deep: false },
|
||||||
)
|
)
|
||||||
|
|
||||||
async function refreshRoots() {
|
|
||||||
try {
|
|
||||||
const roots = await fetchRoots()
|
|
||||||
const newMap = new Map<
|
|
||||||
string,
|
|
||||||
{ name: string; path: string; status: string; snapshotLoaded: boolean }
|
|
||||||
>()
|
|
||||||
const activeIds: string[] = []
|
|
||||||
for (const r of roots) {
|
|
||||||
newMap.set(r.root_id, {
|
|
||||||
name: r.root_id,
|
|
||||||
path: r.path,
|
|
||||||
status: r.status,
|
|
||||||
snapshotLoaded: Boolean(r.snapshot_loaded),
|
|
||||||
})
|
|
||||||
if (r.status === "ready" || r.status === "scanning") {
|
|
||||||
activeIds.push(r.root_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rootStatuses.value = newMap
|
|
||||||
setActiveRoots(activeIds)
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to fetch roots:", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let rootsPollTimer: number | null = null
|
|
||||||
function startRootsPolling() {
|
|
||||||
if (rootsPollTimer !== null) return
|
|
||||||
void refreshRoots()
|
|
||||||
rootsPollTimer = window.setInterval(refreshRoots, 5000)
|
|
||||||
}
|
|
||||||
function stopRootsPolling() {
|
|
||||||
if (rootsPollTimer !== null) {
|
|
||||||
window.clearInterval(rootsPollTimer)
|
|
||||||
rootsPollTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
startRootsPolling()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopRootsPolling()
|
|
||||||
if (libraryUpdateToastTimer !== null) {
|
if (libraryUpdateToastTimer !== null) {
|
||||||
window.clearTimeout(libraryUpdateToastTimer)
|
window.clearTimeout(libraryUpdateToastTimer)
|
||||||
libraryUpdateToastTimer = null
|
libraryUpdateToastTimer = null
|
||||||
@@ -536,7 +390,7 @@ const settings = useSettings()
|
|||||||
const searchResults = ref<MediaItem[]>([])
|
const searchResults = ref<MediaItem[]>([])
|
||||||
const isSearching = ref(false)
|
const isSearching = ref(false)
|
||||||
const mpcBeConnected = ref(false)
|
const mpcBeConnected = ref(false)
|
||||||
const resumePositions = ref<Record<string, number>>({})
|
const resumePositions = ref<Record<string, ResumePositionEntry>>({})
|
||||||
const searchQuery = ref(getRouteSearchQuery())
|
const searchQuery = ref(getRouteSearchQuery())
|
||||||
const searchReturnPath = ref<string | null>(null)
|
const searchReturnPath = ref<string | null>(null)
|
||||||
const browsePanelRef = ref<HTMLElement | null>(null)
|
const browsePanelRef = ref<HTMLElement | null>(null)
|
||||||
@@ -579,6 +433,10 @@ async function refreshResumePositions() {
|
|||||||
resumePositions.value = await fetchResumePositions()
|
resumePositions.value = await fetchResumePositions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshResumePositionsAsEvent() {
|
||||||
|
void refreshResumePositions()
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshPlayerStatus() {
|
async function refreshPlayerStatus() {
|
||||||
if (!isMpcFamilySelected()) {
|
if (!isMpcFamilySelected()) {
|
||||||
mpcBeConnected.value = false
|
mpcBeConnected.value = false
|
||||||
@@ -592,10 +450,25 @@ async function refreshPlayerStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasResumePosition(filePath: string | null) {
|
function hasResumePosition(mediaId: string | null) {
|
||||||
if (!filePath) return false
|
if (!mediaId) return false
|
||||||
const normalizedPath = normalizeMediaPath(filePath)
|
return (resumePositions.value[mediaId]?.pos || 0) > 0
|
||||||
return Number(resumePositions.value[normalizedPath] || 0) > 0
|
}
|
||||||
|
|
||||||
|
function getResumePoint(mediaId: string | null): SeriesResumePoint | null {
|
||||||
|
if (!mediaId) return null
|
||||||
|
const entry = resumePositions.value[mediaId]
|
||||||
|
if (!entry || entry.season === null || entry.episode === null) return null
|
||||||
|
return {
|
||||||
|
seasonNumber: entry.season,
|
||||||
|
episodeNumber: entry.episode,
|
||||||
|
positionSeconds: entry.pos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResumeEpisodes(mediaId: string | null): Record<string, EpisodeWatchEntry> | null {
|
||||||
|
if (!mediaId) return null
|
||||||
|
return resumePositions.value[mediaId]?.episodes ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function startMpcBePolling() {
|
function startMpcBePolling() {
|
||||||
@@ -688,7 +561,9 @@ function showAdjacentDetail(offset: -1 | 1): boolean {
|
|||||||
const sequence = getDetailAdjacentSequence(current)
|
const sequence = getDetailAdjacentSequence(current)
|
||||||
if (sequence.length < 2) return false
|
if (sequence.length < 2) return false
|
||||||
|
|
||||||
const currentIndex = sequence.findIndex((item) => item.id === current.id && item.type === current.type)
|
const currentIndex = sequence.findIndex(
|
||||||
|
(item) => item.id === current.id && item.type === current.type,
|
||||||
|
)
|
||||||
if (currentIndex < 0) return false
|
if (currentIndex < 0) return false
|
||||||
|
|
||||||
const nextIndex = currentIndex + offset
|
const nextIndex = currentIndex + offset
|
||||||
@@ -721,6 +596,23 @@ const searchCategories = ref<{ name: string; items: MediaItem[] }[]>([])
|
|||||||
const focusStateMap = new Map<string, { row: number; col: number }>()
|
const focusStateMap = new Map<string, { row: number; col: number }>()
|
||||||
// Track the last viewed item ID to restore focus to the right card
|
// Track the last viewed item ID to restore focus to the right card
|
||||||
const lastViewedItemId = ref<string | null>(null)
|
const lastViewedItemId = ref<string | null>(null)
|
||||||
|
let browseScrollSnapshot: { panelTop: number; rows: SyncedRowScrollSnapshot } | null = null
|
||||||
|
|
||||||
|
function captureBrowseScrollSnapshot() {
|
||||||
|
browseScrollSnapshot = {
|
||||||
|
panelTop: browsePanelRef.value?.scrollTop ?? 0,
|
||||||
|
rows: snapshotSyncedRowScroll(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreBrowseScrollSnapshot() {
|
||||||
|
if (!browseScrollSnapshot) return
|
||||||
|
if (browsePanelRef.value) {
|
||||||
|
browsePanelRef.value.scrollTop = browseScrollSnapshot.panelTop
|
||||||
|
}
|
||||||
|
restoreSyncedRowScroll(browseScrollSnapshot.rows)
|
||||||
|
browseScrollSnapshot = null
|
||||||
|
}
|
||||||
|
|
||||||
// Save current focus state for a page
|
// Save current focus state for a page
|
||||||
function saveFocusForPage(page: string) {
|
function saveFocusForPage(page: string) {
|
||||||
@@ -740,22 +632,24 @@ function restoreFocusForPage(page: string) {
|
|||||||
if (lastViewedItemId.value) {
|
if (lastViewedItemId.value) {
|
||||||
// Use nextTick + timeout to ensure DOM is updated after navigation
|
// Use nextTick + timeout to ensure DOM is updated after navigation
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
restoreBrowseScrollSnapshot()
|
||||||
const itemId = lastViewedItemId.value
|
const itemId = lastViewedItemId.value
|
||||||
// Find the element with matching item id
|
// Find the element with matching item id
|
||||||
const element = document.querySelector(`[data-item-id="${itemId}"]`) as HTMLElement | null
|
const element = document.querySelector(`[data-item-id="${itemId}"]`) as HTMLElement | null
|
||||||
if (element) {
|
if (element) {
|
||||||
focusElement(element)
|
focusElement(element, { preserveScroll: true })
|
||||||
lastViewedItemId.value = null
|
lastViewedItemId.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Fallback to saved focus state
|
// Fallback to saved focus state
|
||||||
const state = focusStateMap.get(page)
|
const state = focusStateMap.get(page)
|
||||||
restoreFocusState(state || null)
|
restoreFocusState(state || null, { preserveScroll: true })
|
||||||
lastViewedItemId.value = null
|
lastViewedItemId.value = null
|
||||||
}, 100)
|
}, 100)
|
||||||
} else {
|
} else {
|
||||||
|
restoreBrowseScrollSnapshot()
|
||||||
const state = focusStateMap.get(page)
|
const state = focusStateMap.get(page)
|
||||||
restoreFocusState(state || null)
|
restoreFocusState(state || null, { preserveScroll: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,7 +659,10 @@ function restoreBrowseFocus(path: string) {
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSearchExitTargetFromFocusedCard(): { path: "/movies" | "/series"; itemId: string } | null {
|
function getSearchExitTargetFromFocusedCard(): {
|
||||||
|
path: "/movies" | "/series"
|
||||||
|
itemId: string
|
||||||
|
} | null {
|
||||||
const active = document.activeElement as HTMLElement | null
|
const active = document.activeElement as HTMLElement | null
|
||||||
const focusedCard = active?.closest("[data-item-id]") as HTMLElement | null
|
const focusedCard = active?.closest("[data-item-id]") as HTMLElement | null
|
||||||
if (!focusedCard) return null
|
if (!focusedCard) return null
|
||||||
@@ -826,9 +723,7 @@ function clearSearch(options: { preferBack?: boolean; targetPath?: string } = {}
|
|||||||
? normalizeHistoryPath(window.history.state.back)
|
? normalizeHistoryPath(window.history.state.back)
|
||||||
: ""
|
: ""
|
||||||
const canRestoreWithBack =
|
const canRestoreWithBack =
|
||||||
options.preferBack !== false &&
|
options.preferBack !== false && searchReturnPath.value === targetPath && backPath === targetPath
|
||||||
searchReturnPath.value === targetPath &&
|
|
||||||
backPath === targetPath
|
|
||||||
|
|
||||||
searchReturnPath.value = null
|
searchReturnPath.value = null
|
||||||
|
|
||||||
@@ -957,6 +852,7 @@ onMounted(() => {
|
|||||||
document.addEventListener("keydown", handleDetailAdjacentKey)
|
document.addEventListener("keydown", handleDetailAdjacentKey)
|
||||||
window.addEventListener("mediahive:gamepad-action", onGamepadAction as EventListener)
|
window.addEventListener("mediahive:gamepad-action", onGamepadAction as EventListener)
|
||||||
window.addEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener)
|
window.addEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener)
|
||||||
|
window.addEventListener("mediahive:resume-updated", refreshResumePositionsAsEvent)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -964,6 +860,7 @@ onUnmounted(() => {
|
|||||||
document.removeEventListener("keydown", handleDetailAdjacentKey)
|
document.removeEventListener("keydown", handleDetailAdjacentKey)
|
||||||
window.removeEventListener("mediahive:gamepad-action", onGamepadAction as EventListener)
|
window.removeEventListener("mediahive:gamepad-action", onGamepadAction as EventListener)
|
||||||
window.removeEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener)
|
window.removeEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener)
|
||||||
|
window.removeEventListener("mediahive:resume-updated", refreshResumePositionsAsEvent)
|
||||||
stopMpcBePolling()
|
stopMpcBePolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1049,7 +946,7 @@ const activePanelScrollTop = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const headerStyle = computed(() => {
|
const headerStyle = computed(() => {
|
||||||
if (route.path === "/settings") {
|
if (isSettingsView.value) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1072,6 +969,12 @@ function showDetail(item: MediaItem) {
|
|||||||
const currentPage = route.path === "/series" ? "series" : "movies"
|
const currentPage = route.path === "/series" ? "series" : "movies"
|
||||||
saveFocusForPage(currentPage)
|
saveFocusForPage(currentPage)
|
||||||
|
|
||||||
|
// Capture the exact browse scroll positions to restore on return,
|
||||||
|
// but only when leaving the browse page (not for detail-to-detail hops)
|
||||||
|
if (!isDetailOpen.value) {
|
||||||
|
captureBrowseScrollSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
// Check if there are matched episodes to focus on
|
// Check if there are matched episodes to focus on
|
||||||
if (item.type === "series" && item.searchMatchInfo?.matchedEpisodes?.length) {
|
if (item.type === "series" && item.searchMatchInfo?.matchedEpisodes?.length) {
|
||||||
const firstMatch = item.searchMatchInfo.matchedEpisodes[0]
|
const firstMatch = item.searchMatchInfo.matchedEpisodes[0]
|
||||||
@@ -1091,14 +994,18 @@ function showDetail(item: MediaItem) {
|
|||||||
handlePlay(playableFile)
|
handlePlay(playableFile)
|
||||||
} else {
|
} else {
|
||||||
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null
|
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null
|
||||||
router.push({ path: `/series/${epData.series.id}`, state: searchPath ? { searchPath } : undefined })
|
router.push({
|
||||||
|
path: `/series/${epData.series.id}`,
|
||||||
|
state: searchPath ? { searchPath } : undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const detailSearchPath = getDetailSearchPath()
|
const detailSearchPath = getDetailSearchPath()
|
||||||
const searchPath = searchQuery.value
|
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
|
||||||
? getSearchPath(searchQuery.value)
|
router.push({
|
||||||
: detailSearchPath
|
path: `/${item.type}/${item.id}`,
|
||||||
router.push({ path: `/${item.type}/${item.id}`, state: searchPath ? { searchPath } : undefined })
|
state: searchPath ? { searchPath } : undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,9 +1026,7 @@ function handleActorSearch(actorName: string) {
|
|||||||
|
|
||||||
function handleSelectMovieFromDetail(movieId: string) {
|
function handleSelectMovieFromDetail(movieId: string) {
|
||||||
const detailSearchPath = getDetailSearchPath()
|
const detailSearchPath = getDetailSearchPath()
|
||||||
const searchPath = searchQuery.value
|
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
|
||||||
? getSearchPath(searchQuery.value)
|
|
||||||
: detailSearchPath
|
|
||||||
router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined })
|
router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1137,10 +1042,15 @@ function focusDetailEntryTarget(item: MediaItem): boolean {
|
|||||||
'[data-nav-release-item="true"][data-nav-row="2"][data-nav-col="0"][data-nav-focusable="true"]',
|
'[data-nav-release-item="true"][data-nav-row="2"][data-nav-col="0"][data-nav-focusable="true"]',
|
||||||
) as HTMLElement | null
|
) as HTMLElement | null
|
||||||
} else if (item.type === "series") {
|
} else if (item.type === "series") {
|
||||||
// Initial episode tile (first season, first episode) maps to row 2 / col 0.
|
// Row 2 is the season selector strip; land on the selected season poster.
|
||||||
target = detailPanel.querySelector(
|
target = detailPanel.querySelector(
|
||||||
'.episode-tile[data-nav-row="2"][data-nav-col="0"][data-nav-focusable="true"]',
|
'.season-poster-card.season-poster-card--selected[data-nav-focusable="true"]',
|
||||||
) as HTMLElement | null
|
) as HTMLElement | null
|
||||||
|
if (!target) {
|
||||||
|
target = detailPanel.querySelector(
|
||||||
|
'.episode-tile[data-nav-focusable="true"]',
|
||||||
|
) as HTMLElement | null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
@@ -1155,7 +1065,9 @@ function focusDetailEntryTarget(item: MediaItem): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstFocusable = detailPanel.querySelector('[data-nav-focusable="true"]') as HTMLElement | null
|
const firstFocusable = detailPanel.querySelector(
|
||||||
|
'[data-nav-focusable="true"]',
|
||||||
|
) as HTMLElement | null
|
||||||
if (firstFocusable) {
|
if (firstFocusable) {
|
||||||
focusElement(firstFocusable)
|
focusElement(firstFocusable)
|
||||||
return true
|
return true
|
||||||
@@ -1641,6 +1553,7 @@ function findRootIdForPath(filePath: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePlay(filePath: string) {
|
async function handlePlay(filePath: string) {
|
||||||
|
const actionStart = performance.now()
|
||||||
const rootId = findRootIdForPath(filePath)
|
const rootId = findRootIdForPath(filePath)
|
||||||
if (!rootId) {
|
if (!rootId) {
|
||||||
console.error("Cannot play: unknown root for path", filePath)
|
console.error("Cannot play: unknown root for path", filePath)
|
||||||
@@ -1650,7 +1563,10 @@ async function handlePlay(filePath: string) {
|
|||||||
mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS
|
mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd)
|
await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd, {
|
||||||
|
actionStartedAt: actionStart,
|
||||||
|
source: "App.handlePlay",
|
||||||
|
})
|
||||||
if (isMpcFamilySelected()) {
|
if (isMpcFamilySelected()) {
|
||||||
const connected = await tryConnectMpcBe()
|
const connected = await tryConnectMpcBe()
|
||||||
if (connected) {
|
if (connected) {
|
||||||
@@ -1664,13 +1580,17 @@ async function handlePlay(filePath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleOpenFolder(folderPath: string, explicitRootId?: string | null) {
|
async function handleOpenFolder(folderPath: string, explicitRootId?: string | null) {
|
||||||
|
const actionStart = performance.now()
|
||||||
const rootId = explicitRootId || findRootIdForPath(folderPath)
|
const rootId = explicitRootId || findRootIdForPath(folderPath)
|
||||||
if (!rootId) {
|
if (!rootId) {
|
||||||
console.error("Cannot open folder: unknown root for path", folderPath)
|
console.error("Cannot open folder: unknown root for path", folderPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await openFolder(rootId, folderPath)
|
await openFolder(rootId, folderPath, {
|
||||||
|
actionStartedAt: actionStart,
|
||||||
|
source: "App.handleOpenFolder",
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to open folder:", e)
|
console.error("Failed to open folder:", e)
|
||||||
}
|
}
|
||||||
|
|||||||
+197
-40
@@ -9,18 +9,43 @@ export interface PlayerInfo {
|
|||||||
path: string | null
|
path: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RootStatus {
|
export interface RootEntry {
|
||||||
root_id: string
|
root_id: string
|
||||||
path: string
|
path: string
|
||||||
status: string
|
|
||||||
error: string | null
|
|
||||||
snapshot_loaded: boolean
|
|
||||||
movies: number
|
|
||||||
series: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RootsResponse {
|
interface ActionTimingContext {
|
||||||
roots: RootStatus[]
|
actionStartedAt?: number
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowMs(): number {
|
||||||
|
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
||||||
|
return performance.now()
|
||||||
|
}
|
||||||
|
return Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTraceId(action: string): string {
|
||||||
|
const suffix = Math.random().toString(16).slice(2, 8)
|
||||||
|
return `${action}-${Date.now().toString(36)}-${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function logActionTiming(
|
||||||
|
action: string,
|
||||||
|
traceId: string,
|
||||||
|
status: number,
|
||||||
|
actionToFetchMs: number,
|
||||||
|
fetchMs: number,
|
||||||
|
totalMs: number,
|
||||||
|
serverTiming: string | null,
|
||||||
|
source?: string,
|
||||||
|
) {
|
||||||
|
const sourceTag = source ? ` source=${source}` : ""
|
||||||
|
const serverTag = serverTiming ? ` serverTiming=${serverTiming}` : ""
|
||||||
|
console.info(
|
||||||
|
`[timing:${action}] trace=${traceId}${sourceTag} status=${status} actionToFetch=${actionToFetchMs.toFixed(1)}ms fetch=${fetchMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms${serverTag}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeMediaPath(input: string): string {
|
export function normalizeMediaPath(input: string): string {
|
||||||
@@ -123,49 +148,95 @@ function splitAssetTypePath(assetPath: string): { assetType: string; relativePat
|
|||||||
return { assetType: assetType.toLowerCase(), relativePath: rest.join("/") }
|
return { assetType: assetType.toLowerCase(), relativePath: rest.join("/") }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Watch progress for one episode of a series. */
|
||||||
* Fetch active roots and their statuses
|
export interface EpisodeWatchEntry {
|
||||||
*/
|
pos: number
|
||||||
export async function fetchRoots(): Promise<RootStatus[]> {
|
done: boolean
|
||||||
const response = await fetch("/api/roots")
|
}
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to load roots: ${response.statusText}`)
|
/** One stored continue point. season/episode are set for series, null for movies. */
|
||||||
}
|
export interface ResumePositionEntry {
|
||||||
const data = await response.json()
|
pos: number
|
||||||
return data.roots || []
|
season: number | null
|
||||||
|
episode: number | null
|
||||||
|
/** Per-episode watch progress for series, keyed "S<season>E<episode>". */
|
||||||
|
episodes?: Record<string, EpisodeWatchEntry>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch merged resume positions from all roots.
|
* Fetch merged resume positions from all roots.
|
||||||
*/
|
*/
|
||||||
export async function fetchResumePositions(): Promise<Record<string, number>> {
|
export async function fetchResumePositions(): Promise<Record<string, ResumePositionEntry>> {
|
||||||
try {
|
try {
|
||||||
const roots = await fetchRoots()
|
const response = await fetch("/api/meta/playback-state")
|
||||||
const merged: Record<string, number> = {}
|
if (!response.ok) return {}
|
||||||
await Promise.all(
|
|
||||||
roots.map(async (root) => {
|
|
||||||
const response = await fetch(`/api/meta/${encodeURIComponent(root.root_id)}/playback-state`)
|
|
||||||
if (!response.ok) return
|
|
||||||
const data = await response.json().catch(() => ({}))
|
const data = await response.json().catch(() => ({}))
|
||||||
const positions = data?.data?.resume_positions
|
const positions = data?.data?.resume_positions
|
||||||
if (positions && typeof positions === "object") {
|
if (!positions || typeof positions !== "object") {
|
||||||
Object.assign(merged, positions)
|
return {}
|
||||||
}
|
}
|
||||||
}),
|
const normalized: Record<string, ResumePositionEntry> = {}
|
||||||
)
|
for (const [slug, value] of Object.entries(positions as Record<string, unknown>)) {
|
||||||
return merged
|
if (!value || typeof value !== "object") continue
|
||||||
|
const entry = value as { pos?: unknown; season?: unknown; episode?: unknown }
|
||||||
|
if (typeof entry.pos !== "number" || !Number.isFinite(entry.pos) || entry.pos < 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized[slug] = {
|
||||||
|
pos: entry.pos,
|
||||||
|
season: typeof entry.season === "number" ? entry.season : null,
|
||||||
|
episode: typeof entry.episode === "number" ? entry.episode : null,
|
||||||
|
}
|
||||||
|
const rawEpisodes = (entry as { episodes?: unknown }).episodes
|
||||||
|
if (rawEpisodes && typeof rawEpisodes === "object") {
|
||||||
|
const watches: Record<string, EpisodeWatchEntry> = {}
|
||||||
|
for (const [key, watch] of Object.entries(rawEpisodes as Record<string, unknown>)) {
|
||||||
|
if (!watch || typeof watch !== "object") continue
|
||||||
|
const w = watch as { pos?: unknown; done?: unknown }
|
||||||
|
if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue
|
||||||
|
watches[key] = { pos: w.pos, done: w.done === true }
|
||||||
|
}
|
||||||
|
if (Object.keys(watches).length > 0) {
|
||||||
|
normalized[slug].episodes = watches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
} catch {
|
} catch {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report that the user is actively interacting with the UI.
|
||||||
|
*
|
||||||
|
* Ends any server-side assumed-playback session (launched item is assumed
|
||||||
|
* watched while the UI sees no input). Throttled; fire-and-forget.
|
||||||
|
*/
|
||||||
|
let lastActivityReportAt = 0
|
||||||
|
export function reportUserActivity(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastActivityReportAt < 5000) return
|
||||||
|
lastActivityReportAt = now
|
||||||
|
void fetch("/api/activity", { method: "POST" })
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) return
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
if (data?.finalized) {
|
||||||
|
// An assumed-playback position was just written; let views refetch.
|
||||||
|
window.dispatchEvent(new Event("mediahive:resume-updated"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the full root set atomically
|
* Replace the full root set atomically
|
||||||
*/
|
*/
|
||||||
export async function replaceRoots(
|
export async function replaceRoots(
|
||||||
roots: Record<string, string>,
|
roots: Record<string, string>,
|
||||||
): Promise<{ accepted: RootStatus[]; failed: unknown[] }> {
|
): Promise<{ accepted: RootEntry[]; failed: unknown[] }> {
|
||||||
const response = await fetch("/api/roots", {
|
const response = await fetch("/api/config/roots", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ roots }),
|
body: JSON.stringify({ roots }),
|
||||||
@@ -197,23 +268,50 @@ export async function playMedia(
|
|||||||
filePath: string,
|
filePath: string,
|
||||||
playerId?: string | null,
|
playerId?: string | null,
|
||||||
playerCustomCmd?: string | null,
|
playerCustomCmd?: string | null,
|
||||||
|
timing?: ActionTimingContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const normalizedPath = normalizeMediaPath(filePath)
|
const normalizedPath = normalizeMediaPath(filePath)
|
||||||
const body: Record<string, unknown> = { file_path: normalizedPath }
|
const body: Record<string, unknown> = { file_path: normalizedPath }
|
||||||
if (playerId) body.player_id = playerId
|
if (playerId) body.player_id = playerId
|
||||||
if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd
|
if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd
|
||||||
|
const actionStart = timing?.actionStartedAt ?? nowMs()
|
||||||
|
const traceId = makeTraceId("play")
|
||||||
try {
|
try {
|
||||||
|
const fetchStart = nowMs()
|
||||||
|
const actionToFetchMs = Math.max(0, fetchStart - actionStart)
|
||||||
|
const clientSentMs = Date.now()
|
||||||
|
const actionStartEpochMs = clientSentMs - actionToFetchMs
|
||||||
const response = await fetch(`/api/play/${encodeURIComponent(rootId)}`, {
|
const response = await fetch(`/api/play/${encodeURIComponent(rootId)}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-MediaHive-Trace-Id": traceId,
|
||||||
|
"X-MediaHive-Client-Sent-Ms": clientSentMs.toFixed(3),
|
||||||
|
"X-MediaHive-Client-Action-Start-Ms": actionStartEpochMs.toFixed(3),
|
||||||
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
const fetchMs = Math.max(0, nowMs() - fetchStart)
|
||||||
|
const totalMs = Math.max(0, nowMs() - actionStart)
|
||||||
|
const serverTiming = response.headers.get("server-timing")
|
||||||
|
const responseTraceId = response.headers.get("x-mediahive-trace-id") || traceId
|
||||||
|
logActionTiming(
|
||||||
|
"play",
|
||||||
|
responseTraceId,
|
||||||
|
response.status,
|
||||||
|
actionToFetchMs,
|
||||||
|
fetchMs,
|
||||||
|
totalMs,
|
||||||
|
serverTiming,
|
||||||
|
timing?.source,
|
||||||
|
)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json()
|
const error = await response.json()
|
||||||
throw new Error(error.detail || response.statusText)
|
throw new Error(error.detail || response.statusText)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Play media error:", e)
|
const totalMs = Math.max(0, nowMs() - actionStart)
|
||||||
|
console.error(`Play media error after ${totalMs.toFixed(1)}ms (trace=${traceId}):`, e)
|
||||||
alert(`Failed to play media.\n\n${e}`)
|
alert(`Failed to play media.\n\n${e}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,20 +319,50 @@ export async function playMedia(
|
|||||||
/**
|
/**
|
||||||
* Open a folder in the system file manager
|
* Open a folder in the system file manager
|
||||||
*/
|
*/
|
||||||
export async function openFolder(rootId: string, folderPath: string): Promise<void> {
|
export async function openFolder(
|
||||||
|
rootId: string,
|
||||||
|
folderPath: string,
|
||||||
|
timing?: ActionTimingContext,
|
||||||
|
): Promise<void> {
|
||||||
const normalizedPath = normalizeMediaPath(folderPath)
|
const normalizedPath = normalizeMediaPath(folderPath)
|
||||||
|
const actionStart = timing?.actionStartedAt ?? nowMs()
|
||||||
|
const traceId = makeTraceId("open-folder")
|
||||||
try {
|
try {
|
||||||
|
const fetchStart = nowMs()
|
||||||
|
const actionToFetchMs = Math.max(0, fetchStart - actionStart)
|
||||||
|
const clientSentMs = Date.now()
|
||||||
|
const actionStartEpochMs = clientSentMs - actionToFetchMs
|
||||||
const response = await fetch(`/api/open-folder/${encodeURIComponent(rootId)}`, {
|
const response = await fetch(`/api/open-folder/${encodeURIComponent(rootId)}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-MediaHive-Trace-Id": traceId,
|
||||||
|
"X-MediaHive-Client-Sent-Ms": clientSentMs.toFixed(3),
|
||||||
|
"X-MediaHive-Client-Action-Start-Ms": actionStartEpochMs.toFixed(3),
|
||||||
|
},
|
||||||
body: JSON.stringify({ folder_path: normalizedPath }),
|
body: JSON.stringify({ folder_path: normalizedPath }),
|
||||||
})
|
})
|
||||||
|
const fetchMs = Math.max(0, nowMs() - fetchStart)
|
||||||
|
const totalMs = Math.max(0, nowMs() - actionStart)
|
||||||
|
const serverTiming = response.headers.get("server-timing")
|
||||||
|
const responseTraceId = response.headers.get("x-mediahive-trace-id") || traceId
|
||||||
|
logActionTiming(
|
||||||
|
"open-folder",
|
||||||
|
responseTraceId,
|
||||||
|
response.status,
|
||||||
|
actionToFetchMs,
|
||||||
|
fetchMs,
|
||||||
|
totalMs,
|
||||||
|
serverTiming,
|
||||||
|
timing?.source,
|
||||||
|
)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json()
|
const error = await response.json()
|
||||||
throw new Error(error.detail || response.statusText)
|
throw new Error(error.detail || response.statusText)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Open folder error:", e)
|
const totalMs = Math.max(0, nowMs() - actionStart)
|
||||||
|
console.error(`Open folder error after ${totalMs.toFixed(1)}ms (trace=${traceId}):`, e)
|
||||||
alert(`Failed to open folder.\n\n${e}`)
|
alert(`Failed to open folder.\n\n${e}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,13 +426,42 @@ export function getCoverUrl(coverPath: string | null, rootId?: string | null): s
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invoke the native OS folder picker via pywebview, then add the selected
|
* Invoke the native OS folder picker via pywebview.
|
||||||
* folder to the server's root list. Only works inside the packaged desktop app.
|
* Only works inside the packaged desktop app; returns null elsewhere.
|
||||||
*/
|
*/
|
||||||
export async function pickFolderAndAddRoot(): Promise<string | null> {
|
export async function pickFolder(): Promise<string | null> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const api = (window as any).pywebview?.api
|
const api = (window as any).pywebview?.api
|
||||||
if (!api) return null
|
if (!api) return null
|
||||||
const folder: string | null = await api.pick_folder()
|
const folder: string | null = await api.pick_folder()
|
||||||
return folder
|
return folder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateStatus {
|
||||||
|
version: string
|
||||||
|
auto_update: boolean
|
||||||
|
pending_version: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch version, auto-update preference, and any downloaded pending update. */
|
||||||
|
export async function fetchUpdateStatus(): Promise<UpdateStatus> {
|
||||||
|
const response = await fetch("/api/update")
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch update status: ${response.status}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enable or disable automatic update downloads (persisted server-side). */
|
||||||
|
export async function setAutoUpdate(enabled: boolean): Promise<void> {
|
||||||
|
const response = await fetch("/api/config/auto-update", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`Failed to save auto-update setting: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a downloaded update and restart the app into it. */
|
||||||
|
export async function restartForUpdate(): Promise<void> {
|
||||||
|
const response = await fetch("/api/update/restart", { method: "POST" })
|
||||||
|
if (!response.ok) throw new Error(`Failed to restart for update: ${response.status}`)
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -594,13 +594,7 @@ function activateItem(item: MediaItem) {
|
|||||||
|
|
||||||
function handleItemClick(event: MouseEvent, item: MediaItem) {
|
function handleItemClick(event: MouseEvent, item: MediaItem) {
|
||||||
// Let modified clicks navigate natively
|
// Let modified clicks navigate natively
|
||||||
if (
|
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||||
event.button !== 0 ||
|
|
||||||
event.ctrlKey ||
|
|
||||||
event.metaKey ||
|
|
||||||
event.shiftKey ||
|
|
||||||
event.altKey
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
ref="menuRef"
|
||||||
|
class="episode-release-menu"
|
||||||
|
:style="menuStyle"
|
||||||
|
tabindex="-1"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
>
|
||||||
|
<div class="episode-release-header">{{ episodeName }}</div>
|
||||||
|
<div v-if="releases.length > 0" class="episode-release-list">
|
||||||
|
<ReleaseVersionCard
|
||||||
|
v-for="(release, index) in releases"
|
||||||
|
:key="index"
|
||||||
|
:torrent="release"
|
||||||
|
:best="index === 0"
|
||||||
|
:selectable="!!release.playable_file"
|
||||||
|
:disabled="!release.playable_file"
|
||||||
|
compact-flags
|
||||||
|
variant="menu"
|
||||||
|
inert-card
|
||||||
|
show-actions
|
||||||
|
:play-label="getPlayLabel(release.playable_file)"
|
||||||
|
@play="emit('play', release.playable_file || '')"
|
||||||
|
@open-folder="emit('openFolder', release.playable_file || '')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="episode-release-empty">No versions available</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
|
||||||
|
import type { Torrent } from "../types"
|
||||||
|
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
episodeName: string
|
||||||
|
releases: Torrent[]
|
||||||
|
hasResumePosition: (filePath: string | null) => boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
play: [string]
|
||||||
|
openFolder: [string]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const menuRef = ref<HTMLElement | null>(null)
|
||||||
|
const menuLeft = ref(0)
|
||||||
|
const menuTop = ref(0)
|
||||||
|
const VIEWPORT_MARGIN = 12
|
||||||
|
|
||||||
|
const menuStyle = computed(() => ({
|
||||||
|
left: `${menuLeft.value}px`,
|
||||||
|
top: `${menuTop.value}px`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function getPlayLabel(filePath: string | null | undefined): string {
|
||||||
|
return props.hasResumePosition(filePath || null) ? "Continue" : "Play"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFocusableElements(): HTMLElement[] {
|
||||||
|
if (!menuRef.value) return []
|
||||||
|
return Array.from(menuRef.value.querySelectorAll<HTMLElement>(".ctx-btn:not(:disabled)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusNext(delta: number) {
|
||||||
|
const elements = getFocusableElements()
|
||||||
|
if (elements.length === 0) return
|
||||||
|
const currentIndex = elements.findIndex((el) => el === document.activeElement)
|
||||||
|
const nextIndex =
|
||||||
|
currentIndex < 0 ? 0 : (currentIndex + delta + elements.length) % elements.length
|
||||||
|
elements[nextIndex].focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
event.preventDefault()
|
||||||
|
focusNext(event.shiftKey ? -1 : 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||||
|
event.preventDefault()
|
||||||
|
focusNext(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault()
|
||||||
|
focusNext(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
emit("close")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampToViewport() {
|
||||||
|
const menu = menuRef.value
|
||||||
|
if (!menu) return
|
||||||
|
|
||||||
|
const width = menu.offsetWidth
|
||||||
|
const height = menu.offsetHeight
|
||||||
|
|
||||||
|
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN)
|
||||||
|
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)
|
||||||
|
|
||||||
|
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft)
|
||||||
|
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleViewportChange() {
|
||||||
|
if (!props.visible) return
|
||||||
|
clampToViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.visible, props.x, props.y, props.episodeName, props.releases.length],
|
||||||
|
async ([visible]) => {
|
||||||
|
if (!visible) return
|
||||||
|
await nextTick()
|
||||||
|
clampToViewport()
|
||||||
|
// Focus first action button for keyboard navigation
|
||||||
|
const firstBtn = menuRef.value?.querySelector(".ctx-btn:not(:disabled)") as HTMLElement | null
|
||||||
|
firstBtn?.focus()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
window.addEventListener("resize", handleViewportChange)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.removeEventListener("resize", handleViewportChange)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("resize", handleViewportChange)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.episode-release-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(20, 20, 30, 0.98);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 420px;
|
||||||
|
max-width: min(820px, calc(100vw - 24px));
|
||||||
|
max-height: calc(100vh - 24px);
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-release-header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-release-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-release-empty {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+717
-107
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="hex-keyboard-fade">
|
<Transition name="hex-keyboard-fade">
|
||||||
<div v-if="visible" ref="keyboardRef" class="hex-keyboard" @click.stop @keydown="handleKeyDown">
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
ref="keyboardRef"
|
||||||
|
class="hex-keyboard"
|
||||||
|
@click.stop
|
||||||
|
@keydown="handleKeyDown"
|
||||||
|
>
|
||||||
<div ref="gridRef" class="hex-keyboard-grid">
|
<div ref="gridRef" class="hex-keyboard-grid">
|
||||||
<div
|
<div
|
||||||
v-for="(row, rowIndex) in rows"
|
v-for="(row, rowIndex) in rows"
|
||||||
@@ -26,7 +32,9 @@
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@click="handleKeyClick(key)"
|
@click="handleKeyClick(key)"
|
||||||
>
|
>
|
||||||
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{ key.label }}</span>
|
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{
|
||||||
|
key.label
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Green focus outline rendered separately on top -->
|
<!-- Green focus outline rendered separately on top -->
|
||||||
@@ -174,11 +182,16 @@ function getCoord(index: number): { row: number; col: number } {
|
|||||||
|
|
||||||
function getRowRange(row: number): { start: number; count: number } {
|
function getRowRange(row: number): { start: number; count: number } {
|
||||||
switch (row) {
|
switch (row) {
|
||||||
case 0: return { start: ROW_0_START, count: ROW_0_COUNT }
|
case 0:
|
||||||
case 1: return { start: ROW_1_START, count: ROW_1_COUNT }
|
return { start: ROW_0_START, count: ROW_0_COUNT }
|
||||||
case 2: return { start: ROW_2_START, count: ROW_2_COUNT }
|
case 1:
|
||||||
case 3: return { start: ROW_3_START, count: ROW_3_COUNT }
|
return { start: ROW_1_START, count: ROW_1_COUNT }
|
||||||
default: return { start: 0, count: 0 }
|
case 2:
|
||||||
|
return { start: ROW_2_START, count: ROW_2_COUNT }
|
||||||
|
case 3:
|
||||||
|
return { start: ROW_3_START, count: ROW_3_COUNT }
|
||||||
|
default:
|
||||||
|
return { start: 0, count: 0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,10 +260,7 @@ function close() {
|
|||||||
emit("close")
|
emit("close")
|
||||||
}
|
}
|
||||||
|
|
||||||
function findNext(
|
function findNext(currentIdx: number, direction: "up" | "down" | "left" | "right"): number | null {
|
||||||
currentIdx: number,
|
|
||||||
direction: "up" | "down" | "left" | "right",
|
|
||||||
): number | null {
|
|
||||||
const current = getCoord(currentIdx)
|
const current = getCoord(currentIdx)
|
||||||
|
|
||||||
if (direction === "left") {
|
if (direction === "left") {
|
||||||
@@ -510,10 +520,18 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Row horizontal offsets for honeycomb staggering */
|
/* Row horizontal offsets for honeycomb staggering */
|
||||||
.hex-keyboard-row-0 { margin-left: 0; }
|
.hex-keyboard-row-0 {
|
||||||
.hex-keyboard-row-1 { margin-left: calc(var(--key-w) * 0.5); }
|
margin-left: 0;
|
||||||
.hex-keyboard-row-2 { margin-left: calc(var(--key-w) * 1.0); }
|
}
|
||||||
.hex-keyboard-row-3 { margin-left: calc(var(--key-w) * 1.5); }
|
.hex-keyboard-row-1 {
|
||||||
|
margin-left: calc(var(--key-w) * 0.5);
|
||||||
|
}
|
||||||
|
.hex-keyboard-row-2 {
|
||||||
|
margin-left: calc(var(--key-w) * 1);
|
||||||
|
}
|
||||||
|
.hex-keyboard-row-3 {
|
||||||
|
margin-left: calc(var(--key-w) * 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
.hex-key {
|
.hex-key {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -529,7 +547,9 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||||
transition: transform 0.15s ease, color 0.3s ease;
|
transition:
|
||||||
|
transform 0.15s ease,
|
||||||
|
color 0.3s ease;
|
||||||
outline: none;
|
outline: none;
|
||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
@@ -538,7 +558,8 @@ onUnmounted(() => {
|
|||||||
.hex-key-row-0 {
|
.hex-key-row-0 {
|
||||||
background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%);
|
background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%);
|
||||||
}
|
}
|
||||||
.hex-key-row-1, .hex-key-row-3 {
|
.hex-key-row-1,
|
||||||
|
.hex-key-row-3 {
|
||||||
background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%);
|
background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%);
|
||||||
}
|
}
|
||||||
.hex-key-row-2 {
|
.hex-key-row-2 {
|
||||||
@@ -553,8 +574,12 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes hex-label-fade {
|
@keyframes hex-label-fade {
|
||||||
0% { color: #22c55e; }
|
0% {
|
||||||
100% { color: #ffffff; }
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Special key backgrounds override row gradients */
|
/* Special key backgrounds override row gradients */
|
||||||
@@ -600,8 +625,13 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes hex-outline-blink {
|
@keyframes hex-outline-blink {
|
||||||
0%, 100% { opacity: 1; }
|
0%,
|
||||||
50% { opacity: 0.4; }
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Transition */
|
/* Transition */
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
v-for="entry in flagEntries"
|
v-for="entry in flagEntries"
|
||||||
:key="entry.countryCode"
|
:key="entry.countryCode"
|
||||||
class="language-flag"
|
class="language-flag"
|
||||||
:title="`${entry.countryCode}: ${entry.sourceCodes.join(', ')}`"
|
:title="formatLanguageFlagTitle(entry, externalCodes)"
|
||||||
v-html="entry.svg"
|
v-html="entry.svg"
|
||||||
></span>
|
></span>
|
||||||
<span
|
<span
|
||||||
@@ -22,11 +22,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
import { buildLanguageFlags } from "../utils/languageFlags"
|
import { buildLanguageFlags, formatLanguageFlagTitle } from "../utils/languageFlags"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
label?: string
|
label?: string
|
||||||
codes: string[] | null | undefined
|
codes: string[] | null | undefined
|
||||||
|
externalCodes?: string[] | null
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
:src="posterImageUrl"
|
:src="posterImageUrl"
|
||||||
:alt="item.title || 'Unknown'"
|
:alt="item.title || 'Unknown'"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
@error="imageError = true"
|
@error="imageError = true"
|
||||||
/>
|
/>
|
||||||
<div v-else class="media-card-placeholder">
|
<div v-else class="media-card-placeholder">
|
||||||
@@ -79,7 +80,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
||||||
<div v-if="directorAndCast" class="media-card-detail person-list">
|
<div v-if="directorAndCast" class="media-card-detail person-list">
|
||||||
<span v-if="director" class="director-name person-token">{{ formatPersonLabel(director) }}</span>
|
<span v-if="director" class="director-name person-token">{{
|
||||||
|
formatPersonLabel(director)
|
||||||
|
}}</span>
|
||||||
<span
|
<span
|
||||||
v-for="(castName, castIndex) in formattedCastNames"
|
v-for="(castName, castIndex) in formattedCastNames"
|
||||||
:key="`${castName}-${castIndex}`"
|
:key="`${castName}-${castIndex}`"
|
||||||
@@ -111,13 +114,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
function handleClick(event: MouseEvent) {
|
function handleClick(event: MouseEvent) {
|
||||||
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
|
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
|
||||||
if (
|
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||||
event.button !== 0 ||
|
|
||||||
event.ctrlKey ||
|
|
||||||
event.metaKey ||
|
|
||||||
event.shiftKey ||
|
|
||||||
event.altKey
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Prevent default navigation for plain left-clicks and synthetic clicks
|
// Prevent default navigation for plain left-clicks and synthetic clicks
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
<!-- Full screen view for series -->
|
<!-- Full screen view for series -->
|
||||||
<SeriesFullView
|
<SeriesFullView
|
||||||
v-if="item.type === 'series'"
|
v-if="item.type === 'series'"
|
||||||
|
:key="item.id"
|
||||||
:series="item.data as Series"
|
:series="item.data as Series"
|
||||||
:all-movies="allMovies"
|
:all-movies="allMovies"
|
||||||
:focus-episode="focusEpisode"
|
:focus-episode="focusEpisode"
|
||||||
:has-resume-position="hasResumePosition"
|
:resume-point="getResumePoint(item.id)"
|
||||||
|
:resume-episodes="getResumeEpisodes(item.id)"
|
||||||
:get-root-name="getRootName"
|
:get-root-name="getRootName"
|
||||||
@close="$emit('close')"
|
@close="$emit('close')"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@@ -17,7 +19,7 @@
|
|||||||
<div v-else class="movie-page">
|
<div v-else class="movie-page">
|
||||||
<div class="movie-page-content">
|
<div class="movie-page-content">
|
||||||
<!-- Diagonal collage header -->
|
<!-- Diagonal collage header -->
|
||||||
<div class="collage-header">
|
<div ref="collageHeaderRef" class="collage-header">
|
||||||
<!-- Background collage of showreel videos -->
|
<!-- Background collage of showreel videos -->
|
||||||
<div class="collage-grid">
|
<div class="collage-grid">
|
||||||
<div
|
<div
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
<div class="collage-fallback-tile" :class="`collage-fallback-${slot.index + 1}`"></div>
|
<div class="collage-fallback-tile" :class="`collage-fallback-${slot.index + 1}`"></div>
|
||||||
<video
|
<video
|
||||||
v-if="slot.sourcePaths.length > 0"
|
v-if="slot.sourcePaths.length > 0"
|
||||||
|
:key="`${item.id}-${slot.index}-${slot.sourcePaths.join('|')}`"
|
||||||
:ref="(el) => setVideoRef(el as HTMLVideoElement, slot.index)"
|
:ref="(el) => setVideoRef(el as HTMLVideoElement, slot.index)"
|
||||||
:class="{ 'is-ready': isVideoReady(slot.index) }"
|
:class="{ 'is-ready': isVideoReady(slot.index) }"
|
||||||
:autoplay="safariAutoplay"
|
:autoplay="safariAutoplay"
|
||||||
@@ -179,31 +182,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="item.type === 'movies' && similarMovies.length > 0" class="similar-movies-section">
|
<section
|
||||||
<h2 class="similar-movies-title">Similar In Library</h2>
|
v-if="item.type === 'movies' && collectionMovies.length > 1"
|
||||||
<div class="similar-movies-grid" data-sync-scroll-row="true" data-sync-scroll-group="similar">
|
class="similar-movies-section"
|
||||||
<button
|
>
|
||||||
v-for="(movie, similarIndex) in similarMovies"
|
<div
|
||||||
:key="movie.tmdbId"
|
class="similar-movies-grid"
|
||||||
type="button"
|
data-sync-scroll-row="true"
|
||||||
class="similar-movie-card cast-card media-card"
|
data-sync-scroll-group="similar"
|
||||||
v-bind="navAttrs(similarNavRow, similarIndex)"
|
>
|
||||||
@click="handleSelectMovie(movie.localId)"
|
<a
|
||||||
|
v-for="(movie, collectionIndex) in collectionMovies"
|
||||||
|
:key="movie.localId"
|
||||||
|
href="#"
|
||||||
|
class="similar-movie-card media-card"
|
||||||
|
:class="{ 'similar-movie-card--current': movie.isCurrent }"
|
||||||
|
:aria-current="movie.isCurrent ? 'true' : undefined"
|
||||||
|
v-bind="navAttrs(collectionNavRow, collectionIndex)"
|
||||||
|
@click.prevent="handleSelectCollectionMovie(movie.localId, movie.isCurrent)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="movie.coverPath"
|
v-if="movie.coverPath"
|
||||||
:src="getCoverUrl(movie.coverPath, movie.rootId)"
|
:src="getCoverUrl(movie.coverPath, movie.rootId)"
|
||||||
:alt="movie.title || 'Movie'"
|
:alt="movie.title || 'Movie'"
|
||||||
class="similar-movie-poster cast-photo"
|
class="similar-movie-poster"
|
||||||
/>
|
/>
|
||||||
<div v-else class="similar-movie-poster cast-photo similar-movie-poster-fallback"></div>
|
<div v-else class="similar-movie-poster similar-movie-poster-fallback"></div>
|
||||||
<div class="similar-movie-meta cast-copy">
|
</a>
|
||||||
<span class="similar-movie-name cast-name">{{ movie.title }}</span>
|
|
||||||
<span class="similar-movie-sub cast-character">
|
|
||||||
{{ movie.year || "Unknown Year" }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,6 +231,7 @@
|
|||||||
:play-label="getPlayLabel(versionActionMenu.filePath)"
|
:play-label="getPlayLabel(versionActionMenu.filePath)"
|
||||||
@play="handlePlayVersion(versionActionMenu.filePath)"
|
@play="handlePlayVersion(versionActionMenu.filePath)"
|
||||||
@open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
|
@open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
|
||||||
|
@close="closeVersionActionMenu"
|
||||||
/>
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +239,16 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
|
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
|
||||||
import type { CastMember, MediaItem, Movie, MovieUi, Series, Torrent } from "../types"
|
import type {
|
||||||
|
CastMember,
|
||||||
|
MediaItem,
|
||||||
|
Movie,
|
||||||
|
MovieUi,
|
||||||
|
Series,
|
||||||
|
SeriesResumePoint,
|
||||||
|
Torrent,
|
||||||
|
} from "../types"
|
||||||
|
import type { EpisodeWatchEntry } from "../api"
|
||||||
import {
|
import {
|
||||||
getCoverUrl,
|
getCoverUrl,
|
||||||
getVideoPreviewUrl,
|
getVideoPreviewUrl,
|
||||||
@@ -251,13 +266,17 @@ import {
|
|||||||
navAttrs,
|
navAttrs,
|
||||||
registerOutOfBoundsNavigationHandler,
|
registerOutOfBoundsNavigationHandler,
|
||||||
FOCUSABLE_ATTR,
|
FOCUSABLE_ATTR,
|
||||||
|
setModalOpen,
|
||||||
} from "../composables/useKeyboardNavigation"
|
} from "../composables/useKeyboardNavigation"
|
||||||
|
import { useIdlePreviewPlayback } from "../composables/useIdlePreviewPlayback"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
item: MediaItem
|
item: MediaItem
|
||||||
allMovies: MovieUi[]
|
allMovies: MovieUi[]
|
||||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
|
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
|
||||||
hasResumePosition: (filePath: string | null) => boolean
|
hasResumePosition: (mediaId: string | null) => boolean
|
||||||
|
getResumePoint: (mediaId: string | null) => SeriesResumePoint | null
|
||||||
|
getResumeEpisodes: (mediaId: string | null) => Record<string, EpisodeWatchEntry> | null
|
||||||
getRootName: (rootId: string | null | undefined) => string | null
|
getRootName: (rootId: string | null | undefined) => string | null
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -277,6 +296,22 @@ const safariAutoplay = isSafariBrowser()
|
|||||||
const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2]
|
const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2]
|
||||||
const DESKTOP_NAV_SHORTCUT_MIN_WIDTH = 900
|
const DESKTOP_NAV_SHORTCUT_MIN_WIDTH = 900
|
||||||
|
|
||||||
|
const collageHeaderRef = ref<HTMLElement | null>(null)
|
||||||
|
let collageHeaderVisible = true
|
||||||
|
let collageHeaderObserver: IntersectionObserver | null = null
|
||||||
|
const staggerTimers = new Set<ReturnType<typeof setTimeout>>()
|
||||||
|
const COLLAGE_STOP_STEP_MS = 500
|
||||||
|
let staggerToken = 0
|
||||||
|
|
||||||
|
const { stopped: previewPlaybackStopped } = useIdlePreviewPlayback({
|
||||||
|
onStop: stopPreviews,
|
||||||
|
onRestart: () => startStaggeredPlayback(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function previewsSuppressed(): boolean {
|
||||||
|
return previewPlaybackStopped.value || !collageHeaderVisible || document.hidden
|
||||||
|
}
|
||||||
|
|
||||||
let disposeOutOfBoundsHandler: (() => void) | null = null
|
let disposeOutOfBoundsHandler: (() => void) | null = null
|
||||||
let lastReleaseShortcutRow: number | null = null
|
let lastReleaseShortcutRow: number | null = null
|
||||||
|
|
||||||
@@ -293,7 +328,9 @@ function getReleaseAtRow(row: number): HTMLElement | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getLastReleaseRowBefore(castRow: number): number | null {
|
function getLastReleaseRowBefore(castRow: number): number | null {
|
||||||
const releases = Array.from(document.querySelectorAll<HTMLElement>('[data-nav-release-item="true"]'))
|
const releases = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-nav-release-item="true"]'),
|
||||||
|
)
|
||||||
let best: number | null = null
|
let best: number | null = null
|
||||||
for (const release of releases) {
|
for (const release of releases) {
|
||||||
const row = parseInt(release.getAttribute("data-nav-row") || "", 10)
|
const row = parseInt(release.getAttribute("data-nav-row") || "", 10)
|
||||||
@@ -320,11 +357,7 @@ function registerMovieOutOfBoundsShortcut() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (direction === "left" && current.hasAttribute("data-nav-cast-item") && currentCol === 0) {
|
||||||
direction === "left" &&
|
|
||||||
current.hasAttribute("data-nav-cast-item") &&
|
|
||||||
currentCol === 0
|
|
||||||
) {
|
|
||||||
const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow)
|
const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow)
|
||||||
if (targetRow === null) return null
|
if (targetRow === null) return null
|
||||||
return getReleaseAtRow(targetRow)
|
return getReleaseAtRow(targetRow)
|
||||||
@@ -361,15 +394,35 @@ function isVideoReady(index: number): boolean {
|
|||||||
return videoStates.value[index] === "ready"
|
return videoStates.value[index] === "ready"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelStaggeredPlayback() {
|
||||||
|
staggerToken += 1
|
||||||
|
for (const timer of staggerTimers) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
staggerTimers.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleStaggeredStart(token: number, start: () => void, delayMs: number) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
staggerTimers.delete(timer)
|
||||||
|
if (token !== staggerToken || previewsSuppressed()) return
|
||||||
|
start()
|
||||||
|
}, delayMs)
|
||||||
|
staggerTimers.add(timer)
|
||||||
|
}
|
||||||
|
|
||||||
// Start staggered video playback
|
// Start staggered video playback
|
||||||
function startStaggeredPlayback() {
|
function startStaggeredPlayback() {
|
||||||
|
cancelStaggeredPlayback()
|
||||||
|
const token = staggerToken
|
||||||
const videos = videoRefs.value.filter((v) => v !== null) as HTMLVideoElement[]
|
const videos = videoRefs.value.filter((v) => v !== null) as HTMLVideoElement[]
|
||||||
if (videos.length === 0) return
|
if (videos.length === 0 || previewsSuppressed()) return
|
||||||
|
|
||||||
if (safariAutoplay) {
|
if (safariAutoplay) {
|
||||||
videos.forEach((video, index) => {
|
videos.forEach((video, index) => {
|
||||||
const offset = COLLAGE_START_OFFSETS_SECONDS[index] ?? 0
|
const offset = COLLAGE_START_OFFSETS_SECONDS[index] ?? 0
|
||||||
const startVideo = () => {
|
const startVideo = () => {
|
||||||
|
if (token !== staggerToken || previewsSuppressed()) return
|
||||||
video.currentTime = offset
|
video.currentTime = offset
|
||||||
video.play().catch(() => {})
|
video.play().catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -389,11 +442,46 @@ function startStaggeredPlayback() {
|
|||||||
|
|
||||||
// Set up staggered start for remaining videos
|
// Set up staggered start for remaining videos
|
||||||
for (let i = 1; i < videos.length; i++) {
|
for (let i = 1; i < videos.length; i++) {
|
||||||
setTimeout(() => {
|
scheduleStaggeredStart(
|
||||||
|
token,
|
||||||
|
() => {
|
||||||
const video = videos[i]
|
const video = videos[i]
|
||||||
if (!video) return
|
if (!video) return
|
||||||
video.play().catch(() => {})
|
video.play().catch(() => {})
|
||||||
}, i * 2000)
|
},
|
||||||
|
i * 2000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleStaggeredStop(token: number, stop: () => void, delayMs: number) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
staggerTimers.delete(timer)
|
||||||
|
if (token !== staggerToken) return
|
||||||
|
stop()
|
||||||
|
}, delayMs)
|
||||||
|
staggerTimers.add(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop all collage previews with a stagger, and cancel pending staggered starts
|
||||||
|
function stopPreviews() {
|
||||||
|
cancelStaggeredPlayback()
|
||||||
|
const token = staggerToken
|
||||||
|
clearHoverAudioIdleTimer()
|
||||||
|
hoveredVideoIndex = null
|
||||||
|
for (const interval of volumeFadeIntervals.values()) {
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
volumeFadeIntervals.clear()
|
||||||
|
|
||||||
|
let stopIndex = 0
|
||||||
|
for (const video of videoRefs.value) {
|
||||||
|
if (video && !video.paused && !video.ended) {
|
||||||
|
scheduleStaggeredStop(token, () => video.pause(), stopIndex * COLLAGE_STOP_STEP_MS)
|
||||||
|
stopIndex += 1
|
||||||
|
} else {
|
||||||
|
video?.pause()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,6 +584,19 @@ onMounted(() => {
|
|||||||
startStaggeredPlayback()
|
startStaggeredPlayback()
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
|
// Pause the collage videos while the header is scrolled out of view
|
||||||
|
if (collageHeaderRef.value) {
|
||||||
|
collageHeaderObserver = new IntersectionObserver((entries) => {
|
||||||
|
collageHeaderVisible = entries[0]?.isIntersecting ?? true
|
||||||
|
if (collageHeaderVisible) {
|
||||||
|
startStaggeredPlayback()
|
||||||
|
} else {
|
||||||
|
stopPreviews()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
collageHeaderObserver.observe(collageHeaderRef.value)
|
||||||
|
}
|
||||||
|
|
||||||
registerMovieOutOfBoundsShortcut()
|
registerMovieOutOfBoundsShortcut()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -555,6 +656,11 @@ watch(
|
|||||||
)
|
)
|
||||||
videoStates.value = slots.map((slot) => (slot.sourcePaths.length > 0 ? "loading" : "missing"))
|
videoStates.value = slots.map((slot) => (slot.sourcePaths.length > 0 ? "loading" : "missing"))
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
for (let i = 0; i < videoRefs.value.length; i++) {
|
||||||
|
if (slots[i]?.sourcePaths.length > 0) {
|
||||||
|
videoRefs.value[i]?.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
startStaggeredPlayback()
|
startStaggeredPlayback()
|
||||||
}, 100)
|
}, 100)
|
||||||
@@ -650,72 +756,103 @@ const movieKeywords = computed(() => {
|
|||||||
|
|
||||||
const viewportWidth = ref(typeof window !== "undefined" ? window.innerWidth : 1920)
|
const viewportWidth = ref(typeof window !== "undefined" ? window.innerWidth : 1920)
|
||||||
|
|
||||||
const similarNavRow = computed(() => 3 + movieVersions.value.length)
|
const collectionNavRow = computed(() => 3 + movieVersions.value.length)
|
||||||
|
|
||||||
const castNavRow = computed(() => {
|
const castNavRow = computed(() => {
|
||||||
const hasDesktopSimilarShortcut =
|
const hasDesktopSimilarShortcut =
|
||||||
viewportWidth.value > DESKTOP_NAV_SHORTCUT_MIN_WIDTH && similarMovies.value.length > 0
|
viewportWidth.value > DESKTOP_NAV_SHORTCUT_MIN_WIDTH && collectionMovies.value.length > 1
|
||||||
// Desktop with similar row: keep visual cast placement but move it below similar in nav rows.
|
// Desktop with similar row: keep visual cast placement but move it below similar in nav rows.
|
||||||
// Narrow layout (or no similar): preserve existing cast row directly after releases.
|
// Narrow layout (or no similar): preserve existing cast row directly after releases.
|
||||||
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
|
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
|
||||||
})
|
})
|
||||||
|
|
||||||
const similarMovies = computed((): Array<{
|
const collectionMovies = computed(
|
||||||
tmdbId: number
|
(): Array<{
|
||||||
title: string
|
title: string
|
||||||
localId: string
|
localId: string
|
||||||
coverPath: string | null
|
coverPath: string | null
|
||||||
rootId: string | null
|
rootId: string | null
|
||||||
year: string | null
|
year: string | null
|
||||||
}> => {
|
hyphenLang: string | null
|
||||||
|
isCurrent: boolean
|
||||||
|
}> => {
|
||||||
if (props.item.type !== "movies") return []
|
if (props.item.type !== "movies") return []
|
||||||
|
|
||||||
const movie = props.item.data as Movie
|
const movie = props.item.data as Movie
|
||||||
const similar = movie.info?.similar || []
|
const collectionName = movie.info?.collection?.trim()
|
||||||
if (similar.length === 0) return []
|
if (!collectionName) return []
|
||||||
|
const normalizedCollectionName = collectionName.toLowerCase()
|
||||||
const byTmdbId = new Map<number, MovieUi>()
|
|
||||||
for (const libraryMovie of props.allMovies || []) {
|
|
||||||
const tmdbId = libraryMovie.info?.tmdb_id
|
|
||||||
if (libraryMovie.id === props.item.id) continue
|
|
||||||
|
|
||||||
if (typeof tmdbId === "number" && !byTmdbId.has(tmdbId)) {
|
|
||||||
byTmdbId.set(tmdbId, libraryMovie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches: Array<{
|
const matches: Array<{
|
||||||
tmdbId: number
|
|
||||||
title: string
|
title: string
|
||||||
localId: string
|
localId: string
|
||||||
coverPath: string | null
|
coverPath: string | null
|
||||||
rootId: string | null
|
rootId: string | null
|
||||||
year: string | null
|
year: string | null
|
||||||
|
hyphenLang: string | null
|
||||||
|
isCurrent: boolean
|
||||||
}> = []
|
}> = []
|
||||||
|
|
||||||
const seenTmdbIds = new Set<number>()
|
let hasCurrentInMatches = false
|
||||||
for (const similarEntry of similar) {
|
|
||||||
if (seenTmdbIds.has(similarEntry.id)) continue
|
|
||||||
seenTmdbIds.add(similarEntry.id)
|
|
||||||
|
|
||||||
const matched = byTmdbId.get(similarEntry.id)
|
for (const libraryMovie of props.allMovies || []) {
|
||||||
if (!matched) continue
|
const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
|
||||||
|
if (otherCollectionName !== normalizedCollectionName) continue
|
||||||
|
|
||||||
const title = matched.title || matched.info?.title || similarEntry.title
|
const title = libraryMovie.title || libraryMovie.info?.title
|
||||||
if (!title) continue
|
if (!title) continue
|
||||||
|
|
||||||
|
const isCurrent = libraryMovie.id === props.item.id
|
||||||
|
if (isCurrent) hasCurrentInMatches = true
|
||||||
|
|
||||||
matches.push({
|
matches.push({
|
||||||
tmdbId: similarEntry.id,
|
|
||||||
title,
|
title,
|
||||||
localId: matched.id,
|
localId: libraryMovie.id,
|
||||||
coverPath: matched.cover_path || null,
|
coverPath: libraryMovie.cover_path || null,
|
||||||
rootId: matched.root_id || null,
|
rootId: libraryMovie.root_id || null,
|
||||||
year: matched.year ? String(matched.year) : matched.info?.release_date?.slice(0, 4) || null,
|
year: libraryMovie.year
|
||||||
|
? String(libraryMovie.year)
|
||||||
|
: libraryMovie.info?.release_date?.slice(0, 4) || null,
|
||||||
|
hyphenLang: normalizeHyphenationLang(libraryMovie.info?.original_language),
|
||||||
|
isCurrent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches.slice(0, 24)
|
if (!hasCurrentInMatches) {
|
||||||
})
|
matches.push({
|
||||||
|
title: props.item.title || (props.item.data as Movie).info?.title || "Current movie",
|
||||||
|
localId: props.item.id,
|
||||||
|
coverPath: props.item.cover_path || null,
|
||||||
|
rootId: props.item.root_id || null,
|
||||||
|
year: props.item.year
|
||||||
|
? String(props.item.year)
|
||||||
|
: (props.item.data as Movie).info?.release_date?.slice(0, 4) || null,
|
||||||
|
hyphenLang: normalizeHyphenationLang((props.item.data as Movie).info?.original_language),
|
||||||
|
isCurrent: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
.sort((a, b) => {
|
||||||
|
const yearA = parseInt(a.year || "", 10)
|
||||||
|
const yearB = parseInt(b.year || "", 10)
|
||||||
|
const hasYearA = Number.isFinite(yearA)
|
||||||
|
const hasYearB = Number.isFinite(yearB)
|
||||||
|
|
||||||
|
if (hasYearA && hasYearB && yearA !== yearB) return yearA - yearB
|
||||||
|
if (hasYearA !== hasYearB) return hasYearA ? -1 : 1
|
||||||
|
return a.title.localeCompare(b.title)
|
||||||
|
})
|
||||||
|
.slice(0, 24)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function normalizeHyphenationLang(language: string | null | undefined): string | null {
|
||||||
|
if (!language) return null
|
||||||
|
const normalized = language.trim()
|
||||||
|
if (!/^[A-Za-z]{2,3}(?:-[A-Za-z]{2,4})?$/.test(normalized)) return null
|
||||||
|
return normalized.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
function formatKeywordLabel(keyword: string): string {
|
function formatKeywordLabel(keyword: string): string {
|
||||||
// Keep multi-word keywords together while visually narrowing internal spacing.
|
// Keep multi-word keywords together while visually narrowing internal spacing.
|
||||||
@@ -763,14 +900,6 @@ const ratingClass = computed(() => {
|
|||||||
return "rating-low"
|
return "rating-low"
|
||||||
})
|
})
|
||||||
|
|
||||||
const seasons = computed(() => {
|
|
||||||
if (props.item.type !== "series") return []
|
|
||||||
const series = props.item.data as Series
|
|
||||||
return series.seasons || []
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedSeasonIndex = ref<number>(0)
|
|
||||||
|
|
||||||
const versionActionMenu = ref<{
|
const versionActionMenu = ref<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
x: number
|
x: number
|
||||||
@@ -788,14 +917,19 @@ const versionActionMenu = ref<{
|
|||||||
})
|
})
|
||||||
|
|
||||||
function closeVersionActionMenu() {
|
function closeVersionActionMenu() {
|
||||||
|
const wasVisible = versionActionMenu.value.visible
|
||||||
versionActionMenu.value.visible = false
|
versionActionMenu.value.visible = false
|
||||||
versionActionMenu.value.filePath = null
|
versionActionMenu.value.filePath = null
|
||||||
versionActionMenu.value.rootName = null
|
versionActionMenu.value.rootName = null
|
||||||
versionActionMenu.value.rootId = null
|
versionActionMenu.value.rootId = null
|
||||||
|
if (wasVisible) {
|
||||||
|
setModalOpen(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPlayLabel(filePath: string | null): string {
|
function getPlayLabel(filePath: string | null): string {
|
||||||
return props.hasResumePosition(filePath) ? "Continue" : "Play"
|
if (!filePath || props.item.type !== "movies") return "Play"
|
||||||
|
return props.hasResumePosition(props.item.id) ? "Continue" : "Play"
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlayVersion(filePath: string | null) {
|
function handlePlayVersion(filePath: string | null) {
|
||||||
@@ -808,6 +942,7 @@ function handlePlayVersion(filePath: string | null) {
|
|||||||
function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
|
function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
setModalOpen(true)
|
||||||
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
|
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
|
||||||
versionActionMenu.value = {
|
versionActionMenu.value = {
|
||||||
visible: true,
|
visible: true,
|
||||||
@@ -851,17 +986,6 @@ function handleMovieMenuKeydown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select first season by default
|
|
||||||
watch(
|
|
||||||
seasons,
|
|
||||||
(s) => {
|
|
||||||
if (s.length > 0 && selectedSeasonIndex.value >= s.length) {
|
|
||||||
selectedSeasonIndex.value = 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
function handlePlay(filePath: string | null) {
|
function handlePlay(filePath: string | null) {
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
emit("play", filePath)
|
emit("play", filePath)
|
||||||
@@ -893,21 +1017,65 @@ function handleSelectMovie(movieId: string) {
|
|||||||
emit("selectMovie", movieId)
|
emit("selectMovie", movieId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSelectCollectionMovie(movieId: string, isCurrent: boolean) {
|
||||||
|
if (isCurrent) return
|
||||||
|
handleSelectMovie(movieId)
|
||||||
|
}
|
||||||
|
|
||||||
function handleResize() {
|
function handleResize() {
|
||||||
viewportWidth.value = window.innerWidth
|
viewportWidth.value = window.innerWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleGamepadAction(event: Event) {
|
||||||
|
const actionEvent = event as CustomEvent<{ action?: string }>
|
||||||
|
if (actionEvent.detail?.action !== "menu") return
|
||||||
|
|
||||||
|
const active = document.activeElement as HTMLElement | null
|
||||||
|
if (!active || !active.hasAttribute("data-nav-release-item")) return
|
||||||
|
|
||||||
|
const row = parseInt(active.getAttribute("data-nav-row") || "-1", 10)
|
||||||
|
if (row < 0) return
|
||||||
|
|
||||||
|
const index = row - 2 // releases start at nav row 2
|
||||||
|
const version = movieVersions.value[index]
|
||||||
|
if (!version) return
|
||||||
|
|
||||||
|
actionEvent.preventDefault()
|
||||||
|
setModalOpen(true)
|
||||||
|
const rect = active.getBoundingClientRect()
|
||||||
|
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
|
||||||
|
versionActionMenu.value = {
|
||||||
|
visible: true,
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top + rect.height / 2,
|
||||||
|
filePath: version.playable_file || null,
|
||||||
|
rootName: props.getRootName(rootId) || null,
|
||||||
|
rootId,
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
const firstAction = document.querySelector(
|
||||||
|
".version-action-menu .version-action-item:not(:disabled)",
|
||||||
|
) as HTMLElement | null
|
||||||
|
firstAction?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener("keydown", handleMovieMenuKeydown, true)
|
document.addEventListener("keydown", handleMovieMenuKeydown, true)
|
||||||
window.addEventListener("resize", handleResize)
|
window.addEventListener("resize", handleResize)
|
||||||
window.addEventListener("mousemove", handleHoverAudioMouseMove, { passive: true })
|
window.addEventListener("mousemove", handleHoverAudioMouseMove, { passive: true })
|
||||||
|
window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener("keydown", handleMovieMenuKeydown, true)
|
document.removeEventListener("keydown", handleMovieMenuKeydown, true)
|
||||||
window.removeEventListener("resize", handleResize)
|
window.removeEventListener("resize", handleResize)
|
||||||
window.removeEventListener("mousemove", handleHoverAudioMouseMove)
|
window.removeEventListener("mousemove", handleHoverAudioMouseMove)
|
||||||
|
window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
|
||||||
clearHoverAudioIdleTimer()
|
clearHoverAudioIdleTimer()
|
||||||
|
cancelStaggeredPlayback()
|
||||||
|
collageHeaderObserver?.disconnect()
|
||||||
|
collageHeaderObserver = null
|
||||||
disposeOutOfBoundsHandler?.()
|
disposeOutOfBoundsHandler?.()
|
||||||
disposeOutOfBoundsHandler = null
|
disposeOutOfBoundsHandler = null
|
||||||
lastReleaseShortcutRow = null
|
lastReleaseShortcutRow = null
|
||||||
@@ -933,6 +1101,9 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.similar-movies-section {
|
.similar-movies-section {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
position: relative;
|
||||||
|
left: calc(-50vw + 50%);
|
||||||
|
width: 100vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.similar-movies-title {
|
.similar-movies-title {
|
||||||
@@ -943,7 +1114,12 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.similar-movies-grid {
|
.similar-movies-grid {
|
||||||
--sync-row-tail: 0px;
|
--sync-row-tail: 0px;
|
||||||
--sync-row-right-deadzone: 32px;
|
--similar-safe-start: 32px;
|
||||||
|
--similar-safe-end: 32px;
|
||||||
|
--sync-row-left-deadzone: var(--similar-safe-start);
|
||||||
|
--sync-row-right-deadzone: var(--similar-safe-end);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 calc(var(--similar-safe-end) + var(--sync-row-tail)) 0 var(--similar-safe-start);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -963,33 +1139,52 @@ onUnmounted(() => {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0;
|
||||||
|
/* Keep poster clipping local to the poster element. */
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-movie-card--current {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-movie-card::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 0 solid rgba(255, 255, 255, 0.95);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: border-width 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-movie-card:focus-visible,
|
||||||
|
html:not(.mouse-active) .similar-movie-card.nav-focused {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-movie-card:focus-visible::after,
|
||||||
|
html:not(.mouse-active) .similar-movie-card.nav-focused::after {
|
||||||
|
border-width: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.similar-movie-poster {
|
.similar-movie-poster {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: 0 0 0.4rem black;
|
||||||
|
transition: filter 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-movie-card--current .similar-movie-poster {
|
||||||
|
filter: sepia(0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
.similar-movie-poster-fallback {
|
.similar-movie-poster-fallback {
|
||||||
background: linear-gradient(135deg, #282d3a, #171b24);
|
background: linear-gradient(135deg, #282d3a, #171b24);
|
||||||
}
|
}
|
||||||
|
|
||||||
.similar-movie-meta {
|
|
||||||
inset: auto 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-movie-name {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-movie-sub {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-menu-backdrop {
|
.movie-menu-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1110,6 +1305,7 @@ onUnmounted(() => {
|
|||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 0.4rem black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.synopsis-poster {
|
.synopsis-poster {
|
||||||
@@ -1453,6 +1649,13 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.similar-movies-grid {
|
||||||
|
--similar-safe-start: 32px;
|
||||||
|
--similar-safe-end: 32px;
|
||||||
|
--sync-row-left-deadzone: 32px;
|
||||||
|
--sync-row-right-deadzone: 32px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Showreel gallery */
|
/* Showreel gallery */
|
||||||
@@ -1473,20 +1676,14 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.showreel-images::-webkit-scrollbar {
|
.showreel-images::-webkit-scrollbar {
|
||||||
height: 6px;
|
width: 0;
|
||||||
}
|
height: 0;
|
||||||
|
display: none;
|
||||||
.showreel-images::-webkit-scrollbar-track {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showreel-images::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.showreel-image {
|
.showreel-image {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="visible" ref="menuRef" class="version-action-menu" :style="menuStyle">
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
ref="menuRef"
|
||||||
|
class="version-action-menu"
|
||||||
|
:style="menuStyle"
|
||||||
|
tabindex="-1"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
>
|
||||||
<div class="version-action-path" :title="resolvedPath">
|
<div class="version-action-path" :title="resolvedPath">
|
||||||
{{ resolvedPath }}
|
{{ resolvedPath }}
|
||||||
</div>
|
</div>
|
||||||
@@ -47,6 +54,7 @@ const props = withDefaults(
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
play: []
|
play: []
|
||||||
openFolder: []
|
openFolder: []
|
||||||
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const menuRef = ref<HTMLElement | null>(null)
|
const menuRef = ref<HTMLElement | null>(null)
|
||||||
@@ -73,6 +81,44 @@ const menuStyle = computed(() => ({
|
|||||||
|
|
||||||
const disabled = computed(() => !props.filePath)
|
const disabled = computed(() => !props.filePath)
|
||||||
|
|
||||||
|
function getFocusableElements(): HTMLElement[] {
|
||||||
|
if (!menuRef.value) return []
|
||||||
|
return Array.from(
|
||||||
|
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusNext(delta: number) {
|
||||||
|
const elements = getFocusableElements()
|
||||||
|
if (elements.length === 0) return
|
||||||
|
const currentIndex = elements.findIndex((el) => el === document.activeElement)
|
||||||
|
const nextIndex =
|
||||||
|
currentIndex < 0 ? 0 : (currentIndex + delta + elements.length) % elements.length
|
||||||
|
elements[nextIndex].focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
event.preventDefault()
|
||||||
|
focusNext(event.shiftKey ? -1 : 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||||
|
event.preventDefault()
|
||||||
|
focusNext(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault()
|
||||||
|
focusNext(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
emit("close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clampToViewport() {
|
function clampToViewport() {
|
||||||
const menu = menuRef.value
|
const menu = menuRef.value
|
||||||
if (!menu) return
|
if (!menu) return
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
class="version-row"
|
class="version-row"
|
||||||
:class="{
|
:class="{
|
||||||
'version-best': best,
|
'version-best': best,
|
||||||
'version-selectable': isSelectable,
|
'version-selectable': isSelectable && !inertCard,
|
||||||
'version-disabled': isDisabled,
|
'version-disabled': isDisabled,
|
||||||
'version-menu': variant === 'menu',
|
'version-menu': variant === 'menu',
|
||||||
'version-with-actions': showActions,
|
'version-with-actions': showActions,
|
||||||
|
'version-inert': inertCard,
|
||||||
}"
|
}"
|
||||||
tabindex="0"
|
:tabindex="inertCard ? undefined : 0"
|
||||||
:title="resolvedTitle"
|
:title="resolvedTitle"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
@click="handleActivate"
|
@click="handleActivate"
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
<LanguageFlags
|
<LanguageFlags
|
||||||
class="language-flags-subs"
|
class="language-flags-subs"
|
||||||
:codes="torrent.subtitle_languages"
|
:codes="torrent.subtitle_languages"
|
||||||
|
:external-codes="torrent.external_subtitle_languages"
|
||||||
:compact="compactFlags"
|
:compact="compactFlags"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,6 +59,12 @@
|
|||||||
:alt="streamingServiceLogo.alt"
|
:alt="streamingServiceLogo.alt"
|
||||||
:title="streamingServiceLogo.alt"
|
:title="streamingServiceLogo.alt"
|
||||||
/>
|
/>
|
||||||
|
<img
|
||||||
|
v-if="showHdr10PlusLogo"
|
||||||
|
class="version-hdr10plus-logo"
|
||||||
|
:src="hdr10plusLogoUrl"
|
||||||
|
alt="HDR10+"
|
||||||
|
/>
|
||||||
<DolbyBadges
|
<DolbyBadges
|
||||||
class="version-dolby"
|
class="version-dolby"
|
||||||
:has-dolby-vision="hasDolbyVision"
|
:has-dolby-vision="hasDolbyVision"
|
||||||
@@ -70,14 +78,16 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
@click.stop="emit('play')"
|
@click.stop="emit('play')"
|
||||||
:disabled="!torrent.playable_file"
|
:disabled="!torrent.playable_file"
|
||||||
|
:title="playLabel"
|
||||||
>
|
>
|
||||||
▶ {{ playLabel }}
|
▶
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="ctx-btn ctx-btn-folder"
|
class="ctx-btn ctx-btn-folder"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@click.stop="emit('openFolder')"
|
@click.stop="emit('openFolder')"
|
||||||
:disabled="!torrent.playable_file"
|
:disabled="!torrent.playable_file"
|
||||||
|
title="Open Folder"
|
||||||
>
|
>
|
||||||
📁
|
📁
|
||||||
</button>
|
</button>
|
||||||
@@ -100,6 +110,7 @@ import hboMaxLogoUrl from "../assets/service-hbo-max.webp"
|
|||||||
import huluLogoUrl from "../assets/service-hulu.webp"
|
import huluLogoUrl from "../assets/service-hulu.webp"
|
||||||
import disneyLogoUrl from "../assets/service-disney.svg"
|
import disneyLogoUrl from "../assets/service-disney.svg"
|
||||||
import itunesLogoUrl from "../assets/service-itunes.png"
|
import itunesLogoUrl from "../assets/service-itunes.png"
|
||||||
|
import hdr10plusLogoUrl from "../assets/hdr10plus-logo.png"
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
@@ -116,6 +127,9 @@ const props = withDefaults(
|
|||||||
playLabel?: string
|
playLabel?: string
|
||||||
title?: string
|
title?: string
|
||||||
variant?: "default" | "menu"
|
variant?: "default" | "menu"
|
||||||
|
/** When true, the card itself is not interactive (no tabindex, no click/keyboard handlers).
|
||||||
|
* Use with showActions to make only the inline buttons interactive. */
|
||||||
|
inertCard?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
best: false,
|
best: false,
|
||||||
@@ -126,6 +140,7 @@ const props = withDefaults(
|
|||||||
playLabel: "Play",
|
playLabel: "Play",
|
||||||
title: undefined,
|
title: undefined,
|
||||||
variant: "default",
|
variant: "default",
|
||||||
|
inertCard: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -317,6 +332,23 @@ const showHdrBadge = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
return hdr10PlusPattern.test(text)
|
||||||
|
})
|
||||||
|
|
||||||
|
const showHdr10PlusLogo = computed(() => hasHdr10Plus.value && !hasDolbyVision.value)
|
||||||
|
|
||||||
const isSelectable = computed(() => {
|
const isSelectable = computed(() => {
|
||||||
if (props.selectable !== undefined) return props.selectable
|
if (props.selectable !== undefined) return props.selectable
|
||||||
return Boolean(props.torrent.playable_file)
|
return Boolean(props.torrent.playable_file)
|
||||||
@@ -335,7 +367,7 @@ const resolvedTitle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleActivate(event: MouseEvent | KeyboardEvent) {
|
function handleActivate(event: MouseEvent | KeyboardEvent) {
|
||||||
if (!isSelectable.value || isDisabled.value) return
|
if (props.inertCard || !isSelectable.value || isDisabled.value) return
|
||||||
emit("activate", event)
|
emit("activate", event)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -390,6 +422,15 @@ html.mouse-active .version-row.version-best:hover {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-row.version-inert {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-row.version-inert .version-main,
|
||||||
|
.version-row.version-inert .version-dolby-cell {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.version-row.version-disabled {
|
.version-row.version-disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
@@ -500,6 +541,15 @@ html.mouse-active .version-row.version-best:hover {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-hdr10plus-logo {
|
||||||
|
align-self: stretch;
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.v-badge.res {
|
.v-badge.res {
|
||||||
background: #111111;
|
background: #111111;
|
||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
@@ -579,31 +629,32 @@ html.mouse-active .version-row.version-best:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ctx-btn {
|
.ctx-btn {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
border: none;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: transparent;
|
||||||
color: #fff;
|
color: rgba(255, 255, 255, 0.65);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 6px 10px;
|
padding: 4px 8px;
|
||||||
font-size: 0.78rem;
|
font-size: 2em;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.mouse-active .ctx-btn:hover:not(:disabled),
|
html.mouse-active .ctx-btn:hover:not(:disabled),
|
||||||
html:not(.mouse-active) .ctx-btn.nav-focused:not(:disabled),
|
html:not(.mouse-active) .ctx-btn.nav-focused:not(:disabled),
|
||||||
.ctx-btn:focus-visible:not(:disabled) {
|
.ctx-btn:focus-visible:not(:disabled) {
|
||||||
background: rgba(255, 255, 255, 0.16);
|
color: #fff;
|
||||||
border-color: rgba(255, 255, 255, 0.35);
|
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctx-btn:disabled {
|
.ctx-btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.35;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctx-btn-folder {
|
.ctx-btn-folder {
|
||||||
width: 34px;
|
width: auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 6px 0;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -110,7 +110,9 @@ function getDigitalRepeatIntervalMs(holdMs: number): number {
|
|||||||
|
|
||||||
function getAnalogRepeatIntervalMs(intensity: number): number {
|
function getAnalogRepeatIntervalMs(intensity: number): number {
|
||||||
const normalized = Math.min(Math.max(intensity, 0), 1)
|
const normalized = Math.min(Math.max(intensity, 0), 1)
|
||||||
return Math.round(ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized)
|
return Math.round(
|
||||||
|
ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAxisIntensity(rawValue: number): number {
|
function normalizeAxisIntensity(rawValue: number): number {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { onUnmounted, ref, type Ref } from "vue"
|
||||||
|
|
||||||
|
export const PREVIEW_IDLE_MS = 30_000
|
||||||
|
|
||||||
|
const ACTIVITY_EVENTS = ["mousemove", "mousedown", "wheel", "keydown", "touchstart"] as const
|
||||||
|
const GAMEPAD_ACTIVITY_EVENT = "mediahive:gamepad-action"
|
||||||
|
|
||||||
|
interface IdlePreviewPlaybackOptions {
|
||||||
|
idleMs?: number
|
||||||
|
onStop: () => void
|
||||||
|
onRestart: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stops preview videos after a period without user input and restarts them
|
||||||
|
// (via the component's staggered startup) when activity resumes. Also stops
|
||||||
|
// previews while the tab is hidden. Activity listeners run in the capture
|
||||||
|
// phase so the stopped flag clears before hover/focus handlers react.
|
||||||
|
export function useIdlePreviewPlayback(options: IdlePreviewPlaybackOptions): {
|
||||||
|
stopped: Ref<boolean>
|
||||||
|
} {
|
||||||
|
const idleMs = options.idleMs ?? PREVIEW_IDLE_MS
|
||||||
|
const stopped = ref(false)
|
||||||
|
let idleTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function clearIdleTimer() {
|
||||||
|
if (idleTimer !== null) {
|
||||||
|
clearTimeout(idleTimer)
|
||||||
|
idleTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
clearIdleTimer()
|
||||||
|
if (stopped.value) return
|
||||||
|
stopped.value = true
|
||||||
|
options.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActivity() {
|
||||||
|
if (document.hidden) return
|
||||||
|
if (stopped.value) {
|
||||||
|
stopped.value = false
|
||||||
|
options.onRestart()
|
||||||
|
}
|
||||||
|
clearIdleTimer()
|
||||||
|
idleTimer = setTimeout(stop, idleMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.hidden) {
|
||||||
|
stop()
|
||||||
|
} else {
|
||||||
|
handleActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const eventName of ACTIVITY_EVENTS) {
|
||||||
|
window.addEventListener(eventName, handleActivity, { passive: true, capture: true })
|
||||||
|
}
|
||||||
|
window.addEventListener(GAMEPAD_ACTIVITY_EVENT, handleActivity, { passive: true, capture: true })
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||||
|
idleTimer = setTimeout(stop, idleMs)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
for (const eventName of ACTIVITY_EVENTS) {
|
||||||
|
window.removeEventListener(eventName, handleActivity, { capture: true })
|
||||||
|
}
|
||||||
|
window.removeEventListener(GAMEPAD_ACTIVITY_EVENT, handleActivity, { capture: true })
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||||
|
clearIdleTimer()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { stopped }
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { reportUserActivity } from "../api"
|
||||||
|
|
||||||
type InputModality = "mouse" | "keyboard" | "gamepad"
|
type InputModality = "mouse" | "keyboard" | "gamepad"
|
||||||
|
|
||||||
const MOUSE_IDLE_MS = 1400
|
const MOUSE_IDLE_MS = 1400
|
||||||
const MOUSE_INTENT_DISTANCE_PX = 28
|
const MOUSE_INTENT_DISTANCE_PX = 28
|
||||||
const MOUSE_INTENT_WINDOW_MS = 700
|
const MOUSE_INTENT_WINDOW_MS = 700
|
||||||
|
const MOUSE_OVER_INTENT_RECENCY_MS = 500
|
||||||
const MOUSE_INTENT_SELECTOR = [
|
const MOUSE_INTENT_SELECTOR = [
|
||||||
"[data-nav-focusable]",
|
"[data-nav-focusable]",
|
||||||
"button",
|
"button",
|
||||||
@@ -25,6 +28,7 @@ let mouseIdleTimer: number | null = null
|
|||||||
let pointerVisible = false
|
let pointerVisible = false
|
||||||
let mouseTravelPx = 0
|
let mouseTravelPx = 0
|
||||||
let lastMouseMoveAt = 0
|
let lastMouseMoveAt = 0
|
||||||
|
let lastAnyMouseMoveAt = 0
|
||||||
|
|
||||||
function clearMouseIdleTimer() {
|
function clearMouseIdleTimer() {
|
||||||
if (mouseIdleTimer !== null) {
|
if (mouseIdleTimer !== null) {
|
||||||
@@ -90,7 +94,9 @@ function registerMouseIntentTravel(event: MouseEvent): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseMove(event: MouseEvent) {
|
function handleMouseMove(event: MouseEvent) {
|
||||||
|
reportUserActivity()
|
||||||
showPointerFromMotion()
|
showPointerFromMotion()
|
||||||
|
lastAnyMouseMoveAt = performance.now()
|
||||||
|
|
||||||
if (modality === "mouse") {
|
if (modality === "mouse") {
|
||||||
applyInputState(true)
|
applyInputState(true)
|
||||||
@@ -107,11 +113,19 @@ function handleMouseOver(event: MouseEvent) {
|
|||||||
if (modality === "mouse") return
|
if (modality === "mouse") return
|
||||||
if (!isMouseIntentTarget(event.target)) return
|
if (!isMouseIntentTarget(event.target)) return
|
||||||
|
|
||||||
|
// Browsers fire mouseover/mouseenter when scrolling or re-rendering moves
|
||||||
|
// content under a stationary cursor (e.g. sideways season browsing while
|
||||||
|
// the pointer happens to rest over the episode grid). Without recent real
|
||||||
|
// pointer motion this is not mouse intent — activating mouse input here
|
||||||
|
// would let hover handlers steal keyboard/gamepad focus.
|
||||||
|
if (performance.now() - lastAnyMouseMoveAt > MOUSE_OVER_INTENT_RECENCY_MS) return
|
||||||
|
|
||||||
// Entering an interactive target indicates likely mouse intent.
|
// Entering an interactive target indicates likely mouse intent.
|
||||||
activateMouseInput()
|
activateMouseInput()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseIntentAction(event: MouseEvent | WheelEvent) {
|
function handleMouseIntentAction(event: MouseEvent | WheelEvent) {
|
||||||
|
reportUserActivity()
|
||||||
pointerVisible = true
|
pointerVisible = true
|
||||||
if (isMouseIntentTarget(event.target)) {
|
if (isMouseIntentTarget(event.target)) {
|
||||||
activateMouseInput()
|
activateMouseInput()
|
||||||
@@ -124,6 +138,7 @@ function handleMouseIntentAction(event: MouseEvent | WheelEvent) {
|
|||||||
|
|
||||||
function handleKeyboardActivity(event: KeyboardEvent) {
|
function handleKeyboardActivity(event: KeyboardEvent) {
|
||||||
if (event.metaKey || event.ctrlKey || event.altKey) return
|
if (event.metaKey || event.ctrlKey || event.altKey) return
|
||||||
|
reportUserActivity()
|
||||||
activateNonMouseInput("keyboard")
|
activateNonMouseInput("keyboard")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,18 @@ const activeNavigationScope = ref<string | null>(null)
|
|||||||
const desiredCol = ref<number | null>(null)
|
const desiredCol = ref<number | null>(null)
|
||||||
// Track if global handlers are installed
|
// Track if global handlers are installed
|
||||||
let handlersInstalled = false
|
let handlersInstalled = false
|
||||||
|
// Track open modal count — when > 0, global keyboard navigation is suspended
|
||||||
|
let modalOpenCount = 0
|
||||||
|
|
||||||
// Data attribute names
|
// Data attribute names
|
||||||
const FOCUSABLE_ATTR = "data-nav-focusable"
|
const FOCUSABLE_ATTR = "data-nav-focusable"
|
||||||
const ROW_ATTR = "data-nav-row"
|
const ROW_ATTR = "data-nav-row"
|
||||||
const COL_ATTR = "data-nav-col"
|
const COL_ATTR = "data-nav-col"
|
||||||
const ENTRY_COL_ATTR = "data-nav-entry-col"
|
const ENTRY_COL_ATTR = "data-nav-entry-col"
|
||||||
|
// Like ENTRY_COL_ATTR, but only applies when entering the row from above
|
||||||
|
// (ArrowDown). Lets a row declare a fixed landing column for downward entry
|
||||||
|
// without hijacking upward or horizontal moves.
|
||||||
|
const ENTRY_COL_FROM_ABOVE_ATTR = "data-nav-entry-col-from-above"
|
||||||
const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row"
|
const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row"
|
||||||
const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group"
|
const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group"
|
||||||
const DEFAULT_SYNC_SCROLL_GROUP = "browse"
|
const DEFAULT_SYNC_SCROLL_GROUP = "browse"
|
||||||
@@ -54,6 +60,7 @@ let syncedRowsCurrentOffset = 0
|
|||||||
let syncedRowsTargetOffset = 0
|
let syncedRowsTargetOffset = 0
|
||||||
let lastSyncedAnchorCol: number | null = null
|
let lastSyncedAnchorCol: number | null = null
|
||||||
let lastSyncedRowsAnimationAt: number | null = null
|
let lastSyncedRowsAnimationAt: number | null = null
|
||||||
|
let activeSyncedScrollGroup = DEFAULT_SYNC_SCROLL_GROUP
|
||||||
|
|
||||||
// Global metrics measured once from the first synced row. All calculations use
|
// Global metrics measured once from the first synced row. All calculations use
|
||||||
// these same values for every row to avoid per-row DOM query inconsistencies.
|
// these same values for every row to avoid per-row DOM query inconsistencies.
|
||||||
@@ -96,7 +103,10 @@ function measureGlobalMetrics(group: string): boolean {
|
|||||||
const rowStyle = window.getComputedStyle(row)
|
const rowStyle = window.getComputedStyle(row)
|
||||||
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
|
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
|
||||||
const viewportWidth = row.clientWidth
|
const viewportWidth = row.clientWidth
|
||||||
const deadzoneInset = Math.max(paddingLeft, (viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO)
|
const deadzoneInset = Math.max(
|
||||||
|
paddingLeft,
|
||||||
|
(viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO,
|
||||||
|
)
|
||||||
const leftDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_LEFT_DEADZONE_VAR).trim()
|
const leftDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_LEFT_DEADZONE_VAR).trim()
|
||||||
const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw))
|
const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw))
|
||||||
? Math.max(0, parseFloat(leftDeadzoneRaw))
|
? Math.max(0, parseFloat(leftDeadzoneRaw))
|
||||||
@@ -131,6 +141,13 @@ function getMetrics(group: string): ScrollMetrics | null {
|
|||||||
// the first synced row is already scrolled to. This avoids animating from 0
|
// the first synced row is already scrolled to. This avoids animating from 0
|
||||||
// every time the view is entered.
|
// every time the view is entered.
|
||||||
function initCurrentOffsetFromDOM(group: string) {
|
function initCurrentOffsetFromDOM(group: string) {
|
||||||
|
if (activeSyncedScrollGroup !== group) {
|
||||||
|
stopSyncedRowAnimation()
|
||||||
|
activeSyncedScrollGroup = group
|
||||||
|
syncedRowsCurrentOffset = 0
|
||||||
|
syncedRowsTargetOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
if (syncedRowsCurrentOffset !== 0) return
|
if (syncedRowsCurrentOffset !== 0) return
|
||||||
const rows = getSyncRowsByGroup(group)
|
const rows = getSyncRowsByGroup(group)
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -161,10 +178,7 @@ function getRowMaxScroll(row: HTMLElement): number {
|
|||||||
if (n === 0) return 0
|
if (n === 0) return 0
|
||||||
const lastCol = n - 1
|
const lastCol = n - 1
|
||||||
const lastItemLeft = m.paddingLeft + lastCol * m.stride
|
const lastItemLeft = m.paddingLeft + lastCol * m.stride
|
||||||
const maxVisibleLeft = Math.max(
|
const maxVisibleLeft = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
|
||||||
m.paddingLeft,
|
|
||||||
m.viewportWidth - m.cardWidth - m.rightDeadzone,
|
|
||||||
)
|
|
||||||
return Math.max(0, lastItemLeft - maxVisibleLeft)
|
return Math.max(0, lastItemLeft - maxVisibleLeft)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,13 +186,19 @@ function clampRowScrollOffset(row: HTMLElement, offset: number): number {
|
|||||||
return Math.min(Math.max(offset, 0), getRowMaxScroll(row))
|
return Math.min(Math.max(offset, 0), getRowMaxScroll(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySyncedRowScroll(offset: number, rows: HTMLElement[] = getSyncedRows()) {
|
function applySyncedRowScroll(
|
||||||
|
offset: number,
|
||||||
|
rows: HTMLElement[] = getSyncRowsByGroup(activeSyncedScrollGroup),
|
||||||
|
) {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
row.scrollLeft = clampRowScrollOffset(row, offset)
|
row.scrollLeft = clampRowScrollOffset(row, offset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAllRowTails(tailPx: number, rows: HTMLElement[] = getSyncedRows()) {
|
function setAllRowTails(
|
||||||
|
tailPx: number,
|
||||||
|
rows: HTMLElement[] = getSyncRowsByGroup(activeSyncedScrollGroup),
|
||||||
|
) {
|
||||||
const value = `${Math.max(0, tailPx)}px`
|
const value = `${Math.max(0, tailPx)}px`
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
row.style.setProperty(SYNC_SCROLL_TAIL_VAR, value)
|
row.style.setProperty(SYNC_SCROLL_TAIL_VAR, value)
|
||||||
@@ -224,7 +244,7 @@ function stopSyncedRowAnimation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function animateSyncedRows(now: number) {
|
function animateSyncedRows(now: number) {
|
||||||
const rows = getSyncedRows()
|
const rows = getSyncRowsByGroup(activeSyncedScrollGroup)
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
stopSyncedRowAnimation()
|
stopSyncedRowAnimation()
|
||||||
return
|
return
|
||||||
@@ -260,14 +280,8 @@ function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null
|
|||||||
if (!m) return
|
if (!m) return
|
||||||
|
|
||||||
const itemLeft = m.paddingLeft + anchorCol * m.stride
|
const itemLeft = m.paddingLeft + anchorCol * m.stride
|
||||||
const leftVisibleLimit = Math.max(
|
const leftVisibleLimit = Math.max(m.paddingLeft, m.leftDeadzone)
|
||||||
m.paddingLeft,
|
const rightVisibleLimit = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
|
||||||
m.leftDeadzone,
|
|
||||||
)
|
|
||||||
const rightVisibleLimit = Math.max(
|
|
||||||
m.paddingLeft,
|
|
||||||
m.viewportWidth - m.cardWidth - m.rightDeadzone,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Keep focus inside the deadzone: no scroll while the focused item remains
|
// Keep focus inside the deadzone: no scroll while the focused item remains
|
||||||
// between left and right limits.
|
// between left and right limits.
|
||||||
@@ -334,7 +348,9 @@ function getLocalSyncedRowCol(
|
|||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
requestedCol: number,
|
requestedCol: number,
|
||||||
): number {
|
): number {
|
||||||
const cards = Array.from(anchorRow.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`))
|
const cards = Array.from(
|
||||||
|
anchorRow.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`),
|
||||||
|
)
|
||||||
if (cards.length === 0) return Math.max(0, requestedCol)
|
if (cards.length === 0) return Math.max(0, requestedCol)
|
||||||
|
|
||||||
const cardCols = cards
|
const cardCols = cards
|
||||||
@@ -383,7 +399,7 @@ function handleSyncedRowResize() {
|
|||||||
resetSyncedRows(true)
|
resetSyncedRows(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updateSyncedRowTarget(lastSyncedAnchorCol)
|
updateSyncedRowTarget(lastSyncedAnchorCol, activeRow || null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureElementVisibleVertically(element: HTMLElement) {
|
function ensureElementVisibleVertically(element: HTMLElement) {
|
||||||
@@ -446,9 +462,7 @@ function ensureElementVisibleVertically(element: HTMLElement) {
|
|||||||
// Element finding / navigation (unchanged logic, uses getMetrics() now)
|
// Element finding / navigation (unchanged logic, uses getMetrics() now)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function resolveOutOfBoundsNavigation(
|
function resolveOutOfBoundsNavigation(context: OutOfBoundsNavigationContext): HTMLElement | null {
|
||||||
context: OutOfBoundsNavigationContext,
|
|
||||||
): HTMLElement | null {
|
|
||||||
const handlers = Array.from(outOfBoundsHandlers)
|
const handlers = Array.from(outOfBoundsHandlers)
|
||||||
for (let i = handlers.length - 1; i >= 0; i--) {
|
for (let i = handlers.length - 1; i >= 0; i--) {
|
||||||
const result = handlers[i]?.(context)
|
const result = handlers[i]?.(context)
|
||||||
@@ -603,10 +617,7 @@ function findElementClosestToLogicalViewportX(
|
|||||||
return nearest
|
return nearest
|
||||||
}
|
}
|
||||||
|
|
||||||
function findNextElement(
|
function findNextElement(current: HTMLElement, direction: NavDirection): HTMLElement | null {
|
||||||
current: HTMLElement,
|
|
||||||
direction: NavDirection,
|
|
||||||
): HTMLElement | null {
|
|
||||||
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10)
|
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10)
|
||||||
const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10)
|
const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10)
|
||||||
const byRow = getElementsByRow()
|
const byRow = getElementsByRow()
|
||||||
@@ -656,8 +667,21 @@ function findNextElement(
|
|||||||
desiredCol.value = currentCol
|
desiredCol.value = currentCol
|
||||||
}
|
}
|
||||||
|
|
||||||
const entryTarget = findElementAt(targetRow, targetCol, true)
|
|
||||||
const targetRowElements = byRow.get(targetRow) ?? []
|
const targetRowElements = byRow.get(targetRow) ?? []
|
||||||
|
|
||||||
|
if (direction === "down") {
|
||||||
|
for (const el of targetRowElements) {
|
||||||
|
const fromAboveCol = el.element.getAttribute(ENTRY_COL_FROM_ABOVE_ATTR)
|
||||||
|
if (fromAboveCol === null) continue
|
||||||
|
const fromAboveTarget = targetRowElements.find((e) => e.col === parseInt(fromAboveCol, 10))
|
||||||
|
if (fromAboveTarget) {
|
||||||
|
desiredCol.value = fromAboveTarget.col
|
||||||
|
return fromAboveTarget.element
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryTarget = findElementAt(targetRow, targetCol, true)
|
||||||
const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR))
|
const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR))
|
||||||
if (hasEntryOverride) {
|
if (hasEntryOverride) {
|
||||||
return entryTarget?.element || null
|
return entryTarget?.element || null
|
||||||
@@ -686,7 +710,7 @@ function findNextElement(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusElement(element: HTMLElement | null) {
|
function focusElement(element: HTMLElement | null, options?: { preserveScroll?: boolean }) {
|
||||||
if (!element) return
|
if (!element) return
|
||||||
if (!isElementInActiveScope(element)) return
|
if (!isElementInActiveScope(element)) return
|
||||||
|
|
||||||
@@ -698,8 +722,10 @@ function focusElement(element: HTMLElement | null) {
|
|||||||
element.classList.add("nav-focused")
|
element.classList.add("nav-focused")
|
||||||
element.focus({ preventScroll: true })
|
element.focus({ preventScroll: true })
|
||||||
|
|
||||||
|
if (!options?.preserveScroll) {
|
||||||
ensureElementVisibleVertically(element)
|
ensureElementVisibleVertically(element)
|
||||||
syncRowsToElement(element)
|
syncRowsToElement(element)
|
||||||
|
}
|
||||||
|
|
||||||
focusedElement.value = element
|
focusedElement.value = element
|
||||||
}
|
}
|
||||||
@@ -711,13 +737,16 @@ function getFocusState(): { row: number; col: number } | null {
|
|||||||
return { row, col }
|
return { row, col }
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreFocusState(state: { row: number; col: number } | null) {
|
function restoreFocusState(
|
||||||
|
state: { row: number; col: number } | null,
|
||||||
|
options?: { preserveScroll?: boolean },
|
||||||
|
) {
|
||||||
if (!state) return
|
if (!state) return
|
||||||
|
|
||||||
const target = findElementAt(state.row, state.col)
|
const target = findElementAt(state.row, state.col)
|
||||||
if (target) {
|
if (target) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
focusElement(target.element)
|
focusElement(target.element, options)
|
||||||
}, 50)
|
}, 50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -746,6 +775,8 @@ function shouldAllowNavigationFromInput(target: HTMLElement, direction: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (modalOpenCount > 0) return
|
||||||
|
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
|
|
||||||
const direction = {
|
const direction = {
|
||||||
@@ -795,6 +826,8 @@ function handleKeyDown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEnterKey(event: KeyboardEvent) {
|
function handleEnterKey(event: KeyboardEvent) {
|
||||||
|
if (modalOpenCount > 0) return
|
||||||
|
|
||||||
if (event.key !== "Enter") return
|
if (event.key !== "Enter") return
|
||||||
if (event.defaultPrevented) return
|
if (event.defaultPrevented) return
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||||
@@ -821,6 +854,14 @@ export function setActiveNavigationScope(scope: string | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspend global keyboard navigation while a modal/popup is open.
|
||||||
|
* Call with `true` when opening, `false` when closing. Supports nesting.
|
||||||
|
*/
|
||||||
|
export function setModalOpen(open: boolean) {
|
||||||
|
modalOpenCount = Math.max(0, modalOpenCount + (open ? 1 : -1))
|
||||||
|
}
|
||||||
|
|
||||||
export function installKeyboardNavigation() {
|
export function installKeyboardNavigation() {
|
||||||
if (handlersInstalled) return
|
if (handlersInstalled) return
|
||||||
handlersInstalled = true
|
handlersInstalled = true
|
||||||
@@ -865,6 +906,32 @@ export function installKeyboardNavigation() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SyncedRowScrollSnapshot {
|
||||||
|
rows: [HTMLElement, number][]
|
||||||
|
offset: number
|
||||||
|
targetOffset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotSyncedRowScroll(): SyncedRowScrollSnapshot {
|
||||||
|
return {
|
||||||
|
rows: getSyncedRows().map((row) => [row, row.scrollLeft]),
|
||||||
|
offset: syncedRowsCurrentOffset,
|
||||||
|
targetOffset: syncedRowsTargetOffset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSyncedRowScroll(snapshot: SyncedRowScrollSnapshot) {
|
||||||
|
stopSyncedRowAnimation()
|
||||||
|
for (const [row, scrollLeft] of snapshot.rows) {
|
||||||
|
if (row.isConnected) {
|
||||||
|
row.scrollLeft = scrollLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncedRowsCurrentOffset = snapshot.offset
|
||||||
|
syncedRowsTargetOffset = snapshot.targetOffset
|
||||||
|
lastSyncedRowsAnimationAt = null
|
||||||
|
}
|
||||||
|
|
||||||
export function useKeyboardNavigation() {
|
export function useKeyboardNavigation() {
|
||||||
return {
|
return {
|
||||||
focusedElement,
|
focusedElement,
|
||||||
@@ -873,6 +940,8 @@ export function useKeyboardNavigation() {
|
|||||||
focusAt,
|
focusAt,
|
||||||
getFocusState,
|
getFocusState,
|
||||||
restoreFocusState,
|
restoreFocusState,
|
||||||
|
snapshotSyncedRowScroll,
|
||||||
|
restoreSyncedRowScroll,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,31 +13,24 @@ import type {
|
|||||||
MediaIndex,
|
MediaIndex,
|
||||||
TaskInfo,
|
TaskInfo,
|
||||||
WsMessage,
|
WsMessage,
|
||||||
|
WsRootStatus,
|
||||||
} from "../types"
|
} from "../types"
|
||||||
|
|
||||||
interface RootState {
|
interface RootState {
|
||||||
rootId: string
|
|
||||||
ws: WebSocket | null
|
|
||||||
movieMap: Map<string, MovieUi>
|
movieMap: Map<string, MovieUi>
|
||||||
seriesMap: Map<string, SeriesUi>
|
seriesMap: Map<string, SeriesUi>
|
||||||
peopleMap: Map<number, Person>
|
peopleMap: Map<number, Person>
|
||||||
connected: boolean
|
|
||||||
initialized: boolean
|
initialized: boolean
|
||||||
pendingMessages: WsMessage[]
|
pendingMessages: WsMessage[]
|
||||||
reconnectTimer: ReturnType<typeof setTimeout> | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RootStatusEntry extends WsRootStatus {}
|
||||||
|
|
||||||
const MERGED_KEY_DELIMITER = "::"
|
const MERGED_KEY_DELIMITER = "::"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable that connects to per-root MediaHive WebSockets and keeps
|
* Composable that connects to one all-roots MediaHive WebSocket and keeps
|
||||||
* a merged media index updated in real time.
|
* a merged media index updated in real time.
|
||||||
*
|
|
||||||
* The server sends per-root:
|
|
||||||
* - "init" → full index (movies + series) on connect
|
|
||||||
* - "upsert" → single item inserted or updated
|
|
||||||
* - "remove" → single item removed
|
|
||||||
* - "task" → background task progress
|
|
||||||
*/
|
*/
|
||||||
export function useMediaWebSocket() {
|
export function useMediaWebSocket() {
|
||||||
type RootTaskInfo = TaskInfo & { root_id: string }
|
type RootTaskInfo = TaskInfo & { root_id: string }
|
||||||
@@ -47,8 +40,11 @@ export function useMediaWebSocket() {
|
|||||||
const error = shallowRef<string | null>(null)
|
const error = shallowRef<string | null>(null)
|
||||||
const connected = shallowRef(false)
|
const connected = shallowRef(false)
|
||||||
const tasks = shallowRef<Map<string, RootTaskInfo>>(new Map())
|
const tasks = shallowRef<Map<string, RootTaskInfo>>(new Map())
|
||||||
|
const roots = shallowRef<Map<string, RootStatusEntry>>(new Map())
|
||||||
|
|
||||||
const roots = shallowRef<Map<string, RootState>>(new Map())
|
const rootStates = shallowRef<Map<string, RootState>>(new Map())
|
||||||
|
const wsRef = shallowRef<WebSocket | null>(null)
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let disposed = false
|
let disposed = false
|
||||||
|
|
||||||
// Single periodic sweep for completed tasks instead of one timeout per task
|
// Single periodic sweep for completed tasks instead of one timeout per task
|
||||||
@@ -163,10 +159,7 @@ export function useMediaWebSocket() {
|
|||||||
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
|
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCastMember(
|
function normalizeCastMember(member: unknown, people: Map<number, Person>): CastMember {
|
||||||
member: unknown,
|
|
||||||
people: Map<number, Person>,
|
|
||||||
): CastMember {
|
|
||||||
if (!Array.isArray(member)) {
|
if (!Array.isArray(member)) {
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -190,16 +183,6 @@ export function useMediaWebSocket() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSimilarMember(member: unknown): { id: number; title: string } {
|
|
||||||
if (!Array.isArray(member)) {
|
|
||||||
return { id: 0, title: "" }
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: typeof member[0] === "number" ? member[0] : 0,
|
|
||||||
title: typeof member[1] === "string" ? member[1] : "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePerson(member: unknown): Person | null {
|
function normalizePerson(member: unknown): Person | null {
|
||||||
if (!Array.isArray(member)) return null
|
if (!Array.isArray(member)) return null
|
||||||
const gender = normalizeCastGender(member[2])
|
const gender = normalizeCastGender(member[2])
|
||||||
@@ -223,7 +206,7 @@ export function useMediaWebSocket() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeInfo<T extends { cast?: unknown; similar?: unknown }>(
|
function normalizeInfo<T extends { cast?: unknown }>(
|
||||||
info: T | null,
|
info: T | null,
|
||||||
people: Map<number, Person>,
|
people: Map<number, Person>,
|
||||||
): T | null {
|
): T | null {
|
||||||
@@ -235,10 +218,6 @@ export function useMediaWebSocket() {
|
|||||||
.filter((member) => member.name.length > 0)
|
.filter((member) => member.name.length > 0)
|
||||||
next = { ...next, cast } as T
|
next = { ...next, cast } as T
|
||||||
}
|
}
|
||||||
if (Array.isArray((info as { similar?: unknown }).similar)) {
|
|
||||||
const similar = ((info as { similar?: unknown[] }).similar || []).map(normalizeSimilarMember)
|
|
||||||
next = { ...next, similar } as T
|
|
||||||
}
|
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +343,10 @@ export function useMediaWebSocket() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeItemsByHash<T extends MovieUi | SeriesUi>(items: T[], mergeFn: (a: T, b: T) => T): T[] {
|
function mergeItemsByHash<T extends MovieUi | SeriesUi>(
|
||||||
|
items: T[],
|
||||||
|
mergeFn: (a: T, b: T) => T,
|
||||||
|
): T[] {
|
||||||
const map = new Map<string, T[]>()
|
const map = new Map<string, T[]>()
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const hash = getContentHash(item.id)
|
const hash = getContentHash(item.id)
|
||||||
@@ -387,10 +369,41 @@ export function useMediaWebSocket() {
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureRootState(rootId: string): RootState {
|
||||||
|
const existing = rootStates.value.get(rootId)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
const created: RootState = {
|
||||||
|
movieMap: new Map(),
|
||||||
|
seriesMap: new Map(),
|
||||||
|
peopleMap: new Map(),
|
||||||
|
initialized: false,
|
||||||
|
pendingMessages: [],
|
||||||
|
}
|
||||||
|
rootStates.value.set(rootId, created)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneMissingRoots(nextRoots: Map<string, RootStatusEntry>) {
|
||||||
|
for (const rootId of rootStates.value.keys()) {
|
||||||
|
if (!nextRoots.has(rootId)) {
|
||||||
|
rootStates.value.delete(rootId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [taskKey, task] of tasks.value.entries()) {
|
||||||
|
if (!nextRoots.has(task.root_id)) {
|
||||||
|
tasks.value.delete(taskKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tasks.value = new Map(tasks.value)
|
||||||
|
}
|
||||||
|
|
||||||
function buildIndex(): MediaIndex {
|
function buildIndex(): MediaIndex {
|
||||||
const movies: MovieUi[] = []
|
const movies: MovieUi[] = []
|
||||||
const series: SeriesUi[] = []
|
const series: SeriesUi[] = []
|
||||||
for (const state of roots.value.values()) {
|
for (const state of rootStates.value.values()) {
|
||||||
movies.push(...state.movieMap.values())
|
movies.push(...state.movieMap.values())
|
||||||
series.push(...state.seriesMap.values())
|
series.push(...state.seriesMap.values())
|
||||||
}
|
}
|
||||||
@@ -406,35 +419,35 @@ export function useMediaWebSocket() {
|
|||||||
|
|
||||||
function updateMergedState() {
|
function updateMergedState() {
|
||||||
mediaIndex.value = buildIndex()
|
mediaIndex.value = buildIndex()
|
||||||
// Consider a root "connected" only after init is received.
|
|
||||||
let anyInitialized = false
|
let anyInitialized = false
|
||||||
for (const state of roots.value.values()) {
|
for (const state of rootStates.value.values()) {
|
||||||
if (state.connected && state.initialized) {
|
if (state.initialized) {
|
||||||
anyInitialized = true
|
anyInitialized = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (anyInitialized) {
|
|
||||||
|
if (anyInitialized || roots.value.size === 0) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
error.value = null
|
error.value = null
|
||||||
}
|
}
|
||||||
connected.value = anyInitialized
|
|
||||||
|
connected.value = wsRef.value?.readyState === WebSocket.OPEN
|
||||||
}
|
}
|
||||||
|
|
||||||
function processJson(state: RootState, text: string) {
|
function applyRootInit(
|
||||||
const msg = JSON.parse(text) as WsMessage
|
rootId: string,
|
||||||
|
rootData: {
|
||||||
|
movies: Record<string, Movie>
|
||||||
|
series: Record<string, Series>
|
||||||
|
people?: Record<string, unknown>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const state = ensureRootState(rootId)
|
||||||
|
|
||||||
// Prevent out-of-order corruption: buffer delta messages until we receive
|
|
||||||
// the initial full-state payload.
|
|
||||||
if (msg.type !== "init" && !state.initialized) {
|
|
||||||
state.pendingMessages.push(msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (msg.type) {
|
|
||||||
case "init": {
|
|
||||||
state.peopleMap.clear()
|
state.peopleMap.clear()
|
||||||
for (const [id, person] of Object.entries(msg.data.people || {})) {
|
for (const [id, person] of Object.entries(rootData.people || {})) {
|
||||||
const parsed = Number(id)
|
const parsed = Number(id)
|
||||||
const normalized = normalizePerson(person)
|
const normalized = normalizePerson(person)
|
||||||
if (Number.isFinite(parsed)) {
|
if (Number.isFinite(parsed)) {
|
||||||
@@ -444,36 +457,62 @@ export function useMediaWebSocket() {
|
|||||||
|
|
||||||
state.movieMap.clear()
|
state.movieMap.clear()
|
||||||
state.seriesMap.clear()
|
state.seriesMap.clear()
|
||||||
for (const [id, m] of Object.entries(msg.data.movies || {})) {
|
for (const [id, m] of Object.entries(rootData.movies || {})) {
|
||||||
state.movieMap.set(id, withMovieIdentity(id, m, state.rootId, state.peopleMap))
|
state.movieMap.set(id, withMovieIdentity(id, m, rootId, state.peopleMap))
|
||||||
}
|
}
|
||||||
for (const [id, s] of Object.entries(msg.data.series || {})) {
|
for (const [id, s] of Object.entries(rootData.series || {})) {
|
||||||
state.seriesMap.set(id, withSeriesIdentity(id, s, state.rootId, state.peopleMap))
|
state.seriesMap.set(id, withSeriesIdentity(id, s, rootId, state.peopleMap))
|
||||||
}
|
}
|
||||||
|
|
||||||
state.initialized = true
|
state.initialized = true
|
||||||
|
|
||||||
// Replay any deltas that arrived before init completed.
|
|
||||||
if (state.pendingMessages.length > 0) {
|
if (state.pendingMessages.length > 0) {
|
||||||
const queued = state.pendingMessages
|
const queued = state.pendingMessages
|
||||||
state.pendingMessages = []
|
state.pendingMessages = []
|
||||||
for (const queuedMsg of queued) {
|
for (const queuedMsg of queued) {
|
||||||
processJson(state, JSON.stringify(queuedMsg))
|
processMessage(queuedMsg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMergedState()
|
function processMessage(msg: WsMessage) {
|
||||||
console.log(
|
switch (msg.type) {
|
||||||
`[WS ${state.rootId}] init: ${state.movieMap.size} movies, ${state.seriesMap.size} series`,
|
case "roots": {
|
||||||
)
|
const next = new Map<string, RootStatusEntry>()
|
||||||
break
|
for (const root of msg.roots || []) {
|
||||||
|
next.set(root.root_id, { ...root })
|
||||||
|
ensureRootState(root.root_id)
|
||||||
}
|
}
|
||||||
|
roots.value = next
|
||||||
|
pruneMissingRoots(next)
|
||||||
|
updateMergedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case "init": {
|
||||||
|
for (const [rootId, rootData] of Object.entries(msg.roots || {})) {
|
||||||
|
applyRootInit(rootId, rootData)
|
||||||
|
}
|
||||||
|
updateMergedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
case "upsert": {
|
case "upsert": {
|
||||||
|
const state = ensureRootState(msg.root_id)
|
||||||
|
if (!state.initialized) {
|
||||||
|
state.pendingMessages.push(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.people) {
|
if (msg.people) {
|
||||||
for (const [id, person] of Object.entries(msg.people)) {
|
for (const [id, person] of Object.entries(msg.people)) {
|
||||||
const parsed = Number(id)
|
const parsed = Number(id)
|
||||||
const normalized = normalizePerson(person)
|
const normalized = normalizePerson(person)
|
||||||
if (Number.isFinite(parsed)) {
|
if (Number.isFinite(parsed)) {
|
||||||
state.peopleMap.set(parsed, normalized || { name: "", profile_path: null, gender: null })
|
state.peopleMap.set(
|
||||||
|
parsed,
|
||||||
|
normalized || { name: "", profile_path: null, gender: null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -481,160 +520,109 @@ export function useMediaWebSocket() {
|
|||||||
if (msg.kind === "movie") {
|
if (msg.kind === "movie") {
|
||||||
state.movieMap.set(
|
state.movieMap.set(
|
||||||
msg.id,
|
msg.id,
|
||||||
withMovieIdentity(msg.id, msg.item as Movie, state.rootId, state.peopleMap),
|
withMovieIdentity(msg.id, msg.item as Movie, msg.root_id, state.peopleMap),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
state.seriesMap.set(
|
state.seriesMap.set(
|
||||||
msg.id,
|
msg.id,
|
||||||
withSeriesIdentity(msg.id, msg.item as Series, state.rootId, state.peopleMap),
|
withSeriesIdentity(msg.id, msg.item as Series, msg.root_id, state.peopleMap),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
updateMergedState()
|
updateMergedState()
|
||||||
break
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case "remove": {
|
case "remove": {
|
||||||
|
const state = ensureRootState(msg.root_id)
|
||||||
|
if (!state.initialized) {
|
||||||
|
state.pendingMessages.push(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.kind === "movie") {
|
if (msg.kind === "movie") {
|
||||||
state.movieMap.delete(msg.id)
|
state.movieMap.delete(msg.id)
|
||||||
} else {
|
} else {
|
||||||
state.seriesMap.delete(msg.id)
|
state.seriesMap.delete(msg.id)
|
||||||
}
|
}
|
||||||
updateMergedState()
|
updateMergedState()
|
||||||
break
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case "task": {
|
case "task": {
|
||||||
const info = msg.data
|
const info = msg.data
|
||||||
const taskKey = `${state.rootId}:${info.id}`
|
const taskKey = `${msg.root_id}:${info.id}`
|
||||||
tasks.value.set(taskKey, { ...info, root_id: state.rootId })
|
tasks.value.set(taskKey, { ...info, root_id: msg.root_id })
|
||||||
tasks.value = new Map(tasks.value)
|
tasks.value = new Map(tasks.value)
|
||||||
if (info.status === "completed" || info.status === "cancelled" || info.status === "error") {
|
if (info.status === "completed" || info.status === "cancelled" || info.status === "error") {
|
||||||
completedTaskIds.add(taskKey)
|
completedTaskIds.add(taskKey)
|
||||||
startTaskSweep()
|
startTaskSweep()
|
||||||
}
|
}
|
||||||
break
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(state: RootState, event: MessageEvent) {
|
function handleRawMessage(event: MessageEvent) {
|
||||||
|
const processText = (text: string) => {
|
||||||
try {
|
try {
|
||||||
let text: string
|
processMessage(JSON.parse(text) as WsMessage)
|
||||||
if (event.data instanceof Blob) {
|
|
||||||
event.data.text().then((t) => processJson(state, t))
|
|
||||||
return
|
|
||||||
} else if (event.data instanceof ArrayBuffer) {
|
|
||||||
text = new TextDecoder().decode(event.data)
|
|
||||||
} else {
|
|
||||||
text = event.data as string
|
|
||||||
}
|
|
||||||
processJson(state, text)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[WS ${state.rootId}] Failed to handle message:`, e)
|
console.error("[WS] Failed to handle message:", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectRoot(rootId: string) {
|
if (event.data instanceof Blob) {
|
||||||
if (disposed) return
|
void event.data.text().then(processText)
|
||||||
const existing = roots.value.get(rootId)
|
|
||||||
if (existing?.ws) {
|
|
||||||
// Already connecting or connected
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:"
|
processText(new TextDecoder().decode(event.data))
|
||||||
const url = `${proto}//${location.host}/api/ws/${encodeURIComponent(rootId)}`
|
return
|
||||||
|
|
||||||
const state: RootState = {
|
|
||||||
rootId,
|
|
||||||
ws: null,
|
|
||||||
movieMap: new Map(),
|
|
||||||
seriesMap: new Map(),
|
|
||||||
peopleMap: new Map(),
|
|
||||||
connected: false,
|
|
||||||
initialized: false,
|
|
||||||
pendingMessages: [],
|
|
||||||
reconnectTimer: null,
|
|
||||||
}
|
|
||||||
roots.value.set(rootId, state)
|
|
||||||
|
|
||||||
function doConnect() {
|
|
||||||
if (disposed) return
|
|
||||||
console.log(`[WS ${rootId}] Connecting to ${url}...`)
|
|
||||||
const ws = new WebSocket(url)
|
|
||||||
state.ws = ws
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
state.connected = true
|
|
||||||
state.initialized = false
|
|
||||||
state.pendingMessages = []
|
|
||||||
updateMergedState()
|
|
||||||
console.log(`[WS ${rootId}] Connected`)
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onmessage = (ev) => handleMessage(state, ev)
|
|
||||||
|
|
||||||
ws.onclose = (ev) => {
|
|
||||||
state.connected = false
|
|
||||||
state.initialized = false
|
|
||||||
state.pendingMessages = []
|
|
||||||
state.ws = null
|
|
||||||
updateMergedState()
|
|
||||||
console.log(`[WS ${rootId}] Closed (code=${ev.code})`)
|
|
||||||
scheduleReconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onerror = (ev) => {
|
|
||||||
console.error(`[WS ${rootId}] Error:`, ev)
|
|
||||||
if (!mediaIndex.value) {
|
|
||||||
error.value = "WebSocket connection failed"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
processText(event.data as string)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleReconnect() {
|
function scheduleReconnect() {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
if (state.reconnectTimer) clearTimeout(state.reconnectTimer)
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||||
state.reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
console.log(`[WS ${rootId}] Reconnecting...`)
|
reconnectTimer = null
|
||||||
doConnect()
|
connect()
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
doConnect()
|
function connect() {
|
||||||
}
|
|
||||||
|
|
||||||
function disconnectRoot(rootId: string) {
|
|
||||||
const state = roots.value.get(rootId)
|
|
||||||
if (!state) return
|
|
||||||
if (state.reconnectTimer) {
|
|
||||||
clearTimeout(state.reconnectTimer)
|
|
||||||
state.reconnectTimer = null
|
|
||||||
}
|
|
||||||
if (state.ws) {
|
|
||||||
state.ws.onclose = null
|
|
||||||
state.ws.close()
|
|
||||||
state.ws = null
|
|
||||||
}
|
|
||||||
state.connected = false
|
|
||||||
roots.value.delete(rootId)
|
|
||||||
updateMergedState()
|
|
||||||
}
|
|
||||||
|
|
||||||
function setActiveRoots(rootIds: string[]) {
|
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
const desired = new Set(rootIds)
|
if (wsRef.value && wsRef.value.readyState <= WebSocket.OPEN) return
|
||||||
const current = new Set(roots.value.keys())
|
|
||||||
|
|
||||||
// Add new roots
|
const proto = location.protocol === "https:" ? "wss:" : "ws:"
|
||||||
for (const rid of desired) {
|
const url = `${proto}//${location.host}/api/ws`
|
||||||
if (!current.has(rid)) {
|
|
||||||
connectRoot(rid)
|
console.log(`[WS] Connecting to ${url}...`)
|
||||||
}
|
const ws = new WebSocket(url)
|
||||||
|
wsRef.value = ws
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
connected.value = true
|
||||||
|
error.value = null
|
||||||
|
console.log("[WS] Connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove old roots
|
ws.onmessage = (ev) => handleRawMessage(ev)
|
||||||
for (const rid of current) {
|
|
||||||
if (!desired.has(rid)) {
|
ws.onclose = (ev) => {
|
||||||
disconnectRoot(rid)
|
if (wsRef.value === ws) {
|
||||||
|
wsRef.value = null
|
||||||
|
}
|
||||||
|
connected.value = false
|
||||||
|
console.log(`[WS] Closed (code=${ev.code})`)
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = (ev) => {
|
||||||
|
console.error("[WS] Error:", ev)
|
||||||
|
if (!mediaIndex.value) {
|
||||||
|
error.value = "WebSocket connection failed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -642,18 +630,24 @@ export function useMediaWebSocket() {
|
|||||||
function disconnect() {
|
function disconnect() {
|
||||||
disposed = true
|
disposed = true
|
||||||
stopTaskSweep()
|
stopTaskSweep()
|
||||||
for (const state of roots.value.values()) {
|
|
||||||
if (state.reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
clearTimeout(state.reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
}
|
reconnectTimer = null
|
||||||
if (state.ws) {
|
|
||||||
state.ws.onclose = null
|
|
||||||
state.ws.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
roots.value.clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (wsRef.value) {
|
||||||
|
wsRef.value.onclose = null
|
||||||
|
wsRef.value.close()
|
||||||
|
wsRef.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
connected.value = false
|
||||||
|
roots.value.clear()
|
||||||
|
rootStates.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
onUnmounted(disconnect)
|
onUnmounted(disconnect)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -662,7 +656,7 @@ export function useMediaWebSocket() {
|
|||||||
error: readonly(error),
|
error: readonly(error),
|
||||||
connected: readonly(connected),
|
connected: readonly(connected),
|
||||||
tasks: readonly(tasks),
|
tasks: readonly(tasks),
|
||||||
setActiveRoots,
|
roots: readonly(roots),
|
||||||
disconnect,
|
disconnect,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { TaskInfo } from "../types"
|
||||||
|
|
||||||
|
export type RootTaskInfo = TaskInfo & { root_id: string }
|
||||||
|
|
||||||
|
export interface ProgressRootState {
|
||||||
|
rootId: string
|
||||||
|
rootLabel: string
|
||||||
|
scanTarget: string | null
|
||||||
|
phaseLabel: string
|
||||||
|
phaseDetail: string | null
|
||||||
|
progressPercent: number
|
||||||
|
progressLabel: string | null
|
||||||
|
isDeterminate: boolean
|
||||||
|
toneClass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePosixPath(value: string): string {
|
||||||
|
return value.replace(/\\/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractScanPath(detail: string): string | null {
|
||||||
|
if (!detail.startsWith("Scanning:")) return null
|
||||||
|
let value = detail.replace(/^Scanning:\s*/i, "").trim()
|
||||||
|
value = value.replace(/\s*\(\d+\s+found\)\s*$/i, "").trim()
|
||||||
|
return value || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScanTarget(rootPath: string | null, detail: string): string | null {
|
||||||
|
const rawPath = extractScanPath(detail)
|
||||||
|
if (!rawPath) return null
|
||||||
|
|
||||||
|
const posixRaw = normalizePosixPath(rawPath)
|
||||||
|
const posixRoot = rootPath ? normalizePosixPath(rootPath) : null
|
||||||
|
|
||||||
|
if (posixRoot) {
|
||||||
|
const lowRaw = posixRaw.toLowerCase()
|
||||||
|
const lowRoot = posixRoot.toLowerCase()
|
||||||
|
if (lowRaw === lowRoot) return posixRoot
|
||||||
|
if (lowRaw.startsWith(`${lowRoot}/`)) {
|
||||||
|
return posixRaw.slice(posixRoot.length).replace(/^\/+/, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return posixRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeRootProgress(
|
||||||
|
rootId: string,
|
||||||
|
rootPath: string | null,
|
||||||
|
tasksForRoot: RootTaskInfo[],
|
||||||
|
isInitialScanMode: boolean,
|
||||||
|
): ProgressRootState | null {
|
||||||
|
const running = tasksForRoot.filter((task) => task.status === "running")
|
||||||
|
const latestError = [...tasksForRoot].reverse().find((task) => task.status === "error") || null
|
||||||
|
|
||||||
|
if (running.length === 0 && !latestError) return null
|
||||||
|
|
||||||
|
const scanTask = running.find((task) => task.id.startsWith("scan-")) || null
|
||||||
|
const showreelCount = running.filter((task) => task.id.startsWith("showreel-")).length
|
||||||
|
const otherRunningCount = running.length - (scanTask ? 1 : 0) - showreelCount
|
||||||
|
|
||||||
|
let phaseLabel = "Processing media"
|
||||||
|
let phaseDetail: string | null = null
|
||||||
|
let scanTarget: string | null = null
|
||||||
|
let isDeterminate = false
|
||||||
|
let progressPercent = 0
|
||||||
|
let progressLabel: string | null = null
|
||||||
|
let toneClass = ""
|
||||||
|
|
||||||
|
if (scanTask) {
|
||||||
|
const detail = (scanTask.detail || "").trim()
|
||||||
|
scanTarget = buildScanTarget(rootPath, detail)
|
||||||
|
if (detail.startsWith("Scanning:")) {
|
||||||
|
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
|
||||||
|
} else if (/^Processing\s+\d+\s+(items|movies|series)/i.test(detail)) {
|
||||||
|
phaseLabel = "Preparing titles"
|
||||||
|
} else if (/^(Starting scan|No new items|Done|Scan cancelled)/i.test(detail)) {
|
||||||
|
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
|
||||||
|
} else {
|
||||||
|
phaseLabel = "Fetching metadata"
|
||||||
|
phaseDetail = detail || null
|
||||||
|
}
|
||||||
|
if (scanTask.progress > 0 && scanTask.progress <= 1) {
|
||||||
|
isDeterminate = true
|
||||||
|
progressPercent = Math.max(1, Math.round(scanTask.progress * 100))
|
||||||
|
progressLabel = `${progressPercent}%`
|
||||||
|
}
|
||||||
|
} else if (showreelCount > 0) {
|
||||||
|
phaseLabel = "Generating previews"
|
||||||
|
} else if (otherRunningCount > 0) {
|
||||||
|
phaseLabel = "Finalizing updates"
|
||||||
|
} else if (latestError) {
|
||||||
|
phaseLabel = "Needs attention"
|
||||||
|
phaseDetail = latestError.detail || "A background task failed"
|
||||||
|
toneClass = "activity-root-error"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootId,
|
||||||
|
rootLabel: rootId,
|
||||||
|
scanTarget,
|
||||||
|
phaseLabel,
|
||||||
|
phaseDetail,
|
||||||
|
progressPercent,
|
||||||
|
progressLabel,
|
||||||
|
isDeterminate,
|
||||||
|
toneClass,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeProgressRoots(
|
||||||
|
tasks: Iterable<RootTaskInfo>,
|
||||||
|
getRootPath: (rootId: string) => string | null,
|
||||||
|
isInitialScanMode: boolean,
|
||||||
|
): ProgressRootState[] {
|
||||||
|
const byRoot = new Map<string, RootTaskInfo[]>()
|
||||||
|
for (const task of tasks) {
|
||||||
|
const list = byRoot.get(task.root_id) || []
|
||||||
|
list.push(task)
|
||||||
|
byRoot.set(task.root_id, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: ProgressRootState[] = []
|
||||||
|
for (const [rootId, rootTasks] of byRoot) {
|
||||||
|
const row = describeRootProgress(rootId, getRootPath(rootId), rootTasks, isInitialScanMode)
|
||||||
|
if (row) rows.push(row)
|
||||||
|
}
|
||||||
|
return rows.sort((a, b) => a.rootLabel.localeCompare(b.rootLabel))
|
||||||
|
}
|
||||||
@@ -17,9 +17,9 @@ const STORAGE_KEY = "MediaHive"
|
|||||||
const RESOLUTION_PRIORITY: Record<string, number> = {
|
const RESOLUTION_PRIORITY: Record<string, number> = {
|
||||||
"8K": 5,
|
"8K": 5,
|
||||||
"4K": 4,
|
"4K": 4,
|
||||||
"FHD": 3,
|
FHD: 3,
|
||||||
"HD": 2,
|
HD: 2,
|
||||||
"SD": 1,
|
SD: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max resolution priority allowed for each preference level
|
// Max resolution priority allowed for each preference level
|
||||||
@@ -59,7 +59,13 @@ function loadSettings(): MediaHiveSettings {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
return { preferredResolution: "rmax", preferredHdr: "none", playerId: "default", playerCustomCmd: null, playerMpcPort: null }
|
return {
|
||||||
|
preferredResolution: "rmax",
|
||||||
|
preferredHdr: "none",
|
||||||
|
playerId: "default",
|
||||||
|
playerCustomCmd: null,
|
||||||
|
playerMpcPort: null,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = reactive<MediaHiveSettings>(loadSettings())
|
const settings = reactive<MediaHiveSettings>(loadSettings())
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
// Settings is an overlay, not a route: opening it must not change the URL or
|
||||||
|
// the view behind it, so the open state is plain shared local state.
|
||||||
|
const settingsOpen = ref(false)
|
||||||
|
|
||||||
|
export function useSettingsOpen() {
|
||||||
|
return settingsOpen
|
||||||
|
}
|
||||||
@@ -6,10 +6,60 @@ import { installKeyboardNavigation } from "./composables/useKeyboardNavigation"
|
|||||||
import { installGamepadNavigation } from "./composables/useGamepadNavigation"
|
import { installGamepadNavigation } from "./composables/useGamepadNavigation"
|
||||||
import { installInputModalityTracking } from "./composables/useInputModality"
|
import { installInputModalityTracking } from "./composables/useInputModality"
|
||||||
|
|
||||||
|
function postClientError(payload: {
|
||||||
|
message: string
|
||||||
|
stack: string | null
|
||||||
|
source: string | null
|
||||||
|
}) {
|
||||||
|
fetch("/api/client-log", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function installErrorCapture() {
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
const source =
|
||||||
|
event.filename != null ? `${event.filename}:${event.lineno ?? 0}:${event.colno ?? 0}` : null
|
||||||
|
postClientError({
|
||||||
|
message: event.message || String(event.error ?? "Unknown error"),
|
||||||
|
stack: event.error?.stack ?? null,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
const reason = event.reason
|
||||||
|
postClientError({
|
||||||
|
message: reason instanceof Error ? reason.message : `Unhandled rejection: ${String(reason)}`,
|
||||||
|
stack: reason instanceof Error ? (reason.stack ?? null) : null,
|
||||||
|
source: "unhandledrejection",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function installReloadShortcut() {
|
||||||
|
document.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
(event) => {
|
||||||
|
if (
|
||||||
|
event.key === "F5" ||
|
||||||
|
((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "r")
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Install global keyboard navigation handlers immediately
|
// Install global keyboard navigation handlers immediately
|
||||||
installInputModalityTracking()
|
installInputModalityTracking()
|
||||||
installKeyboardNavigation()
|
installKeyboardNavigation()
|
||||||
installGamepadNavigation()
|
installGamepadNavigation()
|
||||||
|
installReloadShortcut()
|
||||||
|
installErrorCapture()
|
||||||
|
|
||||||
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
|
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ const router = createRouter({
|
|||||||
component: EmptyRouteComponent,
|
component: EmptyRouteComponent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Settings is now an overlay with no URL of its own; keep old links working.
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
name: "settings",
|
redirect: "/movies",
|
||||||
component: EmptyRouteComponent,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/series",
|
path: "/series",
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
// Search Web Worker - runs search off the main thread
|
// Search Web Worker - runs search off the main thread
|
||||||
// This file is loaded as a Web Worker, not imported as a module.
|
// This file is loaded as a Web Worker, not imported as a module.
|
||||||
|
|
||||||
import type {
|
import type { MovieUi, SeriesUi, MatchedPerson, MatchedEpisode, SearchMatchInfo } from "./types"
|
||||||
MovieUi,
|
|
||||||
SeriesUi,
|
|
||||||
MatchedPerson,
|
|
||||||
MatchedEpisode,
|
|
||||||
SearchMatchInfo,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Message types
|
// Message types
|
||||||
@@ -65,7 +59,7 @@ let series: SeriesUi[] = []
|
|||||||
function normalizeSearchText(value: string): string {
|
function normalizeSearchText(value: string): string {
|
||||||
return value
|
return value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9\-]+/g, " ")
|
.replace(/[^a-z0-9-]+/g, " ")
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
}
|
}
|
||||||
@@ -277,7 +271,9 @@ function getMatchedWordIndexes(queryWords: string[], value: string): number[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void {
|
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void {
|
||||||
const existing = target.find((person) => person.name.toLowerCase() === candidate.name.toLowerCase())
|
const existing = target.find(
|
||||||
|
(person) => person.name.toLowerCase() === candidate.name.toLowerCase(),
|
||||||
|
)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
|
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
|
||||||
if (candidate.highlightRoles) existing.highlightRoles = true
|
if (candidate.highlightRoles) existing.highlightRoles = true
|
||||||
@@ -292,9 +288,7 @@ function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
|
function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
|
||||||
const sorted = indexes
|
const sorted = indexes.filter((index) => availableIndexes.has(index)).sort((a, b) => a - b)
|
||||||
.filter((index) => availableIndexes.has(index))
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
|
|
||||||
if (sorted.length === 0) return []
|
if (sorted.length === 0) return []
|
||||||
|
|
||||||
@@ -621,7 +615,7 @@ async function performSearch(
|
|||||||
movie.info?.keywords?.join(" "),
|
movie.info?.keywords?.join(" "),
|
||||||
movie.info?.overview,
|
movie.info?.overview,
|
||||||
movie.info?.tagline,
|
movie.info?.tagline,
|
||||||
movie.info?.similar?.map((s) => s.title).join(" "),
|
movie.info?.collection,
|
||||||
),
|
),
|
||||||
getMoviePathScore(movie, query),
|
getMoviePathScore(movie, query),
|
||||||
)
|
)
|
||||||
@@ -719,7 +713,6 @@ async function performSearch(
|
|||||||
seriesItem.info?.keywords?.join(" "),
|
seriesItem.info?.keywords?.join(" "),
|
||||||
seriesItem.info?.overview,
|
seriesItem.info?.overview,
|
||||||
seriesItem.info?.tagline,
|
seriesItem.info?.tagline,
|
||||||
seriesItem.info?.similar?.map((s) => s.title).join(" "),
|
|
||||||
seriesItem.info?.networks?.join(" "),
|
seriesItem.info?.networks?.join(" "),
|
||||||
),
|
),
|
||||||
getSeriesPathScore(seriesItem, query),
|
getSeriesPathScore(seriesItem, query),
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ html:not(.pointer-visible) * {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scrollbar-hidden {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hidden::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -95,6 +106,11 @@ html.mouse-active ::-webkit-scrollbar-thumb:hover {
|
|||||||
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Leave room for the fixed app-exit button in desktop (pywebview) mode. */
|
||||||
|
.header--gui {
|
||||||
|
padding-right: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.header::before {
|
.header::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -409,6 +425,10 @@ html:not(.mouse-active) .btn-secondary.nav-focused {
|
|||||||
outline: none;
|
outline: none;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
/* Skip rendering work for cards scrolled out of view (long rows). Width is
|
||||||
|
fixed; the intrinsic height is only a pre-first-render estimate. */
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-height: auto 330px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.mouse-active .media-card:hover,
|
html.mouse-active .media-card:hover,
|
||||||
@@ -525,6 +545,14 @@ html.mouse-active .media-card:hover .media-card-info {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
|
|||||||
+40
-11
@@ -19,15 +19,11 @@ export interface Person {
|
|||||||
gender?: CastGender | null
|
gender?: CastGender | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimilarMedia {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Info {
|
export interface Info {
|
||||||
tmdb_id: number
|
tmdb_id: number
|
||||||
title: string | null
|
title: string | null
|
||||||
original_title: string | null
|
original_title: string | null
|
||||||
|
original_language: string | null
|
||||||
alternative_titles: string[] | null
|
alternative_titles: string[] | null
|
||||||
rating: number | null
|
rating: number | null
|
||||||
vote_count: number | null
|
vote_count: number | null
|
||||||
@@ -35,9 +31,9 @@ export interface Info {
|
|||||||
genres: string[] | null
|
genres: string[] | null
|
||||||
release_date: string | null
|
release_date: string | null
|
||||||
runtime: number | null
|
runtime: number | null
|
||||||
|
collection: string | null
|
||||||
status: string | null
|
status: string | null
|
||||||
tagline: string | null
|
tagline: string | null
|
||||||
similar: SimilarMedia[] | null
|
|
||||||
keywords: string[] | null
|
keywords: string[] | null
|
||||||
cast: CastMember[] | null
|
cast: CastMember[] | null
|
||||||
director: string | null
|
director: string | null
|
||||||
@@ -57,6 +53,7 @@ export interface Torrent {
|
|||||||
audio: string | null
|
audio: string | null
|
||||||
audio_languages: string[] | null
|
audio_languages: string[] | null
|
||||||
subtitle_languages: string[] | null
|
subtitle_languages: string[] | null
|
||||||
|
external_subtitle_languages?: string[] | null
|
||||||
hdr?: boolean
|
hdr?: boolean
|
||||||
dovi?: boolean
|
dovi?: boolean
|
||||||
atmos?: boolean
|
atmos?: boolean
|
||||||
@@ -113,6 +110,13 @@ export interface Series {
|
|||||||
seasons: Season[]
|
seasons: Season[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A series' single continue point (last watched position). */
|
||||||
|
export interface SeriesResumePoint {
|
||||||
|
seasonNumber: number
|
||||||
|
episodeNumber: number
|
||||||
|
positionSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface MovieUi extends Movie {
|
export interface MovieUi extends Movie {
|
||||||
id: string
|
id: string
|
||||||
root_id: string | null
|
root_id: string | null
|
||||||
@@ -193,17 +197,35 @@ export interface TaskInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket message types (matching server msgspec tagged structs)
|
// WebSocket message types (matching server msgspec tagged structs)
|
||||||
export interface WsInitMessage {
|
export interface WsRootStatus {
|
||||||
type: "init"
|
root_id: string
|
||||||
data: {
|
path: string
|
||||||
|
status: string
|
||||||
|
error: string | null
|
||||||
|
snapshot_loaded: boolean
|
||||||
|
movies: number
|
||||||
|
series: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsRootInitData {
|
||||||
movies: Record<string, Movie>
|
movies: Record<string, Movie>
|
||||||
series: Record<string, Series>
|
series: Record<string, Series>
|
||||||
people?: Record<string, PersonWire>
|
people?: Record<string, PersonWire>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WsRootsMessage {
|
||||||
|
type: "roots"
|
||||||
|
roots: WsRootStatus[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsInitMessage {
|
||||||
|
type: "init"
|
||||||
|
roots: Record<string, WsRootInitData>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WsUpsertMessage {
|
export interface WsUpsertMessage {
|
||||||
type: "upsert"
|
type: "upsert"
|
||||||
|
root_id: string
|
||||||
kind: "movie" | "series"
|
kind: "movie" | "series"
|
||||||
id: string
|
id: string
|
||||||
item: Movie | Series
|
item: Movie | Series
|
||||||
@@ -212,13 +234,20 @@ export interface WsUpsertMessage {
|
|||||||
|
|
||||||
export interface WsRemoveMessage {
|
export interface WsRemoveMessage {
|
||||||
type: "remove"
|
type: "remove"
|
||||||
|
root_id: string
|
||||||
kind: "movie" | "series"
|
kind: "movie" | "series"
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WsTaskMessage {
|
export interface WsTaskMessage {
|
||||||
type: "task"
|
type: "task"
|
||||||
|
root_id: string
|
||||||
data: TaskInfo
|
data: TaskInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WsMessage = WsInitMessage | WsUpsertMessage | WsRemoveMessage | WsTaskMessage
|
export type WsMessage =
|
||||||
|
| WsRootsMessage
|
||||||
|
| WsInitMessage
|
||||||
|
| WsUpsertMessage
|
||||||
|
| WsRemoveMessage
|
||||||
|
| WsTaskMessage
|
||||||
|
|||||||
@@ -16,17 +16,18 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
|
|||||||
// Spanish (including LATAM variants collapsed to Spain flag)
|
// Spanish (including LATAM variants collapsed to Spain flag)
|
||||||
es: "ES",
|
es: "ES",
|
||||||
spa: "ES",
|
spa: "ES",
|
||||||
|
esp: "ES",
|
||||||
esl: "ES",
|
esl: "ES",
|
||||||
spl: "ES",
|
spl: "ES",
|
||||||
"es-es": "ES",
|
"es-es": "ES",
|
||||||
"es-419": "ES",
|
"es-419": "ES",
|
||||||
"spa-la": "ES",
|
"spa-la": "ES",
|
||||||
|
|
||||||
// Portuguese
|
// Portuguese (Brazilian variant collapses to Portugal flag)
|
||||||
pt: "PT",
|
pt: "PT",
|
||||||
por: "PT",
|
por: "PT",
|
||||||
"pt-pt": "PT",
|
"pt-pt": "PT",
|
||||||
"pt-br": "BR",
|
"pt-br": "PT",
|
||||||
|
|
||||||
// Major European languages
|
// Major European languages
|
||||||
fr: "FR",
|
fr: "FR",
|
||||||
@@ -49,7 +50,7 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
|
|||||||
fi: "FI",
|
fi: "FI",
|
||||||
fin: "FI",
|
fin: "FI",
|
||||||
pl: "PL",
|
pl: "PL",
|
||||||
पोल: "PL",
|
pol: "PL",
|
||||||
cs: "CZ",
|
cs: "CZ",
|
||||||
ces: "CZ",
|
ces: "CZ",
|
||||||
cze: "CZ",
|
cze: "CZ",
|
||||||
@@ -121,6 +122,113 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
|
|||||||
eu: "ES",
|
eu: "ES",
|
||||||
baq: "ES",
|
baq: "ES",
|
||||||
eus: "ES",
|
eus: "ES",
|
||||||
|
gl: "ES",
|
||||||
|
glg: "ES",
|
||||||
|
|
||||||
|
// Additional ISO 639-2 codes (bibliographic + terminology)
|
||||||
|
mk: "MK",
|
||||||
|
mkd: "MK",
|
||||||
|
mac: "MK",
|
||||||
|
et: "EE",
|
||||||
|
est: "EE",
|
||||||
|
lv: "LV",
|
||||||
|
lav: "LV",
|
||||||
|
lt: "LT",
|
||||||
|
lit: "LT",
|
||||||
|
is: "IS",
|
||||||
|
isl: "IS",
|
||||||
|
ice: "IS",
|
||||||
|
ga: "IE",
|
||||||
|
gle: "IE",
|
||||||
|
cy: "GB",
|
||||||
|
cym: "GB",
|
||||||
|
wel: "GB",
|
||||||
|
gd: "GB",
|
||||||
|
gla: "GB",
|
||||||
|
mt: "MT",
|
||||||
|
mlt: "MT",
|
||||||
|
sq: "AL",
|
||||||
|
sqi: "AL",
|
||||||
|
alb: "AL",
|
||||||
|
be: "BY",
|
||||||
|
bel: "BY",
|
||||||
|
bs: "BA",
|
||||||
|
bos: "BA",
|
||||||
|
scc: "RS",
|
||||||
|
scr: "HR",
|
||||||
|
nb: "NO",
|
||||||
|
nob: "NO",
|
||||||
|
nn: "NO",
|
||||||
|
nno: "NO",
|
||||||
|
kk: "KZ",
|
||||||
|
kaz: "KZ",
|
||||||
|
az: "AZ",
|
||||||
|
aze: "AZ",
|
||||||
|
hy: "AM",
|
||||||
|
hye: "AM",
|
||||||
|
arm: "AM",
|
||||||
|
ka: "GE",
|
||||||
|
kat: "GE",
|
||||||
|
geo: "GE",
|
||||||
|
uz: "UZ",
|
||||||
|
uzb: "UZ",
|
||||||
|
tk: "TM",
|
||||||
|
tuk: "TM",
|
||||||
|
tg: "TJ",
|
||||||
|
tgk: "TJ",
|
||||||
|
ky: "KG",
|
||||||
|
kir: "KG",
|
||||||
|
mn: "MN",
|
||||||
|
mon: "MN",
|
||||||
|
bo: "CN",
|
||||||
|
bod: "CN",
|
||||||
|
tib: "CN",
|
||||||
|
my: "MM",
|
||||||
|
mya: "MM",
|
||||||
|
bur: "MM",
|
||||||
|
km: "KH",
|
||||||
|
khm: "KH",
|
||||||
|
lo: "LA",
|
||||||
|
lao: "LA",
|
||||||
|
si: "LK",
|
||||||
|
sin: "LK",
|
||||||
|
ne: "NP",
|
||||||
|
nep: "NP",
|
||||||
|
bn: "BD",
|
||||||
|
ben: "BD",
|
||||||
|
ta: "IN",
|
||||||
|
tam: "IN",
|
||||||
|
te: "IN",
|
||||||
|
tel: "IN",
|
||||||
|
kn: "IN",
|
||||||
|
kan: "IN",
|
||||||
|
ml: "IN",
|
||||||
|
mal: "IN",
|
||||||
|
mr: "IN",
|
||||||
|
mar: "IN",
|
||||||
|
gu: "IN",
|
||||||
|
guj: "IN",
|
||||||
|
pa: "IN",
|
||||||
|
pan: "IN",
|
||||||
|
tl: "PH",
|
||||||
|
tgl: "PH",
|
||||||
|
fil: "PH",
|
||||||
|
af: "ZA",
|
||||||
|
afr: "ZA",
|
||||||
|
am: "ET",
|
||||||
|
amh: "ET",
|
||||||
|
so: "SO",
|
||||||
|
som: "SO",
|
||||||
|
ha: "NG",
|
||||||
|
hau: "NG",
|
||||||
|
yo: "NG",
|
||||||
|
yor: "NG",
|
||||||
|
ig: "NG",
|
||||||
|
ibo: "NG",
|
||||||
|
ku: "TR",
|
||||||
|
kur: "TR",
|
||||||
|
ps: "AF",
|
||||||
|
pus: "AF",
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLanguageCode(code: string): string {
|
function normalizeLanguageCode(code: string): string {
|
||||||
@@ -265,9 +373,13 @@ export function mapLanguageToCountry(code: string): string | null {
|
|||||||
const direct = LANGUAGE_TO_COUNTRY[normalized]
|
const direct = LANGUAGE_TO_COUNTRY[normalized]
|
||||||
if (direct) return direct
|
if (direct) return direct
|
||||||
|
|
||||||
// region-tag style code like en-us / pt-br / es-mx
|
// region-tag style code like en-us / pt-br / es-mx: variants collapse to
|
||||||
|
// the base language's host-country flag; only fall back to the region
|
||||||
|
// itself when the base language is unmapped.
|
||||||
const hyphenParts = normalized.split("-")
|
const hyphenParts = normalized.split("-")
|
||||||
if (hyphenParts.length >= 2) {
|
if (hyphenParts.length >= 2) {
|
||||||
|
const base = LANGUAGE_TO_COUNTRY[hyphenParts[0]]
|
||||||
|
if (base) return base
|
||||||
const region = hyphenParts[hyphenParts.length - 1]
|
const region = hyphenParts[hyphenParts.length - 1]
|
||||||
if (/^[a-z]{2}$/i.test(region)) {
|
if (/^[a-z]{2}$/i.test(region)) {
|
||||||
return region.toUpperCase()
|
return region.toUpperCase()
|
||||||
@@ -341,10 +453,15 @@ export function buildLanguageFlags(codes: string[] | null | undefined): {
|
|||||||
const LANGUAGE_NAME_OVERRIDES: Record<string, string> = {
|
const LANGUAGE_NAME_OVERRIDES: Record<string, string> = {
|
||||||
eng: "English",
|
eng: "English",
|
||||||
spa: "Spanish",
|
spa: "Spanish",
|
||||||
|
esp: "Spanish",
|
||||||
"spa-la": "Spanish",
|
"spa-la": "Spanish",
|
||||||
|
"es-419": "Spanish",
|
||||||
esl: "Spanish",
|
esl: "Spanish",
|
||||||
spl: "Spanish",
|
spl: "Spanish",
|
||||||
por: "Portuguese",
|
por: "Portuguese",
|
||||||
|
"pt-br": "Portuguese",
|
||||||
|
nob: "Norwegian",
|
||||||
|
nno: "Norwegian",
|
||||||
fre: "French",
|
fre: "French",
|
||||||
fra: "French",
|
fra: "French",
|
||||||
ger: "German",
|
ger: "German",
|
||||||
@@ -431,6 +548,52 @@ function summarizeLanguageCodes(codes: string[] | null | undefined): string {
|
|||||||
return names.join(", ")
|
return names.join(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REGION_NAME_OVERRIDES: Record<string, string> = {
|
||||||
|
GB: "UK",
|
||||||
|
US: "US",
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRegionName(countryCode: string): string {
|
||||||
|
const override = REGION_NAME_OVERRIDES[countryCode]
|
||||||
|
if (override) return override
|
||||||
|
const display = new Intl.DisplayNames(["en"], { type: "region" })
|
||||||
|
return display.of(countryCode) ?? countryCode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLanguageFlagTitle(
|
||||||
|
entry: LanguageFlagEntry,
|
||||||
|
externalCodes?: string[] | null,
|
||||||
|
): string {
|
||||||
|
const names: string[] = []
|
||||||
|
const variants: string[] = []
|
||||||
|
const external = new Set((externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)))
|
||||||
|
let hasExternal = false
|
||||||
|
for (const code of entry.sourceCodes) {
|
||||||
|
const normalized = resolveLanguageIdentifier(code)
|
||||||
|
const base = normalized.split("-", 1)[0]
|
||||||
|
const name = toLanguageName(base)
|
||||||
|
if (!names.includes(name)) names.push(name)
|
||||||
|
// Explicit region tags (en-us, es-419) become parenthesized variants;
|
||||||
|
// plain codes contribute their host country.
|
||||||
|
const suffix = normalized.split("-").pop() ?? ""
|
||||||
|
const region =
|
||||||
|
/^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
|
||||||
|
? suffix.toUpperCase()
|
||||||
|
: mapLanguageToCountry(code)
|
||||||
|
const regionName = region ? toRegionName(region) : null
|
||||||
|
const variant = external.has(normalized)
|
||||||
|
? regionName
|
||||||
|
? `${regionName} srt`
|
||||||
|
: "srt"
|
||||||
|
: regionName
|
||||||
|
if (variant && !variants.includes(variant)) variants.push(variant)
|
||||||
|
if (external.has(normalized)) hasExternal = true
|
||||||
|
}
|
||||||
|
const title = names.join(" / ")
|
||||||
|
if (variants.length > 1 || hasExternal) return `${title} (${variants.join(", ")})`
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAudioSubtitleSummary(
|
export function formatAudioSubtitleSummary(
|
||||||
audioCodes: string[] | null | undefined,
|
audioCodes: string[] | null | undefined,
|
||||||
subtitleCodes: string[] | null | undefined,
|
subtitleCodes: string[] | null | undefined,
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
* Configures Vite for FastAPI backend integration:
|
* Configures Vite for FastAPI backend integration:
|
||||||
* - Proxies /api/* requests to the FastAPI backend
|
* - Proxies /api/* requests to the FastAPI backend
|
||||||
* - Builds to the Python module's frontend-build directory
|
* - Builds to the Python module's frontend-build directory
|
||||||
|
* - Disables Vite's screen clearing on startup
|
||||||
*
|
*
|
||||||
* Options:
|
* Options:
|
||||||
* paths - Array of paths to proxy (default: ["/api"])
|
* paths - Array of paths to proxy (default: ['/api'])
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
export default function fastapiVue({ paths = ['/api'] } = {}) {
|
||||||
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8420"
|
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || 'http://localhost:8421'
|
||||||
|
|
||||||
// Build proxy configuration for each path
|
// Build proxy configuration for each path
|
||||||
const proxy = {}
|
const proxy = {}
|
||||||
@@ -24,11 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "vite-plugin-fastapi-mediahive",
|
name: 'vite-plugin-fastapi-mediahive',
|
||||||
config: () => ({
|
config: () => ({
|
||||||
|
clearScreen: false,
|
||||||
server: { proxy },
|
server: { proxy },
|
||||||
build: {
|
build: {
|
||||||
outDir: "../mediahive/frontend-build",
|
outDir: '../mediahive/frontend-build',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
pre-commit:
|
||||||
|
parallel: true
|
||||||
|
commands:
|
||||||
|
ruff-check:
|
||||||
|
glob: "*.py"
|
||||||
|
run: .venv/bin/ruff check --fix {staged_files}
|
||||||
|
stage_fixed: true
|
||||||
|
ruff-format:
|
||||||
|
glob: "*.py"
|
||||||
|
run: .venv/bin/ruff format {staged_files}
|
||||||
|
stage_fixed: true
|
||||||
|
oxlint:
|
||||||
|
glob: "frontend/src/**"
|
||||||
|
run: npm --prefix frontend run lint
|
||||||
|
stage_fixed: true
|
||||||
|
oxfmt:
|
||||||
|
glob: "frontend/src/**"
|
||||||
|
run: npm --prefix frontend run format
|
||||||
|
stage_fixed: true
|
||||||
+94
-7
@@ -1,16 +1,20 @@
|
|||||||
"""MediaHive CLI entrypoint."""
|
"""MediaHive CLI entrypoint."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Must be set before fastapi_vue env bindings are created (mediahive.config).
|
||||||
|
os.environ["FASTAPI_VUE"] = "MEDIAHIVE"
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi_vue import server
|
from fastapi_vue import env, server
|
||||||
|
|
||||||
|
from mediahive.config import config
|
||||||
|
|
||||||
DEFAULT_PORT = 8420
|
DEFAULT_PORT = 8420
|
||||||
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_windows_event_loop_policy() -> None:
|
def _configure_windows_event_loop_policy() -> None:
|
||||||
@@ -33,6 +37,54 @@ def _derive_name(path: str) -> str:
|
|||||||
return p.name or p.anchor.strip("/\\").lower() or "media"
|
return p.name or p.anchor.strip("/\\").lower() or "media"
|
||||||
|
|
||||||
|
|
||||||
|
def _dev_reload_supervisor() -> None:
|
||||||
|
"""Windows dev-mode reloader: restart the server process on changes.
|
||||||
|
|
||||||
|
uvicorn's own reload cannot work here: it restarts the child with
|
||||||
|
CTRL_C_EVENT, which is never delivered to a plain spawn child (no own
|
||||||
|
console process group), so the reloader blocks in join() after the
|
||||||
|
first reload and the old server — scanner included — keeps running.
|
||||||
|
And even when the child does restart, uvicorn passes it sockets bound
|
||||||
|
by the parent; ProactorEventLoop cannot register inherited sockets
|
||||||
|
with IOCP (WinError 87 on accept), while the selector loop would lose
|
||||||
|
asyncio subprocess support (ffmpeg/ffprobe showreel generation).
|
||||||
|
|
||||||
|
So: watch the package directory ourselves and respawn a fresh child
|
||||||
|
process that binds its own sockets. The child runs with
|
||||||
|
MEDIAHIVE_DEV_CHILD=1 and reload disabled. Scanner state is persisted
|
||||||
|
after every scan, so a non-graceful child exit on reload loses nothing.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import watchfiles
|
||||||
|
|
||||||
|
watch_dir = Path(__file__).parent
|
||||||
|
argv = [sys.executable, "-m", "mediahive", *sys.argv[1:]]
|
||||||
|
child_env = dict(os.environ, MEDIAHIVE_DEV_CHILD="1")
|
||||||
|
|
||||||
|
print(f"Dev reloader: watching {watch_dir}", file=sys.stderr)
|
||||||
|
proc = subprocess.Popen(argv, env=child_env)
|
||||||
|
try:
|
||||||
|
for changes in watchfiles.watch(watch_dir):
|
||||||
|
changed = sorted({str(Path(p).name) for _, p in changes})
|
||||||
|
print(
|
||||||
|
f"Dev reloader: change in {', '.join(changed[:5])} — restarting",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait()
|
||||||
|
proc = subprocess.Popen(argv, env=child_env)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
_configure_windows_event_loop_policy()
|
_configure_windows_event_loop_policy()
|
||||||
|
|
||||||
@@ -54,9 +106,30 @@ def main() -> None:
|
|||||||
action="append",
|
action="append",
|
||||||
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--gui",
|
||||||
|
action="store_true",
|
||||||
|
help="Run with GUI (fails if GUI dependencies are not installed)",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# --listen implies server-only mode; use --gui to force GUI even with --listen.
|
||||||
|
use_gui = args.gui or not args.listen
|
||||||
|
|
||||||
|
if use_gui:
|
||||||
|
try:
|
||||||
|
from mediahive.winmain import gui_main
|
||||||
|
except ImportError as exc:
|
||||||
|
if args.gui:
|
||||||
|
raise RuntimeError(
|
||||||
|
"GUI dependencies are not installed. "
|
||||||
|
"Install with: uv pip install mediahive[gui]"
|
||||||
|
) from exc
|
||||||
|
else:
|
||||||
|
gui_main()
|
||||||
|
return
|
||||||
|
|
||||||
if args.media_folders:
|
if args.media_folders:
|
||||||
roots: dict[str, str] = {}
|
roots: dict[str, str] = {}
|
||||||
for path in args.media_folders:
|
for path in args.media_folders:
|
||||||
@@ -71,15 +144,29 @@ def main() -> None:
|
|||||||
name = f"{base_name}{suffix}"
|
name = f"{base_name}{suffix}"
|
||||||
suffix += 1
|
suffix += 1
|
||||||
roots[name] = p.as_posix()
|
roots[name] = p.as_posix()
|
||||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots)
|
# Teleported to the server process by fastapi-vue's server.run().
|
||||||
|
config.roots = roots
|
||||||
|
|
||||||
|
if (
|
||||||
|
env.dev
|
||||||
|
and sys.platform == "win32"
|
||||||
|
and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1"
|
||||||
|
):
|
||||||
|
_dev_reload_supervisor()
|
||||||
|
return
|
||||||
|
|
||||||
dev = {"reload": True, "reload_dirs": ["mediahive"]}
|
|
||||||
server.run(
|
server.run(
|
||||||
"mediahive.server:app",
|
"mediahive.server:app",
|
||||||
listen=args.listen,
|
listen=args.listen,
|
||||||
default_port=DEFAULT_PORT,
|
default_port=DEFAULT_PORT,
|
||||||
|
server_header=False,
|
||||||
loop="none" if sys.platform == "win32" else "auto",
|
loop="none" if sys.platform == "win32" else "auto",
|
||||||
**(dev if DEVMODE and sys.platform != "win32" else {}),
|
reload=Path(__file__).parent if env.dev and sys.platform != "win32" else False,
|
||||||
|
# fastapi-vue routes the root logger at INFO in dev / WARNING in prod;
|
||||||
|
# keep our own loggers visible in production too.
|
||||||
|
log_config={
|
||||||
|
"loggers": {"mediahive": {"level": "DEBUG" if env.dev else "INFO"}}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Welcome to the MediaHive installer.
|
||||||
|
|
||||||
|
During installation and on first launch, macOS may ask you to allow
|
||||||
|
permissions (for example, access to your media folders or the local
|
||||||
|
network). Please allow these so MediaHive can find and play your media.
|
||||||
|
|
||||||
|
Click Continue to begin.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
+26
-29
@@ -1,57 +1,54 @@
|
|||||||
r"""Platform-appropriate config persistence for MediaHive.
|
r"""Platform-appropriate config persistence for MediaHive.
|
||||||
|
|
||||||
Config file location:
|
Locations (via platformdirs):
|
||||||
Windows: %APPDATA%\mediahive\config.toml
|
Config — Windows: %LOCALAPPDATA%\mediahive\config.toml
|
||||||
macOS: ~/Library/Application Support/mediahive/config.toml
|
macOS: ~/Library/Application Support/mediahive/config.toml
|
||||||
Linux: $XDG_CONFIG_HOME/mediahive/config.toml (~/.config/mediahive/config.toml)
|
Linux: $XDG_CONFIG_HOME/mediahive/config.toml
|
||||||
|
Logs — Windows: %LOCALAPPDATA%\mediahive\mediahive.log
|
||||||
|
macOS: ~/Library/Logs/mediahive/mediahive.log
|
||||||
|
Linux: $XDG_STATE_HOME/mediahive/mediahive.log
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
import msgspec.toml
|
import msgspec.toml
|
||||||
|
from fastapi_vue import env
|
||||||
|
from platformdirs import user_config_path, user_log_path
|
||||||
|
|
||||||
|
|
||||||
class Config(msgspec.Struct):
|
class Config(msgspec.Struct, omit_defaults=True):
|
||||||
media_folder: str | None = None
|
|
||||||
roots: dict[str, str] | None = None
|
roots: dict[str, str] | None = None
|
||||||
|
auto_update: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
# Runtime config shared between the CLI entrypoint and the server process via
|
||||||
|
# fastapi-vue's env teleport (MEDIAHIVE_CONFIG). Values set here take
|
||||||
|
# precedence over the persisted config file.
|
||||||
|
config = env(Config)
|
||||||
|
|
||||||
|
|
||||||
def config_dir() -> Path:
|
def config_dir() -> Path:
|
||||||
if sys.platform == "win32":
|
# appauthor=False: avoid the doubled %LOCALAPPDATA%\mediahive\mediahive.
|
||||||
base = Path(os.environ.get("APPDATA") or Path.home())
|
# roaming=False: config is machine-specific state, not something to sync
|
||||||
elif sys.platform == "darwin":
|
# across a domain profile.
|
||||||
base = Path.home() / "Library" / "Application Support"
|
return user_config_path("mediahive", appauthor=False, roaming=False)
|
||||||
else:
|
|
||||||
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
|
||||||
return base / "mediahive"
|
def log_dir() -> Path:
|
||||||
|
# opinion=False: no extra Logs/ subdir; mediahive.log sits beside config.
|
||||||
|
return user_log_path("mediahive", appauthor=False, opinion=False)
|
||||||
|
|
||||||
|
|
||||||
def config_path() -> Path:
|
def config_path() -> Path:
|
||||||
return config_dir() / "config.toml"
|
return config_dir() / "config.toml"
|
||||||
|
|
||||||
|
|
||||||
def _migrate_legacy_media_folder(cfg: Config) -> Config:
|
|
||||||
"""If roots is empty but media_folder exists, seed roots with it."""
|
|
||||||
if cfg.roots:
|
|
||||||
return cfg
|
|
||||||
if not cfg.media_folder:
|
|
||||||
return cfg
|
|
||||||
path = Path(cfg.media_folder)
|
|
||||||
name = path.name or path.anchor.strip("/\\").lower() or "media"
|
|
||||||
# Resolve collisions simply by using the basename; if user had weird layout
|
|
||||||
# they can rename via the UI later.
|
|
||||||
return msgspec.structs.replace(cfg, roots={name: cfg.media_folder})
|
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> Config:
|
def load_config() -> Config:
|
||||||
path = config_path()
|
path = config_path()
|
||||||
if path.exists():
|
if path.exists():
|
||||||
try:
|
try:
|
||||||
cfg = msgspec.toml.decode(path.read_bytes(), type=Config)
|
return msgspec.toml.decode(path.read_bytes(), type=Config)
|
||||||
return _migrate_legacy_media_folder(cfg)
|
|
||||||
except OSError, msgspec.DecodeError, msgspec.ValidationError:
|
except OSError, msgspec.DecodeError, msgspec.ValidationError:
|
||||||
return Config()
|
return Config()
|
||||||
return Config()
|
return Config()
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
"""Hivescan CLI entrypoint."""
|
"""Hivescan CLI entrypoint."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Must be set before fastapi_vue env bindings are created (mediahive.config).
|
||||||
|
os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE")
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mediahive.config import config
|
||||||
|
|
||||||
|
|
||||||
def _configure_windows_event_loop_policy() -> None:
|
def _configure_windows_event_loop_policy() -> None:
|
||||||
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
|
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
|
||||||
@@ -33,8 +38,8 @@ Examples:
|
|||||||
|
|
||||||
Exclude paths by creating .mediahive/scanignore (gitignore syntax).
|
Exclude paths by creating .mediahive/scanignore (gitignore syntax).
|
||||||
|
|
||||||
The server exposes per-root endpoints:
|
The server exposes a unified endpoint:
|
||||||
WS /api/ws/{root_id} Live index updates & task progress
|
WS /api/ws Live index updates, task progress, and root status changes
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -55,11 +60,9 @@ The server exposes per-root endpoints:
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Defer filesystem validation to the server; pass raw path via env.
|
# Defer filesystem validation to the server; pass raw path via env config.
|
||||||
media_root = Path(args.media_folder).expanduser()
|
media_root = Path(args.media_folder).expanduser()
|
||||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps({
|
config.roots = {media_root.name or "media": media_root.as_posix()}
|
||||||
media_root.name or "media": media_root.as_posix()
|
|
||||||
})
|
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|||||||
+135
-20
@@ -16,6 +16,7 @@ from mediahive.hivescan.models import ContentType, ParsedContent
|
|||||||
from mediahive.hivescan.scanning import (
|
from mediahive.hivescan.scanning import (
|
||||||
find_cover_image,
|
find_cover_image,
|
||||||
find_episode_files,
|
find_episode_files,
|
||||||
|
find_external_subtitle_languages,
|
||||||
find_metadata_probe_file,
|
find_metadata_probe_file,
|
||||||
find_playable_file,
|
find_playable_file,
|
||||||
)
|
)
|
||||||
@@ -61,6 +62,18 @@ def _infer_hdr10plus(*values: str | None) -> bool:
|
|||||||
return bool(_HDR10PLUS_RE.search(text))
|
return bool(_HDR10PLUS_RE.search(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_subtitle_languages(
|
||||||
|
probed: list[str] | None,
|
||||||
|
external: list[str],
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""Union embedded subtitle languages with sidecar-subtitle languages."""
|
||||||
|
merged = list(probed or [])
|
||||||
|
for lang in external:
|
||||||
|
if lang not in merged:
|
||||||
|
merged.append(lang)
|
||||||
|
return merged or None
|
||||||
|
|
||||||
|
|
||||||
def _compact_playable_file(file_key: str, playable_file: str | None) -> str | None:
|
def _compact_playable_file(file_key: str, playable_file: str | None) -> str | None:
|
||||||
"""Store playable paths compactly relative to the file key when possible."""
|
"""Store playable paths compactly relative to the file key when possible."""
|
||||||
if not playable_file:
|
if not playable_file:
|
||||||
@@ -99,6 +112,7 @@ async def _build_torrent_info(
|
|||||||
probe_target = await find_metadata_probe_file(playable_file)
|
probe_target = await find_metadata_probe_file(playable_file)
|
||||||
if probe_target:
|
if probe_target:
|
||||||
probe_info = await probe_media_info(str(probe_target))
|
probe_info = await probe_media_info(str(probe_target))
|
||||||
|
external_subs = await find_external_subtitle_languages(playable_file)
|
||||||
|
|
||||||
if item.content_hash and item.content_hash.size == 0:
|
if item.content_hash and item.content_hash.size == 0:
|
||||||
item.content_hash.size = await asyncio.to_thread(
|
item.content_hash.size = await asyncio.to_thread(
|
||||||
@@ -125,7 +139,11 @@ async def _build_torrent_info(
|
|||||||
codec=item.codec,
|
codec=item.codec,
|
||||||
audio=item.audio,
|
audio=item.audio,
|
||||||
audio_languages=probe_info.audio_languages if probe_info else None,
|
audio_languages=probe_info.audio_languages if probe_info else None,
|
||||||
subtitle_languages=probe_info.subtitle_languages if probe_info else None,
|
subtitle_languages=_merge_subtitle_languages(
|
||||||
|
probe_info.subtitle_languages if probe_info else None,
|
||||||
|
external_subs,
|
||||||
|
),
|
||||||
|
external_subtitle_languages=external_subs or None,
|
||||||
hdr=probe_info.hdr if probe_info else False,
|
hdr=probe_info.hdr if probe_info else False,
|
||||||
dovi=probe_info.dovi if probe_info else False,
|
dovi=probe_info.dovi if probe_info else False,
|
||||||
atmos=probe_info.atmos if probe_info else False,
|
atmos=probe_info.atmos if probe_info else False,
|
||||||
@@ -148,20 +166,30 @@ async def _cache_people_profiles(
|
|||||||
if not info or not info.cast:
|
if not info or not info.cast:
|
||||||
return info, people
|
return info, people
|
||||||
|
|
||||||
|
semaphore = asyncio.Semaphore(8)
|
||||||
|
|
||||||
|
async def fetch_profile(cast_credit, person):
|
||||||
|
async with semaphore:
|
||||||
|
return cast_credit.id, await download_cast_profile(
|
||||||
|
person.profile_path,
|
||||||
|
media_folder,
|
||||||
|
person.name,
|
||||||
|
cast_credit.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks = []
|
||||||
for cast_credit in info.cast:
|
for cast_credit in info.cast:
|
||||||
if cast_credit.id is None:
|
if cast_credit.id is None:
|
||||||
continue
|
continue
|
||||||
person = people.get(cast_credit.id)
|
person = people.get(cast_credit.id)
|
||||||
if person is None or not person.profile_path:
|
if person is None or not person.profile_path:
|
||||||
continue
|
continue
|
||||||
downloaded_path = await download_cast_profile(
|
tasks.append(fetch_profile(cast_credit, person))
|
||||||
person.profile_path,
|
|
||||||
media_folder,
|
for cast_id, downloaded_path in await asyncio.gather(*tasks):
|
||||||
person.name,
|
|
||||||
cast_credit.id,
|
|
||||||
)
|
|
||||||
if downloaded_path:
|
if downloaded_path:
|
||||||
people[cast_credit.id] = Person(
|
person = people[cast_id]
|
||||||
|
people[cast_id] = Person(
|
||||||
name=person.name,
|
name=person.name,
|
||||||
profile_path=Path(downloaded_path).name,
|
profile_path=Path(downloaded_path).name,
|
||||||
gender=person.gender,
|
gender=person.gender,
|
||||||
@@ -197,12 +225,16 @@ async def _collect_episode_files(
|
|||||||
all_episode_files[key] = []
|
all_episode_files[key] = []
|
||||||
for file_path, file_size in files:
|
for file_path, file_size in files:
|
||||||
probe = await get_probe(file_path)
|
probe = await get_probe(file_path)
|
||||||
|
external_subs = await find_external_subtitle_languages(file_path)
|
||||||
all_episode_files[key].append({
|
all_episode_files[key].append({
|
||||||
"path": file_path,
|
"path": file_path,
|
||||||
"size": file_size,
|
"size": file_size,
|
||||||
"probed_resolution": probe.resolution,
|
"probed_resolution": probe.resolution,
|
||||||
"audio_languages": probe.audio_languages,
|
"audio_languages": probe.audio_languages,
|
||||||
"subtitle_languages": probe.subtitle_languages,
|
"subtitle_languages": _merge_subtitle_languages(
|
||||||
|
probe.subtitle_languages, external_subs
|
||||||
|
),
|
||||||
|
"external_subtitle_languages": external_subs or None,
|
||||||
"hdr": probe.hdr,
|
"hdr": probe.hdr,
|
||||||
"dovi": probe.dovi,
|
"dovi": probe.dovi,
|
||||||
"atmos": probe.atmos,
|
"atmos": probe.atmos,
|
||||||
@@ -245,12 +277,18 @@ async def _collect_episode_files(
|
|||||||
item.content_hash.path,
|
item.content_hash.path,
|
||||||
)
|
)
|
||||||
size = item.content_hash.size if item.content_hash else 0
|
size = item.content_hash.size if item.content_hash else 0
|
||||||
|
external_subs = await find_external_subtitle_languages(
|
||||||
|
playable
|
||||||
|
)
|
||||||
all_episode_files[key].append({
|
all_episode_files[key].append({
|
||||||
"path": playable,
|
"path": playable,
|
||||||
"size": size,
|
"size": size,
|
||||||
"probed_resolution": probe.resolution,
|
"probed_resolution": probe.resolution,
|
||||||
"audio_languages": probe.audio_languages,
|
"audio_languages": probe.audio_languages,
|
||||||
"subtitle_languages": probe.subtitle_languages,
|
"subtitle_languages": _merge_subtitle_languages(
|
||||||
|
probe.subtitle_languages, external_subs
|
||||||
|
),
|
||||||
|
"external_subtitle_languages": external_subs or None,
|
||||||
"hdr": probe.hdr,
|
"hdr": probe.hdr,
|
||||||
"dovi": probe.dovi,
|
"dovi": probe.dovi,
|
||||||
"atmos": probe.atmos,
|
"atmos": probe.atmos,
|
||||||
@@ -333,6 +371,7 @@ def _build_episodes_data(
|
|||||||
audio=f.get("audio"),
|
audio=f.get("audio"),
|
||||||
audio_languages=f.get("audio_languages"),
|
audio_languages=f.get("audio_languages"),
|
||||||
subtitle_languages=f.get("subtitle_languages"),
|
subtitle_languages=f.get("subtitle_languages"),
|
||||||
|
external_subtitle_languages=f.get("external_subtitle_languages"),
|
||||||
hdr=bool(f.get("hdr")),
|
hdr=bool(f.get("hdr")),
|
||||||
dovi=bool(f.get("dovi")),
|
dovi=bool(f.get("dovi")),
|
||||||
atmos=bool(f.get("atmos")),
|
atmos=bool(f.get("atmos")),
|
||||||
@@ -378,6 +417,25 @@ async def _build_seasons_data(
|
|||||||
seasons_map[season_num] = {}
|
seasons_map[season_num] = {}
|
||||||
seasons_map[season_num][episode_num] = files
|
seasons_map[season_num][episode_num] = files
|
||||||
|
|
||||||
|
# Prefetch all missing season details in parallel; the loop below then
|
||||||
|
# reads them straight from season_cache.
|
||||||
|
if tmdb_id:
|
||||||
|
missing = [
|
||||||
|
season_num
|
||||||
|
for season_num in seasons_map
|
||||||
|
if (tmdb_id, season_num) not in season_cache
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
semaphore = asyncio.Semaphore(4)
|
||||||
|
|
||||||
|
async def prefetch(num: int) -> None:
|
||||||
|
async with semaphore:
|
||||||
|
season_cache[tmdb_id, num] = await fetch_season_details(
|
||||||
|
tmdb_id, num
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.gather(*(prefetch(num) for num in missing))
|
||||||
|
|
||||||
seasons_data = []
|
seasons_data = []
|
||||||
for season_num in sorted(seasons_map.keys()):
|
for season_num in sorted(seasons_map.keys()):
|
||||||
episodes_in_season = seasons_map[season_num]
|
episodes_in_season = seasons_map[season_num]
|
||||||
@@ -442,11 +500,15 @@ async def _process_movies(
|
|||||||
generate_showreels: bool,
|
generate_showreels: bool,
|
||||||
media_root: str | None = None,
|
media_root: str | None = None,
|
||||||
root_id: str | None = None,
|
root_id: str | None = None,
|
||||||
) -> AsyncIterator[tuple[str, Movie, tuple[str, Path, str] | None, dict[int, Person]]]:
|
) -> AsyncIterator[
|
||||||
|
tuple[str, Movie, tuple[str, Path, str] | None, dict[int, Person], list[str]]
|
||||||
|
]:
|
||||||
"""Async generator that processes all movies.
|
"""Async generator that processes all movies.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Tuples of ``(Movie, showreel_task_or_None)`` as each movie is processed.
|
Tuples of ``(movie_id, Movie, showreel_task_or_None, people, scanned)``
|
||||||
|
as each movie is processed. ``scanned`` lists the media-root-relative
|
||||||
|
torrent paths whose content was (re)scanned to build the movie.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
_ = root_id
|
_ = root_id
|
||||||
@@ -499,6 +561,21 @@ async def _process_movies(
|
|||||||
len(categories[ContentType.MOVIE]),
|
len(categories[ContentType.MOVIE]),
|
||||||
) if movie_groups else None
|
) if movie_groups else None
|
||||||
|
|
||||||
|
# Prefetch TMDb lookups for all unique titles in parallel; the grouping
|
||||||
|
# loop below then reads them straight from movie_tmdb_cache.
|
||||||
|
if movie_groups:
|
||||||
|
semaphore = asyncio.Semaphore(4)
|
||||||
|
first_by_key = {
|
||||||
|
f"{items[0].title.lower()}:{items[0].year}": items[0]
|
||||||
|
for items in movie_groups.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
async def prefetch(item: ParsedContent) -> None:
|
||||||
|
async with semaphore:
|
||||||
|
await get_movie_tmdb(item.title, item.year)
|
||||||
|
|
||||||
|
await asyncio.gather(*(prefetch(item) for item in first_by_key.values()))
|
||||||
|
|
||||||
for idx, (_movie_key, items) in enumerate(movie_groups.items(), 1):
|
for idx, (_movie_key, items) in enumerate(movie_groups.items(), 1):
|
||||||
first_item = items[0]
|
first_item = items[0]
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -640,7 +717,10 @@ async def _process_movies(
|
|||||||
showreel_source_sets=showreel_source_sets or None,
|
showreel_source_sets=showreel_source_sets or None,
|
||||||
files=files,
|
files=files,
|
||||||
)
|
)
|
||||||
yield item_id, movie, showreel_task, people
|
scanned = [
|
||||||
|
make_relative_path(item.path.as_posix(), media_root) for item in items
|
||||||
|
]
|
||||||
|
yield item_id, movie, showreel_task, people, scanned
|
||||||
|
|
||||||
# Process movies without TMDb info
|
# Process movies without TMDb info
|
||||||
for group_data in no_tmdb_movie_groups.values():
|
for group_data in no_tmdb_movie_groups.values():
|
||||||
@@ -712,7 +792,10 @@ async def _process_movies(
|
|||||||
showreel_source_sets=showreel_source_sets or None,
|
showreel_source_sets=showreel_source_sets or None,
|
||||||
files=files,
|
files=files,
|
||||||
)
|
)
|
||||||
yield item_id, movie, showreel_task, {}
|
scanned = [
|
||||||
|
make_relative_path(item.path.as_posix(), media_root) for item in items
|
||||||
|
]
|
||||||
|
yield item_id, movie, showreel_task, {}, scanned
|
||||||
|
|
||||||
|
|
||||||
async def _process_series(
|
async def _process_series(
|
||||||
@@ -723,12 +806,16 @@ async def _process_series(
|
|||||||
media_root: str | None = None,
|
media_root: str | None = None,
|
||||||
root_id: str | None = None,
|
root_id: str | None = None,
|
||||||
) -> AsyncIterator[
|
) -> AsyncIterator[
|
||||||
tuple[str, Series, list[tuple[str, Path, int, int, str]], dict[int, Person]]
|
tuple[
|
||||||
|
str, Series, list[tuple[str, Path, int, int, str]], dict[int, Person], list[str]
|
||||||
|
]
|
||||||
]:
|
]:
|
||||||
"""Async generator that processes all series.
|
"""Async generator that processes all series.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Tuples of ``(Series, episode_reel_tasks)`` as each series is processed.
|
Tuples of ``(series_id, Series, episode_reel_tasks, people, scanned)``
|
||||||
|
as each series is processed. ``scanned`` lists the media-root-relative
|
||||||
|
torrent paths whose content was (re)scanned to build the series.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
_ = root_id
|
_ = root_id
|
||||||
@@ -783,6 +870,20 @@ async def _process_series(
|
|||||||
len(categories[ContentType.SERIES]),
|
len(categories[ContentType.SERIES]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Prefetch TMDb lookups for all unique titles in parallel; the grouping
|
||||||
|
# loop below then reads them straight from series_tmdb_cache.
|
||||||
|
if series_groups:
|
||||||
|
semaphore = asyncio.Semaphore(4)
|
||||||
|
first_by_key = {
|
||||||
|
items[0].title.lower(): items[0] for items in series_groups.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
async def prefetch(item: ParsedContent) -> None:
|
||||||
|
async with semaphore:
|
||||||
|
await get_series_tmdb(item.title)
|
||||||
|
|
||||||
|
await asyncio.gather(*(prefetch(item) for item in first_by_key.values()))
|
||||||
|
|
||||||
for idx, (_series_key, items) in enumerate(series_groups.items(), 1):
|
for idx, (_series_key, items) in enumerate(series_groups.items(), 1):
|
||||||
first_item = items[0]
|
first_item = items[0]
|
||||||
logger.debug(" [%d/%d] %s", idx, len(series_groups), first_item.title)
|
logger.debug(" [%d/%d] %s", idx, len(series_groups), first_item.title)
|
||||||
@@ -879,7 +980,11 @@ async def _process_series(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not seasons_data:
|
if not seasons_data:
|
||||||
logger.info(" Skipping %s - no episodes found", display_title)
|
logger.info(
|
||||||
|
" Skipping %s - no episodes in the %d scanned torrent(s)",
|
||||||
|
display_title,
|
||||||
|
len(items),
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
different_titles = sorted(
|
different_titles = sorted(
|
||||||
@@ -898,7 +1003,10 @@ async def _process_series(
|
|||||||
backdrop_path=make_relative_path(backdrop_path, media_root),
|
backdrop_path=make_relative_path(backdrop_path, media_root),
|
||||||
seasons=seasons_data,
|
seasons=seasons_data,
|
||||||
)
|
)
|
||||||
yield series_id, series, ep_reel_tasks, people
|
scanned = [
|
||||||
|
make_relative_path(item.path.as_posix(), media_root) for item in items
|
||||||
|
]
|
||||||
|
yield series_id, series, ep_reel_tasks, people, scanned
|
||||||
|
|
||||||
# Process series without TMDb info
|
# Process series without TMDb info
|
||||||
for group_data in no_tmdb_groups.values():
|
for group_data in no_tmdb_groups.values():
|
||||||
@@ -928,7 +1036,11 @@ async def _process_series(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not seasons_data:
|
if not seasons_data:
|
||||||
logger.info(" Skipping %s - no episodes found", title)
|
logger.info(
|
||||||
|
" Skipping %s - no episodes in the %d scanned torrent(s)",
|
||||||
|
title,
|
||||||
|
len(items),
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
item_timestamps = [await get_added_timestamp(item.path) for item in items]
|
item_timestamps = [await get_added_timestamp(item.path) for item in items]
|
||||||
@@ -941,4 +1053,7 @@ async def _process_series(
|
|||||||
cover_path=make_relative_path(cover_path, media_root),
|
cover_path=make_relative_path(cover_path, media_root),
|
||||||
seasons=seasons_data,
|
seasons=seasons_data,
|
||||||
)
|
)
|
||||||
yield series_id, series, ep_reel_tasks, {}
|
scanned = [
|
||||||
|
make_relative_path(item.path.as_posix(), media_root) for item in items
|
||||||
|
]
|
||||||
|
yield series_id, series, ep_reel_tasks, {}, scanned
|
||||||
|
|||||||
+809
-207
File diff suppressed because it is too large
Load Diff
@@ -28,12 +28,61 @@ VIDEO_EXTENSIONS = {
|
|||||||
".m2ts",
|
".m2ts",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Caches for expensive operations
|
# External subtitle file extensions
|
||||||
|
SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub"}
|
||||||
|
|
||||||
|
# Non-language tokens that may follow the language in a sidecar filename
|
||||||
|
_SUBTITLE_FLAG_TOKENS = {"forced", "sdh", "cc", "hi", "dhi", "commentary", "signs"}
|
||||||
|
|
||||||
|
# ISO 639-1 -> ISO 639-2/B for common sidecar language tags, so they merge
|
||||||
|
# with the codes ffmpeg reports for embedded tracks.
|
||||||
|
_ISO_639_1_TO_639_2 = {
|
||||||
|
"ar": "ara",
|
||||||
|
"cs": "ces",
|
||||||
|
"da": "dan",
|
||||||
|
"de": "deu",
|
||||||
|
"el": "ell",
|
||||||
|
"en": "eng",
|
||||||
|
"es": "esp",
|
||||||
|
"fi": "fin",
|
||||||
|
"fr": "fra",
|
||||||
|
"he": "heb",
|
||||||
|
"hi": "hin",
|
||||||
|
"hu": "hun",
|
||||||
|
"id": "ind",
|
||||||
|
"it": "ita",
|
||||||
|
"ja": "jpn",
|
||||||
|
"ko": "kor",
|
||||||
|
"nl": "nld",
|
||||||
|
"no": "nor",
|
||||||
|
"pl": "pol",
|
||||||
|
"pt": "por",
|
||||||
|
"ru": "rus",
|
||||||
|
"sv": "swe",
|
||||||
|
"th": "tha",
|
||||||
|
"tr": "tur",
|
||||||
|
"uk": "ukr",
|
||||||
|
"vi": "vie",
|
||||||
|
"zh": "zho",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Caches for expensive operations. These are per-scan only: the scanner
|
||||||
|
# clears them at the start of every scan. Caching across scans is wrong —
|
||||||
|
# an empty result recorded before a download finished (or during a transient
|
||||||
|
# network-mount error) would stick for the process lifetime and report
|
||||||
|
# "no episodes found" for series that do have episodes.
|
||||||
_episode_files_cache: dict[str, dict[tuple[int, int], list[tuple[str, int]]]] = {}
|
_episode_files_cache: dict[str, dict[tuple[int, int], list[tuple[str, int]]]] = {}
|
||||||
_playable_file_cache: dict[str, str | None] = {}
|
_playable_file_cache: dict[str, str | None] = {}
|
||||||
_bluray_probe_file_cache: dict[str, str | None] = {}
|
_bluray_probe_file_cache: dict[str, str | None] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def clear_scan_caches() -> None:
|
||||||
|
"""Drop all per-scan filesystem caches; called at the start of each scan."""
|
||||||
|
_episode_files_cache.clear()
|
||||||
|
_playable_file_cache.clear()
|
||||||
|
_bluray_probe_file_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
def _scandir_split(
|
def _scandir_split(
|
||||||
directory: Path,
|
directory: Path,
|
||||||
stop_event: threading.Event,
|
stop_event: threading.Event,
|
||||||
@@ -311,6 +360,56 @@ async def find_playable_file(path: Path) -> str | None:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_subtitle_language(video_stem: str, filename: str) -> str | None:
|
||||||
|
"""Language tag from a sidecar subtitle name like `<stem>.esp.srt`, if any."""
|
||||||
|
if not filename.startswith(video_stem + "."):
|
||||||
|
return None
|
||||||
|
suffix = Path(filename).suffix.lower()
|
||||||
|
if suffix not in SUBTITLE_EXTENSIONS:
|
||||||
|
return None
|
||||||
|
middle = filename[len(video_stem) + 1 : -len(suffix)]
|
||||||
|
tokens = [t for t in middle.split(".") if t]
|
||||||
|
while tokens and tokens[-1].lower() in _SUBTITLE_FLAG_TOKENS:
|
||||||
|
tokens.pop()
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
code = tokens[-1].lower()
|
||||||
|
if not code.isalpha() or not 2 <= len(code) <= 3:
|
||||||
|
return None
|
||||||
|
code = _ISO_639_1_TO_639_2.get(code, code)
|
||||||
|
return None if code == "und" else code
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_external_subtitle_languages(video_path: Path) -> list[str]:
|
||||||
|
languages: list[str] = []
|
||||||
|
with os.scandir(video_path.parent) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.is_file(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
lang = _sidecar_subtitle_language(video_path.stem, entry.name)
|
||||||
|
if lang and lang not in languages:
|
||||||
|
languages.append(lang)
|
||||||
|
return languages
|
||||||
|
|
||||||
|
|
||||||
|
async def find_external_subtitle_languages(video_path: str | None) -> list[str]:
|
||||||
|
"""Languages of external subtitle files sitting next to a video file.
|
||||||
|
|
||||||
|
Matches sidecars named `<stem>.<lang>.<ext>` (e.g. `Movie.esp.srt` ->
|
||||||
|
``esp``), optionally with flags like ``forced``/``sdh`` after the language.
|
||||||
|
Bare `<stem>.<ext>` files carry no language tag and are ignored.
|
||||||
|
"""
|
||||||
|
if not video_path or "://" in video_path or video_path.startswith("concat:"):
|
||||||
|
return []
|
||||||
|
path = Path(video_path)
|
||||||
|
if path.suffix.lower() not in VIDEO_EXTENSIONS:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_scan_external_subtitle_languages, path)
|
||||||
|
except OSError, PermissionError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def find_metadata_probe_file(playable_path: str | None) -> str | None:
|
async def find_metadata_probe_file(playable_path: str | None) -> str | None:
|
||||||
"""Resolve a path suitable for ffmpeg stream metadata probing.
|
"""Resolve a path suitable for ffmpeg stream metadata probing.
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ and HDR passthrough.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -425,6 +426,85 @@ class MediaProbeInfo:
|
|||||||
|
|
||||||
|
|
||||||
_media_probe_cache: dict[str, MediaProbeInfo] = {}
|
_media_probe_cache: dict[str, MediaProbeInfo] = {}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Persistent probe records
|
||||||
|
#
|
||||||
|
# Probe results are keyed by (path, mtime, size) and persisted to
|
||||||
|
# ``probe-cache.json`` under the root's .mediahive folder so that process
|
||||||
|
# restarts do not re-run ffmpeg on unchanged files. The in-RAM structures
|
||||||
|
# are process-global (keyed by absolute path, so sharing across roots is
|
||||||
|
# safe); each root loads/saves its own file, merging into the same dict.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_probe_records: dict[str, dict] = {}
|
||||||
|
_probe_records_path: Path | None = None
|
||||||
|
_probe_records_dirty = False
|
||||||
|
|
||||||
|
|
||||||
|
def load_probe_records(path: Path) -> None:
|
||||||
|
"""Load persisted probe records from ``path`` (missing file is fine)."""
|
||||||
|
global _probe_records_path
|
||||||
|
_probe_records_path = path
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except OSError, ValueError:
|
||||||
|
logger.exception("Failed to load probe cache from %s", path)
|
||||||
|
return
|
||||||
|
records = data.get("records")
|
||||||
|
if isinstance(records, dict):
|
||||||
|
_probe_records.update(records)
|
||||||
|
logger.info("Loaded probe cache: %d records from %s", len(records), path)
|
||||||
|
|
||||||
|
|
||||||
|
def probe_records_dirty() -> bool:
|
||||||
|
return _probe_records_dirty
|
||||||
|
|
||||||
|
|
||||||
|
def save_probe_records() -> None:
|
||||||
|
"""Persist probe records if any were added since the last save."""
|
||||||
|
global _probe_records_dirty
|
||||||
|
if not _probe_records_dirty or _probe_records_path is None:
|
||||||
|
return
|
||||||
|
_probe_records_dirty = False
|
||||||
|
try:
|
||||||
|
payload = json.dumps({"version": 1, "records": _probe_records})
|
||||||
|
tmp = _probe_records_path.with_suffix(".tmp")
|
||||||
|
tmp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp.write_text(payload, encoding="utf-8")
|
||||||
|
tmp.replace(_probe_records_path)
|
||||||
|
except OSError, TypeError, ValueError:
|
||||||
|
logger.exception("Failed to save probe cache to %s", _probe_records_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_probe(video_path: str, stat_info, info: MediaProbeInfo) -> None:
|
||||||
|
global _probe_records_dirty
|
||||||
|
if stat_info is None:
|
||||||
|
return # Non-plain paths (bluray:/concat: URIs) are not persisted
|
||||||
|
_probe_records[video_path] = {
|
||||||
|
"mtime": int(stat_info.st_mtime),
|
||||||
|
"size": stat_info.st_size,
|
||||||
|
"info": asdict(info),
|
||||||
|
}
|
||||||
|
_probe_records_dirty = True
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_probe_record(video_path: str, stat_info) -> MediaProbeInfo | None:
|
||||||
|
rec = _probe_records.get(video_path)
|
||||||
|
if rec is None or stat_info is None:
|
||||||
|
return None
|
||||||
|
if rec.get("mtime") != int(stat_info.st_mtime):
|
||||||
|
return None
|
||||||
|
if rec.get("size") != stat_info.st_size:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return MediaProbeInfo(**rec["info"])
|
||||||
|
except TypeError, KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
_duration_re = re.compile(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)")
|
_duration_re = re.compile(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)")
|
||||||
_dimension_re = re.compile(r"(\d{2,5})x(\d{2,5})")
|
_dimension_re = re.compile(r"(\d{2,5})x(\d{2,5})")
|
||||||
_dovi_profile_re = re.compile(
|
_dovi_profile_re = re.compile(
|
||||||
@@ -432,6 +512,8 @@ _dovi_profile_re = re.compile(
|
|||||||
)
|
)
|
||||||
_audio_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Audio:")
|
_audio_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Audio:")
|
||||||
_subtitle_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Subtitle:")
|
_subtitle_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Subtitle:")
|
||||||
|
# showinfo emits e.g. "side data - HDR Dynamic Metadata SMPTE2094-40 (HDR10+)"
|
||||||
|
_showinfo_hdr10plus_re = re.compile(r"SMPTE2094-40|HDR Dynamic Metadata", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _lang_code(raw: str | None) -> str | None:
|
def _lang_code(raw: str | None) -> str | None:
|
||||||
@@ -447,11 +529,23 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
|
|||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
|
# Stat once: used both to validate persisted records and to key new ones.
|
||||||
|
# Non-plain paths (bluray:/concat: URIs) fail stat and stay memory-cached.
|
||||||
|
stat_info = None
|
||||||
|
with contextlib.suppress(OSError, ValueError):
|
||||||
|
stat_info = await AsyncPath(video_path).stat()
|
||||||
|
|
||||||
|
recorded = _lookup_probe_record(video_path, stat_info)
|
||||||
|
if recorded is not None:
|
||||||
|
_media_probe_cache[video_path] = recorded
|
||||||
|
return recorded
|
||||||
|
|
||||||
info = MediaProbeInfo()
|
info = MediaProbeInfo()
|
||||||
cmd = ["ffmpeg", "-hide_banner", "-i", video_path]
|
cmd = ["ffmpeg", "-hide_banner", "-i", video_path]
|
||||||
ffmpeg_result = await _run_ffmpeg(cmd, timeout_seconds=30, allow_nonzero_exit=True)
|
ffmpeg_result = await _run_ffmpeg(cmd, timeout_seconds=30, allow_nonzero_exit=True)
|
||||||
if ffmpeg_result is None:
|
if ffmpeg_result is None:
|
||||||
_media_probe_cache[video_path] = info
|
_media_probe_cache[video_path] = info
|
||||||
|
_record_probe(video_path, stat_info, info)
|
||||||
return info
|
return info
|
||||||
|
|
||||||
stdout, stderr = ffmpeg_result
|
stdout, stderr = ffmpeg_result
|
||||||
@@ -492,6 +586,35 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
|
|||||||
or "dynamic hdr" in lower_text
|
or "dynamic hdr" in lower_text
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# `ffmpeg -i` only reads container headers, not frame-level side data.
|
||||||
|
# Run a tiny showinfo decode (5 frames) to detect HDR10+ dynamic metadata
|
||||||
|
# (SMPTE ST 2094-40) when the header scan didn't already confirm it.
|
||||||
|
if info.hdr and not info.hdr10plus and not info.dovi:
|
||||||
|
showinfo_cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-hide_banner",
|
||||||
|
"-ss",
|
||||||
|
"0",
|
||||||
|
"-i",
|
||||||
|
video_path,
|
||||||
|
"-vf",
|
||||||
|
"showinfo",
|
||||||
|
"-frames:v",
|
||||||
|
"5",
|
||||||
|
"-an",
|
||||||
|
"-f",
|
||||||
|
"null",
|
||||||
|
"-",
|
||||||
|
]
|
||||||
|
showinfo_result = await _run_ffmpeg(
|
||||||
|
showinfo_cmd, timeout_seconds=15, allow_nonzero_exit=True
|
||||||
|
)
|
||||||
|
if showinfo_result is not None:
|
||||||
|
si_stdout, si_stderr = showinfo_result
|
||||||
|
si_text = (si_stderr + si_stdout).decode("utf-8", errors="replace")
|
||||||
|
if _showinfo_hdr10plus_re.search(si_text):
|
||||||
|
info.hdr10plus = True
|
||||||
|
|
||||||
dovi_match = _dovi_profile_re.search(text)
|
dovi_match = _dovi_profile_re.search(text)
|
||||||
if dovi_match:
|
if dovi_match:
|
||||||
info.dovi_profile = int(dovi_match.group(1))
|
info.dovi_profile = int(dovi_match.group(1))
|
||||||
@@ -521,6 +644,7 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
|
|||||||
info.subtitle_languages = subtitle_languages or None
|
info.subtitle_languages = subtitle_languages or None
|
||||||
|
|
||||||
_media_probe_cache[video_path] = info
|
_media_probe_cache[video_path] = info
|
||||||
|
_record_probe(video_path, stat_info, info)
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from mediahive.models.tmdb import (
|
|||||||
Info,
|
Info,
|
||||||
Person,
|
Person,
|
||||||
SeasonInfo,
|
SeasonInfo,
|
||||||
SimilarMedia,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# TMDb API configuration
|
# TMDb API configuration
|
||||||
@@ -148,19 +147,19 @@ async def tmdb_api_request(
|
|||||||
|
|
||||||
|
|
||||||
async def fetch_movie_details(movie_id: int) -> dict | None:
|
async def fetch_movie_details(movie_id: int) -> dict | None:
|
||||||
"""Fetch movie info including credits, similar, keywords, and alt titles."""
|
"""Fetch movie info including credits, keywords, alt titles, and collection."""
|
||||||
# Use append_to_response to get multiple data in one request
|
# Use append_to_response to get multiple data in one request
|
||||||
return await tmdb_api_request(
|
return await tmdb_api_request(
|
||||||
f"/movie/{movie_id}",
|
f"/movie/{movie_id}",
|
||||||
{"append_to_response": "credits,similar,keywords,alternative_titles"},
|
{"append_to_response": "credits,keywords,alternative_titles"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def fetch_series_details(series_id: int) -> dict | None:
|
async def fetch_series_details(series_id: int) -> dict | None:
|
||||||
"""Fetch detailed TV series info including credits, similar, and keywords."""
|
"""Fetch detailed TV series info including credits and keywords."""
|
||||||
# Use append_to_response to get multiple data in one request
|
# Use append_to_response to get multiple data in one request
|
||||||
return await tmdb_api_request(
|
return await tmdb_api_request(
|
||||||
f"/tv/{series_id}", {"append_to_response": "credits,similar,keywords"}
|
f"/tv/{series_id}", {"append_to_response": "credits,keywords"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -385,7 +384,7 @@ async def fetch_movie_info(
|
|||||||
result = data["results"][0]
|
result = data["results"][0]
|
||||||
movie_id = result["id"]
|
movie_id = result["id"]
|
||||||
|
|
||||||
# Fetch full details with credits, similar movies, and keywords
|
# Fetch full details with credits, keywords, alt titles, and collection
|
||||||
details = await fetch_movie_details(movie_id)
|
details = await fetch_movie_details(movie_id)
|
||||||
if not details:
|
if not details:
|
||||||
# Fall back to basic info from search
|
# Fall back to basic info from search
|
||||||
@@ -394,6 +393,7 @@ async def fetch_movie_info(
|
|||||||
tmdb_id=movie_id,
|
tmdb_id=movie_id,
|
||||||
title=result.get("title"),
|
title=result.get("title"),
|
||||||
original_title=result.get("original_title"),
|
original_title=result.get("original_title"),
|
||||||
|
original_language=result.get("original_language"),
|
||||||
rating=result.get("vote_average"),
|
rating=result.get("vote_average"),
|
||||||
vote_count=result.get("vote_count"),
|
vote_count=result.get("vote_count"),
|
||||||
overview=result.get("overview"),
|
overview=result.get("overview"),
|
||||||
@@ -450,15 +450,19 @@ async def fetch_movie_info(
|
|||||||
directors = [c["name"] for c in crew if c.get("job") == "Director"]
|
directors = [c["name"] for c in crew if c.get("job") == "Director"]
|
||||||
director = directors[0] if directors else None
|
director = directors[0] if directors else None
|
||||||
|
|
||||||
# Extract similar movies (limit to 10)
|
collection_data = details.get("belongs_to_collection")
|
||||||
similar_data = details.get("similar", {}).get("results", [])[:10]
|
collection = None
|
||||||
similar = [SimilarMedia(id=s["id"], title=s["title"]) for s in similar_data]
|
if isinstance(collection_data, dict):
|
||||||
|
collection_name = collection_data.get("name")
|
||||||
|
if isinstance(collection_name, str):
|
||||||
|
collection = collection_name or None
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Info(
|
Info(
|
||||||
tmdb_id=movie_id,
|
tmdb_id=movie_id,
|
||||||
title=details.get("title"),
|
title=details.get("title"),
|
||||||
original_title=details.get("original_title"),
|
original_title=details.get("original_title"),
|
||||||
|
original_language=details.get("original_language"),
|
||||||
alternative_titles=alternative_titles,
|
alternative_titles=alternative_titles,
|
||||||
rating=details.get("vote_average"),
|
rating=details.get("vote_average"),
|
||||||
vote_count=details.get("vote_count"),
|
vote_count=details.get("vote_count"),
|
||||||
@@ -466,9 +470,9 @@ async def fetch_movie_info(
|
|||||||
genres=genres or None,
|
genres=genres or None,
|
||||||
release_date=details.get("release_date"),
|
release_date=details.get("release_date"),
|
||||||
runtime=details.get("runtime"),
|
runtime=details.get("runtime"),
|
||||||
|
collection=collection,
|
||||||
status=details.get("status"),
|
status=details.get("status"),
|
||||||
tagline=details.get("tagline"),
|
tagline=details.get("tagline"),
|
||||||
similar=similar or None,
|
|
||||||
keywords=keywords or None,
|
keywords=keywords or None,
|
||||||
cast=cast or None,
|
cast=cast or None,
|
||||||
director=director,
|
director=director,
|
||||||
@@ -513,7 +517,7 @@ async def fetch_series_info(
|
|||||||
result = data["results"][0]
|
result = data["results"][0]
|
||||||
series_id = result["id"]
|
series_id = result["id"]
|
||||||
|
|
||||||
# Fetch full details with credits, similar shows, and keywords
|
# Fetch full details with credits and keywords
|
||||||
details = await fetch_series_details(series_id)
|
details = await fetch_series_details(series_id)
|
||||||
if not details:
|
if not details:
|
||||||
# Fall back to basic info from search
|
# Fall back to basic info from search
|
||||||
@@ -522,6 +526,7 @@ async def fetch_series_info(
|
|||||||
tmdb_id=series_id,
|
tmdb_id=series_id,
|
||||||
title=result.get("name"),
|
title=result.get("name"),
|
||||||
original_title=result.get("original_name"),
|
original_title=result.get("original_name"),
|
||||||
|
original_language=result.get("original_language"),
|
||||||
rating=result.get("vote_average"),
|
rating=result.get("vote_average"),
|
||||||
vote_count=result.get("vote_count"),
|
vote_count=result.get("vote_count"),
|
||||||
overview=result.get("overview"),
|
overview=result.get("overview"),
|
||||||
@@ -564,10 +569,6 @@ async def fetch_series_info(
|
|||||||
# Extract networks
|
# Extract networks
|
||||||
networks = [n["name"] for n in details.get("networks", [])]
|
networks = [n["name"] for n in details.get("networks", [])]
|
||||||
|
|
||||||
# Extract similar series (limit to 10)
|
|
||||||
similar_data = details.get("similar", {}).get("results", [])[:10]
|
|
||||||
similar = [SimilarMedia(id=s["id"], title=s["name"]) for s in similar_data]
|
|
||||||
|
|
||||||
# Get first air date
|
# Get first air date
|
||||||
first_air_date = details.get("first_air_date")
|
first_air_date = details.get("first_air_date")
|
||||||
|
|
||||||
@@ -576,6 +577,7 @@ async def fetch_series_info(
|
|||||||
tmdb_id=series_id,
|
tmdb_id=series_id,
|
||||||
title=details.get("name"),
|
title=details.get("name"),
|
||||||
original_title=details.get("original_name"),
|
original_title=details.get("original_name"),
|
||||||
|
original_language=details.get("original_language"),
|
||||||
rating=details.get("vote_average"),
|
rating=details.get("vote_average"),
|
||||||
vote_count=details.get("vote_count"),
|
vote_count=details.get("vote_count"),
|
||||||
overview=details.get("overview"),
|
overview=details.get("overview"),
|
||||||
@@ -583,7 +585,6 @@ async def fetch_series_info(
|
|||||||
release_date=first_air_date,
|
release_date=first_air_date,
|
||||||
status=details.get("status"),
|
status=details.get("status"),
|
||||||
tagline=details.get("tagline"),
|
tagline=details.get("tagline"),
|
||||||
similar=similar or None,
|
|
||||||
keywords=keywords or None,
|
keywords=keywords or None,
|
||||||
cast=cast or None,
|
cast=cast or None,
|
||||||
creators=creators or None,
|
creators=creators or None,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -11,9 +10,6 @@ from aiopathlib import AsyncPath
|
|||||||
# Default output folder name (created at common root of scanned paths)
|
# Default output folder name (created at common root of scanned paths)
|
||||||
DEFAULT_OUTPUT_FOLDER = ".mediahive"
|
DEFAULT_OUTPUT_FOLDER = ".mediahive"
|
||||||
|
|
||||||
# Threshold for considering atime "too close" to current time (1 hour)
|
|
||||||
_ATIME_FRESHNESS_THRESHOLD = 3600
|
|
||||||
|
|
||||||
# Resolution priority for quality sorting (higher = better)
|
# Resolution priority for quality sorting (higher = better)
|
||||||
RESOLUTION_PRIORITY = {
|
RESOLUTION_PRIORITY = {
|
||||||
"8K": 5,
|
"8K": 5,
|
||||||
@@ -108,10 +104,9 @@ def normalize_resolution_label(value: str | None) -> str | None:
|
|||||||
async def get_added_timestamp(path: Path) -> int | None:
|
async def get_added_timestamp(path: Path) -> int | None:
|
||||||
"""Get the timestamp when a torrent was added to the collection.
|
"""Get the timestamp when a torrent was added to the collection.
|
||||||
|
|
||||||
Heuristic:
|
Best-effort rule:
|
||||||
- For directories: use ctime (most accurate for torrent folder creation)
|
- On Windows: use ctime (creation-time semantics)
|
||||||
- For files: use atime unless it's too close to current time (suggesting
|
- On other OSes: use mtime (ctime is metadata-change time on Unix)
|
||||||
the filesystem updates atime on reads), otherwise use max(mtime, ctime)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Unix timestamp as int, or None if path doesn't exist
|
Unix timestamp as int, or None if path doesn't exist
|
||||||
@@ -122,17 +117,9 @@ async def get_added_timestamp(path: Path) -> int | None:
|
|||||||
stat_info = await ap.stat()
|
stat_info = await ap.stat()
|
||||||
except OSError, PermissionError:
|
except OSError, PermissionError:
|
||||||
return None
|
return None
|
||||||
|
if os.name == "nt":
|
||||||
if await ap.is_dir():
|
|
||||||
return int(stat_info.st_ctime)
|
return int(stat_info.st_ctime)
|
||||||
|
return int(stat_info.st_mtime)
|
||||||
now = time.time()
|
|
||||||
atime = stat_info.st_atime
|
|
||||||
|
|
||||||
if now - atime < _ATIME_FRESHNESS_THRESHOLD:
|
|
||||||
return int(max(stat_info.st_mtime, stat_info.st_ctime))
|
|
||||||
|
|
||||||
return int(atime)
|
|
||||||
|
|
||||||
|
|
||||||
def get_directory_size(path: Path) -> int:
|
def get_directory_size(path: Path) -> int:
|
||||||
|
|||||||
+314
-25
@@ -9,6 +9,7 @@ debounced background task.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -17,16 +18,15 @@ from aiopathlib import AsyncPath
|
|||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
from mediahive.models.data import (
|
from mediahive.models.data import (
|
||||||
|
Episode,
|
||||||
IndexSnapshot,
|
IndexSnapshot,
|
||||||
Movie,
|
Movie,
|
||||||
|
Season,
|
||||||
Series,
|
Series,
|
||||||
TaskInfo,
|
TaskInfo,
|
||||||
|
Torrent,
|
||||||
)
|
)
|
||||||
from mediahive.models.events import Remove, Task, Upsert
|
from mediahive.models.events import Remove, Task, Upsert
|
||||||
from mediahive.models.protocol import (
|
|
||||||
WsInit,
|
|
||||||
WsInitData,
|
|
||||||
)
|
|
||||||
from mediahive.models.tmdb import Person
|
from mediahive.models.tmdb import Person
|
||||||
|
|
||||||
logger = logging.getLogger("mediahive.index_store")
|
logger = logging.getLogger("mediahive.index_store")
|
||||||
@@ -64,6 +64,8 @@ class IndexStore:
|
|||||||
|
|
||||||
# Connected WebSocket clients
|
# Connected WebSocket clients
|
||||||
self._clients: set[WebSocket] = set()
|
self._clients: set[WebSocket] = set()
|
||||||
|
# Passive listeners for broadcast events (used by server-level WS fan-in)
|
||||||
|
self._listeners: set[Callable[[object], None]] = set()
|
||||||
|
|
||||||
# Snapshot debounce state
|
# Snapshot debounce state
|
||||||
self._snapshot_dirty = False
|
self._snapshot_dirty = False
|
||||||
@@ -148,6 +150,120 @@ class IndexStore:
|
|||||||
return None
|
return None
|
||||||
return item.info.tmdb_id
|
return item.info.tmdb_id
|
||||||
|
|
||||||
|
def torrent_paths(self) -> set[str]:
|
||||||
|
"""All media-root-relative torrent paths currently in the index.
|
||||||
|
|
||||||
|
The scanner uses this to reprocess items that are missing from the
|
||||||
|
index even though their mtime is unchanged (e.g. after the snapshot
|
||||||
|
was wiped or an upsert never landed).
|
||||||
|
"""
|
||||||
|
paths: set[str] = set()
|
||||||
|
for movie in self.movies.values():
|
||||||
|
paths.update(movie.files)
|
||||||
|
for show in self.series.values():
|
||||||
|
for season in show.seasons:
|
||||||
|
for episode in season.episodes:
|
||||||
|
paths.update(episode.files)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _newest_from_files(files: dict[str, Torrent]) -> int | None:
|
||||||
|
timestamps = [t.added_at for t in files.values() if t.added_at]
|
||||||
|
return max(timestamps) if timestamps else None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Merge helpers (partial rescan support)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _merge_movie(self, existing: Movie, new: Movie, scanned: set[str]) -> Movie:
|
||||||
|
"""Merge a partially rebuilt movie into the existing entry.
|
||||||
|
|
||||||
|
File entries belonging to torrents in ``scanned`` are replaced by the
|
||||||
|
new data; everything else is preserved.
|
||||||
|
"""
|
||||||
|
files = {k: v for k, v in existing.files.items() if k not in scanned}
|
||||||
|
files.update(new.files)
|
||||||
|
return Movie(
|
||||||
|
title=new.title or existing.title,
|
||||||
|
info=new.info or existing.info,
|
||||||
|
year=new.year if new.year is not None else existing.year,
|
||||||
|
newest=(
|
||||||
|
self._newest_from_files(files)
|
||||||
|
or max(filter(None, [existing.newest, new.newest]), default=None)
|
||||||
|
),
|
||||||
|
cover_path=new.cover_path or existing.cover_path,
|
||||||
|
backdrop_path=new.backdrop_path or existing.backdrop_path,
|
||||||
|
showreel_images=new.showreel_images or existing.showreel_images,
|
||||||
|
showreel_source_sets=new.showreel_source_sets
|
||||||
|
or existing.showreel_source_sets,
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _merge_series(self, existing: Series, new: Series, scanned: set[str]) -> Series:
|
||||||
|
"""Merge a partially rebuilt series into the existing entry.
|
||||||
|
|
||||||
|
File entries belonging to torrents in ``scanned`` are replaced by the
|
||||||
|
new data; seasons/episodes/files from torrents that were not rescanned
|
||||||
|
are preserved. Episodes and seasons left without files are dropped.
|
||||||
|
"""
|
||||||
|
seasons: dict[int, Season] = {}
|
||||||
|
for season in existing.seasons:
|
||||||
|
episodes: dict[int, Episode] = {}
|
||||||
|
for ep in season.episodes:
|
||||||
|
files = {k: v for k, v in ep.files.items() if k not in scanned}
|
||||||
|
if files:
|
||||||
|
episodes[ep.episode_number] = msgspec.structs.replace(
|
||||||
|
ep, files=files
|
||||||
|
)
|
||||||
|
if episodes:
|
||||||
|
seasons[season.season_number] = msgspec.structs.replace(
|
||||||
|
season,
|
||||||
|
episodes=list(episodes.values()),
|
||||||
|
episode_count=len(episodes),
|
||||||
|
)
|
||||||
|
|
||||||
|
for season in new.seasons:
|
||||||
|
current = seasons.get(season.season_number)
|
||||||
|
if current is None:
|
||||||
|
seasons[season.season_number] = season
|
||||||
|
continue
|
||||||
|
episodes = {ep.episode_number: ep for ep in current.episodes}
|
||||||
|
for ep in season.episodes:
|
||||||
|
old = episodes.get(ep.episode_number)
|
||||||
|
if old is None:
|
||||||
|
episodes[ep.episode_number] = ep
|
||||||
|
continue
|
||||||
|
# Same episode from an unscanned torrent too: union the files,
|
||||||
|
# prefer fresh metadata/reel info from the new scan.
|
||||||
|
files = dict(old.files)
|
||||||
|
files.update(ep.files)
|
||||||
|
episodes[ep.episode_number] = msgspec.structs.replace(
|
||||||
|
ep,
|
||||||
|
files=files,
|
||||||
|
reel_image=ep.reel_image or old.reel_image,
|
||||||
|
reel_sources=ep.reel_sources or old.reel_sources,
|
||||||
|
)
|
||||||
|
ordered = [episodes[k] for k in sorted(episodes)]
|
||||||
|
seasons[season.season_number] = msgspec.structs.replace(
|
||||||
|
season,
|
||||||
|
episodes=ordered,
|
||||||
|
episode_count=len(ordered),
|
||||||
|
poster_path=season.poster_path or current.poster_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
alt_titles = sorted(
|
||||||
|
set(existing.alternative_titles or []) | set(new.alternative_titles or [])
|
||||||
|
)
|
||||||
|
return Series(
|
||||||
|
title=new.title or existing.title,
|
||||||
|
info=new.info or existing.info,
|
||||||
|
alternative_titles=alt_titles or None,
|
||||||
|
newest=max(filter(None, [existing.newest, new.newest]), default=None),
|
||||||
|
cover_path=new.cover_path or existing.cover_path,
|
||||||
|
backdrop_path=new.backdrop_path or existing.backdrop_path,
|
||||||
|
seasons=[seasons[k] for k in sorted(seasons)],
|
||||||
|
)
|
||||||
|
|
||||||
def _rebuild_tmdb_indexes(self) -> None:
|
def _rebuild_tmdb_indexes(self) -> None:
|
||||||
"""Rebuild TMDb id lookup maps from the current in-memory items."""
|
"""Rebuild TMDb id lookup maps from the current in-memory items."""
|
||||||
self._movie_tmdb_ids.clear()
|
self._movie_tmdb_ids.clear()
|
||||||
@@ -190,22 +306,34 @@ class IndexStore:
|
|||||||
self._rebuild_tmdb_indexes()
|
self._rebuild_tmdb_indexes()
|
||||||
|
|
||||||
def _collapse_movie_tmdb_duplicates(self, tmdb_id: int, keep_id: str) -> None:
|
def _collapse_movie_tmdb_duplicates(self, tmdb_id: int, keep_id: str) -> None:
|
||||||
"""Remove other movie entries that share a TMDb id."""
|
"""Fold other entries that share a TMDb id into the kept one."""
|
||||||
for item_id, movie in list(self.movies.items()):
|
for item_id, movie in list(self.movies.items()):
|
||||||
if item_id == keep_id:
|
if item_id == keep_id:
|
||||||
continue
|
continue
|
||||||
if self._get_tmdb_id(movie) == tmdb_id:
|
if self._get_tmdb_id(movie) != tmdb_id:
|
||||||
|
continue
|
||||||
|
kept = self.movies.get(keep_id)
|
||||||
|
if kept is not None:
|
||||||
|
# Preserve any file versions the duplicate alone carried.
|
||||||
|
self.movies[keep_id] = self._merge_movie(movie, kept, set())
|
||||||
self.movies.pop(item_id, None)
|
self.movies.pop(item_id, None)
|
||||||
self._rebuild_tmdb_indexes()
|
if self._movie_tmdb_ids.get(tmdb_id) == item_id:
|
||||||
|
self._movie_tmdb_ids[tmdb_id] = keep_id
|
||||||
|
|
||||||
def _collapse_series_tmdb_duplicates(self, tmdb_id: int, keep_id: str) -> None:
|
def _collapse_series_tmdb_duplicates(self, tmdb_id: int, keep_id: str) -> None:
|
||||||
"""Remove other series entries that share a TMDb id."""
|
"""Fold other entries that share a TMDb id into the kept one."""
|
||||||
for item_id, series in list(self.series.items()):
|
for item_id, series in list(self.series.items()):
|
||||||
if item_id == keep_id:
|
if item_id == keep_id:
|
||||||
continue
|
continue
|
||||||
if self._get_tmdb_id(series) == tmdb_id:
|
if self._get_tmdb_id(series) != tmdb_id:
|
||||||
|
continue
|
||||||
|
kept = self.series.get(keep_id)
|
||||||
|
if kept is not None:
|
||||||
|
# Preserve any seasons/episodes the duplicate alone carried.
|
||||||
|
self.series[keep_id] = self._merge_series(series, kept, set())
|
||||||
self.series.pop(item_id, None)
|
self.series.pop(item_id, None)
|
||||||
self._rebuild_tmdb_indexes()
|
if self._series_tmdb_ids.get(tmdb_id) == item_id:
|
||||||
|
self._series_tmdb_ids[tmdb_id] = keep_id
|
||||||
|
|
||||||
async def _write_snapshot(self) -> None:
|
async def _write_snapshot(self) -> None:
|
||||||
"""Write current index to disk (called from debounce task)."""
|
"""Write current index to disk (called from debounce task)."""
|
||||||
@@ -301,8 +429,14 @@ class IndexStore:
|
|||||||
item_id: str,
|
item_id: str,
|
||||||
item: Movie,
|
item: Movie,
|
||||||
people: dict[int, Person] | None = None,
|
people: dict[int, Person] | None = None,
|
||||||
|
scanned: list[str] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Insert or update a movie. Returns True if it was a real change."""
|
"""Insert or update a movie. Returns True if it was a real change.
|
||||||
|
|
||||||
|
When ``scanned`` is given, the item is a partial rebuild covering only
|
||||||
|
those torrent paths; it is merged into the existing entry instead of
|
||||||
|
replacing it.
|
||||||
|
"""
|
||||||
tmdb_id = self._get_tmdb_id(item)
|
tmdb_id = self._get_tmdb_id(item)
|
||||||
existing_id = self._movie_tmdb_ids.get(tmdb_id) if tmdb_id is not None else None
|
existing_id = self._movie_tmdb_ids.get(tmdb_id) if tmdb_id is not None else None
|
||||||
if existing_id is not None and existing_id != item_id:
|
if existing_id is not None and existing_id != item_id:
|
||||||
@@ -312,6 +446,10 @@ class IndexStore:
|
|||||||
if tmdb_id is not None:
|
if tmdb_id is not None:
|
||||||
self._movie_tmdb_ids[tmdb_id] = item_id
|
self._movie_tmdb_ids[tmdb_id] = item_id
|
||||||
self._collapse_movie_tmdb_duplicates(tmdb_id, item_id)
|
self._collapse_movie_tmdb_duplicates(tmdb_id, item_id)
|
||||||
|
existing = self.movies.get(item_id)
|
||||||
|
|
||||||
|
if existing is not None and scanned is not None:
|
||||||
|
item = self._merge_movie(existing, item, set(scanned))
|
||||||
|
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if msgspec.json.encode(existing) == msgspec.json.encode(item):
|
if msgspec.json.encode(existing) == msgspec.json.encode(item):
|
||||||
@@ -335,8 +473,14 @@ class IndexStore:
|
|||||||
item_id: str,
|
item_id: str,
|
||||||
item: Series,
|
item: Series,
|
||||||
people: dict[int, Person] | None = None,
|
people: dict[int, Person] | None = None,
|
||||||
|
scanned: list[str] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Insert or update a series. Returns True if it was a real change."""
|
"""Insert or update a series. Returns True if it was a real change.
|
||||||
|
|
||||||
|
When ``scanned`` is given, the item is a partial rebuild covering only
|
||||||
|
those torrent paths; it is merged into the existing entry instead of
|
||||||
|
replacing it.
|
||||||
|
"""
|
||||||
tmdb_id = self._get_tmdb_id(item)
|
tmdb_id = self._get_tmdb_id(item)
|
||||||
existing_id = (
|
existing_id = (
|
||||||
self._series_tmdb_ids.get(tmdb_id) if tmdb_id is not None else None
|
self._series_tmdb_ids.get(tmdb_id) if tmdb_id is not None else None
|
||||||
@@ -348,6 +492,10 @@ class IndexStore:
|
|||||||
if tmdb_id is not None:
|
if tmdb_id is not None:
|
||||||
self._series_tmdb_ids[tmdb_id] = item_id
|
self._series_tmdb_ids[tmdb_id] = item_id
|
||||||
self._collapse_series_tmdb_duplicates(tmdb_id, item_id)
|
self._collapse_series_tmdb_duplicates(tmdb_id, item_id)
|
||||||
|
existing = self.series.get(item_id)
|
||||||
|
|
||||||
|
if existing is not None and scanned is not None:
|
||||||
|
item = self._merge_series(existing, item, set(scanned))
|
||||||
|
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if msgspec.json.encode(existing) == msgspec.json.encode(item):
|
if msgspec.json.encode(existing) == msgspec.json.encode(item):
|
||||||
@@ -368,39 +516,174 @@ class IndexStore:
|
|||||||
|
|
||||||
def remove_movie(self, item_id: str) -> None:
|
def remove_movie(self, item_id: str) -> None:
|
||||||
"""Remove a movie from the index and broadcast."""
|
"""Remove a movie from the index and broadcast."""
|
||||||
self.movies.pop(item_id, None)
|
movie = self.movies.pop(item_id, None)
|
||||||
for tmdb_id, mapped_id in list(self._movie_tmdb_ids.items()):
|
if movie is None:
|
||||||
if mapped_id == item_id:
|
return
|
||||||
|
tmdb_id = self._get_tmdb_id(movie)
|
||||||
|
if tmdb_id is not None and self._movie_tmdb_ids.get(tmdb_id) == item_id:
|
||||||
self._movie_tmdb_ids.pop(tmdb_id, None)
|
self._movie_tmdb_ids.pop(tmdb_id, None)
|
||||||
self._schedule_snapshot()
|
self._schedule_snapshot()
|
||||||
self._broadcast(Remove(kind="movie", id=item_id))
|
self._broadcast(Remove(kind="movie", id=item_id))
|
||||||
|
|
||||||
def remove_series(self, item_id: str) -> None:
|
def remove_series(self, item_id: str) -> None:
|
||||||
"""Remove a series from the index and broadcast."""
|
"""Remove a series from the index and broadcast."""
|
||||||
self.series.pop(item_id, None)
|
series = self.series.pop(item_id, None)
|
||||||
for tmdb_id, mapped_id in list(self._series_tmdb_ids.items()):
|
if series is None:
|
||||||
if mapped_id == item_id:
|
return
|
||||||
|
tmdb_id = self._get_tmdb_id(series)
|
||||||
|
if tmdb_id is not None and self._series_tmdb_ids.get(tmdb_id) == item_id:
|
||||||
self._series_tmdb_ids.pop(tmdb_id, None)
|
self._series_tmdb_ids.pop(tmdb_id, None)
|
||||||
self._schedule_snapshot()
|
self._schedule_snapshot()
|
||||||
self._broadcast(Remove(kind="series", id=item_id))
|
self._broadcast(Remove(kind="series", id=item_id))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Scanner-driven maintenance
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def sync_torrent_paths(self, paths: set[str]) -> None:
|
||||||
|
"""Drop file entries whose torrent path no longer exists on disk.
|
||||||
|
|
||||||
|
``paths`` is the complete set of media-root-relative torrent paths the
|
||||||
|
scanner found during a fully completed discovery pass. Episodes and
|
||||||
|
seasons left without files are dropped; items left without any files
|
||||||
|
are removed entirely.
|
||||||
|
"""
|
||||||
|
for item_id, movie in list(self.movies.items()):
|
||||||
|
kept = {k: v for k, v in movie.files.items() if k in paths}
|
||||||
|
if len(kept) == len(movie.files):
|
||||||
|
continue
|
||||||
|
if not kept:
|
||||||
|
self.remove_movie(item_id)
|
||||||
|
continue
|
||||||
|
updated = msgspec.structs.replace(
|
||||||
|
movie, files=kept, newest=self._newest_from_files(kept)
|
||||||
|
)
|
||||||
|
self.movies[item_id] = updated
|
||||||
|
self._schedule_snapshot()
|
||||||
|
self._broadcast(Upsert(kind="movie", id=item_id, item=updated))
|
||||||
|
|
||||||
|
for item_id, series in list(self.series.items()):
|
||||||
|
removed_any = False
|
||||||
|
new_seasons: list[Season] = []
|
||||||
|
for season in series.seasons:
|
||||||
|
new_episodes: list[Episode] = []
|
||||||
|
for ep in season.episodes:
|
||||||
|
files = {k: v for k, v in ep.files.items() if k in paths}
|
||||||
|
if len(files) < len(ep.files):
|
||||||
|
removed_any = True
|
||||||
|
if files:
|
||||||
|
new_episodes.append(msgspec.structs.replace(ep, files=files))
|
||||||
|
else:
|
||||||
|
removed_any = True
|
||||||
|
if not new_episodes:
|
||||||
|
removed_any = True
|
||||||
|
continue
|
||||||
|
if len(new_episodes) < len(season.episodes):
|
||||||
|
season = msgspec.structs.replace(
|
||||||
|
season,
|
||||||
|
episodes=new_episodes,
|
||||||
|
episode_count=len(new_episodes),
|
||||||
|
)
|
||||||
|
new_seasons.append(season)
|
||||||
|
if not removed_any:
|
||||||
|
continue
|
||||||
|
if not new_seasons:
|
||||||
|
self.remove_series(item_id)
|
||||||
|
continue
|
||||||
|
updated_series = msgspec.structs.replace(series, seasons=new_seasons)
|
||||||
|
self.series[item_id] = updated_series
|
||||||
|
self._schedule_snapshot()
|
||||||
|
self._broadcast(Upsert(kind="series", id=item_id, item=updated_series))
|
||||||
|
|
||||||
|
def set_movie_showreel(
|
||||||
|
self,
|
||||||
|
item_id: str,
|
||||||
|
showreel_images: list[str] | None,
|
||||||
|
showreel_source_sets: list[list[str]] | None,
|
||||||
|
) -> None:
|
||||||
|
"""Update only the showreel fields of a movie (reel worker callback)."""
|
||||||
|
movie = self.movies.get(item_id)
|
||||||
|
if movie is None:
|
||||||
|
return
|
||||||
|
updated = msgspec.structs.replace(
|
||||||
|
movie,
|
||||||
|
showreel_images=showreel_images,
|
||||||
|
showreel_source_sets=showreel_source_sets,
|
||||||
|
)
|
||||||
|
if msgspec.json.encode(updated) == msgspec.json.encode(movie):
|
||||||
|
return
|
||||||
|
self.movies[item_id] = updated
|
||||||
|
self._schedule_snapshot()
|
||||||
|
self._broadcast(Upsert(kind="movie", id=item_id, item=updated))
|
||||||
|
|
||||||
|
def set_episode_reel(
|
||||||
|
self,
|
||||||
|
item_id: str,
|
||||||
|
season_num: int,
|
||||||
|
episode_num: int,
|
||||||
|
reel_image: str | None,
|
||||||
|
reel_sources: list[str] | None,
|
||||||
|
) -> None:
|
||||||
|
"""Update only the reel fields of one episode (reel worker callback)."""
|
||||||
|
series = self.series.get(item_id)
|
||||||
|
if series is None:
|
||||||
|
return
|
||||||
|
for season in series.seasons:
|
||||||
|
if season.season_number != season_num:
|
||||||
|
continue
|
||||||
|
for ep in season.episodes:
|
||||||
|
if ep.episode_number != episode_num:
|
||||||
|
continue
|
||||||
|
if ep.reel_image == reel_image and ep.reel_sources == reel_sources:
|
||||||
|
return
|
||||||
|
new_episodes = [
|
||||||
|
msgspec.structs.replace(
|
||||||
|
e, reel_image=reel_image, reel_sources=reel_sources
|
||||||
|
)
|
||||||
|
if e is ep
|
||||||
|
else e
|
||||||
|
for e in season.episodes
|
||||||
|
]
|
||||||
|
new_seasons = [
|
||||||
|
msgspec.structs.replace(s, episodes=new_episodes)
|
||||||
|
if s is season
|
||||||
|
else s
|
||||||
|
for s in series.seasons
|
||||||
|
]
|
||||||
|
updated = msgspec.structs.replace(series, seasons=new_seasons)
|
||||||
|
self.series[item_id] = updated
|
||||||
|
self._schedule_snapshot()
|
||||||
|
self._broadcast(Upsert(kind="series", id=item_id, item=updated))
|
||||||
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# WebSocket management
|
# WebSocket management
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def add_listener(self, listener: Callable[[object], None]) -> None:
|
||||||
|
"""Register a listener called for each broadcast message."""
|
||||||
|
self._listeners.add(listener)
|
||||||
|
|
||||||
|
def remove_listener(self, listener: Callable[[object], None]) -> None:
|
||||||
|
"""Unregister a previously registered broadcast listener."""
|
||||||
|
self._listeners.discard(listener)
|
||||||
|
|
||||||
async def connect(self, ws: WebSocket) -> None:
|
async def connect(self, ws: WebSocket) -> None:
|
||||||
"""Accept a WS client and send the full index as init."""
|
"""Accept a WS client and send the full index as init."""
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
self._clients.add(ws)
|
self._clients.add(ws)
|
||||||
logger.info("WS client connected (%d total)", len(self._clients))
|
logger.info("WS client connected (%d total)", len(self._clients))
|
||||||
# Send full current state
|
# Send full current state
|
||||||
msg = WsInit(
|
msg = {
|
||||||
data=WsInitData(
|
"type": "init",
|
||||||
movies=dict(self.movies),
|
"roots": {
|
||||||
series=dict(self.series),
|
"": {
|
||||||
people=dict(self.people),
|
"movies": dict(self.movies),
|
||||||
)
|
"series": dict(self.series),
|
||||||
)
|
"people": dict(self.people),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
await ws.send_bytes(msgspec.json.encode(msg))
|
await ws.send_bytes(msgspec.json.encode(msg))
|
||||||
|
|
||||||
def disconnect(self, ws: WebSocket) -> None:
|
def disconnect(self, ws: WebSocket) -> None:
|
||||||
@@ -410,6 +693,12 @@ class IndexStore:
|
|||||||
|
|
||||||
def _broadcast(self, msg: object) -> None:
|
def _broadcast(self, msg: object) -> None:
|
||||||
"""Broadcast a message to all connected WS clients (non-blocking)."""
|
"""Broadcast a message to all connected WS clients (non-blocking)."""
|
||||||
|
for listener in tuple(self._listeners):
|
||||||
|
try:
|
||||||
|
listener(msg)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("IndexStore listener failed")
|
||||||
|
|
||||||
data = msgspec.json.encode(msg)
|
data = msgspec.json.encode(msg)
|
||||||
dead: list[WebSocket] = []
|
dead: list[WebSocket] = []
|
||||||
for ws in self._clients:
|
for ws in self._clients:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class Torrent(msgspec.Struct, omit_defaults=True):
|
|||||||
audio: str | None = None
|
audio: str | None = None
|
||||||
audio_languages: list[str] | None = None
|
audio_languages: list[str] | None = None
|
||||||
subtitle_languages: list[str] | None = None
|
subtitle_languages: list[str] | None = None
|
||||||
|
external_subtitle_languages: list[str] | None = None
|
||||||
hdr: bool = False
|
hdr: bool = False
|
||||||
dovi: bool = False
|
dovi: bool = False
|
||||||
atmos: bool = False
|
atmos: bool = False
|
||||||
|
|||||||
@@ -14,12 +14,20 @@ from .tmdb import Person
|
|||||||
|
|
||||||
|
|
||||||
class Upsert(msgspec.Struct, tag="upsert"):
|
class Upsert(msgspec.Struct, tag="upsert"):
|
||||||
"""Single item inserted or updated."""
|
"""Single item inserted or updated.
|
||||||
|
|
||||||
|
``scanned`` lists the media-root-relative torrent paths whose content was
|
||||||
|
(re)scanned to build this item. When present, the store merges the item
|
||||||
|
into the existing entry instead of replacing it wholesale: only data
|
||||||
|
belonging to the scanned torrents is replaced. ``None`` means full
|
||||||
|
replacement (legacy behaviour).
|
||||||
|
"""
|
||||||
|
|
||||||
kind: str # "movie" or "series"
|
kind: str # "movie" or "series"
|
||||||
id: str
|
id: str
|
||||||
item: Movie | Series
|
item: Movie | Series
|
||||||
people: dict[int, Person] | None = None
|
people: dict[int, Person] | None = None
|
||||||
|
scanned: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class Remove(msgspec.Struct, tag="remove"):
|
class Remove(msgspec.Struct, tag="remove"):
|
||||||
@@ -29,6 +37,35 @@ class Remove(msgspec.Struct, tag="remove"):
|
|||||||
id: str
|
id: str
|
||||||
|
|
||||||
|
|
||||||
|
class Sync(msgspec.Struct, tag="sync"):
|
||||||
|
"""Full set of media-root-relative torrent paths currently on disk.
|
||||||
|
|
||||||
|
Sent by the scanner after a successfully completed discovery pass so the
|
||||||
|
store can drop entries whose files no longer exist. Internal only —
|
||||||
|
never forwarded to WebSocket clients.
|
||||||
|
"""
|
||||||
|
|
||||||
|
paths: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class MovieShowreel(msgspec.Struct, tag="movie-showreel"):
|
||||||
|
"""Reel worker result for a movie (internal, scanner → store)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
showreel_images: list[str] | None = None
|
||||||
|
showreel_source_sets: list[list[str]] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeReel(msgspec.Struct, tag="episode-reel"):
|
||||||
|
"""Reel worker result for one episode (internal, scanner → store)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
season: int
|
||||||
|
episode: int
|
||||||
|
reel_image: str | None = None
|
||||||
|
reel_sources: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class Task(msgspec.Struct, tag="task"):
|
class Task(msgspec.Struct, tag="task"):
|
||||||
"""Task progress broadcast."""
|
"""Task progress broadcast."""
|
||||||
|
|
||||||
@@ -36,4 +73,4 @@ class Task(msgspec.Struct, tag="task"):
|
|||||||
|
|
||||||
|
|
||||||
# Union of scan events (scanner → server) and WS broadcast messages
|
# Union of scan events (scanner → server) and WS broadcast messages
|
||||||
ScanEvent = Upsert | Task
|
ScanEvent = Upsert | Sync | MovieShowreel | EpisodeReel | Task
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ from __future__ import annotations
|
|||||||
import msgspec
|
import msgspec
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from .data import Movie, Series
|
from .data import Movie, Series, TaskInfo
|
||||||
from .events import Remove, ScanEvent, Task, Upsert
|
from .events import ScanEvent
|
||||||
from .tmdb import Person
|
from .tmdb import Person
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -17,33 +17,78 @@ from .tmdb import Person
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class WsInitData(msgspec.Struct):
|
class WsRootStatus(msgspec.Struct):
|
||||||
"""Payload of the init message."""
|
"""Current status for one configured root."""
|
||||||
|
|
||||||
|
root_id: str
|
||||||
|
path: str
|
||||||
|
status: str
|
||||||
|
error: str | None = None
|
||||||
|
snapshot_loaded: bool = False
|
||||||
|
movies: int = 0
|
||||||
|
series: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class WsRootInitData(msgspec.Struct):
|
||||||
|
"""Initial full index payload for one root."""
|
||||||
|
|
||||||
movies: dict[str, Movie]
|
movies: dict[str, Movie]
|
||||||
series: dict[str, Series]
|
series: dict[str, Series]
|
||||||
people: dict[int, Person]
|
people: dict[int, Person]
|
||||||
|
|
||||||
|
|
||||||
class WsInit(msgspec.Struct, tag="init"):
|
class WsRoots(msgspec.Struct, tag="roots"):
|
||||||
"""Full index sent on WS connect."""
|
"""Root list and status update."""
|
||||||
|
|
||||||
data: WsInitData
|
roots: list[WsRootStatus]
|
||||||
|
|
||||||
|
|
||||||
|
class WsInit(msgspec.Struct, tag="init"):
|
||||||
|
"""Full index payload keyed by root_id."""
|
||||||
|
|
||||||
|
roots: dict[str, WsRootInitData]
|
||||||
|
|
||||||
|
|
||||||
|
class WsUpsert(msgspec.Struct, tag="upsert"):
|
||||||
|
"""Single item inserted or updated for one root."""
|
||||||
|
|
||||||
|
root_id: str
|
||||||
|
kind: str # "movie" or "series"
|
||||||
|
id: str
|
||||||
|
item: Movie | Series
|
||||||
|
people: dict[int, Person] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WsRemove(msgspec.Struct, tag="remove"):
|
||||||
|
"""Single item removed for one root."""
|
||||||
|
|
||||||
|
root_id: str
|
||||||
|
kind: str
|
||||||
|
id: str
|
||||||
|
|
||||||
|
|
||||||
|
class WsTask(msgspec.Struct, tag="task"):
|
||||||
|
"""Task progress update for one root."""
|
||||||
|
|
||||||
|
root_id: str
|
||||||
|
data: TaskInfo
|
||||||
|
|
||||||
|
|
||||||
# Union of all outbound WS messages (for documentation / future decoding)
|
# Union of all outbound WS messages (for documentation / future decoding)
|
||||||
WsMessage = WsInit | Upsert | Remove | Task
|
WsMessage = WsRoots | WsInit | WsUpsert | WsRemove | WsTask
|
||||||
|
|
||||||
|
|
||||||
# Re-export unified types for backward compatibility
|
# Re-export unified types for backward compatibility
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Remove",
|
|
||||||
"ScanEvent",
|
"ScanEvent",
|
||||||
"Task",
|
|
||||||
"Upsert",
|
|
||||||
"WsInit",
|
"WsInit",
|
||||||
"WsInitData",
|
|
||||||
"WsMessage",
|
"WsMessage",
|
||||||
|
"WsRemove",
|
||||||
|
"WsRootInitData",
|
||||||
|
"WsRootStatus",
|
||||||
|
"WsRoots",
|
||||||
|
"WsTask",
|
||||||
|
"WsUpsert",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -67,11 +112,19 @@ class OpenFolderRequest(msgspec.Struct):
|
|||||||
|
|
||||||
|
|
||||||
class RootsRequest(msgspec.Struct):
|
class RootsRequest(msgspec.Struct):
|
||||||
"""PUT /api/roots body."""
|
"""PUT /api/config/roots body."""
|
||||||
|
|
||||||
roots: dict[str, str]
|
roots: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class PlaybackStateUpdateRequest(msgspec.Struct):
|
||||||
|
"""POST /api/meta/playback-state body."""
|
||||||
|
|
||||||
|
root_id: str
|
||||||
|
file_path: str
|
||||||
|
pos: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class RootEntryResponse(msgspec.Struct):
|
class RootEntryResponse(msgspec.Struct):
|
||||||
"""Single root entry in responses."""
|
"""Single root entry in responses."""
|
||||||
|
|
||||||
@@ -80,7 +133,7 @@ class RootEntryResponse(msgspec.Struct):
|
|||||||
|
|
||||||
|
|
||||||
class RootStatusResponse(msgspec.Struct):
|
class RootStatusResponse(msgspec.Struct):
|
||||||
"""Per-root status in GET /api/roots."""
|
"""Legacy per-root status shape kept for non-WS callers."""
|
||||||
|
|
||||||
root_id: str
|
root_id: str
|
||||||
path: str
|
path: str
|
||||||
|
|||||||
@@ -27,13 +27,6 @@ class Person(msgspec.Struct, array_like=True):
|
|||||||
gender: str | None = None
|
gender: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SimilarMedia(msgspec.Struct, array_like=True):
|
|
||||||
"""Pointer to a similar movie/series on TMDb."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# TMDb result types
|
# TMDb result types
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -72,6 +65,7 @@ class Info(msgspec.Struct):
|
|||||||
tmdb_id: int
|
tmdb_id: int
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
original_title: str | None = None
|
original_title: str | None = None
|
||||||
|
original_language: str | None = None
|
||||||
alternative_titles: list[str] | None = None
|
alternative_titles: list[str] | None = None
|
||||||
rating: float | None = None
|
rating: float | None = None
|
||||||
vote_count: int | None = None
|
vote_count: int | None = None
|
||||||
@@ -79,9 +73,9 @@ class Info(msgspec.Struct):
|
|||||||
genres: list[str] | None = None
|
genres: list[str] | None = None
|
||||||
release_date: str | None = None
|
release_date: str | None = None
|
||||||
runtime: int | None = None
|
runtime: int | None = None
|
||||||
|
collection: str | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
tagline: str | None = None
|
tagline: str | None = None
|
||||||
similar: list[SimilarMedia] | None = None
|
|
||||||
keywords: list[str] | None = None
|
keywords: list[str] | None = None
|
||||||
cast: list[CastCredit] | None = None
|
cast: list[CastCredit] | None = None
|
||||||
director: str | None = None
|
director: str | None = None
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ import msgspec
|
|||||||
|
|
||||||
from mediahive.config import load_config, save_config
|
from mediahive.config import load_config, save_config
|
||||||
from mediahive.index_store import IndexStore
|
from mediahive.index_store import IndexStore
|
||||||
from mediahive.models.events import ScanEvent, Task, Upsert
|
from mediahive.models.events import (
|
||||||
|
EpisodeReel,
|
||||||
|
MovieShowreel,
|
||||||
|
ScanEvent,
|
||||||
|
Sync,
|
||||||
|
Task,
|
||||||
|
Upsert,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("mediahive.root_registry")
|
logger = logging.getLogger("mediahive.root_registry")
|
||||||
|
|
||||||
@@ -158,9 +165,27 @@ class RootContext:
|
|||||||
event = await self._events.get()
|
event = await self._events.get()
|
||||||
if isinstance(event, Upsert):
|
if isinstance(event, Upsert):
|
||||||
if event.kind == "movie":
|
if event.kind == "movie":
|
||||||
self.store.upsert_movie(event.id, event.item, event.people)
|
self.store.upsert_movie(
|
||||||
|
event.id, event.item, event.people, event.scanned
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.store.upsert_series(event.id, event.item, event.people)
|
self.store.upsert_series(
|
||||||
|
event.id, event.item, event.people, event.scanned
|
||||||
|
)
|
||||||
|
elif isinstance(event, Sync):
|
||||||
|
self.store.sync_torrent_paths(set(event.paths))
|
||||||
|
elif isinstance(event, MovieShowreel):
|
||||||
|
self.store.set_movie_showreel(
|
||||||
|
event.id, event.showreel_images, event.showreel_source_sets
|
||||||
|
)
|
||||||
|
elif isinstance(event, EpisodeReel):
|
||||||
|
self.store.set_episode_reel(
|
||||||
|
event.id,
|
||||||
|
event.season,
|
||||||
|
event.episode,
|
||||||
|
event.reel_image,
|
||||||
|
event.reel_sources,
|
||||||
|
)
|
||||||
elif isinstance(event, Task):
|
elif isinstance(event, Task):
|
||||||
self.store.broadcast_task(event.data)
|
self.store.broadcast_task(event.data)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -280,7 +305,7 @@ class Supervisor:
|
|||||||
base_name = _derive_root_name(configured_path)
|
base_name = _derive_root_name(configured_path)
|
||||||
unique_name = base_name
|
unique_name = base_name
|
||||||
suffix = 2
|
suffix = 2
|
||||||
existing_names = {e.name for e in candidates}
|
existing_names = {e.root_id for e in candidates}
|
||||||
while unique_name in existing_names:
|
while unique_name in existing_names:
|
||||||
unique_name = f"{base_name}{suffix}"
|
unique_name = f"{base_name}{suffix}"
|
||||||
suffix += 1
|
suffix += 1
|
||||||
@@ -328,6 +353,10 @@ class Supervisor:
|
|||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
for ctx in list(self._contexts.values()):
|
# Stop roots concurrently — each may wait on task cancellation and
|
||||||
await ctx.stop()
|
# network-mount snapshot flushes, and those delays must not add up.
|
||||||
|
await asyncio.gather(
|
||||||
|
*(ctx.stop() for ctx in list(self._contexts.values())),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
self._contexts.clear()
|
self._contexts.clear()
|
||||||
|
|||||||
+1135
-76
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
"""Optional Velopack auto-update integration (GUI builds only).
|
||||||
|
|
||||||
|
In development and portable-ZIP runs Velopack is either not installed or the
|
||||||
|
app is not a Velopack installation; every helper degrades to a no-op then, so
|
||||||
|
callers never need to special-case those environments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mediahive.config import load_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
|
||||||
|
|
||||||
|
|
||||||
|
def _manager():
|
||||||
|
"""Return a Velopack UpdateManager, or None when updates are unavailable."""
|
||||||
|
try:
|
||||||
|
import velopack
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
|
||||||
|
except RuntimeError, OSError:
|
||||||
|
# Not a Velopack installation (dev / portable run).
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pending_update() -> str | None:
|
||||||
|
"""Version of a downloaded update staged for the next launch, if any."""
|
||||||
|
mgr = _manager()
|
||||||
|
if mgr is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
asset = mgr.get_update_pending_restart()
|
||||||
|
except RuntimeError, OSError:
|
||||||
|
return None
|
||||||
|
return str(asset.Version) if asset is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_pending_and_restart() -> bool:
|
||||||
|
"""Apply the staged update and restart into it. False when nothing pending."""
|
||||||
|
mgr = _manager()
|
||||||
|
if mgr is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
asset = mgr.get_update_pending_restart()
|
||||||
|
if asset is None:
|
||||||
|
return False
|
||||||
|
logger.info("Velopack: applying staged update %s and restarting", asset.Version)
|
||||||
|
mgr.apply_updates_and_restart(asset)
|
||||||
|
except (RuntimeError, OSError) as exc:
|
||||||
|
logger.warning("Velopack: failed to apply staged update: %s", exc)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_download() -> None:
|
||||||
|
"""Download available updates in the background, unless disabled in config.
|
||||||
|
|
||||||
|
Downloaded updates are applied automatically by Velopack on the next app
|
||||||
|
start, so the running session is never interrupted. Network failures and
|
||||||
|
non-Velopack runs are expected and skipped quietly.
|
||||||
|
"""
|
||||||
|
if not load_config().auto_update:
|
||||||
|
logger.info("Velopack: automatic updates disabled, skipping check")
|
||||||
|
return
|
||||||
|
mgr = _manager()
|
||||||
|
if mgr is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
info = mgr.check_for_updates()
|
||||||
|
if info is None:
|
||||||
|
logger.info("Velopack: no update available")
|
||||||
|
return
|
||||||
|
version = info.TargetFullRelease.Version
|
||||||
|
logger.info("Velopack: downloading update %s", version)
|
||||||
|
mgr.download_updates(info)
|
||||||
|
logger.info("Velopack: update %s staged, applies on next launch", version)
|
||||||
|
except (RuntimeError, OSError) as exc:
|
||||||
|
logger.info("Velopack update check skipped: %s", exc)
|
||||||
+482
-86
@@ -9,6 +9,7 @@ import asyncio
|
|||||||
import contextlib
|
import contextlib
|
||||||
import ctypes
|
import ctypes
|
||||||
import html
|
import html
|
||||||
|
import importlib.metadata
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -23,11 +24,21 @@ import urllib.request
|
|||||||
from concurrent.futures import Future, ThreadPoolExecutor
|
from concurrent.futures import Future, ThreadPoolExecutor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Must be set before fastapi_vue env bindings are created (mediahive.config);
|
||||||
|
# this module is the PyInstaller entry point and may run without __main__.
|
||||||
|
os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE")
|
||||||
|
|
||||||
import msgspec.structs
|
import msgspec.structs
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
import velopack
|
||||||
import webview
|
import webview
|
||||||
|
from fastapi_vue import env
|
||||||
|
from fastapi_vue.logging import patch_log_config
|
||||||
|
from fastapi_vue.startupbox import print_box
|
||||||
|
from tracerite.html import html_traceback
|
||||||
|
|
||||||
from mediahive.config import load_config, save_config
|
from mediahive import updater
|
||||||
|
from mediahive.config import config, load_config, log_dir, save_config
|
||||||
from mediahive.volume_control import get_volume, set_volume, volume_max
|
from mediahive.volume_control import get_volume, set_volume, volume_max
|
||||||
|
|
||||||
logger = logging.getLogger("mediahive.winmain")
|
logger = logging.getLogger("mediahive.winmain")
|
||||||
@@ -53,6 +64,9 @@ MPC_BE_STATE_RUNNING = 2
|
|||||||
MPC_BE_SEEK_BEGIN_COMMAND = 1085
|
MPC_BE_SEEK_BEGIN_COMMAND = 1085
|
||||||
MPC_BE_RESUME_APPLY_THRESHOLD_MS = 15000
|
MPC_BE_RESUME_APPLY_THRESHOLD_MS = 15000
|
||||||
MPC_BE_RESUME_CLEAR_MARGIN_MS = 15000
|
MPC_BE_RESUME_CLEAR_MARGIN_MS = 15000
|
||||||
|
# Watching (or presumably watching) less than this leaves no position data:
|
||||||
|
# brief peeks and seeks back to re-view a scene are not true progress.
|
||||||
|
MPC_BE_RESUME_MIN_WATCH_MS = 5 * 60 * 1000
|
||||||
MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS = 1.0
|
MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS = 1.0
|
||||||
VOLUME_MIN = 0.0
|
VOLUME_MIN = 0.0
|
||||||
VOLUME_MAX = 1.5
|
VOLUME_MAX = 1.5
|
||||||
@@ -120,59 +134,218 @@ _DURATION_RE = re.compile(r'<p id="duration">(\d+)</p>')
|
|||||||
def _default_playback_state() -> dict[str, object]:
|
def _default_playback_state() -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"current": None,
|
"current": None,
|
||||||
"resume_positions": {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_playback_state(path: Path) -> dict[str, object]:
|
def _normalize_media_path(path: str) -> str:
|
||||||
|
return path.replace("\\", "/").lstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_playable_file(file_key: str, playable_file: str | None) -> str:
|
||||||
|
if not playable_file:
|
||||||
|
return file_key
|
||||||
|
if playable_file.startswith("concat:") or "://" in playable_file:
|
||||||
|
return playable_file
|
||||||
|
if playable_file.startswith(f"{file_key}/"):
|
||||||
|
return playable_file
|
||||||
|
if playable_file.startswith("/"):
|
||||||
|
return playable_file.lstrip("/")
|
||||||
|
return f"{file_key}/{playable_file}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_resume_positions(
|
||||||
|
backend_url: str,
|
||||||
|
) -> tuple[
|
||||||
|
dict[str, tuple[int, int | None, int | None]],
|
||||||
|
dict[tuple[str, int, int], int],
|
||||||
|
]:
|
||||||
|
"""Fetch resume state from the backend.
|
||||||
|
|
||||||
|
Returns (continue_points, episode_positions): continue_points map a slug
|
||||||
|
to (pos_ms, season_number, episode_number) — season/episode set for the
|
||||||
|
series' single continue point, None for movies. episode_positions map
|
||||||
|
(slug, season, episode) to pos_ms for partially watched episodes;
|
||||||
|
fully watched episodes are absent.
|
||||||
|
"""
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url=f"{backend_url}/api/meta/playback-state",
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
||||||
|
raw = json.loads(resp.read().decode("utf-8"))
|
||||||
|
except OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError:
|
||||||
|
return {}, {}
|
||||||
|
|
||||||
|
data = raw.get("data") if isinstance(raw, dict) else None
|
||||||
|
positions = data.get("resume_positions") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(positions, dict):
|
||||||
|
return {}, {}
|
||||||
|
|
||||||
|
cleaned: dict[str, tuple[int, int | None, int | None]] = {}
|
||||||
|
episode_positions: dict[tuple[str, int, int], int] = {}
|
||||||
|
for slug, value in positions.items():
|
||||||
|
if not isinstance(slug, str) or not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
pos = value.get("pos")
|
||||||
|
season = value.get("season")
|
||||||
|
episode = value.get("episode")
|
||||||
|
if isinstance(pos, int) and pos > 0:
|
||||||
|
cleaned[slug] = (
|
||||||
|
pos * 1000,
|
||||||
|
season if isinstance(season, int) else None,
|
||||||
|
episode if isinstance(episode, int) else None,
|
||||||
|
)
|
||||||
|
elif pos == 0 and isinstance(season, int) and isinstance(episode, int):
|
||||||
|
# Series episode boundary marker (previous episode completed).
|
||||||
|
cleaned[slug] = (0, season, episode)
|
||||||
|
|
||||||
|
episodes = value.get("episodes")
|
||||||
|
if not isinstance(episodes, dict):
|
||||||
|
continue
|
||||||
|
for key, watch in episodes.items():
|
||||||
|
match = re.fullmatch(r"S(\d+)E(\d+)", str(key))
|
||||||
|
if not match or not isinstance(watch, dict):
|
||||||
|
continue
|
||||||
|
ep_pos = watch.get("pos")
|
||||||
|
if watch.get("done") or not isinstance(ep_pos, int) or ep_pos <= 0:
|
||||||
|
continue
|
||||||
|
episode_positions[slug, int(match.group(1)), int(match.group(2))] = (
|
||||||
|
ep_pos * 1000
|
||||||
|
)
|
||||||
|
return cleaned, episode_positions
|
||||||
|
|
||||||
|
|
||||||
|
def _post_resume_position(
|
||||||
|
backend_url: str,
|
||||||
|
root_id: str,
|
||||||
|
file_path: str,
|
||||||
|
pos: int | None,
|
||||||
|
) -> bool:
|
||||||
|
body = json.dumps({
|
||||||
|
"root_id": root_id,
|
||||||
|
"file_path": file_path,
|
||||||
|
"pos": pos,
|
||||||
|
}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url=f"{backend_url}/api/meta/playback-state",
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=2):
|
||||||
|
return True
|
||||||
|
except OSError, TimeoutError, urllib.error.URLError:
|
||||||
|
logger.warning("Failed to post playback-state update for %s", file_path)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _media_file_key(
|
||||||
|
mapping: dict[str, str], file_key: str, torrent: object, media_key: str
|
||||||
|
) -> None:
|
||||||
|
"""Map both the raw file key and its expanded playable path to a media key."""
|
||||||
|
mapping[_normalize_media_path(file_key)] = media_key
|
||||||
|
playable_file = torrent.get("playable_file") if isinstance(torrent, dict) else None
|
||||||
|
expanded = _expand_playable_file(
|
||||||
|
file_key, playable_file if isinstance(playable_file, str) else None
|
||||||
|
)
|
||||||
|
mapping[_normalize_media_path(expanded)] = media_key
|
||||||
|
|
||||||
|
|
||||||
|
def _split_media_key(media_key: str) -> tuple[str, int | None, int | None]:
|
||||||
|
"""Split a media key into (slug, season_number, episode_number)."""
|
||||||
|
slug, separator, ep_ref = media_key.partition("#")
|
||||||
|
if not separator:
|
||||||
|
return slug, None, None
|
||||||
|
match = re.fullmatch(r"S(\d+)E(\d+)", ep_ref)
|
||||||
|
if not match:
|
||||||
|
return slug, None, None
|
||||||
|
return slug, int(match.group(1)), int(match.group(2))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_media_key_map(index_path: Path) -> dict[str, str]:
|
||||||
|
"""Map normalized playable file paths to media keys.
|
||||||
|
|
||||||
|
Movies map to their movie id; series episodes map to
|
||||||
|
"<series_id>#S<season>E<episode>" so episode switches are detected while
|
||||||
|
the backend keeps a single continue point per series.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
except OSError, TypeError, json.JSONDecodeError:
|
except OSError, TypeError, json.JSONDecodeError:
|
||||||
return _default_playback_state()
|
return {}
|
||||||
|
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return _default_playback_state()
|
return {}
|
||||||
|
|
||||||
current = raw.get("current")
|
mapping: dict[str, str] = {}
|
||||||
resume_positions = raw.get("resume_positions")
|
|
||||||
normalized: dict[str, object] = {
|
|
||||||
"current": current if isinstance(current, dict) else None,
|
|
||||||
"resume_positions": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
if isinstance(resume_positions, dict):
|
movies = raw.get("movies")
|
||||||
cleaned_positions: dict[str, int] = {}
|
if isinstance(movies, dict):
|
||||||
for key, value in resume_positions.items():
|
for movie_id, movie in movies.items():
|
||||||
if isinstance(key, str) and isinstance(value, (int, float)):
|
if not isinstance(movie_id, str) or not isinstance(movie, dict):
|
||||||
cleaned_positions[key] = max(0, int(value))
|
continue
|
||||||
normalized["resume_positions"] = cleaned_positions
|
files = movie.get("files")
|
||||||
|
if not isinstance(files, dict):
|
||||||
|
continue
|
||||||
|
for file_key, torrent in files.items():
|
||||||
|
if not isinstance(file_key, str):
|
||||||
|
continue
|
||||||
|
_media_file_key(mapping, file_key, torrent, movie_id)
|
||||||
|
|
||||||
return normalized
|
series = raw.get("series")
|
||||||
|
if isinstance(series, dict):
|
||||||
|
for series_id, show in series.items():
|
||||||
|
if not isinstance(series_id, str) or not isinstance(show, dict):
|
||||||
|
continue
|
||||||
|
seasons = show.get("seasons")
|
||||||
|
if not isinstance(seasons, list):
|
||||||
|
continue
|
||||||
|
for season in seasons:
|
||||||
|
if not isinstance(season, dict):
|
||||||
|
continue
|
||||||
|
season_number = season.get("season_number")
|
||||||
|
episodes = season.get("episodes")
|
||||||
|
if not isinstance(season_number, int) or not isinstance(episodes, list):
|
||||||
|
continue
|
||||||
|
for episode in episodes:
|
||||||
|
if not isinstance(episode, dict):
|
||||||
|
continue
|
||||||
|
episode_number = episode.get("episode_number")
|
||||||
|
files = episode.get("files")
|
||||||
|
if not isinstance(episode_number, int) or not isinstance(
|
||||||
|
files, dict
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
media_key = f"{series_id}#S{season_number}E{episode_number}"
|
||||||
|
for file_key, torrent in files.items():
|
||||||
|
if not isinstance(file_key, str):
|
||||||
|
continue
|
||||||
|
_media_file_key(mapping, file_key, torrent, media_key)
|
||||||
|
|
||||||
|
return mapping
|
||||||
def _save_playback_state(path: Path, state: dict[str, object]) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
|
|
||||||
tmp_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
|
|
||||||
tmp_path.replace(path)
|
|
||||||
|
|
||||||
|
|
||||||
def _media_key_for_filepath(
|
def _media_key_for_filepath(
|
||||||
filepath: str, roots: list[Path]
|
filepath: str, roots: dict[str, Path]
|
||||||
) -> tuple[str, Path] | None:
|
) -> tuple[str | None, str, str] | None:
|
||||||
"""Resolve a filepath to a (relative_key, matched_root) tuple."""
|
"""Resolve a filepath to a (media_key, root_id, relative_key) tuple."""
|
||||||
for root in roots:
|
for root_id, root in roots.items():
|
||||||
try:
|
try:
|
||||||
relative = Path(filepath).resolve().relative_to(root.resolve())
|
relative = Path(filepath).resolve().relative_to(root.resolve())
|
||||||
return relative.as_posix(), root
|
relative_key = relative.as_posix()
|
||||||
|
index_path = root / ".mediahive" / "index.json"
|
||||||
|
media_key = _load_media_key_map(index_path).get(
|
||||||
|
_normalize_media_path(relative_key)
|
||||||
|
)
|
||||||
|
return media_key, root_id, relative_key
|
||||||
except OSError, RuntimeError, ValueError:
|
except OSError, RuntimeError, ValueError:
|
||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _should_clear_resume(position_ms: int, duration_ms: int) -> bool:
|
def _should_clear_resume(position_ms: int, duration_ms: int) -> bool:
|
||||||
if position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS:
|
|
||||||
return True
|
|
||||||
if duration_ms <= 0:
|
if duration_ms <= 0:
|
||||||
return False
|
return False
|
||||||
return duration_ms - position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS
|
return duration_ms - position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS
|
||||||
@@ -248,7 +421,7 @@ def _mpcbe_fetch_status() -> tuple[str, int, int, int] | None:
|
|||||||
|
|
||||||
|
|
||||||
def _start_gamepad_remote(
|
def _start_gamepad_remote(
|
||||||
stop_event: threading.Event, roots: list[Path]
|
stop_event: threading.Event, roots: dict[str, Path], backend_url: str
|
||||||
) -> threading.Thread:
|
) -> threading.Thread:
|
||||||
"""Start background XInput polling and send mapped commands to MPC-BE."""
|
"""Start background XInput polling and send mapped commands to MPC-BE."""
|
||||||
get_state = _load_xinput_get_state()
|
get_state = _load_xinput_get_state()
|
||||||
@@ -278,18 +451,11 @@ def _start_gamepad_remote(
|
|||||||
status_updated_at = 0.0
|
status_updated_at = 0.0
|
||||||
status_miss_count = 0
|
status_miss_count = 0
|
||||||
|
|
||||||
# Use the first root's playback state path as primary
|
playback_state = _default_playback_state()
|
||||||
primary_root = roots[0] if roots else Path.cwd()
|
resume_positions, episode_positions = _fetch_resume_positions(backend_url)
|
||||||
playback_state_path = primary_root / ".mediahive" / "playback-state.json"
|
|
||||||
playback_state = _load_playback_state(playback_state_path)
|
|
||||||
resume_positions = playback_state["resume_positions"]
|
|
||||||
if not isinstance(resume_positions, dict):
|
|
||||||
resume_positions = {}
|
|
||||||
playback_state["resume_positions"] = resume_positions
|
|
||||||
if playback_state.get("current") is not None:
|
|
||||||
playback_state["current"] = None
|
|
||||||
_save_playback_state(playback_state_path, playback_state)
|
|
||||||
tracked_media_key: str | None = None
|
tracked_media_key: str | None = None
|
||||||
|
tracked_root_id: str | None = None
|
||||||
|
tracked_relative_path = ""
|
||||||
tracked_filepath = ""
|
tracked_filepath = ""
|
||||||
resume_applied_for_key: str | None = None
|
resume_applied_for_key: str | None = None
|
||||||
last_playback_state_flush_at = 0.0
|
last_playback_state_flush_at = 0.0
|
||||||
@@ -302,12 +468,11 @@ def _start_gamepad_remote(
|
|||||||
request_pool.submit(_seek_mpcbe_to_position, position_ms)
|
request_pool.submit(_seek_mpcbe_to_position, position_ms)
|
||||||
)
|
)
|
||||||
|
|
||||||
def flush_playback_state() -> None:
|
|
||||||
_save_playback_state(playback_state_path, playback_state)
|
|
||||||
|
|
||||||
def clear_tracked_current(*, clear_resume_applied: bool) -> None:
|
def clear_tracked_current(*, clear_resume_applied: bool) -> None:
|
||||||
nonlocal \
|
nonlocal \
|
||||||
tracked_media_key, \
|
tracked_media_key, \
|
||||||
|
tracked_root_id, \
|
||||||
|
tracked_relative_path, \
|
||||||
tracked_filepath, \
|
tracked_filepath, \
|
||||||
last_playback_state_flush_at, \
|
last_playback_state_flush_at, \
|
||||||
resume_applied_for_key
|
resume_applied_for_key
|
||||||
@@ -316,38 +481,72 @@ def _start_gamepad_remote(
|
|||||||
resume_applied_for_key = None
|
resume_applied_for_key = None
|
||||||
return
|
return
|
||||||
tracked_media_key = None
|
tracked_media_key = None
|
||||||
|
tracked_root_id = None
|
||||||
|
tracked_relative_path = ""
|
||||||
tracked_filepath = ""
|
tracked_filepath = ""
|
||||||
playback_state["current"] = None
|
playback_state["current"] = None
|
||||||
last_playback_state_flush_at = 0.0
|
last_playback_state_flush_at = 0.0
|
||||||
if clear_resume_applied:
|
if clear_resume_applied:
|
||||||
resume_applied_for_key = None
|
resume_applied_for_key = None
|
||||||
flush_playback_state()
|
|
||||||
|
|
||||||
def finalize_tracked_current() -> None:
|
def finalize_tracked_current() -> None:
|
||||||
nonlocal \
|
nonlocal \
|
||||||
tracked_media_key, \
|
tracked_media_key, \
|
||||||
|
tracked_root_id, \
|
||||||
|
tracked_relative_path, \
|
||||||
tracked_filepath, \
|
tracked_filepath, \
|
||||||
resume_applied_for_key, \
|
resume_applied_for_key, \
|
||||||
last_playback_state_flush_at
|
last_playback_state_flush_at
|
||||||
if tracked_media_key is None:
|
if tracked_media_key is None:
|
||||||
if playback_state.get("current") is not None:
|
if playback_state.get("current") is not None:
|
||||||
playback_state["current"] = None
|
playback_state["current"] = None
|
||||||
flush_playback_state()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
position_ms = player_position_ms or 0
|
position_ms = player_position_ms or 0
|
||||||
duration_ms = player_duration_ms or 0
|
duration_ms = player_duration_ms or 0
|
||||||
|
tracked_slug, tracked_season, tracked_episode = _split_media_key(
|
||||||
|
tracked_media_key
|
||||||
|
)
|
||||||
if _should_clear_resume(position_ms, duration_ms):
|
if _should_clear_resume(position_ms, duration_ms):
|
||||||
resume_positions.pop(tracked_media_key, None)
|
resume_positions.pop(tracked_slug, None)
|
||||||
|
if tracked_season is not None and tracked_episode is not None:
|
||||||
|
episode_positions.pop(
|
||||||
|
(tracked_slug, tracked_season, tracked_episode), None
|
||||||
|
)
|
||||||
|
if tracked_root_id and tracked_relative_path:
|
||||||
|
_post_resume_position(
|
||||||
|
backend_url, tracked_root_id, tracked_relative_path, None
|
||||||
|
)
|
||||||
|
elif position_ms < MPC_BE_RESUME_MIN_WATCH_MS:
|
||||||
|
# Peeks and brief seeks are not true progress; keep the previous
|
||||||
|
# saved resume position.
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
resume_positions[tracked_media_key] = position_ms
|
position_seconds = max(0, position_ms // 1000)
|
||||||
|
resume_positions[tracked_slug] = (
|
||||||
|
position_ms,
|
||||||
|
tracked_season,
|
||||||
|
tracked_episode,
|
||||||
|
)
|
||||||
|
if tracked_season is not None and tracked_episode is not None:
|
||||||
|
episode_positions[tracked_slug, tracked_season, tracked_episode] = (
|
||||||
|
position_ms
|
||||||
|
)
|
||||||
|
if tracked_root_id and tracked_relative_path:
|
||||||
|
_post_resume_position(
|
||||||
|
backend_url,
|
||||||
|
tracked_root_id,
|
||||||
|
tracked_relative_path,
|
||||||
|
position_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
tracked_media_key = None
|
tracked_media_key = None
|
||||||
|
tracked_root_id = None
|
||||||
|
tracked_relative_path = ""
|
||||||
tracked_filepath = ""
|
tracked_filepath = ""
|
||||||
playback_state["current"] = None
|
playback_state["current"] = None
|
||||||
resume_applied_for_key = None
|
resume_applied_for_key = None
|
||||||
last_playback_state_flush_at = 0.0
|
last_playback_state_flush_at = 0.0
|
||||||
flush_playback_state()
|
|
||||||
|
|
||||||
def persist_tracked_current(now: float, *, force: bool = False) -> None:
|
def persist_tracked_current(now: float, *, force: bool = False) -> None:
|
||||||
nonlocal last_playback_state_flush_at
|
nonlocal last_playback_state_flush_at
|
||||||
@@ -367,7 +566,6 @@ def _start_gamepad_remote(
|
|||||||
"updated_at": int(time.time()),
|
"updated_at": int(time.time()),
|
||||||
}
|
}
|
||||||
last_playback_state_flush_at = now
|
last_playback_state_flush_at = now
|
||||||
flush_playback_state()
|
|
||||||
|
|
||||||
def maybe_apply_resume(now: float) -> None:
|
def maybe_apply_resume(now: float) -> None:
|
||||||
nonlocal player_position_ms, resume_applied_for_key
|
nonlocal player_position_ms, resume_applied_for_key
|
||||||
@@ -376,8 +574,36 @@ def _start_gamepad_remote(
|
|||||||
if resume_applied_for_key == tracked_media_key:
|
if resume_applied_for_key == tracked_media_key:
|
||||||
return
|
return
|
||||||
|
|
||||||
saved_position = resume_positions.get(tracked_media_key)
|
tracked_slug, tracked_season, tracked_episode = _split_media_key(
|
||||||
if not isinstance(saved_position, int):
|
tracked_media_key
|
||||||
|
)
|
||||||
|
if tracked_season is not None and tracked_episode is not None:
|
||||||
|
# Series: the episode's own saved position wins; fall back to the
|
||||||
|
# series continue point when it points at this very episode.
|
||||||
|
saved_position = episode_positions.get((
|
||||||
|
tracked_slug,
|
||||||
|
tracked_season,
|
||||||
|
tracked_episode,
|
||||||
|
))
|
||||||
|
if saved_position is None:
|
||||||
|
saved = resume_positions.get(tracked_slug)
|
||||||
|
if saved is None or (saved[1], saved[2]) != (
|
||||||
|
tracked_season,
|
||||||
|
tracked_episode,
|
||||||
|
):
|
||||||
|
# The series continue point belongs to a different episode.
|
||||||
|
resume_applied_for_key = tracked_media_key
|
||||||
|
return
|
||||||
|
saved_position = saved[0]
|
||||||
|
else:
|
||||||
|
saved = resume_positions.get(tracked_slug)
|
||||||
|
if saved is None:
|
||||||
|
resume_applied_for_key = tracked_media_key
|
||||||
|
return
|
||||||
|
saved_position = saved[0]
|
||||||
|
|
||||||
|
if saved_position <= 0:
|
||||||
|
# Episode boundary marker (previous episode completed): start at 0.
|
||||||
resume_applied_for_key = tracked_media_key
|
resume_applied_for_key = tracked_media_key
|
||||||
return
|
return
|
||||||
if player_position_ms is None or player_duration_ms is None:
|
if player_position_ms is None or player_duration_ms is None:
|
||||||
@@ -386,9 +612,12 @@ def _start_gamepad_remote(
|
|||||||
resume_applied_for_key = tracked_media_key
|
resume_applied_for_key = tracked_media_key
|
||||||
return
|
return
|
||||||
if _should_clear_resume(saved_position, player_duration_ms):
|
if _should_clear_resume(saved_position, player_duration_ms):
|
||||||
resume_positions.pop(tracked_media_key, None)
|
resume_positions.pop(tracked_slug, None)
|
||||||
|
if tracked_season is not None and tracked_episode is not None:
|
||||||
|
episode_positions.pop(
|
||||||
|
(tracked_slug, tracked_season, tracked_episode), None
|
||||||
|
)
|
||||||
resume_applied_for_key = tracked_media_key
|
resume_applied_for_key = tracked_media_key
|
||||||
flush_playback_state()
|
|
||||||
return
|
return
|
||||||
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
|
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
|
||||||
return
|
return
|
||||||
@@ -409,6 +638,8 @@ def _start_gamepad_remote(
|
|||||||
status_updated_at, \
|
status_updated_at, \
|
||||||
status_miss_count, \
|
status_miss_count, \
|
||||||
tracked_media_key, \
|
tracked_media_key, \
|
||||||
|
tracked_root_id, \
|
||||||
|
tracked_relative_path, \
|
||||||
tracked_filepath, \
|
tracked_filepath, \
|
||||||
resume_applied_for_key
|
resume_applied_for_key
|
||||||
if status_future is None or not status_future.done():
|
if status_future is None or not status_future.done():
|
||||||
@@ -437,6 +668,8 @@ def _start_gamepad_remote(
|
|||||||
filepath, position_ms, duration_ms, state = status
|
filepath, position_ms, duration_ms, state = status
|
||||||
resolved = _media_key_for_filepath(filepath, roots) if filepath else None
|
resolved = _media_key_for_filepath(filepath, roots) if filepath else None
|
||||||
media_key = resolved[0] if resolved else None
|
media_key = resolved[0] if resolved else None
|
||||||
|
root_id = resolved[1] if resolved else None
|
||||||
|
relative_path = resolved[2] if resolved else ""
|
||||||
|
|
||||||
if tracked_media_key is not None and media_key != tracked_media_key:
|
if tracked_media_key is not None and media_key != tracked_media_key:
|
||||||
finalize_tracked_current()
|
finalize_tracked_current()
|
||||||
@@ -445,6 +678,8 @@ def _start_gamepad_remote(
|
|||||||
clear_tracked_current(clear_resume_applied=True)
|
clear_tracked_current(clear_resume_applied=True)
|
||||||
elif tracked_media_key != media_key:
|
elif tracked_media_key != media_key:
|
||||||
tracked_media_key = media_key
|
tracked_media_key = media_key
|
||||||
|
tracked_root_id = root_id
|
||||||
|
tracked_relative_path = relative_path
|
||||||
tracked_filepath = filepath
|
tracked_filepath = filepath
|
||||||
resume_applied_for_key = None
|
resume_applied_for_key = None
|
||||||
|
|
||||||
@@ -605,30 +840,79 @@ def _start_gamepad_remote(
|
|||||||
return thread
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
def _rotate_and_open_log(log_path: Path):
|
||||||
|
"""Rotate mediahive.log to .log.1 and open a fresh log file.
|
||||||
|
|
||||||
|
Raises OSError when a previous MediaHive instance still holds the file
|
||||||
|
open (Windows forbids renaming a file that is open without delete
|
||||||
|
sharing) — callers treat that as "previous instance not dead yet".
|
||||||
|
"""
|
||||||
|
prev = log_path.with_suffix(".log.1")
|
||||||
|
if log_path.exists():
|
||||||
|
if prev.exists():
|
||||||
|
prev.unlink()
|
||||||
|
log_path.rename(prev)
|
||||||
|
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
|
||||||
|
return os.fdopen(fd, "w", encoding="utf-8", buffering=1) # line-buffered
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_previous_instance(log_path: Path, timeout: float = 15.0):
|
||||||
|
"""Show a waiting notice while a previous MediaHive instance exits.
|
||||||
|
|
||||||
|
Returns an open log file handle, or None on timeout.
|
||||||
|
"""
|
||||||
|
result: list = []
|
||||||
|
window = webview.create_window(
|
||||||
|
"MediaHive", html=_WAIT_HTML, width=520, height=280, resizable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def poll() -> None:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
result.append(_rotate_and_open_log(log_path))
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.5)
|
||||||
|
window.destroy()
|
||||||
|
|
||||||
|
webview.start(func=poll, icon=_icon_path(), **_webview_start_kwargs())
|
||||||
|
return result[0] if result else None
|
||||||
|
|
||||||
|
|
||||||
def _setup_logging() -> Path:
|
def _setup_logging() -> Path:
|
||||||
"""Redirect stdout/stderr and configure logging to a file in %APPDATA%/mediahive/.
|
"""Redirect stdout/stderr and configure logging to a file in the platform log dir.
|
||||||
|
|
||||||
In a PyInstaller --windowed build there is no console, so any print() or
|
In a PyInstaller --windowed build there is no console, so any print() or
|
||||||
unhandled exception traceback would be lost. This ensures everything ends
|
unhandled exception traceback would be lost. This ensures everything ends
|
||||||
up in a persistent log file the user can send for bug reports.
|
up in a persistent log file the user can send for bug reports.
|
||||||
Returns the path to the log file.
|
Returns the path to the log file.
|
||||||
"""
|
"""
|
||||||
from mediahive.config import config_dir
|
log_directory = log_dir()
|
||||||
|
log_directory.mkdir(parents=True, exist_ok=True)
|
||||||
log_dir = config_dir()
|
log_path = log_directory / "mediahive.log"
|
||||||
log_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
log_path = log_dir / "mediahive.log"
|
|
||||||
|
|
||||||
# Rotate: keep previous run as .log.1
|
|
||||||
prev = log_path.with_suffix(".log.1")
|
|
||||||
if log_path.exists():
|
|
||||||
if prev.exists():
|
|
||||||
prev.unlink()
|
|
||||||
log_path.rename(prev)
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_file = _rotate_and_open_log(log_path)
|
||||||
|
except OSError:
|
||||||
|
# A previous instance still holds the log file. It is usually on its
|
||||||
|
# way out — give it a couple of seconds silently first.
|
||||||
|
log_file = None
|
||||||
|
deadline = time.monotonic() + 2.0
|
||||||
|
while log_file is None and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.25)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
log_file = _rotate_and_open_log(log_path)
|
||||||
|
if log_file is None:
|
||||||
|
log_file = _wait_for_previous_instance(log_path)
|
||||||
|
if log_file is None:
|
||||||
|
# Never fail startup over logging: fall back to a per-process file.
|
||||||
|
log_path = log_directory / f"mediahive-{os.getpid()}.log"
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
|
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
|
||||||
log_file = os.fdopen(fd, "w", encoding="utf-8", buffering=1) # line-buffered
|
log_file = os.fdopen(fd, "w", encoding="utf-8", buffering=1)
|
||||||
|
|
||||||
|
if log_file is not None:
|
||||||
# Redirect raw stdout/stderr so print() and tracebacks go to the file
|
# Redirect raw stdout/stderr so print() and tracebacks go to the file
|
||||||
sys.stdout = log_file
|
sys.stdout = log_file
|
||||||
sys.stderr = log_file
|
sys.stderr = log_file
|
||||||
@@ -660,6 +944,70 @@ _SETUP_HTML = """<!DOCTYPE html>
|
|||||||
<div><h1>MediaHive</h1><p>Choose a folder that contains your media…</p></div>
|
<div><h1>MediaHive</h1><p>Choose a folder that contains your media…</p></div>
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|
||||||
|
# Shown when a previous instance is still shutting down.
|
||||||
|
_WAIT_HTML = """<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background: #141414; color: #fff;
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
height: 100vh; text-align: center; }
|
||||||
|
h1 { font-size: 2rem; color: #e50914; margin-bottom: .5rem; }
|
||||||
|
p { color: #aaa; }
|
||||||
|
</style></head><body>
|
||||||
|
<div><h1>MediaHive</h1>
|
||||||
|
<p>Waiting for the previous MediaHive instance to finish exiting…</p></div>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _show_fatal_error(exc: BaseException) -> None:
|
||||||
|
"""Show an unhandled exception as a TraceRite HTML page in a webview.
|
||||||
|
|
||||||
|
Frozen --windowed builds otherwise surface crashes only as PyInstaller's
|
||||||
|
plain-text error dialog (or nothing at all).
|
||||||
|
"""
|
||||||
|
fragment = str(html_traceback(exc))
|
||||||
|
page = (
|
||||||
|
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
||||||
|
"<title>MediaHive — Error</title></head>"
|
||||||
|
f"<body style='margin:1.5rem'>{fragment}</body></html>"
|
||||||
|
)
|
||||||
|
webview.create_window("MediaHive — Error", html=page, width=1100, height=750)
|
||||||
|
webview.start(icon=_icon_path(), **_webview_start_kwargs())
|
||||||
|
|
||||||
|
|
||||||
|
def _velopack_startup() -> None:
|
||||||
|
"""Handle Velopack install/update/uninstall hooks and pending updates.
|
||||||
|
|
||||||
|
Must be the first thing at startup: when Velopack launches the app with
|
||||||
|
--veloapp-* hook arguments (during install/update/uninstall), run()
|
||||||
|
executes the hook and exits the process, so the GUI never starts.
|
||||||
|
Applies downloaded-but-pending updates. No-op in development and
|
||||||
|
portable-ZIP runs.
|
||||||
|
"""
|
||||||
|
velopack.App().run()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_for_updates() -> None:
|
||||||
|
"""Download available updates in the background (unless disabled in config).
|
||||||
|
|
||||||
|
Downloaded updates are applied automatically by Velopack on the next app
|
||||||
|
start (via _velopack_startup), so the running session is never
|
||||||
|
interrupted. Not a Velopack install (dev/portable) and network failures
|
||||||
|
are expected and skipped quietly.
|
||||||
|
"""
|
||||||
|
updater.check_and_download()
|
||||||
|
|
||||||
|
|
||||||
|
def gui_main() -> None:
|
||||||
|
"""Run the GUI, rendering fatal exceptions as a TraceRite HTML window."""
|
||||||
|
_velopack_startup()
|
||||||
|
try:
|
||||||
|
winmain()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Fatal error")
|
||||||
|
_show_fatal_error(exc)
|
||||||
|
|
||||||
|
|
||||||
class JsApi:
|
class JsApi:
|
||||||
"""Python methods exposed to the frontend via window.pywebview.api."""
|
"""Python methods exposed to the frontend via window.pywebview.api."""
|
||||||
@@ -674,6 +1022,16 @@ class JsApi:
|
|||||||
result = self._window.create_file_dialog(webview.FOLDER_DIALOG)
|
result = self._window.create_file_dialog(webview.FOLDER_DIALOG)
|
||||||
return result[0] if result else None
|
return result[0] if result else None
|
||||||
|
|
||||||
|
def exit_app(self) -> None:
|
||||||
|
"""Close the window, shutting the app down (like the OS close button)."""
|
||||||
|
if self._window:
|
||||||
|
self._window.destroy()
|
||||||
|
|
||||||
|
def toggle_fullscreen(self) -> None:
|
||||||
|
"""Switch between fullscreen and windowed mode in place."""
|
||||||
|
if self._window:
|
||||||
|
self._window.toggle_fullscreen()
|
||||||
|
|
||||||
def set_volume(self, x: float) -> None:
|
def set_volume(self, x: float) -> None:
|
||||||
"""Set system master volume from slider position ``x`` (0.0 .. 1.5)."""
|
"""Set system master volume from slider position ``x`` (0.0 .. 1.5)."""
|
||||||
# Clamp to the platform's maximum so the slider never exceeds what
|
# Clamp to the platform's maximum so the slider never exceeds what
|
||||||
@@ -802,8 +1160,26 @@ def _configure_windows_event_loop_policy() -> None:
|
|||||||
asyncio.set_event_loop_policy(policy_cls())
|
asyncio.set_event_loop_policy(policy_cls())
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_mark_of_the_web() -> None:
|
||||||
|
"""Remove Zone.Identifier streams from bundled DLLs (frozen Windows only).
|
||||||
|
|
||||||
|
Files extracted from a downloaded ZIP carry the Mark-of-the-Web, and the
|
||||||
|
.NET Framework CLR refuses to load such assemblies — pythonnet then fails
|
||||||
|
with "Failed to resolve Python.Runtime.Loader.Initialize from
|
||||||
|
.../Python.Runtime.dll". Strip the mark from the bundled DLLs before
|
||||||
|
pywebview loads the CLR.
|
||||||
|
"""
|
||||||
|
if not getattr(sys, "frozen", False) or sys.platform != "win32":
|
||||||
|
return
|
||||||
|
meipass = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||||
|
for dll in meipass.rglob("*.dll"):
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
Path(f"{dll}:Zone.Identifier").unlink()
|
||||||
|
|
||||||
|
|
||||||
def winmain() -> None:
|
def winmain() -> None:
|
||||||
_configure_windows_event_loop_policy()
|
_configure_windows_event_loop_policy()
|
||||||
|
_strip_mark_of_the_web()
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="MediaHive")
|
parser = argparse.ArgumentParser(description="MediaHive")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -811,7 +1187,7 @@ def winmain() -> None:
|
|||||||
nargs="?",
|
nargs="?",
|
||||||
help="Path to the media folder (default: saved config or initial setup dialog)",
|
help="Path to the media folder (default: saved config or initial setup dialog)",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args, _unknown = parser.parse_known_args()
|
||||||
|
|
||||||
_prepend_meipass_to_path()
|
_prepend_meipass_to_path()
|
||||||
|
|
||||||
@@ -830,10 +1206,6 @@ def winmain() -> None:
|
|||||||
initial_roots[name] = p.as_posix()
|
initial_roots[name] = p.as_posix()
|
||||||
elif cfg.roots:
|
elif cfg.roots:
|
||||||
initial_roots = cfg.roots
|
initial_roots = cfg.roots
|
||||||
elif cfg.media_folder:
|
|
||||||
p = _normalize_media_root_input(cfg.media_folder)
|
|
||||||
name = p.name or "media"
|
|
||||||
initial_roots[name] = p.as_posix()
|
|
||||||
|
|
||||||
if not initial_roots:
|
if not initial_roots:
|
||||||
folder = _run_initial_setup()
|
folder = _run_initial_setup()
|
||||||
@@ -847,23 +1219,43 @@ def winmain() -> None:
|
|||||||
if cfg.roots != initial_roots:
|
if cfg.roots != initial_roots:
|
||||||
save_config(msgspec.structs.replace(cfg, roots=initial_roots))
|
save_config(msgspec.structs.replace(cfg, roots=initial_roots))
|
||||||
|
|
||||||
# Pass roots to the server via env (validation deferred to server startup)
|
# Pass roots to the in-process server via the shared env config
|
||||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(initial_roots)
|
# (validation deferred to server startup)
|
||||||
|
config.roots = initial_roots
|
||||||
|
|
||||||
backend_port = _reserve_backend_port()
|
backend_port = _reserve_backend_port()
|
||||||
backend_url = f"http://{BACKEND_HOST}:{backend_port}"
|
backend_url = f"http://{BACKEND_HOST}:{backend_port}"
|
||||||
os.environ["MEDIAHIVE_BACKEND_URL"] = backend_url
|
os.environ["MEDIAHIVE_BACKEND_URL"] = backend_url
|
||||||
|
|
||||||
# Run the FastAPI backend on a background thread
|
# Startup banner, same as fastapi-vue's server.run() prints in CLI mode.
|
||||||
config = uvicorn.Config(
|
# Goes to stderr, which frozen builds redirect to the log file.
|
||||||
|
try:
|
||||||
|
version = importlib.metadata.version("mediahive")
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
version = "dev"
|
||||||
|
print_box(f"MediaHive {version}\n{backend_url}")
|
||||||
|
|
||||||
|
# Run the FastAPI backend on a background thread. fastapi-vue's patched
|
||||||
|
# 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(
|
||||||
"mediahive.server:app",
|
"mediahive.server:app",
|
||||||
host=BACKEND_HOST,
|
host=BACKEND_HOST,
|
||||||
port=backend_port,
|
port=backend_port,
|
||||||
loop="asyncio",
|
loop="asyncio",
|
||||||
log_level="warning",
|
server_header=False,
|
||||||
timeout_graceful_shutdown=0,
|
timeout_graceful_shutdown=0,
|
||||||
|
access_log=False, # fastapi-vue's middleware replaces uvicorn's
|
||||||
|
log_config=log_config,
|
||||||
)
|
)
|
||||||
server = uvicorn.Server(config)
|
server = uvicorn.Server(uvicorn_config)
|
||||||
backend_thread = threading.Thread(
|
backend_thread = threading.Thread(
|
||||||
target=server.run, daemon=True, name="mediahive-backend"
|
target=server.run, daemon=True, name="mediahive-backend"
|
||||||
)
|
)
|
||||||
@@ -872,7 +1264,7 @@ def winmain() -> None:
|
|||||||
def _activate_initial_roots() -> None:
|
def _activate_initial_roots() -> None:
|
||||||
body = json.dumps({"roots": initial_roots}).encode("utf-8")
|
body = json.dumps({"roots": initial_roots}).encode("utf-8")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url=f"{backend_url}/api/roots",
|
url=f"{backend_url}/api/config/roots",
|
||||||
data=body,
|
data=body,
|
||||||
method="PUT",
|
method="PUT",
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
@@ -887,6 +1279,10 @@ def winmain() -> None:
|
|||||||
server.should_exit = True
|
server.should_exit = True
|
||||||
raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s")
|
raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s")
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=_check_for_updates, daemon=True, name="mediahive-update-check"
|
||||||
|
).start()
|
||||||
|
|
||||||
api = JsApi()
|
api = JsApi()
|
||||||
logger.info("Configured pywebview backend: %s", _selected_webview_backend())
|
logger.info("Configured pywebview backend: %s", _selected_webview_backend())
|
||||||
window = webview.create_window(
|
window = webview.create_window(
|
||||||
@@ -900,7 +1296,7 @@ def winmain() -> None:
|
|||||||
poll_thread: threading.Thread | None = None
|
poll_thread: threading.Thread | None = None
|
||||||
|
|
||||||
# Resolve all root paths for gamepad remote
|
# Resolve all root paths for gamepad remote
|
||||||
gamepad_roots = [Path(p) for p in initial_roots.values()]
|
gamepad_roots = {root_id: Path(p) for root_id, p in initial_roots.items()}
|
||||||
|
|
||||||
def on_shown() -> None:
|
def on_shown() -> None:
|
||||||
api._window = window
|
api._window = window
|
||||||
@@ -913,7 +1309,7 @@ def winmain() -> None:
|
|||||||
|
|
||||||
nonlocal poll_thread
|
nonlocal poll_thread
|
||||||
if poll_thread is None and _supports_gamepad_remote():
|
if poll_thread is None and _supports_gamepad_remote():
|
||||||
poll_thread = _start_gamepad_remote(poll_stop, gamepad_roots)
|
poll_thread = _start_gamepad_remote(poll_stop, gamepad_roots, backend_url)
|
||||||
|
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=_activate_initial_roots,
|
target=_activate_initial_roots,
|
||||||
@@ -956,4 +1352,4 @@ def winmain() -> None:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
winmain()
|
gui_main()
|
||||||
|
|||||||
+53
-44
@@ -8,11 +8,12 @@ dependencies = [
|
|||||||
"aiofiles>=25.1.0",
|
"aiofiles>=25.1.0",
|
||||||
"aiopathlib>=0.6.0",
|
"aiopathlib>=0.6.0",
|
||||||
"bencodepy>=0.9.5",
|
"bencodepy>=0.9.5",
|
||||||
"fastapi-vue>=0.5.2",
|
"fastapi-vue~=1.7.2",
|
||||||
"fastapi[standard]>=0.128.0",
|
"fastapi[standard]>=0.128.0",
|
||||||
"httpx[http2]>=0.28.1",
|
"httpx[http2]>=0.28.1",
|
||||||
"msgspec>=0.19",
|
"msgspec>=0.19",
|
||||||
"parse-torrent-title>=2.8.1",
|
"parse-torrent-title>=2.8.1",
|
||||||
|
"platformdirs>=4.0",
|
||||||
"tomli-w>=1.2.0",
|
"tomli-w>=1.2.0",
|
||||||
"uvicorn[standard]>=0.40.0",
|
"uvicorn[standard]>=0.40.0",
|
||||||
]
|
]
|
||||||
@@ -36,7 +37,11 @@ artifacts = ["mediahive/frontend-build"]
|
|||||||
only-packages = true
|
only-packages = true
|
||||||
|
|
||||||
[tool.hatch.build.targets.sdist.hooks.custom]
|
[tool.hatch.build.targets.sdist.hooks.custom]
|
||||||
path = "scripts/fastapi-vue/build-frontend.py"
|
path = "scripts/fastapi-vue/buildhook.py"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist.force-include]
|
||||||
|
"scripts/fastapi-vue/buildhook.py" = "scripts/fastapi-vue/buildhook.py"
|
||||||
|
"scripts/fastapi-vue/buildutil.py" = "scripts/fastapi-vue/buildutil.py"
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
package = true
|
package = true
|
||||||
@@ -46,17 +51,21 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
gui = [
|
gui = [
|
||||||
"pywebview>=6.2.1; platform_system != 'Darwin'",
|
# pywebview's qt extra is Qt6-only (QtPy + PyQt6 + PyQt6-WebEngine);
|
||||||
"pywebview[qt5]>=6.2.1; platform_system == 'Darwin'",
|
# Qt5 would come from its separate qt5 extra, which we do not use.
|
||||||
"qtpy>=2.4.1; platform_system == 'Darwin'",
|
"pywebview[qt]>=6.2.1; platform_system != 'Windows'",
|
||||||
"PyQt5>=5.15.11; platform_system == 'Darwin'",
|
"pywebview>=6.2.1; platform_system == 'Windows'",
|
||||||
|
"velopack>=1.2",
|
||||||
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
|
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
|
||||||
"pyinstaller>=6.0",
|
"pyinstaller>=6.0",
|
||||||
|
# scripts/guibuild.py reads the version with it (same logic as hatch-vcs).
|
||||||
|
"setuptools_scm>=8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"lefthook>=2.1.14",
|
||||||
"ruff>=0.15.14",
|
"ruff>=0.15.14",
|
||||||
"setuptools-scm>=8",
|
"setuptools-scm>=8",
|
||||||
]
|
]
|
||||||
@@ -67,52 +76,52 @@ preview = true
|
|||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["ALL"]
|
select = ["ALL"]
|
||||||
ignore = [
|
ignore = [
|
||||||
"D203",
|
"incorrect-blank-line-before-class",
|
||||||
"D213",
|
"multi-line-summary-second-line",
|
||||||
"DOC201",
|
"docstring-missing-returns",
|
||||||
"COM812",
|
"missing-trailing-comma",
|
||||||
"T201",
|
"print",
|
||||||
"EM",
|
"EM",
|
||||||
"TC",
|
"TC",
|
||||||
"TRY003",
|
"raise-vanilla-args",
|
||||||
"S",
|
"S",
|
||||||
"CPY001",
|
"missing-copyright-notice",
|
||||||
"PLR",
|
"PLR",
|
||||||
"PLW",
|
"PLW",
|
||||||
# TEMP suppressions - revisit and remove after focused cleanup passes.
|
# TEMP suppressions - revisit and remove after focused cleanup passes.
|
||||||
"C901",
|
"complex-structure",
|
||||||
"DOC501",
|
"docstring-missing-exception",
|
||||||
"ANN201",
|
"missing-return-type-undocumented-public-function",
|
||||||
"D103",
|
"undocumented-public-function",
|
||||||
"FBT001",
|
"boolean-type-hint-positional-argument",
|
||||||
"ANN001",
|
"missing-type-function-argument",
|
||||||
"D102",
|
"undocumented-public-method",
|
||||||
"TRY300",
|
"try-consider-else",
|
||||||
"D107",
|
"undocumented-public-init",
|
||||||
"ANN202",
|
"missing-return-type-private-function",
|
||||||
"B904",
|
"raise-without-from-inside-except",
|
||||||
"ASYNC220",
|
"create-subprocess-in-async-function",
|
||||||
"E501",
|
"line-too-long",
|
||||||
"INP001",
|
"implicit-namespace-package",
|
||||||
"FBT002",
|
"boolean-default-value-positional-argument",
|
||||||
"PLC0415",
|
"import-outside-top-level",
|
||||||
"D101",
|
"undocumented-public-class",
|
||||||
"RUF006",
|
"asyncio-dangling-task",
|
||||||
"SLF001",
|
"private-member-access",
|
||||||
"ASYNC240",
|
"blocking-path-method-in-async-function",
|
||||||
"N801",
|
"invalid-class-name",
|
||||||
"SIM102",
|
"collapsible-if",
|
||||||
"DTZ005",
|
"call-datetime-now-without-tzinfo",
|
||||||
"RUF034",
|
"useless-if-else",
|
||||||
"D415",
|
"missing-terminal-punctuation",
|
||||||
"D400",
|
"missing-trailing-period",
|
||||||
"ANN401",
|
"any-type",
|
||||||
# Allow en-dash in docstrings (used for list formatting)
|
# Allow en-dash in docstrings (used for list formatting)
|
||||||
"RUF002",
|
"ambiguous-unicode-character-docstring",
|
||||||
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
|
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
|
||||||
"N806",
|
"non-lowercase-variable-in-function",
|
||||||
# Allow inline comments that describe output formats
|
# Allow inline comments that describe output formats
|
||||||
"ERA001",
|
"commented-out-code",
|
||||||
# Allow unused local variables in ctypes COM boilerplate
|
# Allow unused local variables in ctypes COM boilerplate
|
||||||
"F841",
|
"unused-variable",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""RTorrent Client - Communicate with rtorrent via XMLRPC over SCGI socket."""
|
|
||||||
|
|
||||||
import socket
|
|
||||||
import xmlrpc.client
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class SCGITransport(xmlrpc.client.Transport):
|
|
||||||
"""SCGI transport for communicating with rtorrent via Unix socket."""
|
|
||||||
|
|
||||||
def __init__(self, socket_path: str) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.socket_path = socket_path
|
|
||||||
|
|
||||||
def single_request(self, _host, _handler, request_body, _verbose=False):
|
|
||||||
# Create SCGI request
|
|
||||||
headers = f"CONTENT_LENGTH\x00{len(request_body)}\x00SCGI\x001\x00"
|
|
||||||
request = f"{len(headers)}:{headers},{request_body.decode('utf-8')}"
|
|
||||||
|
|
||||||
# Connect to socket
|
|
||||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
sock.connect(self.socket_path)
|
|
||||||
sock.send(request.encode("utf-8"))
|
|
||||||
|
|
||||||
# Read response
|
|
||||||
response = b""
|
|
||||||
while True:
|
|
||||||
data = sock.recv(4096)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
response += data
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
# Parse response - skip HTTP headers
|
|
||||||
if b"\r\n\r\n" in response:
|
|
||||||
response = response.split(b"\r\n\r\n", 1)[1]
|
|
||||||
|
|
||||||
return self.parse_response(response)
|
|
||||||
|
|
||||||
def parse_response(self, response_body):
|
|
||||||
p, u = xmlrpc.client.getparser()
|
|
||||||
p.feed(response_body)
|
|
||||||
p.close()
|
|
||||||
return u.close()
|
|
||||||
|
|
||||||
|
|
||||||
class RTorrentClient:
|
|
||||||
"""Client for communicating with rtorrent via XMLRPC over SCGI socket."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, socket_path: str = "/home/user/rtorrent/.session/rpc.socket"
|
|
||||||
) -> None:
|
|
||||||
self.socket_path = socket_path
|
|
||||||
transport = SCGITransport(socket_path)
|
|
||||||
self.proxy = xmlrpc.client.ServerProxy(
|
|
||||||
"http://localhost/RPC2", transport=transport
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_loaded_hashes(self) -> set[str]:
|
|
||||||
"""Get set of info hashes for all currently loaded torrents."""
|
|
||||||
try:
|
|
||||||
downloads = self.proxy.download_list("")
|
|
||||||
return {h.upper() for h in downloads}
|
|
||||||
except (OSError, xmlrpc.client.Error) as e:
|
|
||||||
print(f"Error getting loaded torrents: {e}")
|
|
||||||
return set()
|
|
||||||
|
|
||||||
def load_torrent(self, torrent_path: Path, download_dir: Path) -> bool:
|
|
||||||
"""Load a torrent file and set its download directory.
|
|
||||||
|
|
||||||
Uses load.start_verbose to load and immediately start/hash-check.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
torrent_path: Path to the .torrent file
|
|
||||||
download_dir: Directory where the data already exists
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# load.start_verbose with d.directory.set to specify download location
|
|
||||||
# This will hash-check existing files instead of re-downloading
|
|
||||||
self.proxy.load.start_verbose(
|
|
||||||
"", str(torrent_path), f'd.directory.set="{download_dir}"'
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except (OSError, xmlrpc.client.Error) as e:
|
|
||||||
print(f"Error loading torrent {torrent_path}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_torrent_info(self, info_hash: str) -> dict | None:
|
|
||||||
"""Get info about a loaded torrent."""
|
|
||||||
try:
|
|
||||||
name = self.proxy.d.name(info_hash)
|
|
||||||
message = self.proxy.d.message(info_hash)
|
|
||||||
tied_file = self.proxy.d.tied_to_file(info_hash)
|
|
||||||
directory = self.proxy.d.directory(info_hash)
|
|
||||||
base_path = self.proxy.d.base_path(info_hash) # Actual data path
|
|
||||||
is_multi_file = self.proxy.d.is_multi_file(info_hash)
|
|
||||||
return {
|
|
||||||
"hash": info_hash,
|
|
||||||
"name": name,
|
|
||||||
"message": message,
|
|
||||||
"tied_file": tied_file,
|
|
||||||
"directory": directory,
|
|
||||||
"base_path": base_path, # Full path to data (file or folder)
|
|
||||||
"is_multi_file": is_multi_file,
|
|
||||||
}
|
|
||||||
except (OSError, xmlrpc.client.Error) as e:
|
|
||||||
print(f"Error getting torrent info for {info_hash}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_unregistered_torrents(self) -> list[dict]:
|
|
||||||
"""Find all torrents with 'unregistered' or 'not registered' tracker errors.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of torrent info dicts for torrents with registration errors
|
|
||||||
|
|
||||||
"""
|
|
||||||
unregistered = []
|
|
||||||
try:
|
|
||||||
hashes = self.proxy.download_list("")
|
|
||||||
for info_hash in hashes:
|
|
||||||
try:
|
|
||||||
message = self.proxy.d.message(info_hash)
|
|
||||||
if message and (
|
|
||||||
"unregistered" in message.lower()
|
|
||||||
or "not registered" in message.lower()
|
|
||||||
):
|
|
||||||
info = self.get_torrent_info(info_hash)
|
|
||||||
if info:
|
|
||||||
unregistered.append(info)
|
|
||||||
except OSError, xmlrpc.client.Error:
|
|
||||||
continue
|
|
||||||
except (OSError, xmlrpc.client.Error) as e:
|
|
||||||
print(f"Error scanning for unregistered torrents: {e}")
|
|
||||||
return unregistered
|
|
||||||
|
|
||||||
def remove_torrent(self, info_hash: str, delete_files: bool = False) -> bool:
|
|
||||||
"""Remove a torrent from rtorrent.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
info_hash: The info hash of the torrent to remove
|
|
||||||
delete_files: If True, also delete downloaded files (default: False)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if delete_files:
|
|
||||||
# This would delete the data - NOT what we want
|
|
||||||
self.proxy.d.erase(info_hash)
|
|
||||||
else:
|
|
||||||
# Just remove from rtorrent, keep files
|
|
||||||
self.proxy.d.erase(info_hash)
|
|
||||||
return True
|
|
||||||
except (OSError, xmlrpc.client.Error) as e:
|
|
||||||
print(f"Error removing torrent {info_hash}: {e}")
|
|
||||||
return False
|
|
||||||
+26
-8
@@ -5,12 +5,13 @@
|
|||||||
# pyinstaller --noconfirm --clean scripts/MediaHive.spec
|
# pyinstaller --noconfirm --clean scripts/MediaHive.spec
|
||||||
#
|
#
|
||||||
# Or use the build script (recommended—handles versioning and packaging):
|
# Or use the build script (recommended—handles versioning and packaging):
|
||||||
# uv run scripts/winbuild.py
|
# uv run scripts/guibuild.py
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import mediahive.winmain
|
import mediahive.winmain
|
||||||
import mediahive.server
|
import mediahive.server
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from PyInstaller.utils.hooks import collect_data_files
|
||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
|
|
||||||
@@ -19,7 +20,15 @@ _frontend_build = _pkg / "frontend-build"
|
|||||||
_logo_webp = _pkg / "assets" / "mediahive.webp"
|
_logo_webp = _pkg / "assets" / "mediahive.webp"
|
||||||
_icon_win = _pkg / "assets" / "mediahive.ico"
|
_icon_win = _pkg / "assets" / "mediahive.ico"
|
||||||
_icon_mac = _pkg / "assets" / "mediahive.icns"
|
_icon_mac = _pkg / "assets" / "mediahive.icns"
|
||||||
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
|
# ffmpeg staging lives in the persistent build cache (same logic as
|
||||||
|
# scripts/guibuild.py); fall back to the legacy build/ffmpeg location.
|
||||||
|
from platformdirs import user_cache_path
|
||||||
|
|
||||||
|
_tools_dir = (
|
||||||
|
user_cache_path("mediahive-build", appauthor=False, opinion=False) / "ffmpeg"
|
||||||
|
)
|
||||||
|
if not _tools_dir.exists():
|
||||||
|
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
|
||||||
_tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg"]
|
_tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg"]
|
||||||
|
|
||||||
_binaries = []
|
_binaries = []
|
||||||
@@ -32,6 +41,9 @@ _datas = [
|
|||||||
# Bundled Vue frontend served by the FastAPI backend
|
# Bundled Vue frontend served by the FastAPI backend
|
||||||
(str(_frontend_build), "mediahive/frontend-build"),
|
(str(_frontend_build), "mediahive/frontend-build"),
|
||||||
]
|
]
|
||||||
|
# tracerite (indirect dep) loads style.css / script.js at runtime; PyInstaller
|
||||||
|
# has no hook for it, so collect its package data explicitly
|
||||||
|
_datas += collect_data_files("tracerite")
|
||||||
if _icon_win.exists():
|
if _icon_win.exists():
|
||||||
_datas.append((str(_icon_win), "mediahive/assets"))
|
_datas.append((str(_icon_win), "mediahive/assets"))
|
||||||
if _icon_mac.exists():
|
if _icon_mac.exists():
|
||||||
@@ -76,11 +88,12 @@ if sys.platform == "darwin":
|
|||||||
# pywebview Qt backend selected dynamically via webview.start(gui="qt")
|
# pywebview Qt backend selected dynamically via webview.start(gui="qt")
|
||||||
"webview.platforms.qt",
|
"webview.platforms.qt",
|
||||||
"qtpy",
|
"qtpy",
|
||||||
"PyQt5",
|
"PyQt6",
|
||||||
"PyQt5.QtCore",
|
"PyQt6.QtCore",
|
||||||
"PyQt5.QtGui",
|
"PyQt6.QtGui",
|
||||||
"PyQt5.QtWidgets",
|
"PyQt6.QtWidgets",
|
||||||
"PyQt5.QtWebEngineWidgets",
|
"PyQt6.QtWebEngineCore",
|
||||||
|
"PyQt6.QtWebEngineWidgets",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,6 +132,11 @@ exe = EXE(
|
|||||||
windowed=True,
|
windowed=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# UPX breaks .NET assemblies: packing Python.Runtime.dll strips/corrupts its
|
||||||
|
# CLR metadata and pythonnet then fails with "Failed to resolve
|
||||||
|
# Python.Runtime.Loader.Initialize from .../Python.Runtime.dll".
|
||||||
|
_upx_exclude = ["Python.Runtime.dll"] if sys.platform == "win32" else []
|
||||||
|
|
||||||
coll = COLLECT(
|
coll = COLLECT(
|
||||||
exe,
|
exe,
|
||||||
a.binaries,
|
a.binaries,
|
||||||
@@ -126,7 +144,7 @@ coll = COLLECT(
|
|||||||
a.datas,
|
a.datas,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
upx=True,
|
||||||
upx_exclude=[],
|
upx_exclude=_upx_exclude,
|
||||||
name="MediaHive",
|
name="MediaHive",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+24
-12
@@ -5,13 +5,15 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import tracerite
|
||||||
|
|
||||||
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||||
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||||
from devutil import ( # type: ignore[import-not-found]
|
from devutil import (
|
||||||
ProcessGroup,
|
ProcessGroup,
|
||||||
check_ports_free,
|
check_ports_free,
|
||||||
logger,
|
logger,
|
||||||
@@ -22,11 +24,15 @@ from devutil import ( # type: ignore[import-not-found]
|
|||||||
|
|
||||||
DEFAULT_VITE_PORT = 8420
|
DEFAULT_VITE_PORT = 8420
|
||||||
DEFAULT_DEV_PORT = 8421
|
DEFAULT_DEV_PORT = 8421
|
||||||
|
HEALTH = "/api/health?from=devserver.py"
|
||||||
|
|
||||||
|
|
||||||
async def run_devserver(
|
async def run_devserver(
|
||||||
listen: str, backend: str, extra_args: list[str] | None = None
|
listen: str,
|
||||||
|
backend: str,
|
||||||
|
extra_args: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Start Vite and FastAPI dev servers with hot reload."""
|
||||||
reporoot = Path(__file__).parent.parent
|
reporoot = Path(__file__).parent.parent
|
||||||
front = reporoot / "frontend"
|
front = reporoot / "frontend"
|
||||||
if not (front / "package.json").exists():
|
if not (front / "package.json").exists():
|
||||||
@@ -36,20 +42,22 @@ async def run_devserver(
|
|||||||
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
|
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
|
||||||
backurl, mediahive = setup_cli("mediahive", backend, DEFAULT_DEV_PORT)
|
backurl, mediahive = setup_cli("mediahive", backend, DEFAULT_DEV_PORT)
|
||||||
|
|
||||||
# Tell the everyone by environment (vite proxy and backend devmode use these)
|
# Tell everyone via environment (vite proxy and backend devmode use these)
|
||||||
os.environ["MEDIAHIVE_VITE_URL"] = viteurl
|
os.environ["MEDIAHIVE_VITE_URL"] = viteurl
|
||||||
os.environ["MEDIAHIVE_BACKEND_URL"] = backurl
|
os.environ["MEDIAHIVE_BACKEND_URL"] = backurl
|
||||||
os.environ["MEDIAHIVE_DEV"] = "1"
|
os.environ["MEDIAHIVE_DEV"] = "1"
|
||||||
|
|
||||||
async with ProcessGroup() as pg:
|
async with ProcessGroup() as pg:
|
||||||
|
pg.create_task(check_ports_free(viteurl, backurl))
|
||||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||||
await check_ports_free(viteurl, backurl)
|
await pg.spawn(*mediahive, *(extra_args or []), vital=True)
|
||||||
await pg.spawn(*mediahive, *(extra_args or []))
|
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
||||||
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
|
await pg.spawn(*vite, cwd=front, vital=True)
|
||||||
await pg.spawn(*vite, cwd=front)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
"""Parse CLI arguments and run the devserver."""
|
||||||
|
tracerite.load()
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Run Vite and FastAPI development servers",
|
description="Run Vite and FastAPI development servers",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
@@ -58,21 +66,25 @@ def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-l",
|
"-l",
|
||||||
"--listen",
|
"--listen",
|
||||||
metavar="host:port",
|
metavar="addr",
|
||||||
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
metavar="host:port",
|
metavar="addr",
|
||||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||||
)
|
)
|
||||||
args, extra_args = parser.parse_known_args()
|
args, extra_args = parser.parse_known_args()
|
||||||
with suppress(KeyboardInterrupt):
|
try:
|
||||||
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
||||||
|
except* KeyboardInterrupt:
|
||||||
|
pass # user stopped the devserver: normal exit
|
||||||
|
except* subprocess.SubprocessError, RuntimeError:
|
||||||
|
raise SystemExit(1) from None # logged in devutil already; exit 1
|
||||||
|
|
||||||
|
|
||||||
HELP_EPILOG = """
|
HELP_EPILOG = """
|
||||||
scripts/devserver.py [args to mediahive]
|
Other options are forwarded to mediahive [args]
|
||||||
|
|
||||||
JS_RUNTIME environment variable can be used to select the JS runtime:
|
JS_RUNTIME environment variable can be used to select the JS runtime:
|
||||||
npm, deno, bun, or full path to the runtime executable (node maps to npm).
|
npm, deno, bun, or full path to the runtime executable (node maps to npm).
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
"""Hatch build hook for building Vue frontend during package build."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from hatchling.builders.hooks.plugin.interface import ( # type: ignore[import-not-found]
|
|
||||||
BuildHookInterface,
|
|
||||||
)
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
from buildutil import build
|
|
||||||
|
|
||||||
|
|
||||||
class CustomBuildHook(BuildHookInterface):
|
|
||||||
def initialize(self, version, build_data) -> None:
|
|
||||||
super().initialize(version, build_data)
|
|
||||||
build("frontend")
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Hatch build hook for building Vue frontend during package build."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from buildutil import build
|
||||||
|
|
||||||
|
|
||||||
|
class CustomBuildHook(BuildHookInterface): # type: ignore[misc]
|
||||||
|
"""Hatch build hook that builds Vue frontend during package build."""
|
||||||
|
|
||||||
|
def initialize(self, version: str, build_data: dict) -> None: # type: ignore[override]
|
||||||
|
"""Build frontend before package is built."""
|
||||||
|
super().initialize(version, build_data)
|
||||||
|
build("frontend")
|
||||||
@@ -7,21 +7,30 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
MIN_NODE_VERSION = 20
|
||||||
|
|
||||||
class _PrefixFormatter(logging.Formatter):
|
|
||||||
"""Formatter that adds prefix based on log level."""
|
class _Formatter(logging.Formatter):
|
||||||
|
"""Prefix formatter, intentionally different from fastapi_vue.logging.
|
||||||
|
|
||||||
|
INFO and below pass through unprefixed so messages can use their own
|
||||||
|
markings (>>>, ###); WARNING and above get an emoji prefix.
|
||||||
|
"""
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
if record.levelno >= logging.ERROR:
|
||||||
|
return f"🛑 {record.getMessage()}"
|
||||||
if record.levelno >= logging.WARNING:
|
if record.levelno >= logging.WARNING:
|
||||||
return f"⚠️ {record.getMessage()}"
|
return f"💣 {record.getMessage()}"
|
||||||
return record.getMessage()
|
return record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
_handler = logging.StreamHandler()
|
_handler = logging.StreamHandler()
|
||||||
_handler.setFormatter(_PrefixFormatter())
|
_handler.setFormatter(_Formatter())
|
||||||
logger = logging.getLogger("fastapi-vue")
|
logger = logging.getLogger("fastapi-vue")
|
||||||
logger.addHandler(_handler)
|
logger.addHandler(_handler)
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.propagate = False # own handler; do not double-print via a configured root
|
||||||
|
|
||||||
|
|
||||||
def _check_node_version(node_path: str) -> None:
|
def _check_node_version(node_path: str) -> None:
|
||||||
@@ -31,81 +40,118 @@ def _check_node_version(node_path: str) -> None:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[node_path, "--version"], capture_output=True, text=True, check=True
|
[node_path, "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
)
|
)
|
||||||
version_str = result.stdout.strip()
|
version_str = result.stdout.strip()
|
||||||
# Parse version like "v20.10.0" or "v18.17.1"
|
# Parse version like "v20.10.0" or "v18.17.1"
|
||||||
match = re.match(r"v(\d+)", version_str)
|
match = re.match(r"v(\d+)", version_str)
|
||||||
if match:
|
if match:
|
||||||
major_version = int(match.group(1))
|
major_version = int(match.group(1))
|
||||||
if major_version >= 20:
|
if major_version >= MIN_NODE_VERSION:
|
||||||
return
|
return
|
||||||
raise RuntimeError(
|
msg = f"Node.js {version_str} found, but v{MIN_NODE_VERSION}+ required"
|
||||||
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
raise RuntimeError(msg)
|
||||||
)
|
|
||||||
except subprocess.CalledProcessError, FileNotFoundError, ValueError:
|
except subprocess.CalledProcessError, FileNotFoundError, ValueError:
|
||||||
pass
|
pass
|
||||||
raise RuntimeError("Could not determine Node.js version")
|
msg = "Could not determine Node.js version"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_npm_runtime(tool: str) -> bool:
|
||||||
|
"""Validate npm runtime by checking Node.js version. Returns True if valid."""
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
_check_node_version(node_path)
|
||||||
|
except RuntimeError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _find_runtime_from_env(options: list[str]) -> tuple[str, str] | None:
|
||||||
|
"""Find runtime specified by JS_RUNTIME environment variable."""
|
||||||
|
js_runtime_env = os.environ.get("JS_RUNTIME")
|
||||||
|
if not js_runtime_env:
|
||||||
|
return None
|
||||||
|
|
||||||
|
js_runtime = js_runtime_env
|
||||||
|
js_path = Path(js_runtime)
|
||||||
|
runtime_name = js_path.name
|
||||||
|
|
||||||
|
# Map node to npm
|
||||||
|
if runtime_name == "node":
|
||||||
|
runtime_name = "npm"
|
||||||
|
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
||||||
|
|
||||||
|
for option in options:
|
||||||
|
if option != runtime_name and not runtime_name.startswith(option):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tool = shutil.which(js_runtime)
|
||||||
|
if tool is None:
|
||||||
|
msg = f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
if option == "npm":
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
msg = f"JS_RUNTIME={js_runtime_env}: node not found"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
_check_node_version(node_path)
|
||||||
|
|
||||||
|
return tool, option
|
||||||
|
|
||||||
|
msg = f"JS_RUNTIME={js_runtime_env} not recognized"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_detect_runtime(options: list[str]) -> tuple[str, str]:
|
||||||
|
"""Auto-detect JavaScript runtime from available options."""
|
||||||
|
node_version_error: RuntimeError | None = None
|
||||||
|
|
||||||
|
for option in options:
|
||||||
|
tool = shutil.which(option)
|
||||||
|
if not tool:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if option == "npm" and not _validate_npm_runtime(tool):
|
||||||
|
try:
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path:
|
||||||
|
_check_node_version(node_path)
|
||||||
|
except RuntimeError as e:
|
||||||
|
node_version_error = e
|
||||||
|
continue
|
||||||
|
|
||||||
|
return tool, option
|
||||||
|
|
||||||
|
if node_version_error:
|
||||||
|
raise node_version_error
|
||||||
|
msg = "Node.js (v20+), Deno or Bun is required but none was found"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
def find_js_runtime() -> tuple[str, str]:
|
def find_js_runtime() -> tuple[str, str]:
|
||||||
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||||
|
|
||||||
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||||
Raises JSRuntimeError if no suitable runtime is found.
|
Raises RuntimeError if no suitable runtime is found.
|
||||||
"""
|
"""
|
||||||
options = ["npm", "deno", "bun"]
|
options = ["npm", "deno", "bun"]
|
||||||
node_version_error: RuntimeError | None = None
|
|
||||||
|
|
||||||
# Check for JS_RUNTIME environment variable
|
# Check for JS_RUNTIME environment variable
|
||||||
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
if result := _find_runtime_from_env(options):
|
||||||
js_runtime = js_runtime_env
|
return result
|
||||||
js_path = Path(js_runtime)
|
|
||||||
runtime_name = js_path.name
|
|
||||||
# Map node to npm
|
|
||||||
if runtime_name == "node":
|
|
||||||
runtime_name = "npm"
|
|
||||||
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
|
||||||
for option in options:
|
|
||||||
if option == runtime_name or runtime_name.startswith(option):
|
|
||||||
tool = shutil.which(js_runtime)
|
|
||||||
if tool is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
|
||||||
)
|
|
||||||
# Check Node.js version if using npm
|
|
||||||
if option == "npm":
|
|
||||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
|
||||||
if node_path is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"JS_RUNTIME={js_runtime_env}: node not found"
|
|
||||||
)
|
|
||||||
_check_node_version(node_path) # Raises on failure
|
|
||||||
return tool, option
|
|
||||||
raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized")
|
|
||||||
|
|
||||||
# Auto-detect
|
# Auto-detect
|
||||||
for option in options:
|
return _auto_detect_runtime(options)
|
||||||
if tool := shutil.which(option):
|
|
||||||
# Check Node.js version if using npm
|
|
||||||
if option == "npm":
|
|
||||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
|
||||||
if node_path is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
_check_node_version(node_path)
|
|
||||||
except RuntimeError as e:
|
|
||||||
node_version_error = e
|
|
||||||
continue # Try next runtime
|
|
||||||
return tool, option
|
|
||||||
|
|
||||||
# No runtime found - provide helpful error
|
|
||||||
if node_version_error:
|
|
||||||
raise node_version_error
|
|
||||||
raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found")
|
|
||||||
|
|
||||||
|
|
||||||
def find_build_tool():
|
def find_build_tool() -> tuple[list[str], list[str]]:
|
||||||
"""Find JavaScript runtime and construct install/build commands.
|
"""Find JavaScript runtime and construct install/build commands.
|
||||||
|
|
||||||
Returns (install_cmd, build_cmd) tuples of command lists.
|
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||||
@@ -143,9 +189,7 @@ def find_dev_tool() -> list[str]:
|
|||||||
|
|
||||||
if name == "bun":
|
if name == "bun":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Bun has a bug in WS proxying "
|
"Bun has a WS proxy bug (github.com/oven-sh/bun/issues/9882). Consider npm.",
|
||||||
"(https://github.com/oven-sh/bun/issues/9882). "
|
|
||||||
"Consider using npm instead."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return [tool, *dev_args[name]]
|
return [tool, *dev_args[name]]
|
||||||
@@ -178,9 +222,9 @@ def build(folder: str = "frontend") -> None:
|
|||||||
install_cmd, build_cmd = find_build_tool()
|
install_cmd, build_cmd = find_build_tool()
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1) from None
|
||||||
|
|
||||||
def run(cmd) -> None:
|
def run(cmd: list[str]) -> None:
|
||||||
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||||
logger.info("### %s", " ".join(display_cmd))
|
logger.info("### %s", " ".join(display_cmd))
|
||||||
subprocess.run(cmd, check=True, cwd=folder)
|
subprocess.run(cmd, check=True, cwd=folder)
|
||||||
@@ -190,4 +234,4 @@ def build(folder: str = "frontend") -> None:
|
|||||||
logger.info("")
|
logger.info("")
|
||||||
run(build_cmd)
|
run(build_cmd)
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
raise SystemExit(1)
|
raise SystemExit(1) from None
|
||||||
|
|||||||
+120
-113
@@ -1,153 +1,154 @@
|
|||||||
"""Utilities for the devserver script in the source repository.
|
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||||
|
|
||||||
Used only with development dependencies.
|
from __future__ import annotations
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Coroutine
|
from asyncio.subprocess import Process
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Self
|
from subprocess import CalledProcessError
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import httpx
|
|
||||||
from buildutil import find_dev_tool, find_install_tool, logger
|
from buildutil import find_dev_tool, find_install_tool, logger
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
|
||||||
class ProcessGroup:
|
|
||||||
"""Manage async subprocesses with automatic cleanup.
|
|
||||||
|
|
||||||
Acts like TaskGroup for processes.
|
class ProcessGroup(asyncio.TaskGroup):
|
||||||
"""
|
"""TaskGroup with structured ownership of async subprocesses."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, *, terminate_timeout: float = 10) -> None:
|
||||||
self._procs: list[asyncio.subprocess.Process] = []
|
"""Set the grace period before terminate() escalates to kill()."""
|
||||||
self._cmds: dict[int, str] = {} # pid -> command name
|
super().__init__()
|
||||||
|
self._terminate_timeout = terminate_timeout
|
||||||
|
self._cmds: dict[Process, tuple[str, ...]] = {}
|
||||||
|
|
||||||
async def spawn(
|
async def spawn(
|
||||||
self, *cmd: str, cwd: str | None = None
|
self, *cmd: str, cwd: str | None = None, vital: bool = False
|
||||||
) -> asyncio.subprocess.Process:
|
) -> Process:
|
||||||
"""Spawn a subprocess and track it."""
|
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
|
||||||
cmd_name = Path(cmd[0]).stem
|
|
||||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
|
||||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
|
||||||
self._procs.append(proc)
|
|
||||||
self._cmds[proc.pid] = cmd_name
|
|
||||||
return proc
|
|
||||||
|
|
||||||
async def wait(
|
async def run() -> None:
|
||||||
self, *waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any]
|
name = Path(cmd[0]).stem
|
||||||
) -> None:
|
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
|
||||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
|
||||||
|
|
||||||
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
|
||||||
returncode = await proc.wait()
|
|
||||||
if returncode != 0:
|
|
||||||
cmd_name = self._cmds.get(proc.pid, "unknown")
|
|
||||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
|
||||||
|
|
||||||
tasks = [
|
|
||||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
|
||||||
for w in waitables
|
|
||||||
]
|
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(*tasks)
|
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||||
except subprocess.CalledProcessError as e:
|
self._cmds[proc] = cmd
|
||||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
started.set_result(proc)
|
||||||
raise SystemExit(1) from None
|
except Exception as e: # ruff: ignore[blind-except]
|
||||||
|
started.set_exception(e)
|
||||||
async def __aenter__(self) -> Self:
|
|
||||||
"""Return this process group context manager."""
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(
|
|
||||||
self,
|
|
||||||
exc_type: type[BaseException] | None,
|
|
||||||
*_: object,
|
|
||||||
) -> None:
|
|
||||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
|
||||||
await self._cleanup(immediate=exc_type is not None)
|
|
||||||
|
|
||||||
async def _cleanup(self, immediate: bool = False) -> None:
|
|
||||||
running = [p for p in self._procs if p.returncode is None]
|
|
||||||
if not running:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not immediate:
|
|
||||||
# Wait for any one process to exit
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await asyncio.wait(
|
|
||||||
[asyncio.create_task(p.wait()) for p in running],
|
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Terminate remaining processes
|
|
||||||
for p in self._procs:
|
|
||||||
if p.returncode is None:
|
|
||||||
with suppress(ProcessLookupError):
|
|
||||||
p.terminate()
|
|
||||||
|
|
||||||
# Wait for all to finish (with overall timeout), shielded from cancellation
|
|
||||||
still_running = [p for p in self._procs if p.returncode is None]
|
|
||||||
if still_running:
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
try:
|
try:
|
||||||
await asyncio.shield(
|
returncode = await proc.wait()
|
||||||
asyncio.wait_for(
|
finally:
|
||||||
asyncio.gather(*[p.wait() for p in still_running]),
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except TimeoutError:
|
|
||||||
for p in self._procs:
|
|
||||||
if p.returncode is None:
|
|
||||||
with suppress(ProcessLookupError):
|
with suppress(ProcessLookupError):
|
||||||
p.kill()
|
proc.terminate()
|
||||||
await p.wait()
|
try:
|
||||||
|
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
|
if vital:
|
||||||
|
logger.warning("Vital process %s exited", name)
|
||||||
|
raise CalledProcessError(returncode, cmd)
|
||||||
|
|
||||||
|
started = asyncio.get_running_loop().create_future()
|
||||||
|
self.create_task(run())
|
||||||
|
return await asyncio.shield(started)
|
||||||
|
|
||||||
|
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
|
||||||
|
"""Wait concurrently and return results in argument order."""
|
||||||
|
|
||||||
|
async def task(w: Process | Awaitable) -> Any:
|
||||||
|
if not isinstance(w, Process):
|
||||||
|
return await w
|
||||||
|
if retcode := await w.wait():
|
||||||
|
cmd = self._cmds[w]
|
||||||
|
logger.warning(
|
||||||
|
"Process %s exited with status %d", Path(cmd[0]).stem, retcode
|
||||||
|
)
|
||||||
|
raise CalledProcessError(retcode, cmd)
|
||||||
|
return retcode
|
||||||
|
|
||||||
|
async with asyncio.TaskGroup() as group:
|
||||||
|
tasks = [group.create_task(task(w)) for w in waitables]
|
||||||
|
|
||||||
|
return tuple(task.result() for task in tasks)
|
||||||
|
|
||||||
|
|
||||||
|
async def http_get_server(url: str, timeout: float) -> str | None: # ruff: ignore[async-function-with-timeout]
|
||||||
|
"""GET url with plain asyncio streams, return the response Server header.
|
||||||
|
|
||||||
|
Returns an empty string when the server responds without a Server header,
|
||||||
|
and None when the server is unreachable or doesn't answer in time.
|
||||||
|
"""
|
||||||
|
parts = urlsplit(url)
|
||||||
|
host = parts.hostname or "localhost"
|
||||||
|
port = parts.port or (443 if parts.scheme == "https" else 80)
|
||||||
|
path = parts.path or "/"
|
||||||
|
if parts.query:
|
||||||
|
path += f"?{parts.query}"
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(timeout):
|
||||||
|
reader, writer = await asyncio.open_connection(host, port)
|
||||||
|
try:
|
||||||
|
writer.write(f"GET {path} HTTP/1.0\r\nHost: {host}\r\n\r\n".encode())
|
||||||
|
await writer.drain()
|
||||||
|
data = await reader.readuntil(b"\r\n\r\n")
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
except OSError, EOFError, ValueError, TimeoutError:
|
||||||
|
return None
|
||||||
|
for line in data.decode(errors="replace").split("\r\n"):
|
||||||
|
if line.lower().startswith("server:"):
|
||||||
|
return line[7:].strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def check_ports_free(*urls: str) -> None:
|
async def check_ports_free(*urls: str) -> None:
|
||||||
"""Verify URLs are not responding (ports are free).
|
"""Verify URLs are not responding (ports are free).
|
||||||
|
|
||||||
Raise SystemExit if any endpoint responds.
|
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))
|
||||||
async def check(client: httpx.AsyncClient, url: str) -> None:
|
for url, server in zip(urls, servers, strict=True):
|
||||||
with suppress(httpx.RequestError):
|
if server is not None:
|
||||||
res = await client.get(url, timeout=0.1)
|
logger.error(
|
||||||
server = res.headers.get("server", "server")
|
"Conflicting %s already running at %s", server or "server", url
|
||||||
logger.warning("Conflicting %s already running at %s", server, url)
|
)
|
||||||
raise SystemExit(1)
|
raise RuntimeError(url)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
await asyncio.gather(*[check(client, url) for url in urls])
|
|
||||||
|
|
||||||
|
|
||||||
async def ready(url: str, path: str = "") -> None:
|
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||||
"""Wait for the server to be ready by polling an endpoint.
|
"""Wait for the server to be ready by polling an endpoint.
|
||||||
|
|
||||||
Raises SystemExit(1) if server doesn't start in time.
|
Use empty path to disable the check and make this return immediately.
|
||||||
|
Logs, then raises RuntimeError if the server doesn't start in time.
|
||||||
"""
|
"""
|
||||||
max_attempts = 50
|
if not path:
|
||||||
full_url = f"{url}{path}"
|
return
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
try:
|
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
||||||
await client.get(full_url, timeout=1.0)
|
logger.info("🟢 Backend ready!")
|
||||||
logger.info("✓ Backend ready!")
|
|
||||||
return
|
return
|
||||||
except httpx.RequestError:
|
|
||||||
if attempt == max_attempts - 1:
|
if attempt == max_attempts - 1:
|
||||||
logger.warning("Backend didn't start in time")
|
logger.error("Backend at %s didn't start in time", url)
|
||||||
raise SystemExit(1)
|
raise RuntimeError(url)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
def setup_vite(
|
def setup_vite(
|
||||||
endpoint: str, default_port: int = 5173
|
endpoint: str,
|
||||||
|
default_port: int = 5173,
|
||||||
) -> tuple[str, list[str], list[str]]:
|
) -> tuple[str, list[str], list[str]]:
|
||||||
"""Parse frontend endpoint and build commands.
|
"""Parse frontend endpoint and build commands.
|
||||||
|
|
||||||
@@ -173,7 +174,9 @@ def setup_vite(
|
|||||||
|
|
||||||
|
|
||||||
def setup_fastapi(
|
def setup_fastapi(
|
||||||
endpoint: str, module: str, default_port: int = 8000
|
endpoint: str,
|
||||||
|
module: str,
|
||||||
|
default_port: int = 8000,
|
||||||
) -> tuple[str, list[str]]:
|
) -> tuple[str, list[str]]:
|
||||||
"""Parse backend endpoint and build uvicorn command.
|
"""Parse backend endpoint and build uvicorn command.
|
||||||
|
|
||||||
@@ -205,7 +208,9 @@ def setup_fastapi(
|
|||||||
|
|
||||||
|
|
||||||
def setup_cli(
|
def setup_cli(
|
||||||
cli: str, endpoint: str, default_port: int = 8000
|
cli: str,
|
||||||
|
endpoint: str,
|
||||||
|
default_port: int = 8000,
|
||||||
) -> tuple[str, list[str]]:
|
) -> tuple[str, list[str]]:
|
||||||
"""Parse backend endpoint and build CLI command.
|
"""Parse backend endpoint and build CLI command.
|
||||||
|
|
||||||
@@ -221,5 +226,7 @@ def setup_cli(
|
|||||||
host = endpoints[0]["host"]
|
host = endpoints[0]["host"]
|
||||||
port = endpoints[0]["port"]
|
port = endpoints[0]["port"]
|
||||||
|
|
||||||
cmd = [cli, f"--listen={host}:{port}"]
|
# Run the package as a module with the current interpreter, instead of
|
||||||
|
# relying on a PATH-installed CLI entry point.
|
||||||
|
cmd = [sys.executable, "-m", cli, f"--listen={host}:{port}"]
|
||||||
return f"http://{host}:{port}", cmd
|
return f"http://{host}:{port}", cmd
|
||||||
|
|||||||
Regular → Executable
+374
-32
@@ -1,22 +1,36 @@
|
|||||||
"""Build the desktop GUI application and package it as a version-numbered ZIP.
|
#!/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:
|
Usage:
|
||||||
uv run scripts/winbuild.py
|
uv run scripts/guibuild.py
|
||||||
|
|
||||||
This runs in the project environment where dependencies
|
Self-contained: inline script dependencies above make uv resolve the
|
||||||
are available via pyproject.toml.
|
package (with the gui extra) plus this script's own direct imports.
|
||||||
|
|
||||||
This script:
|
This script:
|
||||||
1. Reads the version from pyproject.toml
|
1. Reads the version from pyproject.toml
|
||||||
2. Runs `uv build` to produce the wheel/sdist
|
2. Runs `uv build` to produce the wheel/sdist
|
||||||
3. On Windows, downloads the latest ffmpeg.exe for bundling
|
3. On Windows/macOS, downloads the ffmpeg binary for bundling
|
||||||
4. On macOS arm64, downloads a prebuilt ffmpeg binary for bundling
|
|
||||||
4. Builds MediaHive using PyInstaller
|
4. Builds MediaHive using PyInstaller
|
||||||
5. Creates a ZIP file with the version number
|
5. Packages with Velopack: Setup.exe (Windows), .pkg (macOS),
|
||||||
|
.AppImage (Linux), plus the update feed in build/velopack/
|
||||||
|
that release.py uploads for in-app auto-updates
|
||||||
|
6. On Windows, also creates a portable ZIP (no auto-updates)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -24,8 +38,10 @@ import sys
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
import setuptools_scm
|
import setuptools_scm
|
||||||
|
from platformdirs import user_cache_path
|
||||||
|
|
||||||
# BtbN automated builds always publish a 'latest' tag with this asset.
|
# BtbN automated builds always publish a 'latest' tag with this asset.
|
||||||
_FFMPEG_URL = (
|
_FFMPEG_URL = (
|
||||||
@@ -35,29 +51,86 @@ _FFMPEG_URL = (
|
|||||||
_MACOS_ARM64_TOOL_URLS = {
|
_MACOS_ARM64_TOOL_URLS = {
|
||||||
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
|
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
|
||||||
}
|
}
|
||||||
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
|
|
||||||
_REPO_ROOT = Path(__file__).parent.parent
|
_REPO_ROOT = Path(__file__).parent.parent
|
||||||
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
|
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
|
||||||
|
|
||||||
|
|
||||||
def _platform_zip_suffix() -> str:
|
def _build_cache_dir() -> Path:
|
||||||
machine = platform.machine().lower()
|
"""Return the persistent cross-build cache dir for downloaded tools (CI wipes build/)."""
|
||||||
arch = {
|
return user_cache_path("mediahive-build", appauthor=False, opinion=False)
|
||||||
"x86_64": "x64",
|
|
||||||
"amd64": "x64",
|
|
||||||
"arm64": "arm64",
|
|
||||||
"aarch64": "arm64",
|
|
||||||
}.get(machine, machine or "unknown")
|
|
||||||
|
|
||||||
|
|
||||||
|
_FFMPEG_STAGING = _build_cache_dir() / "ffmpeg"
|
||||||
|
|
||||||
|
# Velopack CLI (dotnet tool package). Runs on the machine's .NET runtime; the
|
||||||
|
# produced Setup.exe/Update.exe are native and need no runtime on end-user
|
||||||
|
# machines. Pin a version whose tools target an installed .NET major.
|
||||||
|
_VPK_VERSION = "1.2.158"
|
||||||
|
_VPK_URL = (
|
||||||
|
f"https://api.nuget.org/v3-flatcontainer/vpk/{_VPK_VERSION}"
|
||||||
|
f"/vpk.{_VPK_VERSION}.nupkg"
|
||||||
|
)
|
||||||
|
_VPK_STAGING = _build_cache_dir() / f"vpk-{_VPK_VERSION}"
|
||||||
|
|
||||||
|
|
||||||
|
class _Platform(NamedTuple):
|
||||||
|
"""Per-platform naming/packaging constants.
|
||||||
|
|
||||||
|
tag is the release artifact suffix. Only Windows keeps an arch marker
|
||||||
|
(win64); macOS builds are arm64-only and we ship one Linux flavor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tag: str # win64 / macos / linux
|
||||||
|
channel: str # Velopack update channel: win / osx / linux
|
||||||
|
rid: str # Velopack runtime id
|
||||||
|
dist_dir: str # PyInstaller output dir under build/
|
||||||
|
icon: str # file in mediahive/assets
|
||||||
|
main_exe: str
|
||||||
|
setup_ext: str
|
||||||
|
|
||||||
|
|
||||||
|
def _platform() -> _Platform:
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
return "win64"
|
return _Platform(
|
||||||
|
"win64",
|
||||||
|
"win",
|
||||||
|
"win-x64",
|
||||||
|
"MediaHive",
|
||||||
|
"mediahive.ico",
|
||||||
|
"MediaHive.exe",
|
||||||
|
".exe",
|
||||||
|
)
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
return f"macos-{arch}"
|
return _Platform(
|
||||||
return f"linux-{arch}"
|
"macos",
|
||||||
|
"osx",
|
||||||
|
"osx-arm64",
|
||||||
|
"MediaHive.app",
|
||||||
|
"mediahive.icns",
|
||||||
|
"MediaHive",
|
||||||
|
".pkg",
|
||||||
|
)
|
||||||
|
return _Platform(
|
||||||
|
"linux",
|
||||||
|
"linux",
|
||||||
|
"linux-x64",
|
||||||
|
"MediaHive",
|
||||||
|
"mediahive.png",
|
||||||
|
"MediaHive",
|
||||||
|
".AppImage",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_artifact_name() -> str:
|
||||||
|
"""Versionless name so releases/download/latest/<name> links stay valid."""
|
||||||
|
p = _platform()
|
||||||
|
# Windows keeps the -setup suffix: a bare .exe isn't self-explanatory.
|
||||||
|
suffix = "-setup" if sys.platform == "win32" else ""
|
||||||
|
return f"MediaHive-{p.tag}{suffix}{p.setup_ext}"
|
||||||
|
|
||||||
|
|
||||||
def fetch_ffmpeg() -> Path:
|
def fetch_ffmpeg() -> Path:
|
||||||
"""Download latest ffmpeg.exe from BtbN builds into build/ffmpeg/."""
|
"""Download latest ffmpeg.exe from BtbN builds into the persistent build cache."""
|
||||||
dest = _FFMPEG_STAGING / "ffmpeg.exe"
|
dest = _FFMPEG_STAGING / "ffmpeg.exe"
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
print(f"ffmpeg already staged at {dest}, skipping download.")
|
print(f"ffmpeg already staged at {dest}, skipping download.")
|
||||||
@@ -82,7 +155,7 @@ def fetch_ffmpeg() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def fetch_macos_arm64_binaries() -> dict[str, Path]:
|
def fetch_macos_arm64_binaries() -> dict[str, Path]:
|
||||||
"""Download prebuilt macOS arm64 ffmpeg binary into build/ffmpeg/."""
|
"""Download prebuilt macOS arm64 ffmpeg binary into the persistent build cache."""
|
||||||
if sys.platform != "darwin" or platform.machine().lower() not in {
|
if sys.platform != "darwin" or platform.machine().lower() not in {
|
||||||
"arm64",
|
"arm64",
|
||||||
"aarch64",
|
"aarch64",
|
||||||
@@ -179,6 +252,268 @@ def ensure_macos_icon() -> Path:
|
|||||||
return icon_icns
|
return icon_icns
|
||||||
|
|
||||||
|
|
||||||
|
_VPK_TFM = "net10.0"
|
||||||
|
_VPK_REQUIRED_DOTNET_MAJOR = int(re.fullmatch(r"net(\d+)\.0", _VPK_TFM).group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_vpk() -> Path:
|
||||||
|
"""Download the Velopack CLI package into the persistent build cache.
|
||||||
|
|
||||||
|
Returns the path to vpk.dll, runnable with `dotnet vpk.dll ...`.
|
||||||
|
"""
|
||||||
|
vpk_dll = _VPK_STAGING / "tools" / _VPK_TFM / "any" / "vpk.dll"
|
||||||
|
if vpk_dll.exists():
|
||||||
|
print(f"vpk already staged at {_VPK_STAGING}, skipping download.")
|
||||||
|
return vpk_dll
|
||||||
|
|
||||||
|
_VPK_STAGING.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"Downloading vpk from {_VPK_URL} ...")
|
||||||
|
with urllib.request.urlopen(_VPK_URL) as resp:
|
||||||
|
data = resp.read()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
zf.extractall(_VPK_STAGING)
|
||||||
|
|
||||||
|
if not vpk_dll.exists():
|
||||||
|
raise RuntimeError(f"vpk.dll not found in package at {vpk_dll}")
|
||||||
|
print(f"vpk staged at {_VPK_STAGING}")
|
||||||
|
return vpk_dll
|
||||||
|
|
||||||
|
|
||||||
|
def _dotnet_runtime_major(exe: Path) -> int | None:
|
||||||
|
"""Return the highest installed Microsoft.NETCore.App major version, or None."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
except OSError, subprocess.TimeoutExpired:
|
||||||
|
return None
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
majors = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2 and parts[0] == "Microsoft.NETCore.App":
|
||||||
|
try:
|
||||||
|
majors.append(int(parts[1].split(".")[0]))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return max(majors, default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_dotnet() -> str:
|
||||||
|
"""Resolve a system dotnet host able to run vpk (needs .NET >= 10).
|
||||||
|
|
||||||
|
The .NET SDK is a build prerequisite installed on the build machine —
|
||||||
|
downloading a runtime per build is slow and flaky. Several dotnet
|
||||||
|
installations may coexist (PATH may resolve to a runtime-only .NET 8
|
||||||
|
while scoop holds the SDK 10), so probe known locations and pick the
|
||||||
|
newest runtime rather than the first that runs.
|
||||||
|
"""
|
||||||
|
exe_name = "dotnet.exe" if sys.platform == "win32" else "dotnet"
|
||||||
|
candidates: list[Path] = []
|
||||||
|
root = os.environ.get("DOTNET_ROOT")
|
||||||
|
if root:
|
||||||
|
candidates.append(Path(root) / exe_name)
|
||||||
|
which = shutil.which("dotnet")
|
||||||
|
if which:
|
||||||
|
candidates.append(Path(which))
|
||||||
|
if sys.platform == "win32":
|
||||||
|
candidates += [
|
||||||
|
Path(r"C:\ProgramData\scoop\apps\dotnet-sdk\current") / exe_name,
|
||||||
|
Path(r"C:\Program Files\dotnet") / exe_name,
|
||||||
|
]
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
candidates += [
|
||||||
|
Path("/opt/homebrew/bin") / exe_name,
|
||||||
|
Path("/usr/local/share/dotnet") / exe_name,
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
candidates += [
|
||||||
|
Path("/usr/share/dotnet") / exe_name,
|
||||||
|
Path("/usr/lib/dotnet") / exe_name,
|
||||||
|
Path.home() / ".dotnet" / exe_name,
|
||||||
|
]
|
||||||
|
|
||||||
|
best: tuple[int, Path] | None = None
|
||||||
|
for exe in candidates:
|
||||||
|
if not exe.exists():
|
||||||
|
continue
|
||||||
|
major = _dotnet_runtime_major(exe)
|
||||||
|
if major is not None and (best is None or major > best[0]):
|
||||||
|
best = (major, exe)
|
||||||
|
|
||||||
|
if best is not None and best[0] >= _VPK_REQUIRED_DOTNET_MAJOR:
|
||||||
|
print(f"Using dotnet at {best[1]} (.NET {best[0]})")
|
||||||
|
return str(best[1])
|
||||||
|
|
||||||
|
found = f"newest found is .NET {best[0]} at {best[1]}" if best else "none found"
|
||||||
|
raise RuntimeError(
|
||||||
|
f"vpk requires Microsoft.NETCore.App >= {_VPK_REQUIRED_DOTNET_MAJOR} ({found}). "
|
||||||
|
"Install the current .NET SDK on this build machine "
|
||||||
|
"(Windows: `scoop install dotnet-sdk`; macOS: `brew install dotnet-sdk`; "
|
||||||
|
"Linux: distro `dotnet-sdk` package or the dotnet-install script)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_velopack(version: str) -> Path:
|
||||||
|
"""Build the Velopack installer/bundle for this platform.
|
||||||
|
|
||||||
|
Windows: per-user Setup.exe. macOS: .pkg installer. Linux: .AppImage.
|
||||||
|
Also produces the update feed (releases.<channel>.json, *.nupkg) in
|
||||||
|
build/velopack/ for release.py to upload — in-app auto-updates read it
|
||||||
|
from the Gitea release. Velopack installs carry no Mark-of-the-Web, so
|
||||||
|
the .NET CLR loads pythonnet/pywebview assemblies that it refuses from
|
||||||
|
a downloaded ZIP.
|
||||||
|
"""
|
||||||
|
plat = _platform()
|
||||||
|
dist_folder = _REPO_ROOT / "build" / plat.dist_dir
|
||||||
|
if not dist_folder.exists():
|
||||||
|
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||||
|
|
||||||
|
vpk_dll = fetch_vpk()
|
||||||
|
releases_dir = _REPO_ROOT / "build" / "velopack"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
fetch_dotnet(),
|
||||||
|
str(vpk_dll),
|
||||||
|
"pack",
|
||||||
|
"--packId",
|
||||||
|
"MediaHive",
|
||||||
|
"--packVersion",
|
||||||
|
version,
|
||||||
|
"--packDir",
|
||||||
|
str(dist_folder),
|
||||||
|
"--mainExe",
|
||||||
|
plat.main_exe,
|
||||||
|
"--packAuthors",
|
||||||
|
"MediaHive",
|
||||||
|
"--packTitle",
|
||||||
|
"MediaHive",
|
||||||
|
"--icon",
|
||||||
|
str(_ASSETS_DIR / plat.icon),
|
||||||
|
"--runtime",
|
||||||
|
plat.rid,
|
||||||
|
"--outputDir",
|
||||||
|
str(releases_dir),
|
||||||
|
]
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
cmd += ["--instWelcome", str(_ASSETS_DIR / "macos-installer-welcome.txt")]
|
||||||
|
print(f"Running: {' '.join(cmd)}")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, cwd=_REPO_ROOT, capture_output=True, text=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"vpk failed to start: {exc}") from exc
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"vpk pack failed with exit code {result.returncode}\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
setup = next(iter(sorted(releases_dir.glob(f"*Setup*{plat.setup_ext}"))), None)
|
||||||
|
if setup is None:
|
||||||
|
setup = next(iter(sorted(releases_dir.glob(f"*{plat.setup_ext}"))), None)
|
||||||
|
if setup is None:
|
||||||
|
raise RuntimeError(f"vpk produced no *{plat.setup_ext} in {releases_dir}")
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
force_macos_user_install(setup)
|
||||||
|
artifact = _REPO_ROOT / "build" / setup_artifact_name()
|
||||||
|
artifact.unlink(missing_ok=True)
|
||||||
|
setup.rename(artifact)
|
||||||
|
rename_feed_package(releases_dir, version, plat.channel)
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
|
||||||
|
def rename_feed_package(releases_dir: Path, version: str, channel: str) -> None:
|
||||||
|
"""Rename this platform's update-feed nupkg in place.
|
||||||
|
|
||||||
|
vpk hardcodes MediaHive-{ver}[-{channel}]-full.nupkg (Windows, the legacy
|
||||||
|
default channel, gets no marker). Rename all to the uniform
|
||||||
|
mediahive-{ver}-{channel}-full.nupkg: lowercase groups them with the
|
||||||
|
wheel/sdist below the capitalized user downloads on the release page,
|
||||||
|
and every platform carries its channel. releases.<channel>.json
|
||||||
|
references the filename, so patch it too.
|
||||||
|
"""
|
||||||
|
old_name = f"MediaHive-{version}-full.nupkg"
|
||||||
|
if not (releases_dir / old_name).exists():
|
||||||
|
old_name = f"MediaHive-{version}-{channel}-full.nupkg"
|
||||||
|
nupkg = releases_dir / old_name
|
||||||
|
if not nupkg.exists():
|
||||||
|
raise RuntimeError(f"vpk produced no {old_name} in {releases_dir}")
|
||||||
|
|
||||||
|
new_name = f"mediahive-{version}-{channel}-full.nupkg"
|
||||||
|
manifest = releases_dir / f"releases.{channel}.json"
|
||||||
|
text = manifest.read_text()
|
||||||
|
if old_name not in text:
|
||||||
|
raise RuntimeError(f"{manifest.name} does not reference {old_name}")
|
||||||
|
manifest.write_text(text.replace(old_name, new_name))
|
||||||
|
nupkg.rename(nupkg.with_name(new_name))
|
||||||
|
|
||||||
|
|
||||||
|
def force_macos_user_install(pkg: Path) -> None:
|
||||||
|
"""Restrict the Velopack-generated pkg to per-user installs (~/Applications).
|
||||||
|
|
||||||
|
Velopack hardcodes two install domains (currentUserHome + localSystem) in
|
||||||
|
the distribution XML. System installs land in /Applications, which the
|
||||||
|
user may not own — Velopack's UpdateMac then cannot replace the .app on
|
||||||
|
auto-update. With a single domain, macOS Installer skips the Destination
|
||||||
|
Select page and installs to ~/Applications without admin rights.
|
||||||
|
|
||||||
|
Also drops the `sudo -u "$USER"` prefix from Velopack's postinstall
|
||||||
|
script: under a per-user install the script already runs as the
|
||||||
|
installing user, and sudo would fail for lack of a tty.
|
||||||
|
|
||||||
|
NB: only ever use `pkgutil --expand` (which keeps component Payloads
|
||||||
|
archived) — `--expand-full` flattens payloads to loose files that
|
||||||
|
`--flatten` cannot repack, producing a pkg that "installs" nothing.
|
||||||
|
"""
|
||||||
|
expanded = pkg.with_name(pkg.stem + "-expanded")
|
||||||
|
shutil.rmtree(expanded, ignore_errors=True)
|
||||||
|
subprocess.run(["pkgutil", "--expand", str(pkg), str(expanded)], check=True)
|
||||||
|
|
||||||
|
dist_xml = expanded / "Distribution"
|
||||||
|
xml = dist_xml.read_text()
|
||||||
|
new_xml, count = re.subn(
|
||||||
|
r"<domains [^>]*/>",
|
||||||
|
'<domains enable_anywhere="false" enable_currentUserHome="true" enable_localSystem="false" />',
|
||||||
|
xml,
|
||||||
|
)
|
||||||
|
if count != 1:
|
||||||
|
raise RuntimeError("Unexpected distribution.xml: <domains> not found")
|
||||||
|
dist_xml.write_text(new_xml)
|
||||||
|
|
||||||
|
# Edit postinstall inside the component pkg. Depending on the macOS
|
||||||
|
# version, --expand leaves the component as an archived file (needs a
|
||||||
|
# nested expand/flatten round) or as an already-expanded directory.
|
||||||
|
components = list(expanded.glob("*.pkg"))
|
||||||
|
if len(components) != 1:
|
||||||
|
contents = sorted(p.name for p in expanded.iterdir())
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Unexpected pkg layout: components={components} in {contents}"
|
||||||
|
)
|
||||||
|
component = components[0]
|
||||||
|
if component.is_dir():
|
||||||
|
comp_dir = component
|
||||||
|
else:
|
||||||
|
comp_dir = expanded / (component.stem + "-component")
|
||||||
|
subprocess.run(
|
||||||
|
["pkgutil", "--expand", str(component), str(comp_dir)], check=True
|
||||||
|
)
|
||||||
|
postinstall = comp_dir / "Scripts" / "postinstall"
|
||||||
|
script = postinstall.read_text()
|
||||||
|
if 'sudo -u "$USER" ' not in script:
|
||||||
|
raise RuntimeError("Unexpected postinstall script: sudo prefix not found")
|
||||||
|
postinstall.write_text(script.replace('sudo -u "$USER" ', ""))
|
||||||
|
if comp_dir is not component:
|
||||||
|
subprocess.run(
|
||||||
|
["pkgutil", "--flatten", str(comp_dir), str(component)], check=True
|
||||||
|
)
|
||||||
|
shutil.rmtree(comp_dir)
|
||||||
|
|
||||||
|
subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True)
|
||||||
|
shutil.rmtree(expanded)
|
||||||
|
|
||||||
|
|
||||||
def read_version() -> str:
|
def read_version() -> str:
|
||||||
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
|
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
|
||||||
return setuptools_scm.get_version(root=str(_REPO_ROOT))
|
return setuptools_scm.get_version(root=str(_REPO_ROOT))
|
||||||
@@ -216,18 +551,18 @@ def build_executable() -> None:
|
|||||||
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
|
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
|
||||||
|
|
||||||
|
|
||||||
def create_zip(version: str) -> Path:
|
def create_portable_zip() -> Path:
|
||||||
"""Create a version-numbered ZIP file of the build/MediaHive folder."""
|
"""Create the Windows portable ZIP of the build/MediaHive folder.
|
||||||
repo_root = _REPO_ROOT
|
|
||||||
dist_folder = repo_root / "build" / "MediaHive"
|
|
||||||
|
|
||||||
|
Velopack-less plain-folder distribution for users who cannot or do not
|
||||||
|
want to run Setup.exe. No auto-updates; the app strips Mark-of-the-Web
|
||||||
|
from bundled DLLs at first run instead.
|
||||||
|
"""
|
||||||
|
dist_folder = _REPO_ROOT / "build" / "MediaHive"
|
||||||
if not dist_folder.exists():
|
if not dist_folder.exists():
|
||||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||||
|
|
||||||
zip_name = f"MediaHive-{version}-{_platform_zip_suffix()}.zip"
|
zip_path = _REPO_ROOT / "build" / "MediaHive-win64-portable.zip"
|
||||||
zip_path = repo_root / "build" / zip_name
|
|
||||||
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
print(f"Creating {zip_path}...")
|
print(f"Creating {zip_path}...")
|
||||||
shutil.make_archive(
|
shutil.make_archive(
|
||||||
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
|
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
|
||||||
@@ -238,6 +573,9 @@ def create_zip(version: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
# Windows consoles default to cp1252, which can't encode ✓/✗
|
||||||
|
sys.stdout.reconfigure(errors="replace")
|
||||||
|
sys.stderr.reconfigure(errors="replace")
|
||||||
try:
|
try:
|
||||||
version = read_version()
|
version = read_version()
|
||||||
print(f"MediaHive version: {version}")
|
print(f"MediaHive version: {version}")
|
||||||
@@ -254,10 +592,14 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
build_wheel()
|
build_wheel()
|
||||||
build_executable()
|
build_executable()
|
||||||
zip_path = create_zip(version)
|
|
||||||
|
|
||||||
print(f"✓ Built successfully: {zip_path}")
|
artifacts = [build_velopack(version)]
|
||||||
print(f" Size: {zip_path.stat().st_size / (1024 * 1024):.1f} MB")
|
if sys.platform == "win32":
|
||||||
|
artifacts.append(create_portable_zip())
|
||||||
|
|
||||||
|
for artifact_path in artifacts:
|
||||||
|
print(f"✓ Built successfully: {artifact_path}")
|
||||||
|
print(f" Size: {artifact_path.stat().st_size / (1024 * 1024):.1f} MB")
|
||||||
except (FileNotFoundError, OSError, RuntimeError, ValueError) as e:
|
except (FileNotFoundError, OSError, RuntimeError, ValueError) as e:
|
||||||
print(f"✗ Build failed: {e}", file=sys.stderr)
|
print(f"✗ Build failed: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
Regular → Executable
+111
-34
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env -S uv run
|
||||||
"""Publish a MediaHive release to Gitea.
|
"""Publish a MediaHive release to Gitea.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@@ -8,10 +9,15 @@ Reads from [project.urls] Repository in pyproject.toml.
|
|||||||
Token: GITEA_TOKEN environment variable
|
Token: GITEA_TOKEN environment variable
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Find clean-versioned ZIPs in build/ and matching dist/ wheels/sdists
|
1. Read the clean tag version via setuptools_scm, find platform artifacts
|
||||||
2. Abort if any dist files are missing for a found ZIP version
|
in build/ and matching dist/ wheels/sdists
|
||||||
3. Create a Gitea release for each version and upload all assets
|
2. Abort if any dist files are missing
|
||||||
|
3. Create a Gitea release for each version (or reuse the existing one
|
||||||
|
for the tag, skipping already-uploaded assets) and upload all assets
|
||||||
4. Remind the user to run: uv publish
|
4. Remind the user to run: uv publish
|
||||||
|
|
||||||
|
Parallel CI platform builds converge on one release per tag; pass --no-dist
|
||||||
|
on all but one platform so only it uploads the wheel/sdist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -23,6 +29,7 @@ from pathlib import Path
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import setuptools_scm
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).parent.parent
|
REPO_ROOT = Path(__file__).parent.parent
|
||||||
|
|
||||||
@@ -62,20 +69,31 @@ def load_token() -> str:
|
|||||||
# ZIP + dist helpers
|
# ZIP + dist helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-macos-arm64.zip, etc.
|
# Installer artifacts are versionless (MediaHive-win64-setup.exe,
|
||||||
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip
|
# MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage,
|
||||||
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.zip$")
|
# MediaHive-win64-portable.zip) so /releases/download/latest/<name> links
|
||||||
|
# stay valid. The version comes from setuptools_scm instead.
|
||||||
|
_ARTIFACT_RE = re.compile(
|
||||||
|
r"^MediaHive-(?!\d)[A-Za-z0-9._-]+\.(?:zip|dmg|exe|pkg|AppImage)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def find_releasable_zips() -> list[tuple[Path, str, str]]:
|
def read_version() -> str:
|
||||||
"""Return (path, version, platform_tag) for clean-versioned ZIPs in build/."""
|
"""Read version via setuptools_scm, refusing dev/dirty versions."""
|
||||||
|
version = setuptools_scm.get_version(root=str(REPO_ROOT))
|
||||||
|
if not re.fullmatch(r"\d+(?:\.\d+)*", version):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Refusing to release non-clean version {version!r}. Tag a release first."
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def find_releasable_artifacts() -> list[Path]:
|
||||||
|
"""Return platform artifact paths in build/."""
|
||||||
build_dir = REPO_ROOT / "build"
|
build_dir = REPO_ROOT / "build"
|
||||||
results = []
|
return [
|
||||||
for p in sorted(build_dir.glob("MediaHive-*.zip")):
|
p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)
|
||||||
m = _CLEAN_ZIP_RE.match(p.name)
|
]
|
||||||
if m:
|
|
||||||
results.append((p, m.group(1), m.group(2)))
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def find_dist_files(version: str) -> list[Path]:
|
def find_dist_files(version: str) -> list[Path]:
|
||||||
@@ -107,6 +125,23 @@ def find_dist_files(version: str) -> list[Path]:
|
|||||||
return [wheel, sdist]
|
return [wheel, sdist]
|
||||||
|
|
||||||
|
|
||||||
|
def find_velopack_feed_files() -> list[Path]:
|
||||||
|
"""Velopack update feed files produced by vpk pack in build/velopack/.
|
||||||
|
|
||||||
|
Only what the in-app updater (GiteaSource) reads from the latest
|
||||||
|
release: this channel's releases.<channel>.json index and the nupkg
|
||||||
|
payload it points to. The legacy RELEASES and assets.*.json manifests
|
||||||
|
(Squirrel compat / setup bootstrap) are not uploaded.
|
||||||
|
"""
|
||||||
|
releases_dir = REPO_ROOT / "build" / "velopack"
|
||||||
|
if not releases_dir.exists():
|
||||||
|
return []
|
||||||
|
files: list[Path] = []
|
||||||
|
for pattern in ("releases.*.json", "*.nupkg"):
|
||||||
|
files.extend(sorted(releases_dir.glob(pattern)))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Gitea API helpers
|
# Gitea API helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -116,6 +151,18 @@ def gitea_headers(token: str) -> dict:
|
|||||||
return {"Authorization": f"token {token}", "Accept": "application/json"}
|
return {"Authorization": f"token {token}", "Accept": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_release_by_tag(
|
||||||
|
client: httpx.Client, base_url: str, repo: str, tag: str
|
||||||
|
) -> dict | None:
|
||||||
|
"""Return the existing release for a tag, or None."""
|
||||||
|
url = f"{base_url}/api/v1/repos/{repo}/releases/tags/{tag}"
|
||||||
|
resp = client.get(url)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
def create_release(
|
def create_release(
|
||||||
client: httpx.Client,
|
client: httpx.Client,
|
||||||
base_url: str,
|
base_url: str,
|
||||||
@@ -124,8 +171,12 @@ def create_release(
|
|||||||
version: str,
|
version: str,
|
||||||
notes: str,
|
notes: str,
|
||||||
draft: bool,
|
draft: bool,
|
||||||
) -> int:
|
) -> tuple[int, set[str]]:
|
||||||
"""Create a Gitea release and return its id."""
|
"""Create a Gitea release, or reuse the existing one for the tag.
|
||||||
|
|
||||||
|
Returns (release_id, names of assets already attached), so parallel
|
||||||
|
platform builds can converge on one release without conflicts.
|
||||||
|
"""
|
||||||
url = f"{base_url}/api/v1/repos/{repo}/releases"
|
url = f"{base_url}/api/v1/repos/{repo}/releases"
|
||||||
payload = {
|
payload = {
|
||||||
"tag_name": tag,
|
"tag_name": tag,
|
||||||
@@ -136,11 +187,17 @@ def create_release(
|
|||||||
}
|
}
|
||||||
resp = client.post(url, json=payload)
|
resp = client.post(url, json=payload)
|
||||||
if resp.status_code == 409:
|
if resp.status_code == 409:
|
||||||
raise RuntimeError(f"A release for tag '{tag}' already exists on Gitea.")
|
existing = get_release_by_tag(client, base_url, repo, tag)
|
||||||
|
if existing is None:
|
||||||
|
raise RuntimeError(f"Release for tag '{tag}' conflicts but cannot be read.")
|
||||||
|
release_id = existing["id"]
|
||||||
|
assets = {a["name"] for a in existing.get("assets", [])}
|
||||||
|
print(f"Release for tag '{tag}' already exists (id={release_id}), reusing it.")
|
||||||
|
return release_id, assets
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
release_id = resp.json()["id"]
|
release_id = resp.json()["id"]
|
||||||
print(f"Created release id={release_id} (draft={draft})")
|
print(f"Created release id={release_id} (draft={draft})")
|
||||||
return release_id
|
return release_id, set()
|
||||||
|
|
||||||
|
|
||||||
def upload_asset(
|
def upload_asset(
|
||||||
@@ -153,7 +210,10 @@ def upload_asset(
|
|||||||
"""Upload a file to the release and return the download URL."""
|
"""Upload a file to the release and return the download URL."""
|
||||||
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
|
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
|
||||||
size_mb = path.stat().st_size / (1024 * 1024)
|
size_mb = path.stat().st_size / (1024 * 1024)
|
||||||
mime = "application/zip" if path.suffix == ".zip" else "application/octet-stream"
|
mime = {
|
||||||
|
".zip": "application/zip",
|
||||||
|
".dmg": "application/x-apple-diskimage",
|
||||||
|
}.get(path.suffix, "application/octet-stream")
|
||||||
print(f"Uploading {path.name} ({size_mb:.1f} MB) ...")
|
print(f"Uploading {path.name} ({size_mb:.1f} MB) ...")
|
||||||
with Path(path).open("rb") as fh:
|
with Path(path).open("rb") as fh:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
@@ -173,6 +233,10 @@ def upload_asset(
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
# Windows consoles default to cp1252, which can't encode ✓/✗
|
||||||
|
sys.stdout.reconfigure(errors="replace")
|
||||||
|
sys.stderr.reconfigure(errors="replace")
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea")
|
parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--draft", action="store_true", help="Create as a draft release"
|
"--draft", action="store_true", help="Create as a draft release"
|
||||||
@@ -180,45 +244,58 @@ def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--notes", default="", metavar="TEXT", help="Release notes body"
|
"--notes", default="", metavar="TEXT", help="Release notes body"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-dist",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip wheel/sdist upload (for parallel platform builds; one job uploads them)",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cfg = load_gitea_config()
|
cfg = load_gitea_config()
|
||||||
token = load_token()
|
token = load_token()
|
||||||
|
version = read_version()
|
||||||
|
|
||||||
zips = find_releasable_zips()
|
artifacts = find_releasable_artifacts()
|
||||||
if not zips:
|
if not artifacts:
|
||||||
print(
|
print(
|
||||||
"No clean-versioned ZIPs found in build/.\n"
|
"No platform artifacts found in build/.\n"
|
||||||
"Run scripts/guibuild.py first.",
|
"Run scripts/guibuild.py first.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Validate all dist files exist before touching Gitea
|
# Validate all dist files exist before touching Gitea
|
||||||
dist_files: dict[str, list[Path]] = {}
|
dist_files: list[Path] = [] if args.no_dist else find_dist_files(version)
|
||||||
for _, version, _platform_tag in zips:
|
|
||||||
dist_files[version] = find_dist_files(version)
|
|
||||||
|
|
||||||
base_url = cfg["url"].rstrip("/")
|
base_url = cfg["url"].rstrip("/")
|
||||||
repo = cfg["repo"]
|
repo = cfg["repo"]
|
||||||
|
|
||||||
with httpx.Client(headers=gitea_headers(token)) as client:
|
with httpx.Client(headers=gitea_headers(token)) as client:
|
||||||
release_ids_by_version: dict[str, int] = {}
|
|
||||||
for zip_path, version, platform_tag in zips:
|
|
||||||
print(f"\nReleasing {version} ...")
|
print(f"\nReleasing {version} ...")
|
||||||
tag = f"v{version}"
|
tag = f"v{version}"
|
||||||
release_id = release_ids_by_version.get(version)
|
release_id, uploaded = create_release(
|
||||||
if release_id is None:
|
|
||||||
release_id = create_release(
|
|
||||||
client, base_url, repo, tag, version, args.notes, args.draft
|
client, base_url, repo, tag, version, args.notes, args.draft
|
||||||
)
|
)
|
||||||
release_ids_by_version[version] = release_id
|
for path in dist_files:
|
||||||
for path in dist_files[version]:
|
if path.name in uploaded:
|
||||||
|
print(f"Skipping {path.name}, already on the release.")
|
||||||
|
continue
|
||||||
upload_asset(client, base_url, repo, release_id, path)
|
upload_asset(client, base_url, repo, release_id, path)
|
||||||
|
|
||||||
print(f"Uploading platform artifact: {platform_tag}")
|
for artifact_path in artifacts:
|
||||||
upload_asset(client, base_url, repo, release_id, zip_path)
|
if artifact_path.name in uploaded:
|
||||||
|
print(f"Skipping {artifact_path.name}, already on the release.")
|
||||||
|
continue
|
||||||
|
print(f"Uploading platform artifact: {artifact_path.name}")
|
||||||
|
upload_asset(client, base_url, repo, release_id, artifact_path)
|
||||||
|
uploaded.add(artifact_path.name)
|
||||||
|
for feed_file in find_velopack_feed_files():
|
||||||
|
if feed_file.name in uploaded:
|
||||||
|
print(f"Skipping {feed_file.name}, already on the release.")
|
||||||
|
continue
|
||||||
|
upload_asset(client, base_url, repo, release_id, feed_file)
|
||||||
|
uploaded.add(feed_file.name)
|
||||||
print(f" ✓ {tag} published")
|
print(f" ✓ {tag} published")
|
||||||
|
|
||||||
print("\nDone. To publish to PyPI, run:")
|
print("\nDone. To publish to PyPI, run:")
|
||||||
|
|||||||
@@ -1,467 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Torrent Scanner - Scans for .torrent files and analyzes their trackers."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import shutil
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import bencodepy
|
|
||||||
|
|
||||||
from rtorrent_client import RTorrentClient
|
|
||||||
|
|
||||||
|
|
||||||
def _expand_path_pattern(pattern: str) -> list[Path]:
|
|
||||||
"""Expand a user-provided path or glob pattern with pathlib."""
|
|
||||||
expanded = Path(pattern).expanduser()
|
|
||||||
pattern_text = str(expanded)
|
|
||||||
has_glob = any(ch in pattern_text for ch in "*?[")
|
|
||||||
|
|
||||||
if not has_glob:
|
|
||||||
return [expanded] if expanded.exists() else []
|
|
||||||
|
|
||||||
normalized = pattern_text.replace("\\", "/")
|
|
||||||
if expanded.is_absolute():
|
|
||||||
root = Path(expanded.anchor)
|
|
||||||
remainder = normalized[len(expanded.anchor) :].lstrip("/")
|
|
||||||
return list(root.glob(remainder)) if remainder else []
|
|
||||||
return list(Path().glob(normalized))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TorrentInfo:
|
|
||||||
"""Information extracted from a torrent file."""
|
|
||||||
|
|
||||||
path: Path
|
|
||||||
name: str
|
|
||||||
trackers: list[str]
|
|
||||||
size: int | None = None
|
|
||||||
files: list[str] | None = None
|
|
||||||
info_hash: str | None = None
|
|
||||||
is_multi_file: bool = False
|
|
||||||
|
|
||||||
def has_tracker(self, domain: str) -> bool:
|
|
||||||
"""Check if any tracker URL contains the given domain."""
|
|
||||||
return any(domain.lower() in tracker.lower() for tracker in self.trackers)
|
|
||||||
|
|
||||||
def get_download_directory(self) -> Path:
|
|
||||||
"""Get the download directory (parent of .torrents folder).
|
|
||||||
|
|
||||||
Assumes .torrent files are in <download_dir>/.torrents/
|
|
||||||
so the actual downloads are one level up.
|
|
||||||
"""
|
|
||||||
return self.path.parent.parent
|
|
||||||
|
|
||||||
def get_expected_data_path(self) -> Path:
|
|
||||||
"""Get the expected path where downloaded data should exist.
|
|
||||||
|
|
||||||
For multi-file torrents: download_dir/torrent_name/ (directory)
|
|
||||||
For single-file torrents: download_dir/torrent_name (file)
|
|
||||||
"""
|
|
||||||
return self.get_download_directory() / self.name
|
|
||||||
|
|
||||||
def verify_download_exists(self) -> tuple[bool, str]:
|
|
||||||
"""Verify that the downloaded data exists on disk.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (exists: bool, message: str)
|
|
||||||
|
|
||||||
"""
|
|
||||||
expected_path = self.get_expected_data_path()
|
|
||||||
|
|
||||||
if self.is_multi_file:
|
|
||||||
# Multi-file torrent: expect a directory
|
|
||||||
if not expected_path.exists():
|
|
||||||
return False, f"Directory not found: {expected_path}"
|
|
||||||
if not expected_path.is_dir():
|
|
||||||
return False, f"Expected directory but found file: {expected_path}"
|
|
||||||
# Optionally check if at least some files exist
|
|
||||||
existing_files = list(expected_path.rglob("*"))
|
|
||||||
file_count = sum(1 for f in existing_files if f.is_file())
|
|
||||||
if file_count == 0:
|
|
||||||
return False, f"Directory exists but is empty: {expected_path}"
|
|
||||||
return True, f"Directory exists with {file_count} files"
|
|
||||||
# Single-file torrent: expect a file
|
|
||||||
if not expected_path.exists():
|
|
||||||
return False, f"File not found: {expected_path}"
|
|
||||||
if expected_path.is_dir():
|
|
||||||
return False, f"Expected file but found directory: {expected_path}"
|
|
||||||
return True, f"File exists: {expected_path}"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_torrent(filepath: Path) -> TorrentInfo | None:
|
|
||||||
"""Parse a .torrent file and extract relevant information.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filepath: Path to the .torrent file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TorrentInfo object or None if parsing fails
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = bencodepy.decode(Path(filepath).read_bytes())
|
|
||||||
except (OSError, ValueError, TypeError) as e:
|
|
||||||
print(f"Error parsing {filepath}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Extract trackers
|
|
||||||
trackers = []
|
|
||||||
|
|
||||||
# Main announce URL
|
|
||||||
if b"announce" in data:
|
|
||||||
announce = data[b"announce"]
|
|
||||||
if isinstance(announce, bytes):
|
|
||||||
trackers.append(announce.decode("utf-8", errors="replace"))
|
|
||||||
|
|
||||||
# Announce list (multiple trackers)
|
|
||||||
if b"announce-list" in data:
|
|
||||||
for tier in data[b"announce-list"]:
|
|
||||||
for tracker in tier:
|
|
||||||
if isinstance(tracker, bytes):
|
|
||||||
url = tracker.decode("utf-8", errors="replace")
|
|
||||||
if url not in trackers:
|
|
||||||
trackers.append(url)
|
|
||||||
|
|
||||||
# Extract name
|
|
||||||
info = data.get(b"info", {})
|
|
||||||
name = info.get(b"name", b"Unknown").decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
# Calculate info hash
|
|
||||||
info_hash = hashlib.sha1(bencodepy.encode(info)).hexdigest().upper()
|
|
||||||
|
|
||||||
# Extract size and files
|
|
||||||
size = None
|
|
||||||
files = None
|
|
||||||
is_multi_file = False
|
|
||||||
|
|
||||||
if b"length" in info:
|
|
||||||
# Single file torrent
|
|
||||||
size = info[b"length"]
|
|
||||||
files = [name]
|
|
||||||
is_multi_file = False
|
|
||||||
elif b"files" in info:
|
|
||||||
# Multi-file torrent
|
|
||||||
files = []
|
|
||||||
size = 0
|
|
||||||
is_multi_file = True
|
|
||||||
for file_info in info[b"files"]:
|
|
||||||
file_path = "/".join(
|
|
||||||
p.decode("utf-8", errors="replace") for p in file_info.get(b"path", [])
|
|
||||||
)
|
|
||||||
files.append(file_path)
|
|
||||||
size += file_info.get(b"length", 0)
|
|
||||||
|
|
||||||
return TorrentInfo(
|
|
||||||
path=filepath,
|
|
||||||
name=name,
|
|
||||||
trackers=trackers,
|
|
||||||
size=size,
|
|
||||||
files=files,
|
|
||||||
info_hash=info_hash,
|
|
||||||
is_multi_file=is_multi_file,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def scan_torrent_directories(paths: list[str]) -> Iterator[Path]:
|
|
||||||
"""Scan directories for .torrent files.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
paths: List of directory paths or glob patterns to scan
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
Path objects for each .torrent file found
|
|
||||||
|
|
||||||
"""
|
|
||||||
for pattern in paths:
|
|
||||||
for torrent_dir in _expand_path_pattern(pattern):
|
|
||||||
if torrent_dir.is_dir():
|
|
||||||
yield from torrent_dir.glob("*.torrent")
|
|
||||||
|
|
||||||
|
|
||||||
def find_torrents_with_tracker(
|
|
||||||
tracker_domain: str, paths: list[str]
|
|
||||||
) -> list[TorrentInfo]:
|
|
||||||
"""Find all torrents that have a specific tracker domain.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tracker_domain: Domain to search for in tracker URLs (e.g., "hdbits.org")
|
|
||||||
paths: List of directory paths or glob patterns to scan
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of TorrentInfo objects for matching torrents
|
|
||||||
|
|
||||||
"""
|
|
||||||
matching_torrents = []
|
|
||||||
|
|
||||||
for torrent_path in scan_torrent_directories(paths):
|
|
||||||
info = parse_torrent(torrent_path)
|
|
||||||
if info and info.has_tracker(tracker_domain):
|
|
||||||
matching_torrents.append(info)
|
|
||||||
|
|
||||||
return matching_torrents
|
|
||||||
|
|
||||||
|
|
||||||
def format_size(size_bytes: int | None) -> str:
|
|
||||||
"""Format bytes as human-readable size."""
|
|
||||||
if size_bytes is None:
|
|
||||||
return "Unknown"
|
|
||||||
|
|
||||||
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
||||||
if size_bytes < 1024:
|
|
||||||
return f"{size_bytes:.2f} {unit}"
|
|
||||||
size_bytes /= 1024
|
|
||||||
return f"{size_bytes:.2f} PB"
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""Run the torrent scanner command-line workflow."""
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Scan and manage torrent files",
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog="""
|
|
||||||
Examples:
|
|
||||||
%(prog)s /path/to/torrents*/.torrents/
|
|
||||||
%(prog)s /mnt/disk1/torrents/.torrents/ /mnt/disk2/torrents/.torrents/
|
|
||||||
%(prog)s /torrents*/.torrents/ --tracker hdbits.org
|
|
||||||
%(prog)s /torrents*/.torrents/ --dry
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"paths",
|
|
||||||
nargs="+",
|
|
||||||
help="Directories or glob patterns containing .torrent files",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--dry",
|
|
||||||
action="store_true",
|
|
||||||
help="Dry run - show what would be done without making changes",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--tracker",
|
|
||||||
default="hdbits.org",
|
|
||||||
help="Tracker domain to filter by (default: hdbits.org)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
dry_run = args.dry
|
|
||||||
tracker_domain = args.tracker
|
|
||||||
|
|
||||||
# Expand glob patterns
|
|
||||||
expanded_paths = []
|
|
||||||
for pattern in args.paths:
|
|
||||||
matches = [str(path) for path in _expand_path_pattern(pattern)]
|
|
||||||
if matches:
|
|
||||||
expanded_paths.extend(matches)
|
|
||||||
else:
|
|
||||||
expanded_paths.append(pattern)
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print("=" * 60)
|
|
||||||
print("DRY RUN MODE - No changes will be made")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
print("Scanning for torrents...")
|
|
||||||
print(f"Search paths: {expanded_paths}")
|
|
||||||
print("-" * 60)
|
|
||||||
|
|
||||||
# Parse all torrents
|
|
||||||
all_torrents: list[TorrentInfo] = []
|
|
||||||
for torrent_path in scan_torrent_directories(expanded_paths):
|
|
||||||
info = parse_torrent(torrent_path)
|
|
||||||
if info:
|
|
||||||
all_torrents.append(info)
|
|
||||||
|
|
||||||
# Separate by tracker
|
|
||||||
with_hdbits = [t for t in all_torrents if t.has_tracker(tracker_domain)]
|
|
||||||
without_hdbits = [t for t in all_torrents if not t.has_tracker(tracker_domain)]
|
|
||||||
|
|
||||||
# Print stats
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print("SUMMARY")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
print(f"Total torrents scanned: {len(all_torrents)}")
|
|
||||||
print(f"With {tracker_domain}: {len(with_hdbits)}")
|
|
||||||
print(f"Without {tracker_domain}: {len(without_hdbits)}")
|
|
||||||
|
|
||||||
# Add hdbits torrents to rtorrent
|
|
||||||
if with_hdbits:
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print("VERIFYING DOWNLOADS & ADDING TO RTORRENT")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
|
|
||||||
# First, verify which torrents have their data
|
|
||||||
verified = []
|
|
||||||
missing_data = []
|
|
||||||
|
|
||||||
for torrent in with_hdbits:
|
|
||||||
exists, message = torrent.verify_download_exists()
|
|
||||||
if exists:
|
|
||||||
verified.append(torrent)
|
|
||||||
else:
|
|
||||||
missing_data.append((torrent, message))
|
|
||||||
|
|
||||||
print("\nVerification results:")
|
|
||||||
print(f" Downloads found: {len(verified)}")
|
|
||||||
print(f" Downloads missing: {len(missing_data)}")
|
|
||||||
|
|
||||||
# Report missing downloads
|
|
||||||
if missing_data:
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print("TORRENTS WITH MISSING DATA (will not add)")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
for torrent, message in missing_data:
|
|
||||||
print(f"\n Name: {torrent.name}")
|
|
||||||
print(f" Torrent: {torrent.path}")
|
|
||||||
print(f" Reason: {message}")
|
|
||||||
|
|
||||||
# Now add verified torrents to rtorrent
|
|
||||||
if verified:
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print(f"ADDING {len(verified)} VERIFIED TORRENTS TO RTORRENT")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
|
|
||||||
client = RTorrentClient()
|
|
||||||
loaded_hashes = client.get_loaded_hashes()
|
|
||||||
print(f"Currently loaded in rtorrent: {len(loaded_hashes)} torrents")
|
|
||||||
|
|
||||||
added = 0
|
|
||||||
skipped = 0
|
|
||||||
failed = 0
|
|
||||||
|
|
||||||
for torrent in verified:
|
|
||||||
if torrent.info_hash and torrent.info_hash in loaded_hashes:
|
|
||||||
print(f"Skipping (already loaded): {torrent.name}")
|
|
||||||
skipped += 1
|
|
||||||
else:
|
|
||||||
download_dir = torrent.get_download_directory()
|
|
||||||
if dry_run:
|
|
||||||
print(f"Would add: {torrent.name}")
|
|
||||||
print(f" Download dir: {download_dir}")
|
|
||||||
added += 1
|
|
||||||
else:
|
|
||||||
print(f"Adding: {torrent.name}")
|
|
||||||
print(f" Download dir: {download_dir}")
|
|
||||||
if client.load_torrent(torrent.path, download_dir):
|
|
||||||
added += 1
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print(f"\nDry run: {added} would be added, {skipped} already loaded")
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
f"\nRtorrent results: {added} added, "
|
|
||||||
f"{skipped} skipped, {failed} failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clean up unregistered torrents from rtorrent
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print("CHECKING FOR UNREGISTERED TORRENTS")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
|
|
||||||
client = RTorrentClient()
|
|
||||||
unregistered = client.get_unregistered_torrents()
|
|
||||||
|
|
||||||
if unregistered:
|
|
||||||
print(f"Found {len(unregistered)} unregistered torrent(s):\n")
|
|
||||||
|
|
||||||
removed_from_rtorrent = 0
|
|
||||||
removed_torrent_files = 0
|
|
||||||
removed_downloads = 0
|
|
||||||
|
|
||||||
for torrent_info in unregistered:
|
|
||||||
# Determine the download path (base_path is the actual file/folder)
|
|
||||||
download_path = (
|
|
||||||
Path(torrent_info["base_path"]) if torrent_info["base_path"] else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
status = "[DRY]"
|
|
||||||
if download_path:
|
|
||||||
print(f" {status} {download_path}")
|
|
||||||
else:
|
|
||||||
print(f" {status} {torrent_info['name']} (no data path)")
|
|
||||||
# Remove from rtorrent (keeps downloaded files)
|
|
||||||
elif client.remove_torrent(torrent_info["hash"]):
|
|
||||||
removed_from_rtorrent += 1
|
|
||||||
|
|
||||||
# Delete the .torrent file if it exists
|
|
||||||
tied_file = torrent_info["tied_file"]
|
|
||||||
if tied_file:
|
|
||||||
torrent_file = Path(tied_file)
|
|
||||||
if torrent_file.exists():
|
|
||||||
try:
|
|
||||||
torrent_file.unlink()
|
|
||||||
removed_torrent_files += 1
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Delete the downloaded files
|
|
||||||
if download_path and download_path.exists():
|
|
||||||
try:
|
|
||||||
if download_path.is_dir():
|
|
||||||
shutil.rmtree(download_path)
|
|
||||||
else:
|
|
||||||
download_path.unlink()
|
|
||||||
removed_downloads += 1
|
|
||||||
print(f" [DEL] {download_path}")
|
|
||||||
except OSError as e:
|
|
||||||
print(f" [ERR] {download_path}: {e}")
|
|
||||||
else:
|
|
||||||
print(f" [DEL] {torrent_info['name']} (no data)")
|
|
||||||
else:
|
|
||||||
print(f" [ERR] {torrent_info['name']}: failed to remove from rtorrent")
|
|
||||||
|
|
||||||
print()
|
|
||||||
if dry_run:
|
|
||||||
print(
|
|
||||||
f"Dry run: {len(unregistered)} would be removed "
|
|
||||||
"(rtorrent + .torrent + downloads)"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
f"Cleanup: {removed_from_rtorrent} from rtorrent, "
|
|
||||||
f"{removed_torrent_files} .torrents, {removed_downloads} downloads"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print("No unregistered torrents found.")
|
|
||||||
|
|
||||||
# List torrents without hdbits.org
|
|
||||||
if without_hdbits:
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print(f"TORRENTS WITHOUT {tracker_domain.upper()}")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
for torrent in without_hdbits:
|
|
||||||
print(f"\nName: {torrent.name}")
|
|
||||||
print(f"Path: {torrent.path}")
|
|
||||||
print(f"Size: {format_size(torrent.size)}")
|
|
||||||
if torrent.trackers:
|
|
||||||
print("Trackers:")
|
|
||||||
for tracker in torrent.trackers:
|
|
||||||
print(f" - {tracker}")
|
|
||||||
else:
|
|
||||||
print("Trackers: (none)")
|
|
||||||
|
|
||||||
# Remove the non-hdbits torrent files
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
if dry_run:
|
|
||||||
print(f"WOULD REMOVE {len(without_hdbits)} TORRENT FILE(S)")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
for torrent in without_hdbits:
|
|
||||||
print(f"Would remove: {torrent.path}")
|
|
||||||
else:
|
|
||||||
print(f"REMOVING {len(without_hdbits)} TORRENT FILE(S)")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
for torrent in without_hdbits:
|
|
||||||
try:
|
|
||||||
torrent.path.unlink()
|
|
||||||
print(f"Removed: {torrent.path}")
|
|
||||||
except OSError as e:
|
|
||||||
print(f"Failed to remove {torrent.path}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user