Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a029eabded | ||
|
|
4926b72167 | ||
|
|
cd336d0bdb | ||
|
|
c75fb30921 | ||
|
|
28c50cab0b | ||
|
|
14b76520a6 | ||
|
|
c9d2bcfdcc | ||
|
|
7a0247d78f | ||
|
|
bb4bd3723f | ||
|
|
7675940029 | ||
|
|
613b516785 | ||
|
|
2f72d1e7e2 | ||
|
|
b51a7ea73d | ||
|
|
fe5be2087d | ||
|
|
46eecd8dbf | ||
|
|
c2ee25ba3d | ||
|
|
649992a10a | ||
|
|
ada61e1400 | ||
|
|
3551808351 | ||
|
|
0ef6896181 | ||
|
|
72963aeb7a | ||
|
|
352835a664 | ||
|
|
900fcea638 | ||
|
|
550f67531a | ||
|
|
25e9bab57b | ||
|
|
359ca0c5b4 | ||
|
|
cac3348ec1 | ||
|
|
0b3cd15589 | ||
|
|
17d9426d0a | ||
|
|
ed4a5b2900 | ||
|
|
1383fc8da3 | ||
|
|
fe71e1be8b | ||
|
|
a0d1433154 | ||
|
|
02a89aee68 | ||
|
|
29cda926a2 | ||
|
|
81be850e0c | ||
|
|
e78774613a | ||
|
|
96c2d52ee0 | ||
|
|
79d3f40162 | ||
|
|
f982785850 | ||
|
|
4c33cee4ac | ||
|
|
cf2b90809a | ||
|
|
43ca8ea1d1 | ||
|
|
8cb7ab5938 | ||
|
|
7fa8ddad65 | ||
|
|
f219840967 | ||
|
|
eee8c973ed | ||
|
|
a3f5ef5dac | ||
|
|
fbf35f7242 | ||
|
|
ab1dfffdf3 | ||
|
|
5b57640949 | ||
|
|
f484ab2606 | ||
|
|
598cef8926 | ||
|
|
f39473e26e | ||
|
|
31fedcea6c | ||
|
|
b8cdd460b6 | ||
|
|
cfa1781192 | ||
|
|
76c426546b | ||
|
|
1d3d682b60 | ||
|
|
0330a3023a | ||
|
|
1cf3e6a08f | ||
|
|
17f8f60f6e | ||
|
|
23379dc2c1 | ||
|
|
b1e775a29b | ||
|
|
f6a40babc9 | ||
|
|
836a563897 | ||
|
|
17c5a47936 | ||
|
|
e86f1e22de | ||
|
|
6646068adc | ||
|
|
6115a3ff0a | ||
|
|
8d3e872d41 | ||
|
|
3768967e7d | ||
|
|
54a4b57230 | ||
|
|
95081ca50e | ||
|
|
0e5251ab91 | ||
|
|
5f69b5365f | ||
|
|
b835b82e94 | ||
|
|
93d89e32e2 | ||
|
|
eeb28e991f | ||
|
|
780f223b82 | ||
|
|
8586bd26e1 | ||
|
|
b574c974a7 | ||
|
|
94f2f10dcd | ||
|
|
487e410a8f | ||
|
|
36fc2a77d9 | ||
|
|
06565f688c | ||
|
|
4097c3c9ac | ||
|
|
9c75fa4569 | ||
|
|
2f83193ff4 | ||
|
|
79dc1b25cc | ||
|
|
0f404e5e53 | ||
|
|
075f6a50cc | ||
|
|
0b5c47f4d1 | ||
|
|
169eb122b5 | ||
|
|
7fbfb27d95 | ||
|
|
4ac40e3585 | ||
|
|
52ce50e548 | ||
|
|
b8e23587b8 | ||
|
|
fbfe05588d | ||
|
|
6820937a4b | ||
|
|
c5d09c5177 | ||
|
|
00454f0929 | ||
|
|
6b74a6d308 | ||
|
|
04aa858338 | ||
|
|
491e707b6f | ||
|
|
c3dee9bed9 | ||
|
|
a748adf87e | ||
|
|
e23ea7d14e | ||
|
|
155c5712c2 | ||
|
|
be140bc286 | ||
|
|
33b59bbace | ||
|
|
8d3de512eb | ||
|
|
0660b4e9d2 | ||
|
|
7eb81877fd | ||
|
|
eeed9f3ef7 | ||
|
|
2e9928a411 | ||
|
|
e40c953617 | ||
|
|
7aeec90e4f | ||
|
|
98fbdc63f1 | ||
|
|
ed37238122 | ||
|
|
5bb46c162e | ||
|
|
81fbfee5d3 | ||
|
|
c405ea98b3 | ||
|
|
76c4cc0829 | ||
|
|
c3f5ac2f6e |
@@ -0,0 +1,60 @@
|
||||
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 }}"
|
||||
|
||||
- name: Build GUI app and dist packages
|
||||
shell: ${{ matrix.shell }}
|
||||
run: uv run --extra gui scripts/guibuild.py
|
||||
|
||||
# Every platform converges on the one release for the tag; release.py
|
||||
# reuses an existing release and skips already-uploaded assets.
|
||||
# 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
|
||||
|
||||
# Wheel/sdist are platform-independent; the linux job also pushes them
|
||||
# to PyPI. Token is the PYPI_TOKEN repository secret.
|
||||
- name: Publish to PyPI
|
||||
if: matrix.os == 'linux'
|
||||
shell: ${{ matrix.shell }}
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
run: uv publish
|
||||
|
||||
- name: Attach platform artifact to the Gitea release
|
||||
if: matrix.os != 'linux'
|
||||
shell: ${{ matrix.shell }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: uv run scripts/release.py --no-dist
|
||||
@@ -19,3 +19,4 @@ package-lock.json
|
||||
# Dotfiles
|
||||
.*
|
||||
!.gitignore
|
||||
!.gitea/
|
||||
|
||||
@@ -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,94 +1,64 @@
|
||||

|
||||
|
||||
# MediaHive
|
||||
|
||||
Media scanning, indexing, and Netflix-style web streaming for your torrent collection.
|
||||
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
|
||||
|
||||
## Project Structure
|
||||
## 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)
|
||||
|
||||
### Linux
|
||||
|
||||
```
|
||||
hivescan/ Indexing & previews (library + CLI)
|
||||
indexer.py Media index generation
|
||||
scanning.py File system scanning
|
||||
parsing.py Torrent name parsing (PTN)
|
||||
models.py Data models
|
||||
images.py TMDb cover/backdrop downloading
|
||||
showreel.py Video preview clip generation (ffmpeg)
|
||||
tmdb_client.py TMDb API client with caching
|
||||
utils.py Path, size, and timestamp helpers
|
||||
mediahive/ FastAPI web server + Vue frontend
|
||||
server.py FastAPI app (API + media serving)
|
||||
__main__.py CLI entry point
|
||||
frontend/ Vue 3 frontend source
|
||||
src/
|
||||
components/ Vue components (Netflix-style UI)
|
||||
styles/ CSS styles
|
||||
api.ts API calls to FastAPI backend
|
||||
types.ts TypeScript interfaces
|
||||
scripts/
|
||||
devserver.py Development server (Vite + FastAPI)
|
||||
rtorrent-manager.py Torrent scanning & rtorrent management
|
||||
fastapi-vue/ Build utilities for frontend
|
||||
rtorrent_client.py RTorrent XMLRPC/SCGI client
|
||||
wget https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage
|
||||
chmod +x MediaHive-linux.AppImage && ./MediaHive-linux.AppImage
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
You may also run without installing via
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
uvx --from mediahive[gui] mediahive
|
||||
```
|
||||
|
||||
### 1. Scan & Index Media
|
||||
## What It Does
|
||||
|
||||
```bash
|
||||
# Scan downloads, auto-detect common root, create .mediahive folder
|
||||
hivescan /media/torrents/*
|
||||
- Scans your chosen media folder for all movies and series that can be found
|
||||
- Produces preview video clips and downloads metadata
|
||||
- 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
|
||||
|
||||
# Scan multiple locations
|
||||
hivescan /mnt/disk1/* /mnt/disk2/*
|
||||
On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
|
||||
|
||||
# Override output directory
|
||||
hivescan /media/torrents/* -o /srv/media/.mediahive
|
||||
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.
|
||||
|
||||
# Skip cover/showreel generation
|
||||
hivescan /media/torrents/* --no-covers --no-showreels
|
||||
```
|
||||
## Controls
|
||||
|
||||
### 2. Serve & Browse
|
||||
MediaHive is designed to work with a mouse, keyboard, or gamepad.
|
||||
|
||||
```bash
|
||||
# Start the web server
|
||||
mediahive /path/to/your/media/folder
|
||||
| Input | Controls |
|
||||
| --- | --- |
|
||||
| Mouse | Click posters, rows, search, play, and folder actions directly. |
|
||||
| Keyboard | Arrow keys move focus, `Enter` activates the focused item, `Escape` goes back, and `/` or `Ctrl`/`Cmd`+`F` jumps to search. |
|
||||
| Gamepad | D-pad or left stick moves focus, `A` selects or plays, and `B` goes back. `RB`/`LB` browses adjacent items, and the Search bar has an OSD keyboard. Player controls during playback. |
|
||||
|
||||
# Server starts at http://localhost:8420
|
||||
```
|
||||
## Recommended Players
|
||||
|
||||
The app expects `<media-folder>/.mediahive/index.json` generated by hivescan.
|
||||
- Windows: [MPC-BE](https://github.com/Aleksoid1978/MPC-BE/releases)
|
||||
- macOS: [IINA](https://iina.io/)
|
||||
- Linux: SMPlayer
|
||||
|
||||
### 3. Development
|
||||
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.
|
||||
|
||||
```bash
|
||||
cd frontend && npm install && cd ..
|
||||
python scripts/devserver.py
|
||||
```
|
||||
- `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.
|
||||
|
||||
Starts Vite dev server + FastAPI backend with auto-reload.
|
||||
## Background
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/index` — Load media index
|
||||
- `POST /api/play` — Open media file with system player
|
||||
- `POST /api/open-folder` — Open folder in file explorer
|
||||
- `GET /api/media/{path}` — Serve media files (images, video)
|
||||
|
||||
## RTorrent Manager
|
||||
|
||||
```bash
|
||||
python scripts/rtorrent-manager.py /media/torrents*/.torrents/
|
||||
```
|
||||
|
||||
Scans `.torrent` files, verifies downloads exist, loads into rtorrent.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python ≥ 3.14
|
||||
- Node.js 18+ (frontend development)
|
||||
- ffmpeg (showreel generation)
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# API
|
||||
|
||||
MediaHive exposes a small local API used by the desktop app and frontend.
|
||||
All media paths are scoped to a **root**, identified by a friendly `root_id`
|
||||
(same identifier shown as the root name).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/health` | Lightweight health check. |
|
||||
| `GET` | `/api/config` | Returns the current root configuration. |
|
||||
| `PUT` | `/api/config/roots` | Atomically replace the full root set. Returns `{ "status": "ok", "accepted": [{path, root_id}], "failed": [...] }`. |
|
||||
| `POST` | `/api/play/{root_id}` | Opens a media file with a media player. Also starts an assumed-playback session (see notes). |
|
||||
| `GET` | `/api/players` | Lists detected media players. Returns `{ "players": [{id, name, family, path}] }`, including synthetic `default` and `custom` entries. |
|
||||
| `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. |
|
||||
| `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/assets/{root_id}/{asset_type}/{asset_path:path}` | Serves typed assets from `<root>/.mediahive`. |
|
||||
| `WS` | `/api/ws` | Streams roots, index updates, and task progress for all roots (see WebSocket notes). |
|
||||
|
||||
## Notes
|
||||
|
||||
- `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. 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. 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/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`.
|
||||
- `GET /api/player/status` returns `{ "remote": true|false }`.
|
||||
- Roots may also be provided at startup via CLI arguments (`mediahive /path/to/media ...`), which are passed to the server through fastapi-vue's env config (`mediahive.config.config`) and override the persisted configuration.
|
||||
- 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.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Development
|
||||
|
||||
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
|
||||
|
||||
- Python 3.14+
|
||||
- `uv`
|
||||
- Node.js 18+
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
```bash
|
||||
uv sync --extra gui --group dev
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
## Run The Backend Directly
|
||||
|
||||
```bash
|
||||
uv run mediahive /path/to/media/folder
|
||||
```
|
||||
|
||||
This starts the FastAPI backend and serves the built frontend.
|
||||
|
||||
## Run Frontend + Backend In Development
|
||||
|
||||
```bash
|
||||
uv run scripts/devserver.py /path/to/media/folder
|
||||
```
|
||||
|
||||
This starts the FastAPI backend with auto-reload plus the Vite frontend dev server.
|
||||
|
||||
## Run The Desktop App In Development
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
## Building And Releasing
|
||||
|
||||
The helper scripts are directly executable via their `uv run` shebang (on Windows, run them with `uv run scripts/<name>.py`):
|
||||
|
||||
- `./scripts/guibuild.py` builds the PyInstaller desktop app and 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
|
||||
|
||||
- The selected media folder is scanned continuously by the backend.
|
||||
- The desktop app remembers the chosen folder between launches.
|
||||
- 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).
|
||||
- Scanner/indexer design notes and the v0.5.0 rescan fixes are reviewed in [scanning-review.md](scanning-review.md).
|
||||
|
After Width: | Height: | Size: 427 KiB |
@@ -0,0 +1,623 @@
|
||||
# MPC-BE Web Interface API
|
||||
|
||||
This document describes the MPC-BE web interface exposed by the local player instance running on `http://127.0.0.1:13579`.
|
||||
|
||||
This version is based on the actual MPC-BE source from GitHub, not just live UI inspection.
|
||||
|
||||
## Authoritative upstream sources
|
||||
|
||||
The command list and web behavior below come from these MPC-BE source files:
|
||||
|
||||
- `src/apps/mplayerc/resource.h`
|
||||
- authoritative numeric command IDs
|
||||
- `src/apps/mplayerc/WebClient.cpp`
|
||||
- `command.html` behavior and special web-only commands
|
||||
- `src/apps/mplayerc/WebServer.cpp`
|
||||
- endpoint table and built-in asset deployment
|
||||
- `src/apps/mplayerc/WebClient.h`
|
||||
- request handler declarations
|
||||
|
||||
Upstream repository:
|
||||
|
||||
- https://github.com/Aleksoid1978/MPC-BE
|
||||
|
||||
Relevant source paths:
|
||||
|
||||
- https://github.com/Aleksoid1978/MPC-BE/blob/master/src/apps/mplayerc/resource.h
|
||||
- https://github.com/Aleksoid1978/MPC-BE/blob/master/src/apps/mplayerc/WebClient.cpp
|
||||
- https://github.com/Aleksoid1978/MPC-BE/blob/master/src/apps/mplayerc/WebServer.cpp
|
||||
|
||||
## Base URL
|
||||
|
||||
- `http://127.0.0.1:13579`
|
||||
|
||||
## Endpoints
|
||||
|
||||
From `WebServer.cpp`, the built-in internal pages are:
|
||||
|
||||
- `GET /`
|
||||
- `GET /index.html`
|
||||
- `GET /info.html`
|
||||
- `GET /browser.html`
|
||||
- `GET /controls.html`
|
||||
- `GET /command.html`
|
||||
- `POST /command.html`
|
||||
- `GET /status.html`
|
||||
- `GET /player.html`
|
||||
- `GET /variables.html`
|
||||
- `GET /snapshot.jpg`
|
||||
- `GET /404.html`
|
||||
|
||||
Built-in downloadable assets exposed by the web server include:
|
||||
|
||||
- `GET /default.css`
|
||||
- `GET /favicon.png`
|
||||
- `GET /logo.png`
|
||||
- `GET /seekbarleft.png`
|
||||
- `GET /seekbarmid.png`
|
||||
- `GET /seekbarright.png`
|
||||
- `GET /seekbargrip.png`
|
||||
- `GET /controlbuttonplay.png`
|
||||
- `GET /controlbuttonpause.png`
|
||||
- `GET /controlbuttonstop.png`
|
||||
- `GET /controlbuttonskipback.png`
|
||||
- `GET /controlbuttondecrate.png`
|
||||
- `GET /controlbuttonincrate.png`
|
||||
- `GET /controlbuttonskipforward.png`
|
||||
- `GET /controlbuttonstep.png`
|
||||
- `GET /controlvolumeon.png`
|
||||
- `GET /controlvolumeoff.png`
|
||||
- `GET /controlvolumebar.png`
|
||||
- `GET /controlvolumegrip.png`
|
||||
|
||||
The web server can also expose static files and CGI handlers from the configured web root.
|
||||
|
||||
## `command.html` behavior
|
||||
|
||||
From `WebClient.cpp`, `command.html` reads `wm_command` from the request and behaves as follows:
|
||||
|
||||
- If `wm_command == CMD_SETPOS`
|
||||
- accepts either `position=HH:MM:SS[.ms]`
|
||||
- or `percent=<number>`
|
||||
- If `wm_command == CMD_SETVOLUME`
|
||||
- accepts `volume=<0-100>`
|
||||
- If `wm_command == ID_FILE_EXIT`
|
||||
- posts the exit command asynchronously
|
||||
- For any other positive `wm_command`
|
||||
- forwards it to the player as `WM_COMMAND`
|
||||
|
||||
This is the key point: the web API is not limited to a tiny hardcoded list. For any positive command ID defined in `resource.h`, the web server forwards it into the player.
|
||||
|
||||
## Special web-only command IDs
|
||||
|
||||
These are handled specially by `WebClient.cpp` and are not normal `resource.h` command IDs:
|
||||
|
||||
| `wm_command` | Meaning | Parameters |
|
||||
| --- | --- | --- |
|
||||
| `-1` | Absolute seek | `position=HH:MM:SS[.ms]` or `percent=<0-100>` |
|
||||
| `-2` | Absolute volume set | `volume=<0-100>` |
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
/command.html?wm_command=-1&position=01:07:42
|
||||
/command.html?wm_command=-1&percent=50
|
||||
/command.html?wm_command=-2&volume=80
|
||||
```
|
||||
|
||||
## Variables exposed by `/variables.html`
|
||||
|
||||
From `WebClient.cpp`, `OnVariables()` emits the following fields into the HTML page:
|
||||
|
||||
- `file`
|
||||
- `filepatharg`
|
||||
- `filepath`
|
||||
- `filedirarg`
|
||||
- `filedir`
|
||||
- `state`
|
||||
- `statestring`
|
||||
- `position`
|
||||
- `positionstring`
|
||||
- `duration`
|
||||
- `durationstring`
|
||||
- `volumelevel`
|
||||
- `muted`
|
||||
- `playbackrate`
|
||||
- `size`
|
||||
- `reloadtime`
|
||||
- `hdr`
|
||||
- `version`
|
||||
|
||||
Observed meanings from source:
|
||||
|
||||
- `state`: numeric `OAFilterState`
|
||||
- `statestring`: localized text such as playing, paused, stopped
|
||||
- `position`: current playback position in milliseconds
|
||||
- `positionstring`: current playback position formatted as `HH:MM:SS`
|
||||
- `duration`: total duration in milliseconds
|
||||
- `durationstring`: total duration formatted as `HH:MM:SS`
|
||||
- `volumelevel`: toolbar volume control position, usually `0-100`
|
||||
- `muted`: `1` when muted, `0` otherwise
|
||||
- `playbackrate`: currently emitted as `1` in this source path
|
||||
- `size`: formatted file size string
|
||||
- `reloadtime`: currently emitted as `0`
|
||||
- `hdr`: `SDR`, `HDR`, or `HDR(HLG)` depending on media/render graph inspection
|
||||
- `version`: full MPC-BE version string
|
||||
|
||||
Example shape:
|
||||
|
||||
```html
|
||||
<p id="position">4062466</p>
|
||||
<p id="positionstring">01:07:42</p>
|
||||
<p id="duration">8060032</p>
|
||||
<p id="durationstring">02:14:20</p>
|
||||
```
|
||||
|
||||
## Browser endpoint behavior
|
||||
|
||||
From `WebClient.cpp`, `browser.html` supports:
|
||||
|
||||
- `path=<filesystem path>`
|
||||
- optional `focus=no`
|
||||
|
||||
When `path` points to a file, the browser page can send a `WM_COPYDATA` message to open that file in MPC-BE.
|
||||
|
||||
## Actual command listing from `resource.h`
|
||||
|
||||
These are the actual command IDs defined by MPC-BE in the `commands` block of `src/apps/mplayerc/resource.h`.
|
||||
|
||||
Because `WebClient.cpp` forwards any positive `wm_command` to `WM_COMMAND`, these are the real actionable web command IDs.
|
||||
|
||||
### File commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 800 | `ID_FILE_OPENFILEURL` |
|
||||
| 801 | `ID_FILE_OPENDVD` |
|
||||
| 802 | `ID_FILE_OPENDEVICE` |
|
||||
| 803 | `ID_FILE_CLOSEMEDIA` |
|
||||
| 804 | `ID_FILE_CLOSEPLAYLIST` |
|
||||
| 805 | `ID_FILE_SAVE_COPY` |
|
||||
| 806 | `ID_FILE_SAVE_IMAGE` |
|
||||
| 807 | `ID_FILE_AUTOSAVE_IMAGE` |
|
||||
| 808 | `ID_FILE_SAVE_THUMBNAILS` |
|
||||
| 809 | `ID_FILE_LOAD_SUBTITLE` |
|
||||
| 810 | `ID_FILE_SAVE_SUBTITLE` |
|
||||
| 812 | `ID_FILE_ISDB_DOWNLOAD` |
|
||||
| 813 | `ID_FILE_ISDB_SEARCH` |
|
||||
| 814 | `ID_FILE_PROPERTIES` |
|
||||
| 816 | `ID_FILE_EXIT` |
|
||||
| 969 | `ID_FILE_OPENFILE` |
|
||||
| 976 | `ID_FILE_REOPEN` |
|
||||
| 996 | `ID_FILE_AUTOSAVE_DISPLAY` |
|
||||
| 1016 | `ID_FILE_OPENDIRECTORY` |
|
||||
| 1035 | `ID_FILE_LOAD_AUDIO` |
|
||||
| 1090 | `ID_FILE_OPENISO` |
|
||||
|
||||
### View and window commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 815 | `ID_VIEW_OPTIONS` |
|
||||
| 817 | `ID_VIEW_CAPTIONMENU` |
|
||||
| 818 | `ID_VIEW_SEEKER` |
|
||||
| 819 | `ID_VIEW_CONTROLS` |
|
||||
| 820 | `ID_VIEW_INFORMATION` |
|
||||
| 821 | `ID_VIEW_STATISTICS` |
|
||||
| 822 | `ID_VIEW_STATUS` |
|
||||
| 823 | `ID_VIEW_SUBRESYNC` |
|
||||
| 824 | `ID_VIEW_PLAYLIST` |
|
||||
| 825 | `ID_VIEW_CAPTURE` |
|
||||
| 826 | `ID_VIEW_SHADEREDITOR` |
|
||||
| 827 | `ID_VIEW_PRESETS_MINIMAL` |
|
||||
| 828 | `ID_VIEW_PRESETS_COMPACT` |
|
||||
| 829 | `ID_VIEW_PRESETS_NORMAL` |
|
||||
| 830 | `ID_VIEW_FULLSCREEN` |
|
||||
| 831 | `ID_VIEW_FULLSCREEN_2` |
|
||||
| 832 | `ID_VIEW_ZOOM_50` |
|
||||
| 833 | `ID_VIEW_ZOOM_100` |
|
||||
| 834 | `ID_VIEW_ZOOM_200` |
|
||||
| 835 | `ID_VIEW_VF_HALF` |
|
||||
| 836 | `ID_VIEW_VF_NORMAL` |
|
||||
| 837 | `ID_VIEW_VF_DOUBLE` |
|
||||
| 838 | `ID_VIEW_VF_STRETCH` |
|
||||
| 839 | `ID_VIEW_VF_FROMINSIDE` |
|
||||
| 840 | `ID_VIEW_VF_FROMOUTSIDE` |
|
||||
| 841 | `ID_VIEW_VF_ZOOM1` |
|
||||
| 842 | `ID_VIEW_VF_ZOOM2` |
|
||||
| 843 | `ID_VIEW_VF_SWITCHZOOM` |
|
||||
| 844 | `ID_VIEW_VF_KEEPASPECTRATIO` |
|
||||
| 845 | `ID_VIEW_VF_COMPMONDESKARDIFF` |
|
||||
| 859 | `ID_ASPECTRATIO_NEXT` |
|
||||
| 861 | `ID_VIEW_RESET` |
|
||||
| 862 | `ID_VIEW_INCSIZE` |
|
||||
| 863 | `ID_VIEW_DECSIZE` |
|
||||
| 864 | `ID_VIEW_INCWIDTH` |
|
||||
| 865 | `ID_VIEW_DECWIDTH` |
|
||||
| 866 | `ID_VIEW_INCHEIGHT` |
|
||||
| 867 | `ID_VIEW_DECHEIGHT` |
|
||||
| 968 | `ID_VIEW_ZOOM_AUTOFIT` |
|
||||
| 1009 | `ID_VIEW_NAVIGATION` |
|
||||
| 1023 | `ID_D3DFULLSCREEN_TOGGLE` |
|
||||
| 1038 | `ID_WINDOW_TO_PRIMARYSCREEN` |
|
||||
| 1040 | `ID_VIEW_RESETSTATS` |
|
||||
| 1041 | `ID_VIEW_TEARING_TEST` |
|
||||
| 1042 | `ID_VIEW_DISPLAYSTATS` |
|
||||
| 1043 | `ID_VIEW_REMAINING_TIME` |
|
||||
| 1044 | `ID_VIEW_EVROUTPUTRANGE_0_255` |
|
||||
| 1045 | `ID_VIEW_EVROUTPUTRANGE_16_235` |
|
||||
| 1046 | `ID_VIEW_EXCLUSIVE_FULLSCREEN` |
|
||||
| 1053 | `ID_VIEW_ENABLEFRAMETIMECORRECTION` |
|
||||
| 1066 | `ID_VIEW_VSYNC` |
|
||||
| 1067 | `ID_VIEW_VSYNCINTERNAL` |
|
||||
| 1070 | `ID_VIEW_VSYNCOFFSET_DECREASE` |
|
||||
| 1071 | `ID_VIEW_VSYNCOFFSET_INCREASE` |
|
||||
| 1075 | `ID_VIEW_RESET_DEFAULT` |
|
||||
|
||||
### Aspect ratio presets
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 850 | `ID_ASPECTRATIO_SOURCE` |
|
||||
| 851 | `ID_ASPECTRATIO_4_3` |
|
||||
| 852 | `ID_ASPECTRATIO_5_4` |
|
||||
| 853 | `ID_ASPECTRATIO_16_9` |
|
||||
| 854 | `ID_ASPECTRATIO_235_100` |
|
||||
| 855 | `ID_ASPECTRATIO_185_100` |
|
||||
|
||||
### Pan and scan commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 868 | `ID_PANSCAN_MOVELEFT` |
|
||||
| 869 | `ID_PANSCAN_MOVERIGHT` |
|
||||
| 870 | `ID_PANSCAN_MOVEUP` |
|
||||
| 871 | `ID_PANSCAN_MOVEDOWN` |
|
||||
| 872 | `ID_PANSCAN_MOVEUPLEFT` |
|
||||
| 873 | `ID_PANSCAN_MOVEUPRIGHT` |
|
||||
| 874 | `ID_PANSCAN_MOVEDOWNLEFT` |
|
||||
| 875 | `ID_PANSCAN_MOVEDOWNRIGHT` |
|
||||
| 876 | `ID_PANSCAN_CENTER` |
|
||||
| 880 | `ID_PANSCAN_FLIP` |
|
||||
| 881 | `ID_PANSCAN_ROTATE_CCW` |
|
||||
| 882 | `ID_PANSCAN_ROTATE_CW` |
|
||||
|
||||
### On-top modes
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 883 | `ID_ONTOP_NEVER` |
|
||||
| 884 | `ID_ONTOP_ALWAYS` |
|
||||
| 885 | `ID_ONTOP_WHILEPLAYING` |
|
||||
| 886 | `ID_ONTOP_WHILEPLAYINGVIDEO` |
|
||||
|
||||
### Playback commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 887 | `ID_PLAY_PLAY` |
|
||||
| 888 | `ID_PLAY_PAUSE` |
|
||||
| 889 | `ID_PLAY_PLAYPAUSE` |
|
||||
| 890 | `ID_PLAY_STOP` |
|
||||
| 891 | `ID_PLAY_FRAMESTEP` |
|
||||
| 892 | `ID_PLAY_FRAMESTEP_BACK` |
|
||||
| 893 | `ID_PLAY_GOTO` |
|
||||
| 894 | `ID_PLAY_DECRATE` |
|
||||
| 895 | `ID_PLAY_INCRATE` |
|
||||
| 896 | `ID_PLAY_RESETRATE` |
|
||||
| 897 | `ID_PLAY_SEEKKEYBACKWARD` |
|
||||
| 898 | `ID_PLAY_SEEKKEYFORWARD` |
|
||||
| 899 | `ID_PLAY_SEEKBACKWARDSMALL` |
|
||||
| 900 | `ID_PLAY_SEEKFORWARDSMALL` |
|
||||
| 901 | `ID_PLAY_SEEKBACKWARDMED` |
|
||||
| 902 | `ID_PLAY_SEEKFORWARDMED` |
|
||||
| 903 | `ID_PLAY_SEEKBACKWARDLARGE` |
|
||||
| 904 | `ID_PLAY_SEEKFORWARDLARGE` |
|
||||
| 905 | `ID_PLAY_AUDIODELAY_PLUS` |
|
||||
| 906 | `ID_PLAY_AUDIODELAY_MINUS` |
|
||||
| 995 | `ID_PLAY_AUDIODELAY_ONOFF` |
|
||||
| 1085 | `ID_PLAY_SEEKBEGIN` |
|
||||
| 1201 | `ID_PLAY_REPEAT_AB` |
|
||||
| 1202 | `ID_PLAY_REPEAT_AB_MARK_A` |
|
||||
| 1203 | `ID_PLAY_REPEAT_AB_MARK_B` |
|
||||
|
||||
### Volume commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 907 | `ID_VOLUME_UP` |
|
||||
| 908 | `ID_VOLUME_DOWN` |
|
||||
| 909 | `ID_VOLUME_MUTE` |
|
||||
| 910 | `ID_VOLUME_MUTE_OFF` |
|
||||
| 911 | `ID_VOLUME_MUTE_DISABLED` |
|
||||
| 970 | `ID_VOLUME_GAIN_INC` |
|
||||
| 971 | `ID_VOLUME_GAIN_DEC` |
|
||||
| 972 | `ID_VOLUME_GAIN_OFF` |
|
||||
| 973 | `ID_VOLUME_GAIN_MAX` |
|
||||
|
||||
### After playback commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 912 | `ID_AFTERPLAYBACK_CLOSE` |
|
||||
| 913 | `ID_AFTERPLAYBACK_STANDBY` |
|
||||
| 914 | `ID_AFTERPLAYBACK_HIBERNATE` |
|
||||
| 915 | `ID_AFTERPLAYBACK_SHUTDOWN` |
|
||||
| 916 | `ID_AFTERPLAYBACK_LOGOFF` |
|
||||
| 917 | `ID_AFTERPLAYBACK_LOCK` |
|
||||
| 947 | `ID_AFTERPLAYBACK_NEXT` |
|
||||
| 948 | `ID_AFTERPLAYBACK_DONOTHING` |
|
||||
| 1029 | `ID_AFTERPLAYBACK_ONCE` |
|
||||
| 1030 | `ID_AFTERPLAYBACK_EVERYTIME` |
|
||||
| 1077 | `ID_AFTERPLAYBACK_EXIT` |
|
||||
| 1078 | `ID_AFTERPLAYBACK_CLOSE_FILE` |
|
||||
| 1079 | `ID_AFTERPLAYBACK_NEXT_LOOPED` |
|
||||
| 1080 | `ID_AFTERPLAYBACK_CLOSE_FILE_AND_MINIMIZE` |
|
||||
| 1081 | `ID_AFTERPLAYBACK_EVERYTIMEDONOTHING` |
|
||||
|
||||
### Navigation commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 919 | `ID_NAVIGATE_SKIPBACKFILE` |
|
||||
| 920 | `ID_NAVIGATE_SKIPFORWARDFILE` |
|
||||
| 921 | `ID_NAVIGATE_SKIPBACK` |
|
||||
| 922 | `ID_NAVIGATE_SKIPFORWARD` |
|
||||
| 923 | `ID_NAVIGATE_TITLEMENU` |
|
||||
| 924 | `ID_NAVIGATE_ROOTMENU` |
|
||||
| 925 | `ID_NAVIGATE_SUBPICTUREMENU` |
|
||||
| 926 | `ID_NAVIGATE_AUDIOMENU` |
|
||||
| 927 | `ID_NAVIGATE_ANGLEMENU` |
|
||||
| 928 | `ID_NAVIGATE_CHAPTERMENU` |
|
||||
| 929 | `ID_NAVIGATE_MENU_LEFT` |
|
||||
| 930 | `ID_NAVIGATE_MENU_RIGHT` |
|
||||
| 931 | `ID_NAVIGATE_MENU_UP` |
|
||||
| 932 | `ID_NAVIGATE_MENU_DOWN` |
|
||||
| 933 | `ID_NAVIGATE_MENU_ACTIVATE` |
|
||||
| 934 | `ID_NAVIGATE_MENU_BACK` |
|
||||
| 935 | `ID_NAVIGATE_MENU_LEAVE` |
|
||||
| 974 | `ID_NAVIGATE_TUNERSCAN` |
|
||||
| 1033 | `ID_NAVIGATE_SUBTITLES` |
|
||||
| 1034 | `ID_NAVIGATE_AUDIO` |
|
||||
|
||||
### Menu, favorites, and help commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 936 | `ID_MENU_FAVORITES` |
|
||||
| 937 | `ID_FAVORITES_ORGANIZE` |
|
||||
| 938 | `ID_FAVORITES_ADD` |
|
||||
| 939 | `ID_HELP_HOMEPAGE` |
|
||||
| 940 | `ID_HELP_DONATE` |
|
||||
| 941 | `ID_HELP_SHOWCOMMANDLINESWITCHES` |
|
||||
| 942 | `ID_HELP_TOOLBARIMAGES` |
|
||||
| 943 | `ID_HELP_ABOUT` |
|
||||
| 944 | `ID_BOSS` |
|
||||
| 949 | `ID_MENU_PLAYER_LONG` |
|
||||
| 950 | `ID_MENU_PLAYER_SHORT` |
|
||||
| 951 | `ID_MENU_FILTERS` |
|
||||
| 975 | `ID_FAVORITES_QUICKADD` |
|
||||
| 1000 | `ID_MENU_AUDIOLANG` |
|
||||
| 1001 | `ID_MENU_SUBTITLELANG` |
|
||||
| 1002 | `ID_MENU_JUMPTO` |
|
||||
| 1003 | `ID_MENU_AFTERPLAYBACK` |
|
||||
| 1006 | `ID_MENU_RECENT_FILES` |
|
||||
| 1007 | `ID_START` |
|
||||
| 1008 | `ID_SAVE` |
|
||||
| 1018 | `ID_SHOW_HISTORY` |
|
||||
| 1019 | `ID_RECENT_FILES_CLEAR` |
|
||||
| 1032 | `ID_HELP_CHECKFORUPDATE` |
|
||||
|
||||
### Stream and subtitle/audio switching commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 952 | `ID_STREAM_AUDIO_NEXT` |
|
||||
| 953 | `ID_STREAM_AUDIO_PREV` |
|
||||
| 954 | `ID_STREAM_SUB_NEXT` |
|
||||
| 955 | `ID_STREAM_SUB_PREV` |
|
||||
| 956 | `ID_STREAM_SUB_ONOFF` |
|
||||
| 961 | `ID_STREAM_VIDEO_NEXT` |
|
||||
| 962 | `ID_STREAM_VIDEO_PREV` |
|
||||
| 1150 | `ID_AUDIO_CENTER_INC` |
|
||||
| 1151 | `ID_AUDIO_CENTER_DEC` |
|
||||
| 1160 | `ID_AUDIO_OPTIONS` |
|
||||
| 1170 | `ID_SUBTITLES_OPTIONS` |
|
||||
| 1171 | `ID_SUBTITLES_ENABLE` |
|
||||
| 1172 | `ID_SUBTITLES_STYLES` |
|
||||
| 1173 | `ID_SUBTITLES_RELOAD` |
|
||||
| 1175 | `ID_SUBTITLES_DEFSTYLE` |
|
||||
| 1176 | `ID_SUBTITLES_FORCEDONLY` |
|
||||
| 1177 | `ID_SUBTITLES_STEREO_DONTUSE` |
|
||||
| 1178 | `ID_SUBTITLES_STEREO_SIDEBYSIDE` |
|
||||
| 1179 | `ID_SUBTITLES_STEREO_TOPBOTTOM` |
|
||||
|
||||
### Misc playback and OSD commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 967 | `ID_REPEAT_FOREVER` |
|
||||
| 984 | `ID_COLOR_BRIGHTNESS_INC` |
|
||||
| 985 | `ID_COLOR_BRIGHTNESS_DEC` |
|
||||
| 986 | `ID_COLOR_CONTRAST_INC` |
|
||||
| 987 | `ID_COLOR_CONTRAST_DEC` |
|
||||
| 988 | `ID_COLOR_HUE_INC` |
|
||||
| 989 | `ID_COLOR_HUE_DEC` |
|
||||
| 990 | `ID_COLOR_SATURATION_INC` |
|
||||
| 991 | `ID_COLOR_SATURATION_DEC` |
|
||||
| 992 | `ID_COLOR_RESET` |
|
||||
| 994 | `ID_NORMALIZE` |
|
||||
| 997 | `ID_COPY_IMAGE` |
|
||||
| 1012 | `ID_SHIFT_SUB_DOWN` |
|
||||
| 1013 | `ID_SHIFT_SUB_UP` |
|
||||
| 1014 | `ID_GOTO_PREV_SUB` |
|
||||
| 1015 | `ID_GOTO_NEXT_SUB` |
|
||||
| 1021 | `ID_SHADERS_1_ENABLE` |
|
||||
| 1022 | `ID_SHADERS_2_ENABLE` |
|
||||
| 1036 | `ID_OSD_LOCAL_TIME` |
|
||||
| 1037 | `ID_OSD_FILE_NAME` |
|
||||
| 1039 | `ID_SHADERS_SELECT` |
|
||||
| 1100 | `ID_SUB_POS_UP` |
|
||||
| 1101 | `ID_SUB_POS_DOWN` |
|
||||
| 1102 | `ID_SUB_POS_LEFT` |
|
||||
| 1103 | `ID_SUB_POS_RIGHT` |
|
||||
| 1104 | `ID_SUB_POS_RESTORE` |
|
||||
| 1106 | `ID_SUB_COPYTOCLIPBOARD` |
|
||||
| 1107 | `ID_SUB_SIZE_DEC` |
|
||||
| 1108 | `ID_SUB_SIZE_INC` |
|
||||
| 1110 | `ID_STEREO3D_AUTO` |
|
||||
| 1111 | `ID_STEREO3D_MONO` |
|
||||
| 1112 | `ID_STEREO3D_ROW_INTERLEAVED` |
|
||||
| 1113 | `ID_STEREO3D_ROW_INTERLEAVED_2X` |
|
||||
| 1114 | `ID_STEREO3D_HALFOVERUNDER` |
|
||||
| 1115 | `ID_STEREO3D_OVERUNDER` |
|
||||
| 1120 | `ID_STEREO3D_SWAP_LEFTRIGHT` |
|
||||
| 1200 | `ID_SHOW_MILLISECONDS` |
|
||||
| 1210 | `ID_ADDTOPLAYLISTROMCLIPBOARD` |
|
||||
| 1211 | `ID_MOVEWINDOWBYVIDEO_ONOFF` |
|
||||
| 1212 | `ID_PLAYLIST_OPENFOLDER` |
|
||||
|
||||
## Practical implications for the web API
|
||||
|
||||
Because `OnCommand()` forwards any positive `wm_command` to `WM_COMMAND`, the effective web API surface is:
|
||||
|
||||
- all positive command IDs from the `resource.h` command block
|
||||
- plus the two special web-only commands:
|
||||
- `-1` seek
|
||||
- `-2` set volume
|
||||
|
||||
That means the following are all valid examples:
|
||||
|
||||
```text
|
||||
/command.html?wm_command=887
|
||||
/command.html?wm_command=889
|
||||
/command.html?wm_command=899
|
||||
/command.html?wm_command=902
|
||||
/command.html?wm_command=907
|
||||
/command.html?wm_command=909
|
||||
/command.html?wm_command=921
|
||||
/command.html?wm_command=952
|
||||
/command.html?wm_command=1171
|
||||
```
|
||||
|
||||
## Known good examples
|
||||
|
||||
### Play
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=887
|
||||
```
|
||||
|
||||
### Pause
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=888
|
||||
```
|
||||
|
||||
### Toggle play/pause
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=889
|
||||
```
|
||||
|
||||
### Stop
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=890
|
||||
```
|
||||
|
||||
### Exit player
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=816
|
||||
```
|
||||
|
||||
### Small backward seek
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=899
|
||||
```
|
||||
|
||||
### Small forward seek
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=900
|
||||
```
|
||||
|
||||
### Medium backward seek
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=901
|
||||
```
|
||||
|
||||
### Medium forward seek
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=902
|
||||
```
|
||||
|
||||
### Large backward seek
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=903
|
||||
```
|
||||
|
||||
### Large forward seek
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=904
|
||||
```
|
||||
|
||||
### Volume up / down / mute
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=907
|
||||
GET /command.html?wm_command=908
|
||||
GET /command.html?wm_command=909
|
||||
```
|
||||
|
||||
### Absolute seek to position
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=-1&position=00:12:30
|
||||
```
|
||||
|
||||
### Absolute seek by percent
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=-1&percent=25
|
||||
```
|
||||
|
||||
### Set absolute volume
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=-2&volume=80
|
||||
```
|
||||
|
||||
### Read live player state
|
||||
|
||||
```text
|
||||
GET /variables.html
|
||||
```
|
||||
|
||||
## MediaHive integration notes
|
||||
|
||||
Current MediaHive integration uses the MPC-BE web interface in two ways:
|
||||
|
||||
- frontend status indication through MediaHive's backend proxy
|
||||
- native Python control in `mediahive.winmain`, which sends MPC-BE web requests directly
|
||||
|
||||
Current native command usage is centered on:
|
||||
|
||||
- `889` for play/pause
|
||||
- `816` for exit
|
||||
- `-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
|
||||
|
||||
- Prefer source-backed IDs from `resource.h` over icon inference from the HTML pages.
|
||||
- Prefer `-1&position=...` or `-1&percent=...` when deterministic seek positioning is needed.
|
||||
- Use `/variables.html` for timing and state.
|
||||
- Treat the web interface as version-dependent: the command list here is accurate for the inspected upstream `master` branch and may differ across releases.
|
||||
@@ -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 |
|
||||
@@ -2,7 +2,11 @@
|
||||
"tasks": {
|
||||
"dev": "deno run -A npm:vite",
|
||||
"build": "deno run -A npm:vue-tsc --noEmit && deno run -A npm:vite build",
|
||||
"preview": "deno run -A npm:vite preview"
|
||||
"preview": "deno run -A npm:vite preview",
|
||||
"lint": "deno run -A npm:oxlint --vue-plugin --import-plugin src",
|
||||
"lint:fix": "deno task lint --fix",
|
||||
"format": "deno run -A npm:oxfmt --config oxfmt.json src",
|
||||
"format:check": "deno run -A npm:oxfmt --config oxfmt.json --check src"
|
||||
},
|
||||
"imports": {
|
||||
"vue": "npm:vue@^3.4.0"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"semi": false,
|
||||
"ignorePatterns": []
|
||||
}
|
||||
@@ -6,17 +6,23 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"lint": "oxlint --vue-plugin --import-plugin src",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"format": "oxfmt --config oxfmt.json src",
|
||||
"format:check": "oxfmt --config oxfmt.json --check src"
|
||||
},
|
||||
"dependencies": {
|
||||
"country-flag-icons": "^1.6.17",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"oxfmt": "^0.51.0",
|
||||
"oxlint": "^1.66.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,401 @@
|
||||
import type { MediaIndex } from './types';
|
||||
export interface PlayerStatus {
|
||||
remote: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the media index from the server
|
||||
*/
|
||||
export async function loadMediaIndex(): Promise<MediaIndex> {
|
||||
const response = await fetch('/api/index');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load media index: ${response.statusText}`);
|
||||
export interface PlayerInfo {
|
||||
id: string
|
||||
name: string
|
||||
family: string
|
||||
path: string | null
|
||||
}
|
||||
|
||||
export interface RootEntry {
|
||||
root_id: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface ActionTimingContext {
|
||||
actionStartedAt?: number
|
||||
source?: string
|
||||
}
|
||||
|
||||
function nowMs(): number {
|
||||
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
||||
return performance.now()
|
||||
}
|
||||
return response.json();
|
||||
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 {
|
||||
return input
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^[A-Za-z]:\//, "")
|
||||
.replace(/^\/+/, "")
|
||||
}
|
||||
|
||||
export function isVideoPath(path: string | null | undefined): boolean {
|
||||
return Boolean(path && /\.(webm|mp4|mkv|avi|mov)$/i.test(path))
|
||||
}
|
||||
|
||||
export interface VideoSourceAttributes {
|
||||
type: string
|
||||
codecs: string
|
||||
}
|
||||
|
||||
export function isSafariBrowser(): boolean {
|
||||
if (typeof navigator === "undefined") {
|
||||
return false
|
||||
}
|
||||
|
||||
const ua = navigator.userAgent
|
||||
return /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|FxiOS/i.test(ua)
|
||||
}
|
||||
|
||||
export function getVideoPreviewUrl(url: string): string {
|
||||
if (!url || !isSafariBrowser()) {
|
||||
return url
|
||||
}
|
||||
|
||||
if (url.includes("#")) {
|
||||
return url
|
||||
}
|
||||
|
||||
// Safari often needs a tiny time offset to paint the first frame before playback.
|
||||
return `${url}#t=0.001`
|
||||
}
|
||||
|
||||
export function getVideoSourceAttributes(path: string | null | undefined): VideoSourceAttributes {
|
||||
if (!path) {
|
||||
return { type: "video/mp4", codecs: "hvc1" }
|
||||
}
|
||||
|
||||
if (/\.webm$/i.test(path)) {
|
||||
return { type: "video/webm", codecs: "av1" }
|
||||
}
|
||||
|
||||
if (/\.mp4$/i.test(path) || /\.m4v$/i.test(path)) {
|
||||
return { type: "video/mp4", codecs: "hvc1" }
|
||||
}
|
||||
|
||||
if (/\.mov$/i.test(path)) {
|
||||
return { type: "video/quicktime", codecs: "hvc1" }
|
||||
}
|
||||
|
||||
if (/\.avi$/i.test(path)) {
|
||||
return { type: "video/x-msvideo", codecs: "" }
|
||||
}
|
||||
|
||||
if (/\.mkv$/i.test(path)) {
|
||||
return { type: "video/x-matroska", codecs: "" }
|
||||
}
|
||||
|
||||
return { type: "video/mp4", codecs: "hvc1" }
|
||||
}
|
||||
|
||||
function encodePathSegments(path: string): string {
|
||||
return path
|
||||
.split("/")
|
||||
.filter((segment) => segment.length > 0)
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/")
|
||||
}
|
||||
|
||||
function normalizeCoverPath(path: string): string {
|
||||
return path
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^[A-Za-z]:\//, "")
|
||||
.replace(/^\/+/, "")
|
||||
}
|
||||
|
||||
function toRootAssetPath(path: string): string | null {
|
||||
const normalized = normalizeCoverPath(path)
|
||||
const marker = ".mediahive/"
|
||||
const idx = normalized.toLowerCase().indexOf(marker)
|
||||
if (idx < 0) return null
|
||||
const logical = normalized.slice(idx + marker.length)
|
||||
return logical.length > 0 ? logical : null
|
||||
}
|
||||
|
||||
function splitAssetTypePath(assetPath: string): { assetType: string; relativePath: string } | null {
|
||||
const parts = assetPath.split("/").filter((segment) => segment.length > 0)
|
||||
if (parts.length < 2) return null
|
||||
const [assetType, ...rest] = parts
|
||||
if (!assetType || !["movies", "series", "people"].includes(assetType.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return { assetType: assetType.toLowerCase(), relativePath: rest.join("/") }
|
||||
}
|
||||
|
||||
/** Watch progress for one episode of a series. */
|
||||
export interface EpisodeWatchEntry {
|
||||
pos: number
|
||||
done: boolean
|
||||
}
|
||||
|
||||
/** One stored continue point. season/episode are set for series, null for movies. */
|
||||
export interface ResumePositionEntry {
|
||||
pos: number
|
||||
season: number | null
|
||||
episode: number | null
|
||||
/** Per-episode watch progress for series, keyed "S<season>E<episode>". */
|
||||
episodes?: Record<string, EpisodeWatchEntry>
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a media file with the system's default player
|
||||
* Fetch merged resume positions from all roots.
|
||||
*/
|
||||
export async function playMedia(filePath: string): Promise<void> {
|
||||
export async function fetchResumePositions(): Promise<Record<string, ResumePositionEntry>> {
|
||||
try {
|
||||
const response = await fetch('/api/play', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: filePath }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || response.statusText);
|
||||
const response = await fetch("/api/meta/playback-state")
|
||||
if (!response.ok) return {}
|
||||
const data = await response.json().catch(() => ({}))
|
||||
const positions = data?.data?.resume_positions
|
||||
if (!positions || typeof positions !== "object") {
|
||||
return {}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Play media error:', e);
|
||||
alert(`Failed to play: ${e}`);
|
||||
const normalized: Record<string, ResumePositionEntry> = {}
|
||||
for (const [slug, value] of Object.entries(positions as Record<string, unknown>)) {
|
||||
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 {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a folder in Windows Explorer
|
||||
* 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.
|
||||
*/
|
||||
export async function openFolder(folderPath: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/api/open-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_path: folderPath }),
|
||||
});
|
||||
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
|
||||
*/
|
||||
export async function replaceRoots(
|
||||
roots: Record<string, string>,
|
||||
): Promise<{ accepted: RootEntry[]; failed: unknown[] }> {
|
||||
const response = await fetch("/api/config/roots", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roots }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || response.statusText);
|
||||
const err = await response.json().catch(() => ({ detail: response.statusText }))
|
||||
throw new Error(err.detail || response.statusText)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch detected media players from the backend.
|
||||
*/
|
||||
export async function fetchPlayers(): Promise<PlayerInfo[]> {
|
||||
const response = await fetch("/api/players")
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load players: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.players || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a media file with the selected player.
|
||||
*/
|
||||
export async function playMedia(
|
||||
rootId: string,
|
||||
filePath: string,
|
||||
playerId?: string | null,
|
||||
playerCustomCmd?: string | null,
|
||||
timing?: ActionTimingContext,
|
||||
): Promise<void> {
|
||||
const normalizedPath = normalizeMediaPath(filePath)
|
||||
const body: Record<string, unknown> = { file_path: normalizedPath }
|
||||
if (playerId) body.player_id = playerId
|
||||
if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd
|
||||
const actionStart = timing?.actionStartedAt ?? nowMs()
|
||||
const traceId = makeTraceId("play")
|
||||
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)}`, {
|
||||
method: "POST",
|
||||
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),
|
||||
})
|
||||
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) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || response.statusText)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Open folder error:', e);
|
||||
alert(`Failed to open folder: ${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}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a folder in the system file manager
|
||||
*/
|
||||
export async function openFolder(
|
||||
rootId: string,
|
||||
folderPath: string,
|
||||
timing?: ActionTimingContext,
|
||||
): Promise<void> {
|
||||
const normalizedPath = normalizeMediaPath(folderPath)
|
||||
const actionStart = timing?.actionStartedAt ?? nowMs()
|
||||
const traceId = makeTraceId("open-folder")
|
||||
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)}`, {
|
||||
method: "POST",
|
||||
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 }),
|
||||
})
|
||||
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) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || response.statusText)
|
||||
}
|
||||
} catch (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}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether MPC-BE local web control is currently reachable.
|
||||
* @param port - Optional custom port (default 13579)
|
||||
*/
|
||||
export async function isMpcBeReachable(port?: number | null): Promise<boolean> {
|
||||
try {
|
||||
const url = port ? `/api/mpcbe/status?port=${port}` : "/api/mpcbe/status"
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) return false
|
||||
const data = await response.json().catch(() => ({}))
|
||||
return Boolean(data.reachable)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return player integration capabilities for the current OS.
|
||||
* @param port - Optional custom MPC-BE port (default 13579)
|
||||
*/
|
||||
export async function getPlayerStatus(port?: number | null): Promise<PlayerStatus> {
|
||||
const url = port ? `/api/player/status?port=${port}` : "/api/player/status"
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load player status: ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a cover path to a displayable URL.
|
||||
* Uses FastAPI server for async file serving.
|
||||
@@ -58,57 +403,36 @@ export async function openFolder(folderPath: string): Promise<void> {
|
||||
* The path comes from the server already converted to Windows format (Z:\...)
|
||||
* Paths starting with '/' are TMDB relative paths that weren't fetched - ignore them
|
||||
*/
|
||||
export function getCoverUrl(coverPath: string | null): string {
|
||||
export function getCoverUrl(coverPath: string | null, rootId?: string | null): string {
|
||||
if (!coverPath) {
|
||||
return '';
|
||||
return ""
|
||||
}
|
||||
// Ignore TMDB relative paths (start with /) - these are bugs in the index
|
||||
if (coverPath.startsWith('/')) {
|
||||
return '';
|
||||
if (coverPath.startsWith("/")) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Convert relative path to URL path for FastAPI server
|
||||
// .mediahive/covers/Movies/... -> /media/.mediahive/covers/Movies/...
|
||||
let urlPath = coverPath;
|
||||
|
||||
// Remove drive letter (Z:) and convert backslashes to forward slashes
|
||||
if (urlPath.match(/^[A-Za-z]:/)) {
|
||||
urlPath = urlPath.substring(2);
|
||||
const rid = rootId || "unknown"
|
||||
const assetPath = toRootAssetPath(coverPath)
|
||||
if (assetPath) {
|
||||
const split = splitAssetTypePath(assetPath)
|
||||
if (split) {
|
||||
return `/api/assets/${encodeURIComponent(rid)}/${encodeURIComponent(split.assetType)}/${encodePathSegments(split.relativePath)}`
|
||||
}
|
||||
urlPath = urlPath.replace(/\\/g, '/');
|
||||
|
||||
// Ensure path starts with /
|
||||
if (!urlPath.startsWith('/')) {
|
||||
urlPath = '/' + urlPath;
|
||||
}
|
||||
|
||||
// Encode URI components but preserve slashes
|
||||
const encodedPath = urlPath.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
||||
|
||||
return `/api/media${encodedPath}`;
|
||||
const mediaPath = normalizeCoverPath(coverPath)
|
||||
return `/api/media/${encodeURIComponent(rid)}/${encodePathSegments(mediaPath)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the native OS folder picker via pywebview, then switch the server's
|
||||
* media folder in-place and reload the page. Only works inside the packaged
|
||||
* desktop app.
|
||||
* Invoke the native OS folder picker via pywebview, then add the selected
|
||||
* folder to the server's root list. Only works inside the packaged desktop app.
|
||||
*/
|
||||
export async function pickFolderAndRestart(): Promise<void> {
|
||||
export async function pickFolderAndAddRoot(): Promise<string | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const api = (window as any).pywebview?.api;
|
||||
if (!api) return;
|
||||
const folder: string | null = await api.pick_folder();
|
||||
if (!folder) return;
|
||||
const res = await fetch('/api/change-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder }),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Give the server a moment to complete the background folder switch before reloading
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
alert(`Failed to change folder: ${err.detail || res.statusText}`);
|
||||
}
|
||||
const api = (window as any).pywebview?.api
|
||||
if (!api) return null
|
||||
const folder: string | null = await api.pick_folder()
|
||||
return folder
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,37 @@
|
||||
<svg width="119" height="140" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<linearGradient id="imagebot_9">
|
||||
<stop stop-color="#b3925d" id="imagebot_65"/>
|
||||
<stop stop-color="#5d4014" id="imagebot_64" offset="1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="imagebot_24">
|
||||
<stop stop-color="#fff" id="imagebot_63"/>
|
||||
<stop stop-opacity="0" stop-color="#fff" offset="1" id="imagebot_62"/>
|
||||
</linearGradient>
|
||||
<linearGradient x1="3.83529" y2="0.26302" x2="3.83529" y1="-0.34629" id="imagebot_16" xlink:href="#imagebot_24"/>
|
||||
<linearGradient x1="2.18514" y2="0.37278" x2="2.18514" y1="-0.01478" id="imagebot_20" xlink:href="#imagebot_24"/>
|
||||
<linearGradient x1="-0.55279" y2="1.01379" x2="-0.55279" y1="-0.01379" id="imagebot_17" xlink:href="#imagebot_9"/>
|
||||
<linearGradient y2="1" x2="0.5" y1="0" x1="0.5" id="imagebot_82">
|
||||
<stop offset="0" stop-opacity="0.99219" stop-color="#542800"/>
|
||||
<stop offset="1" stop-opacity="0.99219" stop-color="#662000"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="imagebot_83" y2="1" x2="0.5" y1="0" x1="0.5">
|
||||
<stop offset="0" stop-opacity="0.99219" stop-color="#542800"/>
|
||||
<stop offset="1" stop-opacity="0.99219" stop-color="#662000"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g>
|
||||
<title>Layer 1</title>
|
||||
<path id="imagebot_79" stroke-width="0.5" stroke="#57401e" fill-rule="evenodd" fill="url(#imagebot_17)"/>
|
||||
<g transform="translate(0 3.06434)" id="imagebot_102">
|
||||
<path d="M 22.74422 74.74535044870376 C 31.91926 58.45145044870377 37.64859 41.81313044870377 42.85091 29.248270448703767 C 41.90924 13.406620448703766 52.72854 8.192040448703766 59.79554 8.192040448703766 C 66.86253 8.192040448703766 78.99256 12.669340448703766 76.0848 29.534990448703766 C 80.95944 42.30465044870377 86.3611 59.270660448703765 95.53614 76.05607044870376 C 115.8523 130.92610044870378 73.92952 131.87786044870376 59.79554 131.87786044870376 C 45.66155 131.87786044870376 3.4111000000000002 116.83586044870377 22.74422 74.74535044870376 z" id="imagebot_80" stroke-linecap="null" stroke-linejoin="null" stroke-dasharray="null" stroke="#000000" fill="url(#imagebot_83)"/>
|
||||
<path transform="translate(0 0.512) matrix(1 0 0 1 0 -37.4937)" id="imagebot_92" stroke-width="1.143" stroke="url(#imagebot_16)" fill="none" opacity="0.586" d="M 59.47936427844316 46.39889503216985 C 51.40949466195967 46.6597993524647 46.7912370464379 50.28419053324464 44.19117979551757 55.13965915058735 C 50.034283878930864 61.52322215922001 68.81172041094496 64.17701625988963 74.92515422056397 54.788942632751485 C 72.23901202492665 50.05236559006813 67.80056983488612 46.54991700945619 61.10049593995004 46.39889503216985 C 60.54621353239983 46.386403240385086 59.993902535797375 46.38226202213887 59.47936427844316 46.39889503216985 z"/>
|
||||
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="1.113" stroke="#323232" fill-rule="evenodd" fill="#000000" d="M 0.7986898918151866 174.22619862772942 C -0.097920108184816 164.68506862772944 0.9079298918151864 145.87931862772942 14.117579891815183 142.82343862772942 L 57.65248989181518 133.5089986277294 L 103.1211998918152 142.15179862772942 C 116.5566498918152 145.02410862772942 118.02105989181518 163.55207862772943 118.42437989181519 173.9827686277294 C 0.5719998918151816 174.42936862772942 118.47697989181518 173.89226862772944 0.7986898918151866 174.22619862772942 z" id="imagebot_2"/>
|
||||
<path stroke-width="1.113" stroke="url(#imagebot_20)" fill="#000000" opacity="0.593" d="M 57.1773 97.07883872083664 L 14.434240000000003 106.71762872083664 L 14.39866 106.71762872083664 C 8.436970000000002 108.11243872083665 5.2424800000000005 112.98359872083665 3.5303200000000032 118.97568872083664 C 1.9480099999999965 124.51329872083664 1.8499800000000022 130.70706872083665 2.1762299999999972 135.29601872083663 C 30.032970000000006 135.21761872083664 45.100809999999996 135.15751872083663 52.241870000000006 135.15347872083663 C 55.91150999999999 135.15147872083665 57.73647 135.18207872083664 58.655969999999996 135.18907872083665 C 59.11572000000001 135.19307872083664 59.32381000000001 135.18307872083665 59.47555 135.18907872083665 C 59.52641 135.19107872083663 59.61632 135.21627872083664 59.68934999999999 135.22467872083664 C 59.69961000000001 135.22501872083663 59.77909 135.22430872083663 59.7962 135.22467872083664 C 59.89894000000001 135.22667872083665 60.04916 135.22367872083663 60.43761000000001 135.22467872083664 C 61.34398999999999 135.22767872083665 63.17347000000001 135.23367872083665 66.85171 135.22467872083664 C 73.98958 135.20697872083664 89.13769000000002 135.15137872083665 116.88172 135.04650872083664 C 116.64753000000002 130.02106872083664 116.11918 123.72255872083664 114.28044 118.19166872083665 C 112.27774 112.16755872083664 108.95868999999999 107.39762872083665 102.87759 106.07614872083664 L 57.1773 97.07883872083664 z" id="imagebot_3"/>
|
||||
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="1.113" stroke="#000000" fill-rule="evenodd" fill="#ffcdb2" d="M 45.24016 124.68690958276748 C 45.47224 130.3192395827675 44.13723 133.56532958276748 41.49851000000001 138.3393595827675 C 46.63544 147.78121958276748 51.76527999999999 153.7549695827675 59.62002 160.2540295827675 C 67.47475 152.82669958276747 73.54878 146.3342795827675 77.70689 137.80701958276748 C 76.45166 134.35116958276748 75.19644 129.66651958276748 73.94121 124.57226958276749 L 45.24016 124.68690958276748 z" id="imagebot_4"/>
|
||||
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="0.976" stroke-linejoin="round" stroke="#000000" fill="#ffcdb2" d="M 87.18131 100.67885958276749 C 87.18131 119.35216958276749 75.33934 134.0977195827675 58.60526 138.1937195827675 C 45.14797999999999 134.0977195827675 32.48680999999999 119.35216958276749 32.48680999999999 100.67885958276749 C 32.48680999999999 82.00555958276749 44.73838000000001 66.85040958276748 59.834059999999994 66.85040958276748 C 74.92974 66.85040958276748 87.18131 82.00555958276749 87.18131 100.67885958276749 z" id="imagebot_5"/>
|
||||
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke="#000000" fill-rule="evenodd" fill="url(#imagebot_82)" d="M 58.73652 60.55180958276749 C 32.629369999999994 61.25627958276748 27.718890000000002 80.5286295827675 29.11278 95.02406958276748 C 29.329250000000002 97.27517958276749 34.27735 131.44748958276747 35.90773 133.26849958276748 C 37.13297 121.83706958276748 35.742279999999994 80.90836958276749 49.40835 74.09559958276749 L 59.11117 77.18910958276749 L 69.46934 74.38438958276748 C 83.18997 80.30845958276748 80.91984 128.0179995827675 81.90542 133.42349958276748 C 83.38494 131.42852958276748 88.61500000000001 96.54846958276748 89.01375000000002 93.86481958276748 C 91.08852000000002 79.90128958276749 83.21866 60.963189582767484 61.350390000000004 60.55180958276749 C 60.4636 60.535119582767486 59.57871 60.52907958276749 58.73652 60.55180958276749 z" id="imagebot_6"/>
|
||||
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="1.143" stroke="url(#imagebot_16)" fill="none" opacity="0.586" d="M 58.762820000000005 61.683199582767486 C 45.99126 62.027819582767485 38.6823 66.81515958276749 34.56739 73.22858958276748 C 43.81482 81.66042958276749 73.53242 85.16573958276749 83.20768 72.76533958276748 C 78.95653 66.50894958276749 71.93215000000001 61.88267958276749 61.32846000000001 61.683199582767486 C 60.45124 61.666699582767485 59.57714 61.66122958276749 58.762820000000005 61.683199582767486 z" id="imagebot_7"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0"?>
|
||||
<svg
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:ns1="http://sozi.baierouge.fr"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
id="svg2"
|
||||
viewBox="0 0 600 600"
|
||||
version="1.1"
|
||||
>
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-743.2 97.566)"
|
||||
>
|
||||
<path
|
||||
id="path2996"
|
||||
opacity=".5"
|
||||
d="m1026.5-34.044c-14.911 1.272-30.456 4.7566-42.996 12.924-10.972 7.1462-19.233 18.368-25.552 29.836-4.6901 8.5125-6.9924 18.276-8.5878 27.864-2.5245 15.17-1.4223 46.115-1.4223 46.115l-5.4062 8.8695c-1.3634 10.729 0.37951 21.116 0.77301 31.844 0.30666 8.362 1.245 16.724 2.9677 25.086l8.5076 9.971s1.979 11.852 5.4611 20.055c3.4821 8.2036 17.093 26.995 17.093 26.995-12.537 4.3352-31.155 8.049-37.59 13.009-4.6728 3.6014-5.7623 10.625-8.1937 16.387l-115.3 21.118-14.36 21.202v30.663l-58.708 194.54 600 0.00002-18.415-62.086-22.554-126.28-1.3515-25.257-8.8696-15.036-46.375-17.063-66.226-11.573c-7.0675-7.2927-16-17.43-21.202-21.878-5.1891-4.4364-28.664-10.925-42.996-16.387l-2.7031-9.5453s7.6682-9.2528 10.221-16.387c2.5528-7.1346 3.3788-25.933 3.3788-25.933l4.8149 1.3516 4.8149-7.518 2.0273-27.284s0.3969-32.34-1.3515-36.154c-1.7485-3.8134-5.4907-4.8149-5.4907-4.8149s0.9972-29.564-2.7031-43.672c-3.8499-14.678-10.425-29.076-19.851-40.969-7.7283-9.7506-17.535-18.652-28.986-23.516-16.34-6.9413-35.177-7.9803-52.867-6.4713zm-47.135 251.89 10.897 19.766 38.941 32.775 19.09 1.3515 44.432-23.905 19.766-27.284-2.0274 10.221-23.905 68.929-19.851 64.874-12.924 59.383-13.009 72.392-32.015-193.19-19.175-40.293zm84.649 145.37-16.383 81.916-6.8263 49.524-22.835-154.12 7.8543-20.737 20.183-2.001-19.196-0.32134-12.317-25.607-6.626 5.2982 17.919-23.38 24.575 1.3653 26.964 31.742-15.647-13.051-14.73 23.632 16.383 34.814z"
|
||||
fill="#999"
|
||||
/>
|
||||
</g>
|
||||
<metadata>
|
||||
<rdf:RDF>
|
||||
<cc:Work>
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
<cc:license rdf:resource="http://creativecommons.org/licenses/publicdomain/"/>
|
||||
<dc:publisher>
|
||||
<cc:Agent rdf:about="http://openclipart.org/">
|
||||
<dc:title>Openclipart</dc:title>
|
||||
</cc:Agent>
|
||||
</dc:publisher>
|
||||
<dc:title>Generic Profile Image Placeholder - Suit</dc:title>
|
||||
<dc:date>2013-04-26T23:09:54</dc:date>
|
||||
<dc:description>Generic Profile Image Placeholder - Suit</dc:description>
|
||||
<dc:source>https://openclipart.org/detail/177482/generic-profile-image-placeholder---suit-by-naught101-177482</dc:source>
|
||||
<dc:creator>
|
||||
<cc:Agent>
|
||||
<dc:title>naught101</dc:title>
|
||||
</cc:Agent>
|
||||
</dc:creator>
|
||||
</cc:Work>
|
||||
<cc:License rdf:about="http://creativecommons.org/licenses/publicdomain/">
|
||||
<cc:permits rdf:resource="http://creativecommons.org/ns#Reproduction"/>
|
||||
<cc:permits rdf:resource="http://creativecommons.org/ns#Distribution"/>
|
||||
<cc:permits rdf:resource="http://creativecommons.org/ns#DerivativeWorks"/>
|
||||
</cc:License>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,747 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="svg9"
|
||||
width="589"
|
||||
height="320"
|
||||
viewBox="0 0 589 320"
|
||||
sodipodi:docname="en_dplus_lg_r_2x_54572343.svg"
|
||||
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs13" />
|
||||
<sodipodi:namedview
|
||||
id="namedview11"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="1.865625"
|
||||
inkscape:cx="294.27136"
|
||||
inkscape:cy="160"
|
||||
inkscape:window-width="1368"
|
||||
inkscape:window-height="842"
|
||||
inkscape:window-x="-6"
|
||||
inkscape:window-y="-6"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g15" />
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
inkscape:label="Image"
|
||||
id="g15">
|
||||
<image
|
||||
width="589"
|
||||
height="320"
|
||||
preserveAspectRatio="none"
|
||||
xlink:href="data:image/png;base64,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
|
||||
"
|
||||
id="image17" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div v-if="showDolbyLogo" class="dolby-badges" :class="{ compact }">
|
||||
<img :src="dolbyLogoSrc" :alt="dolbyLogoAlt" class="dolby-logo" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import dolbyAtmosUrl from "../assets/dolby-atmos.webp"
|
||||
import dolbyVisionUrl from "../assets/dolby-vision.webp"
|
||||
import dolbyVisionAtmosUrl from "../assets/dolby-vision-atmos.webp"
|
||||
|
||||
const props = defineProps<{
|
||||
hasDolbyVision?: boolean | null
|
||||
hasDolbyAtmos?: boolean | null
|
||||
isHdr?: boolean | null
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const hasDolbyVision = computed(() => props.hasDolbyVision === true)
|
||||
const hasDolbyAtmos = computed(() => props.hasDolbyAtmos === true)
|
||||
const compact = computed(() => props.compact === true)
|
||||
|
||||
const showDolbyLogo = computed(() => hasDolbyVision.value || hasDolbyAtmos.value)
|
||||
|
||||
const dolbyLogoSrc = computed(() => {
|
||||
if (hasDolbyVision.value && hasDolbyAtmos.value) return dolbyVisionAtmosUrl
|
||||
if (hasDolbyVision.value) return dolbyVisionUrl
|
||||
return dolbyAtmosUrl
|
||||
})
|
||||
|
||||
const dolbyLogoAlt = computed(() => {
|
||||
if (hasDolbyVision.value && hasDolbyAtmos.value) return "Dolby Vision + Dolby Atmos"
|
||||
if (hasDolbyVision.value) return "Dolby Vision"
|
||||
return "Dolby Atmos"
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dolby-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.dolby-logo {
|
||||
height: 100%;
|
||||
max-height: 44px;
|
||||
min-height: 30px;
|
||||
width: auto;
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
filter: invert(1) brightness(1.1) contrast(1.05);
|
||||
}
|
||||
|
||||
.dolby-badges.compact .dolby-logo {
|
||||
max-height: 34px;
|
||||
min-height: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<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>
|
||||
@@ -1,149 +0,0 @@
|
||||
<template>
|
||||
<section class="hero">
|
||||
<div
|
||||
v-if="coverUrl"
|
||||
class="hero-background"
|
||||
:style="{ backgroundImage: `url('${coverUrl}')` }"
|
||||
></div>
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">{{ item.title }}</h1>
|
||||
<div class="hero-meta">
|
||||
<span v-if="item.year" class="hero-year">{{ item.year }}</span>
|
||||
<span v-if="rating" class="hero-rating" :class="ratingClass">
|
||||
★ {{ rating.toFixed(1) }}
|
||||
</span>
|
||||
<span v-if="resolution" class="hero-quality">{{ resolution }}</span>
|
||||
<span v-if="quality" class="hero-quality">{{ quality }}</span>
|
||||
</div>
|
||||
<p v-if="overview" class="hero-overview">{{ overview }}</p>
|
||||
<div class="hero-buttons">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="handlePlay"
|
||||
:disabled="!playableFile"
|
||||
>
|
||||
▶ Play
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="$emit('info', item)">
|
||||
ℹ More Info
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { MediaItem, Movie, Series } from '../types';
|
||||
import { getCoverUrl } from '../api';
|
||||
|
||||
const props = defineProps<{
|
||||
item: MediaItem;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [string];
|
||||
info: [MediaItem];
|
||||
}>();
|
||||
|
||||
const coverUrl = computed(() => {
|
||||
return getCoverUrl(props.item.cover_path);
|
||||
});
|
||||
|
||||
const resolution = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
const movie = props.item.data as Movie;
|
||||
const torrents = Object.values(movie.torrents || {});
|
||||
return torrents.length > 0 ? torrents[0].resolution : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const quality = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
const movie = props.item.data as Movie;
|
||||
const torrents = Object.values(movie.torrents || {});
|
||||
return torrents.length > 0 ? torrents[0].quality : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const rating = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
return (props.item.data as Movie).info?.rating;
|
||||
}
|
||||
return (props.item.data as Series).info?.rating;
|
||||
});
|
||||
|
||||
const ratingClass = computed(() => {
|
||||
if (!rating.value) return '';
|
||||
if (rating.value >= 7.5) return 'rating-high';
|
||||
if (rating.value >= 6) return 'rating-medium';
|
||||
return 'rating-low';
|
||||
});
|
||||
|
||||
const overview = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
const o = (props.item.data as Movie).info?.overview;
|
||||
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null;
|
||||
}
|
||||
const o = (props.item.data as Series).info?.overview;
|
||||
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null;
|
||||
});
|
||||
|
||||
const playableFile = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
const movie = props.item.data as Movie;
|
||||
const torrents = Object.values(movie.torrents || {});
|
||||
return torrents.length > 0 ? torrents[0].playable_file : null;
|
||||
}
|
||||
// For series, get first available file from episodes
|
||||
const series = props.item.data as Series;
|
||||
for (const season of series.seasons || []) {
|
||||
for (const episode of season.episodes || []) {
|
||||
const torrents = Object.values(episode.torrents || {});
|
||||
for (const torrent of torrents) {
|
||||
if (torrent.playable_file) {
|
||||
return torrent.playable_file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
function handlePlay() {
|
||||
if (playableFile.value) {
|
||||
emit('play', playableFile.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-rating {
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.rating-high {
|
||||
color: #46d369;
|
||||
}
|
||||
|
||||
.rating-medium {
|
||||
color: #f9a825;
|
||||
}
|
||||
|
||||
.rating-low {
|
||||
color: #e53935;
|
||||
}
|
||||
|
||||
.hero-overview {
|
||||
max-width: 500px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,617 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="hex-keyboard-fade">
|
||||
<div v-if="visible" ref="keyboardRef" class="hex-keyboard" @click.stop @keydown="handleKeyDown">
|
||||
<div ref="gridRef" class="hex-keyboard-grid">
|
||||
<div
|
||||
v-for="(row, rowIndex) in rows"
|
||||
:key="rowIndex"
|
||||
class="hex-keyboard-row"
|
||||
:class="`hex-keyboard-row-${rowIndex}`"
|
||||
>
|
||||
<button
|
||||
v-for="key in row"
|
||||
:key="key.id"
|
||||
:ref="(el: unknown) => setKeyRef(el as HTMLElement | null, key.globalIndex)"
|
||||
class="hex-key"
|
||||
:class="[
|
||||
`hex-key-row-${getCoord(key.globalIndex).row}`,
|
||||
{
|
||||
'hex-key-blue': key.bg === 'blue',
|
||||
'hex-key-yellow': key.bg === 'yellow',
|
||||
'hex-key-red': key.bg === 'red',
|
||||
'hex-key-pressed': isPressed(key.globalIndex),
|
||||
},
|
||||
]"
|
||||
tabindex="-1"
|
||||
@click="handleKeyClick(key)"
|
||||
>
|
||||
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{ key.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Green focus outline rendered separately on top -->
|
||||
<svg
|
||||
v-if="focusedIndex !== null"
|
||||
class="hex-key-focus"
|
||||
viewBox="-5 -5 96.6 110"
|
||||
preserveAspectRatio="none"
|
||||
:style="focusSvgStyle"
|
||||
>
|
||||
<polygon points="43.3,0 86.6,25 86.6,75 43.3,100 0,75 0,25" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onMounted, onUnmounted, computed } from "vue"
|
||||
|
||||
interface KeyDef {
|
||||
id: string
|
||||
label: string
|
||||
value?: string
|
||||
action?: "backspace" | "space" | "close"
|
||||
bg?: "blue" | "yellow" | "red"
|
||||
}
|
||||
|
||||
interface RowKeyDef extends KeyDef {
|
||||
globalIndex: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
modelValue: string
|
||||
searchRef?: HTMLInputElement | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [string]
|
||||
close: []
|
||||
submit: []
|
||||
}>()
|
||||
|
||||
const focusedIndex = ref<number | null>(null)
|
||||
const pressedTimestamps = ref<Map<number, number>>(new Map())
|
||||
const keyRefs = ref<(HTMLElement | null)[]>([])
|
||||
const keyboardRef = ref<HTMLElement | null>(null)
|
||||
const gridRef = ref<HTMLElement | null>(null)
|
||||
const pressedTimeouts = new Map<number, ReturnType<typeof setTimeout>>()
|
||||
|
||||
function isPressed(index: number): boolean {
|
||||
const ts = pressedTimestamps.value.get(index)
|
||||
if (!ts) return false
|
||||
return Date.now() - ts < 1000
|
||||
}
|
||||
|
||||
const rows = computed<RowKeyDef[][]>(() => {
|
||||
const result: RowKeyDef[][] = [[], [], [], []]
|
||||
for (let i = 0; i < layoutKeys.length; i++) {
|
||||
const key = layoutKeys[i]
|
||||
const coord = getCoord(i)
|
||||
result[coord.row].push({ ...key, globalIndex: i })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Four-row layout with honeycomb staggering
|
||||
// Each row shifted 0.5 cell left relative to the one below:
|
||||
// Row 0 (numbers): shift 0
|
||||
// Row 1 (qwerty): shift 0.5
|
||||
// Row 2 (asdf): shift 1.0
|
||||
// Row 3 (zxcv): shift 1.5
|
||||
const layoutKeys: KeyDef[] = [
|
||||
// Row 0 (number row) — shift 0
|
||||
{ id: "1", label: "1", value: "1" },
|
||||
{ id: "2", label: "2", value: "2" },
|
||||
{ id: "3", label: "3", value: "3" },
|
||||
{ id: "4", label: "4", value: "4" },
|
||||
{ id: "5", label: "5", value: "5" },
|
||||
{ id: "6", label: "6", value: "6" },
|
||||
{ id: "7", label: "7", value: "7" },
|
||||
{ id: "8", label: "8", value: "8" },
|
||||
{ id: "9", label: "9", value: "9" },
|
||||
{ id: "0", label: "0", value: "0" },
|
||||
{ id: "bs", label: "\u2190", action: "backspace", bg: "blue" },
|
||||
// Row 1 (QWERTY) — shift 0.5
|
||||
{ id: "q", label: "Q", value: "q" },
|
||||
{ id: "w", label: "W", value: "w" },
|
||||
{ id: "e", label: "E", value: "e" },
|
||||
{ id: "r", label: "R", value: "r" },
|
||||
{ id: "t", label: "T", value: "t" },
|
||||
{ id: "y", label: "Y", value: "y" },
|
||||
{ id: "u", label: "U", value: "u" },
|
||||
{ id: "i", label: "I", value: "i" },
|
||||
{ id: "o", label: "O", value: "o" },
|
||||
{ id: "p", label: "P", value: "p" },
|
||||
// Row 2 (ASDF) — shift 1.0
|
||||
{ id: "a", label: "A", value: "a" },
|
||||
{ id: "s", label: "S", value: "s" },
|
||||
{ id: "d", label: "D", value: "d" },
|
||||
{ id: "f", label: "F", value: "f" },
|
||||
{ id: "g", label: "G", value: "g" },
|
||||
{ id: "h", label: "H", value: "h" },
|
||||
{ id: "j", label: "J", value: "j" },
|
||||
{ id: "k", label: "K", value: "k" },
|
||||
{ id: "l", label: "L", value: "l" },
|
||||
// Row 3 (ZXCV + Space + Close) — shift 1.5
|
||||
{ id: "z", label: "Z", value: "z" },
|
||||
{ id: "x", label: "X", value: "x" },
|
||||
{ id: "c", label: "C", value: "c" },
|
||||
{ id: "v", label: "V", value: "v" },
|
||||
{ id: "b", label: "B", value: "b" },
|
||||
{ id: "n", label: "N", value: "n" },
|
||||
{ id: "m", label: "M", value: "m" },
|
||||
{ id: "sp", label: "\u2423", value: " ", bg: "yellow" },
|
||||
{ id: "cls", label: "Close", action: "close", bg: "red" },
|
||||
]
|
||||
|
||||
const ROW_0_START = 0
|
||||
const ROW_0_COUNT = 11
|
||||
const ROW_1_START = 11
|
||||
const ROW_1_COUNT = 10
|
||||
const ROW_2_START = 21
|
||||
const ROW_2_COUNT = 9
|
||||
const ROW_3_START = 30
|
||||
const ROW_3_COUNT = 9
|
||||
|
||||
function getCoord(index: number): { row: number; col: number } {
|
||||
if (index >= ROW_0_START && index < ROW_0_START + ROW_0_COUNT) {
|
||||
return { row: 0, col: index - ROW_0_START }
|
||||
}
|
||||
if (index >= ROW_1_START && index < ROW_1_START + ROW_1_COUNT) {
|
||||
return { row: 1, col: index - ROW_1_START }
|
||||
}
|
||||
if (index >= ROW_2_START && index < ROW_2_START + ROW_2_COUNT) {
|
||||
return { row: 2, col: index - ROW_2_START }
|
||||
}
|
||||
if (index >= ROW_3_START && index < ROW_3_START + ROW_3_COUNT) {
|
||||
return { row: 3, col: index - ROW_3_START }
|
||||
}
|
||||
return { row: 0, col: 0 }
|
||||
}
|
||||
|
||||
function getRowRange(row: number): { start: number; count: number } {
|
||||
switch (row) {
|
||||
case 0: return { start: ROW_0_START, count: ROW_0_COUNT }
|
||||
case 1: return { start: ROW_1_START, count: ROW_1_COUNT }
|
||||
case 2: return { start: ROW_2_START, count: ROW_2_COUNT }
|
||||
case 3: return { start: ROW_3_START, count: ROW_3_COUNT }
|
||||
default: return { start: 0, count: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function findIndexAt(row: number, col: number): number | null {
|
||||
for (let i = 0; i < layoutKeys.length; i++) {
|
||||
const c = getCoord(i)
|
||||
if (c.row === row && c.col === col) return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function setKeyRef(el: HTMLElement | null, index: number) {
|
||||
keyRefs.value[index] = el
|
||||
}
|
||||
|
||||
function triggerPress(index: number) {
|
||||
pressedTimestamps.value.set(index, Date.now())
|
||||
const existing = pressedTimeouts.get(index)
|
||||
if (existing) clearTimeout(existing)
|
||||
const timeout = setTimeout(() => {
|
||||
pressedTimestamps.value.delete(index)
|
||||
pressedTimeouts.delete(index)
|
||||
}, 1000)
|
||||
pressedTimeouts.set(index, timeout)
|
||||
|
||||
// Restart CSS animation immediately by touching the DOM directly
|
||||
const el = keyRefs.value[index]
|
||||
const label = el?.querySelector(".hex-key-label") as HTMLElement | null
|
||||
if (label) {
|
||||
label.style.animation = "none"
|
||||
// Force reflow
|
||||
void label.offsetWidth
|
||||
label.style.animation = ""
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyClick(key: KeyDef) {
|
||||
const index = layoutKeys.findIndex((k) => k.id === key.id)
|
||||
if (index !== -1) triggerPress(index)
|
||||
|
||||
if (key.action === "backspace") {
|
||||
emit("update:modelValue", props.modelValue.slice(0, -1))
|
||||
return
|
||||
}
|
||||
if (key.action === "space") {
|
||||
emit("update:modelValue", props.modelValue + " ")
|
||||
return
|
||||
}
|
||||
if (key.action === "close") {
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (key.value) {
|
||||
emit("update:modelValue", props.modelValue + key.value)
|
||||
}
|
||||
}
|
||||
|
||||
function activateKeyById(keyId: string): boolean {
|
||||
const key = layoutKeys.find((candidate) => candidate.id === keyId)
|
||||
if (!key) return false
|
||||
handleKeyClick(key)
|
||||
return true
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("close")
|
||||
}
|
||||
|
||||
function findNext(
|
||||
currentIdx: number,
|
||||
direction: "up" | "down" | "left" | "right",
|
||||
): number | null {
|
||||
const current = getCoord(currentIdx)
|
||||
|
||||
if (direction === "left") {
|
||||
const targetCol = current.col - 1
|
||||
if (targetCol < 0) {
|
||||
const range = getRowRange(current.row)
|
||||
return range.start + range.count - 1
|
||||
}
|
||||
let next = findIndexAt(current.row, targetCol)
|
||||
if (next !== null) return next
|
||||
if (current.row > 0) {
|
||||
next = findIndexAt(current.row - 1, targetCol)
|
||||
if (next !== null) return next
|
||||
}
|
||||
if (current.row < 3) {
|
||||
next = findIndexAt(current.row + 1, targetCol)
|
||||
if (next !== null) return next
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (direction === "right") {
|
||||
const targetCol = current.col + 1
|
||||
const range = getRowRange(current.row)
|
||||
if (targetCol >= range.count) {
|
||||
return range.start
|
||||
}
|
||||
let next = findIndexAt(current.row, targetCol)
|
||||
if (next !== null) return next
|
||||
if (current.row > 0) {
|
||||
next = findIndexAt(current.row - 1, targetCol)
|
||||
if (next !== null) return next
|
||||
}
|
||||
if (current.row < 3) {
|
||||
next = findIndexAt(current.row + 1, targetCol)
|
||||
if (next !== null) return next
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (direction === "up") {
|
||||
const targetRow = current.row - 1
|
||||
if (targetRow < 0) return null
|
||||
let next = findIndexAt(targetRow, current.col)
|
||||
if (next !== null) return next
|
||||
next = findIndexAt(targetRow, current.col - 1)
|
||||
if (next !== null) return next
|
||||
next = findIndexAt(targetRow, current.col + 1)
|
||||
if (next !== null) return next
|
||||
return null
|
||||
}
|
||||
|
||||
if (direction === "down") {
|
||||
const targetRow = current.row + 1
|
||||
if (targetRow > 3) return null
|
||||
let next = findIndexAt(targetRow, current.col)
|
||||
if (next !== null) return next
|
||||
next = findIndexAt(targetRow, current.col - 1)
|
||||
if (next !== null) return next
|
||||
next = findIndexAt(targetRow, current.col + 1)
|
||||
if (next !== null) return next
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const direction = {
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
}[e.key] as "up" | "down" | "left" | "right" | undefined
|
||||
|
||||
if (!direction) {
|
||||
if (e.key === "Enter" && focusedIndex.value !== null) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleKeyClick(layoutKeys[focusedIndex.value])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const current = focusedIndex.value ?? 0
|
||||
const next = findNext(current, direction)
|
||||
|
||||
if (next !== null && keyRefs.value[next]) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
focusedIndex.value = next
|
||||
}
|
||||
}
|
||||
|
||||
// Compute focus outline position from focused key element
|
||||
const focusSvgStyle = computed(() => {
|
||||
const idx = focusedIndex.value
|
||||
if (idx === null || !keyRefs.value[idx] || !gridRef.value) return {}
|
||||
const el = keyRefs.value[idx]!
|
||||
const grid = gridRef.value
|
||||
const gridRect = grid.getBoundingClientRect()
|
||||
const elRect = el.getBoundingClientRect()
|
||||
return {
|
||||
left: `${(elRect.left - gridRect.left) / 0.8}px`,
|
||||
top: `${(elRect.top - gridRect.top) / 0.8}px`,
|
||||
width: `${elRect.width / 0.8}px`,
|
||||
height: `${elRect.height / 0.8}px`,
|
||||
}
|
||||
})
|
||||
|
||||
// Position keyboard under search input
|
||||
function updatePosition() {
|
||||
if (!keyboardRef.value || !props.searchRef) return
|
||||
const searchRect = props.searchRef.getBoundingClientRect()
|
||||
const keyboardEl = keyboardRef.value
|
||||
keyboardEl.style.left = `${searchRect.left + searchRect.width / 2}px`
|
||||
keyboardEl.style.top = `${searchRect.bottom + 8}px`
|
||||
keyboardEl.style.bottom = "auto"
|
||||
keyboardEl.style.transform = "translateX(-50%) scale(0.8)"
|
||||
}
|
||||
|
||||
// Track focus loss to close keyboard
|
||||
function onFocusIn(event: FocusEvent) {
|
||||
if (!props.visible) return
|
||||
const target = event.target as HTMLElement | null
|
||||
if (keyboardRef.value && !keyboardRef.value.contains(target)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
// Initial focus on 'A' (index 21)
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
nextTick(() => {
|
||||
focusedIndex.value = 21
|
||||
updatePosition()
|
||||
})
|
||||
} else {
|
||||
focusedIndex.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function onGamepadAction(event: Event) {
|
||||
if (!props.visible) return
|
||||
const customEvent = event as CustomEvent<{ action?: string }>
|
||||
const action = customEvent.detail?.action
|
||||
if (!action) return
|
||||
|
||||
if (action === "select") {
|
||||
event.preventDefault()
|
||||
if (focusedIndex.value !== null) {
|
||||
triggerPress(focusedIndex.value)
|
||||
handleKeyClick(layoutKeys[focusedIndex.value])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "back") {
|
||||
event.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
const direction = {
|
||||
up: "up",
|
||||
down: "down",
|
||||
left: "left",
|
||||
right: "right",
|
||||
}[action] as "up" | "down" | "left" | "right" | undefined
|
||||
|
||||
if (direction && focusedIndex.value !== null) {
|
||||
event.preventDefault()
|
||||
const next = findNext(focusedIndex.value, direction)
|
||||
if (next !== null && keyRefs.value[next]) {
|
||||
focusedIndex.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom handler for X (backspace), Y (space) and shoulder buttons (LB/RB)
|
||||
function onRawGamepad(event: Event) {
|
||||
if (!props.visible) return
|
||||
const customEvent = event as CustomEvent<{ action?: string; button?: number }>
|
||||
const button = customEvent.detail?.button
|
||||
if (button === undefined) return
|
||||
|
||||
// X button = backspace (button 2)
|
||||
if (button === 2) {
|
||||
event.preventDefault()
|
||||
activateKeyById("bs")
|
||||
return
|
||||
}
|
||||
|
||||
// Y button = space (button 3)
|
||||
if (button === 3) {
|
||||
event.preventDefault()
|
||||
activateKeyById("sp")
|
||||
return
|
||||
}
|
||||
|
||||
// LB = move cursor left in search input (button 4)
|
||||
if (button === 4) {
|
||||
event.preventDefault()
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }))
|
||||
return
|
||||
}
|
||||
|
||||
// RB = move cursor right in search input (button 5)
|
||||
if (button === 5) {
|
||||
event.preventDefault()
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("mediahive:gamepad-action", onGamepadAction)
|
||||
window.addEventListener("mediahive:gamepad-button", onRawGamepad)
|
||||
document.addEventListener("focusin", onFocusIn)
|
||||
window.addEventListener("resize", updatePosition)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("mediahive:gamepad-action", onGamepadAction)
|
||||
window.removeEventListener("mediahive:gamepad-button", onRawGamepad)
|
||||
document.removeEventListener("focusin", onFocusIn)
|
||||
window.removeEventListener("resize", updatePosition)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hex-keyboard {
|
||||
position: fixed;
|
||||
z-index: 3000;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.hex-keyboard-grid {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
--key-h: 56px;
|
||||
--key-w: calc(0.866 * var(--key-h));
|
||||
}
|
||||
|
||||
.hex-keyboard-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Overlap: each row overlaps the previous by 0.25 * key-h */
|
||||
.hex-keyboard-row:not(:first-child) {
|
||||
margin-top: calc(var(--key-h) * -0.25);
|
||||
}
|
||||
|
||||
/* Row horizontal offsets for honeycomb staggering */
|
||||
.hex-keyboard-row-0 { margin-left: 0; }
|
||||
.hex-keyboard-row-1 { margin-left: calc(var(--key-w) * 0.5); }
|
||||
.hex-keyboard-row-2 { margin-left: calc(var(--key-w) * 1.0); }
|
||||
.hex-keyboard-row-3 { margin-left: calc(var(--key-w) * 1.5); }
|
||||
|
||||
.hex-key {
|
||||
position: relative;
|
||||
height: var(--key-h);
|
||||
width: var(--key-w);
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||
transition: transform 0.15s ease, color 0.3s ease;
|
||||
outline: none;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* Row-based greyscale gradients — lighter top, darker home row */
|
||||
.hex-key-row-0 {
|
||||
background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%);
|
||||
}
|
||||
.hex-key-row-1, .hex-key-row-3 {
|
||||
background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%);
|
||||
}
|
||||
.hex-key-row-2 {
|
||||
background: linear-gradient(180deg, #1f2937d0 0%, #111827d0 100%);
|
||||
}
|
||||
|
||||
/* No hover/active scale — keyboard is gamepad-operated */
|
||||
|
||||
/* Keypress feedback: instant green, then exponential fade to white */
|
||||
.hex-key-pressed .hex-key-label {
|
||||
animation: hex-label-fade 1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes hex-label-fade {
|
||||
0% { color: #22c55e; }
|
||||
100% { color: #ffffff; }
|
||||
}
|
||||
|
||||
/* Special key backgrounds override row gradients */
|
||||
.hex-key-blue {
|
||||
background: linear-gradient(180deg, #60a5fad0 0%, #3b82f6d0 100%);
|
||||
}
|
||||
|
||||
.hex-key-yellow {
|
||||
background: linear-gradient(180deg, #facc15d0 0%, #eab308d0 100%);
|
||||
}
|
||||
|
||||
.hex-key-red {
|
||||
background: linear-gradient(180deg, #f87171d0 0%, #ef4444d0 100%);
|
||||
}
|
||||
|
||||
.hex-key-label {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.hex-key-label-large {
|
||||
font-size: 2em;
|
||||
line-height: 1;
|
||||
margin-top: -0.15em;
|
||||
}
|
||||
|
||||
/* Green focus outline rendered separately on top of the grid */
|
||||
.hex-key-focus {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hex-key-focus polygon {
|
||||
fill: none;
|
||||
stroke: #22c55e;
|
||||
stroke-width: 3;
|
||||
vector-effect: non-scaling-stroke;
|
||||
animation: hex-outline-blink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes hex-outline-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.hex-keyboard-fade-enter-active,
|
||||
.hex-keyboard-fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.hex-keyboard-fade-enter-from,
|
||||
.hex-keyboard-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div v-if="hasContent" class="language-flags" :class="{ compact }">
|
||||
<span v-if="label" class="language-flags-label">{{ label }}</span>
|
||||
<span class="language-flag-list">
|
||||
<span
|
||||
v-for="entry in flagEntries"
|
||||
:key="entry.countryCode"
|
||||
class="language-flag"
|
||||
:title="formatLanguageFlagTitle(entry, externalCodes)"
|
||||
v-html="entry.svg"
|
||||
></span>
|
||||
<span
|
||||
v-for="code in unmappedCodes"
|
||||
:key="`raw-${code}`"
|
||||
class="language-code-fallback"
|
||||
:title="code"
|
||||
>{{ code }}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import { buildLanguageFlags, formatLanguageFlagTitle } from "../utils/languageFlags"
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string
|
||||
codes: string[] | null | undefined
|
||||
externalCodes?: string[] | null
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const mapped = computed(() => buildLanguageFlags(props.codes))
|
||||
const flagEntries = computed(() => mapped.value.flags)
|
||||
const unmappedCodes = computed(() => mapped.value.unmappedCodes)
|
||||
const hasContent = computed(() => flagEntries.value.length > 0 || unmappedCodes.value.length > 0)
|
||||
const compact = computed(() => props.compact === true)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.language-flags {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.language-flags-label {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.language-flag-list {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.language-flag {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35) inset;
|
||||
}
|
||||
|
||||
.language-flag :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.language-code-fallback {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 3px;
|
||||
padding: 0.08rem 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.language-flags.compact .language-flags-label {
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.language-flags.compact .language-flag {
|
||||
width: 16px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.language-flags.compact .language-code-fallback {
|
||||
font-size: 0.52rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
<component
|
||||
:is="href ? 'a' : 'div'"
|
||||
class="media-card"
|
||||
v-bind="navAttributes"
|
||||
:data-item-id="item.id"
|
||||
@click="$emit('click')"
|
||||
:data-item-type="item.type"
|
||||
:href="href || undefined"
|
||||
@click="handleClick"
|
||||
@keydown.enter.prevent="$emit('click')"
|
||||
>
|
||||
<div class="media-card-poster">
|
||||
<!-- SVG focus outline -->
|
||||
<svg class="card-focus-outline" viewBox="0 0 100 150" preserveAspectRatio="none">
|
||||
<rect x="0" y="0" width="100" height="150" />
|
||||
</svg>
|
||||
<img
|
||||
v-if="coverUrl && !imageError"
|
||||
:src="coverUrl"
|
||||
v-if="posterImageUrl && !imageError"
|
||||
:src="posterImageUrl"
|
||||
:alt="item.title || 'Unknown'"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="imageError = true"
|
||||
/>
|
||||
<div v-else class="media-card-placeholder">
|
||||
{{ item.type === 'movies' ? '🎬' : item.type === 'episode' ? '📺' : '📺' }}
|
||||
{{ item.type === "movies" ? "🎬" : item.type === "episode" ? "📺" : "📺" }}
|
||||
</div>
|
||||
<div v-if="rating" class="media-card-rating" :class="ratingClass">
|
||||
★ {{ rating.toFixed(1) }}
|
||||
@@ -30,145 +33,206 @@
|
||||
<span class="media-card-title">{{ displayTitle }}</span>
|
||||
<span v-if="item.year" class="media-card-year">{{ item.year }}</span>
|
||||
</div>
|
||||
<!-- Search match info (when searching) -->
|
||||
<template v-if="item.searchMatchInfo">
|
||||
<div v-if="matchedPeople && matchedPeople.length > 0" class="media-card-detail match-reason">
|
||||
<div
|
||||
v-if="matchedPeople && matchedPeople.length > 0"
|
||||
class="media-card-detail match-reason"
|
||||
>
|
||||
<template v-for="(person, idx) in matchedPeople" :key="person.name">
|
||||
<span :class="person.highlightRoles ? 'match-dim' : 'match-name'">{{ person.name }}</span>
|
||||
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'">({{ person.roles }})</span><span v-if="idx < matchedPeople.length - 1">, </span>
|
||||
<span :class="person.highlightRoles ? 'match-dim' : 'match-name'">{{
|
||||
person.name
|
||||
}}</span>
|
||||
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'"
|
||||
>({{ person.roles }})</span
|
||||
><span v-if="idx < matchedPeople.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="item.searchMatchInfo.matchedEpisodes && item.searchMatchInfo.matchedEpisodes.length > 0" class="media-card-episodes">
|
||||
<div v-for="ep in item.searchMatchInfo.matchedEpisodes.slice(0, 3)" :key="ep.name" class="matched-episode">
|
||||
<div
|
||||
v-if="
|
||||
item.searchMatchInfo.matchedEpisodes && item.searchMatchInfo.matchedEpisodes.length > 0
|
||||
"
|
||||
class="media-card-episodes"
|
||||
>
|
||||
<div
|
||||
v-for="ep in item.searchMatchInfo.matchedEpisodes.slice(0, 3)"
|
||||
:key="ep.name"
|
||||
class="matched-episode"
|
||||
>
|
||||
<span class="match-name">{{ ep.name }}</span>
|
||||
<span class="match-roles"> ({{ ep.location }})</span>
|
||||
</div>
|
||||
<div v-if="item.searchMatchInfo.matchedEpisodes.length > 3" class="matched-episode-more">+{{ item.searchMatchInfo.matchedEpisodes.length - 3 }} more</div>
|
||||
<div v-if="item.searchMatchInfo.matchedEpisodes.length > 3" class="matched-episode-more">
|
||||
+{{ item.searchMatchInfo.matchedEpisodes.length - 3 }} more
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Default display (browsing) -->
|
||||
<template v-else>
|
||||
<div v-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
||||
<div v-if="directorAndCast" class="media-card-detail">
|
||||
<span v-if="director" class="director-name">{{ director }}</span><span v-if="director && filteredCastNames">, </span>{{ filteredCastNames }}
|
||||
<div
|
||||
v-if="item.type === 'series' && formattedSeriesCreators"
|
||||
class="media-card-detail person-list"
|
||||
>
|
||||
<span
|
||||
v-for="(creatorName, creatorIndex) in formattedSeriesCreators"
|
||||
:key="`${creatorName}-${creatorIndex}`"
|
||||
class="person-token"
|
||||
>{{ creatorName }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
||||
<div v-if="directorAndCast" class="media-card-detail person-list">
|
||||
<span v-if="director" class="director-name person-token">{{ formatPersonLabel(director) }}</span>
|
||||
<span
|
||||
v-for="(castName, castIndex) in formattedCastNames"
|
||||
:key="`${castName}-${castIndex}`"
|
||||
class="person-token"
|
||||
>{{ castName }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { MediaItem, Movie, Series, EpisodeWithSeries } from '../types';
|
||||
import { getCoverUrl } from '../api';
|
||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
import { computed, ref } from "vue"
|
||||
import type { MediaItem, Movie, Series, EpisodeWithSeries } from "../types"
|
||||
import { getCoverUrl, isVideoPath } from "../api"
|
||||
import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
|
||||
const props = defineProps<{
|
||||
item: MediaItem;
|
||||
navRow?: number;
|
||||
navCol?: number;
|
||||
}>();
|
||||
item: MediaItem
|
||||
navRow?: number
|
||||
navCol?: number
|
||||
href?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Prevent default navigation for plain left-clicks and synthetic clicks
|
||||
// so that parent handlers can manage side-effects and routing
|
||||
event.preventDefault()
|
||||
emit("click")
|
||||
}
|
||||
|
||||
// Navigation attributes for keyboard navigation
|
||||
const navAttributes = computed(() => {
|
||||
if (props.navRow !== undefined && props.navCol !== undefined) {
|
||||
return navAttrs(props.navRow, props.navCol);
|
||||
return navAttrs(props.navRow, props.navCol)
|
||||
}
|
||||
return {};
|
||||
});
|
||||
return {}
|
||||
})
|
||||
|
||||
const imageError = ref(false);
|
||||
const imageError = ref(false)
|
||||
|
||||
const coverUrl = computed(() => {
|
||||
if (imageError.value) return null;
|
||||
return getCoverUrl(props.item.cover_path);
|
||||
});
|
||||
const posterImageUrl = computed(() => {
|
||||
if (imageError.value) return null
|
||||
if (!props.item.cover_path || isVideoPath(props.item.cover_path)) {
|
||||
return null
|
||||
}
|
||||
return getCoverUrl(props.item.cover_path, props.item.root_id)
|
||||
})
|
||||
|
||||
const rating = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
return (props.item.data as Movie).info?.rating;
|
||||
if (props.item.type === "movies") {
|
||||
return (props.item.data as Movie).info?.rating
|
||||
}
|
||||
if (props.item.type === 'episode') {
|
||||
const epData = props.item.data as EpisodeWithSeries;
|
||||
return epData.episode.rating ?? epData.series.info?.rating;
|
||||
if (props.item.type === "episode") {
|
||||
const epData = props.item.data as EpisodeWithSeries
|
||||
return epData.episode.rating ?? epData.series.info?.rating
|
||||
}
|
||||
return (props.item.data as Series).info?.rating;
|
||||
});
|
||||
return (props.item.data as Series).info?.rating
|
||||
})
|
||||
|
||||
const ratingClass = computed(() => {
|
||||
if (!rating.value) return '';
|
||||
if (rating.value >= 7.5) return 'rating-high';
|
||||
if (rating.value >= 6) return 'rating-medium';
|
||||
return 'rating-low';
|
||||
});
|
||||
if (!rating.value) return ""
|
||||
if (rating.value >= 7.5) return "rating-high"
|
||||
if (rating.value >= 6) return "rating-medium"
|
||||
return "rating-low"
|
||||
})
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
if (props.item.type === 'episode') {
|
||||
const epData = props.item.data as EpisodeWithSeries;
|
||||
return epData.episode.name || `Episode ${epData.episode.episode_number}`;
|
||||
if (props.item.type === "episode") {
|
||||
const epData = props.item.data as EpisodeWithSeries
|
||||
return epData.episode.name || `Episode ${epData.episode.episode_number}`
|
||||
}
|
||||
return props.item.title;
|
||||
});
|
||||
return props.item.title
|
||||
})
|
||||
|
||||
const subtitle = computed(() => {
|
||||
if (props.item.type === 'episode') {
|
||||
const epData = props.item.data as EpisodeWithSeries;
|
||||
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`;
|
||||
if (props.item.type === "episode") {
|
||||
const epData = props.item.data as EpisodeWithSeries
|
||||
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`
|
||||
}
|
||||
// For series, show creators
|
||||
if (props.item.type === 'series') {
|
||||
const creators = (props.item.data as Series).info?.creators;
|
||||
return creators && creators.length > 0 ? creators.join(', ') : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return null
|
||||
})
|
||||
|
||||
const seriesCreators = computed(() => {
|
||||
if (props.item.type !== "series") return null
|
||||
const creators = (props.item.data as Series).info?.creators
|
||||
return creators && creators.length > 0 ? creators : null
|
||||
})
|
||||
|
||||
function formatPersonLabel(name: string): string {
|
||||
return name.trim().replace(/\s+/g, "\u202F")
|
||||
}
|
||||
|
||||
const formattedSeriesCreators = computed(() => {
|
||||
if (!seriesCreators.value) return null
|
||||
return seriesCreators.value.map((name) => formatPersonLabel(name))
|
||||
})
|
||||
|
||||
// Director for movies
|
||||
const director = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return (props.item.data as Movie).info?.director;
|
||||
});
|
||||
if (props.item.type !== "movies") return null
|
||||
return (props.item.data as Movie).info?.director
|
||||
})
|
||||
|
||||
// Check if we have director and/or cast to display
|
||||
const directorAndCast = computed(() => {
|
||||
if (props.item.type !== 'movies') return false;
|
||||
return director.value || filteredCastNames.value;
|
||||
});
|
||||
if (props.item.type !== "movies") return false
|
||||
return !!director.value || filteredCastNames.value.length > 0
|
||||
})
|
||||
|
||||
// Cast names, excluding director if they appear in cast
|
||||
const filteredCastNames = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
const cast = (props.item.data as Movie).info?.cast;
|
||||
if (!cast || cast.length === 0) return null;
|
||||
if (props.item.type !== "movies") return []
|
||||
const cast = (props.item.data as Movie).info?.cast
|
||||
if (!cast || cast.length === 0) return []
|
||||
|
||||
const directorName = director.value?.toLowerCase();
|
||||
const directorName = director.value?.toLowerCase()
|
||||
const filteredCast = directorName
|
||||
? cast.filter(c => c.name.toLowerCase() !== directorName)
|
||||
: cast;
|
||||
? cast.filter((c) => c.name.toLowerCase() !== directorName)
|
||||
: cast
|
||||
|
||||
if (filteredCast.length === 0) return null;
|
||||
if (filteredCast.length === 0) return []
|
||||
|
||||
// Show first 3 cast members
|
||||
const names = filteredCast.slice(0, 3).map(c => c.name);
|
||||
return names.join(', ');
|
||||
});
|
||||
return filteredCast.slice(0, 3).map((c) => c.name)
|
||||
})
|
||||
|
||||
const formattedCastNames = computed(() => {
|
||||
return filteredCastNames.value.map((name) => formatPersonLabel(name))
|
||||
})
|
||||
|
||||
// Matched people from search (from searchMatchInfo)
|
||||
const matchedPeople = computed(() => {
|
||||
const info = props.item.searchMatchInfo;
|
||||
if (!info || !info.matchedPeople) return null;
|
||||
return info.matchedPeople;
|
||||
});
|
||||
const info = props.item.searchMatchInfo
|
||||
if (!info || !info.matchedPeople) return null
|
||||
return info.matchedPeople
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Blinking animation for focus outline */
|
||||
@keyframes card-outline-blink {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
@@ -176,7 +240,6 @@ const matchedPeople = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* SVG focus outline styles */
|
||||
.card-focus-outline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -195,15 +258,21 @@ const matchedPeople = computed(() => {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
/* Show outline on hover and focus */
|
||||
.media-card:hover .card-focus-outline,
|
||||
.media-card.nav-focused .card-focus-outline {
|
||||
.media-card-poster img,
|
||||
.media-card-poster video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
html.mouse-active .media-card:hover .card-focus-outline,
|
||||
html:not(.mouse-active) .media-card.nav-focused .card-focus-outline {
|
||||
opacity: 1;
|
||||
animation: card-outline-blink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Brighter outline for keyboard focus */
|
||||
.media-card.nav-focused .card-focus-outline rect {
|
||||
html:not(.mouse-active) .media-card.nav-focused .card-focus-outline rect {
|
||||
stroke: #ffffff;
|
||||
stroke-width: 5;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
|
||||
@@ -236,7 +305,6 @@ const matchedPeople = computed(() => {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
/* Allow up to 2 lines with ellipsis */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -244,12 +312,26 @@ const matchedPeople = computed(() => {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.media-card-detail.person-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
row-gap: 1px;
|
||||
column-gap: 0.5ch;
|
||||
-webkit-line-clamp: unset;
|
||||
-webkit-box-orient: unset;
|
||||
max-height: calc(1.3em * 2);
|
||||
}
|
||||
|
||||
.person-token {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.director-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Search match styles */
|
||||
.match-reason {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -264,7 +346,6 @@ const matchedPeople = computed(() => {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* When character name matched - highlight the role, dim the name */
|
||||
.match-dim {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
<template>
|
||||
<div class="media-row" :class="{ 'media-row-wrap': wrap }">
|
||||
<div
|
||||
class="media-row"
|
||||
:class="{ 'media-row-wrap': wrap }"
|
||||
:data-sync-scroll-row="!wrap && rowIndex !== undefined ? 'true' : undefined"
|
||||
:data-sync-scroll-group="!wrap && rowIndex !== undefined ? 'browse' : undefined"
|
||||
>
|
||||
<MediaCard
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:nav-row="rowIndex"
|
||||
:nav-col="index"
|
||||
:href="getItemHref(item)"
|
||||
@click="$emit('select', item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MediaItem } from '../types';
|
||||
import MediaCard from './MediaCard.vue';
|
||||
import type { MediaItem, EpisodeWithSeries } from "../types"
|
||||
import MediaCard from "./MediaCard.vue"
|
||||
|
||||
defineProps<{
|
||||
items: MediaItem[];
|
||||
wrap?: boolean;
|
||||
rowIndex?: number;
|
||||
}>();
|
||||
items: MediaItem[]
|
||||
wrap?: boolean
|
||||
rowIndex?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [MediaItem];
|
||||
}>();
|
||||
select: [MediaItem]
|
||||
}>()
|
||||
|
||||
function getItemHref(item: MediaItem): string | undefined {
|
||||
if (item.type === "episode") {
|
||||
const epData = item.data as EpisodeWithSeries
|
||||
return `/series/${epData.series.id}`
|
||||
}
|
||||
return `/${item.type}/${item.id}`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="version-action-menu"
|
||||
:style="menuStyle"
|
||||
tabindex="-1"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div class="version-action-path" :title="resolvedPath">
|
||||
{{ resolvedPath }}
|
||||
</div>
|
||||
<button class="version-action-item" :disabled="disabled" @click="emit('play')">
|
||||
<span class="version-action-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" focusable="false">
|
||||
<path
|
||||
d="M4 3.2c0-.54.6-.86 1.05-.56l6.2 4.14a.67.67 0 0 1 0 1.12l-6.2 4.14A.67.67 0 0 1 4 11.44V3.2Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{{ playLabel }}
|
||||
</button>
|
||||
<button class="version-action-item" :disabled="disabled" @click="emit('openFolder')">
|
||||
<span class="version-action-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" focusable="false">
|
||||
<path
|
||||
d="M1.4 4.3c0-.72.58-1.3 1.3-1.3h3.55c.3 0 .58.13.77.35l.72.85h5.56c.72 0 1.3.58 1.3 1.3v.92H1.4V4.3Zm0 3.22h13.2v4.2c0 .72-.58 1.3-1.3 1.3H2.7c-.72 0-1.3-.58-1.3-1.3v-4.2Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
Open Folder
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
filePath: string | null
|
||||
rootName?: string | null
|
||||
playLabel?: string
|
||||
}>(),
|
||||
{
|
||||
rootName: null,
|
||||
playLabel: "Play",
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: []
|
||||
openFolder: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const menuLeft = ref(0)
|
||||
const menuTop = ref(0)
|
||||
const VIEWPORT_MARGIN = 12
|
||||
|
||||
function toPosixPath(value: string | null | undefined): string {
|
||||
return (value || "").replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
const resolvedPath = computed(() => {
|
||||
if (!props.filePath) return "No playable file"
|
||||
const normalizedFilePath = toPosixPath(props.filePath)
|
||||
const rootName = toPosixPath((props.rootName || "").trim())
|
||||
if (!rootName) return normalizedFilePath
|
||||
return `${rootName}/${normalizedFilePath}`
|
||||
})
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${menuLeft.value}px`,
|
||||
top: `${menuTop.value}px`,
|
||||
}))
|
||||
|
||||
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() {
|
||||
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, resolvedPath.value],
|
||||
async ([visible]) => {
|
||||
if (!visible) return
|
||||
await nextTick()
|
||||
clampToViewport()
|
||||
},
|
||||
{ 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>
|
||||
.version-action-menu {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
min-width: 260px;
|
||||
max-width: min(680px, calc(100vw - 24px));
|
||||
background: rgba(18, 20, 28, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.version-action-path {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.35;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.version-action-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
html.mouse-active .version-action-item:hover:not(:disabled),
|
||||
.version-action-item:focus-visible:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.version-action-item:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.version-action-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
flex: 0 0 16px;
|
||||
}
|
||||
|
||||
.version-action-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,655 @@
|
||||
<template>
|
||||
<div
|
||||
class="version-row"
|
||||
:class="{
|
||||
'version-best': best,
|
||||
'version-selectable': isSelectable && !inertCard,
|
||||
'version-disabled': isDisabled,
|
||||
'version-menu': variant === 'menu',
|
||||
'version-with-actions': showActions,
|
||||
'version-inert': inertCard,
|
||||
}"
|
||||
:tabindex="inertCard ? undefined : 0"
|
||||
:title="resolvedTitle"
|
||||
v-bind="$attrs"
|
||||
@click="handleActivate"
|
||||
@keydown.enter.prevent="handleActivate"
|
||||
@keydown.space.prevent="handleActivate"
|
||||
>
|
||||
<div class="version-main">
|
||||
<div class="version-badges">
|
||||
<span v-if="torrent.resolution" class="v-badge res">{{ torrent.resolution }}</span>
|
||||
<span v-if="showHdrBadge" class="v-badge hdr">HDR</span>
|
||||
<span v-if="displayCodecBadge" class="v-badge codec">{{ displayCodecBadge }}</span>
|
||||
<span v-if="displayQualityBadge" class="v-badge qual">{{ displayQualityBadge }}</span>
|
||||
<span v-if="displayAudioBadge" class="v-badge audio">{{ displayAudioBadge }}</span>
|
||||
<span v-if="displayReleaseGroupBadge" class="v-badge group">{{
|
||||
displayReleaseGroupBadge
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="version-language-flags">
|
||||
<LanguageFlags
|
||||
class="language-flags-audio"
|
||||
:codes="torrent.audio_languages"
|
||||
:compact="compactFlags"
|
||||
/>
|
||||
<span
|
||||
v-if="
|
||||
hasLanguageDisplay(torrent.audio_languages) &&
|
||||
hasLanguageDisplay(torrent.subtitle_languages)
|
||||
"
|
||||
class="language-separator"
|
||||
>•</span
|
||||
>
|
||||
<LanguageFlags
|
||||
class="language-flags-subs"
|
||||
:codes="torrent.subtitle_languages"
|
||||
:external-codes="torrent.external_subtitle_languages"
|
||||
:compact="compactFlags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-dolby-cell">
|
||||
<img v-if="showBlurayLogo" class="version-disc-logo" :src="blurayLogoUrl" alt="Blu-ray" />
|
||||
<img v-else-if="showDvdLogo" class="version-disc-logo" :src="dvdLogoUrl" alt="DVD" />
|
||||
<img
|
||||
v-if="streamingServiceLogo"
|
||||
class="version-service-logo"
|
||||
:src="streamingServiceLogo.src"
|
||||
:alt="streamingServiceLogo.alt"
|
||||
:title="streamingServiceLogo.alt"
|
||||
/>
|
||||
<img
|
||||
v-if="showHdr10PlusLogo"
|
||||
class="version-hdr10plus-logo"
|
||||
:src="hdr10plusLogoUrl"
|
||||
alt="HDR10+"
|
||||
/>
|
||||
<DolbyBadges
|
||||
class="version-dolby"
|
||||
:has-dolby-vision="hasDolbyVision"
|
||||
:has-dolby-atmos="hasDolbyAtmos"
|
||||
:is-hdr="hasHdr"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showActions" class="version-actions">
|
||||
<button
|
||||
class="ctx-btn ctx-btn-play"
|
||||
tabindex="0"
|
||||
@click.stop="emit('play')"
|
||||
:disabled="!torrent.playable_file"
|
||||
:title="playLabel"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
<button
|
||||
class="ctx-btn ctx-btn-folder"
|
||||
tabindex="0"
|
||||
@click.stop="emit('openFolder')"
|
||||
:disabled="!torrent.playable_file"
|
||||
title="Open Folder"
|
||||
>
|
||||
📁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import type { Torrent } from "../types"
|
||||
import LanguageFlags from "./LanguageFlags.vue"
|
||||
import DolbyBadges from "./DolbyBadges.vue"
|
||||
import { buildLanguageFlags } from "../utils/languageFlags"
|
||||
import blurayLogoUrl from "../assets/bluray.webp"
|
||||
import dvdLogoUrl from "../assets/dvd.webp"
|
||||
import amazonLogoUrl from "../assets/service-amazon.webp"
|
||||
import appleTvLogoUrl from "../assets/service-apple-tv.webp"
|
||||
import netflixLogoUrl from "../assets/service-netflix.webp"
|
||||
import hboMaxLogoUrl from "../assets/service-hbo-max.webp"
|
||||
import huluLogoUrl from "../assets/service-hulu.webp"
|
||||
import disneyLogoUrl from "../assets/service-disney.svg"
|
||||
import itunesLogoUrl from "../assets/service-itunes.png"
|
||||
import hdr10plusLogoUrl from "../assets/hdr10plus-logo.png"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
torrent: Torrent
|
||||
best?: boolean
|
||||
selectable?: boolean
|
||||
disabled?: boolean
|
||||
compactFlags?: boolean
|
||||
showActions?: boolean
|
||||
playLabel?: string
|
||||
title?: string
|
||||
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,
|
||||
selectable: undefined,
|
||||
disabled: undefined,
|
||||
compactFlags: false,
|
||||
showActions: false,
|
||||
playLabel: "Play",
|
||||
title: undefined,
|
||||
variant: "default",
|
||||
inertCard: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
activate: [MouseEvent | KeyboardEvent]
|
||||
play: []
|
||||
openFolder: []
|
||||
}>()
|
||||
|
||||
const dolbyTagPattern = /\b(dolby|atmos|vision|dovi|dv)\b/i
|
||||
const dolbyVisionPattern = /\b(dolby\s*vision|dovi|\bdv\b)\b/i
|
||||
const dolbyAtmosPattern = /\b(dolby\s*atmos|atmos)\b/i
|
||||
const hdrPattern = /\bhdr\b|smpte\s*2084|bt\s*2020|hlg/i
|
||||
const blurayTagPattern = /\bblu[\s.-]*ray\b/i
|
||||
const blurayPlayablePattern = /(?:^|[\\/])(movieobject|index)\.bdmv$/i
|
||||
const dvdPlayablePattern = /(?:^|[\\/])video_ts\.ifo$/i
|
||||
const webQualityPattern = /^web(?:[ .-]?dl|[ .-]?rip)$/i
|
||||
|
||||
const serviceLogoMap: Array<{ aliases: string[]; src: string; alt: string }> = [
|
||||
{
|
||||
aliases: ["amazon studios", "amazon prime video", "prime video", "amazon", "amzn"],
|
||||
src: amazonLogoUrl,
|
||||
alt: "Amazon Prime Video",
|
||||
},
|
||||
{
|
||||
aliases: ["apple tv+", "apple tv plus", "apple tv", "atvp"],
|
||||
src: appleTvLogoUrl,
|
||||
alt: "Apple TV+",
|
||||
},
|
||||
{ aliases: ["itunes", "it"], src: itunesLogoUrl, alt: "iTunes" },
|
||||
{ aliases: ["netflix", "nf", "nflx"], src: netflixLogoUrl, alt: "Netflix" },
|
||||
{ aliases: ["hbo max", "max", "hmax"], src: hboMaxLogoUrl, alt: "HBO Max" },
|
||||
{
|
||||
aliases: ["disney plus", "disney+", "disney plus hotstar", "dsnp"],
|
||||
src: disneyLogoUrl,
|
||||
alt: "Disney+",
|
||||
},
|
||||
{ aliases: ["hulu"], src: huluLogoUrl, alt: "Hulu" },
|
||||
]
|
||||
|
||||
function hasDolbyTag(value: string | null | undefined): boolean {
|
||||
return Boolean(value && dolbyTagPattern.test(value))
|
||||
}
|
||||
|
||||
function hasAnyTag(pattern: RegExp, ...values: Array<string | null | undefined>): boolean {
|
||||
return values.some((value) => Boolean(value && pattern.test(value)))
|
||||
}
|
||||
|
||||
function hasLanguageDisplay(codes: string[] | null | undefined): boolean {
|
||||
const mapped = buildLanguageFlags(codes)
|
||||
return mapped.flags.length > 0 || mapped.unmappedCodes.length > 0
|
||||
}
|
||||
|
||||
function hasHdrTag(value: string | null | undefined): boolean {
|
||||
return Boolean(value && hdrPattern.test(value))
|
||||
}
|
||||
|
||||
function normalizeProviderName(value: string | null | undefined): string {
|
||||
return (value || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9+]+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizeQualityBadge(value: string): string {
|
||||
const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "")
|
||||
|
||||
if (
|
||||
normalized === "hdtv" ||
|
||||
normalized === "pdtv" ||
|
||||
normalized === "tvrip" ||
|
||||
normalized === "sdtv"
|
||||
) {
|
||||
return "TV"
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("cam") ||
|
||||
normalized === "telesync" ||
|
||||
normalized === "ts" ||
|
||||
normalized === "hdts" ||
|
||||
normalized === "telecine" ||
|
||||
normalized === "tc"
|
||||
) {
|
||||
return "CAM"
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const hasDolbyVision = computed(() => {
|
||||
return (
|
||||
props.torrent.dovi === true ||
|
||||
hasAnyTag(
|
||||
dolbyVisionPattern,
|
||||
props.torrent.quality,
|
||||
props.torrent.codec,
|
||||
props.torrent.audio,
|
||||
props.torrent.title,
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const hasDolbyAtmos = computed(() => {
|
||||
return (
|
||||
props.torrent.atmos === true ||
|
||||
hasAnyTag(
|
||||
dolbyAtmosPattern,
|
||||
props.torrent.quality,
|
||||
props.torrent.codec,
|
||||
props.torrent.audio,
|
||||
props.torrent.title,
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const hasHdr = computed(() => {
|
||||
return (
|
||||
props.torrent.hdr === true ||
|
||||
hasAnyTag(
|
||||
hdrPattern,
|
||||
props.torrent.quality,
|
||||
props.torrent.codec,
|
||||
props.torrent.audio,
|
||||
props.torrent.title,
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const isBlurayDisc = computed(() => {
|
||||
if (!props.torrent.playable_file) return false
|
||||
return blurayPlayablePattern.test(props.torrent.playable_file)
|
||||
})
|
||||
|
||||
const isDvdDisc = computed(() => {
|
||||
if (!props.torrent.playable_file) return false
|
||||
return dvdPlayablePattern.test(props.torrent.playable_file)
|
||||
})
|
||||
|
||||
const showBlurayLogo = computed(() => {
|
||||
return isBlurayDisc.value
|
||||
})
|
||||
|
||||
const showDvdLogo = computed(() => {
|
||||
return isDvdDisc.value
|
||||
})
|
||||
|
||||
const streamingServiceLogo = computed(() => {
|
||||
if (!props.torrent.quality || !webQualityPattern.test(props.torrent.quality)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const network = normalizeProviderName(props.torrent.network)
|
||||
if (!network) return null
|
||||
|
||||
const found = serviceLogoMap.find((entry) => entry.aliases.includes(network))
|
||||
return found ? { src: found.src, alt: found.alt } : null
|
||||
})
|
||||
|
||||
const displayQualityBadge = computed(() => {
|
||||
if (!props.torrent.quality || hasDolbyTag(props.torrent.quality)) return null
|
||||
if (streamingServiceLogo.value) return null
|
||||
if (blurayTagPattern.test(props.torrent.quality)) return null
|
||||
return normalizeQualityBadge(props.torrent.quality)
|
||||
})
|
||||
|
||||
const displayCodecBadge = computed(() => {
|
||||
if (!props.torrent.codec || hasDolbyTag(props.torrent.codec)) return null
|
||||
return props.torrent.codec
|
||||
})
|
||||
|
||||
const displayAudioBadge = computed(() => {
|
||||
if (!props.torrent.audio || hasDolbyTag(props.torrent.audio)) return null
|
||||
return props.torrent.audio
|
||||
})
|
||||
|
||||
const displayReleaseGroupBadge = computed(() => {
|
||||
const value = props.torrent.encoder?.trim()
|
||||
if (!value) return null
|
||||
return value
|
||||
})
|
||||
|
||||
const showHdrBadge = computed(() => {
|
||||
if (!hasHdr.value) return false
|
||||
return (
|
||||
!hasHdrTag(props.torrent.quality) &&
|
||||
!hasHdrTag(props.torrent.codec) &&
|
||||
!hasHdrTag(props.torrent.audio)
|
||||
)
|
||||
})
|
||||
|
||||
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(() => {
|
||||
if (props.selectable !== undefined) return props.selectable
|
||||
return Boolean(props.torrent.playable_file)
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
if (props.disabled !== undefined) return props.disabled
|
||||
return !isSelectable.value
|
||||
})
|
||||
|
||||
const resolvedTitle = computed(() => {
|
||||
if (props.title !== undefined) return props.title
|
||||
return isSelectable.value
|
||||
? "Click to play/continue. Alt+Click to open folder."
|
||||
: "No playable file"
|
||||
})
|
||||
|
||||
function handleActivate(event: MouseEvent | KeyboardEvent) {
|
||||
if (props.inertCard || !isSelectable.value || isDisabled.value) return
|
||||
emit("activate", event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.version-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: stretch;
|
||||
column-gap: 8px;
|
||||
row-gap: 6px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(10, 14, 22, 0.2);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
|
||||
.version-row.version-menu {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.version-row.version-with-actions {
|
||||
grid-template-columns: minmax(0, 1fr) max-content max-content;
|
||||
}
|
||||
|
||||
html.mouse-active .version-row:hover {
|
||||
background: rgba(10, 14, 22, 0.28);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.version-row.version-best {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(10, 14, 22, 0.2);
|
||||
}
|
||||
|
||||
html.mouse-active .version-row.version-best:hover {
|
||||
background: rgba(10, 14, 22, 0.28);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.version-row.version-selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-row.version-selectable:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.85);
|
||||
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 {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.version-main {
|
||||
grid-column: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-dolby-cell {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-dolby {
|
||||
align-self: stretch;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.version-disc-logo {
|
||||
align-self: stretch;
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 0.4px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.version-badges {
|
||||
--badge-row-height: 1.2rem;
|
||||
/* Regular hex geometry: horizontal inset = h / (2 * sqrt(3)) ~= 0.288675 * h */
|
||||
--badge-hex-inset: calc(var(--badge-row-height) * 0.288675);
|
||||
--badge-slant: 0.25em;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
height: var(--badge-row-height);
|
||||
}
|
||||
|
||||
.v-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.5em 0 0.3em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-left: calc(-1 * var(--badge-slant));
|
||||
clip-path: polygon(var(--badge-slant) 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
|
||||
.v-badge:first-child {
|
||||
margin-left: 0;
|
||||
padding-left: 0.5em;
|
||||
clip-path: polygon(
|
||||
var(--badge-hex-inset) 0,
|
||||
100% 0,
|
||||
100% 100%,
|
||||
var(--badge-hex-inset) 100%,
|
||||
0 50%
|
||||
);
|
||||
}
|
||||
|
||||
.v-badge:last-child {
|
||||
padding-right: 0.5em;
|
||||
clip-path: polygon(
|
||||
var(--badge-slant) 0,
|
||||
calc(100% - var(--badge-hex-inset)) 0,
|
||||
100% 50%,
|
||||
calc(100% - var(--badge-hex-inset)) 100%,
|
||||
0 100%
|
||||
);
|
||||
}
|
||||
|
||||
.v-badge:only-child {
|
||||
margin-left: 0;
|
||||
padding-left: 6px;
|
||||
padding-right: calc(6px + var(--badge-hex-inset));
|
||||
clip-path: polygon(
|
||||
var(--badge-hex-inset) 0,
|
||||
calc(100% - var(--badge-hex-inset)) 0,
|
||||
100% 50%,
|
||||
calc(100% - var(--badge-hex-inset)) 100%,
|
||||
var(--badge-hex-inset) 100%,
|
||||
0 50%
|
||||
);
|
||||
}
|
||||
|
||||
.version-service-logo {
|
||||
align-self: stretch;
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.version-hdr10plus-logo {
|
||||
align-self: stretch;
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.v-badge.res {
|
||||
background: #111111;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.v-badge.qual {
|
||||
background: #334155;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.v-badge.codec {
|
||||
background: #1f2937;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.v-badge.audio {
|
||||
background: #475569;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.v-badge.hdr {
|
||||
background: #d4a017;
|
||||
color: #1c1917;
|
||||
}
|
||||
|
||||
.v-badge.group {
|
||||
background: #1c54a1;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.version-language-flags {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-language-flags > .language-flags-audio {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.version-language-flags > .language-flags-subs {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
-webkit-mask-image: linear-gradient(to right, black calc(100% - 14px), transparent);
|
||||
mask-image: linear-gradient(to right, black calc(100% - 14px), transparent);
|
||||
}
|
||||
|
||||
.version-language-flags > .language-flags-subs :deep(.language-flags) {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.version-language-flags > .language-flags-subs :deep(.language-flag-list) {
|
||||
width: max-content;
|
||||
max-width: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.language-separator {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
grid-column: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ctx-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 2em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
html.mouse-active .ctx-btn:hover:not(:disabled),
|
||||
html:not(.mouse-active) .ctx-btn.nav-focused:not(:disabled),
|
||||
.ctx-btn:focus-visible:not(:disabled) {
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ctx-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ctx-btn-folder {
|
||||
width: auto;
|
||||
text-align: center;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,23 @@
|
||||
type GamepadAction = 'up' | 'down' | 'left' | 'right' | 'select' | 'back';
|
||||
type GamepadAction = "up" | "down" | "left" | "right" | "select" | "back" | "menu"
|
||||
type DirectionAction = "up" | "down" | "left" | "right"
|
||||
|
||||
const GAMEPAD_AXIS_THRESHOLD = 0.55;
|
||||
const GAMEPAD_REPEAT_MS = 180;
|
||||
const ANALOG_DEADZONE = 0.25
|
||||
const DIGITAL_INITIAL_REPEAT_DELAY_MS = 150
|
||||
const DIGITAL_REPEAT_START_MS = 90
|
||||
const DIGITAL_REPEAT_MIN_MS = 16
|
||||
const DIGITAL_ACCEL_RAMP_MS = 3500
|
||||
const ANALOG_REPEAT_MAX_MS = 180
|
||||
const ANALOG_REPEAT_MIN_MS = 16
|
||||
const IDLE_POLL_MS = 500
|
||||
|
||||
const KEY_BY_ACTION: Record<GamepadAction, string> = {
|
||||
up: 'ArrowUp',
|
||||
down: 'ArrowDown',
|
||||
left: 'ArrowLeft',
|
||||
right: 'ArrowRight',
|
||||
select: 'Enter',
|
||||
back: 'Escape',
|
||||
};
|
||||
const KEY_BY_ACTION: Partial<Record<GamepadAction, string>> = {
|
||||
up: "ArrowUp",
|
||||
down: "ArrowDown",
|
||||
left: "ArrowLeft",
|
||||
right: "ArrowRight",
|
||||
select: "Enter",
|
||||
back: "Escape",
|
||||
}
|
||||
|
||||
const gamepadPressedState: Record<GamepadAction, boolean> = {
|
||||
up: false,
|
||||
@@ -19,105 +26,287 @@ const gamepadPressedState: Record<GamepadAction, boolean> = {
|
||||
right: false,
|
||||
select: false,
|
||||
back: false,
|
||||
};
|
||||
menu: false,
|
||||
}
|
||||
|
||||
const gamepadLastTriggerAt: Record<GamepadAction, number> = {
|
||||
const rawButtonPressedState: Record<number, boolean> = {
|
||||
2: false,
|
||||
3: false,
|
||||
4: false,
|
||||
5: false,
|
||||
}
|
||||
|
||||
const analogLastTriggerAt: Record<DirectionAction, number> = {
|
||||
up: 0,
|
||||
down: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
select: 0,
|
||||
back: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let gamepadFrameId: number | null = null;
|
||||
let gamepadInstalled = false;
|
||||
let digitalHoldStartedAt = 0
|
||||
let digitalLastRepeatAt = 0
|
||||
let digitalRepeatCount = 0
|
||||
|
||||
let gamepadFrameId: number | null = null
|
||||
let idleTimerId: number | null = null
|
||||
let gamepadInstalled = false
|
||||
|
||||
function dispatchKey(key: string) {
|
||||
const active = document.activeElement;
|
||||
const target = active instanceof HTMLElement ? active : document;
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
const active = document.activeElement
|
||||
const target = active instanceof HTMLElement ? active : document
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: number) {
|
||||
const wasPressed = gamepadPressedState[action];
|
||||
gamepadPressedState[action] = isPressed;
|
||||
function dispatchGamepadAction(action: GamepadAction): void {
|
||||
const actionEvent = new CustomEvent("mediahive:gamepad-action", {
|
||||
detail: { action },
|
||||
cancelable: true,
|
||||
})
|
||||
const shouldContinueWithKeyboard = window.dispatchEvent(actionEvent)
|
||||
if (!shouldContinueWithKeyboard) return
|
||||
|
||||
if (!isPressed) return;
|
||||
const key = KEY_BY_ACTION[action]
|
||||
if (key) {
|
||||
dispatchKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRepeat = action === 'up' || action === 'down' || action === 'left' || action === 'right';
|
||||
const canTrigger = !wasPressed || (shouldRepeat && now - gamepadLastTriggerAt[action] >= GAMEPAD_REPEAT_MS);
|
||||
if (!canTrigger) return;
|
||||
function dispatchRawButton(button: number): void {
|
||||
const event = new CustomEvent("mediahive:gamepad-button", {
|
||||
detail: { button },
|
||||
cancelable: true,
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
gamepadLastTriggerAt[action] = now;
|
||||
dispatchKey(KEY_BY_ACTION[action]);
|
||||
function triggerSinglePressAction(action: GamepadAction, isPressed: boolean): void {
|
||||
const wasPressed = gamepadPressedState[action]
|
||||
gamepadPressedState[action] = isPressed
|
||||
if (isPressed && !wasPressed) {
|
||||
dispatchGamepadAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
function triggerSinglePressRawButton(button: number, isPressed: boolean): void {
|
||||
const wasPressed = rawButtonPressedState[button] ?? false
|
||||
rawButtonPressedState[button] = isPressed
|
||||
if (isPressed && !wasPressed) {
|
||||
dispatchRawButton(button)
|
||||
}
|
||||
}
|
||||
|
||||
function getDigitalRepeatIntervalMs(holdMs: number): number {
|
||||
const progress = Math.min(Math.max(holdMs, 0), DIGITAL_ACCEL_RAMP_MS) / DIGITAL_ACCEL_RAMP_MS
|
||||
return Math.round(
|
||||
DIGITAL_REPEAT_START_MS - (DIGITAL_REPEAT_START_MS - DIGITAL_REPEAT_MIN_MS) * progress,
|
||||
)
|
||||
}
|
||||
|
||||
function getAnalogRepeatIntervalMs(intensity: number): number {
|
||||
const normalized = Math.min(Math.max(intensity, 0), 1)
|
||||
return Math.round(ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized)
|
||||
}
|
||||
|
||||
function normalizeAxisIntensity(rawValue: number): number {
|
||||
if (rawValue <= ANALOG_DEADZONE) return 0
|
||||
return Math.min((rawValue - ANALOG_DEADZONE) / (1 - ANALOG_DEADZONE), 1)
|
||||
}
|
||||
|
||||
function applyAnalogDirection(action: DirectionAction, intensity: number, now: number): void {
|
||||
if (intensity <= 0) {
|
||||
analogLastTriggerAt[action] = 0
|
||||
return
|
||||
}
|
||||
|
||||
const last = analogLastTriggerAt[action]
|
||||
const repeatMs = getAnalogRepeatIntervalMs(intensity)
|
||||
|
||||
if (last === 0 || now - last >= repeatMs) {
|
||||
analogLastTriggerAt[action] = now
|
||||
dispatchGamepadAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
function resetPressedState() {
|
||||
gamepadPressedState.up = false;
|
||||
gamepadPressedState.down = false;
|
||||
gamepadPressedState.left = false;
|
||||
gamepadPressedState.right = false;
|
||||
gamepadPressedState.select = false;
|
||||
gamepadPressedState.back = false;
|
||||
gamepadPressedState.up = false
|
||||
gamepadPressedState.down = false
|
||||
gamepadPressedState.left = false
|
||||
gamepadPressedState.right = false
|
||||
gamepadPressedState.select = false
|
||||
gamepadPressedState.back = false
|
||||
gamepadPressedState.menu = false
|
||||
rawButtonPressedState[2] = false
|
||||
rawButtonPressedState[3] = false
|
||||
rawButtonPressedState[4] = false
|
||||
rawButtonPressedState[5] = false
|
||||
analogLastTriggerAt.up = 0
|
||||
analogLastTriggerAt.down = 0
|
||||
analogLastTriggerAt.left = 0
|
||||
analogLastTriggerAt.right = 0
|
||||
digitalHoldStartedAt = 0
|
||||
digitalLastRepeatAt = 0
|
||||
digitalRepeatCount = 0
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (gamepadFrameId !== null) {
|
||||
window.cancelAnimationFrame(gamepadFrameId)
|
||||
gamepadFrameId = null
|
||||
}
|
||||
if (idleTimerId !== null) {
|
||||
window.clearTimeout(idleTimerId)
|
||||
idleTimerId = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleIdlePoll() {
|
||||
if (idleTimerId !== null) return
|
||||
idleTimerId = window.setTimeout(() => {
|
||||
idleTimerId = null
|
||||
if (gamepadInstalled) {
|
||||
pollGamepad()
|
||||
}
|
||||
}, IDLE_POLL_MS)
|
||||
}
|
||||
|
||||
function pollGamepad() {
|
||||
const gamepads = navigator.getGamepads?.() ?? [];
|
||||
const now = performance.now();
|
||||
const connectedGamepads = gamepads.filter((gp): gp is Gamepad => Boolean(gp && gp.connected));
|
||||
const gamepads = navigator.getGamepads?.() ?? []
|
||||
const now = performance.now()
|
||||
const connectedGamepads = gamepads.filter((gp): gp is Gamepad => Boolean(gp && gp.connected))
|
||||
|
||||
if (connectedGamepads.length > 0) {
|
||||
let up = false;
|
||||
let down = false;
|
||||
let left = false;
|
||||
let right = false;
|
||||
let select = false;
|
||||
let back = false;
|
||||
let digitalUp = false
|
||||
let digitalDown = false
|
||||
let digitalLeft = false
|
||||
let digitalRight = false
|
||||
let analogUpIntensity = 0
|
||||
let analogDownIntensity = 0
|
||||
let analogLeftIntensity = 0
|
||||
let analogRightIntensity = 0
|
||||
let select = false
|
||||
let back = false
|
||||
let menu = false
|
||||
let raw2 = false
|
||||
let raw3 = false
|
||||
let raw4 = false
|
||||
let raw5 = false
|
||||
|
||||
for (const gamepad of connectedGamepads) {
|
||||
const axisX = gamepad.axes[0] ?? 0;
|
||||
const axisY = gamepad.axes[1] ?? 0;
|
||||
const axisX = gamepad.axes[0] ?? 0
|
||||
const axisY = gamepad.axes[1] ?? 0
|
||||
|
||||
up = up || Boolean(gamepad.buttons[12]?.pressed) || axisY <= -GAMEPAD_AXIS_THRESHOLD;
|
||||
down = down || Boolean(gamepad.buttons[13]?.pressed) || axisY >= GAMEPAD_AXIS_THRESHOLD;
|
||||
left = left || Boolean(gamepad.buttons[14]?.pressed) || axisX <= -GAMEPAD_AXIS_THRESHOLD;
|
||||
right = right || Boolean(gamepad.buttons[15]?.pressed) || axisX >= GAMEPAD_AXIS_THRESHOLD;
|
||||
digitalUp = digitalUp || Boolean(gamepad.buttons[12]?.pressed)
|
||||
digitalDown = digitalDown || Boolean(gamepad.buttons[13]?.pressed)
|
||||
digitalLeft = digitalLeft || Boolean(gamepad.buttons[14]?.pressed)
|
||||
digitalRight = digitalRight || Boolean(gamepad.buttons[15]?.pressed)
|
||||
|
||||
// Xbox mapping on standard gamepads: A=0, B=1
|
||||
select = select || Boolean(gamepad.buttons[0]?.pressed);
|
||||
back = back || Boolean(gamepad.buttons[1]?.pressed);
|
||||
const upIntensity = normalizeAxisIntensity(-axisY)
|
||||
const downIntensity = normalizeAxisIntensity(axisY)
|
||||
const leftIntensity = normalizeAxisIntensity(-axisX)
|
||||
const rightIntensity = normalizeAxisIntensity(axisX)
|
||||
if (upIntensity > analogUpIntensity) analogUpIntensity = upIntensity
|
||||
if (downIntensity > analogDownIntensity) analogDownIntensity = downIntensity
|
||||
if (leftIntensity > analogLeftIntensity) analogLeftIntensity = leftIntensity
|
||||
if (rightIntensity > analogRightIntensity) analogRightIntensity = rightIntensity
|
||||
|
||||
// Xbox mapping on standard gamepads: A=0, B=1, X=2, Y=3
|
||||
select = select || Boolean(gamepad.buttons[0]?.pressed)
|
||||
back = back || Boolean(gamepad.buttons[1]?.pressed)
|
||||
menu = menu || Boolean(gamepad.buttons[3]?.pressed)
|
||||
|
||||
raw2 = raw2 || Boolean(gamepad.buttons[2]?.pressed)
|
||||
raw3 = raw3 || Boolean(gamepad.buttons[3]?.pressed)
|
||||
raw4 = raw4 || Boolean(gamepad.buttons[4]?.pressed)
|
||||
raw5 = raw5 || Boolean(gamepad.buttons[5]?.pressed)
|
||||
}
|
||||
|
||||
applyGamepadAction('up', up, now);
|
||||
applyGamepadAction('down', down, now);
|
||||
applyGamepadAction('left', left, now);
|
||||
applyGamepadAction('right', right, now);
|
||||
applyGamepadAction('select', select, now);
|
||||
applyGamepadAction('back', back, now);
|
||||
triggerSinglePressAction("up", digitalUp)
|
||||
triggerSinglePressAction("down", digitalDown)
|
||||
triggerSinglePressAction("left", digitalLeft)
|
||||
triggerSinglePressAction("right", digitalRight)
|
||||
triggerSinglePressAction("select", select)
|
||||
triggerSinglePressAction("back", back)
|
||||
triggerSinglePressAction("menu", menu)
|
||||
triggerSinglePressRawButton(2, raw2)
|
||||
triggerSinglePressRawButton(3, raw3)
|
||||
triggerSinglePressRawButton(4, raw4)
|
||||
triggerSinglePressRawButton(5, raw5)
|
||||
|
||||
const hasDigitalRepeatable =
|
||||
digitalUp || digitalDown || digitalLeft || digitalRight || raw2 || raw3 || raw4 || raw5
|
||||
|
||||
if (!hasDigitalRepeatable) {
|
||||
digitalHoldStartedAt = 0
|
||||
digitalLastRepeatAt = 0
|
||||
digitalRepeatCount = 0
|
||||
} else {
|
||||
resetPressedState();
|
||||
if (digitalHoldStartedAt === 0) {
|
||||
digitalHoldStartedAt = now
|
||||
digitalLastRepeatAt = now
|
||||
digitalRepeatCount = 0
|
||||
}
|
||||
|
||||
gamepadFrameId = window.requestAnimationFrame(pollGamepad);
|
||||
const holdMs = now - digitalHoldStartedAt
|
||||
const repeatMs =
|
||||
digitalRepeatCount === 0
|
||||
? DIGITAL_INITIAL_REPEAT_DELAY_MS
|
||||
: getDigitalRepeatIntervalMs(holdMs)
|
||||
|
||||
if (now - digitalLastRepeatAt >= repeatMs) {
|
||||
if (digitalUp) dispatchGamepadAction("up")
|
||||
if (digitalDown) dispatchGamepadAction("down")
|
||||
if (digitalLeft) dispatchGamepadAction("left")
|
||||
if (digitalRight) dispatchGamepadAction("right")
|
||||
if (raw2) dispatchRawButton(2)
|
||||
if (raw3) dispatchRawButton(3)
|
||||
if (raw4) dispatchRawButton(4)
|
||||
if (raw5) dispatchRawButton(5)
|
||||
|
||||
digitalLastRepeatAt = now
|
||||
digitalRepeatCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Analog repeat has no initial delay and speed increases with stick deflection.
|
||||
// If digital d-pad direction is held, it owns that direction to avoid duplicate repeats.
|
||||
applyAnalogDirection("up", digitalUp ? 0 : analogUpIntensity, now)
|
||||
applyAnalogDirection("down", digitalDown ? 0 : analogDownIntensity, now)
|
||||
applyAnalogDirection("left", digitalLeft ? 0 : analogLeftIntensity, now)
|
||||
applyAnalogDirection("right", digitalRight ? 0 : analogRightIntensity, now)
|
||||
|
||||
// Keep using rAF while gamepads are active for responsive input
|
||||
gamepadFrameId = window.requestAnimationFrame(pollGamepad)
|
||||
} else {
|
||||
resetPressedState()
|
||||
// No gamepads connected — drop to slow polling to save CPU
|
||||
scheduleIdlePoll()
|
||||
}
|
||||
}
|
||||
|
||||
function handleGamepadConnected() {
|
||||
if (!gamepadInstalled) return
|
||||
// A gamepad was plugged in; make sure we're polling
|
||||
stopPolling()
|
||||
gamepadFrameId = window.requestAnimationFrame(pollGamepad)
|
||||
}
|
||||
|
||||
export function installGamepadNavigation() {
|
||||
if (gamepadInstalled) return;
|
||||
gamepadInstalled = true;
|
||||
gamepadFrameId = window.requestAnimationFrame(pollGamepad);
|
||||
if (gamepadInstalled) return
|
||||
gamepadInstalled = true
|
||||
gamepadFrameId = window.requestAnimationFrame(pollGamepad)
|
||||
window.addEventListener("gamepadconnected", handleGamepadConnected)
|
||||
}
|
||||
|
||||
export function uninstallGamepadNavigation() {
|
||||
if (!gamepadInstalled) return;
|
||||
gamepadInstalled = false;
|
||||
if (gamepadFrameId !== null) {
|
||||
window.cancelAnimationFrame(gamepadFrameId);
|
||||
gamepadFrameId = null;
|
||||
}
|
||||
resetPressedState();
|
||||
if (!gamepadInstalled) return
|
||||
gamepadInstalled = false
|
||||
stopPolling()
|
||||
resetPressedState()
|
||||
window.removeEventListener("gamepadconnected", handleGamepadConnected)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { reportUserActivity } from "../api"
|
||||
|
||||
type InputModality = "mouse" | "keyboard" | "gamepad"
|
||||
|
||||
|
||||
const MOUSE_IDLE_MS = 1400
|
||||
const MOUSE_INTENT_DISTANCE_PX = 28
|
||||
const MOUSE_INTENT_WINDOW_MS = 700
|
||||
const MOUSE_INTENT_SELECTOR = [
|
||||
"[data-nav-focusable]",
|
||||
"button",
|
||||
"a[href]",
|
||||
"input",
|
||||
"select",
|
||||
"textarea",
|
||||
'[role="button"]',
|
||||
".media-card",
|
||||
".collage-item",
|
||||
".episode-tile",
|
||||
".version-row",
|
||||
".ctx-btn",
|
||||
".header-nav-item",
|
||||
].join(",")
|
||||
|
||||
let installed = false
|
||||
let modality: InputModality = "mouse"
|
||||
let mouseIdleTimer: number | null = null
|
||||
let pointerVisible = false
|
||||
let mouseTravelPx = 0
|
||||
let lastMouseMoveAt = 0
|
||||
|
||||
function clearMouseIdleTimer() {
|
||||
if (mouseIdleTimer !== null) {
|
||||
window.clearTimeout(mouseIdleTimer)
|
||||
mouseIdleTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function applyInputState(mouseActive: boolean) {
|
||||
const root = document.documentElement
|
||||
root.classList.toggle("mouse-active", mouseActive)
|
||||
root.classList.toggle("pointer-visible", pointerVisible)
|
||||
}
|
||||
|
||||
function scheduleMouseIdle() {
|
||||
clearMouseIdleTimer()
|
||||
mouseIdleTimer = window.setTimeout(() => {
|
||||
pointerVisible = false
|
||||
applyInputState(false)
|
||||
}, MOUSE_IDLE_MS)
|
||||
}
|
||||
|
||||
function activateMouseInput() {
|
||||
modality = "mouse"
|
||||
pointerVisible = true
|
||||
applyInputState(true)
|
||||
scheduleMouseIdle()
|
||||
}
|
||||
|
||||
function activateNonMouseInput(next: InputModality) {
|
||||
modality = next
|
||||
pointerVisible = false
|
||||
mouseTravelPx = 0
|
||||
clearMouseIdleTimer()
|
||||
applyInputState(false)
|
||||
}
|
||||
|
||||
function showPointerFromMotion() {
|
||||
pointerVisible = true
|
||||
applyInputState(modality === "mouse")
|
||||
scheduleMouseIdle()
|
||||
}
|
||||
|
||||
function isMouseIntentTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false
|
||||
return Boolean(target.closest(MOUSE_INTENT_SELECTOR))
|
||||
}
|
||||
|
||||
function registerMouseIntentTravel(event: MouseEvent): boolean {
|
||||
const now = performance.now()
|
||||
if (now - lastMouseMoveAt > MOUSE_INTENT_WINDOW_MS) {
|
||||
mouseTravelPx = 0
|
||||
}
|
||||
lastMouseMoveAt = now
|
||||
|
||||
const step = Math.hypot(event.movementX || 0, event.movementY || 0)
|
||||
mouseTravelPx += step
|
||||
if (mouseTravelPx >= MOUSE_INTENT_DISTANCE_PX) {
|
||||
mouseTravelPx = 0
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
reportUserActivity()
|
||||
showPointerFromMotion()
|
||||
|
||||
if (modality === "mouse") {
|
||||
applyInputState(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMouseIntentTarget(event.target)) return
|
||||
if (registerMouseIntentTravel(event)) {
|
||||
activateMouseInput()
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseOver(event: MouseEvent) {
|
||||
if (modality === "mouse") return
|
||||
if (!isMouseIntentTarget(event.target)) return
|
||||
|
||||
// Entering an interactive target indicates likely mouse intent.
|
||||
activateMouseInput()
|
||||
}
|
||||
|
||||
function handleMouseIntentAction(event: MouseEvent | WheelEvent) {
|
||||
reportUserActivity()
|
||||
pointerVisible = true
|
||||
if (isMouseIntentTarget(event.target)) {
|
||||
activateMouseInput()
|
||||
return
|
||||
}
|
||||
|
||||
applyInputState(modality === "mouse")
|
||||
scheduleMouseIdle()
|
||||
}
|
||||
|
||||
function handleKeyboardActivity(event: KeyboardEvent) {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return
|
||||
reportUserActivity()
|
||||
activateNonMouseInput("keyboard")
|
||||
}
|
||||
|
||||
function handleGamepadActivity() {
|
||||
activateNonMouseInput("gamepad")
|
||||
}
|
||||
|
||||
export function installInputModalityTracking() {
|
||||
if (installed) return
|
||||
installed = true
|
||||
|
||||
applyInputState(false)
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove, { passive: true })
|
||||
window.addEventListener("mouseover", handleMouseOver, { passive: true })
|
||||
window.addEventListener("mousedown", handleMouseIntentAction, { passive: true })
|
||||
window.addEventListener("wheel", handleMouseIntentAction, { passive: true })
|
||||
window.addEventListener("keydown", handleKeyboardActivity, { passive: true })
|
||||
window.addEventListener("mediahive:gamepad-action", handleGamepadActivity as EventListener)
|
||||
}
|
||||
@@ -1,179 +1,644 @@
|
||||
import { ref, readonly, onUnmounted } from 'vue';
|
||||
import type { Movie, Series, MediaIndex, TaskInfo, WsMessage } from '../types';
|
||||
import { shallowRef, readonly, onUnmounted } from "vue"
|
||||
import type {
|
||||
CastGender,
|
||||
CastMember,
|
||||
Movie,
|
||||
MovieUi,
|
||||
Person,
|
||||
Series,
|
||||
SeriesUi,
|
||||
Episode,
|
||||
Season,
|
||||
Torrent,
|
||||
MediaIndex,
|
||||
TaskInfo,
|
||||
WsMessage,
|
||||
WsRootStatus,
|
||||
} from "../types"
|
||||
|
||||
interface RootState {
|
||||
movieMap: Map<string, MovieUi>
|
||||
seriesMap: Map<string, SeriesUi>
|
||||
peopleMap: Map<number, Person>
|
||||
initialized: boolean
|
||||
pendingMessages: WsMessage[]
|
||||
}
|
||||
|
||||
export interface RootStatusEntry extends WsRootStatus {}
|
||||
|
||||
const MERGED_KEY_DELIMITER = "::"
|
||||
|
||||
/**
|
||||
* Composable that connects to the MediaHive WebSocket and keeps
|
||||
* the media index updated in real time.
|
||||
*
|
||||
* The server sends:
|
||||
* - "init" → full index (movies + series) on connect
|
||||
* - "upsert" → single item inserted or updated
|
||||
* - "remove" → single item removed
|
||||
* - "task" → background task progress
|
||||
*
|
||||
* Messages are msgspec-encoded binary JSON with a "type" tag field.
|
||||
* Composable that connects to one all-roots MediaHive WebSocket and keeps
|
||||
* a merged media index updated in real time.
|
||||
*/
|
||||
export function useMediaWebSocket() {
|
||||
const mediaIndex = ref<MediaIndex | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const connected = ref(false);
|
||||
const tasks = ref<Map<string, TaskInfo>>(new Map());
|
||||
type RootTaskInfo = TaskInfo & { root_id: string }
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let disposed = false;
|
||||
const mediaIndex = shallowRef<MediaIndex | null>(null)
|
||||
const loading = shallowRef(true)
|
||||
const error = shallowRef<string | null>(null)
|
||||
const connected = shallowRef(false)
|
||||
const tasks = shallowRef<Map<string, RootTaskInfo>>(new Map())
|
||||
const roots = shallowRef<Map<string, RootStatusEntry>>(new Map())
|
||||
|
||||
// Lookup maps for fast upsert / remove
|
||||
const movieMap = new Map<string, Movie>();
|
||||
const seriesMap = new Map<string, Series>();
|
||||
const rootStates = shallowRef<Map<string, RootState>>(new Map())
|
||||
const wsRef = shallowRef<WebSocket | null>(null)
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let disposed = false
|
||||
|
||||
// Single periodic sweep for completed tasks instead of one timeout per task
|
||||
const completedTaskIds = new Set<string>()
|
||||
let taskSweepTimer: ReturnType<typeof setInterval> | null = null
|
||||
function startTaskSweep() {
|
||||
if (taskSweepTimer !== null) return
|
||||
taskSweepTimer = setInterval(() => {
|
||||
if (completedTaskIds.size === 0) return
|
||||
const next = new Map(tasks.value)
|
||||
let changed = false
|
||||
for (const id of completedTaskIds) {
|
||||
if (next.delete(id)) changed = true
|
||||
}
|
||||
completedTaskIds.clear()
|
||||
if (changed) {
|
||||
tasks.value = next
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
function stopTaskSweep() {
|
||||
if (taskSweepTimer !== null) {
|
||||
clearInterval(taskSweepTimer)
|
||||
taskSweepTimer = null
|
||||
}
|
||||
}
|
||||
onUnmounted(stopTaskSweep)
|
||||
|
||||
function getContentHash(itemId: string): string {
|
||||
return itemId.split(":").pop() || itemId
|
||||
}
|
||||
|
||||
function torrentQualityScore(t: Torrent): number {
|
||||
let score = 0
|
||||
const res = (t.resolution || "").toLowerCase()
|
||||
if (res.includes("2160") || res.includes("4k") || res.includes("uhd")) score += 100
|
||||
else if (res.includes("1080") || res.includes("fhd")) score += 80
|
||||
else if (res.includes("720") || res === "hd") score += 60
|
||||
else if (res.includes("480") || res === "sd") score += 40
|
||||
else if (res.includes("360")) score += 20
|
||||
if (t.dovi) score += 15
|
||||
if (t.hdr) score += 10
|
||||
if (t.atmos) score += 5
|
||||
return score
|
||||
}
|
||||
|
||||
function expandPlayablePath(fileKey: string, playableFile: string | null): string | null {
|
||||
if (!playableFile) return fileKey
|
||||
if (playableFile.startsWith("concat:") || playableFile.includes("://")) return playableFile
|
||||
if (playableFile.startsWith(`${fileKey}/`)) return playableFile
|
||||
if (playableFile.startsWith("/")) return playableFile.replace(/^\/+/, "")
|
||||
return `${fileKey}/${playableFile}`
|
||||
}
|
||||
|
||||
function annotateFiles(
|
||||
files: { [key: string]: Torrent },
|
||||
rootId: string | null,
|
||||
): { [key: string]: Torrent } {
|
||||
return Object.fromEntries(
|
||||
Object.entries(files || {}).map(([k, t]) => [
|
||||
k,
|
||||
{
|
||||
...t,
|
||||
playable_file: expandPlayablePath(resolveFilePathKey(k), t.playable_file || null),
|
||||
root_id: t.root_id || rootId,
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveFilePathKey(key: string): string {
|
||||
const delimIndex = key.indexOf(MERGED_KEY_DELIMITER)
|
||||
if (delimIndex >= 0) {
|
||||
return key.slice(delimIndex + MERGED_KEY_DELIMITER.length)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function mergeTorrentDicts(
|
||||
a: { [key: string]: Torrent },
|
||||
b: { [key: string]: Torrent },
|
||||
): { [key: string]: Torrent } {
|
||||
const merged: { [key: string]: Torrent } = { ...a }
|
||||
for (const [k, t] of Object.entries(b)) {
|
||||
const uniqueKey = merged[k] ? `${t.root_id || "unknown"}${MERGED_KEY_DELIMITER}${k}` : k
|
||||
merged[uniqueKey] = t
|
||||
}
|
||||
const sorted = Object.entries(merged).sort(([, t1], [, t2]) => {
|
||||
const s1 = torrentQualityScore(t1)
|
||||
const s2 = torrentQualityScore(t2)
|
||||
if (s2 !== s1) return s2 - s1
|
||||
return (t2.size || 0) - (t1.size || 0)
|
||||
})
|
||||
return Object.fromEntries(sorted)
|
||||
}
|
||||
|
||||
function withMovieIdentity(
|
||||
id: string,
|
||||
movie: Movie,
|
||||
rootId: string,
|
||||
people: Map<number, Person>,
|
||||
): MovieUi {
|
||||
return { ...normalizeMovie(movie, rootId, people), id, root_id: rootId }
|
||||
}
|
||||
|
||||
function withSeriesIdentity(
|
||||
id: string,
|
||||
series: Series,
|
||||
rootId: string,
|
||||
people: Map<number, Person>,
|
||||
): SeriesUi {
|
||||
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
|
||||
}
|
||||
|
||||
function normalizeCastMember(
|
||||
member: unknown,
|
||||
people: Map<number, Person>,
|
||||
): CastMember {
|
||||
if (!Array.isArray(member)) {
|
||||
return {
|
||||
name: "",
|
||||
character: null,
|
||||
profile_path: null,
|
||||
gender: null,
|
||||
id: null,
|
||||
}
|
||||
}
|
||||
|
||||
// Current wire format: CastCredit(array_like=True) => [character, id]
|
||||
const character = typeof member[0] === "string" ? member[0] : null
|
||||
const id = typeof member[1] === "number" ? member[1] : null
|
||||
const person = id !== null ? people.get(id) || null : null
|
||||
return {
|
||||
name: person?.name || "",
|
||||
character,
|
||||
profile_path: person?.profile_path || null,
|
||||
gender: person?.gender ?? null,
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePerson(member: unknown): Person | null {
|
||||
if (!Array.isArray(member)) return null
|
||||
const gender = normalizeCastGender(member[2])
|
||||
return {
|
||||
name: typeof member[0] === "string" ? member[0] : "",
|
||||
profile_path: typeof member[1] === "string" ? member[1] : null,
|
||||
gender,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCastGender(value: unknown): CastGender | null {
|
||||
if (typeof value !== "string") return null
|
||||
switch (value) {
|
||||
case "female":
|
||||
case "male":
|
||||
case "non_binary":
|
||||
case "unknown":
|
||||
return value
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInfo<T extends { cast?: unknown }>(
|
||||
info: T | null,
|
||||
people: Map<number, Person>,
|
||||
): T | null {
|
||||
if (!info) return info
|
||||
let next: T = info
|
||||
if (Array.isArray((info as { cast?: unknown }).cast)) {
|
||||
const cast = ((info as { cast?: unknown[] }).cast || [])
|
||||
.map((member) => normalizeCastMember(member, people))
|
||||
.filter((member) => member.name.length > 0)
|
||||
next = { ...next, cast } as T
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function normalizeMovie(movie: Movie, rootId: string | null, people: Map<number, Person>): Movie {
|
||||
return {
|
||||
...movie,
|
||||
files: annotateFiles(movie.files, rootId),
|
||||
info: normalizeInfo(movie.info, people),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSeries(
|
||||
series: Series,
|
||||
rootId: string | null,
|
||||
people: Map<number, Person>,
|
||||
): Series {
|
||||
return {
|
||||
...series,
|
||||
seasons: (series.seasons || []).map((season) => ({
|
||||
...season,
|
||||
episodes: (season.episodes || []).map((episode) => ({
|
||||
...episode,
|
||||
files: annotateFiles(episode.files, rootId),
|
||||
})),
|
||||
})),
|
||||
info: normalizeInfo(series.info, people),
|
||||
}
|
||||
}
|
||||
|
||||
function mergeMovies(a: MovieUi, b: MovieUi): MovieUi {
|
||||
const filesA = annotateFiles(a.files, a.root_id)
|
||||
const filesB = annotateFiles(b.files, b.root_id)
|
||||
return {
|
||||
...a,
|
||||
id: getContentHash(a.id),
|
||||
files: mergeTorrentDicts(filesA, filesB),
|
||||
info: a.info || b.info,
|
||||
cover_path: a.cover_path || b.cover_path,
|
||||
backdrop_path: a.backdrop_path || b.backdrop_path,
|
||||
showreel_images: a.showreel_images?.length ? a.showreel_images : b.showreel_images,
|
||||
showreel_source_sets: a.showreel_source_sets?.length
|
||||
? a.showreel_source_sets
|
||||
: b.showreel_source_sets,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeEpisodes(
|
||||
a: Episode,
|
||||
b: Episode,
|
||||
rootIdA: string | null,
|
||||
rootIdB: string | null,
|
||||
): Episode {
|
||||
const filesA = annotateFiles(a.files, rootIdA)
|
||||
const filesB = annotateFiles(b.files, rootIdB)
|
||||
return {
|
||||
...a,
|
||||
files: mergeTorrentDicts(filesA, filesB),
|
||||
reel_image: a.reel_image || b.reel_image,
|
||||
reel_sources: a.reel_sources?.length ? a.reel_sources : b.reel_sources,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeSeasons(
|
||||
a: Season,
|
||||
b: Season,
|
||||
rootIdA: string | null,
|
||||
rootIdB: string | null,
|
||||
): Season {
|
||||
const episodeMap = new Map<number, Episode>()
|
||||
for (const ep of a.episodes) {
|
||||
episodeMap.set(ep.episode_number, ep)
|
||||
}
|
||||
for (const ep of b.episodes) {
|
||||
const existing = episodeMap.get(ep.episode_number)
|
||||
if (existing) {
|
||||
episodeMap.set(ep.episode_number, mergeEpisodes(existing, ep, rootIdA, rootIdB))
|
||||
} else {
|
||||
episodeMap.set(ep.episode_number, {
|
||||
...ep,
|
||||
files: annotateFiles(ep.files, rootIdB),
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
...a,
|
||||
episodes: Array.from(episodeMap.values()).sort((a, b) => a.episode_number - b.episode_number),
|
||||
poster_path: a.poster_path || b.poster_path,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeSeries(a: SeriesUi, b: SeriesUi): SeriesUi {
|
||||
const seasonMap = new Map<number, Season>()
|
||||
for (const season of a.seasons || []) {
|
||||
seasonMap.set(season.season_number, {
|
||||
...season,
|
||||
episodes: season.episodes.map((ep) => ({
|
||||
...ep,
|
||||
files: annotateFiles(ep.files, a.root_id),
|
||||
})),
|
||||
})
|
||||
}
|
||||
for (const season of b.seasons || []) {
|
||||
const existing = seasonMap.get(season.season_number)
|
||||
if (existing) {
|
||||
seasonMap.set(season.season_number, mergeSeasons(existing, season, a.root_id, b.root_id))
|
||||
} else {
|
||||
seasonMap.set(season.season_number, {
|
||||
...season,
|
||||
episodes: season.episodes.map((ep) => ({
|
||||
...ep,
|
||||
files: annotateFiles(ep.files, b.root_id),
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
...a,
|
||||
id: getContentHash(a.id),
|
||||
seasons: Array.from(seasonMap.values()).sort((a, b) => a.season_number - b.season_number),
|
||||
info: a.info || b.info,
|
||||
cover_path: a.cover_path || b.cover_path,
|
||||
backdrop_path: a.backdrop_path || b.backdrop_path,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeItemsByHash<T extends MovieUi | SeriesUi>(items: T[], mergeFn: (a: T, b: T) => T): T[] {
|
||||
const map = new Map<string, T[]>()
|
||||
for (const item of items) {
|
||||
const hash = getContentHash(item.id)
|
||||
const arr = map.get(hash) || []
|
||||
arr.push(item)
|
||||
map.set(hash, arr)
|
||||
}
|
||||
const merged: T[] = []
|
||||
for (const [, group] of map) {
|
||||
if (group.length === 1) {
|
||||
merged.push(group[0])
|
||||
} else {
|
||||
let result = group[0]
|
||||
for (let i = 1; i < group.length; i++) {
|
||||
result = mergeFn(result, group[i])
|
||||
}
|
||||
merged.push(result)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
const movies = Array.from(movieMap.values());
|
||||
const series = Array.from(seriesMap.values());
|
||||
const movies: MovieUi[] = []
|
||||
const series: SeriesUi[] = []
|
||||
for (const state of rootStates.value.values()) {
|
||||
movies.push(...state.movieMap.values())
|
||||
series.push(...state.seriesMap.values())
|
||||
}
|
||||
const mergedMovies = mergeItemsByHash(movies, mergeMovies)
|
||||
const mergedSeries = mergeItemsByHash(series, mergeSeries)
|
||||
return {
|
||||
version: 0,
|
||||
v: 1,
|
||||
generated_at: new Date().toISOString(),
|
||||
stats: {
|
||||
total_movies: movies.length,
|
||||
total_series: series.length,
|
||||
},
|
||||
movies,
|
||||
series,
|
||||
};
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent) {
|
||||
try {
|
||||
// Server sends binary frames (msgspec json bytes)
|
||||
let text: string;
|
||||
if (event.data instanceof Blob) {
|
||||
// Will be handled by the blob reader below
|
||||
event.data.text().then((t) => processJson(t));
|
||||
return;
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
text = new TextDecoder().decode(event.data);
|
||||
} else {
|
||||
text = event.data as string;
|
||||
}
|
||||
processJson(text);
|
||||
} catch (e) {
|
||||
console.error('[WS] Failed to handle message:', e);
|
||||
movies: mergedMovies,
|
||||
series: mergedSeries,
|
||||
}
|
||||
}
|
||||
|
||||
function processJson(text: string) {
|
||||
const msg = JSON.parse(text) as WsMessage;
|
||||
function updateMergedState() {
|
||||
mediaIndex.value = buildIndex()
|
||||
|
||||
let anyInitialized = false
|
||||
for (const state of rootStates.value.values()) {
|
||||
if (state.initialized) {
|
||||
anyInitialized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (anyInitialized || roots.value.size === 0) {
|
||||
loading.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
connected.value = wsRef.value?.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
function applyRootInit(rootId: string, rootData: { movies: Record<string, Movie>; series: Record<string, Series>; people?: Record<string, unknown> }) {
|
||||
const state = ensureRootState(rootId)
|
||||
|
||||
state.peopleMap.clear()
|
||||
for (const [id, person] of Object.entries(rootData.people || {})) {
|
||||
const parsed = Number(id)
|
||||
const normalized = normalizePerson(person)
|
||||
if (Number.isFinite(parsed)) {
|
||||
state.peopleMap.set(parsed, normalized || { name: "", profile_path: null, gender: null })
|
||||
}
|
||||
}
|
||||
|
||||
state.movieMap.clear()
|
||||
state.seriesMap.clear()
|
||||
for (const [id, m] of Object.entries(rootData.movies || {})) {
|
||||
state.movieMap.set(id, withMovieIdentity(id, m, rootId, state.peopleMap))
|
||||
}
|
||||
for (const [id, s] of Object.entries(rootData.series || {})) {
|
||||
state.seriesMap.set(id, withSeriesIdentity(id, s, rootId, state.peopleMap))
|
||||
}
|
||||
|
||||
state.initialized = true
|
||||
|
||||
if (state.pendingMessages.length > 0) {
|
||||
const queued = state.pendingMessages
|
||||
state.pendingMessages = []
|
||||
for (const queuedMsg of queued) {
|
||||
processMessage(queuedMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processMessage(msg: WsMessage) {
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
movieMap.clear();
|
||||
seriesMap.clear();
|
||||
for (const m of msg.data.movies) movieMap.set(m.id, m);
|
||||
for (const s of msg.data.series) seriesMap.set(s.id, s);
|
||||
mediaIndex.value = buildIndex();
|
||||
loading.value = false;
|
||||
error.value = null;
|
||||
console.log(`[WS] init: ${movieMap.size} movies, ${seriesMap.size} series`);
|
||||
break;
|
||||
case "roots": {
|
||||
const next = new Map<string, RootStatusEntry>()
|
||||
for (const root of msg.roots || []) {
|
||||
next.set(root.root_id, { ...root })
|
||||
ensureRootState(root.root_id)
|
||||
}
|
||||
case 'upsert': {
|
||||
if (msg.kind === 'movie') {
|
||||
movieMap.set(msg.item.id, msg.item as Movie);
|
||||
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": {
|
||||
const state = ensureRootState(msg.root_id)
|
||||
if (!state.initialized) {
|
||||
state.pendingMessages.push(msg)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.people) {
|
||||
for (const [id, person] of Object.entries(msg.people)) {
|
||||
const parsed = Number(id)
|
||||
const normalized = normalizePerson(person)
|
||||
if (Number.isFinite(parsed)) {
|
||||
state.peopleMap.set(parsed, normalized || { name: "", profile_path: null, gender: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.kind === "movie") {
|
||||
state.movieMap.set(
|
||||
msg.id,
|
||||
withMovieIdentity(msg.id, msg.item as Movie, msg.root_id, state.peopleMap),
|
||||
)
|
||||
} else {
|
||||
seriesMap.set(msg.item.id, msg.item as Series);
|
||||
state.seriesMap.set(
|
||||
msg.id,
|
||||
withSeriesIdentity(msg.id, msg.item as Series, msg.root_id, state.peopleMap),
|
||||
)
|
||||
}
|
||||
// Rebuild the index ref so Vue detects the change
|
||||
mediaIndex.value = buildIndex();
|
||||
break;
|
||||
updateMergedState()
|
||||
return
|
||||
}
|
||||
case 'remove': {
|
||||
if (msg.kind === 'movie') {
|
||||
movieMap.delete(msg.id);
|
||||
|
||||
case "remove": {
|
||||
const state = ensureRootState(msg.root_id)
|
||||
if (!state.initialized) {
|
||||
state.pendingMessages.push(msg)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.kind === "movie") {
|
||||
state.movieMap.delete(msg.id)
|
||||
} else {
|
||||
seriesMap.delete(msg.id);
|
||||
state.seriesMap.delete(msg.id)
|
||||
}
|
||||
mediaIndex.value = buildIndex();
|
||||
break;
|
||||
updateMergedState()
|
||||
return
|
||||
}
|
||||
case 'task': {
|
||||
const info = msg.data;
|
||||
if (info.status === 'completed' || info.status === 'cancelled' || info.status === 'error') {
|
||||
// Keep finished tasks briefly so the UI can show completion
|
||||
tasks.value.set(info.id, info);
|
||||
setTimeout(() => {
|
||||
tasks.value.delete(info.id);
|
||||
tasks.value = new Map(tasks.value);
|
||||
}, 3000);
|
||||
} else {
|
||||
tasks.value.set(info.id, info);
|
||||
|
||||
case "task": {
|
||||
const info = msg.data
|
||||
const taskKey = `${msg.root_id}:${info.id}`
|
||||
tasks.value.set(taskKey, { ...info, root_id: msg.root_id })
|
||||
tasks.value = new Map(tasks.value)
|
||||
if (info.status === "completed" || info.status === "cancelled" || info.status === "error") {
|
||||
completedTaskIds.add(taskKey)
|
||||
startTaskSweep()
|
||||
}
|
||||
// Trigger reactivity
|
||||
tasks.value = new Map(tasks.value);
|
||||
break;
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (disposed) return;
|
||||
|
||||
// Build WS URL relative to current page
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}/api/ws`;
|
||||
|
||||
console.log(`[WS] Connecting to ${url}...`);
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
connected.value = true;
|
||||
error.value = null;
|
||||
console.log('[WS] Connected');
|
||||
};
|
||||
|
||||
ws.onmessage = handleMessage;
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
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';
|
||||
function handleRawMessage(event: MessageEvent) {
|
||||
const processText = (text: string) => {
|
||||
try {
|
||||
processMessage(JSON.parse(text) as WsMessage)
|
||||
} catch (e) {
|
||||
console.error("[WS] Failed to handle message:", e)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (event.data instanceof Blob) {
|
||||
void event.data.text().then(processText)
|
||||
return
|
||||
}
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
processText(new TextDecoder().decode(event.data))
|
||||
return
|
||||
}
|
||||
processText(event.data as string)
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (disposed) return;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (disposed) return
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
console.log('[WS] Reconnecting...');
|
||||
connect();
|
||||
}, 2000);
|
||||
reconnectTimer = null
|
||||
connect()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (disposed) return
|
||||
if (wsRef.value && wsRef.value.readyState <= WebSocket.OPEN) return
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:"
|
||||
const url = `${proto}//${location.host}/api/ws`
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
ws.onmessage = (ev) => handleRawMessage(ev)
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
disposed = true;
|
||||
disposed = true
|
||||
stopTaskSweep()
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (ws) {
|
||||
ws.onclose = null; // prevent reconnect
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
// Start the connection
|
||||
connect();
|
||||
if (wsRef.value) {
|
||||
wsRef.value.onclose = null
|
||||
wsRef.value.close()
|
||||
wsRef.value = null
|
||||
}
|
||||
|
||||
// Clean up on component unmount
|
||||
onUnmounted(disconnect);
|
||||
connected.value = false
|
||||
roots.value.clear()
|
||||
rootStates.value.clear()
|
||||
}
|
||||
|
||||
connect()
|
||||
onUnmounted(disconnect)
|
||||
|
||||
return {
|
||||
mediaIndex,
|
||||
@@ -181,6 +646,7 @@ export function useMediaWebSocket() {
|
||||
error: readonly(error),
|
||||
connected: readonly(connected),
|
||||
tasks: readonly(tasks),
|
||||
roots: readonly(roots),
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { reactive, watch } from "vue"
|
||||
import type { Torrent } from "../types"
|
||||
|
||||
export type ResolutionPreference = "r2" | "r3" | "r4" | "rmax"
|
||||
export type HdrPreference = "none" | "hdr10plus" | "dovi"
|
||||
|
||||
export interface MediaHiveSettings {
|
||||
preferredResolution: ResolutionPreference
|
||||
preferredHdr: HdrPreference
|
||||
playerId: string | null
|
||||
playerCustomCmd: string | null
|
||||
playerMpcPort: number | null
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "MediaHive"
|
||||
|
||||
const RESOLUTION_PRIORITY: Record<string, number> = {
|
||||
"8K": 5,
|
||||
"4K": 4,
|
||||
"FHD": 3,
|
||||
"HD": 2,
|
||||
"SD": 1,
|
||||
}
|
||||
|
||||
// Max resolution priority allowed for each preference level
|
||||
const RESOLUTION_CAP: Record<ResolutionPreference, number> = {
|
||||
r2: 2,
|
||||
r3: 3,
|
||||
r4: 4,
|
||||
rmax: 999,
|
||||
}
|
||||
|
||||
const RESOLUTION_VALUES = ["r2", "r3", "r4", "rmax"] as const
|
||||
const HDR_VALUES = ["none", "hdr10plus", "dovi"] as const
|
||||
|
||||
function isResolutionPreference(value: unknown): value is ResolutionPreference {
|
||||
return typeof value === "string" && RESOLUTION_VALUES.includes(value as ResolutionPreference)
|
||||
}
|
||||
|
||||
function isHdrPreference(value: unknown): value is HdrPreference {
|
||||
return typeof value === "string" && HDR_VALUES.includes(value as HdrPreference)
|
||||
}
|
||||
|
||||
function loadSettings(): MediaHiveSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<MediaHiveSettings>
|
||||
return {
|
||||
preferredResolution: isResolutionPreference(parsed.preferredResolution)
|
||||
? parsed.preferredResolution
|
||||
: "rmax",
|
||||
preferredHdr: isHdrPreference(parsed.preferredHdr) ? parsed.preferredHdr : "none",
|
||||
playerId: typeof parsed.playerId === "string" ? parsed.playerId : "default",
|
||||
playerCustomCmd: typeof parsed.playerCustomCmd === "string" ? parsed.playerCustomCmd : null,
|
||||
playerMpcPort: typeof parsed.playerMpcPort === "number" ? parsed.playerMpcPort : null,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return { preferredResolution: "rmax", preferredHdr: "none", playerId: "default", playerCustomCmd: null, playerMpcPort: null }
|
||||
}
|
||||
|
||||
const settings = reactive<MediaHiveSettings>(loadSettings())
|
||||
|
||||
watch(
|
||||
() => ({ ...settings }),
|
||||
(value) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
export function useSettings() {
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a list of Torrent objects according to the current format preferences.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Compute the "effective priority" = min(actual priority, cap).
|
||||
* Releases exceeding the cap receive the capped priority.
|
||||
* 2. Higher effective priority wins.
|
||||
* 3. For equal effective priority, prefer HDR flavor per `preferredHdr`.
|
||||
* 4. Equal in all above: fall back to raw resolution priority (larger is better)
|
||||
* then size.
|
||||
*/
|
||||
export function sortTorrentsByPreference(torrents: Torrent[]): Torrent[] {
|
||||
const cap = RESOLUTION_CAP[settings.preferredResolution]
|
||||
|
||||
function effectivePriority(t: Torrent): number {
|
||||
const raw = RESOLUTION_PRIORITY[t.resolution ?? ""] ?? 0
|
||||
return Math.min(raw, cap)
|
||||
}
|
||||
|
||||
type HdrProfile = "sdr" | "hdr10" | "hdr10plus" | "dovi"
|
||||
|
||||
function detectHdrProfile(t: Torrent): HdrProfile {
|
||||
const text = [t.title, t.quality, t.codec, t.audio].filter(Boolean).join(" ")
|
||||
const hasDovi = t.dovi || /dolby\s*vision|dovi|\bdv\b/i.test(text)
|
||||
const hasHdr10Plus = t.hdr10plus || /hdr10\+|hdr10plus/i.test(text)
|
||||
const hasAnyHdr = t.hdr || hasHdr10Plus || hasDovi || /\bhdr\b/i.test(text)
|
||||
|
||||
if (hasDovi) return "dovi"
|
||||
if (hasHdr10Plus) return "hdr10plus"
|
||||
if (hasAnyHdr) return "hdr10"
|
||||
return "sdr"
|
||||
}
|
||||
|
||||
function scoreForPreference(profile: HdrProfile): number {
|
||||
if (settings.preferredHdr === "none") {
|
||||
// Prefer no HDR; HDR variants after SDR.
|
||||
if (profile === "sdr") return 4
|
||||
if (profile === "hdr10") return 3
|
||||
if (profile === "hdr10plus") return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
if (settings.preferredHdr === "hdr10plus") {
|
||||
// Requested behavior: HDR10+ best, HDR10 next, SDR then, DoVi last.
|
||||
if (profile === "hdr10plus") return 4
|
||||
if (profile === "hdr10") return 3
|
||||
if (profile === "sdr") return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
if (profile === "dovi") return 4
|
||||
if (profile === "hdr10plus") return 3
|
||||
if (profile === "hdr10") return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function hdrPreferenceScore(t: Torrent): number {
|
||||
return scoreForPreference(detectHdrProfile(t))
|
||||
}
|
||||
|
||||
return [...torrents].sort((a, b) => {
|
||||
const epA = effectivePriority(a)
|
||||
const epB = effectivePriority(b)
|
||||
if (epB !== epA) return epB - epA
|
||||
|
||||
// Same effective resolution bucket - apply HDR format preference
|
||||
const hdrScoreA = hdrPreferenceScore(a)
|
||||
const hdrScoreB = hdrPreferenceScore(b)
|
||||
if (hdrScoreB !== hdrScoreA) {
|
||||
return hdrScoreB - hdrScoreA
|
||||
}
|
||||
|
||||
// Fall back to raw resolution priority then size
|
||||
const rawA = RESOLUTION_PRIORITY[a.resolution ?? ""] ?? 0
|
||||
const rawB = RESOLUTION_PRIORITY[b.resolution ?? ""] ?? 0
|
||||
if (rawB !== rawA) return rawB - rawA
|
||||
|
||||
return (b.size ?? 0) - (a.size ?? 0)
|
||||
})
|
||||
}
|
||||
@@ -1,18 +1,73 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/main.css'
|
||||
import { installKeyboardNavigation } from './composables/useKeyboardNavigation'
|
||||
import { installGamepadNavigation } from './composables/useGamepadNavigation'
|
||||
import { createApp } from "vue"
|
||||
import App from "./App.vue"
|
||||
import router from "./router"
|
||||
import "./styles/main.css"
|
||||
import { installKeyboardNavigation } from "./composables/useKeyboardNavigation"
|
||||
import { installGamepadNavigation } from "./composables/useGamepadNavigation"
|
||||
import { installInputModalityTracking } from "./composables/useInputModality"
|
||||
|
||||
// Install global keyboard navigation handlers immediately
|
||||
installKeyboardNavigation()
|
||||
installGamepadNavigation()
|
||||
|
||||
if ('serviceWorker' in navigator && !navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
window.location.reload()
|
||||
}, { once: true })
|
||||
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(() => {})
|
||||
}
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
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
|
||||
installInputModalityTracking()
|
||||
installKeyboardNavigation()
|
||||
installGamepadNavigation()
|
||||
installReloadShortcut()
|
||||
installErrorCapture()
|
||||
|
||||
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||
for (const registration of registrations) {
|
||||
registration.unregister()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
createApp(App).use(router).mount("#app")
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
|
||||
export function registerMediaHivePwa(): void {
|
||||
const updateServiceWorker = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh: () => void updateServiceWorker(true),
|
||||
})
|
||||
}
|
||||
@@ -1,49 +1,59 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { createRouter, createWebHistory } from "vue-router"
|
||||
import { defineComponent, h } from "vue"
|
||||
|
||||
// Empty component - App.vue handles all rendering based on route meta
|
||||
const EmptyRouteComponent = defineComponent({
|
||||
render() {
|
||||
return h('div');
|
||||
}
|
||||
});
|
||||
return h("div")
|
||||
},
|
||||
})
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
history: createWebHistory(),
|
||||
scrollBehavior() {
|
||||
// Always scroll to top on navigation
|
||||
return { top: 0 };
|
||||
return { top: 0 }
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/movies',
|
||||
path: "/",
|
||||
redirect: "/movies",
|
||||
},
|
||||
{
|
||||
path: '/movies',
|
||||
name: 'movies',
|
||||
path: "/movies",
|
||||
name: "movies",
|
||||
component: EmptyRouteComponent,
|
||||
meta: { view: 'movies' },
|
||||
meta: { view: "movies" },
|
||||
},
|
||||
{
|
||||
path: '/movies/:id',
|
||||
name: 'movie-detail',
|
||||
path: "/movies/:id",
|
||||
name: "movie-detail",
|
||||
component: EmptyRouteComponent,
|
||||
meta: { view: 'movies' },
|
||||
meta: { view: "movies" },
|
||||
},
|
||||
{
|
||||
path: '/series',
|
||||
name: 'series',
|
||||
path: "/search/:term",
|
||||
name: "search",
|
||||
component: EmptyRouteComponent,
|
||||
meta: { view: 'series' },
|
||||
},
|
||||
{
|
||||
path: '/series/:id',
|
||||
name: 'series-detail',
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
component: EmptyRouteComponent,
|
||||
meta: { view: 'series' },
|
||||
},
|
||||
{
|
||||
path: "/series",
|
||||
name: "series",
|
||||
component: EmptyRouteComponent,
|
||||
meta: { view: "series" },
|
||||
},
|
||||
{
|
||||
path: "/series/:id",
|
||||
name: "series-detail",
|
||||
component: EmptyRouteComponent,
|
||||
meta: { view: "series" },
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
|
||||
export default router;
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
// Search Web Worker - runs search off the main thread
|
||||
// This file is loaded as a Web Worker, not imported as a module.
|
||||
|
||||
import type {
|
||||
MovieUi,
|
||||
SeriesUi,
|
||||
MatchedPerson,
|
||||
MatchedEpisode,
|
||||
SearchMatchInfo,
|
||||
} from "./types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SearchIndexMessage {
|
||||
type: "index"
|
||||
movies: MovieUi[]
|
||||
series: SeriesUi[]
|
||||
}
|
||||
|
||||
export interface SearchQueryMessage {
|
||||
type: "query"
|
||||
id: number
|
||||
query: string
|
||||
}
|
||||
|
||||
export type SearchWorkerMessage = SearchIndexMessage | SearchQueryMessage
|
||||
|
||||
export interface SearchResultItem {
|
||||
id: string
|
||||
title: string | null
|
||||
year?: number | null
|
||||
cover_path: string | null
|
||||
showreel_images?: string[] | null
|
||||
showreel_source_sets?: string[][] | null
|
||||
type: "movies" | "series"
|
||||
resolution?: string | null
|
||||
root_id: string | null
|
||||
searchMatchInfo?: SearchMatchInfo
|
||||
}
|
||||
|
||||
export interface SearchCategoryResult {
|
||||
name: string
|
||||
items: SearchResultItem[]
|
||||
}
|
||||
|
||||
export interface SearchResponseMessage {
|
||||
id: number
|
||||
results: SearchResultItem[]
|
||||
categories: SearchCategoryResult[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let movies: MovieUi[] = []
|
||||
let series: SeriesUi[] = []
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalization helpers (mirrored from App.vue)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeSearchText(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\-]+/g, " ")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
}
|
||||
|
||||
function normalizePathSearchText(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[\\/]+/g, "/")
|
||||
.replace(/[^a-z0-9/]+/g, " ")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoring helpers (mirrored from App.vue)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getTermMatchScore(term: string, field: string): number {
|
||||
const index = field.indexOf(term)
|
||||
if (index < 0) return 0
|
||||
if (index === 0) return 100
|
||||
const charBefore = field[index - 1]
|
||||
if (/\s/.test(charBefore)) return 80
|
||||
if (index < field.length / 2) return 50
|
||||
return 30
|
||||
}
|
||||
|
||||
function getPathTermMatchScore(term: string, field: string): number {
|
||||
const index = field.indexOf(term)
|
||||
if (index < 0) return 0
|
||||
if (index === 0) return 100
|
||||
const charBefore = field[index - 1]
|
||||
if (/\s|\//.test(charBefore)) return 80
|
||||
if (index < field.length / 2) return 50
|
||||
return 30
|
||||
}
|
||||
|
||||
function getRelevanceScore(query: string, field: string): number {
|
||||
const normalizedField = normalizeSearchText(field)
|
||||
const normalizedQuery = normalizeSearchText(query)
|
||||
if (!normalizedField || !normalizedQuery) return 0
|
||||
|
||||
let bestScore = 0
|
||||
const exactIndex = normalizedField.indexOf(normalizedQuery)
|
||||
|
||||
if (exactIndex >= 0) {
|
||||
if (exactIndex === 0) {
|
||||
bestScore = 110
|
||||
} else {
|
||||
const charBefore = normalizedField[exactIndex - 1]
|
||||
if (/\s/.test(charBefore)) {
|
||||
bestScore = 95
|
||||
} else if (exactIndex < normalizedField.length / 2) {
|
||||
bestScore = 75
|
||||
} else {
|
||||
bestScore = 60
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const terms = normalizedQuery.split(" ")
|
||||
if (terms.length > 1) {
|
||||
let matchedTerms = 0
|
||||
let termScoreTotal = 0
|
||||
for (const term of terms) {
|
||||
const termScore = getTermMatchScore(term, normalizedField)
|
||||
if (termScore > 0) {
|
||||
matchedTerms += 1
|
||||
termScoreTotal += termScore
|
||||
}
|
||||
}
|
||||
if (matchedTerms > 0) {
|
||||
const coverage = matchedTerms / terms.length
|
||||
const averageScore = termScoreTotal / matchedTerms
|
||||
const combinedScore = Math.round(averageScore * (0.6 + coverage * 0.4))
|
||||
if (combinedScore > bestScore) bestScore = combinedScore
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore
|
||||
}
|
||||
|
||||
function getPathRelevanceScore(query: string, field: string): number {
|
||||
const normalizedField = normalizePathSearchText(field)
|
||||
const normalizedQuery = normalizePathSearchText(query)
|
||||
if (!normalizedField || !normalizedQuery) return 0
|
||||
|
||||
let bestScore = 0
|
||||
const exactIndex = normalizedField.indexOf(normalizedQuery)
|
||||
|
||||
if (exactIndex >= 0) {
|
||||
if (exactIndex === 0) {
|
||||
bestScore = 110
|
||||
} else {
|
||||
const charBefore = normalizedField[exactIndex - 1]
|
||||
if (/\s|\//.test(charBefore)) {
|
||||
bestScore = 95
|
||||
} else if (exactIndex < normalizedField.length / 2) {
|
||||
bestScore = 75
|
||||
} else {
|
||||
bestScore = 60
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const terms = normalizedQuery.split(" ")
|
||||
if (terms.length > 1) {
|
||||
let matchedTerms = 0
|
||||
let termScoreTotal = 0
|
||||
for (const term of terms) {
|
||||
const termScore = getPathTermMatchScore(term, normalizedField)
|
||||
if (termScore > 0) {
|
||||
matchedTerms += 1
|
||||
termScoreTotal += termScore
|
||||
}
|
||||
}
|
||||
if (matchedTerms > 0) {
|
||||
const coverage = matchedTerms / terms.length
|
||||
const averageScore = termScoreTotal / matchedTerms
|
||||
const combinedScore = Math.round(averageScore * (0.6 + coverage * 0.4))
|
||||
if (combinedScore > bestScore) bestScore = combinedScore
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore
|
||||
}
|
||||
|
||||
function getBestScore(query: string, ...fields: (string | null | undefined)[]): number {
|
||||
let bestScore = 0
|
||||
for (const field of fields) {
|
||||
if (field) {
|
||||
const score = getRelevanceScore(query, field)
|
||||
if (score > bestScore) bestScore = score
|
||||
}
|
||||
}
|
||||
return bestScore
|
||||
}
|
||||
|
||||
function getMoviePathScore(movie: MovieUi, query: string): number {
|
||||
const torrentFields: (string | null | undefined)[] = []
|
||||
for (const torrent of Object.values(movie.files || {})) {
|
||||
torrentFields.push(torrent.title, torrent.playable_file)
|
||||
}
|
||||
let bestScore = 0
|
||||
for (const field of torrentFields) {
|
||||
if (!field) continue
|
||||
const score = getPathRelevanceScore(query, field)
|
||||
if (score > bestScore) bestScore = score
|
||||
}
|
||||
return bestScore
|
||||
}
|
||||
|
||||
function getSeriesPathScore(series: SeriesUi, query: string): number {
|
||||
const torrentFields: (string | null | undefined)[] = []
|
||||
for (const season of series.seasons || []) {
|
||||
for (const episode of season.episodes || []) {
|
||||
for (const torrent of Object.values(episode.files || {})) {
|
||||
torrentFields.push(torrent.title, torrent.playable_file)
|
||||
}
|
||||
}
|
||||
}
|
||||
let bestScore = 0
|
||||
for (const field of torrentFields) {
|
||||
if (!field) continue
|
||||
const score = getPathRelevanceScore(query, field)
|
||||
if (score > bestScore) bestScore = score
|
||||
}
|
||||
return bestScore
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// People matching (mirrored from App.vue)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PersonMatch {
|
||||
name: string
|
||||
roles: string[]
|
||||
highlightRoles: boolean
|
||||
}
|
||||
|
||||
interface PersonCandidate {
|
||||
name: string
|
||||
role: string
|
||||
highlightRoles: boolean
|
||||
score: number
|
||||
matchedWordIndexes: number[]
|
||||
}
|
||||
|
||||
function getQueryWords(value: string): string[] {
|
||||
const normalized = normalizeSearchText(value)
|
||||
if (!normalized) return []
|
||||
return normalized.split(" ")
|
||||
}
|
||||
|
||||
function getMatchedWordIndexes(queryWords: string[], value: string): number[] {
|
||||
const normalized = normalizeSearchText(value)
|
||||
if (!normalized || queryWords.length === 0) return []
|
||||
const targetWords = normalized.split(" ")
|
||||
const matches: number[] = []
|
||||
|
||||
for (let i = 0; i < queryWords.length; i += 1) {
|
||||
const queryWord = queryWords[i]
|
||||
if (targetWords.some((targetWord) => targetWord.startsWith(queryWord))) {
|
||||
matches.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void {
|
||||
const existing = target.find((person) => person.name.toLowerCase() === candidate.name.toLowerCase())
|
||||
if (existing) {
|
||||
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
|
||||
if (candidate.highlightRoles) existing.highlightRoles = true
|
||||
return
|
||||
}
|
||||
|
||||
target.push({
|
||||
name: candidate.name,
|
||||
roles: [candidate.role],
|
||||
highlightRoles: candidate.highlightRoles,
|
||||
})
|
||||
}
|
||||
|
||||
function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
|
||||
const sorted = indexes
|
||||
.filter((index) => availableIndexes.has(index))
|
||||
.sort((a, b) => a - b)
|
||||
|
||||
if (sorted.length === 0) return []
|
||||
|
||||
let bestStart = 0
|
||||
let bestLength = 1
|
||||
let runStart = 0
|
||||
let runLength = 1
|
||||
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
if (sorted[i] === sorted[i - 1] + 1) {
|
||||
runLength += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (runLength > bestLength) {
|
||||
bestStart = runStart
|
||||
bestLength = runLength
|
||||
}
|
||||
|
||||
runStart = i
|
||||
runLength = 1
|
||||
}
|
||||
|
||||
if (runLength > bestLength) {
|
||||
bestStart = runStart
|
||||
bestLength = runLength
|
||||
}
|
||||
|
||||
return sorted.slice(bestStart, bestStart + bestLength)
|
||||
}
|
||||
|
||||
function nameMatchesQuery(query: string, name: string): boolean {
|
||||
const nq = normalizeSearchText(query)
|
||||
const nn = normalizeSearchText(name)
|
||||
if (!nq || !nn) return false
|
||||
// Exact substring match (e.g. "jackie chan" matches "jackie chan" and "jackie chans")
|
||||
if (nn.includes(nq)) return true
|
||||
// Each query word must appear as a prefix of a name word (word-boundary match)
|
||||
const queryWords = nq.split(" ")
|
||||
const nameWords = nn.split(" ")
|
||||
return queryWords.every((qw) => nameWords.some((nw) => nw.startsWith(qw)))
|
||||
}
|
||||
|
||||
function getNameMatchScore(query: string, name: string): number {
|
||||
if (!nameMatchesQuery(query, name)) return 0
|
||||
const nq = normalizeSearchText(query)
|
||||
const nn = normalizeSearchText(name)
|
||||
const idx = nn.indexOf(nq)
|
||||
if (idx === 0) return 110
|
||||
if (idx > 0) {
|
||||
const before = nn[idx - 1]
|
||||
if (/\s/.test(before)) return 95
|
||||
return 75
|
||||
}
|
||||
// Prefix-based match: score lower than exact substring
|
||||
return 70
|
||||
}
|
||||
|
||||
function matchesPeople(
|
||||
query: string,
|
||||
cast: { name: string; character?: string | null }[] | null | undefined,
|
||||
director?: string | null,
|
||||
creators?: string[] | null,
|
||||
): { matches: PersonMatch[]; score: number } {
|
||||
const matchedPeople: PersonMatch[] = []
|
||||
const candidates: PersonCandidate[] = []
|
||||
const queryWords = getQueryWords(query)
|
||||
|
||||
const addCandidate = (
|
||||
name: string,
|
||||
role: string,
|
||||
highlightRoles: boolean,
|
||||
score: number,
|
||||
matchedWordIndexes: number[],
|
||||
) => {
|
||||
candidates.push({ name, role, highlightRoles, score, matchedWordIndexes })
|
||||
}
|
||||
|
||||
let bestScore = 0
|
||||
|
||||
if (director) {
|
||||
const score = getNameMatchScore(query, director)
|
||||
if (score > 0) {
|
||||
addCandidate(director, "Director", false, score, getMatchedWordIndexes(queryWords, director))
|
||||
} else {
|
||||
addCandidate(director, "Director", false, 0, getMatchedWordIndexes(queryWords, director))
|
||||
}
|
||||
}
|
||||
|
||||
if (creators) {
|
||||
for (const creator of creators) {
|
||||
const score = getNameMatchScore(query, creator)
|
||||
if (score > 0) {
|
||||
addCandidate(creator, "Creator", false, score, getMatchedWordIndexes(queryWords, creator))
|
||||
} else {
|
||||
addCandidate(creator, "Creator", false, 0, getMatchedWordIndexes(queryWords, creator))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cast) {
|
||||
for (const person of cast) {
|
||||
const nameScore = getNameMatchScore(query, person.name)
|
||||
const characterScore = person.character ? getNameMatchScore(query, person.character) : 0
|
||||
const bestPersonScore = Math.max(nameScore, characterScore)
|
||||
const nameWordIndexes = getMatchedWordIndexes(queryWords, person.name)
|
||||
const characterWordIndexes = person.character
|
||||
? getMatchedWordIndexes(queryWords, person.character)
|
||||
: []
|
||||
const useCharacterWords = characterWordIndexes.length > nameWordIndexes.length
|
||||
const matchedWordIndexes = useCharacterWords ? characterWordIndexes : nameWordIndexes
|
||||
|
||||
if (bestPersonScore > 0) {
|
||||
const role = person.character || "Cast"
|
||||
const highlightRoles = characterScore > nameScore
|
||||
addCandidate(person.name, role, highlightRoles, bestPersonScore, matchedWordIndexes)
|
||||
} else {
|
||||
addCandidate(person.name, person.character || "Cast", false, 0, matchedWordIndexes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.score <= 0) continue
|
||||
mergePersonMatch(matchedPeople, candidate)
|
||||
if (candidate.score > bestScore) bestScore = candidate.score
|
||||
}
|
||||
|
||||
if (matchedPeople.length > 0) {
|
||||
return { matches: matchedPeople, score: bestScore }
|
||||
}
|
||||
|
||||
if (queryWords.some((word) => word.length < 2)) {
|
||||
return { matches: [], score: 0 }
|
||||
}
|
||||
|
||||
const explicitMultiPerson = /[,&+]/.test(query)
|
||||
const uncoveredWordIndexes = new Set(queryWords.map((_, index) => index))
|
||||
const selected: Array<{ candidate: PersonCandidate; matchedIndexes: number[] }> = []
|
||||
const usableCandidates = candidates.filter((candidate) => candidate.matchedWordIndexes.length > 0)
|
||||
let hasMultiWordChunk = false
|
||||
|
||||
while (uncoveredWordIndexes.size > 0) {
|
||||
let bestCandidate: PersonCandidate | null = null
|
||||
let bestChunk: number[] = []
|
||||
let bestCoverage = 0
|
||||
|
||||
for (const candidate of usableCandidates) {
|
||||
if (selected.some((entry) => entry.candidate === candidate)) continue
|
||||
const chunk = getBestContiguousWordRun(candidate.matchedWordIndexes, uncoveredWordIndexes)
|
||||
if (chunk.length <= 0) continue
|
||||
const coverage = candidate.matchedWordIndexes.length
|
||||
|
||||
if (
|
||||
chunk.length > bestChunk.length ||
|
||||
(chunk.length === bestChunk.length && coverage > bestCoverage)
|
||||
) {
|
||||
bestCandidate = candidate
|
||||
bestChunk = chunk
|
||||
bestCoverage = coverage
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestCandidate || bestChunk.length <= 0) break
|
||||
selected.push({ candidate: bestCandidate, matchedIndexes: bestChunk })
|
||||
if (bestChunk.length > 1) hasMultiWordChunk = true
|
||||
for (const index of bestChunk) {
|
||||
uncoveredWordIndexes.delete(index)
|
||||
}
|
||||
}
|
||||
|
||||
if (uncoveredWordIndexes.size > 0 || selected.length === 0) {
|
||||
return { matches: [], score: 0 }
|
||||
}
|
||||
|
||||
// Without explicit separators, require at least one multi-word person chunk.
|
||||
// This avoids accidental matches like "jackie chan" => "Jackie" + "Chan".
|
||||
if (!explicitMultiPerson && selected.length > 1 && !hasMultiWordChunk) {
|
||||
return { matches: [], score: 0 }
|
||||
}
|
||||
|
||||
let multiPersonScore = 0
|
||||
for (const entry of selected) {
|
||||
mergePersonMatch(matchedPeople, entry.candidate)
|
||||
const candidateScore = entry.candidate.score > 0 ? entry.candidate.score : 65
|
||||
if (candidateScore > multiPersonScore) multiPersonScore = candidateScore
|
||||
}
|
||||
|
||||
return { matches: matchedPeople, score: multiPersonScore }
|
||||
}
|
||||
|
||||
function formatMatchedPeople(people: PersonMatch[]): MatchedPerson[] {
|
||||
return people.map((p) => ({
|
||||
name: p.name,
|
||||
roles: p.roles.join(", "),
|
||||
highlightRoles: p.highlightRoles,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MediaItem conversion (lightweight, without data payload)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function movieToSearchResult(movie: MovieUi): SearchResultItem {
|
||||
const files = Object.values(movie.files || {})
|
||||
const resolution = files.length > 0 ? files[0].resolution : null
|
||||
return {
|
||||
id: movie.id,
|
||||
title: movie.title || "Unknown",
|
||||
year: movie.year,
|
||||
cover_path: movie.cover_path,
|
||||
showreel_images: movie.showreel_images,
|
||||
showreel_source_sets: movie.showreel_source_sets,
|
||||
type: "movies",
|
||||
resolution,
|
||||
root_id: movie.root_id,
|
||||
}
|
||||
}
|
||||
|
||||
function seriesToSearchResult(series: SeriesUi): SearchResultItem {
|
||||
const reelImages: string[] = []
|
||||
const reelSourceSets: string[][] = []
|
||||
for (const season of series.seasons || []) {
|
||||
for (const episode of season.episodes || []) {
|
||||
if (episode.reel_sources && episode.reel_sources.length > 0) {
|
||||
reelImages.push(episode.reel_sources[0])
|
||||
reelSourceSets.push(episode.reel_sources)
|
||||
} else if (episode.reel_image) {
|
||||
reelImages.push(episode.reel_image)
|
||||
reelSourceSets.push([episode.reel_image])
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: series.id,
|
||||
title: series.title || "Unknown",
|
||||
year: null,
|
||||
cover_path: series.cover_path,
|
||||
showreel_images: reelImages.length > 0 ? reelImages : null,
|
||||
showreel_source_sets: reelSourceSets.length > 0 ? reelSourceSets : null,
|
||||
type: "series",
|
||||
root_id: series.root_id,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core search with cancellation token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_RESULTS = 100
|
||||
|
||||
interface ScoredResult {
|
||||
item: SearchResultItem
|
||||
score: number
|
||||
matchType: "movies" | "series" | "people" | "other"
|
||||
}
|
||||
|
||||
interface CancelToken {
|
||||
id: number
|
||||
}
|
||||
|
||||
let currentSearchId = 0
|
||||
|
||||
function isCancelled(token: CancelToken): boolean {
|
||||
return token.id !== currentSearchId
|
||||
}
|
||||
|
||||
/** Yield control briefly so the worker can receive a new message. */
|
||||
function yieldControl(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
async function performSearch(
|
||||
query: string,
|
||||
token: CancelToken,
|
||||
): Promise<SearchResponseMessage | null> {
|
||||
const allScored: ScoredResult[] = []
|
||||
const processedIds = new Set<string>()
|
||||
|
||||
const yearMatch = query.match(/^(\d{4})$/)
|
||||
const searchYear = yearMatch ? parseInt(yearMatch[1], 10) : null
|
||||
const isYearQuery = searchYear !== null && searchYear >= 1900 && searchYear <= 2100
|
||||
const yearBonus = 25
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Search movies
|
||||
// -------------------------------------------------------------------------
|
||||
for (let i = 0; i < movies.length; i++) {
|
||||
if (i % 50 === 0) {
|
||||
if (isCancelled(token)) return null
|
||||
await yieldControl()
|
||||
}
|
||||
|
||||
const movie = movies[i]
|
||||
const titleScore = getBestScore(query, movie.title, movie.info?.original_title)
|
||||
const yearScore = isYearQuery && movie.year === searchYear ? yearBonus : 0
|
||||
if (titleScore > 0 || yearScore > 0) {
|
||||
allScored.push({
|
||||
item: movieToSearchResult(movie),
|
||||
score: titleScore + yearScore + (movie.info?.rating ?? 0) / 10,
|
||||
matchType: "movies",
|
||||
})
|
||||
processedIds.add(movie.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const peopleMatch = matchesPeople(query, movie.info?.cast, movie.info?.director)
|
||||
if (peopleMatch.matches.length > 0) {
|
||||
const item = movieToSearchResult(movie)
|
||||
item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) }
|
||||
allScored.push({
|
||||
item,
|
||||
score: peopleMatch.score + (movie.info?.rating ?? 0) / 10,
|
||||
matchType: "people",
|
||||
})
|
||||
processedIds.add(movie.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const otherScore = Math.max(
|
||||
getBestScore(
|
||||
query,
|
||||
movie.info?.genres?.join(" "),
|
||||
movie.info?.keywords?.join(" "),
|
||||
movie.info?.overview,
|
||||
movie.info?.tagline,
|
||||
movie.info?.collection,
|
||||
),
|
||||
getMoviePathScore(movie, query),
|
||||
)
|
||||
if (otherScore > 0) {
|
||||
allScored.push({
|
||||
item: movieToSearchResult(movie),
|
||||
score: otherScore + (movie.info?.rating ?? 0) / 10,
|
||||
matchType: "other",
|
||||
})
|
||||
processedIds.add(movie.id)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Search series
|
||||
// -------------------------------------------------------------------------
|
||||
for (let i = 0; i < series.length; i++) {
|
||||
if (i % 50 === 0) {
|
||||
if (isCancelled(token)) return null
|
||||
await yieldControl()
|
||||
}
|
||||
|
||||
const seriesItem = series[i]
|
||||
const titleScore = getBestScore(query, seriesItem.title, seriesItem.info?.original_title)
|
||||
const seriesYear = seriesItem.info?.release_date
|
||||
? parseInt(seriesItem.info.release_date.substring(0, 4), 10)
|
||||
: null
|
||||
const yearScore = isYearQuery && seriesYear === searchYear ? yearBonus : 0
|
||||
if (titleScore > 0 || yearScore > 0) {
|
||||
allScored.push({
|
||||
item: seriesToSearchResult(seriesItem),
|
||||
score: titleScore + yearScore + (seriesItem.info?.rating ?? 0) / 10,
|
||||
matchType: "series",
|
||||
})
|
||||
processedIds.add(seriesItem.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const matchedEpisodes: MatchedEpisode[] = []
|
||||
let episodeScore = 0
|
||||
const isEndedSingleSeason =
|
||||
(seriesItem.info?.number_of_seasons === 1 || seriesItem.seasons?.length === 1) &&
|
||||
["Ended", "Canceled", "Cancelled"].includes(seriesItem.info?.status || "")
|
||||
|
||||
for (const season of seriesItem.seasons || []) {
|
||||
for (const episode of season.episodes || []) {
|
||||
if (episode.name) {
|
||||
const epScore = getRelevanceScore(query, episode.name)
|
||||
if (epScore > 0) {
|
||||
const hideSeason = isEndedSingleSeason || season.season_number === 0
|
||||
const location = hideSeason
|
||||
? `Episode ${episode.episode_number}`
|
||||
: `S${season.season_number} Episode ${episode.episode_number}`
|
||||
matchedEpisodes.push({
|
||||
name: episode.name,
|
||||
location,
|
||||
seasonNumber: season.season_number,
|
||||
episodeNumber: episode.episode_number,
|
||||
})
|
||||
if (epScore > episodeScore) episodeScore = epScore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matchedEpisodes.length > 0 && !processedIds.has(seriesItem.id)) {
|
||||
const item = seriesToSearchResult(seriesItem)
|
||||
item.searchMatchInfo = { matchedEpisodes }
|
||||
allScored.push({
|
||||
item,
|
||||
score: episodeScore + (seriesItem.info?.rating ?? 0) / 10,
|
||||
matchType: "series",
|
||||
})
|
||||
processedIds.add(seriesItem.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const peopleMatch = matchesPeople(query, seriesItem.info?.cast, null, seriesItem.info?.creators)
|
||||
if (peopleMatch.matches.length > 0 && !processedIds.has(seriesItem.id)) {
|
||||
const item = seriesToSearchResult(seriesItem)
|
||||
item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) }
|
||||
allScored.push({
|
||||
item,
|
||||
score: peopleMatch.score + (seriesItem.info?.rating ?? 0) / 10,
|
||||
matchType: "people",
|
||||
})
|
||||
processedIds.add(seriesItem.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!processedIds.has(seriesItem.id)) {
|
||||
const otherScore = Math.max(
|
||||
getBestScore(
|
||||
query,
|
||||
seriesItem.info?.genres?.join(" "),
|
||||
seriesItem.info?.keywords?.join(" "),
|
||||
seriesItem.info?.overview,
|
||||
seriesItem.info?.tagline,
|
||||
seriesItem.info?.networks?.join(" "),
|
||||
),
|
||||
getSeriesPathScore(seriesItem, query),
|
||||
)
|
||||
if (otherScore > 0) {
|
||||
allScored.push({
|
||||
item: seriesToSearchResult(seriesItem),
|
||||
score: otherScore + (seriesItem.info?.rating ?? 0) / 10,
|
||||
matchType: "other",
|
||||
})
|
||||
processedIds.add(seriesItem.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isCancelled(token)) return null
|
||||
|
||||
allScored.sort((a, b) => b.score - a.score)
|
||||
const topResults = allScored.slice(0, MAX_RESULTS)
|
||||
|
||||
const moviesCat: SearchResultItem[] = []
|
||||
const seriesCat: SearchResultItem[] = []
|
||||
const peopleCat: SearchResultItem[] = []
|
||||
const otherCat: SearchResultItem[] = []
|
||||
|
||||
for (const scored of topResults) {
|
||||
switch (scored.matchType) {
|
||||
case "movies":
|
||||
moviesCat.push(scored.item)
|
||||
break
|
||||
case "series":
|
||||
seriesCat.push(scored.item)
|
||||
break
|
||||
case "people":
|
||||
peopleCat.push(scored.item)
|
||||
break
|
||||
case "other":
|
||||
otherCat.push(scored.item)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const categories: SearchCategoryResult[] = []
|
||||
if (moviesCat.length > 0) categories.push({ name: "Movies", items: moviesCat })
|
||||
if (seriesCat.length > 0) categories.push({ name: "Series", items: seriesCat })
|
||||
if (peopleCat.length > 0) categories.push({ name: "People", items: peopleCat })
|
||||
if (otherCat.length > 0) categories.push({ name: "Other", items: otherCat })
|
||||
|
||||
return {
|
||||
id: token.id,
|
||||
results: topResults.map((s) => s.item),
|
||||
categories,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker message handler — single persistent runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
self.onmessage = (event: MessageEvent<SearchWorkerMessage>) => {
|
||||
const msg = event.data
|
||||
|
||||
if (msg.type === "index") {
|
||||
movies = msg.movies
|
||||
series = msg.series
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === "query") {
|
||||
const { id, query } = msg
|
||||
currentSearchId = id
|
||||
const token: CancelToken = { id }
|
||||
|
||||
void (async () => {
|
||||
const response = await performSearch(query, token)
|
||||
if (!response) {
|
||||
return
|
||||
}
|
||||
self.postMessage(response)
|
||||
})()
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,15 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||
html,
|
||||
body {
|
||||
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
@@ -40,9 +43,33 @@ html, body {
|
||||
-ms-hyphens: auto;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
html:not(.pointer-visible),
|
||||
html:not(.pointer-visible) * {
|
||||
cursor: none !important;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollbar-hidden {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.scrollbar-hidden::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@@ -60,7 +87,7 @@ html, body {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
html.mouse-active ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -75,9 +102,28 @@ html, body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
isolation: isolate;
|
||||
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.header::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
border-radius: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.header > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
top: 40px;
|
||||
}
|
||||
@@ -88,8 +134,8 @@ html, body {
|
||||
}
|
||||
|
||||
.header-after-movie-header {
|
||||
/* Position after movie detail collage-header (300px) */
|
||||
top: 320px;
|
||||
/* Place header below movie detail collage-header (300px). */
|
||||
top: 300px;
|
||||
}
|
||||
|
||||
.header-after-series-hero {
|
||||
@@ -97,6 +143,22 @@ html, body {
|
||||
top: clamp(450px, 70vh, 600px);
|
||||
}
|
||||
|
||||
.header-after-series-hero::before {
|
||||
width: 30em;
|
||||
max-width: calc(100vw - 24px);
|
||||
opacity: 1;
|
||||
background: linear-gradient(to bottom, rgba(5, 7, 10, 0.72) 0%, rgba(5, 7, 10, 0.5) 100%);
|
||||
backdrop-filter: blur(10px) saturate(115%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(115%);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.header-after-hero {
|
||||
/* Match the reduced browse hero height at <=900px. */
|
||||
top: clamp(350px, 50vh, 600px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Spacer to reserve space for header in layout */
|
||||
.header-spacer {
|
||||
height: calc(var(--header-height) + 20px);
|
||||
@@ -108,6 +170,11 @@ html, body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-logo-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
@@ -133,13 +200,18 @@ html, body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header-nav-item:hover,
|
||||
.header-after-movie-header .header-nav-item,
|
||||
.header-after-series-hero .header-nav-item {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
html.mouse-active .header-nav-item:hover,
|
||||
.header-nav-item.active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-nav-item:focus,
|
||||
.header-nav-item.nav-focused {
|
||||
html:not(.mouse-active) .header-nav-item.nav-focused {
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
text-decoration: underline;
|
||||
@@ -170,7 +242,7 @@ html, body {
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.header-settings-btn:hover {
|
||||
html.mouse-active .header-settings-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -205,84 +277,10 @@ html, body {
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
/* Hero section - kept for backwards compatibility but not used */
|
||||
.hero {
|
||||
position: relative;
|
||||
height: 70vh;
|
||||
max-height: 600px;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 0 4% 8%;
|
||||
background-color: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background-size: contain;
|
||||
background-position: right top;
|
||||
background-repeat: no-repeat;
|
||||
mask-image: linear-gradient(to left, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.3) 60%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to left, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.3) 60%, transparent 100%);
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 200px;
|
||||
background: var(--gradient-fade);
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
text-shadow: 2px 2px 4px var(--shadow-color);
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hero-year {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hero-quality {
|
||||
background: var(--bg-secondary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* Blinking animation for button focus */
|
||||
@keyframes btn-outline-blink {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
50% {
|
||||
@@ -318,8 +316,8 @@ html, body {
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary.nav-focused {
|
||||
html.mouse-active .btn-primary:hover,
|
||||
html:not(.mouse-active) .btn-primary.nav-focused {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
animation: btn-outline-blink 1s ease-in-out infinite;
|
||||
}
|
||||
@@ -329,8 +327,8 @@ html, body {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover,
|
||||
.btn-secondary.nav-focused {
|
||||
html.mouse-active .btn-secondary:hover,
|
||||
html:not(.mouse-active) .btn-secondary.nav-focused {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
animation: btn-outline-blink 1s ease-in-out infinite;
|
||||
}
|
||||
@@ -346,11 +344,15 @@ html, body {
|
||||
}
|
||||
|
||||
.view-zoom-enter-active {
|
||||
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
|
||||
transition:
|
||||
opacity 0.4s ease-out,
|
||||
transform 0.4s ease-out;
|
||||
}
|
||||
|
||||
.view-zoom-leave-active {
|
||||
transition: opacity 0.3s ease-in, transform 0.3s ease-in;
|
||||
transition:
|
||||
opacity 0.3s ease-in,
|
||||
transform 0.3s ease-in;
|
||||
}
|
||||
|
||||
.view-zoom-enter-from {
|
||||
@@ -365,7 +367,7 @@ html, body {
|
||||
|
||||
/* Media rows */
|
||||
.media-section {
|
||||
padding: 0 var(--section-padding);
|
||||
padding: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@@ -373,18 +375,22 @@ html, body {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
padding: 0 calc(2px + var(--section-padding));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.media-row {
|
||||
--sync-row-tail: 0px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
padding-bottom: 8px;
|
||||
margin: 0 -2px;
|
||||
padding: 4px 2px 8px;
|
||||
/* Tail padding is added only on the right so that increasing it does not
|
||||
shift items to the right (which would break safe-zone calculations). */
|
||||
padding: 4px calc(2px + var(--section-padding) + var(--sync-row-tail)) 8px
|
||||
calc(2px + var(--section-padding));
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
@@ -407,13 +413,21 @@ html, body {
|
||||
flex-shrink: 0;
|
||||
width: var(--card-width);
|
||||
cursor: pointer;
|
||||
transition: z-index 0s, box-shadow var(--transition-medium);
|
||||
transition:
|
||||
z-index 0s,
|
||||
box-shadow var(--transition-medium);
|
||||
position: relative;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
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;
|
||||
}
|
||||
|
||||
.media-card:hover,
|
||||
.media-card.nav-focused {
|
||||
html.mouse-active .media-card:hover,
|
||||
html:not(.mouse-active) .media-card.nav-focused {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@@ -435,7 +449,7 @@ html, body {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.media-card:hover .media-card-poster {
|
||||
html.mouse-active .media-card:hover .media-card-poster {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
@@ -481,7 +495,7 @@ html, body {
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.media-card:hover .media-card-info {
|
||||
html.mouse-active .media-card:hover .media-card-info {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -526,6 +540,14 @@ html, body {
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.modal-overlay::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@@ -557,7 +579,7 @@ html, body {
|
||||
}
|
||||
|
||||
.modal-header::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
@@ -585,7 +607,7 @@ html, body {
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
html.mouse-active .modal-close:hover {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
@@ -681,7 +703,7 @@ html, body {
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.release-item:hover {
|
||||
html.mouse-active .release-item:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
@@ -788,23 +810,6 @@ html, body {
|
||||
--section-padding: 3%;
|
||||
}
|
||||
|
||||
.hero {
|
||||
height: 50vh;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
@@ -817,13 +822,6 @@ html, body {
|
||||
@media (max-width: 480px) {
|
||||
:root {
|
||||
--card-width: 100px;
|
||||
--header-height: 48px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.media-card-info {
|
||||
|
||||
@@ -1,189 +1,253 @@
|
||||
// Type definitions for the media browser
|
||||
|
||||
export type CastGender = "female" | "male" | "non_binary" | "unknown"
|
||||
|
||||
export interface CastMember {
|
||||
name: string;
|
||||
character?: string | null;
|
||||
profile_path: string | null;
|
||||
name: string
|
||||
character?: string | null
|
||||
profile_path: string | null
|
||||
gender?: CastGender | null
|
||||
id?: number | null
|
||||
}
|
||||
|
||||
export interface SimilarMedia {
|
||||
id: number;
|
||||
title: string;
|
||||
poster_path: string | null;
|
||||
export type CastCreditWire = [character: string | null, id: number | null]
|
||||
export type PersonWire = [name: string, profile_path: string | null, gender: CastGender | null]
|
||||
|
||||
export interface Person {
|
||||
name: string
|
||||
profile_path: string | null
|
||||
gender?: CastGender | null
|
||||
}
|
||||
|
||||
export interface Info {
|
||||
tmdb_id: number;
|
||||
title: string | null;
|
||||
original_title: string | null;
|
||||
alternative_titles: string[] | null;
|
||||
rating: number | null;
|
||||
vote_count: number | null;
|
||||
overview: string | null;
|
||||
genres: string[] | null;
|
||||
release_date: string | null;
|
||||
runtime: number | null;
|
||||
status: string | null;
|
||||
tagline: string | null;
|
||||
poster_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
similar: SimilarMedia[] | null;
|
||||
keywords: string[] | null;
|
||||
cast: CastMember[] | null;
|
||||
director: string | null;
|
||||
creators: string[] | null;
|
||||
number_of_seasons: number | null;
|
||||
number_of_episodes: number | null;
|
||||
networks: string[] | null;
|
||||
tmdb_id: number
|
||||
title: string | null
|
||||
original_title: string | null
|
||||
original_language: string | null
|
||||
alternative_titles: string[] | null
|
||||
rating: number | null
|
||||
vote_count: number | null
|
||||
overview: string | null
|
||||
genres: string[] | null
|
||||
release_date: string | null
|
||||
runtime: number | null
|
||||
collection: string | null
|
||||
status: string | null
|
||||
tagline: string | null
|
||||
keywords: string[] | null
|
||||
cast: CastMember[] | null
|
||||
director: string | null
|
||||
creators: string[] | null
|
||||
number_of_seasons: number | null
|
||||
number_of_episodes: number | null
|
||||
networks: string[] | null
|
||||
}
|
||||
|
||||
export interface Torrent {
|
||||
title: string | null;
|
||||
playable_file: string | null;
|
||||
resolution: string | null;
|
||||
quality: string | null;
|
||||
codec: string | null;
|
||||
audio: string | null;
|
||||
encoder: string | null;
|
||||
size: number | null;
|
||||
added_at: number | null;
|
||||
title: string | null
|
||||
playable_file: string | null
|
||||
resolution: string | null
|
||||
quality: string | null
|
||||
network: string | null
|
||||
codec: string | null
|
||||
audio: string | null
|
||||
audio_languages: string[] | null
|
||||
subtitle_languages: string[] | null
|
||||
external_subtitle_languages?: string[] | null
|
||||
hdr?: boolean
|
||||
dovi?: boolean
|
||||
atmos?: boolean
|
||||
hdr10plus?: boolean
|
||||
encoder: string | null
|
||||
size: number | null
|
||||
added_at: number | null
|
||||
root_id?: string | null
|
||||
}
|
||||
|
||||
export interface Movie {
|
||||
id: string;
|
||||
title: string | null;
|
||||
info: Info | null;
|
||||
year: number | null;
|
||||
newest: number | null;
|
||||
cover_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
showreel_images: string[] | null;
|
||||
torrents: { [key: string]: Torrent };
|
||||
title: string | null
|
||||
info: Info | null
|
||||
year: number | null
|
||||
newest: number | null
|
||||
cover_path: string | null
|
||||
backdrop_path: string | null
|
||||
showreel_images: string[] | null
|
||||
showreel_source_sets: string[][] | null
|
||||
files: { [key: string]: Torrent }
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
episode_number: number;
|
||||
name: string | null;
|
||||
overview: string | null;
|
||||
air_date: string | null;
|
||||
runtime: number | null;
|
||||
still_path: string | null;
|
||||
rating: number | null;
|
||||
director: string | null;
|
||||
reel_image: string | null;
|
||||
torrents: { [key: string]: Torrent };
|
||||
episode_number: number
|
||||
name: string | null
|
||||
overview: string | null
|
||||
air_date: string | null
|
||||
runtime: number | null
|
||||
still_path: string | null
|
||||
rating: number | null
|
||||
director: string | null
|
||||
reel_image: string | null
|
||||
reel_sources: string[] | null
|
||||
files: { [key: string]: Torrent }
|
||||
}
|
||||
|
||||
export interface Season {
|
||||
season_number: number;
|
||||
name: string | null;
|
||||
overview: string | null;
|
||||
air_date: string | null;
|
||||
poster_path: string | null;
|
||||
episode_count: number | null;
|
||||
episodes: Episode[];
|
||||
season_number: number
|
||||
name: string | null
|
||||
overview: string | null
|
||||
air_date: string | null
|
||||
poster_path: string | null
|
||||
episode_count: number | null
|
||||
episodes: Episode[]
|
||||
}
|
||||
|
||||
export interface Series {
|
||||
id: string;
|
||||
title: string | null;
|
||||
info: Info | null;
|
||||
alternative_titles: string[] | null;
|
||||
newest: number | null;
|
||||
cover_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
seasons: Season[];
|
||||
title: string | null
|
||||
info: Info | null
|
||||
alternative_titles: string[] | null
|
||||
newest: number | null
|
||||
cover_path: string | null
|
||||
backdrop_path: string | null
|
||||
seasons: Season[]
|
||||
}
|
||||
|
||||
/** A series' single continue point (last watched position). */
|
||||
export interface SeriesResumePoint {
|
||||
seasonNumber: number
|
||||
episodeNumber: number
|
||||
positionSeconds: number
|
||||
}
|
||||
|
||||
export interface MovieUi extends Movie {
|
||||
id: string
|
||||
root_id: string | null
|
||||
}
|
||||
|
||||
export interface SeriesUi extends Series {
|
||||
id: string
|
||||
root_id: string | null
|
||||
}
|
||||
|
||||
export interface MediaStats {
|
||||
total_movies: number;
|
||||
total_movie_versions?: number;
|
||||
total_series: number;
|
||||
total_series_episodes?: number;
|
||||
total_movies: number
|
||||
total_movie_versions?: number
|
||||
total_series: number
|
||||
total_series_episodes?: number
|
||||
}
|
||||
|
||||
export interface MediaIndex {
|
||||
version: number;
|
||||
generated_at: string;
|
||||
stats: MediaStats;
|
||||
movies: Movie[];
|
||||
series: Series[];
|
||||
v: number
|
||||
generated_at: string
|
||||
movies: MovieUi[]
|
||||
series: SeriesUi[]
|
||||
}
|
||||
|
||||
export type MediaType = 'movies' | 'series' | 'episode';
|
||||
export type MediaType = "movies" | "series" | "episode"
|
||||
|
||||
// Matched person info for search results
|
||||
export interface MatchedPerson {
|
||||
name: string;
|
||||
roles: string; // e.g., "Director", "Tony Stark", "Creator"
|
||||
highlightRoles: boolean; // true if the roles/character matched (vs the name)
|
||||
name: string
|
||||
roles: string // e.g., "Director", "Tony Stark", "Creator"
|
||||
highlightRoles: boolean // true if the roles/character matched (vs the name)
|
||||
}
|
||||
|
||||
// Matched episode info for search results
|
||||
export interface MatchedEpisode {
|
||||
name: string; // Episode name (highlighted)
|
||||
location: string; // "SN Episode M" (dimmed)
|
||||
seasonNumber: number; // For navigation to episode
|
||||
episodeNumber: number; // For navigation to episode
|
||||
name: string // Episode name (highlighted)
|
||||
location: string // "SN Episode M" (dimmed)
|
||||
seasonNumber: number // For navigation to episode
|
||||
episodeNumber: number // For navigation to episode
|
||||
}
|
||||
|
||||
// Info about why a search matched this item
|
||||
export interface SearchMatchInfo {
|
||||
// Matched people with their roles/characters
|
||||
matchedPeople?: MatchedPerson[];
|
||||
matchedPeople?: MatchedPerson[]
|
||||
// Matched episodes for series
|
||||
matchedEpisodes?: MatchedEpisode[];
|
||||
matchedEpisodes?: MatchedEpisode[]
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
title: string | null;
|
||||
year?: number | null;
|
||||
cover_path: string | null;
|
||||
showreel_images?: string[] | null;
|
||||
type: MediaType;
|
||||
resolution?: string | null;
|
||||
data: Movie | Series | EpisodeWithSeries;
|
||||
id: string
|
||||
title: string | null
|
||||
year?: number | null
|
||||
cover_path: string | null
|
||||
showreel_images?: string[] | null
|
||||
showreel_source_sets?: string[][] | null
|
||||
type: MediaType
|
||||
resolution?: string | null
|
||||
data: Movie | Series | EpisodeWithSeries
|
||||
root_id: string | null
|
||||
// Optional search match info - only present in search results
|
||||
searchMatchInfo?: SearchMatchInfo;
|
||||
searchMatchInfo?: SearchMatchInfo
|
||||
}
|
||||
|
||||
// Episode with parent series info for standalone display
|
||||
export interface EpisodeWithSeries {
|
||||
episode: Episode;
|
||||
series: Series;
|
||||
seasonNumber: number;
|
||||
episode: Episode
|
||||
series: SeriesUi
|
||||
seasonNumber: number
|
||||
}
|
||||
|
||||
// Task progress info from background scanning
|
||||
export interface TaskInfo {
|
||||
id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
detail: string;
|
||||
id: string
|
||||
status: string
|
||||
progress: number
|
||||
detail: string
|
||||
}
|
||||
|
||||
// WebSocket message types (matching server msgspec tagged structs)
|
||||
export interface WsRootStatus {
|
||||
root_id: string
|
||||
path: string
|
||||
status: string
|
||||
error: string | null
|
||||
snapshot_loaded: boolean
|
||||
movies: number
|
||||
series: number
|
||||
}
|
||||
|
||||
export interface WsRootInitData {
|
||||
movies: Record<string, Movie>
|
||||
series: Record<string, Series>
|
||||
people?: Record<string, PersonWire>
|
||||
}
|
||||
|
||||
export interface WsRootsMessage {
|
||||
type: "roots"
|
||||
roots: WsRootStatus[]
|
||||
}
|
||||
|
||||
export interface WsInitMessage {
|
||||
type: 'init';
|
||||
data: { movies: Movie[]; series: Series[] };
|
||||
type: "init"
|
||||
roots: Record<string, WsRootInitData>
|
||||
}
|
||||
|
||||
export interface WsUpsertMessage {
|
||||
type: 'upsert';
|
||||
kind: 'movie' | 'series';
|
||||
item: Movie | Series;
|
||||
type: "upsert"
|
||||
root_id: string
|
||||
kind: "movie" | "series"
|
||||
id: string
|
||||
item: Movie | Series
|
||||
people?: Record<string, PersonWire>
|
||||
}
|
||||
|
||||
export interface WsRemoveMessage {
|
||||
type: 'remove';
|
||||
kind: 'movie' | 'series';
|
||||
id: string;
|
||||
type: "remove"
|
||||
root_id: string
|
||||
kind: "movie" | "series"
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface WsTaskMessage {
|
||||
type: 'task';
|
||||
data: TaskInfo;
|
||||
type: "task"
|
||||
root_id: string
|
||||
data: TaskInfo
|
||||
}
|
||||
|
||||
export type WsMessage = WsInitMessage | WsUpsertMessage | WsRemoveMessage | WsTaskMessage;
|
||||
export type WsMessage =
|
||||
| WsRootsMessage
|
||||
| WsInitMessage
|
||||
| WsUpsertMessage
|
||||
| WsRemoveMessage
|
||||
| WsTaskMessage
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
import * as flagSvgs from "country-flag-icons/string/3x2"
|
||||
|
||||
export interface LanguageFlagEntry {
|
||||
countryCode: string
|
||||
svg: string
|
||||
sourceCodes: string[]
|
||||
}
|
||||
|
||||
const FLAGS = flagSvgs as Record<string, string>
|
||||
|
||||
const LANGUAGE_TO_COUNTRY: Record<string, string> = {
|
||||
// English
|
||||
en: "GB",
|
||||
eng: "GB",
|
||||
|
||||
// Spanish (including LATAM variants collapsed to Spain flag)
|
||||
es: "ES",
|
||||
spa: "ES",
|
||||
esp: "ES",
|
||||
esl: "ES",
|
||||
spl: "ES",
|
||||
"es-es": "ES",
|
||||
"es-419": "ES",
|
||||
"spa-la": "ES",
|
||||
|
||||
// Portuguese (Brazilian variant collapses to Portugal flag)
|
||||
pt: "PT",
|
||||
por: "PT",
|
||||
"pt-pt": "PT",
|
||||
"pt-br": "PT",
|
||||
|
||||
// Major European languages
|
||||
fr: "FR",
|
||||
fra: "FR",
|
||||
fre: "FR",
|
||||
de: "DE",
|
||||
deu: "DE",
|
||||
ger: "DE",
|
||||
it: "IT",
|
||||
ita: "IT",
|
||||
nl: "NL",
|
||||
nld: "NL",
|
||||
dut: "NL",
|
||||
sv: "SE",
|
||||
swe: "SE",
|
||||
no: "NO",
|
||||
nor: "NO",
|
||||
da: "DK",
|
||||
dan: "DK",
|
||||
fi: "FI",
|
||||
fin: "FI",
|
||||
pl: "PL",
|
||||
pol: "PL",
|
||||
cs: "CZ",
|
||||
ces: "CZ",
|
||||
cze: "CZ",
|
||||
hu: "HU",
|
||||
hun: "HU",
|
||||
ro: "RO",
|
||||
ron: "RO",
|
||||
rum: "RO",
|
||||
el: "GR",
|
||||
gre: "GR",
|
||||
ell: "GR",
|
||||
tr: "TR",
|
||||
tur: "TR",
|
||||
|
||||
// Slavic / Eurasian
|
||||
ru: "RU",
|
||||
rus: "RU",
|
||||
uk: "UA",
|
||||
ukr: "UA",
|
||||
bg: "BG",
|
||||
bul: "BG",
|
||||
sr: "RS",
|
||||
srp: "RS",
|
||||
hr: "HR",
|
||||
hrv: "HR",
|
||||
sl: "SI",
|
||||
slv: "SI",
|
||||
sk: "SK",
|
||||
slk: "SK",
|
||||
slo: "SK",
|
||||
|
||||
// East / South / SE Asia
|
||||
ja: "JP",
|
||||
jpn: "JP",
|
||||
ko: "KR",
|
||||
kor: "KR",
|
||||
zh: "CN",
|
||||
zho: "CN",
|
||||
chi: "CN",
|
||||
yue: "HK",
|
||||
th: "TH",
|
||||
tha: "TH",
|
||||
vi: "VN",
|
||||
vie: "VN",
|
||||
id: "ID",
|
||||
ind: "ID",
|
||||
ms: "MY",
|
||||
msa: "MY",
|
||||
may: "MY",
|
||||
hi: "IN",
|
||||
hin: "IN",
|
||||
|
||||
// Middle East / Africa
|
||||
ar: "SA",
|
||||
ara: "SA",
|
||||
he: "IL",
|
||||
heb: "IL",
|
||||
fa: "IR",
|
||||
fas: "IR",
|
||||
per: "IR",
|
||||
ur: "PK",
|
||||
urd: "PK",
|
||||
sw: "TZ",
|
||||
swa: "TZ",
|
||||
|
||||
// Other common
|
||||
ca: "ES",
|
||||
cat: "ES",
|
||||
eu: "ES",
|
||||
baq: "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 {
|
||||
return code.trim().toLowerCase().replace("_", "-")
|
||||
}
|
||||
|
||||
function normalizeLanguageName(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ")
|
||||
}
|
||||
|
||||
let _browserLanguagePreferences: string[] | null = null
|
||||
let _browserPreferenceRanks: Map<string, number> | null = null
|
||||
|
||||
const LANGUAGE_NAME_TO_CODE: Record<string, string> = {
|
||||
english: "en",
|
||||
spanish: "es",
|
||||
portuguese: "pt",
|
||||
french: "fr",
|
||||
german: "de",
|
||||
italian: "it",
|
||||
dutch: "nl",
|
||||
swedish: "sv",
|
||||
norwegian: "no",
|
||||
danish: "da",
|
||||
finnish: "fi",
|
||||
polish: "pl",
|
||||
czech: "cs",
|
||||
hungarian: "hu",
|
||||
romanian: "ro",
|
||||
greek: "el",
|
||||
turkish: "tr",
|
||||
russian: "ru",
|
||||
ukrainian: "uk",
|
||||
bulgarian: "bg",
|
||||
serbian: "sr",
|
||||
croatian: "hr",
|
||||
slovenian: "sl",
|
||||
slovak: "sk",
|
||||
japanese: "ja",
|
||||
korean: "ko",
|
||||
chinese: "zh",
|
||||
cantonese: "yue",
|
||||
thai: "th",
|
||||
vietnamese: "vi",
|
||||
indonesian: "id",
|
||||
malay: "ms",
|
||||
hindi: "hi",
|
||||
arabic: "ar",
|
||||
hebrew: "he",
|
||||
persian: "fa",
|
||||
urdu: "ur",
|
||||
swahili: "sw",
|
||||
catalan: "ca",
|
||||
basque: "eu",
|
||||
}
|
||||
|
||||
function getBrowserLanguagePreferences(): string[] {
|
||||
if (_browserLanguagePreferences) return _browserLanguagePreferences
|
||||
|
||||
const preferences: string[] = []
|
||||
if (typeof navigator !== "undefined") {
|
||||
if (Array.isArray(navigator.languages)) {
|
||||
for (const lang of navigator.languages) {
|
||||
if (typeof lang === "string" && lang.trim()) {
|
||||
preferences.push(normalizeLanguageCode(lang))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof navigator.language === "string" && navigator.language.trim()) {
|
||||
preferences.push(normalizeLanguageCode(navigator.language))
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = Array.from(new Set(preferences))
|
||||
_browserLanguagePreferences = deduped
|
||||
return deduped
|
||||
}
|
||||
|
||||
function getBrowserPreferenceRanks(): Map<string, number> {
|
||||
if (_browserPreferenceRanks) return _browserPreferenceRanks
|
||||
|
||||
const preferences = getBrowserLanguagePreferences()
|
||||
const ranks = new Map<string, number>()
|
||||
const display = new Intl.DisplayNames(["en"], { type: "language" })
|
||||
|
||||
for (const [index, pref] of preferences.entries()) {
|
||||
const base = pref.split("-", 1)[0]
|
||||
if (!ranks.has(pref)) ranks.set(pref, index)
|
||||
if (!ranks.has(base)) ranks.set(base, index)
|
||||
|
||||
const prefName = display.of(pref)
|
||||
if (prefName) {
|
||||
const key = normalizeLanguageName(prefName)
|
||||
if (!ranks.has(key)) ranks.set(key, index)
|
||||
}
|
||||
const baseName = display.of(base)
|
||||
if (baseName) {
|
||||
const key = normalizeLanguageName(baseName)
|
||||
if (!ranks.has(key)) ranks.set(key, index)
|
||||
}
|
||||
}
|
||||
|
||||
_browserPreferenceRanks = ranks
|
||||
return ranks
|
||||
}
|
||||
|
||||
function resolveLanguageIdentifier(value: string): string {
|
||||
const normalized = normalizeLanguageCode(value)
|
||||
if (/^[a-z]{2,3}(?:-[a-z0-9]{2,})?$/i.test(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const nameKey = normalizeLanguageName(value)
|
||||
const byName = LANGUAGE_NAME_TO_CODE[nameKey]
|
||||
if (byName) return byName
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getPreferenceRank(value: string, preferenceRanks: Map<string, number>): number {
|
||||
const normalized = resolveLanguageIdentifier(value)
|
||||
const exact = preferenceRanks.get(normalized)
|
||||
if (exact !== undefined) return exact
|
||||
|
||||
const base = normalized.split("-", 1)[0]
|
||||
const baseRank = preferenceRanks.get(base)
|
||||
if (baseRank !== undefined) return baseRank
|
||||
|
||||
const nameKey = normalizeLanguageName(toLanguageName(normalized))
|
||||
const nameRank = preferenceRanks.get(nameKey)
|
||||
if (nameRank !== undefined) return nameRank
|
||||
|
||||
const rawNameRank = preferenceRanks.get(normalizeLanguageName(value))
|
||||
if (rawNameRank !== undefined) return rawNameRank
|
||||
|
||||
return Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
export function mapLanguageToCountry(code: string): string | null {
|
||||
const normalized = resolveLanguageIdentifier(code)
|
||||
|
||||
const direct = LANGUAGE_TO_COUNTRY[normalized]
|
||||
if (direct) return direct
|
||||
|
||||
// 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("-")
|
||||
if (hyphenParts.length >= 2) {
|
||||
const base = LANGUAGE_TO_COUNTRY[hyphenParts[0]]
|
||||
if (base) return base
|
||||
const region = hyphenParts[hyphenParts.length - 1]
|
||||
if (/^[a-z]{2}$/i.test(region)) {
|
||||
return region.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildLanguageFlags(codes: string[] | null | undefined): {
|
||||
flags: LanguageFlagEntry[]
|
||||
unmappedCodes: string[]
|
||||
} {
|
||||
if (!codes || codes.length === 0) {
|
||||
return { flags: [], unmappedCodes: [] }
|
||||
}
|
||||
|
||||
const byCountry = new Map<string, string[]>()
|
||||
const unmapped: string[] = []
|
||||
const seenUnmapped = new Set<string>()
|
||||
const preferenceRanks = getBrowserPreferenceRanks()
|
||||
|
||||
const ordered = codes
|
||||
.map((raw, index) => ({ raw, index }))
|
||||
.filter((v) => Boolean(v.raw && String(v.raw).trim()))
|
||||
.sort((a, b) => {
|
||||
const aRank = getPreferenceRank(String(a.raw), preferenceRanks)
|
||||
const bRank = getPreferenceRank(String(b.raw), preferenceRanks)
|
||||
if (aRank !== bRank) return aRank - bRank
|
||||
return a.index - b.index
|
||||
})
|
||||
|
||||
for (const { raw } of ordered) {
|
||||
if (!raw) continue
|
||||
const countryCode = mapLanguageToCountry(raw)
|
||||
if (!countryCode) {
|
||||
const upper = raw.toUpperCase()
|
||||
if (!seenUnmapped.has(upper)) {
|
||||
seenUnmapped.add(upper)
|
||||
unmapped.push(upper)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!FLAGS[countryCode]) {
|
||||
const upper = raw.toUpperCase()
|
||||
if (!seenUnmapped.has(upper)) {
|
||||
seenUnmapped.add(upper)
|
||||
unmapped.push(upper)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const existing = byCountry.get(countryCode) || []
|
||||
existing.push(raw)
|
||||
byCountry.set(countryCode, existing)
|
||||
}
|
||||
|
||||
const flags: LanguageFlagEntry[] = Array.from(byCountry.entries()).map(
|
||||
([countryCode, sourceCodes]) => ({
|
||||
countryCode,
|
||||
svg: FLAGS[countryCode],
|
||||
sourceCodes,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
flags,
|
||||
unmappedCodes: unmapped,
|
||||
}
|
||||
}
|
||||
|
||||
const LANGUAGE_NAME_OVERRIDES: Record<string, string> = {
|
||||
eng: "English",
|
||||
spa: "Spanish",
|
||||
esp: "Spanish",
|
||||
"spa-la": "Spanish",
|
||||
"es-419": "Spanish",
|
||||
esl: "Spanish",
|
||||
spl: "Spanish",
|
||||
por: "Portuguese",
|
||||
"pt-br": "Portuguese",
|
||||
nob: "Norwegian",
|
||||
nno: "Norwegian",
|
||||
fre: "French",
|
||||
fra: "French",
|
||||
ger: "German",
|
||||
deu: "German",
|
||||
ita: "Italian",
|
||||
nld: "Dutch",
|
||||
dut: "Dutch",
|
||||
swe: "Swedish",
|
||||
nor: "Norwegian",
|
||||
dan: "Danish",
|
||||
fin: "Finnish",
|
||||
pol: "Polish",
|
||||
ces: "Czech",
|
||||
cze: "Czech",
|
||||
hun: "Hungarian",
|
||||
ron: "Romanian",
|
||||
rum: "Romanian",
|
||||
ell: "Greek",
|
||||
gre: "Greek",
|
||||
tur: "Turkish",
|
||||
rus: "Russian",
|
||||
ukr: "Ukrainian",
|
||||
bul: "Bulgarian",
|
||||
srp: "Serbian",
|
||||
hrv: "Croatian",
|
||||
slv: "Slovenian",
|
||||
slk: "Slovak",
|
||||
slo: "Slovak",
|
||||
jpn: "Japanese",
|
||||
kor: "Korean",
|
||||
zho: "Chinese",
|
||||
chi: "Chinese",
|
||||
yue: "Cantonese",
|
||||
tha: "Thai",
|
||||
vie: "Vietnamese",
|
||||
ind: "Indonesian",
|
||||
msa: "Malay",
|
||||
may: "Malay",
|
||||
hin: "Hindi",
|
||||
ara: "Arabic",
|
||||
heb: "Hebrew",
|
||||
fas: "Persian",
|
||||
per: "Persian",
|
||||
urd: "Urdu",
|
||||
swa: "Swahili",
|
||||
cat: "Catalan",
|
||||
eus: "Basque",
|
||||
baq: "Basque",
|
||||
}
|
||||
|
||||
function toLanguageName(code: string): string {
|
||||
const normalized = normalizeLanguageCode(code)
|
||||
const override = LANGUAGE_NAME_OVERRIDES[normalized]
|
||||
if (override) return override
|
||||
|
||||
const display = new Intl.DisplayNames(["en"], { type: "language" })
|
||||
const candidate = display.of(normalized)
|
||||
if (candidate) return candidate
|
||||
|
||||
const base = normalized.split("-", 1)[0]
|
||||
const baseOverride = LANGUAGE_NAME_OVERRIDES[base]
|
||||
if (baseOverride) return baseOverride
|
||||
const baseCandidate = display.of(base)
|
||||
if (baseCandidate) return baseCandidate
|
||||
|
||||
return code.toUpperCase()
|
||||
}
|
||||
|
||||
function summarizeLanguageCodes(codes: string[] | null | undefined): string {
|
||||
if (!codes || codes.length === 0) return ""
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const raw of codes) {
|
||||
if (!raw) continue
|
||||
const name = toLanguageName(raw).trim()
|
||||
if (!name) continue
|
||||
const key = name.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
names.push(name)
|
||||
}
|
||||
|
||||
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(
|
||||
audioCodes: string[] | null | undefined,
|
||||
subtitleCodes: string[] | null | undefined,
|
||||
): string {
|
||||
const audio = summarizeLanguageCodes(audioCodes)
|
||||
const subs = summarizeLanguageCodes(subtitleCodes)
|
||||
if (audio && subs) return `${audio} / ${subs}`
|
||||
return audio || subs
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue"
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
* Configures Vite for FastAPI backend integration:
|
||||
* - Proxies /api/* requests to the FastAPI backend
|
||||
* - Builds to the Python module's frontend-build directory
|
||||
* - Disables Vite's screen clearing on startup
|
||||
*
|
||||
* Options:
|
||||
* paths - Array of paths to proxy (default: ["/api"])
|
||||
* paths - Array of paths to proxy (default: ['/api'])
|
||||
*/
|
||||
|
||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8420"
|
||||
export default function fastapiVue({ paths = ['/api'] } = {}) {
|
||||
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || 'http://localhost:8421'
|
||||
|
||||
// Build proxy configuration for each path
|
||||
const proxy = {}
|
||||
@@ -24,11 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
name: "vite-plugin-fastapi-mediahive",
|
||||
name: 'vite-plugin-fastapi-mediahive',
|
||||
config: () => ({
|
||||
clearScreen: false,
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../mediahive/frontend-build",
|
||||
outDir: '../mediahive/frontend-build',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import fastapiVue from './vite-plugin-fastapi.js'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
@@ -8,52 +7,6 @@ export default defineConfig(async () => ({
|
||||
plugins: [
|
||||
fastapiVue(),
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
injectRegister: "script",
|
||||
manifest: {
|
||||
id: "/",
|
||||
name: "MediaHive",
|
||||
short_name: "MediaHive",
|
||||
description: "Movies and Series",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display_override: ["window-controls-overlay", "fullscreen", "standalone"],
|
||||
display: "fullscreen",
|
||||
background_color: "#0a0a0a",
|
||||
theme_color: "#0a0a0a",
|
||||
icons: [
|
||||
{
|
||||
src: "/mediahive-32.webp",
|
||||
sizes: "32x32",
|
||||
type: "image/webp"
|
||||
},
|
||||
{
|
||||
src: "/mediahive.webp",
|
||||
sizes: "192x192",
|
||||
type: "image/webp"
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
clientsClaim: true,
|
||||
skipWaiting: true,
|
||||
manifestTransforms: [
|
||||
async (entries) => {
|
||||
const manifest = entries.map((entry) =>
|
||||
entry.url === "index.html" ? { ...entry, url: "/" } : entry
|
||||
);
|
||||
return { manifest, warnings: [] };
|
||||
},
|
||||
],
|
||||
navigateFallback: null,
|
||||
navigateFallbackDenylist: [/^\/api\//],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
// Vite dev server options
|
||||
@@ -62,4 +15,8 @@ export default defineConfig(async () => ({
|
||||
port: 8420,
|
||||
strictPort: true,
|
||||
},
|
||||
|
||||
worker: {
|
||||
format: "es",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""MediaHive - Media Browser Server"""
|
||||
"""MediaHive - Media Browser Server."""
|
||||
|
||||
@@ -1,38 +1,104 @@
|
||||
import argparse
|
||||
"""MediaHive CLI entrypoint."""
|
||||
|
||||
import os
|
||||
|
||||
# Must be set before fastapi_vue env bindings are created (mediahive.config).
|
||||
os.environ["FASTAPI_VUE"] = "MEDIAHIVE"
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi_vue import server
|
||||
from fastapi_vue import env, server
|
||||
|
||||
from mediahive.config import config
|
||||
|
||||
DEFAULT_PORT = 8420
|
||||
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
|
||||
|
||||
|
||||
def resolve_media_root(path: str | None = None) -> Path:
|
||||
"""Resolve the media root folder from a path, MEDIAHIVE_PATH env, or cwd."""
|
||||
match Path(path or os.environ.get("MEDIAHIVE_PATH") or Path.cwd()).parts:
|
||||
case (*rest, ".mediahive", "index.json"):
|
||||
...
|
||||
case (*rest, ".mediahive"):
|
||||
...
|
||||
case rest:
|
||||
...
|
||||
mediaroot = Path(*rest).resolve()
|
||||
if not mediaroot.exists() or not mediaroot.is_dir():
|
||||
sys.stderr.write(f"Error: Folder does not exist: {mediaroot}\n")
|
||||
sys.exit(1)
|
||||
return mediaroot
|
||||
def _configure_windows_event_loop_policy() -> None:
|
||||
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
import warnings
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None)
|
||||
if policy_cls is None:
|
||||
return
|
||||
asyncio.set_event_loop_policy(policy_cls())
|
||||
|
||||
|
||||
def main():
|
||||
def _derive_name(path: str) -> str:
|
||||
"""Derive a root name from a path."""
|
||||
p = Path(path)
|
||||
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:
|
||||
_configure_windows_event_loop_policy()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="MediaHive - Media scanning, indexing, and streaming"
|
||||
)
|
||||
parser.add_argument(
|
||||
"media_folder",
|
||||
nargs="?",
|
||||
help="Path to the media folder (default: MEDIAHIVE_PATH or current directory)",
|
||||
"media_folders",
|
||||
nargs="*",
|
||||
metavar="MEDIA_FOLDER",
|
||||
help=(
|
||||
"One or more media folders to index "
|
||||
"(default: none — configure via UI or API)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
@@ -40,18 +106,67 @@ def main():
|
||||
action="append",
|
||||
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()
|
||||
|
||||
mediaroot = resolve_media_root(args.media_folder)
|
||||
os.environ["MEDIAHIVE_PATH"] = mediaroot.as_posix()
|
||||
# --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:
|
||||
roots: dict[str, str] = {}
|
||||
for path in args.media_folders:
|
||||
# Defer filesystem validation to the server so startup is never
|
||||
# blocked by macOS permission dialogs or missing paths.
|
||||
p = Path(path).expanduser()
|
||||
name = _derive_name(p.as_posix())
|
||||
# Resolve collisions
|
||||
base_name = name
|
||||
suffix = 2
|
||||
while name in roots:
|
||||
name = f"{base_name}{suffix}"
|
||||
suffix += 1
|
||||
roots[name] = p.as_posix()
|
||||
# Teleported to the server process by fastapi-vue's server.run().
|
||||
config.roots = roots
|
||||
|
||||
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(
|
||||
"mediahive.server:app",
|
||||
listen=args.listen,
|
||||
default_port=DEFAULT_PORT,
|
||||
**(dev if DEVMODE else {}),
|
||||
server_header=False,
|
||||
loop="none" if sys.platform == "win32" else "auto",
|
||||
reload=Path(__file__).parent if env.dev and sys.platform != "win32" else False,
|
||||
# fastapi-vue routes the root logger at INFO in dev / WARNING in prod;
|
||||
# keep our own loggers visible in production too.
|
||||
log_config={
|
||||
"loggers": {"mediahive": {"level": "DEBUG" if env.dev else "INFO"}}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -1,31 +1,42 @@
|
||||
"""Platform-appropriate config persistence for MediaHive.
|
||||
r"""Platform-appropriate config persistence for MediaHive.
|
||||
|
||||
Config file location:
|
||||
Windows: %APPDATA%\\mediahive\\config.toml
|
||||
Locations (via platformdirs):
|
||||
Config — Windows: %LOCALAPPDATA%\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
|
||||
|
||||
import msgspec
|
||||
import msgspec.toml
|
||||
from fastapi_vue import env
|
||||
from platformdirs import user_config_path, user_log_path
|
||||
|
||||
|
||||
class Config(msgspec.Struct):
|
||||
media_folder: str | None = None
|
||||
class Config(msgspec.Struct, omit_defaults=True):
|
||||
roots: dict[str, str] | None = None
|
||||
|
||||
|
||||
# Runtime config shared between the CLI entrypoint and the server process via
|
||||
# fastapi-vue's env teleport (MEDIAHIVE_CONFIG). Values set here take
|
||||
# precedence over the persisted config file.
|
||||
config = env(Config)
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA") or Path.home())
|
||||
elif sys.platform == "darwin":
|
||||
base = Path.home() / "Library" / "Application Support"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
||||
return base / "mediahive"
|
||||
# appauthor=False: avoid the doubled %LOCALAPPDATA%\mediahive\mediahive.
|
||||
# roaming=False: config is machine-specific state, not something to sync
|
||||
# across a domain profile.
|
||||
return user_config_path("mediahive", appauthor=False, roaming=False)
|
||||
|
||||
|
||||
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:
|
||||
@@ -37,7 +48,7 @@ def load_config() -> Config:
|
||||
if path.exists():
|
||||
try:
|
||||
return msgspec.toml.decode(path.read_bytes(), type=Config)
|
||||
except Exception:
|
||||
except OSError, msgspec.DecodeError, msgspec.ValidationError:
|
||||
return Config()
|
||||
return Config()
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
Hivescan - Continuous media scanning with live WebSocket updates.
|
||||
"""Hivescan - Continuous media scanning with live WebSocket updates.
|
||||
|
||||
Import from submodules directly:
|
||||
from mediahive.hivescan.scanner import start, stop
|
||||
@@ -9,13 +8,13 @@ Import from submodules directly:
|
||||
"""
|
||||
|
||||
# Minimal public API - prefer importing from submodules directly
|
||||
from mediahive.hivescan.models import ContentType, ContentHash, ParsedContent
|
||||
from mediahive.hivescan.models import ContentHash, ContentType, ParsedContent
|
||||
from mediahive.hivescan.utils import DEFAULT_OUTPUT_FOLDER, find_common_root
|
||||
|
||||
__all__ = [
|
||||
"ContentType",
|
||||
"ContentHash",
|
||||
"ParsedContent",
|
||||
"DEFAULT_OUTPUT_FOLDER",
|
||||
"ContentHash",
|
||||
"ContentType",
|
||||
"ParsedContent",
|
||||
"find_common_root",
|
||||
]
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
import argparse
|
||||
import logging
|
||||
"""Hivescan CLI entrypoint."""
|
||||
|
||||
import os
|
||||
|
||||
# Must be set before fastapi_vue env bindings are created (mediahive.config).
|
||||
os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE")
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from mediahive.config import config
|
||||
|
||||
|
||||
def _configure_windows_event_loop_policy() -> None:
|
||||
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None)
|
||||
if policy_cls is None:
|
||||
return
|
||||
asyncio.set_event_loop_policy(policy_cls())
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_configure_windows_event_loop_policy()
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Hivescan server — continuous media scanning with live WS updates.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
@@ -16,11 +38,8 @@ Examples:
|
||||
|
||||
Exclude paths by creating .mediahive/scanignore (gitignore syntax).
|
||||
|
||||
The server exposes:
|
||||
WS /ws Live index updates & task progress
|
||||
POST /api/scan Trigger a new scan
|
||||
GET /api/status Current server status
|
||||
GET /api/index Full index as JSON (HTTP fallback)
|
||||
The server exposes a unified endpoint:
|
||||
WS /api/ws Live index updates, task progress, and root status changes
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -41,12 +60,9 @@ The server exposes:
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
media_root = Path(args.media_folder).resolve()
|
||||
if not media_root.exists() or not media_root.is_dir():
|
||||
print(f"Error: Folder does not exist: {media_root}")
|
||||
exit(1)
|
||||
|
||||
os.environ["MEDIAHIVE_PATH"] = str(media_root)
|
||||
# Defer filesystem validation to the server; pass raw path via env config.
|
||||
media_root = Path(args.media_folder).expanduser()
|
||||
config.roots = {media_root.name or "media": media_root.as_posix()}
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
"""TMDb image downloading functions."""
|
||||
|
||||
import httpx
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from aiopathlib import AsyncPath
|
||||
|
||||
from mediahive.hivescan.utils import get_media_folder_path
|
||||
|
||||
|
||||
# TMDb image configuration
|
||||
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
|
||||
DEFAULT_POSTER_SIZE = "w500"
|
||||
DEFAULT_BACKDROP_SIZE = "w1280"
|
||||
DEFAULT_PROFILE_SIZE = "w185"
|
||||
|
||||
# Shared async HTTP client (created lazily)
|
||||
_image_client: Optional[httpx.AsyncClient] = None
|
||||
_image_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def _get_image_client() -> httpx.AsyncClient:
|
||||
@@ -30,13 +30,19 @@ def _get_image_client() -> httpx.AsyncClient:
|
||||
return _image_client
|
||||
|
||||
|
||||
async def _download_image(
|
||||
url: str, output_path: Path, description: str
|
||||
) -> Optional[str]:
|
||||
async def close_image_client() -> None:
|
||||
"""Close the persistent image HTTP client if it was created."""
|
||||
global _image_client
|
||||
if _image_client is not None:
|
||||
await _image_client.aclose()
|
||||
_image_client = None
|
||||
|
||||
|
||||
async def _download_image(url: str, output_path: Path, description: str) -> str | None:
|
||||
"""Download an image from URL to output path."""
|
||||
ap = AsyncPath(output_path)
|
||||
if await ap.exists():
|
||||
return str(output_path)
|
||||
return output_path.as_posix()
|
||||
|
||||
try:
|
||||
client = _get_image_client()
|
||||
@@ -44,8 +50,8 @@ async def _download_image(
|
||||
response.raise_for_status()
|
||||
await AsyncPath(output_path.parent).mkdir(parents=True, exist_ok=True)
|
||||
await ap.write_bytes(response.content)
|
||||
return str(output_path)
|
||||
except Exception as e:
|
||||
return output_path.as_posix()
|
||||
except (httpx.HTTPError, OSError) as e:
|
||||
print(f" Failed to download {description}: {e}")
|
||||
return None
|
||||
|
||||
@@ -53,11 +59,11 @@ async def _download_image(
|
||||
async def download_cover_image(
|
||||
poster_path: str,
|
||||
title: str,
|
||||
year: Optional[int],
|
||||
year: int | None,
|
||||
media_type: str,
|
||||
cover_dir: Path,
|
||||
size: str = DEFAULT_POSTER_SIZE,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
"""Download a cover image from TMDb."""
|
||||
if not poster_path:
|
||||
return None
|
||||
@@ -66,7 +72,7 @@ async def download_cover_image(
|
||||
cover_path = media_folder / "cover.jpg"
|
||||
|
||||
if await AsyncPath(cover_path).exists():
|
||||
return str(cover_path)
|
||||
return cover_path.as_posix()
|
||||
|
||||
url = f"{TMDB_IMAGE_BASE}/{size}{poster_path}"
|
||||
print(f" Downloading cover: {title}")
|
||||
@@ -76,11 +82,11 @@ async def download_cover_image(
|
||||
async def download_backdrop_image(
|
||||
backdrop_path: str,
|
||||
title: str,
|
||||
year: Optional[int],
|
||||
year: int | None,
|
||||
media_type: str,
|
||||
cover_dir: Path,
|
||||
size: str = DEFAULT_BACKDROP_SIZE,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
"""Download a backdrop image from TMDb."""
|
||||
if not backdrop_path:
|
||||
return None
|
||||
@@ -89,7 +95,7 @@ async def download_backdrop_image(
|
||||
local_path = media_folder / "backdrop.jpg"
|
||||
|
||||
if await AsyncPath(local_path).exists():
|
||||
return str(local_path)
|
||||
return local_path.as_posix()
|
||||
|
||||
url = f"{TMDB_IMAGE_BASE}/{size}{backdrop_path}"
|
||||
print(f" Downloading backdrop: {title}")
|
||||
@@ -100,7 +106,7 @@ async def download_season_poster(
|
||||
poster_path: str,
|
||||
media_folder: Path,
|
||||
season_num: int,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
"""Download a season poster image from TMDb."""
|
||||
if not poster_path:
|
||||
return None
|
||||
@@ -108,8 +114,39 @@ async def download_season_poster(
|
||||
output_path = media_folder / f"season{season_num:02d}.jpg"
|
||||
|
||||
if await AsyncPath(output_path).exists():
|
||||
return str(output_path)
|
||||
return output_path.as_posix()
|
||||
|
||||
await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True)
|
||||
url = f"{TMDB_IMAGE_BASE}/{DEFAULT_POSTER_SIZE}{poster_path}"
|
||||
return await _download_image(url, output_path, f"season {season_num} poster")
|
||||
|
||||
|
||||
async def download_cast_profile(
|
||||
profile_path: str,
|
||||
media_folder: Path,
|
||||
cast_name: str,
|
||||
person_id: int | None,
|
||||
size: str = DEFAULT_PROFILE_SIZE,
|
||||
) -> str | None:
|
||||
"""Download a cached cast profile image from TMDb."""
|
||||
if not profile_path:
|
||||
return None
|
||||
|
||||
# Shared people cache avoids duplicating identical actor images per title.
|
||||
people_dir = media_folder.parent.parent / "people"
|
||||
safe_name = _slugify_person_name(cast_name) or "Unknown"
|
||||
person_suffix = str(person_id) if person_id is not None else "unknown"
|
||||
output_path = people_dir / f"{safe_name}-{person_suffix}.jpg"
|
||||
|
||||
if await AsyncPath(output_path).exists():
|
||||
return output_path.as_posix()
|
||||
|
||||
await AsyncPath(people_dir).mkdir(parents=True, exist_ok=True)
|
||||
url = f"{TMDB_IMAGE_BASE}/{size}{profile_path}"
|
||||
return await _download_image(url, output_path, f"cast profile for {cast_name}")
|
||||
|
||||
|
||||
def _slugify_person_name(name: str) -> str:
|
||||
"""Slugify a person name preserving capitals and hyphens; use dots as separators."""
|
||||
slug = re.sub(r"[^0-9A-Za-z-]+", ".", name)
|
||||
return re.sub(r"\.+", ".", slug).strip(".")
|
||||
|
||||
@@ -4,7 +4,6 @@ import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ContentType(Enum):
|
||||
@@ -24,7 +23,7 @@ class ContentHash:
|
||||
size: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_path(cls, path: Path) -> "ContentHash":
|
||||
def from_path(cls, path: Path) -> ContentHash:
|
||||
"""Generate a content hash based on torrent name."""
|
||||
hash_val = hashlib.md5(path.name.encode()).hexdigest()[:16]
|
||||
return cls(path=path, hash=hash_val)
|
||||
@@ -38,16 +37,17 @@ class ParsedContent:
|
||||
name: str
|
||||
content_type: ContentType
|
||||
title: str
|
||||
year: Optional[int] = None
|
||||
resolution: Optional[str] = None
|
||||
quality: Optional[str] = None
|
||||
codec: Optional[str] = None
|
||||
audio: Optional[str] = None
|
||||
season: Optional[int] = None
|
||||
episode: Optional[int] = None
|
||||
episode_name: Optional[str] = None
|
||||
encoder: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
year: int | None = None
|
||||
resolution: str | None = None
|
||||
quality: str | None = None
|
||||
network: str | None = None
|
||||
codec: str | None = None
|
||||
audio: str | None = None
|
||||
season: int | None = None
|
||||
episode: int | None = None
|
||||
episode_name: str | None = None
|
||||
encoder: str | None = None
|
||||
language: str | None = None
|
||||
is_directory: bool = False
|
||||
raw_parsed: dict = field(default_factory=dict)
|
||||
content_hash: Optional[ContentHash] = None
|
||||
content_hash: ContentHash | None = None
|
||||
|
||||
@@ -2,12 +2,21 @@
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import PTN
|
||||
from aiopathlib import AsyncPath
|
||||
|
||||
from mediahive.hivescan.models import ContentHash, ContentType, ParsedContent
|
||||
from mediahive.hivescan.utils import normalize_resolution_label
|
||||
|
||||
_EDGE_NON_ALPHANUMERICS_RE = re.compile(r"^[^0-9A-Za-z]+|[^0-9A-Za-z]+$")
|
||||
|
||||
|
||||
def strip_edge_non_alphanumerics(value: str | None) -> str | None:
|
||||
"""Remove punctuation from the start and end of PTN scene tags."""
|
||||
if not value:
|
||||
return None
|
||||
return _EDGE_NON_ALPHANUMERICS_RE.sub("", value)
|
||||
|
||||
|
||||
def determine_content_type(parsed: dict) -> ContentType:
|
||||
@@ -27,6 +36,7 @@ async def parse_download(path: Path) -> ParsedContent:
|
||||
"""Parse a downloaded torrent directory/file name."""
|
||||
name = path.name
|
||||
parsed = PTN.parse(name)
|
||||
parsed["encoder"] = strip_edge_non_alphanumerics(parsed.get("encoder"))
|
||||
content_type = determine_content_type(parsed)
|
||||
content_hash = ContentHash.from_path(path)
|
||||
|
||||
@@ -36,8 +46,9 @@ async def parse_download(path: Path) -> ParsedContent:
|
||||
content_type=content_type,
|
||||
title=parsed.get("title", name),
|
||||
year=parsed.get("year"),
|
||||
resolution=parsed.get("resolution"),
|
||||
resolution=normalize_resolution_label(parsed.get("resolution")),
|
||||
quality=parsed.get("quality"),
|
||||
network=parsed.get("network"),
|
||||
codec=parsed.get("codec"),
|
||||
audio=parsed.get("audio"),
|
||||
season=parsed.get("season"),
|
||||
@@ -51,14 +62,14 @@ async def parse_download(path: Path) -> ParsedContent:
|
||||
)
|
||||
|
||||
|
||||
def parse_episode_from_filename(filename: str) -> Optional[Tuple[int, int]]:
|
||||
"""
|
||||
Parse season and episode numbers from a filename.
|
||||
def parse_episode_from_filename(filename: str) -> tuple[int, int] | None:
|
||||
"""Parse season and episode numbers from a filename.
|
||||
|
||||
Handles formats: S01E05, 1x05, Season 1 Episode 5
|
||||
|
||||
Returns:
|
||||
Tuple of (season_number, episode_number) or None if not found
|
||||
|
||||
"""
|
||||
name = filename.lower()
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
Gitignore-style path matcher for controlling which directories the scanner visits.
|
||||
"""Gitignore-style path matcher for controlling which directories the scanner visits.
|
||||
|
||||
Reads patterns from ``<media_root>/.mediahive/scanignore``. The file uses the
|
||||
same syntax as ``.gitignore``:
|
||||
@@ -30,8 +29,6 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
# Built-in patterns that are always excluded (before user file)
|
||||
_BUILTIN_EXCLUDES: list[str] = [
|
||||
@@ -75,11 +72,9 @@ def _pattern_to_regex(pattern: str) -> re.Pattern[str]:
|
||||
parts.append("(?:.+/)?")
|
||||
i += 3
|
||||
continue
|
||||
else:
|
||||
parts.append(".*")
|
||||
i += 2
|
||||
continue
|
||||
else:
|
||||
parts.append("[^/]*")
|
||||
i += 1
|
||||
elif c == "?":
|
||||
@@ -94,12 +89,7 @@ def _pattern_to_regex(pattern: str) -> re.Pattern[str]:
|
||||
|
||||
regex_str = "".join(parts)
|
||||
|
||||
if anchored or has_slash:
|
||||
# Match from the start of the relative path
|
||||
regex_str = "^" + regex_str
|
||||
else:
|
||||
# Match against any path component (basename or as suffix after /)
|
||||
regex_str = "(?:^|/)" + regex_str
|
||||
regex_str = "^" + regex_str if anchored or has_slash else "(?:^|/)" + regex_str
|
||||
|
||||
# Must match the whole remaining path or be a prefix (directory match)
|
||||
regex_str += "(?:/.*)?$"
|
||||
@@ -112,7 +102,7 @@ class ScanIgnore:
|
||||
|
||||
def __init__(self, media_root: Path) -> None:
|
||||
self.media_root = media_root.resolve()
|
||||
self._rules: List[Tuple[bool, re.Pattern[str]]] = [] # (negated, regex)
|
||||
self._rules: list[tuple[bool, re.Pattern[str]]] = [] # (negated, regex)
|
||||
self._load_builtins()
|
||||
self._load_file()
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import asyncio
|
||||
import glob
|
||||
import operator
|
||||
import os
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from aiopathlib import AsyncPath
|
||||
|
||||
@@ -11,7 +14,6 @@ from mediahive.hivescan.models import ContentType, ParsedContent
|
||||
from mediahive.hivescan.parsing import parse_download, parse_episode_from_filename
|
||||
from mediahive.hivescan.utils import get_media_folder_path, sanitize_filename
|
||||
|
||||
|
||||
# Video file extensions
|
||||
VIDEO_EXTENSIONS = {
|
||||
".mkv",
|
||||
@@ -26,23 +28,97 @@ VIDEO_EXTENSIONS = {
|
||||
".m2ts",
|
||||
}
|
||||
|
||||
# Caches for expensive operations
|
||||
_episode_files_cache: Dict[str, Dict[Tuple[int, int], List[Tuple[str, int]]]] = {}
|
||||
_playable_file_cache: Dict[str, Optional[str]] = {}
|
||||
# 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]]]] = {}
|
||||
_playable_file_cache: dict[str, str | None] = {}
|
||||
_bluray_probe_file_cache: dict[str, str | None] = {}
|
||||
|
||||
|
||||
async def scan_downloads(base_pattern: str) -> List[ParsedContent]:
|
||||
"""
|
||||
Scan download directories matching the pattern.
|
||||
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(
|
||||
directory: Path,
|
||||
stop_event: threading.Event,
|
||||
) -> tuple[list[Path], list[Path]]:
|
||||
"""Return child directories and files, checking stop_event each iteration."""
|
||||
child_dirs: list[Path] = []
|
||||
child_files: list[Path] = []
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
if stop_event.is_set():
|
||||
return child_dirs, child_files
|
||||
try:
|
||||
is_dir = entry.is_dir(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
p = Path(entry.path)
|
||||
if is_dir:
|
||||
child_dirs.append(p)
|
||||
else:
|
||||
child_files.append(p)
|
||||
return child_dirs, child_files
|
||||
|
||||
|
||||
def _scandir_files_with_suffix(
|
||||
directory: Path,
|
||||
suffixes: set[str],
|
||||
stop_event: threading.Event,
|
||||
) -> list[Path]:
|
||||
"""Return files in directory with a matching suffix, cancellable via stop_event."""
|
||||
files: list[Path] = []
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
if stop_event.is_set():
|
||||
return files
|
||||
try:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
p = Path(entry.path)
|
||||
if p.suffix.lower() in suffixes:
|
||||
files.append(p)
|
||||
return files
|
||||
|
||||
|
||||
async def scan_downloads(base_pattern: str) -> list[ParsedContent]:
|
||||
"""Scan download directories matching the pattern.
|
||||
|
||||
Args:
|
||||
base_pattern: Glob pattern for finding download directories
|
||||
|
||||
Returns:
|
||||
List of ParsedContent objects for each found download
|
||||
|
||||
"""
|
||||
exclude_patterns = [".torrents", "incomplete", ".incomplete"]
|
||||
results: List[ParsedContent] = []
|
||||
results: list[ParsedContent] = []
|
||||
|
||||
paths = await asyncio.to_thread(glob.glob, base_pattern)
|
||||
for path_str in paths:
|
||||
@@ -81,57 +157,74 @@ def categorize_downloads(
|
||||
|
||||
async def find_episode_files(
|
||||
path: Path,
|
||||
) -> Dict[Tuple[int, int], List[Tuple[str, int]]]:
|
||||
"""
|
||||
Find all episode video files in a directory.
|
||||
) -> dict[tuple[int, int], list[tuple[str, int]]]:
|
||||
"""Find all episode video files in a directory.
|
||||
|
||||
Args:
|
||||
path: Path to search (can be a season pack directory or single file)
|
||||
|
||||
Returns:
|
||||
Dict mapping (season_num, episode_num) to list of (file_path, file_size) tuples
|
||||
|
||||
"""
|
||||
cache_key = str(path)
|
||||
cache_key = path.as_posix()
|
||||
if cache_key in _episode_files_cache:
|
||||
return _episode_files_cache[cache_key]
|
||||
|
||||
episodes: Dict[Tuple[int, int], List[Tuple[str, int]]] = {}
|
||||
episodes: dict[tuple[int, int], list[tuple[str, int]]] = {}
|
||||
ap = AsyncPath(path)
|
||||
|
||||
if await ap.is_file():
|
||||
if path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||
ep_info = parse_episode_from_filename(path.name)
|
||||
if ep_info:
|
||||
episodes[ep_info] = [(str(path), (await ap.stat()).st_size)]
|
||||
episodes[ep_info] = [(path.as_posix(), (await ap.stat()).st_size)]
|
||||
_episode_files_cache[cache_key] = episodes
|
||||
return episodes
|
||||
|
||||
stack = [path]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
stop_event = threading.Event()
|
||||
try:
|
||||
for f in ap.rglob("*"):
|
||||
af = AsyncPath(f)
|
||||
if await af.is_file() and Path(f).suffix.lower() in VIDEO_EXTENSIONS:
|
||||
if "sample" in Path(f).name.lower():
|
||||
child_dirs, child_files = await asyncio.to_thread(
|
||||
_scandir_split,
|
||||
current,
|
||||
stop_event,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
stop_event.set()
|
||||
raise
|
||||
except OSError, PermissionError:
|
||||
continue
|
||||
ep_info = parse_episode_from_filename(Path(f).name)
|
||||
|
||||
stack.extend(child_dirs)
|
||||
for f in child_files:
|
||||
if f.suffix.lower() not in VIDEO_EXTENSIONS:
|
||||
continue
|
||||
if "sample" in f.name.lower():
|
||||
continue
|
||||
af = AsyncPath(f)
|
||||
try:
|
||||
ep_info = parse_episode_from_filename(f.name)
|
||||
if ep_info:
|
||||
if ep_info not in episodes:
|
||||
episodes[ep_info] = []
|
||||
episodes[ep_info].append((str(f), (await af.stat()).st_size))
|
||||
episodes[ep_info].append((f.as_posix(), (await af.stat()).st_size))
|
||||
except OSError, PermissionError:
|
||||
pass
|
||||
continue
|
||||
|
||||
_episode_files_cache[cache_key] = episodes
|
||||
return episodes
|
||||
|
||||
|
||||
async def find_playable_file(path: Path) -> Optional[str]:
|
||||
"""
|
||||
Find the main playable media file in a directory.
|
||||
async def find_playable_file(path: Path) -> str | None:
|
||||
"""Find the main playable media file in a directory.
|
||||
|
||||
For Blu-ray discs: Returns BDMV/index.bdmv
|
||||
For Blu-ray discs: Returns BDMV/MovieObject.bdmv (fallback: BDMV/index.bdmv)
|
||||
For other content: Returns the largest video file
|
||||
"""
|
||||
cache_key = str(path)
|
||||
cache_key = path.as_posix()
|
||||
if cache_key in _playable_file_cache:
|
||||
return _playable_file_cache[cache_key]
|
||||
|
||||
@@ -139,71 +232,306 @@ async def find_playable_file(path: Path) -> Optional[str]:
|
||||
|
||||
if await ap.is_file():
|
||||
if path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||
result = str(path)
|
||||
result = path.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
_playable_file_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
# Check for Blu-ray disc structure
|
||||
bdmv_index = path / "BDMV" / "index.bdmv"
|
||||
bdmv_dir = path / "BDMV"
|
||||
bdmv_movieobject = bdmv_dir / "MovieObject.bdmv"
|
||||
bdmv_index = bdmv_dir / "index.bdmv"
|
||||
|
||||
if await AsyncPath(bdmv_movieobject).exists():
|
||||
result = bdmv_movieobject.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
if await AsyncPath(bdmv_index).exists():
|
||||
result = str(bdmv_index)
|
||||
result = bdmv_index.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
# Check for DVD disc structure
|
||||
video_ts_dir = path / "VIDEO_TS"
|
||||
video_ts_ifo = video_ts_dir / "VIDEO_TS.IFO"
|
||||
|
||||
if await AsyncPath(video_ts_ifo).exists():
|
||||
result = video_ts_ifo.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
# Check nested Blu-ray structure (e.g., MovieName/DISC1/BDMV/)
|
||||
try:
|
||||
for subdir in ap.iterdir():
|
||||
if await AsyncPath(subdir).is_dir():
|
||||
nested_bdmv = Path(subdir) / "BDMV" / "index.bdmv"
|
||||
if await AsyncPath(nested_bdmv).exists():
|
||||
result = str(nested_bdmv)
|
||||
stop_event = threading.Event()
|
||||
child_dirs, _ = await asyncio.to_thread(
|
||||
_scandir_split,
|
||||
path,
|
||||
stop_event,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
stop_event.set()
|
||||
raise
|
||||
except OSError, PermissionError:
|
||||
child_dirs = []
|
||||
|
||||
for subdir in child_dirs:
|
||||
nested_bdmv_dir = subdir / "BDMV"
|
||||
nested_movieobject = nested_bdmv_dir / "MovieObject.bdmv"
|
||||
nested_index = nested_bdmv_dir / "index.bdmv"
|
||||
|
||||
if await AsyncPath(nested_movieobject).exists():
|
||||
result = nested_movieobject.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
if await AsyncPath(nested_index).exists():
|
||||
result = nested_index.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
nested_video_ts_dir = subdir / "VIDEO_TS"
|
||||
nested_video_ts_ifo = nested_video_ts_dir / "VIDEO_TS.IFO"
|
||||
|
||||
if await AsyncPath(nested_video_ts_ifo).exists():
|
||||
result = nested_video_ts_ifo.as_posix()
|
||||
_playable_file_cache[cache_key] = result
|
||||
return result
|
||||
except OSError, PermissionError:
|
||||
pass
|
||||
|
||||
# Find largest video file
|
||||
video_files = []
|
||||
stack = [path]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
stop_event = threading.Event()
|
||||
try:
|
||||
for f in ap.rglob("*"):
|
||||
af = AsyncPath(f)
|
||||
if await af.is_file() and Path(f).suffix.lower() in VIDEO_EXTENSIONS:
|
||||
if "sample" in Path(f).name.lower():
|
||||
continue
|
||||
video_files.append((str(f), (await af.stat()).st_size))
|
||||
child_dirs, child_files = await asyncio.to_thread(
|
||||
_scandir_split,
|
||||
current,
|
||||
stop_event,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
stop_event.set()
|
||||
raise
|
||||
except OSError, PermissionError:
|
||||
pass
|
||||
continue
|
||||
|
||||
stack.extend(child_dirs)
|
||||
for f in child_files:
|
||||
if f.suffix.lower() not in VIDEO_EXTENSIONS:
|
||||
continue
|
||||
if "sample" in f.name.lower():
|
||||
continue
|
||||
af = AsyncPath(f)
|
||||
try:
|
||||
video_files.append((f.as_posix(), (await af.stat()).st_size))
|
||||
except OSError, PermissionError:
|
||||
continue
|
||||
|
||||
if not video_files:
|
||||
_playable_file_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
video_files.sort(key=lambda x: x[1], reverse=True)
|
||||
video_files.sort(key=operator.itemgetter(1), reverse=True)
|
||||
result = video_files[0][0]
|
||||
_playable_file_cache[cache_key] = 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:
|
||||
"""Resolve a path suitable for ffmpeg stream metadata probing.
|
||||
|
||||
For regular files, returns ``playable_path`` unchanged.
|
||||
For Blu-ray control files (``*.bdmv``), returns the largest
|
||||
``BDMV/STREAM/*.m2ts`` file, which ffmpeg can usually inspect even
|
||||
when direct BDMV probing is unsupported.
|
||||
For DVD control files (``*.ifo``), returns a ``concat:`` URI that
|
||||
covers all VOBs of the largest title set, giving ffmpeg the full
|
||||
main feature to probe.
|
||||
"""
|
||||
if not playable_path:
|
||||
return None
|
||||
|
||||
if not playable_path.lower().endswith(".bdmv"):
|
||||
# For DVD control files, build a concat URI covering the main title set
|
||||
if playable_path.lower().endswith(".ifo"):
|
||||
cache_key = playable_path
|
||||
if cache_key in _bluray_probe_file_cache:
|
||||
return _bluray_probe_file_cache[cache_key]
|
||||
|
||||
playable = Path(playable_path)
|
||||
video_ts_dir = (
|
||||
playable.parent
|
||||
if playable.parent.name.upper() == "VIDEO_TS"
|
||||
else playable.parent
|
||||
)
|
||||
|
||||
# Group VOBs by title set (VTS_XX_Y.VOB)
|
||||
title_sets: dict[str, list[tuple[str, int]]] = defaultdict(list)
|
||||
try:
|
||||
stop_event = threading.Event()
|
||||
vob_files = await asyncio.to_thread(
|
||||
_scandir_files_with_suffix,
|
||||
video_ts_dir,
|
||||
{".vob"},
|
||||
stop_event,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
stop_event.set()
|
||||
raise
|
||||
except OSError, PermissionError:
|
||||
_bluray_probe_file_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
for f in vob_files:
|
||||
af = AsyncPath(f)
|
||||
name = f.name.upper()
|
||||
if not name.startswith("VTS_") or len(name) < 10:
|
||||
continue
|
||||
try:
|
||||
size = (await af.stat()).st_size
|
||||
except OSError, PermissionError:
|
||||
continue
|
||||
ts_num = name[4:6]
|
||||
title_sets[ts_num].append((f.as_posix(), size))
|
||||
|
||||
if not title_sets:
|
||||
_bluray_probe_file_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
# Pick the title set with the largest total size (main feature)
|
||||
best_ts = max(
|
||||
title_sets.keys(),
|
||||
key=lambda ts: sum(size for _, size in title_sets[ts]),
|
||||
)
|
||||
best_vobs = sorted(title_sets[best_ts], key=lambda x: x[0].upper())
|
||||
concat_uri = "concat:" + "|".join(path for path, _ in best_vobs)
|
||||
_bluray_probe_file_cache[cache_key] = concat_uri
|
||||
return concat_uri
|
||||
|
||||
return playable_path
|
||||
|
||||
cache_key = playable_path
|
||||
if cache_key in _bluray_probe_file_cache:
|
||||
return _bluray_probe_file_cache[cache_key]
|
||||
|
||||
playable = Path(playable_path)
|
||||
bdmv_dir = (
|
||||
playable.parent if playable.parent.name.upper() == "BDMV" else playable.parent
|
||||
)
|
||||
stream_dir = bdmv_dir / "STREAM"
|
||||
|
||||
ap_stream = AsyncPath(stream_dir)
|
||||
if not await ap_stream.exists() or not await ap_stream.is_dir():
|
||||
_bluray_probe_file_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
candidates: list[tuple[str, int]] = []
|
||||
stack = [stream_dir]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
stop_event = threading.Event()
|
||||
try:
|
||||
child_dirs, child_files = await asyncio.to_thread(
|
||||
_scandir_split,
|
||||
current,
|
||||
stop_event,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
stop_event.set()
|
||||
raise
|
||||
except OSError, PermissionError:
|
||||
continue
|
||||
|
||||
stack.extend(child_dirs)
|
||||
for f in child_files:
|
||||
if f.suffix.lower() != ".m2ts":
|
||||
continue
|
||||
af = AsyncPath(f)
|
||||
try:
|
||||
candidates.append((f.as_posix(), (await af.stat()).st_size))
|
||||
except OSError, PermissionError:
|
||||
continue
|
||||
|
||||
if not candidates:
|
||||
_bluray_probe_file_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
candidates.sort(key=operator.itemgetter(1), reverse=True)
|
||||
result = candidates[0][0]
|
||||
_bluray_probe_file_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
|
||||
async def find_cover_image(
|
||||
title: str, year: Optional[int], media_type: str, cover_dir: Path
|
||||
) -> Optional[str]:
|
||||
title: str, year: int | None, media_type: str, cover_dir: Path
|
||||
) -> str | None:
|
||||
"""Find a cover image for the given media item."""
|
||||
media_folder = get_media_folder_path(title, year, media_type, cover_dir)
|
||||
cover_path = media_folder / "cover.jpg"
|
||||
if await AsyncPath(cover_path).exists():
|
||||
return str(cover_path)
|
||||
return cover_path.as_posix()
|
||||
|
||||
# Legacy structure fallback
|
||||
subdir = "movies" if media_type == "movie" else "series"
|
||||
if media_type == "movie" and year:
|
||||
legacy_path = cover_dir / subdir / f"{sanitize_filename(title)} ({year}).jpg"
|
||||
if await AsyncPath(legacy_path).exists():
|
||||
return str(legacy_path)
|
||||
return legacy_path.as_posix()
|
||||
|
||||
legacy_path = cover_dir / subdir / f"{sanitize_filename(title)}.jpg"
|
||||
if await AsyncPath(legacy_path).exists():
|
||||
return str(legacy_path)
|
||||
return legacy_path.as_posix()
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
TMDb Client - Fetch movie and TV series metadata from The Movie Database (TMDb).
|
||||
"""
|
||||
"""TMDb Client - Fetch movie and TV series metadata from The Movie Database (TMDb)."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
@@ -10,17 +8,16 @@ import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
import httpx
|
||||
from aiopathlib import AsyncPath
|
||||
|
||||
from mediahive.models.tmdb import (
|
||||
CastMember,
|
||||
CastCredit,
|
||||
EpisodeInfo,
|
||||
Info,
|
||||
Person,
|
||||
SeasonInfo,
|
||||
SimilarMedia,
|
||||
)
|
||||
|
||||
# TMDb API configuration
|
||||
@@ -28,10 +25,10 @@ TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "6bd914e6a5df1c6d1ddf622cf2dbc232"
|
||||
TMDB_API_BASE = "https://api.themoviedb.org/3"
|
||||
|
||||
# API response cache directory (can be overridden via set_cache_dir)
|
||||
_tmdb_cache_dir: Optional[Path] = None
|
||||
_tmdb_cache_dir: Path | None = None
|
||||
|
||||
# Persistent async HTTP client for connection reuse
|
||||
_http_client: Optional[httpx.AsyncClient] = None
|
||||
_http_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def set_cache_dir(cache_dir: Path) -> None:
|
||||
@@ -61,11 +58,19 @@ def _get_http_client() -> httpx.AsyncClient:
|
||||
return _http_client
|
||||
|
||||
|
||||
async def close_http_client() -> None:
|
||||
"""Close the persistent TMDb HTTP client if it was created."""
|
||||
global _http_client
|
||||
if _http_client is not None:
|
||||
await _http_client.aclose()
|
||||
_http_client = None
|
||||
|
||||
|
||||
# Sentinel value to distinguish "cached None" from "not in cache"
|
||||
_NOT_FOUND = object()
|
||||
|
||||
|
||||
def _get_cache_path(endpoint: str, params: Dict[str, str]) -> Path:
|
||||
def _get_cache_path(endpoint: str, params: dict[str, str]) -> Path:
|
||||
"""Generate a cache file path for an API request."""
|
||||
# Create a stable cache key from endpoint and sorted params
|
||||
cache_key = endpoint + "?" + urllib.parse.urlencode(sorted(params.items()))
|
||||
@@ -84,20 +89,17 @@ async def _load_from_cache(cache_path: Path):
|
||||
if data.get("_cached_none"):
|
||||
return None
|
||||
return data
|
||||
except Exception:
|
||||
except OSError, TypeError, json.JSONDecodeError:
|
||||
return _NOT_FOUND
|
||||
|
||||
|
||||
async def _save_to_cache(cache_path: Path, data: Optional[Dict]):
|
||||
async def _save_to_cache(cache_path: Path, data: dict | None) -> None:
|
||||
"""Save response to cache."""
|
||||
try:
|
||||
await AsyncPath(_get_cache_dir()).mkdir(parents=True, exist_ok=True)
|
||||
if data is None:
|
||||
text = json.dumps({"_cached_none": True})
|
||||
else:
|
||||
text = json.dumps(data)
|
||||
text = json.dumps({"_cached_none": True}) if data is None else json.dumps(data)
|
||||
await AsyncPath(cache_path).write_text(text, encoding="utf-8")
|
||||
except Exception:
|
||||
except OSError, TypeError, ValueError:
|
||||
pass # Cache write failures are not critical
|
||||
|
||||
|
||||
@@ -105,8 +107,8 @@ async def _save_to_cache(cache_path: Path, data: Optional[Dict]):
|
||||
|
||||
|
||||
async def tmdb_api_request(
|
||||
endpoint: str, params: Optional[Dict[str, str]] = None
|
||||
) -> Optional[Dict[str, str]]:
|
||||
endpoint: str, params: dict[str, str] | None = None
|
||||
) -> dict[str, str] | None:
|
||||
"""Make a request to the TMDb API with disk caching and connection reuse."""
|
||||
params = params or {}
|
||||
|
||||
@@ -139,35 +141,30 @@ async def tmdb_api_request(
|
||||
# Cache the failure (None) to avoid retrying
|
||||
await _save_to_cache(cache_path, None)
|
||||
return None
|
||||
except Exception:
|
||||
except httpx.HTTPError:
|
||||
# Don't cache network errors - they may be transient
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_movie_details(movie_id: int) -> Optional[Dict]:
|
||||
"""Fetch detailed movie info including credits, similar, keywords, and alternative titles."""
|
||||
async def fetch_movie_details(movie_id: int) -> dict | None:
|
||||
"""Fetch movie info including credits, keywords, alt titles, and collection."""
|
||||
# Use append_to_response to get multiple data in one request
|
||||
data = await tmdb_api_request(
|
||||
return await tmdb_api_request(
|
||||
f"/movie/{movie_id}",
|
||||
{"append_to_response": "credits,similar,keywords,alternative_titles"},
|
||||
{"append_to_response": "credits,keywords,alternative_titles"},
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def fetch_series_details(series_id: int) -> Optional[Dict]:
|
||||
"""Fetch detailed TV series info including credits, similar, and keywords."""
|
||||
async def fetch_series_details(series_id: int) -> dict | None:
|
||||
"""Fetch detailed TV series info including credits and keywords."""
|
||||
# Use append_to_response to get multiple data in one request
|
||||
data = await tmdb_api_request(
|
||||
f"/tv/{series_id}", {"append_to_response": "credits,similar,keywords"}
|
||||
return await tmdb_api_request(
|
||||
f"/tv/{series_id}", {"append_to_response": "credits,keywords"}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def fetch_season_details(
|
||||
series_id: int, season_number: int
|
||||
) -> Optional[SeasonInfo]:
|
||||
"""
|
||||
Fetch detailed season info including all episodes.
|
||||
async def fetch_season_details(series_id: int, season_number: int) -> SeasonInfo | None:
|
||||
"""Fetch detailed season info including all episodes.
|
||||
|
||||
Returns season metadata with episode list including:
|
||||
- Episode names, overviews, air dates
|
||||
@@ -217,9 +214,21 @@ async def fetch_season_details(
|
||||
)
|
||||
|
||||
|
||||
def _map_person_gender(value: object) -> str | None:
|
||||
"""Map TMDb person gender codes to stable string values."""
|
||||
if value == 1:
|
||||
return "female"
|
||||
if value == 2:
|
||||
return "male"
|
||||
if value == 3:
|
||||
return "non_binary"
|
||||
if value == 0:
|
||||
return "unknown"
|
||||
return None
|
||||
|
||||
|
||||
def _generate_title_variants(words: list[str], min_words: int = 2) -> list[str]:
|
||||
"""
|
||||
Generate title variants by progressively removing words from both ends.
|
||||
"""Generate title variants by progressively removing words from both ends.
|
||||
|
||||
Order: full title, then shorter from end, then shorter from start.
|
||||
"""
|
||||
@@ -232,12 +241,15 @@ def _generate_title_variants(words: list[str], min_words: int = 2) -> list[str]:
|
||||
variants.append(" ".join(words))
|
||||
|
||||
# Then try removing from end (most common: edition names at end)
|
||||
for num_words in range(len(words) - 1, min_words - 1, -1):
|
||||
variants.append(" ".join(words[:num_words]))
|
||||
variants.extend(
|
||||
" ".join(words[:num_words])
|
||||
for num_words in range(len(words) - 1, min_words - 1, -1)
|
||||
)
|
||||
|
||||
# Then try removing from start (garbage at beginning)
|
||||
for start in range(1, len(words) - min_words + 1):
|
||||
variants.append(" ".join(words[start:]))
|
||||
variants.extend(
|
||||
" ".join(words[start:]) for start in range(1, len(words) - min_words + 1)
|
||||
)
|
||||
|
||||
# Finally try middle portions (remove from both ends)
|
||||
for start in range(1, len(words) - min_words):
|
||||
@@ -259,8 +271,7 @@ def _normalize_for_match(text: str) -> set[str]:
|
||||
|
||||
|
||||
def _titles_match(original_title: str, tmdb_title: str, search_query: str) -> bool:
|
||||
"""
|
||||
Check if TMDb result title reasonably matches our original title.
|
||||
"""Check if TMDb result title reasonably matches our original title.
|
||||
|
||||
Uses word overlap to verify the result is relevant, preventing
|
||||
false matches from short queries like "The" or just a year.
|
||||
@@ -312,11 +323,8 @@ def _titles_match(original_title: str, tmdb_title: str, search_query: str) -> bo
|
||||
)
|
||||
|
||||
|
||||
async def _search_movie_with_fallbacks(
|
||||
title: str, year: Optional[int]
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Search for a movie with progressive title shortening fallbacks.
|
||||
async def _search_movie_with_fallbacks(title: str, year: int | None) -> dict | None:
|
||||
"""Search for a movie with progressive title shortening fallbacks.
|
||||
|
||||
PTN often includes edition names (THEATRICAL CUT, DIRECTOR'S CUT, etc.)
|
||||
or garbage at the beginning/end of the title.
|
||||
@@ -327,7 +335,7 @@ async def _search_movie_with_fallbacks(
|
||||
variants = _generate_title_variants(words, min_words=2)
|
||||
|
||||
def _result_matches(
|
||||
top_result: Dict, original_title: str, search_query: str
|
||||
top_result: dict, original_title: str, search_query: str
|
||||
) -> bool:
|
||||
"""Check if result matches against either title or original_title."""
|
||||
tmdb_title = top_result.get("title", "")
|
||||
@@ -363,7 +371,10 @@ async def _search_movie_with_fallbacks(
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_movie_info(title: str, year: Optional[int] = None) -> Optional[Info]:
|
||||
async def fetch_movie_info(
|
||||
title: str,
|
||||
year: int | None = None,
|
||||
) -> tuple[Info, str | None, str | None, dict[int, Person]] | None:
|
||||
"""Fetch comprehensive movie info from TMDb."""
|
||||
data = await _search_movie_with_fallbacks(title, year)
|
||||
|
||||
@@ -373,20 +384,24 @@ async def fetch_movie_info(title: str, year: Optional[int] = None) -> Optional[I
|
||||
result = data["results"][0]
|
||||
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)
|
||||
if not details:
|
||||
# Fall back to basic info from search
|
||||
return Info(
|
||||
return (
|
||||
Info(
|
||||
tmdb_id=movie_id,
|
||||
title=result.get("title"),
|
||||
original_title=result.get("original_title"),
|
||||
original_language=result.get("original_language"),
|
||||
rating=result.get("vote_average"),
|
||||
vote_count=result.get("vote_count"),
|
||||
overview=result.get("overview"),
|
||||
poster_path=result.get("poster_path"),
|
||||
backdrop_path=result.get("backdrop_path"),
|
||||
release_date=result.get("release_date"),
|
||||
),
|
||||
result.get("poster_path"),
|
||||
result.get("backdrop_path"),
|
||||
{},
|
||||
)
|
||||
|
||||
# Extract genres
|
||||
@@ -410,55 +425,66 @@ async def fetch_movie_info(title: str, year: Optional[int] = None) -> Optional[I
|
||||
alt_titles_set.discard(orig_title)
|
||||
alternative_titles = sorted(alt_titles_set) if alt_titles_set else None
|
||||
|
||||
# Extract top cast (limit to 10)
|
||||
credits = details.get("credits", {})
|
||||
cast_data = credits.get("cast", [])[:10]
|
||||
cast = [
|
||||
CastMember(
|
||||
name=c["name"],
|
||||
character=c.get("character", ""),
|
||||
# Extract full cast
|
||||
credits_data = details.get("credits", {})
|
||||
cast_data = credits_data.get("cast", [])
|
||||
cast: list[CastCredit] = []
|
||||
people: dict[int, Person] = {}
|
||||
for c in cast_data:
|
||||
person_id = c.get("id")
|
||||
cast.append(
|
||||
CastCredit(
|
||||
character=c.get("character", "") or None,
|
||||
id=person_id if isinstance(person_id, int) else None,
|
||||
)
|
||||
)
|
||||
if isinstance(person_id, int):
|
||||
people[person_id] = Person(
|
||||
name=c.get("name") or "",
|
||||
profile_path=c.get("profile_path"),
|
||||
gender=_map_person_gender(c.get("gender")),
|
||||
)
|
||||
for c in cast_data
|
||||
]
|
||||
|
||||
# Extract director from crew
|
||||
crew = credits.get("crew", [])
|
||||
crew = credits_data.get("crew", [])
|
||||
directors = [c["name"] for c in crew if c.get("job") == "Director"]
|
||||
director = directors[0] if directors else None
|
||||
|
||||
# Extract similar movies (limit to 10)
|
||||
similar_data = details.get("similar", {}).get("results", [])[:10]
|
||||
similar = [
|
||||
SimilarMedia(id=s["id"], title=s["title"], poster_path=s.get("poster_path"))
|
||||
for s in similar_data
|
||||
]
|
||||
collection_data = details.get("belongs_to_collection")
|
||||
collection = None
|
||||
if isinstance(collection_data, dict):
|
||||
collection_name = collection_data.get("name")
|
||||
if isinstance(collection_name, str):
|
||||
collection = collection_name or None
|
||||
|
||||
return Info(
|
||||
return (
|
||||
Info(
|
||||
tmdb_id=movie_id,
|
||||
title=details.get("title"),
|
||||
original_title=details.get("original_title"),
|
||||
original_language=details.get("original_language"),
|
||||
alternative_titles=alternative_titles,
|
||||
rating=details.get("vote_average"),
|
||||
vote_count=details.get("vote_count"),
|
||||
overview=details.get("overview"),
|
||||
genres=genres if genres else None,
|
||||
genres=genres or None,
|
||||
release_date=details.get("release_date"),
|
||||
runtime=details.get("runtime"),
|
||||
collection=collection,
|
||||
status=details.get("status"),
|
||||
tagline=details.get("tagline"),
|
||||
poster_path=details.get("poster_path"),
|
||||
backdrop_path=details.get("backdrop_path"),
|
||||
similar=similar if similar else None,
|
||||
keywords=keywords if keywords else None,
|
||||
cast=cast if cast else None,
|
||||
keywords=keywords or None,
|
||||
cast=cast or None,
|
||||
director=director,
|
||||
),
|
||||
details.get("poster_path"),
|
||||
details.get("backdrop_path"),
|
||||
people,
|
||||
)
|
||||
|
||||
|
||||
async def _search_series_with_fallbacks(title: str) -> Optional[Dict]:
|
||||
"""
|
||||
Search for a TV series with progressive title shortening fallbacks.
|
||||
async def _search_series_with_fallbacks(title: str) -> dict | None:
|
||||
"""Search for a TV series with progressive title shortening fallbacks.
|
||||
|
||||
PTN often includes extra text in the title at beginning or end.
|
||||
Results are validated with fuzzy matching to prevent false positives.
|
||||
@@ -479,7 +505,9 @@ async def _search_series_with_fallbacks(title: str) -> Optional[Dict]:
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_series_info(title: str) -> Optional[Info]:
|
||||
async def fetch_series_info(
|
||||
title: str,
|
||||
) -> tuple[Info, str | None, str | None, dict[int, Person]] | None:
|
||||
"""Fetch comprehensive TV series info from TMDb."""
|
||||
data = await _search_series_with_fallbacks(title)
|
||||
|
||||
@@ -489,19 +517,23 @@ async def fetch_series_info(title: str) -> Optional[Info]:
|
||||
result = data["results"][0]
|
||||
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)
|
||||
if not details:
|
||||
# Fall back to basic info from search
|
||||
return Info(
|
||||
return (
|
||||
Info(
|
||||
tmdb_id=series_id,
|
||||
title=result.get("name"),
|
||||
original_title=result.get("original_name"),
|
||||
original_language=result.get("original_language"),
|
||||
rating=result.get("vote_average"),
|
||||
vote_count=result.get("vote_count"),
|
||||
overview=result.get("overview"),
|
||||
poster_path=result.get("poster_path"),
|
||||
backdrop_path=result.get("backdrop_path"),
|
||||
),
|
||||
result.get("poster_path"),
|
||||
result.get("backdrop_path"),
|
||||
{},
|
||||
)
|
||||
|
||||
# Extract genres
|
||||
@@ -511,17 +543,25 @@ async def fetch_series_info(title: str) -> Optional[Info]:
|
||||
keywords_data = details.get("keywords", {}).get("results", [])
|
||||
keywords = [k["name"] for k in keywords_data]
|
||||
|
||||
# Extract top cast (limit to 10)
|
||||
credits = details.get("credits", {})
|
||||
cast_data = credits.get("cast", [])[:10]
|
||||
cast = [
|
||||
CastMember(
|
||||
name=c["name"],
|
||||
character=c.get("character", ""),
|
||||
# Extract full cast
|
||||
credits_data = details.get("credits", {})
|
||||
cast_data = credits_data.get("cast", [])
|
||||
cast: list[CastCredit] = []
|
||||
people: dict[int, Person] = {}
|
||||
for c in cast_data:
|
||||
person_id = c.get("id")
|
||||
cast.append(
|
||||
CastCredit(
|
||||
character=c.get("character", "") or None,
|
||||
id=person_id if isinstance(person_id, int) else None,
|
||||
)
|
||||
)
|
||||
if isinstance(person_id, int):
|
||||
people[person_id] = Person(
|
||||
name=c.get("name") or "",
|
||||
profile_path=c.get("profile_path"),
|
||||
gender=_map_person_gender(c.get("gender")),
|
||||
)
|
||||
for c in cast_data
|
||||
]
|
||||
|
||||
# Extract creators
|
||||
creators = [c["name"] for c in details.get("created_by", [])]
|
||||
@@ -529,34 +569,30 @@ async def fetch_series_info(title: str) -> Optional[Info]:
|
||||
# Extract 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"], poster_path=s.get("poster_path"))
|
||||
for s in similar_data
|
||||
]
|
||||
|
||||
# Get first air date
|
||||
first_air_date = details.get("first_air_date")
|
||||
|
||||
return Info(
|
||||
return (
|
||||
Info(
|
||||
tmdb_id=series_id,
|
||||
title=details.get("name"),
|
||||
original_title=details.get("original_name"),
|
||||
original_language=details.get("original_language"),
|
||||
rating=details.get("vote_average"),
|
||||
vote_count=details.get("vote_count"),
|
||||
overview=details.get("overview"),
|
||||
genres=genres if genres else None,
|
||||
genres=genres or None,
|
||||
release_date=first_air_date,
|
||||
status=details.get("status"),
|
||||
tagline=details.get("tagline"),
|
||||
poster_path=details.get("poster_path"),
|
||||
backdrop_path=details.get("backdrop_path"),
|
||||
similar=similar if similar else None,
|
||||
keywords=keywords if keywords else None,
|
||||
cast=cast if cast else None,
|
||||
creators=creators if creators else None,
|
||||
keywords=keywords or None,
|
||||
cast=cast or None,
|
||||
creators=creators or None,
|
||||
number_of_seasons=details.get("number_of_seasons"),
|
||||
number_of_episodes=details.get("number_of_episodes"),
|
||||
networks=networks if networks else None,
|
||||
networks=networks or None,
|
||||
),
|
||||
details.get("poster_path"),
|
||||
details.get("backdrop_path"),
|
||||
people,
|
||||
)
|
||||
|
||||
@@ -1,86 +1,170 @@
|
||||
"""Utility functions for paths, sizes, and timestamps."""
|
||||
|
||||
import time
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
from aiopathlib import AsyncPath
|
||||
|
||||
|
||||
# Default output folder name (created at common root of scanned paths)
|
||||
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 = {
|
||||
"2160p": 4,
|
||||
"8K": 5,
|
||||
"4K": 4,
|
||||
"FHD": 3,
|
||||
"HD": 2,
|
||||
"SD": 1,
|
||||
# Backward compatibility for existing snapshot data
|
||||
"4320p": 5,
|
||||
"2160p": 4,
|
||||
"UHD": 4,
|
||||
"1080p": 3,
|
||||
"1080i": 3,
|
||||
"720p": 2,
|
||||
"576p": 1,
|
||||
"480p": 1,
|
||||
}
|
||||
|
||||
|
||||
async def get_added_timestamp(path: Path) -> Optional[int]:
|
||||
"""
|
||||
Get the timestamp when a torrent was added to the collection.
|
||||
def build_movie_id(title: str | None, year: int | None) -> str:
|
||||
"""Build a readable movie ID slug from the title and year."""
|
||||
normalized_title = unicodedata.normalize("NFKD", title or "")
|
||||
ascii_title = normalized_title.encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", ascii_title.lower()).strip("-")
|
||||
slug = slug or "movie"
|
||||
if year:
|
||||
return f"{slug}-{year}"
|
||||
return slug
|
||||
|
||||
Heuristic:
|
||||
- For directories: use ctime (most accurate for torrent folder creation)
|
||||
- For files: use atime unless it's too close to current time (suggesting
|
||||
the filesystem updates atime on reads), otherwise use max(mtime, ctime)
|
||||
|
||||
def build_series_id(title: str | None) -> str:
|
||||
"""Build a readable series ID slug from the title."""
|
||||
normalized_title = unicodedata.normalize("NFKD", title or "")
|
||||
ascii_title = normalized_title.encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", ascii_title.lower()).strip("-")
|
||||
return slug or "series"
|
||||
|
||||
|
||||
def classify_resolution_from_dimensions(
|
||||
width: int | None, height: int | None
|
||||
) -> str | None:
|
||||
"""Map raw frame dimensions to SD/HD/FHD/4K/8K buckets.
|
||||
|
||||
Uses the smallest standard frame bucket that can contain the source frame,
|
||||
which keeps cropped cinematic encodes in their expected class.
|
||||
"""
|
||||
if not width or not height or width <= 0 or height <= 0:
|
||||
return None
|
||||
|
||||
long_edge = max(width, height)
|
||||
short_edge = min(width, height)
|
||||
|
||||
buckets = [
|
||||
(1024, 576, "SD"),
|
||||
(1280, 720, "HD"),
|
||||
(1920, 1080, "FHD"),
|
||||
(4096, 2160, "4K"),
|
||||
(8192, 4320, "8K"),
|
||||
]
|
||||
|
||||
for max_w, max_h, label in buckets:
|
||||
if long_edge <= max_w and short_edge <= max_h:
|
||||
return label
|
||||
|
||||
return "8K"
|
||||
|
||||
|
||||
def normalize_resolution_label(value: str | None) -> str | None:
|
||||
"""Normalize PTN/legacy resolution text into SD/HD/FHD/4K/8K labels."""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
normalized = str(value).strip().upper()
|
||||
mapping = {
|
||||
"SD": "SD",
|
||||
"HD": "HD",
|
||||
"FHD": "FHD",
|
||||
"4K": "4K",
|
||||
"8K": "8K",
|
||||
"4320P": "8K",
|
||||
"2160P": "4K",
|
||||
"UHD": "4K",
|
||||
"1080P": "FHD",
|
||||
"1080I": "FHD",
|
||||
"720P": "HD",
|
||||
"576P": "SD",
|
||||
"480P": "SD",
|
||||
}
|
||||
return mapping.get(normalized)
|
||||
|
||||
|
||||
async def get_added_timestamp(path: Path) -> int | None:
|
||||
"""Get the timestamp when a torrent was added to the collection.
|
||||
|
||||
Best-effort rule:
|
||||
- On Windows: use ctime (creation-time semantics)
|
||||
- On other OSes: use mtime (ctime is metadata-change time on Unix)
|
||||
|
||||
Returns:
|
||||
Unix timestamp as int, or None if path doesn't exist
|
||||
|
||||
"""
|
||||
ap = AsyncPath(path)
|
||||
try:
|
||||
stat_info = await ap.stat()
|
||||
except OSError, PermissionError:
|
||||
return None
|
||||
|
||||
if await ap.is_dir():
|
||||
if os.name == "nt":
|
||||
return int(stat_info.st_ctime)
|
||||
|
||||
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)
|
||||
return int(stat_info.st_mtime)
|
||||
|
||||
|
||||
async def get_directory_size(path: Path) -> int:
|
||||
"""Calculate total size of a directory recursively."""
|
||||
ap = AsyncPath(path)
|
||||
total = 0
|
||||
def get_directory_size(path: Path) -> int:
|
||||
"""Calculate total size using scandir recursion in a sync worker."""
|
||||
try:
|
||||
if await ap.is_file():
|
||||
return (await ap.stat()).st_size
|
||||
for item in ap.rglob("*"):
|
||||
if await AsyncPath(item).is_file():
|
||||
total += (await AsyncPath(item).stat()).st_size
|
||||
if path.is_file():
|
||||
return path.stat().st_size
|
||||
except OSError, PermissionError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
total = 0
|
||||
stack = [path]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
try:
|
||||
with os.scandir(current) as entries:
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
stack.append(Path(entry.path))
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
total += entry.stat(follow_symlinks=False).st_size
|
||||
except OSError:
|
||||
continue
|
||||
except OSError, PermissionError:
|
||||
continue
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Format size in human-readable format."""
|
||||
size = float(size_bytes)
|
||||
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"
|
||||
if size < 1024:
|
||||
return f"{size:.2f} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.2f} PB"
|
||||
|
||||
|
||||
async def find_common_root(paths: List[Path]) -> Optional[Path]:
|
||||
"""
|
||||
Find the common root directory for a list of paths.
|
||||
async def find_common_root(paths: list[Path]) -> Path | None:
|
||||
"""Find the common root directory for a list of paths.
|
||||
|
||||
Returns None if paths are on different drives/mounts or have no common ancestor.
|
||||
"""
|
||||
@@ -126,7 +210,7 @@ async def find_common_root(paths: List[Path]) -> Optional[Path]:
|
||||
|
||||
# Find common prefix
|
||||
common_parts = []
|
||||
for parts in zip(*all_parts):
|
||||
for parts in zip(*all_parts, strict=False):
|
||||
if len(set(parts)) == 1:
|
||||
common_parts.append(parts[0])
|
||||
else:
|
||||
@@ -138,43 +222,38 @@ async def find_common_root(paths: List[Path]) -> Optional[Path]:
|
||||
return Path(*common_parts)
|
||||
|
||||
|
||||
def make_relative_path(
|
||||
path: Optional[str], root: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Convert an absolute path to a path relative to the given root.
|
||||
def make_relative_path(path: str | None, root: str | None = None) -> str | None:
|
||||
"""Convert an absolute path to a posix-style path relative to the given root.
|
||||
|
||||
If root is None, returns the path unchanged.
|
||||
If root is None, returns the path as a posix string unchanged.
|
||||
"""
|
||||
if path is None:
|
||||
return None
|
||||
p = Path(path)
|
||||
if root is None:
|
||||
return path
|
||||
root_str = str(root).rstrip("/")
|
||||
if path.startswith(root_str):
|
||||
rel = path[len(root_str) :]
|
||||
return rel.lstrip("/")
|
||||
return path
|
||||
return p.as_posix()
|
||||
try:
|
||||
return p.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
return p.as_posix()
|
||||
|
||||
|
||||
def sanitize_filename(name: str) -> str:
|
||||
"""Sanitize a string for use as a filename."""
|
||||
for char in ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]:
|
||||
name = name.replace(char, "_")
|
||||
name = name.strip(". ")
|
||||
return name
|
||||
return name.strip(". ")
|
||||
|
||||
|
||||
def get_media_folder_name(title: str, year: Optional[int], media_type: str) -> str:
|
||||
def get_media_folder_name(title: str, year: int | None, media_type: str) -> str:
|
||||
"""Get the folder name for a media item."""
|
||||
sanitized_title = sanitize_filename(title)
|
||||
if media_type == "movie" and year:
|
||||
return f"{sanitized_title} ({year})"
|
||||
return sanitized_title
|
||||
if media_type == "movie":
|
||||
return build_movie_id(title, year)
|
||||
return build_series_id(title)
|
||||
|
||||
|
||||
def get_media_folder_path(
|
||||
title: str, year: Optional[int], media_type: str, cover_dir: Path
|
||||
title: str, year: int | None, media_type: str, cover_dir: Path
|
||||
) -> Path:
|
||||
"""Get the full path to a media item's folder."""
|
||||
subdir = "movies" if media_type == "movie" else "series"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
In-memory index store with disk snapshot and WebSocket broadcast.
|
||||
"""In-memory index store with disk snapshot and WebSocket broadcast.
|
||||
|
||||
The IndexStore is the single source of truth for the media index.
|
||||
All mutations happen synchronously in the asyncio event loop — no locks needed.
|
||||
@@ -8,33 +7,34 @@ debounced background task.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import msgspec
|
||||
from aiopathlib import AsyncPath
|
||||
from fastapi import WebSocket
|
||||
|
||||
from mediahive.models.data import (
|
||||
Episode,
|
||||
IndexSnapshot,
|
||||
MediaStats,
|
||||
Movie,
|
||||
Season,
|
||||
Series,
|
||||
TaskInfo,
|
||||
Torrent,
|
||||
)
|
||||
from mediahive.models.events import Remove, Task, Upsert
|
||||
from mediahive.models.protocol import (
|
||||
WsInit,
|
||||
WsInitData,
|
||||
)
|
||||
from mediahive.models.tmdb import Person
|
||||
|
||||
logger = logging.getLogger("mediahive.index_store")
|
||||
|
||||
# Debounce interval for writing snapshots to disk (seconds)
|
||||
SNAPSHOT_DEBOUNCE = 5.0
|
||||
# Debounce interval for rebuilding the in-memory API snapshot (seconds)
|
||||
SNAPSHOT_CACHE_DEBOUNCE = 0.25
|
||||
|
||||
|
||||
class IndexStore:
|
||||
@@ -48,20 +48,38 @@ class IndexStore:
|
||||
# This would make IndexStore testable without FastAPI's WebSocket.
|
||||
"""
|
||||
|
||||
def __init__(self, snapshot_path: Path, media_root: Optional[str] = None):
|
||||
def __init__(
|
||||
self,
|
||||
snapshot_path: Path,
|
||||
) -> None:
|
||||
self.snapshot_path = snapshot_path
|
||||
self.media_root = media_root
|
||||
self.snapshot_loaded = False
|
||||
|
||||
# The index: keyed by item id
|
||||
self.movies: dict[str, Movie] = {}
|
||||
self.series: dict[str, Series] = {}
|
||||
self.people: dict[int, Person] = {}
|
||||
self._movie_tmdb_ids: dict[int, str] = {}
|
||||
self._series_tmdb_ids: dict[int, str] = {}
|
||||
|
||||
# Connected WebSocket clients
|
||||
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
|
||||
self._snapshot_dirty = False
|
||||
self._snapshot_task: Optional[asyncio.Task] = None
|
||||
self._snapshot_task: asyncio.Task | None = None
|
||||
|
||||
# In-memory API snapshot cache (served by get_full_index)
|
||||
self._snapshot_cache_dirty = True
|
||||
self._snapshot_cache_task: asyncio.Task | None = None
|
||||
self._cached_snapshot = IndexSnapshot(
|
||||
generated_at=datetime.now().isoformat(),
|
||||
movies={},
|
||||
series={},
|
||||
people={},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Persistence
|
||||
@@ -70,41 +88,313 @@ class IndexStore:
|
||||
async def load_snapshot(self) -> None:
|
||||
"""Load index from disk snapshot (recovery on startup)."""
|
||||
ap = AsyncPath(self.snapshot_path)
|
||||
self.snapshot_loaded = False
|
||||
if not await ap.exists():
|
||||
logger.info("No snapshot found at %s, starting fresh", self.snapshot_path)
|
||||
self._schedule_snapshot_cache_refresh()
|
||||
return
|
||||
try:
|
||||
data = msgspec.json.decode(await ap.read_bytes(), type=IndexSnapshot)
|
||||
for m in data.movies:
|
||||
self.movies[m.id] = m
|
||||
for s in data.series:
|
||||
self.series[s.id] = s
|
||||
logger.info(
|
||||
"Loaded snapshot: %d movies, %d series",
|
||||
len(self.movies),
|
||||
len(self.series),
|
||||
raw = await ap.read_bytes()
|
||||
loaded_movies, loaded_series, loaded_people = await asyncio.to_thread(
|
||||
self._load_snapshot_sync,
|
||||
raw,
|
||||
)
|
||||
self._merge_loaded_snapshot(
|
||||
loaded_movies,
|
||||
loaded_series,
|
||||
loaded_people,
|
||||
)
|
||||
self._rebuild_tmdb_indexes()
|
||||
self.snapshot_loaded = True
|
||||
|
||||
self._schedule_snapshot_cache_refresh()
|
||||
except Exception:
|
||||
logger.exception("Failed to load snapshot from %s", self.snapshot_path)
|
||||
|
||||
def _load_snapshot_sync(
|
||||
self,
|
||||
raw: bytes,
|
||||
) -> tuple[dict[str, Movie], dict[str, Series], dict[int, Person]]:
|
||||
"""Parse snapshot bytes in a thread-pool context."""
|
||||
data = msgspec.json.decode(raw, type=IndexSnapshot)
|
||||
loaded_movies = dict(data.movies)
|
||||
loaded_series = dict(data.series)
|
||||
loaded_people = dict(data.people)
|
||||
logger.info(
|
||||
"Loaded snapshot: %d movies, %d series, %d people",
|
||||
len(loaded_movies),
|
||||
len(loaded_series),
|
||||
len(loaded_people),
|
||||
)
|
||||
return loaded_movies, loaded_series, loaded_people
|
||||
|
||||
def _merge_loaded_snapshot(
|
||||
self,
|
||||
movies: dict[str, Movie],
|
||||
series: dict[str, Series],
|
||||
people: dict[int, Person],
|
||||
) -> None:
|
||||
"""Merge loaded snapshot items without overriding newer in-memory updates."""
|
||||
for item_id, movie in movies.items():
|
||||
if item_id not in self.movies:
|
||||
self.movies[item_id] = movie
|
||||
for item_id, show in series.items():
|
||||
if item_id not in self.series:
|
||||
self.series[item_id] = show
|
||||
for person_id, person in people.items():
|
||||
self.people[person_id] = person
|
||||
|
||||
@staticmethod
|
||||
def _get_tmdb_id(item: Movie | Series) -> int | None:
|
||||
if item.info is None:
|
||||
return None
|
||||
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:
|
||||
"""Rebuild TMDb id lookup maps from the current in-memory items."""
|
||||
self._movie_tmdb_ids.clear()
|
||||
self._series_tmdb_ids.clear()
|
||||
for item_id, movie in self.movies.items():
|
||||
tmdb_id = self._get_tmdb_id(movie)
|
||||
if tmdb_id is not None:
|
||||
self._movie_tmdb_ids[tmdb_id] = item_id
|
||||
for item_id, series in self.series.items():
|
||||
tmdb_id = self._get_tmdb_id(series)
|
||||
if tmdb_id is not None:
|
||||
self._series_tmdb_ids[tmdb_id] = item_id
|
||||
|
||||
def _dedupe_tmdb_duplicates(self) -> None:
|
||||
"""Remove duplicate entries that point at the same TMDb item."""
|
||||
seen_movies: dict[int, str] = {}
|
||||
for item_id, movie in list(self.movies.items()):
|
||||
tmdb_id = self._get_tmdb_id(movie)
|
||||
if tmdb_id is None:
|
||||
continue
|
||||
existing_id = seen_movies.get(tmdb_id)
|
||||
if existing_id is None:
|
||||
seen_movies[tmdb_id] = item_id
|
||||
continue
|
||||
if existing_id != item_id:
|
||||
self.movies.pop(item_id, None)
|
||||
|
||||
seen_series: dict[int, str] = {}
|
||||
for item_id, series in list(self.series.items()):
|
||||
tmdb_id = self._get_tmdb_id(series)
|
||||
if tmdb_id is None:
|
||||
continue
|
||||
existing_id = seen_series.get(tmdb_id)
|
||||
if existing_id is None:
|
||||
seen_series[tmdb_id] = item_id
|
||||
continue
|
||||
if existing_id != item_id:
|
||||
self.series.pop(item_id, None)
|
||||
|
||||
self._rebuild_tmdb_indexes()
|
||||
|
||||
def _collapse_movie_tmdb_duplicates(self, tmdb_id: int, keep_id: str) -> None:
|
||||
"""Fold other entries that share a TMDb id into the kept one."""
|
||||
for item_id, movie in list(self.movies.items()):
|
||||
if item_id == keep_id:
|
||||
continue
|
||||
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)
|
||||
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:
|
||||
"""Fold other entries that share a TMDb id into the kept one."""
|
||||
for item_id, series in list(self.series.items()):
|
||||
if item_id == keep_id:
|
||||
continue
|
||||
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)
|
||||
if self._series_tmdb_ids.get(tmdb_id) == item_id:
|
||||
self._series_tmdb_ids[tmdb_id] = keep_id
|
||||
|
||||
async def _write_snapshot(self) -> None:
|
||||
"""Write current index to disk (called from debounce task)."""
|
||||
snapshot = self._build_snapshot()
|
||||
|
||||
await AsyncPath(self.snapshot_path.parent).mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.snapshot_path.with_suffix(".tmp")
|
||||
await AsyncPath(tmp).write_bytes(
|
||||
msgspec.json.format(msgspec.json.encode(snapshot), indent=2)
|
||||
)
|
||||
# os.replace is atomic and overwrites on all platforms (unlike rename on Windows)
|
||||
await asyncio.to_thread(os.replace, tmp, self.snapshot_path)
|
||||
# Copy values on the event loop thread, then do full snapshot build + disk I/O
|
||||
# in a worker thread to keep the loop responsive.
|
||||
movies = dict(self.movies)
|
||||
series = dict(self.series)
|
||||
people = dict(self.people)
|
||||
await asyncio.to_thread(self._write_snapshot_sync, movies, series, people)
|
||||
logger.debug("Snapshot written to %s", self.snapshot_path)
|
||||
|
||||
def _write_snapshot_sync(
|
||||
self,
|
||||
movies: dict[str, Movie],
|
||||
series: dict[str, Series],
|
||||
people: dict[int, Person],
|
||||
) -> None:
|
||||
"""Build and write snapshot synchronously in a worker thread."""
|
||||
snapshot = self._build_snapshot_from_maps(movies, series, people)
|
||||
self.snapshot_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.snapshot_path.with_suffix(".tmp")
|
||||
tmp.write_bytes(msgspec.json.encode(snapshot))
|
||||
# Path.replace is atomic and overwrites on all platforms.
|
||||
tmp.replace(self.snapshot_path)
|
||||
|
||||
def _schedule_snapshot(self) -> None:
|
||||
"""Schedule a debounced snapshot write."""
|
||||
self._snapshot_dirty = True
|
||||
if self._snapshot_task is None or self._snapshot_task.done():
|
||||
self._snapshot_task = asyncio.create_task(self._snapshot_writer())
|
||||
self._schedule_snapshot_cache_refresh()
|
||||
|
||||
def _schedule_snapshot_cache_refresh(self) -> None:
|
||||
"""Schedule a debounced rebuild of the in-memory API snapshot cache."""
|
||||
self._snapshot_cache_dirty = True
|
||||
if self._snapshot_cache_task is None or self._snapshot_cache_task.done():
|
||||
self._snapshot_cache_task = asyncio.create_task(
|
||||
self._snapshot_cache_writer()
|
||||
)
|
||||
|
||||
async def _refresh_snapshot_cache_once(self) -> None:
|
||||
"""Rebuild cached snapshot once using copied store values."""
|
||||
movies = dict(self.movies)
|
||||
series = dict(self.series)
|
||||
people = dict(self.people)
|
||||
self._cached_snapshot = await asyncio.to_thread(
|
||||
self._build_snapshot_from_maps,
|
||||
movies,
|
||||
series,
|
||||
people,
|
||||
)
|
||||
|
||||
async def _snapshot_cache_writer(self) -> None:
|
||||
"""Refresh the in-memory snapshot cache while mutations are pending."""
|
||||
while True:
|
||||
await asyncio.sleep(SNAPSHOT_CACHE_DEBOUNCE)
|
||||
if self._snapshot_cache_dirty:
|
||||
self._snapshot_cache_dirty = False
|
||||
await self._refresh_snapshot_cache_once()
|
||||
else:
|
||||
break
|
||||
|
||||
async def _snapshot_writer(self) -> None:
|
||||
"""Flush to disk every SNAPSHOT_DEBOUNCE seconds while dirty."""
|
||||
@@ -118,68 +408,282 @@ class IndexStore:
|
||||
|
||||
async def flush_snapshot(self) -> None:
|
||||
"""Force-write a snapshot immediately (e.g. on shutdown)."""
|
||||
if self._snapshot_cache_task and not self._snapshot_cache_task.done():
|
||||
self._snapshot_cache_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._snapshot_cache_task
|
||||
await self._refresh_snapshot_cache_once()
|
||||
|
||||
if self._snapshot_task and not self._snapshot_task.done():
|
||||
self._snapshot_task.cancel()
|
||||
try:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._snapshot_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self._write_snapshot()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mutations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def upsert_movie(self, item: Movie) -> bool:
|
||||
"""Insert or update a movie. Returns True if it was a real change."""
|
||||
existing = self.movies.get(item.id)
|
||||
def upsert_movie(
|
||||
self,
|
||||
item_id: str,
|
||||
item: Movie,
|
||||
people: dict[int, Person] | None = None,
|
||||
scanned: list[str] | None = None,
|
||||
) -> bool:
|
||||
"""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)
|
||||
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:
|
||||
item_id = existing_id
|
||||
|
||||
existing = self.movies.get(item_id)
|
||||
if tmdb_id is not None:
|
||||
self._movie_tmdb_ids[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 msgspec.json.encode(existing) == msgspec.json.encode(item):
|
||||
return False
|
||||
self.movies[item.id] = item
|
||||
if people:
|
||||
self.people.update(people)
|
||||
self._schedule_snapshot()
|
||||
self._broadcast(Upsert(kind="movie", item=item))
|
||||
self._broadcast(
|
||||
Upsert(kind="movie", id=item_id, item=item, people=people)
|
||||
)
|
||||
return True
|
||||
return False
|
||||
self.movies[item_id] = item
|
||||
if people:
|
||||
self.people.update(people)
|
||||
self._schedule_snapshot()
|
||||
self._broadcast(Upsert(kind="movie", id=item_id, item=item, people=people))
|
||||
return True
|
||||
|
||||
def upsert_series(self, item: Series) -> bool:
|
||||
"""Insert or update a series. Returns True if it was a real change."""
|
||||
existing = self.series.get(item.id)
|
||||
def upsert_series(
|
||||
self,
|
||||
item_id: str,
|
||||
item: Series,
|
||||
people: dict[int, Person] | None = None,
|
||||
scanned: list[str] | None = None,
|
||||
) -> bool:
|
||||
"""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)
|
||||
existing_id = (
|
||||
self._series_tmdb_ids.get(tmdb_id) if tmdb_id is not None else None
|
||||
)
|
||||
if existing_id is not None and existing_id != item_id:
|
||||
item_id = existing_id
|
||||
|
||||
existing = self.series.get(item_id)
|
||||
if tmdb_id is not None:
|
||||
self._series_tmdb_ids[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 msgspec.json.encode(existing) == msgspec.json.encode(item):
|
||||
return False
|
||||
self.series[item.id] = item
|
||||
if people:
|
||||
self.people.update(people)
|
||||
self._schedule_snapshot()
|
||||
self._broadcast(Upsert(kind="series", item=item))
|
||||
self._broadcast(
|
||||
Upsert(kind="series", id=item_id, item=item, people=people)
|
||||
)
|
||||
return True
|
||||
return False
|
||||
self.series[item_id] = item
|
||||
if people:
|
||||
self.people.update(people)
|
||||
self._schedule_snapshot()
|
||||
self._broadcast(Upsert(kind="series", id=item_id, item=item, people=people))
|
||||
return True
|
||||
|
||||
def remove_movie(self, item_id: str) -> None:
|
||||
"""Remove a movie from the index and broadcast."""
|
||||
self.movies.pop(item_id, None)
|
||||
movie = self.movies.pop(item_id, None)
|
||||
if movie is None:
|
||||
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._schedule_snapshot()
|
||||
self._broadcast(Remove(kind="movie", id=item_id))
|
||||
|
||||
def remove_series(self, item_id: str) -> None:
|
||||
"""Remove a series from the index and broadcast."""
|
||||
self.series.pop(item_id, None)
|
||||
series = self.series.pop(item_id, None)
|
||||
if series is None:
|
||||
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._schedule_snapshot()
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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:
|
||||
"""Accept a WS client and send the full index as init."""
|
||||
await ws.accept()
|
||||
self._clients.add(ws)
|
||||
logger.info("WS client connected (%d total)", len(self._clients))
|
||||
# Send full current state
|
||||
msg = WsInit(
|
||||
data=WsInitData(
|
||||
movies=list(self.movies.values()),
|
||||
series=list(self.series.values()),
|
||||
)
|
||||
)
|
||||
msg = {
|
||||
"type": "init",
|
||||
"roots": {
|
||||
"": {
|
||||
"movies": dict(self.movies),
|
||||
"series": dict(self.series),
|
||||
"people": dict(self.people),
|
||||
}
|
||||
},
|
||||
}
|
||||
await ws.send_bytes(msgspec.json.encode(msg))
|
||||
|
||||
def disconnect(self, ws: WebSocket) -> None:
|
||||
@@ -189,6 +693,12 @@ class IndexStore:
|
||||
|
||||
def _broadcast(self, msg: object) -> None:
|
||||
"""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)
|
||||
dead: list[WebSocket] = []
|
||||
for ws in self._clients:
|
||||
@@ -202,7 +712,7 @@ class IndexStore:
|
||||
"""Send data to a WS client; mark as dead on failure."""
|
||||
try:
|
||||
await ws.send_bytes(data)
|
||||
except Exception:
|
||||
except OSError, RuntimeError:
|
||||
dead.append(ws)
|
||||
|
||||
def broadcast_task(self, task_info: TaskInfo) -> None:
|
||||
@@ -213,31 +723,28 @@ class IndexStore:
|
||||
# Read helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_snapshot(self) -> IndexSnapshot:
|
||||
"""Build a sorted IndexSnapshot with computed stats."""
|
||||
movies_list = sorted(
|
||||
self.movies.values(), key=lambda x: (x.title.lower(), x.year or 0)
|
||||
)
|
||||
series_list = sorted(self.series.values(), key=lambda x: x.title.lower())
|
||||
|
||||
total_movie_versions = sum(len(m.torrents) for m in movies_list)
|
||||
total_series_episodes = sum(
|
||||
sum(len(season.episodes) for season in s.seasons) for s in series_list
|
||||
)
|
||||
|
||||
def _build_snapshot_from_maps(
|
||||
self,
|
||||
movies: dict[str, Movie],
|
||||
series: dict[str, Series],
|
||||
people: dict[int, Person],
|
||||
) -> IndexSnapshot:
|
||||
"""Build a keyed IndexSnapshot from map copies."""
|
||||
return IndexSnapshot(
|
||||
generated_at=datetime.now().isoformat(),
|
||||
media_root=self.media_root,
|
||||
stats=MediaStats(
|
||||
total_movies=len(movies_list),
|
||||
total_movie_versions=total_movie_versions,
|
||||
total_series=len(series_list),
|
||||
total_series_episodes=total_series_episodes,
|
||||
),
|
||||
movies=movies_list,
|
||||
series=series_list,
|
||||
movies=movies,
|
||||
series=series,
|
||||
people=people,
|
||||
)
|
||||
|
||||
def _build_snapshot(self) -> IndexSnapshot:
|
||||
"""Build a sorted IndexSnapshot with computed stats."""
|
||||
return self._build_snapshot_from_maps(
|
||||
dict(self.movies),
|
||||
dict(self.series),
|
||||
dict(self.people),
|
||||
)
|
||||
|
||||
def get_full_index(self) -> IndexSnapshot:
|
||||
"""Return the full index as an IndexSnapshot."""
|
||||
return self._build_snapshot()
|
||||
"""Return the latest in-memory IndexSnapshot cache."""
|
||||
return self._cached_snapshot
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared data/protocol models for MediaHive."""
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
Data structures for mediahive and hivescan.
|
||||
"""Data structures for mediahive and hivescan.
|
||||
|
||||
All types are msgspec.Structs for fast serialization.
|
||||
"""
|
||||
@@ -8,23 +7,30 @@ from __future__ import annotations
|
||||
|
||||
import msgspec
|
||||
|
||||
from .tmdb import Info
|
||||
|
||||
from .tmdb import Info, Person
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Index item types (the state stored in IndexStore, sent over WS/API)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Torrent(msgspec.Struct):
|
||||
class Torrent(msgspec.Struct, omit_defaults=True):
|
||||
"""A torrent file, either for a movie or an episode."""
|
||||
|
||||
title: str | None = None
|
||||
playable_file: str | None = None
|
||||
resolution: str | None = None
|
||||
quality: str | None = None
|
||||
network: str | None = None
|
||||
codec: str | None = None
|
||||
audio: str | None = None
|
||||
audio_languages: list[str] | None = None
|
||||
subtitle_languages: list[str] | None = None
|
||||
external_subtitle_languages: list[str] | None = None
|
||||
hdr: bool = False
|
||||
dovi: bool = False
|
||||
atmos: bool = False
|
||||
hdr10plus: bool = False
|
||||
encoder: str | None = None
|
||||
size: int | None = None
|
||||
added_at: int | None = None
|
||||
@@ -42,7 +48,8 @@ class Episode(msgspec.Struct):
|
||||
rating: float | None = None
|
||||
director: str | None = None
|
||||
reel_image: str | None = None
|
||||
torrents: dict[str, Torrent] = {}
|
||||
reel_sources: list[str] | None = None
|
||||
files: dict[str, Torrent] = {}
|
||||
|
||||
|
||||
class Season(msgspec.Struct):
|
||||
@@ -60,7 +67,6 @@ class Season(msgspec.Struct):
|
||||
class Movie(msgspec.Struct):
|
||||
"""A movie in the index (one or more versions/releases)."""
|
||||
|
||||
id: str
|
||||
title: str | None = None
|
||||
info: Info | None = None
|
||||
year: int | None = None
|
||||
@@ -68,13 +74,13 @@ class Movie(msgspec.Struct):
|
||||
cover_path: str | None = None
|
||||
backdrop_path: str | None = None
|
||||
showreel_images: list[str] | None = None
|
||||
torrents: dict[str, Torrent] = {}
|
||||
showreel_source_sets: list[list[str]] | None = None
|
||||
files: dict[str, Torrent] = {}
|
||||
|
||||
|
||||
class Series(msgspec.Struct):
|
||||
"""A TV series in the index."""
|
||||
|
||||
id: str
|
||||
title: str | None = None
|
||||
info: Info | None = None
|
||||
alternative_titles: list[str] | None = None
|
||||
@@ -88,29 +94,17 @@ class Series(msgspec.Struct):
|
||||
# Snapshot (disk format for index.json)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MediaStats(msgspec.Struct):
|
||||
"""Aggregate counts for the index snapshot."""
|
||||
|
||||
total_movies: int = 0
|
||||
total_movie_versions: int = 0
|
||||
total_series: int = 0
|
||||
total_series_episodes: int = 0
|
||||
INDEX_SNAPSHOT_VERSION = 1
|
||||
|
||||
|
||||
class IndexSnapshot(msgspec.Struct):
|
||||
"""On-disk recovery snapshot of the full index."""
|
||||
|
||||
version: int = 6
|
||||
v: int = INDEX_SNAPSHOT_VERSION
|
||||
generated_at: str = ""
|
||||
media_root: str | None = None
|
||||
stats: MediaStats = msgspec.UNSET # type: ignore[assignment]
|
||||
movies: list[Movie] = []
|
||||
series: list[Series] = []
|
||||
|
||||
def __post_init__(self):
|
||||
if self.stats is msgspec.UNSET:
|
||||
self.stats = MediaStats()
|
||||
movies: dict[str, Movie] = {}
|
||||
series: dict[str, Series] = {}
|
||||
people: dict[int, Person] = {}
|
||||
|
||||
|
||||
class TaskInfo(msgspec.Struct):
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
Event types shared between scanner and WebSocket.
|
||||
"""Event types shared between scanner and WebSocket.
|
||||
|
||||
These types are used as:
|
||||
- Internal scan events (scanner → server queue)
|
||||
@@ -11,13 +10,24 @@ from __future__ import annotations
|
||||
import msgspec
|
||||
|
||||
from .data import Movie, Series, TaskInfo
|
||||
from .tmdb import Person
|
||||
|
||||
|
||||
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"
|
||||
id: str
|
||||
item: Movie | Series
|
||||
people: dict[int, Person] | None = None
|
||||
scanned: list[str] | None = None
|
||||
|
||||
|
||||
class Remove(msgspec.Struct, tag="remove"):
|
||||
@@ -27,6 +37,35 @@ class Remove(msgspec.Struct, tag="remove"):
|
||||
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"):
|
||||
"""Task progress broadcast."""
|
||||
|
||||
@@ -34,4 +73,4 @@ class Task(msgspec.Struct, tag="task"):
|
||||
|
||||
|
||||
# Union of scan events (scanner → server) and WS broadcast messages
|
||||
ScanEvent = Upsert | Task
|
||||
ScanEvent = Upsert | Sync | MovieShowreel | EpisodeReel | Task
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
Protocol structures for API and WebSocket communication.
|
||||
"""Protocol structures for API and WebSocket communication.
|
||||
|
||||
All types are msgspec.Structs for fast serialization.
|
||||
"""
|
||||
@@ -9,41 +8,87 @@ from __future__ import annotations
|
||||
import msgspec
|
||||
from fastapi.responses import Response
|
||||
|
||||
from .data import Movie, Series
|
||||
from .events import Remove, ScanEvent, Task, Upsert
|
||||
|
||||
from .data import Movie, Series, TaskInfo
|
||||
from .events import ScanEvent
|
||||
from .tmdb import Person
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket message types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WsInitData(msgspec.Struct):
|
||||
"""Payload of the init message."""
|
||||
class WsRootStatus(msgspec.Struct):
|
||||
"""Current status for one configured root."""
|
||||
|
||||
movies: list[Movie]
|
||||
series: list[Series]
|
||||
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]
|
||||
series: dict[str, Series]
|
||||
people: dict[int, Person]
|
||||
|
||||
|
||||
class WsRoots(msgspec.Struct, tag="roots"):
|
||||
"""Root list and status update."""
|
||||
|
||||
roots: list[WsRootStatus]
|
||||
|
||||
|
||||
class WsInit(msgspec.Struct, tag="init"):
|
||||
"""Full index sent on WS connect."""
|
||||
"""Full index payload keyed by root_id."""
|
||||
|
||||
data: WsInitData
|
||||
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)
|
||||
WsMessage = WsInit | Upsert | Remove | Task
|
||||
WsMessage = WsRoots | WsInit | WsUpsert | WsRemove | WsTask
|
||||
|
||||
|
||||
# Re-export unified types for backward compatibility
|
||||
__all__ = [
|
||||
"Remove",
|
||||
"ScanEvent",
|
||||
"Task",
|
||||
"Upsert",
|
||||
"WsInit",
|
||||
"WsInitData",
|
||||
"WsMessage",
|
||||
"WsRemove",
|
||||
"WsRootInitData",
|
||||
"WsRootStatus",
|
||||
"WsRoots",
|
||||
"WsTask",
|
||||
"WsUpsert",
|
||||
]
|
||||
|
||||
|
||||
@@ -52,37 +97,50 @@ __all__ = [
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ScanRequest(msgspec.Struct):
|
||||
"""POST /api/scan body."""
|
||||
|
||||
paths: list[str] | None = None
|
||||
|
||||
|
||||
class StatusResponse(msgspec.Struct):
|
||||
"""GET /api/status response."""
|
||||
|
||||
scanning: bool = False
|
||||
movies: int = 0
|
||||
series: int = 0
|
||||
showreel_queue: int = 0
|
||||
|
||||
|
||||
class PlayMediaRequest(msgspec.Struct):
|
||||
"""POST /api/play body (mediahive server)."""
|
||||
"""POST /api/play/{root_id} body."""
|
||||
|
||||
file_path: str = ""
|
||||
player_id: str | None = None
|
||||
player_custom_cmd: str | None = None
|
||||
|
||||
|
||||
class OpenFolderRequest(msgspec.Struct):
|
||||
"""POST /api/open-folder body (mediahive server)."""
|
||||
"""POST /api/open-folder/{root_id} body."""
|
||||
|
||||
folder_path: str = ""
|
||||
|
||||
|
||||
class ChangeFolderRequest(msgspec.Struct):
|
||||
"""POST /api/change-folder body."""
|
||||
class RootsRequest(msgspec.Struct):
|
||||
"""PUT /api/config/roots body."""
|
||||
|
||||
folder: 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):
|
||||
"""Single root entry in responses."""
|
||||
|
||||
path: str
|
||||
root_id: str
|
||||
|
||||
|
||||
class RootStatusResponse(msgspec.Struct):
|
||||
"""Legacy per-root status shape kept for non-WS callers."""
|
||||
|
||||
root_id: str
|
||||
path: str
|
||||
status: str
|
||||
error: str | None = None
|
||||
movies: int = 0
|
||||
series: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
TMDb data structures.
|
||||
"""TMDb data structures.
|
||||
|
||||
All types are msgspec.Structs for fast serialization.
|
||||
"""
|
||||
@@ -8,26 +7,24 @@ from __future__ import annotations
|
||||
|
||||
import msgspec
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-types (shared by TMDb results and index items)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CastMember(msgspec.Struct):
|
||||
"""Actor/crew member."""
|
||||
class CastCredit(msgspec.Struct, array_like=True):
|
||||
"""Cast reference embedded in media info (character + person id)."""
|
||||
|
||||
character: str | None = None
|
||||
id: int | None = None
|
||||
|
||||
|
||||
class Person(msgspec.Struct, array_like=True):
|
||||
"""Deduplicated person payload stored in top-level people map."""
|
||||
|
||||
name: str
|
||||
character: str | None = None
|
||||
profile_path: str | None = None
|
||||
|
||||
|
||||
class SimilarMedia(msgspec.Struct):
|
||||
"""Pointer to a similar movie/series on TMDb."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
poster_path: str | None = None
|
||||
gender: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -68,6 +65,7 @@ class Info(msgspec.Struct):
|
||||
tmdb_id: int
|
||||
title: str | None = None
|
||||
original_title: str | None = None
|
||||
original_language: str | None = None
|
||||
alternative_titles: list[str] | None = None
|
||||
rating: float | None = None
|
||||
vote_count: int | None = None
|
||||
@@ -75,13 +73,11 @@ class Info(msgspec.Struct):
|
||||
genres: list[str] | None = None
|
||||
release_date: str | None = None
|
||||
runtime: int | None = None
|
||||
collection: str | None = None
|
||||
status: str | None = None
|
||||
tagline: str | None = None
|
||||
poster_path: str | None = None
|
||||
backdrop_path: str | None = None
|
||||
similar: list[SimilarMedia] | None = None
|
||||
keywords: list[str] | None = None
|
||||
cast: list[CastMember] | None = None
|
||||
cast: list[CastCredit] | None = None
|
||||
director: str | None = None
|
||||
creators: list[str] | None = None
|
||||
number_of_seasons: int | None = None
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Media player detection and launching.
|
||||
|
||||
Windows: registry + known install paths.
|
||||
macOS: /Applications + PATH.
|
||||
Linux: PATH (which) only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
|
||||
|
||||
class PlayerInfo(msgspec.Struct):
|
||||
"""Descriptor for a detected media player."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
family: str
|
||||
path: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _which(cmd: str) -> str | None:
|
||||
"""Find a command in PATH."""
|
||||
return shutil.which(cmd)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _winreg_lookup(key_path: str, value_name: str = "") -> str | None:
|
||||
"""Read a string value from the Windows registry."""
|
||||
try:
|
||||
import winreg
|
||||
|
||||
# key_path like r"HKLM\Software\MPC-BE Team\MPC-BE"
|
||||
parts = key_path.split("\\", 1)
|
||||
hive_name = parts[0].upper()
|
||||
subpath = parts[1] if len(parts) > 1 else ""
|
||||
hive = {
|
||||
"HKLM": winreg.HKEY_LOCAL_MACHINE,
|
||||
"HKCU": winreg.HKEY_CURRENT_USER,
|
||||
"HKCR": winreg.HKEY_CLASSES_ROOT,
|
||||
}.get(hive_name)
|
||||
if hive is None:
|
||||
return None
|
||||
with winreg.OpenKey(hive, subpath) as key:
|
||||
val, _ = winreg.QueryValueEx(key, value_name or None)
|
||||
if isinstance(val, str):
|
||||
return val
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _expand_command_path(cmd: str) -> Path | None:
|
||||
r"""Extract the executable path from a shell\open\command string.
|
||||
|
||||
Handles quoted paths like ``"C:\Program Files\Player\player.exe" "%1"``
|
||||
and unquoted like ``C:\Program Files\Player\player.exe "%1"``.
|
||||
"""
|
||||
cmd = cmd.strip()
|
||||
if cmd.startswith('"'):
|
||||
end = cmd.find('"', 1)
|
||||
if end != -1:
|
||||
exe = cmd[1:end]
|
||||
return Path(exe) if Path(exe).exists() else None
|
||||
# Space-separated, take first token
|
||||
parts = cmd.split()
|
||||
if parts:
|
||||
candidate = Path(parts[0])
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _detect_default_player() -> PlayerInfo | None:
|
||||
"""Detect which program is associated with .mkv files."""
|
||||
try:
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r".mkv") as key:
|
||||
progid, _ = winreg.QueryValueEx(key, None)
|
||||
if not progid:
|
||||
return None
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_CLASSES_ROOT, f"{progid}\\shell\\open\\command"
|
||||
) as key:
|
||||
cmd, _ = winreg.QueryValueEx(key, None)
|
||||
exe_path = _expand_command_path(cmd) if cmd else None
|
||||
name = progid.replace(".", " ").title()
|
||||
return PlayerInfo(
|
||||
id="default-associated",
|
||||
name=f"Default ({name})",
|
||||
family="default",
|
||||
path=str(exe_path) if exe_path else None,
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _detect_mpc_be() -> PlayerInfo | None:
|
||||
candidates = [
|
||||
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
|
||||
/ "MPC-BE"
|
||||
/ "mpc-be64.exe",
|
||||
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
|
||||
/ "MPC-BE"
|
||||
/ "mpc-be.exe",
|
||||
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
|
||||
/ "MPC-BE"
|
||||
/ "mpc-be.exe",
|
||||
]
|
||||
# Registry path used by MPC-BE installer
|
||||
reg = _winreg_lookup(r"HKLM\Software\MPC-BE Team\MPC-BE", "ExePath")
|
||||
if reg:
|
||||
candidates.insert(0, Path(reg))
|
||||
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
return PlayerInfo(id="mpc-be", name="MPC-BE", family="mpc", path=str(path))
|
||||
return None
|
||||
|
||||
|
||||
def _detect_mpc_hc() -> PlayerInfo | None:
|
||||
candidates = [
|
||||
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
|
||||
/ "MPC-HC"
|
||||
/ "mpc-hc64.exe",
|
||||
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
|
||||
/ "MPC-HC"
|
||||
/ "mpc-hc.exe",
|
||||
]
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
return PlayerInfo(id="mpc-hc", name="MPC-HC", family="mpc", path=str(path))
|
||||
return None
|
||||
|
||||
|
||||
def _detect_vlc() -> PlayerInfo | None:
|
||||
candidates = [
|
||||
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
|
||||
/ "VideoLAN"
|
||||
/ "VLC"
|
||||
/ "vlc.exe",
|
||||
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
|
||||
/ "VideoLAN"
|
||||
/ "VLC"
|
||||
/ "vlc.exe",
|
||||
]
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
return PlayerInfo(
|
||||
id="vlc", name="VLC media player", family="vlc", path=str(path)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_potplayer() -> PlayerInfo | None:
|
||||
candidates = [
|
||||
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
|
||||
/ "DAUM"
|
||||
/ "PotPlayer"
|
||||
/ "PotPlayer64.exe",
|
||||
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
|
||||
/ "DAUM"
|
||||
/ "PotPlayer"
|
||||
/ "PotPlayer.exe",
|
||||
]
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
return PlayerInfo(
|
||||
id="potplayer", name="PotPlayer", family="potplayer", path=str(path)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_mpv() -> PlayerInfo | None:
|
||||
# Check PATH first
|
||||
mpv_in_path = shutil.which("mpv")
|
||||
if mpv_in_path:
|
||||
return PlayerInfo(id="mpv", name="mpv", family="mpv", path=mpv_in_path)
|
||||
candidates = [
|
||||
Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local"))
|
||||
/ "mpv"
|
||||
/ "mpv.exe",
|
||||
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "mpv" / "mpv.exe",
|
||||
]
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
return PlayerInfo(id="mpv", name="mpv", family="mpv", path=str(path))
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# macOS detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _detect_macos_app(
|
||||
bundle_name: str, display_name: str, family: str
|
||||
) -> PlayerInfo | None:
|
||||
"""Detect an app in /Applications or ~/Applications."""
|
||||
for apps_dir in (Path("/Applications"), Path.home() / "Applications"):
|
||||
app_path = apps_dir / f"{bundle_name}.app"
|
||||
if app_path.exists():
|
||||
# Find the actual executable inside the bundle
|
||||
macos_dir = app_path / "Contents" / "MacOS"
|
||||
if macos_dir.exists():
|
||||
# Often the executable name matches the bundle name
|
||||
exe = macos_dir / bundle_name
|
||||
if exe.exists():
|
||||
return PlayerInfo(
|
||||
id=family, name=display_name, family=family, path=str(exe)
|
||||
)
|
||||
# Fallback: any executable in MacOS dir
|
||||
for child in macos_dir.iterdir():
|
||||
if child.is_file() and os.access(child, os.X_OK):
|
||||
return PlayerInfo(
|
||||
id=family, name=display_name, family=family, path=str(child)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_iina() -> PlayerInfo | None:
|
||||
return _detect_macos_app("IINA", "IINA", "mpv")
|
||||
|
||||
|
||||
def _detect_vlc_macos() -> PlayerInfo | None:
|
||||
return _detect_macos_app("VLC", "VLC media player", "vlc")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Linux detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _detect_vlc_linux() -> PlayerInfo | None:
|
||||
path = _which("vlc")
|
||||
if path:
|
||||
return PlayerInfo(id="vlc", name="VLC media player", family="vlc", path=path)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_smplayer() -> PlayerInfo | None:
|
||||
path = _which("smplayer")
|
||||
if path:
|
||||
return PlayerInfo(id="smplayer", name="SMPlayer", family="mpv", path=path)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def detect_players() -> list[PlayerInfo]:
|
||||
"""Return a list of detected players plus default and custom entries."""
|
||||
detected: list[PlayerInfo] = []
|
||||
|
||||
if sys.platform == "win32":
|
||||
# Specific players
|
||||
for detector in (
|
||||
_detect_mpc_be,
|
||||
_detect_mpc_hc,
|
||||
_detect_vlc,
|
||||
_detect_potplayer,
|
||||
_detect_mpv,
|
||||
):
|
||||
player = detector()
|
||||
if player:
|
||||
detected.append(player)
|
||||
|
||||
# Default associated player (optional, for info)
|
||||
default_assoc = _detect_default_player()
|
||||
if default_assoc and default_assoc.path:
|
||||
# Only add if it's a different executable than one we already found
|
||||
existing_paths = {p.path.lower() for p in detected if p.path}
|
||||
if default_assoc.path.lower() not in existing_paths:
|
||||
detected.append(default_assoc)
|
||||
|
||||
elif sys.platform == "darwin":
|
||||
for detector in (_detect_iina, _detect_vlc_macos):
|
||||
player = detector()
|
||||
if player:
|
||||
detected.append(player)
|
||||
|
||||
else:
|
||||
# Linux / other Unix
|
||||
for detector in (_detect_vlc_linux, _detect_smplayer):
|
||||
player = detector()
|
||||
if player:
|
||||
detected.append(player)
|
||||
|
||||
# Always include the abstract "default" and "custom" options
|
||||
result: list[PlayerInfo] = [
|
||||
PlayerInfo(id="default", name="System Default", family="default"),
|
||||
]
|
||||
result.extend(detected)
|
||||
result.append(PlayerInfo(id="custom", name="Custom…", family="custom"))
|
||||
return result
|
||||
|
||||
|
||||
def launch_player(
|
||||
player_id: str,
|
||||
file_path: Path,
|
||||
player_path: str | None = None,
|
||||
custom_cmd: str | None = None,
|
||||
) -> None:
|
||||
"""Launch a media file with the specified player.
|
||||
|
||||
Args:
|
||||
player_id: One of "default", "custom", or a detected player id.
|
||||
file_path: Absolute path to the media file.
|
||||
player_path: Executable path for detected players (from detection).
|
||||
custom_cmd: Raw command string for "custom" player (with %s).
|
||||
|
||||
"""
|
||||
if player_id == "default" or not player_id:
|
||||
if sys.platform == "win32":
|
||||
os.startfile(str(file_path))
|
||||
return
|
||||
opener = "open" if sys.platform == "darwin" else "xdg-open"
|
||||
subprocess.Popen(
|
||||
[opener, str(file_path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return
|
||||
|
||||
if player_id == "custom":
|
||||
if not custom_cmd:
|
||||
raise ValueError("Custom player command is empty")
|
||||
cmd_str = custom_cmd.replace("%s", str(file_path))
|
||||
if "%s" not in custom_cmd:
|
||||
cmd_str = f'{custom_cmd} "{file_path}"'
|
||||
# Use shell=True for custom commands so arguments are parsed naturally
|
||||
subprocess.Popen(
|
||||
cmd_str, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
return
|
||||
|
||||
if not player_path:
|
||||
raise ValueError(f"Player path not provided for {player_id}")
|
||||
|
||||
# Detected player: pass file path as the single argument
|
||||
subprocess.Popen(
|
||||
[player_path, str(file_path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Root registry, per-root context, and supervisor for multi-root media support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
|
||||
from mediahive.config import load_config, save_config
|
||||
from mediahive.index_store import IndexStore
|
||||
from mediahive.models.events import (
|
||||
EpisodeReel,
|
||||
MovieShowreel,
|
||||
ScanEvent,
|
||||
Sync,
|
||||
Task,
|
||||
Upsert,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mediahive.root_registry")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Root path normalization and friendly name derivation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
"""Canonicalize a path for stable ID generation.
|
||||
|
||||
- expanduser() only (do not resolve symlinks/mapped drives)
|
||||
- lower-case drive letter on Windows
|
||||
- strip trailing separators
|
||||
- use forward slashes
|
||||
"""
|
||||
p = Path(path).expanduser()
|
||||
posix = p.as_posix()
|
||||
# Canonicalize drive-only roots ("Z:") to drive root ("Z:/") so paths are absolute.
|
||||
if len(posix) == 2 and posix[1] == ":" and posix[0].isalpha():
|
||||
posix = f"{posix}/"
|
||||
# Windows drive letter normalization
|
||||
if len(posix) >= 2 and posix[1] == ":":
|
||||
posix = posix[0].lower() + posix[1:]
|
||||
# Strip trailing slash (except root "/")
|
||||
while (
|
||||
len(posix) > 1
|
||||
and posix.endswith("/")
|
||||
and not (len(posix) == 3 and posix[1] == ":" and posix[2] == "/")
|
||||
):
|
||||
posix = posix[:-1]
|
||||
return posix
|
||||
|
||||
|
||||
def _derive_root_name(path: str) -> str:
|
||||
"""Derive a friendly root name from a path basename/anchor."""
|
||||
normalized = (path or "").replace("\\", "/").rstrip("/")
|
||||
if not normalized:
|
||||
return "media"
|
||||
parts = [segment for segment in normalized.split("/") if segment]
|
||||
if parts:
|
||||
leaf = parts[-1]
|
||||
if len(leaf) == 2 and leaf[1] == ":" and leaf[0].isalpha():
|
||||
return leaf[0]
|
||||
return leaf
|
||||
return "media"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Root entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RootEntry(msgspec.Struct):
|
||||
path: str
|
||||
root_id: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Root context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RootContext:
|
||||
"""Runtime container for a single media root."""
|
||||
|
||||
def __init__(self, root_id: str, root_path: Path) -> None:
|
||||
self.root_id = root_id
|
||||
self.root_path = root_path
|
||||
self.status = "loading"
|
||||
self.error: str | None = None
|
||||
|
||||
snapshot_path = root_path / ".mediahive" / "index.json"
|
||||
self.store = IndexStore(snapshot_path)
|
||||
|
||||
# Scanner is injected later by the supervisor
|
||||
self.scanner: object | None = None
|
||||
|
||||
# Event queue and consumer
|
||||
self._events: asyncio.Queue[ScanEvent] = asyncio.Queue()
|
||||
self._consumer_task: asyncio.Task | None = None
|
||||
self._startup_task: asyncio.Task | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start consumer immediately and load snapshot in the background."""
|
||||
self.status = "loading"
|
||||
self.error = None
|
||||
|
||||
if self._consumer_task is None or self._consumer_task.done():
|
||||
self._consumer_task = asyncio.create_task(self._consume_events())
|
||||
|
||||
if self._startup_task is None or self._startup_task.done():
|
||||
self._startup_task = asyncio.create_task(self._load_snapshot_background())
|
||||
|
||||
async def _load_snapshot_background(self) -> None:
|
||||
"""Load snapshot without blocking root activation paths."""
|
||||
try:
|
||||
await self.store.load_snapshot()
|
||||
self.status = "ready"
|
||||
logger.info(
|
||||
"Root %s ready: %d movies, %d series",
|
||||
self.root_id,
|
||||
len(self.store.movies),
|
||||
len(self.store.series),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.status = "error"
|
||||
self.error = str(exc)
|
||||
logger.exception("Root %s failed to load snapshot", self.root_id)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop consumer, flush snapshot, stop scanner."""
|
||||
if self._startup_task and not self._startup_task.done():
|
||||
self._startup_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._startup_task
|
||||
|
||||
if self.scanner is not None:
|
||||
try:
|
||||
await self.scanner.stop()
|
||||
except Exception:
|
||||
logger.exception("Error stopping scanner for root %s", self.root_id)
|
||||
self.scanner = None
|
||||
|
||||
if self._consumer_task and not self._consumer_task.done():
|
||||
self._consumer_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._consumer_task
|
||||
|
||||
try:
|
||||
await self.store.flush_snapshot()
|
||||
except Exception:
|
||||
logger.exception("Error flushing snapshot for root %s", self.root_id)
|
||||
|
||||
async def send_event(self, event: ScanEvent) -> None:
|
||||
"""Push a scanner event into this root's queue."""
|
||||
await self._events.put(event)
|
||||
|
||||
async def _consume_events(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
event = await self._events.get()
|
||||
if isinstance(event, Upsert):
|
||||
if event.kind == "movie":
|
||||
self.store.upsert_movie(
|
||||
event.id, event.item, event.people, event.scanned
|
||||
)
|
||||
else:
|
||||
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):
|
||||
self.store.broadcast_task(event.data)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error processing scan event for root %s", self.root_id
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supervisor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Supervisor:
|
||||
"""Manages the active set of RootContexts and handles atomic replacement."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Active contexts keyed by root_id
|
||||
self._contexts: dict[str, RootContext] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Read helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get(self, root_id: str) -> RootContext | None:
|
||||
return self._contexts.get(root_id)
|
||||
|
||||
def all_contexts(self) -> dict[str, RootContext]:
|
||||
return self._contexts.copy()
|
||||
|
||||
def all_statuses(self) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"root_id": ctx.root_id,
|
||||
"path": ctx.root_path.as_posix(),
|
||||
"status": ctx.status,
|
||||
"error": ctx.error,
|
||||
"snapshot_loaded": ctx.store.snapshot_loaded,
|
||||
"movies": len(ctx.store.movies),
|
||||
"series": len(ctx.store.series),
|
||||
}
|
||||
for ctx in self._contexts.values()
|
||||
]
|
||||
|
||||
def merged_index(self) -> dict:
|
||||
"""Return a merged index snapshot from all ready roots."""
|
||||
movies = []
|
||||
series = []
|
||||
total_movie_versions = 0
|
||||
total_series_episodes = 0
|
||||
for ctx in self._contexts.values():
|
||||
if ctx.status not in {"ready", "scanning"}:
|
||||
continue
|
||||
movies.extend(ctx.store.movies.values())
|
||||
series.extend(ctx.store.series.values())
|
||||
total_movie_versions += sum(len(m.files) for m in ctx.store.movies.values())
|
||||
total_series_episodes += sum(
|
||||
sum(len(season.episodes) for season in s.seasons)
|
||||
for s in ctx.store.series.values()
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
return {
|
||||
"v": 1,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"stats": {
|
||||
"total_movies": len(movies),
|
||||
"total_movie_versions": total_movie_versions,
|
||||
"total_series": len(series),
|
||||
"total_series_episodes": total_series_episodes,
|
||||
},
|
||||
"movies": movies,
|
||||
"series": series,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Atomic replacement
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def replace_roots(
|
||||
self, roots: dict[str, str]
|
||||
) -> tuple[list[RootEntry], list[dict]]:
|
||||
"""Atomically replace the active root set.
|
||||
|
||||
Returns (accepted_entries, failed_entries_with_reason).
|
||||
"""
|
||||
async with self._lock:
|
||||
# Validate and canonicalize
|
||||
candidates: list[RootEntry] = []
|
||||
seen_paths: set[str] = set()
|
||||
failed: list[dict] = []
|
||||
|
||||
for path_str in roots.values():
|
||||
p = Path(path_str).expanduser()
|
||||
if not p.exists() or not p.is_dir():
|
||||
failed.append({
|
||||
"path": path_str,
|
||||
"reason": "not a directory",
|
||||
})
|
||||
continue
|
||||
norm = _normalize_path(p.as_posix())
|
||||
if norm in seen_paths:
|
||||
failed.append({
|
||||
"path": path_str,
|
||||
"reason": "duplicate path",
|
||||
})
|
||||
continue
|
||||
seen_paths.add(norm)
|
||||
|
||||
# Friendly names should reflect the configured root path (e.g. "Z:" -> "Z"),
|
||||
# not the resolved physical target (which may be a UNC path).
|
||||
configured_path = Path(path_str).expanduser().as_posix()
|
||||
base_name = _derive_root_name(configured_path)
|
||||
unique_name = base_name
|
||||
suffix = 2
|
||||
existing_names = {e.root_id for e in candidates}
|
||||
while unique_name in existing_names:
|
||||
unique_name = f"{base_name}{suffix}"
|
||||
suffix += 1
|
||||
|
||||
# root_id now uses the same friendly identifier as the display name.
|
||||
candidates.append(RootEntry(path=norm, root_id=unique_name))
|
||||
|
||||
# Build desired root_id set
|
||||
desired_ids = {e.root_id for e in candidates}
|
||||
|
||||
# Stop scanners for roots that are being removed or changed
|
||||
old_contexts = list(self._contexts.values())
|
||||
for ctx in old_contexts:
|
||||
if ctx.root_id not in desired_ids:
|
||||
asyncio.create_task(ctx.stop())
|
||||
|
||||
# Prepare new contexts
|
||||
new_contexts: dict[str, RootContext] = {}
|
||||
for entry in candidates:
|
||||
existing = self._contexts.get(entry.root_id)
|
||||
if existing and existing.root_path.as_posix() == entry.path:
|
||||
# Reuse existing context
|
||||
new_contexts[entry.root_id] = existing
|
||||
else:
|
||||
# If existing path changed, stop old one
|
||||
if existing:
|
||||
asyncio.create_task(existing.stop())
|
||||
ctx = RootContext(entry.root_id, Path(entry.path))
|
||||
await ctx.start()
|
||||
new_contexts[entry.root_id] = ctx
|
||||
|
||||
# Atomic swap
|
||||
self._contexts = new_contexts
|
||||
|
||||
# Persist to config
|
||||
cfg = load_config()
|
||||
save_config(
|
||||
msgspec.structs.replace(
|
||||
cfg,
|
||||
roots={e.root_id: e.path for e in candidates},
|
||||
)
|
||||
)
|
||||
|
||||
return candidates, failed
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
async with self._lock:
|
||||
# Stop roots concurrently — each may wait on task cancellation and
|
||||
# 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()
|
||||
@@ -0,0 +1,475 @@
|
||||
"""Cross-platform master volume control with linear amplification mapping.
|
||||
|
||||
The UI slider produces a value ``x`` in the range ``[0, 1.5]``. This module
|
||||
converts ``x`` to a linear amplitude coefficient ``amp`` using:
|
||||
|
||||
amp = 0.02 * x if x < 0.1
|
||||
amp = (exp(6 * x) - 1) / 402.42879349 otherwise
|
||||
|
||||
``amp`` is then applied to the system mixer in a platform-specific way:
|
||||
|
||||
* **Linux / PipeWire** – ``wpctl`` accepts linear values > 1.0 natively, so
|
||||
amplification up to ~150 % (and beyond) works without clipping.
|
||||
* **Windows** – ``IAudioEndpointVolume`` uses an audio-tapered scalar and does
|
||||
not support > 100 %. ``amp`` is clamped to 1.0 and mapped to the scalar.
|
||||
* **macOS** – ``osascript`` sets a 0-100 slider and does not support > 100 %.
|
||||
``amp`` is clamped to 1.0 and mapped to that range.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = logging.getLogger("mediahive.volume")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Linear amplitude formula
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AMP_DENOMINATOR = 402.42879349
|
||||
|
||||
|
||||
def slider_to_amp(x: float) -> float:
|
||||
"""Convert UI slider position ``x`` (0.0 .. 1.5) to linear amplitude.
|
||||
|
||||
Returns a value in the range ``[0, ~20]`` where ``1.0`` means unity gain.
|
||||
"""
|
||||
if x <= 0.0:
|
||||
return 0.0
|
||||
if x < 0.1:
|
||||
return 0.02 * x
|
||||
return (math.exp(6.0 * x) - 1.0) / _AMP_DENOMINATOR
|
||||
|
||||
|
||||
def amp_to_slider(amp: float) -> float:
|
||||
"""Inverse of :func:`slider_to_amp` for the ``x >= 0.1`` branch.
|
||||
|
||||
Returns a slider position in ``[0.1, 1.5]`` (or slightly above when the
|
||||
platform reports amplification > 1.0).
|
||||
"""
|
||||
if amp <= 0.0:
|
||||
return 0.0
|
||||
# For very small values we fall back to the linear branch
|
||||
if amp < 0.002042892590415348: # value at x == 0.1
|
||||
return amp / 0.02
|
||||
return math.log(amp * _AMP_DENOMINATOR + 1.0) / 6.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform backends
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _set_volume_windows(amp: float) -> None:
|
||||
"""Set master volume on Windows via ``IAudioEndpointVolume`` (ctypes)."""
|
||||
import ctypes
|
||||
import uuid
|
||||
from ctypes import (
|
||||
POINTER,
|
||||
Structure,
|
||||
byref,
|
||||
c_float,
|
||||
c_uint32,
|
||||
c_void_p,
|
||||
cast,
|
||||
wintypes,
|
||||
)
|
||||
|
||||
class GUID(Structure):
|
||||
_fields_ = [
|
||||
("Data1", wintypes.DWORD),
|
||||
("Data2", wintypes.WORD),
|
||||
("Data3", wintypes.WORD),
|
||||
("Data4", wintypes.BYTE * 8),
|
||||
]
|
||||
|
||||
def _uuid_to_guid(u: uuid.UUID) -> GUID:
|
||||
g = GUID()
|
||||
g.Data1 = u.time_low
|
||||
g.Data2 = u.time_mid
|
||||
g.Data3 = u.time_hi_version
|
||||
g.Data4[0] = u.clock_seq_hi_variant & 0xFF
|
||||
g.Data4[1] = u.clock_seq_low & 0xFF
|
||||
for i in range(6):
|
||||
g.Data4[2 + i] = (u.node >> (40 - i * 8)) & 0xFF
|
||||
return g
|
||||
|
||||
CLSID_MMDeviceEnumerator = _uuid_to_guid(
|
||||
uuid.UUID("{BCDE0395-E52F-467C-8E3D-C4579291692E}")
|
||||
)
|
||||
IID_IMMDeviceEnumerator = _uuid_to_guid(
|
||||
uuid.UUID("{A95664D2-9614-4F35-A746-DE8DB63617E6}")
|
||||
)
|
||||
IID_IAudioEndpointVolume = _uuid_to_guid(
|
||||
uuid.UUID("{5CDF2C82-841E-4546-9722-0CF74078229A}")
|
||||
)
|
||||
IID_IMMDevice = _uuid_to_guid(uuid.UUID("{D666063F-1587-4E43-81F1-B948E807363F}"))
|
||||
|
||||
CLSCTX_ALL = 23
|
||||
|
||||
ole32 = ctypes.windll.ole32
|
||||
ole32.CoInitializeEx(None, 0)
|
||||
|
||||
CoCreateInstance = ole32.CoCreateInstance
|
||||
CoCreateInstance.argtypes = [
|
||||
POINTER(GUID),
|
||||
c_void_p,
|
||||
wintypes.DWORD,
|
||||
POINTER(GUID),
|
||||
POINTER(c_void_p),
|
||||
]
|
||||
CoCreateInstance.restype = wintypes.HRESULT
|
||||
|
||||
enumerator_ptr = c_void_p()
|
||||
hr = CoCreateInstance(
|
||||
byref(CLSID_MMDeviceEnumerator),
|
||||
None,
|
||||
CLSCTX_ALL,
|
||||
byref(IID_IMMDeviceEnumerator),
|
||||
byref(enumerator_ptr),
|
||||
)
|
||||
if hr != 0:
|
||||
raise OSError(f"CoCreateInstance failed: {hr:#x}")
|
||||
|
||||
try:
|
||||
vtable_ptr = cast(enumerator_ptr, POINTER(c_void_p)).contents
|
||||
vtable = cast(vtable_ptr, POINTER(c_void_p * 6))
|
||||
GetDefaultAudioEndpoint = ctypes.WINFUNCTYPE(
|
||||
wintypes.HRESULT, c_void_p, c_uint32, c_uint32, POINTER(c_void_p)
|
||||
)(vtable[0][4])
|
||||
|
||||
device_ptr = c_void_p()
|
||||
hr = GetDefaultAudioEndpoint(enumerator_ptr, 0, 0, byref(device_ptr))
|
||||
if hr != 0:
|
||||
raise OSError(f"GetDefaultAudioEndpoint failed: {hr:#x}")
|
||||
|
||||
try:
|
||||
device_vtable_ptr = cast(device_ptr, POINTER(c_void_p)).contents
|
||||
device_vtable = cast(device_vtable_ptr, POINTER(c_void_p * 7))
|
||||
Activate = ctypes.WINFUNCTYPE(
|
||||
wintypes.HRESULT,
|
||||
c_void_p,
|
||||
POINTER(GUID),
|
||||
wintypes.DWORD,
|
||||
c_void_p,
|
||||
POINTER(c_void_p),
|
||||
)(device_vtable[0][3])
|
||||
|
||||
volume_ptr = c_void_p()
|
||||
hr = Activate(
|
||||
device_ptr,
|
||||
byref(IID_IAudioEndpointVolume),
|
||||
CLSCTX_ALL,
|
||||
None,
|
||||
byref(volume_ptr),
|
||||
)
|
||||
if hr != 0:
|
||||
raise OSError(f"Activate(IAudioEndpointVolume) failed: {hr:#x}")
|
||||
|
||||
try:
|
||||
vol_vtable_ptr = cast(volume_ptr, POINTER(c_void_p)).contents
|
||||
vol_vtable = cast(vol_vtable_ptr, POINTER(c_void_p * 22))
|
||||
|
||||
GetVolumeRange = ctypes.WINFUNCTYPE(
|
||||
wintypes.HRESULT,
|
||||
c_void_p,
|
||||
POINTER(c_float),
|
||||
POINTER(c_float),
|
||||
POINTER(c_float),
|
||||
)(vol_vtable[0][20])
|
||||
|
||||
min_db = c_float()
|
||||
max_db = c_float()
|
||||
step_db = c_float()
|
||||
hr = GetVolumeRange(
|
||||
volume_ptr, byref(min_db), byref(max_db), byref(step_db)
|
||||
)
|
||||
if hr != 0:
|
||||
raise OSError(f"GetVolumeRange failed: {hr:#x}")
|
||||
|
||||
SetMasterVolumeLevel = ctypes.WINFUNCTYPE(
|
||||
wintypes.HRESULT, c_void_p, c_float, POINTER(GUID)
|
||||
)(vol_vtable[0][6])
|
||||
SetMasterVolumeLevelScalar = ctypes.WINFUNCTYPE(
|
||||
wintypes.HRESULT, c_void_p, c_float, POINTER(GUID)
|
||||
)(vol_vtable[0][7])
|
||||
|
||||
# Windows does not natively support >100% system volume.
|
||||
# Clamp to unity gain and map to the audio-tapered scalar.
|
||||
# The exponent 0.573 was empirically derived from measurements
|
||||
# on a reference Windows system.
|
||||
clamped = min(amp, 1.0)
|
||||
scalar = clamped**0.573
|
||||
SetMasterVolumeLevelScalar(volume_ptr, scalar, None)
|
||||
finally:
|
||||
Release = ctypes.WINFUNCTYPE(wintypes.ULONG, c_void_p)(vol_vtable[0][2])
|
||||
Release(volume_ptr)
|
||||
finally:
|
||||
Release = ctypes.WINFUNCTYPE(wintypes.ULONG, c_void_p)(device_vtable[0][2])
|
||||
Release(device_ptr)
|
||||
finally:
|
||||
Release = ctypes.WINFUNCTYPE(wintypes.ULONG, c_void_p)(vtable[0][2])
|
||||
Release(enumerator_ptr)
|
||||
|
||||
|
||||
def _get_volume_windows() -> float:
|
||||
"""Return current linear amplitude on Windows."""
|
||||
import ctypes
|
||||
import uuid
|
||||
from ctypes import (
|
||||
POINTER,
|
||||
Structure,
|
||||
byref,
|
||||
c_float,
|
||||
c_uint32,
|
||||
c_void_p,
|
||||
cast,
|
||||
wintypes,
|
||||
)
|
||||
|
||||
class GUID(Structure):
|
||||
_fields_ = [
|
||||
("Data1", wintypes.DWORD),
|
||||
("Data2", wintypes.WORD),
|
||||
("Data3", wintypes.WORD),
|
||||
("Data4", wintypes.BYTE * 8),
|
||||
]
|
||||
|
||||
def _uuid_to_guid(u: uuid.UUID) -> GUID:
|
||||
g = GUID()
|
||||
g.Data1 = u.time_low
|
||||
g.Data2 = u.time_mid
|
||||
g.Data3 = u.time_hi_version
|
||||
g.Data4[0] = u.clock_seq_hi_variant & 0xFF
|
||||
g.Data4[1] = u.clock_seq_low & 0xFF
|
||||
for i in range(6):
|
||||
g.Data4[2 + i] = (u.node >> (40 - i * 8)) & 0xFF
|
||||
return g
|
||||
|
||||
CLSID_MMDeviceEnumerator = _uuid_to_guid(
|
||||
uuid.UUID("{BCDE0395-E52F-467C-8E3D-C4579291692E}")
|
||||
)
|
||||
IID_IMMDeviceEnumerator = _uuid_to_guid(
|
||||
uuid.UUID("{A95664D2-9614-4F35-A746-DE8DB63617E6}")
|
||||
)
|
||||
IID_IAudioEndpointVolume = _uuid_to_guid(
|
||||
uuid.UUID("{5CDF2C82-841E-4546-9722-0CF74078229A}")
|
||||
)
|
||||
IID_IMMDevice = _uuid_to_guid(uuid.UUID("{D666063F-1587-4E43-81F1-B948E807363F}"))
|
||||
|
||||
CLSCTX_ALL = 23
|
||||
|
||||
ole32 = ctypes.windll.ole32
|
||||
ole32.CoInitializeEx(None, 0)
|
||||
|
||||
CoCreateInstance = ole32.CoCreateInstance
|
||||
CoCreateInstance.argtypes = [
|
||||
POINTER(GUID),
|
||||
c_void_p,
|
||||
wintypes.DWORD,
|
||||
POINTER(GUID),
|
||||
POINTER(c_void_p),
|
||||
]
|
||||
CoCreateInstance.restype = wintypes.HRESULT
|
||||
|
||||
enumerator_ptr = c_void_p()
|
||||
hr = CoCreateInstance(
|
||||
byref(CLSID_MMDeviceEnumerator),
|
||||
None,
|
||||
CLSCTX_ALL,
|
||||
byref(IID_IMMDeviceEnumerator),
|
||||
byref(enumerator_ptr),
|
||||
)
|
||||
if hr != 0:
|
||||
raise OSError(f"CoCreateInstance failed: {hr:#x}")
|
||||
|
||||
try:
|
||||
vtable_ptr = cast(enumerator_ptr, POINTER(c_void_p)).contents
|
||||
vtable = cast(vtable_ptr, POINTER(c_void_p * 6))
|
||||
GetDefaultAudioEndpoint = ctypes.WINFUNCTYPE(
|
||||
wintypes.HRESULT, c_void_p, c_uint32, c_uint32, POINTER(c_void_p)
|
||||
)(vtable[0][4])
|
||||
|
||||
device_ptr = c_void_p()
|
||||
hr = GetDefaultAudioEndpoint(enumerator_ptr, 0, 0, byref(device_ptr))
|
||||
if hr != 0:
|
||||
raise OSError(f"GetDefaultAudioEndpoint failed: {hr:#x}")
|
||||
|
||||
try:
|
||||
device_vtable_ptr = cast(device_ptr, POINTER(c_void_p)).contents
|
||||
device_vtable = cast(device_vtable_ptr, POINTER(c_void_p * 7))
|
||||
Activate = ctypes.WINFUNCTYPE(
|
||||
wintypes.HRESULT,
|
||||
c_void_p,
|
||||
POINTER(GUID),
|
||||
wintypes.DWORD,
|
||||
c_void_p,
|
||||
POINTER(c_void_p),
|
||||
)(device_vtable[0][3])
|
||||
|
||||
volume_ptr = c_void_p()
|
||||
hr = Activate(
|
||||
device_ptr,
|
||||
byref(IID_IAudioEndpointVolume),
|
||||
CLSCTX_ALL,
|
||||
None,
|
||||
byref(volume_ptr),
|
||||
)
|
||||
if hr != 0:
|
||||
raise OSError(f"Activate failed: {hr:#x}")
|
||||
|
||||
try:
|
||||
vol_vtable_ptr = cast(volume_ptr, POINTER(c_void_p)).contents
|
||||
vol_vtable = cast(vol_vtable_ptr, POINTER(c_void_p * 22))
|
||||
|
||||
GetMasterVolumeLevel = ctypes.WINFUNCTYPE(
|
||||
wintypes.HRESULT, c_void_p, POINTER(c_float)
|
||||
)(vol_vtable[0][8])
|
||||
GetMasterVolumeLevelScalar = ctypes.WINFUNCTYPE(
|
||||
wintypes.HRESULT, c_void_p, POINTER(c_float)
|
||||
)(vol_vtable[0][9])
|
||||
|
||||
db = c_float()
|
||||
hr = GetMasterVolumeLevel(volume_ptr, byref(db))
|
||||
if hr == 0 and db.value > -90.0:
|
||||
return 10.0 ** (db.value / 20.0)
|
||||
|
||||
scalar = c_float()
|
||||
hr = GetMasterVolumeLevelScalar(volume_ptr, byref(scalar))
|
||||
if hr != 0:
|
||||
raise OSError(f"GetMasterVolumeLevelScalar failed: {hr:#x}")
|
||||
# Invert the audio-tapered mapping
|
||||
return scalar.value ** (1.0 / 0.573)
|
||||
finally:
|
||||
Release = ctypes.WINFUNCTYPE(wintypes.ULONG, c_void_p)(vol_vtable[0][2])
|
||||
Release(volume_ptr)
|
||||
finally:
|
||||
Release = ctypes.WINFUNCTYPE(wintypes.ULONG, c_void_p)(device_vtable[0][2])
|
||||
Release(device_ptr)
|
||||
finally:
|
||||
Release = ctypes.WINFUNCTYPE(wintypes.ULONG, c_void_p)(vtable[0][2])
|
||||
Release(enumerator_ptr)
|
||||
|
||||
|
||||
def _set_volume_macos(amp: float) -> None:
|
||||
"""Set master volume on macOS via AppleScript."""
|
||||
# macOS does not natively support >100% system volume.
|
||||
clamped = min(amp, 1.0)
|
||||
level = int(clamped * 100)
|
||||
subprocess.run(
|
||||
["osascript", "-e", f"set volume output volume {level}"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def _get_volume_macos() -> float:
|
||||
"""Return current linear amplitude on macOS."""
|
||||
result = subprocess.run(
|
||||
["osascript", "-e", "output volume of (get volume settings)"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
try:
|
||||
return int(result.stdout.strip()) / 100.0
|
||||
except ValueError, AttributeError:
|
||||
return 1.0
|
||||
|
||||
|
||||
def _set_volume_linux(amp: float) -> None:
|
||||
"""Set master volume on Linux via PipeWire (``wpctl``)."""
|
||||
# wpctl is the native PipeWire CLI and accepts linear values > 1.0
|
||||
# directly. We do not fall back to PulseAudio tools.
|
||||
subprocess.run(
|
||||
["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", str(amp)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def _get_volume_linux() -> float:
|
||||
"""Return current linear amplitude on Linux via PipeWire (``wpctl``)."""
|
||||
result = subprocess.run(
|
||||
["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
# Output: "Volume: 0.40" or "Volume: 0.50 [MUTED]"
|
||||
line = result.stdout.strip()
|
||||
parts = line.split()
|
||||
return float(parts[1])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_setter: Callable[[float], None] | None = None
|
||||
_getter: Callable[[], float] | None = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _init_backend() -> None:
|
||||
"""Lazily select the platform backend."""
|
||||
global _setter, _getter
|
||||
with _lock:
|
||||
if _setter is not None:
|
||||
return
|
||||
if sys.platform == "win32":
|
||||
_setter = _set_volume_windows
|
||||
_getter = _get_volume_windows
|
||||
elif sys.platform == "darwin":
|
||||
_setter = _set_volume_macos
|
||||
_getter = _get_volume_macos
|
||||
else:
|
||||
_setter = _set_volume_linux
|
||||
_getter = _get_volume_linux
|
||||
|
||||
|
||||
def set_volume(x: float) -> None:
|
||||
"""Set master volume from slider position ``x`` (0.0 .. 1.5).
|
||||
|
||||
The value is converted to a linear amplitude coefficient and applied to
|
||||
the system mixer using the platform-native API.
|
||||
"""
|
||||
_init_backend()
|
||||
amp = slider_to_amp(x)
|
||||
assert _setter is not None
|
||||
try:
|
||||
_setter(amp)
|
||||
except Exception:
|
||||
logger.exception("Failed to set volume (x=%.3f, amp=%.6f)", x, amp)
|
||||
|
||||
|
||||
def get_volume() -> float:
|
||||
"""Return the current slider position (0.0 .. 1.5) by reading the OS mixer.
|
||||
|
||||
On platforms that do not support amplification above 100 % the returned
|
||||
value will never exceed ``1.0``.
|
||||
"""
|
||||
_init_backend()
|
||||
assert _getter is not None
|
||||
try:
|
||||
amp = _getter()
|
||||
except Exception:
|
||||
logger.exception("Failed to get volume")
|
||||
return 1.0
|
||||
return amp_to_slider(amp)
|
||||
|
||||
|
||||
def volume_max() -> float:
|
||||
"""Return the maximum slider position supported on this platform.
|
||||
|
||||
* Linux / PipeWire – ``1.5`` (amplification above 100 % is supported).
|
||||
* Windows / macOS – ``1.0`` (the OS mixer does not amplify above unity).
|
||||
"""
|
||||
if sys.platform in ("win32", "darwin"):
|
||||
return 1.0
|
||||
return 1.5
|
||||
@@ -8,11 +8,12 @@ dependencies = [
|
||||
"aiofiles>=25.1.0",
|
||||
"aiopathlib>=0.6.0",
|
||||
"bencodepy>=0.9.5",
|
||||
"fastapi-vue>=0.5.2",
|
||||
"fastapi-vue~=1.7.2",
|
||||
"fastapi[standard]>=0.128.0",
|
||||
"httpx[http2]>=0.28.1",
|
||||
"msgspec>=0.19",
|
||||
"parse-torrent-title>=2.8.1",
|
||||
"platformdirs>=4.0",
|
||||
"tomli-w>=1.2.0",
|
||||
"uvicorn[standard]>=0.40.0",
|
||||
]
|
||||
@@ -20,6 +21,9 @@ dependencies = [
|
||||
[project.scripts]
|
||||
mediahive = "mediahive.__main__:main"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://git.zi.fi/LeoVasanko/mediahive"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling", "hatch-vcs"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -33,7 +37,11 @@ artifacts = ["mediahive/frontend-build"]
|
||||
only-packages = true
|
||||
|
||||
[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]
|
||||
package = true
|
||||
@@ -43,7 +51,11 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
|
||||
|
||||
[project.optional-dependencies]
|
||||
gui = [
|
||||
"pywebview>=6.2.1",
|
||||
# pywebview's qt extra is Qt6-only (QtPy + PyQt6 + PyQt6-WebEngine);
|
||||
# Qt5 would come from its separate qt5 extra, which we do not use.
|
||||
"pywebview[qt]>=6.2.1; platform_system != 'Windows'",
|
||||
"pywebview>=6.2.1; platform_system == 'Windows'",
|
||||
"velopack>=1.2",
|
||||
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
|
||||
"pyinstaller>=6.0",
|
||||
]
|
||||
@@ -51,5 +63,62 @@ gui = [
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"httpx>=0.28.1",
|
||||
"ruff>=0.15.14",
|
||||
"setuptools-scm>=8",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
preview = true
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"D203",
|
||||
"D213",
|
||||
"DOC201",
|
||||
"COM812",
|
||||
"T201",
|
||||
"EM",
|
||||
"TC",
|
||||
"TRY003",
|
||||
"S",
|
||||
"CPY001",
|
||||
"PLR",
|
||||
"PLW",
|
||||
# TEMP suppressions - revisit and remove after focused cleanup passes.
|
||||
"C901",
|
||||
"DOC501",
|
||||
"ANN201",
|
||||
"D103",
|
||||
"FBT001",
|
||||
"ANN001",
|
||||
"D102",
|
||||
"TRY300",
|
||||
"D107",
|
||||
"ANN202",
|
||||
"B904",
|
||||
"ASYNC220",
|
||||
"E501",
|
||||
"INP001",
|
||||
"FBT002",
|
||||
"PLC0415",
|
||||
"D101",
|
||||
"RUF006",
|
||||
"SLF001",
|
||||
"ASYNC240",
|
||||
"N801",
|
||||
"SIM102",
|
||||
"DTZ005",
|
||||
"RUF034",
|
||||
"D415",
|
||||
"D400",
|
||||
"ANN401",
|
||||
# Allow en-dash in docstrings (used for list formatting)
|
||||
"RUF002",
|
||||
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
|
||||
"N806",
|
||||
# Allow inline comments that describe output formats
|
||||
"ERA001",
|
||||
# Allow unused local variables in ctypes COM boilerplate
|
||||
"F841",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class SCGITransport(xmlrpc.client.Transport):
|
||||
"""SCGI transport for communicating with rtorrent via Unix socket."""
|
||||
|
||||
def __init__(self, socket_path: str):
|
||||
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"):
|
||||
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 set(h.upper() for h in downloads)
|
||||
except Exception 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 Exception as e:
|
||||
print(f"Error loading torrent {torrent_path}: {e}")
|
||||
return False
|
||||
|
||||
def get_torrent_info(self, info_hash: str) -> Optional[Dict]:
|
||||
"""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 Exception 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 Exception:
|
||||
continue
|
||||
except Exception 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 Exception as e:
|
||||
print(f"Error removing torrent {info_hash}: {e}")
|
||||
return False
|
||||
@@ -1,37 +1,57 @@
|
||||
# MediaHive.spec — PyInstaller build for the Windows GUI application
|
||||
# MediaHive.spec — PyInstaller build for the MediaHive desktop GUI app
|
||||
#
|
||||
# Build manually (from repo root):
|
||||
# uv run --no-project --python 3.14 --with ".[gui]" --with pyinstaller ^
|
||||
# uv run --no-project --python 3.14 --with ".[gui]" --with pyinstaller \
|
||||
# pyinstaller --noconfirm --clean scripts/MediaHive.spec
|
||||
#
|
||||
# Or use the build script (recommended—handles versioning and packaging):
|
||||
# uv run scripts/build_windows_gui.py
|
||||
# uv run scripts/guibuild.py
|
||||
|
||||
import sys
|
||||
import mediahive.winmain
|
||||
import mediahive.server
|
||||
from pathlib import Path
|
||||
from PyInstaller.utils.hooks import collect_data_files
|
||||
|
||||
block_cipher = None
|
||||
|
||||
_pkg = Path(mediahive.server.__file__).parent
|
||||
_frontend_build = _pkg / "frontend-build"
|
||||
_icon = _pkg / "assets" / "mediahive.ico"
|
||||
_ffmpeg = Path(SPECPATH).parent / "build" / "ffmpeg" / "ffmpeg.exe"
|
||||
_logo_webp = _pkg / "assets" / "mediahive.webp"
|
||||
_icon_win = _pkg / "assets" / "mediahive.ico"
|
||||
_icon_mac = _pkg / "assets" / "mediahive.icns"
|
||||
# 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
|
||||
|
||||
a = Analysis(
|
||||
[mediahive.winmain.__file__],
|
||||
pathex=[],
|
||||
binaries=[
|
||||
# Bundle ffmpeg so showreel generation works without a system install.
|
||||
# Populated by build_windows_gui.py before PyInstaller runs.
|
||||
(str(_ffmpeg), "."),
|
||||
],
|
||||
datas=[
|
||||
_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"]
|
||||
|
||||
_binaries = []
|
||||
for _tool_name in _tool_names:
|
||||
_tool_path = _tools_dir / _tool_name
|
||||
if _tool_path.exists():
|
||||
_binaries.append((str(_tool_path), "."))
|
||||
|
||||
_datas = [
|
||||
# Bundled Vue frontend served by the FastAPI backend
|
||||
(str(_frontend_build), "mediahive/frontend-build"),
|
||||
(str(_icon), "mediahive/assets"),
|
||||
],
|
||||
hiddenimports=[
|
||||
]
|
||||
# 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():
|
||||
_datas.append((str(_icon_win), "mediahive/assets"))
|
||||
if _icon_mac.exists():
|
||||
_datas.append((str(_icon_mac), "mediahive/assets"))
|
||||
if _logo_webp.exists():
|
||||
_datas.append((str(_logo_webp), "mediahive/assets"))
|
||||
|
||||
_hiddenimports = [
|
||||
# uvicorn dynamic imports
|
||||
"uvicorn.logging",
|
||||
"uvicorn.loops",
|
||||
@@ -60,7 +80,29 @@ a = Analysis(
|
||||
"starlette.routing",
|
||||
# msgspec TOML write backend
|
||||
"tomli_w",
|
||||
],
|
||||
]
|
||||
|
||||
if sys.platform == "darwin":
|
||||
_hiddenimports.extend(
|
||||
[
|
||||
# pywebview Qt backend selected dynamically via webview.start(gui="qt")
|
||||
"webview.platforms.qt",
|
||||
"qtpy",
|
||||
"PyQt6",
|
||||
"PyQt6.QtCore",
|
||||
"PyQt6.QtGui",
|
||||
"PyQt6.QtWidgets",
|
||||
"PyQt6.QtWebEngineCore",
|
||||
"PyQt6.QtWebEngineWidgets",
|
||||
]
|
||||
)
|
||||
|
||||
a = Analysis(
|
||||
[mediahive.winmain.__file__],
|
||||
pathex=[],
|
||||
binaries=_binaries,
|
||||
datas=_datas,
|
||||
hiddenimports=_hiddenimports,
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
@@ -80,12 +122,21 @@ exe = EXE(
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
icon=str(_icon),
|
||||
icon=(
|
||||
str(_icon_mac)
|
||||
if sys.platform == "darwin" and _icon_mac.exists()
|
||||
else str(_icon_win) if _icon_win.exists() else None
|
||||
),
|
||||
# windowed=True hides the console; the backend subprocess inherits this
|
||||
console=False,
|
||||
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(
|
||||
exe,
|
||||
a.binaries,
|
||||
@@ -93,6 +144,14 @@ coll = COLLECT(
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
upx_exclude=_upx_exclude,
|
||||
name="MediaHive",
|
||||
)
|
||||
|
||||
if sys.platform == "darwin":
|
||||
app = BUNDLE(
|
||||
coll,
|
||||
name="MediaHive.app",
|
||||
icon=str(_icon_mac) if _icon_mac.exists() else None,
|
||||
bundle_identifier="fi.zi.mediahive",
|
||||
)
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
import tracerite
|
||||
|
||||
# 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")))
|
||||
from devutil import ( # type: ignore
|
||||
from devutil import (
|
||||
ProcessGroup,
|
||||
check_ports_free,
|
||||
logger,
|
||||
@@ -22,11 +24,15 @@ from devutil import ( # type: ignore
|
||||
|
||||
DEFAULT_VITE_PORT = 8420
|
||||
DEFAULT_DEV_PORT = 8421
|
||||
HEALTH = "/api/health?from=devserver.py"
|
||||
|
||||
|
||||
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:
|
||||
"""Start Vite and FastAPI dev servers with hot reload."""
|
||||
reporoot = Path(__file__).parent.parent
|
||||
front = reporoot / "frontend"
|
||||
if not (front / "package.json").exists():
|
||||
@@ -36,20 +42,22 @@ async def run_devserver(
|
||||
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_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_BACKEND_URL"] = backurl
|
||||
os.environ["MEDIAHIVE_DEV"] = "1"
|
||||
|
||||
async with ProcessGroup() as pg:
|
||||
pg.create_task(check_ports_free(viteurl, backurl))
|
||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
await pg.spawn(*mediahive, *(extra_args or []))
|
||||
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
|
||||
await pg.spawn(*vite, cwd=front)
|
||||
await pg.spawn(*mediahive, *(extra_args or []), vital=True)
|
||||
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
||||
await pg.spawn(*vite, cwd=front, vital=True)
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
"""Parse CLI arguments and run the devserver."""
|
||||
tracerite.load()
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run Vite and FastAPI development servers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
@@ -58,21 +66,25 @@ def main():
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--listen",
|
||||
metavar="host:port",
|
||||
metavar="addr",
|
||||
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
metavar="host:port",
|
||||
metavar="addr",
|
||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||
)
|
||||
args, extra_args = parser.parse_known_args()
|
||||
with suppress(KeyboardInterrupt):
|
||||
try:
|
||||
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
||||
except* KeyboardInterrupt:
|
||||
pass # user stopped the devserver: normal exit
|
||||
except* subprocess.SubprocessError, RuntimeError:
|
||||
raise SystemExit(1) from None # logged in devutil already; exit 1
|
||||
|
||||
|
||||
HELP_EPILOG = """
|
||||
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:
|
||||
npm, deno, bun, or full path to the runtime executable (node maps to npm).
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from buildutil import build
|
||||
|
||||
|
||||
class CustomBuildHook(BuildHookInterface):
|
||||
def initialize(self, version, build_data):
|
||||
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
|
||||
from pathlib import Path
|
||||
|
||||
MIN_NODE_VERSION = 20
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
|
||||
class _Formatter(logging.Formatter):
|
||||
"""Prefix formatter, intentionally different from fastapi_vue.logging.
|
||||
|
||||
INFO and below pass through unprefixed so messages can use their own
|
||||
markings (>>>, ###); WARNING and above get an emoji prefix.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if record.levelno >= logging.ERROR:
|
||||
return f"🛑 {record.getMessage()}"
|
||||
if record.levelno >= logging.WARNING:
|
||||
return f"⚠️ {record.getMessage()}"
|
||||
return f"💣 {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
_handler.setFormatter(_Formatter())
|
||||
logger = logging.getLogger("fastapi-vue")
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False # own handler; do not double-print via a configured root
|
||||
|
||||
|
||||
def _check_node_version(node_path: str) -> None:
|
||||
@@ -31,81 +40,118 @@ def _check_node_version(node_path: str) -> None:
|
||||
"""
|
||||
try:
|
||||
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()
|
||||
# Parse version like "v20.10.0" or "v18.17.1"
|
||||
match = re.match(r"v(\d+)", version_str)
|
||||
if match:
|
||||
major_version = int(match.group(1))
|
||||
if major_version >= 20:
|
||||
if major_version >= MIN_NODE_VERSION:
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
||||
)
|
||||
msg = f"Node.js {version_str} found, but v{MIN_NODE_VERSION}+ required"
|
||||
raise RuntimeError(msg)
|
||||
except subprocess.CalledProcessError, FileNotFoundError, ValueError:
|
||||
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]:
|
||||
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||
|
||||
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"]
|
||||
node_version_error: RuntimeError | None = None
|
||||
|
||||
# Check for JS_RUNTIME environment variable
|
||||
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
||||
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 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")
|
||||
if result := _find_runtime_from_env(options):
|
||||
return result
|
||||
|
||||
# Auto-detect
|
||||
for option in 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")
|
||||
return _auto_detect_runtime(options)
|
||||
|
||||
|
||||
def find_build_tool():
|
||||
def find_build_tool() -> tuple[list[str], list[str]]:
|
||||
"""Find JavaScript runtime and construct install/build commands.
|
||||
|
||||
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||
@@ -143,7 +189,7 @@ def find_dev_tool() -> list[str]:
|
||||
|
||||
if name == "bun":
|
||||
logger.warning(
|
||||
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
|
||||
"Bun has a WS proxy bug (github.com/oven-sh/bun/issues/9882). Consider npm.",
|
||||
)
|
||||
|
||||
return [tool, *dev_args[name]]
|
||||
@@ -176,9 +222,9 @@ def build(folder: str = "frontend") -> None:
|
||||
install_cmd, build_cmd = find_build_tool()
|
||||
except RuntimeError as e:
|
||||
logger.warning(e)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
def run(cmd):
|
||||
def run(cmd: list[str]) -> None:
|
||||
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||
logger.info("### %s", " ".join(display_cmd))
|
||||
subprocess.run(cmd, check=True, cwd=folder)
|
||||
@@ -188,4 +234,4 @@ def build(folder: str = "frontend") -> None:
|
||||
logger.info("")
|
||||
run(build_cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
@@ -1,140 +1,154 @@
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Coroutine
|
||||
from asyncio.subprocess import Process
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
from subprocess import CalledProcessError
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable
|
||||
|
||||
|
||||
class ProcessGroup:
|
||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||
class ProcessGroup(asyncio.TaskGroup):
|
||||
"""TaskGroup with structured ownership of async subprocesses."""
|
||||
|
||||
def __init__(self):
|
||||
self._procs: list[asyncio.subprocess.Process] = []
|
||||
self._cmds: dict[int, str] = {} # pid -> command name
|
||||
def __init__(self, *, terminate_timeout: float = 10) -> None:
|
||||
"""Set the grace period before terminate() escalates to kill()."""
|
||||
super().__init__()
|
||||
self._terminate_timeout = terminate_timeout
|
||||
self._cmds: dict[Process, tuple[str, ...]] = {}
|
||||
|
||||
async def spawn(
|
||||
self, *cmd: str, cwd: str | None = None
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a subprocess and track it."""
|
||||
cmd_name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._procs.append(proc)
|
||||
self._cmds[proc.pid] = cmd_name
|
||||
return proc
|
||||
self, *cmd: str, cwd: str | None = None, vital: bool = False
|
||||
) -> Process:
|
||||
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
|
||||
|
||||
async def wait(
|
||||
self, *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]"
|
||||
) -> None:
|
||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||
|
||||
async def 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
|
||||
]
|
||||
async def run() -> None:
|
||||
name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, *_):
|
||||
"""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):
|
||||
running = [p for p in self._procs if p.returncode is None]
|
||||
if not running:
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._cmds[proc] = cmd
|
||||
started.set_result(proc)
|
||||
except Exception as e: # ruff: ignore[blind-except]
|
||||
started.set_exception(e)
|
||||
return
|
||||
|
||||
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:
|
||||
await asyncio.shield(
|
||||
asyncio.wait_for(
|
||||
asyncio.gather(*[p.wait() for p in still_running]),
|
||||
timeout=10,
|
||||
)
|
||||
)
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
returncode = await proc.wait()
|
||||
finally:
|
||||
with suppress(ProcessLookupError):
|
||||
p.kill()
|
||||
await p.wait()
|
||||
proc.terminate()
|
||||
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:
|
||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||
"""Verify URLs are not responding (ports are free).
|
||||
|
||||
async def check(client: httpx.AsyncClient, url: str) -> None:
|
||||
with suppress(httpx.RequestError):
|
||||
res = await client.get(url, timeout=0.1)
|
||||
server = res.headers.get("server", "server")
|
||||
logger.warning("Conflicting %s already running at %s", server, url)
|
||||
raise SystemExit(1)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
await asyncio.gather(*[check(client, url) for url in urls])
|
||||
Meant to run as a task inside a TaskGroup. Logs the conflict and raises
|
||||
RuntimeError (handled like a failed process) if any URL responds.
|
||||
"""
|
||||
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
|
||||
for url, server in zip(urls, servers, strict=True):
|
||||
if server is not None:
|
||||
logger.error(
|
||||
"Conflicting %s already running at %s", server or "server", url
|
||||
)
|
||||
raise RuntimeError(url)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
full_url = f"{url}{path}"
|
||||
if not path:
|
||||
return
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
await client.get(full_url, timeout=1.0)
|
||||
logger.info("✓ Backend ready!")
|
||||
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
||||
logger.info("🟢 Backend ready!")
|
||||
return
|
||||
except httpx.RequestError:
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
logger.error("Backend at %s didn't start in time", url)
|
||||
raise RuntimeError(url)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
def setup_vite(
|
||||
endpoint: str, default_port: int = 5173
|
||||
endpoint: str,
|
||||
default_port: int = 5173,
|
||||
) -> tuple[str, list[str], list[str]]:
|
||||
"""Parse frontend endpoint and build commands.
|
||||
|
||||
@@ -160,7 +174,9 @@ def setup_vite(
|
||||
|
||||
|
||||
def setup_fastapi(
|
||||
endpoint: str, module: str, default_port: int = 8000
|
||||
endpoint: str,
|
||||
module: str,
|
||||
default_port: int = 8000,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Parse backend endpoint and build uvicorn command.
|
||||
|
||||
@@ -175,7 +191,7 @@ def setup_fastapi(
|
||||
|
||||
host = endpoints[0]["host"]
|
||||
port = endpoints[0]["port"]
|
||||
reload_dir = module.split(".")[0] # Don't reload on frontend changes
|
||||
reload_dir = module.split(".", maxsplit=1)[0] # Don't reload on frontend changes
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
@@ -192,7 +208,9 @@ def setup_fastapi(
|
||||
|
||||
|
||||
def setup_cli(
|
||||
cli: str, endpoint: str, default_port: int = 8000
|
||||
cli: str,
|
||||
endpoint: str,
|
||||
default_port: int = 8000,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Parse backend endpoint and build CLI command.
|
||||
|
||||
@@ -208,5 +226,7 @@ def setup_cli(
|
||||
host = endpoints[0]["host"]
|
||||
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
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Build the desktop GUI application and package it with Velopack.
|
||||
|
||||
Usage:
|
||||
uv run scripts/guibuild.py
|
||||
|
||||
This runs in the project environment where dependencies
|
||||
are available via pyproject.toml.
|
||||
|
||||
This script:
|
||||
1. Reads the version from pyproject.toml
|
||||
2. Runs `uv build` to produce the wheel/sdist
|
||||
3. On Windows/macOS, downloads the ffmpeg binary for bundling
|
||||
4. Builds MediaHive using PyInstaller
|
||||
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 os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import setuptools_scm
|
||||
from platformdirs import user_cache_path
|
||||
|
||||
# BtbN automated builds always publish a 'latest' tag with this asset.
|
||||
_FFMPEG_URL = (
|
||||
"https://github.com/BtbN/ffmpeg-builds/releases/download/latest"
|
||||
"/ffmpeg-master-latest-win64-gpl.zip"
|
||||
)
|
||||
_MACOS_ARM64_TOOL_URLS = {
|
||||
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
|
||||
}
|
||||
_REPO_ROOT = Path(__file__).parent.parent
|
||||
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
|
||||
|
||||
|
||||
def _build_cache_dir() -> Path:
|
||||
"""Return the persistent cross-build cache dir for downloaded tools (CI wipes build/)."""
|
||||
return user_cache_path("mediahive-build", appauthor=False, opinion=False)
|
||||
|
||||
|
||||
_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":
|
||||
return _Platform("win64", "win", "win-x64", "MediaHive", "mediahive.ico", "MediaHive.exe", ".exe")
|
||||
if sys.platform == "darwin":
|
||||
return _Platform("macos", "osx", "osx-arm64", "MediaHive.app", "mediahive.icns", "MediaHive", ".pkg")
|
||||
return _Platform("linux", "linux", "linux-x64", "MediaHive", "mediahive.png", "MediaHive", ".AppImage")
|
||||
|
||||
|
||||
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:
|
||||
"""Download latest ffmpeg.exe from BtbN builds into the persistent build cache."""
|
||||
dest = _FFMPEG_STAGING / "ffmpeg.exe"
|
||||
if dest.exists():
|
||||
print(f"ffmpeg already staged at {dest}, skipping download.")
|
||||
return dest
|
||||
|
||||
_FFMPEG_STAGING.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Downloading ffmpeg from {_FFMPEG_URL} ...")
|
||||
with urllib.request.urlopen(_FFMPEG_URL) as resp:
|
||||
data = resp.read()
|
||||
|
||||
print("Extracting ffmpeg.exe ...")
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
# The zip contains a top-level folder; ffmpeg.exe is under .../bin/
|
||||
ffmpeg_entry = next(
|
||||
name for name in zf.namelist() if name.endswith("/bin/ffmpeg.exe")
|
||||
)
|
||||
with zf.open(ffmpeg_entry) as src:
|
||||
Path(dest).write_bytes(src.read())
|
||||
|
||||
print(f"ffmpeg staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)")
|
||||
return dest
|
||||
|
||||
|
||||
def fetch_macos_arm64_binaries() -> dict[str, Path]:
|
||||
"""Download prebuilt macOS arm64 ffmpeg binary into the persistent build cache."""
|
||||
if sys.platform != "darwin" or platform.machine().lower() not in {
|
||||
"arm64",
|
||||
"aarch64",
|
||||
}:
|
||||
raise RuntimeError("macOS bundling is only supported for arm64 builds")
|
||||
|
||||
_FFMPEG_STAGING.mkdir(parents=True, exist_ok=True)
|
||||
staged: dict[str, Path] = {}
|
||||
|
||||
for tool_name, url in _MACOS_ARM64_TOOL_URLS.items():
|
||||
dest = _FFMPEG_STAGING / tool_name
|
||||
if dest.exists():
|
||||
print(f"{tool_name} already staged at {dest}, skipping download.")
|
||||
staged[tool_name] = dest
|
||||
continue
|
||||
|
||||
print(f"Downloading {tool_name} from {url} ...")
|
||||
with urllib.request.urlopen(url) as resp:
|
||||
data = resp.read()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
entry_name = next(
|
||||
name
|
||||
for name in zf.namelist()
|
||||
if Path(name).name == tool_name and not name.endswith("/")
|
||||
)
|
||||
with zf.open(entry_name) as src:
|
||||
Path(dest).write_bytes(src.read())
|
||||
|
||||
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
# Make the bundled binary runnable when copied out of the zip/app on macOS.
|
||||
subprocess.run(["xattr", "-cr", str(dest)], check=False)
|
||||
subprocess.run(["codesign", "-f", "-s", "-", str(dest)], check=True)
|
||||
|
||||
staged[tool_name] = dest
|
||||
print(
|
||||
f"{tool_name} staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)"
|
||||
)
|
||||
|
||||
return staged
|
||||
|
||||
|
||||
def ensure_macos_icon() -> Path:
|
||||
"""Create mediahive.icns from mediahive.ico when building on macOS."""
|
||||
icon_icns = _ASSETS_DIR / "mediahive.icns"
|
||||
if icon_icns.exists():
|
||||
return icon_icns
|
||||
|
||||
icon_ico = _ASSETS_DIR / "mediahive.ico"
|
||||
if not icon_ico.exists():
|
||||
raise FileNotFoundError(f"Missing source icon: {icon_ico}")
|
||||
|
||||
iconset_dir = _REPO_ROOT / "build" / "mediahive.iconset"
|
||||
iconset_dir.mkdir(parents=True, exist_ok=True)
|
||||
base_png = _REPO_ROOT / "build" / "mediahive-icon-1024.png"
|
||||
|
||||
subprocess.run(
|
||||
["sips", "-s", "format", "png", str(icon_ico), "--out", str(base_png)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
size_entries = [
|
||||
(16, "icon_16x16.png"),
|
||||
(32, "icon_16x16@2x.png"),
|
||||
(32, "icon_32x32.png"),
|
||||
(64, "icon_32x32@2x.png"),
|
||||
(128, "icon_128x128.png"),
|
||||
(256, "icon_128x128@2x.png"),
|
||||
(256, "icon_256x256.png"),
|
||||
(512, "icon_256x256@2x.png"),
|
||||
(512, "icon_512x512.png"),
|
||||
(1024, "icon_512x512@2x.png"),
|
||||
]
|
||||
for pixels, name in size_entries:
|
||||
subprocess.run(
|
||||
[
|
||||
"sips",
|
||||
"-z",
|
||||
str(pixels),
|
||||
str(pixels),
|
||||
str(base_png),
|
||||
"--out",
|
||||
str(iconset_dir / name),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["iconutil", "-c", "icns", str(iconset_dir), "-o", str(icon_icns)],
|
||||
check=True,
|
||||
)
|
||||
print(f"macOS app icon generated: {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:
|
||||
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
|
||||
return setuptools_scm.get_version(root=str(_REPO_ROOT))
|
||||
|
||||
|
||||
def build_wheel() -> None:
|
||||
"""Run uv build to produce the wheel and sdist."""
|
||||
repo_root = _REPO_ROOT
|
||||
cmd = ["uv", "build"]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, cwd=repo_root)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"uv build failed with exit code {result.returncode}")
|
||||
|
||||
|
||||
def build_executable() -> None:
|
||||
"""Run PyInstaller to build the desktop GUI app."""
|
||||
repo_root = _REPO_ROOT
|
||||
spec_file = Path(__file__).parent / "MediaHive.spec"
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
"--distpath",
|
||||
str(repo_root / "build"),
|
||||
"--workpath",
|
||||
str(repo_root / "build" / ".pyinstaller-work"),
|
||||
str(spec_file),
|
||||
]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, cwd=repo_root)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
|
||||
|
||||
|
||||
def create_portable_zip() -> Path:
|
||||
"""Create the Windows portable ZIP of the build/MediaHive folder.
|
||||
|
||||
Velopack-less plain-folder distribution for users who cannot or do not
|
||||
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():
|
||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||
|
||||
zip_path = _REPO_ROOT / "build" / "MediaHive-win64-portable.zip"
|
||||
print(f"Creating {zip_path}...")
|
||||
shutil.make_archive(
|
||||
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
|
||||
"zip",
|
||||
root_dir=str(dist_folder), # zip contents of MediaHive/, not the folder itself
|
||||
)
|
||||
return zip_path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Windows consoles default to cp1252, which can't encode ✓/✗
|
||||
sys.stdout.reconfigure(errors="replace")
|
||||
sys.stderr.reconfigure(errors="replace")
|
||||
try:
|
||||
version = read_version()
|
||||
print(f"MediaHive version: {version}")
|
||||
|
||||
if sys.platform == "win32":
|
||||
fetch_ffmpeg()
|
||||
elif sys.platform == "darwin":
|
||||
fetch_macos_arm64_binaries()
|
||||
ensure_macos_icon()
|
||||
else:
|
||||
print(
|
||||
"Skipping ffmpeg bundling on this platform "
|
||||
"(uses system ffmpeg if available)."
|
||||
)
|
||||
build_wheel()
|
||||
build_executable()
|
||||
|
||||
artifacts = [build_velopack(version)]
|
||||
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:
|
||||
print(f"✗ Build failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Publish a MediaHive release to Gitea.
|
||||
|
||||
Usage:
|
||||
uv run scripts/release.py [--draft] [--notes "Release notes"]
|
||||
|
||||
Reads from [project.urls] Repository in pyproject.toml.
|
||||
|
||||
Token: GITEA_TOKEN environment variable
|
||||
|
||||
Steps:
|
||||
1. Read the clean tag version via setuptools_scm, find platform artifacts
|
||||
in build/ and matching dist/ wheels/sdists
|
||||
2. Abort if any dist files are missing
|
||||
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
|
||||
|
||||
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 os
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import setuptools_scm
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config / token helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_gitea_config() -> dict:
|
||||
pyproject = REPO_ROOT / "pyproject.toml"
|
||||
with Path(pyproject).open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
repo_url = data.get("project", {}).get("urls", {}).get("Repository")
|
||||
if not repo_url:
|
||||
raise RuntimeError("[project.urls] Repository missing from pyproject.toml")
|
||||
parsed = urlparse(repo_url.rstrip("/"))
|
||||
parts = parsed.path.lstrip("/").split("/", 1)
|
||||
if len(parts) != 2:
|
||||
raise RuntimeError(
|
||||
"[project.urls] Repository must include owner and repo, e.g. https://git.example.com/owner/repo"
|
||||
)
|
||||
return {
|
||||
"url": f"{parsed.scheme}://{parsed.netloc}",
|
||||
"repo": f"{parts[0]}/{parts[1]}",
|
||||
}
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
token = os.environ.get("GITEA_TOKEN", "").strip()
|
||||
if not token:
|
||||
raise RuntimeError("GITEA_TOKEN environment variable is not set")
|
||||
return token
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ZIP + dist helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Installer artifacts are versionless (MediaHive-win64-setup.exe,
|
||||
# MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage,
|
||||
# MediaHive-win64-portable.zip) so /releases/download/latest/<name> links
|
||||
# stay valid. The version comes from setuptools_scm instead.
|
||||
_ARTIFACT_RE = re.compile(r"^MediaHive-(?!\d)[A-Za-z0-9._-]+\.(?:zip|dmg|exe|pkg|AppImage)$")
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
"""Read version via setuptools_scm, refusing dev/dirty versions."""
|
||||
version = setuptools_scm.get_version(root=str(REPO_ROOT))
|
||||
if not re.fullmatch(r"\d+(?:\.\d+)*", version):
|
||||
raise RuntimeError(
|
||||
f"Refusing to release non-clean version {version!r}. Tag a release first."
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def find_releasable_artifacts() -> list[Path]:
|
||||
"""Return platform artifact paths in build/."""
|
||||
build_dir = REPO_ROOT / "build"
|
||||
return [p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)]
|
||||
|
||||
|
||||
def find_dist_files(version: str) -> list[Path]:
|
||||
"""Return wheel and sdist paths in dist/ for the given version.
|
||||
|
||||
Raises FileNotFoundError listing every missing file if any are absent.
|
||||
"""
|
||||
dist_dir = REPO_ROOT / "dist"
|
||||
wheel = next((p for p in dist_dir.glob(f"mediahive-{version}-*.whl")), None)
|
||||
sdist = next(
|
||||
(
|
||||
p
|
||||
for p in dist_dir.glob(f"mediahive-{version}.*")
|
||||
if p.suffix in {".gz", ".zip"} and p.name != f"mediahive-{version}.zip"
|
||||
),
|
||||
None,
|
||||
)
|
||||
missing = []
|
||||
if wheel is None:
|
||||
missing.append(f" dist/mediahive-{version}-*.whl")
|
||||
if sdist is None:
|
||||
missing.append(f" dist/mediahive-{version}.tar.gz (or .zip)")
|
||||
if missing:
|
||||
raise FileNotFoundError(
|
||||
f"Missing dist files for version {version}:\n"
|
||||
+ "\n".join(missing)
|
||||
+ "\nRun: uv build"
|
||||
)
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def gitea_headers(token: str) -> dict:
|
||||
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(
|
||||
client: httpx.Client,
|
||||
base_url: str,
|
||||
repo: str,
|
||||
tag: str,
|
||||
version: str,
|
||||
notes: str,
|
||||
draft: bool,
|
||||
) -> tuple[int, set[str]]:
|
||||
"""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"
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"name": f"MediaHive {version}",
|
||||
"body": notes,
|
||||
"draft": draft,
|
||||
"prerelease": False,
|
||||
}
|
||||
resp = client.post(url, json=payload)
|
||||
if resp.status_code == 409:
|
||||
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()
|
||||
release_id = resp.json()["id"]
|
||||
print(f"Created release id={release_id} (draft={draft})")
|
||||
return release_id, set()
|
||||
|
||||
|
||||
def upload_asset(
|
||||
client: httpx.Client,
|
||||
base_url: str,
|
||||
repo: str,
|
||||
release_id: int,
|
||||
path: Path,
|
||||
) -> str:
|
||||
"""Upload a file to the release and return the download URL."""
|
||||
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
|
||||
size_mb = path.stat().st_size / (1024 * 1024)
|
||||
mime = {
|
||||
".zip": "application/zip",
|
||||
".dmg": "application/x-apple-diskimage",
|
||||
}.get(path.suffix, "application/octet-stream")
|
||||
print(f"Uploading {path.name} ({size_mb:.1f} MB) ...")
|
||||
with Path(path).open("rb") as fh:
|
||||
resp = client.post(
|
||||
url,
|
||||
files={"attachment": (path.name, fh, mime)},
|
||||
timeout=300,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
download_url = resp.json()["browser_download_url"]
|
||||
print(f" -> {download_url}")
|
||||
return download_url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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.add_argument(
|
||||
"--draft", action="store_true", help="Create as a draft release"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--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()
|
||||
|
||||
try:
|
||||
cfg = load_gitea_config()
|
||||
token = load_token()
|
||||
version = read_version()
|
||||
|
||||
artifacts = find_releasable_artifacts()
|
||||
if not artifacts:
|
||||
print(
|
||||
"No platform artifacts found in build/.\n"
|
||||
"Run scripts/guibuild.py first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Validate all dist files exist before touching Gitea
|
||||
dist_files: list[Path] = [] if args.no_dist else find_dist_files(version)
|
||||
|
||||
base_url = cfg["url"].rstrip("/")
|
||||
repo = cfg["repo"]
|
||||
|
||||
with httpx.Client(headers=gitea_headers(token)) as client:
|
||||
print(f"\nReleasing {version} ...")
|
||||
tag = f"v{version}"
|
||||
release_id, uploaded = create_release(
|
||||
client, base_url, repo, tag, version, args.notes, args.draft
|
||||
)
|
||||
for path in dist_files:
|
||||
if path.name in uploaded:
|
||||
print(f"Skipping {path.name}, already on the release.")
|
||||
continue
|
||||
upload_asset(client, base_url, repo, release_id, path)
|
||||
|
||||
for artifact_path in artifacts:
|
||||
if artifact_path.name in uploaded:
|
||||
print(f"Skipping {artifact_path.name}, already on the release.")
|
||||
continue
|
||||
print(f"Uploading platform artifact: {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("\nDone. To publish to PyPI, run:")
|
||||
print(" uv publish")
|
||||
|
||||
except (FileNotFoundError, OSError, RuntimeError, ValueError, httpx.HTTPError) as e:
|
||||
print(f"✗ Release failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,457 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Torrent Scanner - Scans for .torrent files and analyzes their trackers.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import hashlib
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator
|
||||
|
||||
import bencodepy
|
||||
from rtorrent_client import RTorrentClient
|
||||
|
||||
|
||||
@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"
|
||||
else:
|
||||
# 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:
|
||||
with open(filepath, "rb") as f:
|
||||
data = bencodepy.decode(f.read())
|
||||
except Exception 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 dir_path in glob.glob(pattern):
|
||||
torrent_dir = Path(dir_path)
|
||||
if torrent_dir.is_dir():
|
||||
for torrent_file in torrent_dir.glob("*.torrent"):
|
||||
yield torrent_file
|
||||
|
||||
|
||||
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():
|
||||
"""Main entry point for the torrent scanner."""
|
||||
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 = glob.glob(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, {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)")
|
||||
else:
|
||||
# Remove from rtorrent (keeps downloaded files)
|
||||
if 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 Exception:
|
||||
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 Exception 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, {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 Exception as e:
|
||||
print(f"Failed to remove {torrent.path}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Build the Windows GUI application and package it as a version-numbered ZIP.
|
||||
|
||||
Usage:
|
||||
uv run scripts/build_windows_gui.py
|
||||
|
||||
This runs in the project environment where dependencies are available via pyproject.toml.
|
||||
|
||||
This script:
|
||||
1. Reads the version from pyproject.toml
|
||||
2. Runs `uv build` to produce the wheel/sdist
|
||||
3. Downloads the latest ffmpeg.exe
|
||||
4. Builds MediaHive.exe using PyInstaller
|
||||
5. Creates a ZIP file with the version number
|
||||
"""
|
||||
|
||||
import io
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import setuptools_scm
|
||||
|
||||
# BtbN automated builds always publish a 'latest' tag with this asset.
|
||||
_FFMPEG_URL = (
|
||||
"https://github.com/BtbN/ffmpeg-builds/releases/download/latest"
|
||||
"/ffmpeg-master-latest-win64-gpl.zip"
|
||||
)
|
||||
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
|
||||
|
||||
|
||||
def fetch_ffmpeg() -> Path:
|
||||
"""Download latest ffmpeg.exe from BtbN builds into build/ffmpeg/."""
|
||||
dest = _FFMPEG_STAGING / "ffmpeg.exe"
|
||||
if dest.exists():
|
||||
print(f"ffmpeg already staged at {dest}, skipping download.")
|
||||
return dest
|
||||
|
||||
_FFMPEG_STAGING.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Downloading ffmpeg from {_FFMPEG_URL} ...")
|
||||
with urllib.request.urlopen(_FFMPEG_URL) as resp:
|
||||
data = resp.read()
|
||||
|
||||
print("Extracting ffmpeg.exe ...")
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
# The zip contains a top-level folder; ffmpeg.exe is under .../bin/
|
||||
ffmpeg_entry = next(
|
||||
name for name in zf.namelist()
|
||||
if name.endswith("/bin/ffmpeg.exe")
|
||||
)
|
||||
with zf.open(ffmpeg_entry) as src, open(dest, "wb") as out:
|
||||
out.write(src.read())
|
||||
|
||||
print(f"ffmpeg staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)")
|
||||
return dest
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
|
||||
repo_root = Path(__file__).parent.parent
|
||||
return setuptools_scm.get_version(root=str(repo_root))
|
||||
|
||||
|
||||
def build_wheel() -> None:
|
||||
"""Run uv build to produce the wheel and sdist."""
|
||||
repo_root = Path(__file__).parent.parent
|
||||
cmd = ["uv", "build"]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, cwd=repo_root)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"uv build failed with exit code {result.returncode}")
|
||||
|
||||
|
||||
def build_exe() -> None:
|
||||
"""Run PyInstaller to build the executable."""
|
||||
repo_root = Path(__file__).parent.parent
|
||||
spec_file = Path(__file__).parent / "MediaHive.spec"
|
||||
cmd = [
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--noconfirm", "--clean",
|
||||
"--distpath", str(repo_root / "build"),
|
||||
"--workpath", str(repo_root / "build" / ".pyinstaller-work"),
|
||||
str(spec_file),
|
||||
]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, cwd=repo_root)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
|
||||
|
||||
|
||||
def create_zip(version: str) -> Path:
|
||||
"""Create a version-numbered ZIP file of the dist/MediaHive folder."""
|
||||
repo_root = Path(__file__).parent.parent
|
||||
dist_folder = repo_root / "build" / "MediaHive"
|
||||
|
||||
if not dist_folder.exists():
|
||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||
|
||||
zip_name = f"MediaHive-{version}-win64.zip"
|
||||
zip_path = repo_root / "build" / zip_name
|
||||
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Creating {zip_path}...")
|
||||
shutil.make_archive(
|
||||
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
|
||||
"zip",
|
||||
root_dir=str(dist_folder), # zip contents of MediaHive/, not the folder itself
|
||||
)
|
||||
return zip_path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
version = read_version()
|
||||
print(f"MediaHive version: {version}")
|
||||
|
||||
fetch_ffmpeg()
|
||||
build_wheel()
|
||||
build_exe()
|
||||
zip_path = create_zip(version)
|
||||
|
||||
print(f"✓ Built successfully: {zip_path}")
|
||||
print(f" Size: {zip_path.stat().st_size / (1024 * 1024):.1f} MB")
|
||||
except Exception as e:
|
||||
print(f"✗ Build failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||