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

|
||||||
|
|
||||||
# MediaHive
|
# 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.
|
||||||
|
|
||||||
**[Windows portable ZIP download](https://git.zi.fi/LeoVasanko/mediahive/releases)**
|
## Downloads
|
||||||
|
|
||||||
## Project Structure
|
- **Windows**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-win64-setup.exe) · [Portable ZIP](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-win64-portable.zip)
|
||||||
|
- **macOS**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-macos.pkg)
|
||||||
|
- **Linux**: [AppImage](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage)
|
||||||
|
|
||||||
|
You may also run or install with [UV](https://docs.astral.sh/uv/getting-started/installation/):
|
||||||
|
|
||||||
```
|
```
|
||||||
hivescan/ Indexing & previews (library + CLI)
|
uvx --from mediahive[gui] mediahive
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## What It Does
|
||||||
|
|
||||||
```bash
|
- Scans your chosen media folder for all movies and series that can be found
|
||||||
pip install -e .
|
- 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
|
||||||
|
|
||||||
### 1. Scan & Index Media
|
On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
|
||||||
|
|
||||||
```bash
|
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.
|
||||||
# Scan downloads, auto-detect common root, create .mediahive folder
|
|
||||||
hivescan /media/torrents/*
|
|
||||||
|
|
||||||
# Scan multiple locations
|
## Controls
|
||||||
hivescan /mnt/disk1/* /mnt/disk2/*
|
|
||||||
|
|
||||||
# Override output directory
|
MediaHive is designed to work with a mouse, keyboard, or gamepad.
|
||||||
hivescan /media/torrents/* -o /srv/media/.mediahive
|
|
||||||
|
|
||||||
# Skip cover/showreel generation
|
**Keyboard:** Arrow keys, Enter and Escape to navigate, `/` to search and the usual ones you already know.
|
||||||
hivescan /media/torrents/* --no-covers --no-showreels
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Serve & Browse
|

|
||||||
|
*Navigational controls work across the application. Search bar offers OSD keyboard. In-player controls are currently available only on MPC-BE, with its WebUI enabled.*
|
||||||
|
|
||||||
```bash
|
## Recommended Players
|
||||||
# Start the web server
|
|
||||||
mediahive /path/to/your/media/folder
|
|
||||||
|
|
||||||
# Server starts at http://localhost:8420
|
- Windows: [MPC-BE](https://github.com/Aleksoid1978/MPC-BE/releases)
|
||||||
```
|
- macOS: [IINA](https://iina.io/)
|
||||||
|
- Linux: SMPlayer
|
||||||
|
|
||||||
The app expects `<media-folder>/.mediahive/index.json` generated by hivescan.
|
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.
|
||||||
|
|
||||||
### 3. Development
|
## Background
|
||||||
|
|
||||||
```bash
|
This project started as a personal project that I have used for browsing my warez for some time now. After it grew in number of users, I've put serious development effort into it to provide a truly polished view, while responding to user needs.
|
||||||
cd frontend && npm install && cd ..
|
|
||||||
python scripts/devserver.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Starts Vite dev server + FastAPI backend with auto-reload.
|
Little details include flags for audio and subtitle languages (also srt) and a series view with per episode video previews while avoiding spoilers of the episodes you haven't gotten to yet:
|
||||||
|
|
||||||
## 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)
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 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": [...] }`. |
|
||||||
|
| `GET` | `/api/update` | Returns `{ "version", "auto_update", "pending_version" }` — installed version, auto-update preference, and any downloaded update staged for the next launch (Velopack GUI builds only; `null` elsewhere). |
|
||||||
|
| `PUT` | `/api/config/auto-update` | Enable/disable automatic update downloads. Body `{ "enabled": bool }`, persisted in config. |
|
||||||
|
| `POST` | `/api/update/restart` | Applies the staged update and restarts into it. `404` when no update is pending. |
|
||||||
|
| `POST` | `/api/play/{root_id}` | Opens a media file with a media player. Also starts an assumed-playback session (see notes). |
|
||||||
|
| `GET` | `/api/players` | Lists detected media players. Returns `{ "players": [{id, name, family, path}] }`, including synthetic `default` and `custom` entries. |
|
||||||
|
| `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. |
|
||||||
|
| `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.
|
||||||
|
After Width: | Height: | Size: 88 KiB |
@@ -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 |
@@ -611,7 +611,9 @@ Current native command usage is centered on:
|
|||||||
|
|
||||||
- `889` for play/pause
|
- `889` for play/pause
|
||||||
- `816` for exit
|
- `816` for exit
|
||||||
- `-1&position=HH:MM:SS` for exact 4-second seeking
|
- `-1&position=HH:MM:SS` to seek to the stored resume position when playback starts
|
||||||
|
|
||||||
|
The GUI also polls `/variables.html` for the live position and duration and posts resume positions back to the MediaHive backend (`/api/meta/playback-state`), which is how per-episode resume positions and series continue points are tracked. Sessions shorter than 5 minutes are ignored.
|
||||||
|
|
||||||
## Guidance
|
## Guidance
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
After Width: | Height: | Size: 303 KiB |
@@ -2,7 +2,11 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": "deno run -A npm:vite",
|
"dev": "deno run -A npm:vite",
|
||||||
"build": "deno run -A npm:vue-tsc --noEmit && deno run -A npm:vite build",
|
"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": {
|
"imports": {
|
||||||
"vue": "npm:vue@^3.4.0"
|
"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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"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": {
|
"dependencies": {
|
||||||
|
"country-flag-icons": "^1.6.17",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"oxfmt": "^0.51.0",
|
||||||
|
"oxlint": "^1.66.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"vite-plugin-pwa": "^1.3.0",
|
|
||||||
"vue-tsc": "^2.0.0"
|
"vue-tsc": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,399 @@
|
|||||||
import type { MediaIndex } from './types';
|
export interface PlayerStatus {
|
||||||
|
remote: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTraceId(action: string): string {
|
||||||
|
const suffix = Math.random().toString(16).slice(2, 8)
|
||||||
|
return `${action}-${Date.now().toString(36)}-${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function logActionTiming(
|
||||||
|
action: string,
|
||||||
|
traceId: string,
|
||||||
|
status: number,
|
||||||
|
actionToFetchMs: number,
|
||||||
|
fetchMs: number,
|
||||||
|
totalMs: number,
|
||||||
|
serverTiming: string | null,
|
||||||
|
source?: string,
|
||||||
|
) {
|
||||||
|
const sourceTag = source ? ` source=${source}` : ""
|
||||||
|
const serverTag = serverTiming ? ` serverTiming=${serverTiming}` : ""
|
||||||
|
console.info(
|
||||||
|
`[timing:${action}] trace=${traceId}${sourceTag} status=${status} actionToFetch=${actionToFetchMs.toFixed(1)}ms fetch=${fetchMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms${serverTag}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeMediaPath(input: string): string {
|
export function normalizeMediaPath(input: string): string {
|
||||||
return input
|
return input
|
||||||
.replace(/\\/g, '/')
|
.replace(/\\/g, "/")
|
||||||
.replace(/^[A-Za-z]:\//, '')
|
.replace(/^[A-Za-z]:\//, "")
|
||||||
.replace(/^\/+/, '');
|
.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>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the media index from the server
|
* Fetch merged resume positions from all roots.
|
||||||
*/
|
*/
|
||||||
export async function loadMediaIndex(): Promise<MediaIndex> {
|
export async function fetchResumePositions(): Promise<Record<string, ResumePositionEntry>> {
|
||||||
const response = await fetch('/api/index');
|
try {
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report that the user is actively interacting with the UI.
|
||||||
|
*
|
||||||
|
* Ends any server-side assumed-playback session (launched item is assumed
|
||||||
|
* watched while the UI sees no input). Throttled; fire-and-forget.
|
||||||
|
*/
|
||||||
|
let lastActivityReportAt = 0
|
||||||
|
export function reportUserActivity(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastActivityReportAt < 5000) return
|
||||||
|
lastActivityReportAt = now
|
||||||
|
void fetch("/api/activity", { method: "POST" })
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) return
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
if (data?.finalized) {
|
||||||
|
// An assumed-playback position was just written; let views refetch.
|
||||||
|
window.dispatchEvent(new Event("mediahive:resume-updated"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the full root set atomically
|
||||||
|
*/
|
||||||
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load media index: ${response.statusText}`);
|
const err = await response.json().catch(() => ({ detail: response.statusText }))
|
||||||
|
throw new Error(err.detail || response.statusText)
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Play a media file with the system's default player
|
* Fetch detected media players from the backend.
|
||||||
*/
|
*/
|
||||||
export async function playMedia(filePath: string): Promise<void> {
|
export async function fetchPlayers(): Promise<PlayerInfo[]> {
|
||||||
const normalizedPath = normalizeMediaPath(filePath);
|
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 {
|
try {
|
||||||
const response = await fetch('/api/play', {
|
const fetchStart = nowMs()
|
||||||
method: 'POST',
|
const actionToFetchMs = Math.max(0, fetchStart - actionStart)
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const clientSentMs = Date.now()
|
||||||
body: JSON.stringify({ file_path: normalizedPath }),
|
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) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json()
|
||||||
throw new Error(error.detail || response.statusText);
|
throw new Error(error.detail || response.statusText)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Play media error:', e);
|
const totalMs = Math.max(0, nowMs() - actionStart)
|
||||||
alert(`Failed to play media.\n\n${e}`);
|
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 Windows Explorer
|
* Open a folder in the system file manager
|
||||||
*/
|
*/
|
||||||
export async function openFolder(folderPath: string): Promise<void> {
|
export async function openFolder(
|
||||||
const normalizedPath = normalizeMediaPath(folderPath);
|
rootId: string,
|
||||||
|
folderPath: string,
|
||||||
|
timing?: ActionTimingContext,
|
||||||
|
): Promise<void> {
|
||||||
|
const normalizedPath = normalizeMediaPath(folderPath)
|
||||||
|
const actionStart = timing?.actionStartedAt ?? nowMs()
|
||||||
|
const traceId = makeTraceId("open-folder")
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/open-folder', {
|
const fetchStart = nowMs()
|
||||||
method: 'POST',
|
const actionToFetchMs = Math.max(0, fetchStart - actionStart)
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 }),
|
body: JSON.stringify({ folder_path: normalizedPath }),
|
||||||
});
|
})
|
||||||
|
const fetchMs = Math.max(0, nowMs() - fetchStart)
|
||||||
|
const totalMs = Math.max(0, nowMs() - actionStart)
|
||||||
|
const serverTiming = response.headers.get("server-timing")
|
||||||
|
const responseTraceId = response.headers.get("x-mediahive-trace-id") || traceId
|
||||||
|
logActionTiming(
|
||||||
|
"open-folder",
|
||||||
|
responseTraceId,
|
||||||
|
response.status,
|
||||||
|
actionToFetchMs,
|
||||||
|
fetchMs,
|
||||||
|
totalMs,
|
||||||
|
serverTiming,
|
||||||
|
timing?.source,
|
||||||
|
)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json()
|
||||||
throw new Error(error.detail || response.statusText);
|
throw new Error(error.detail || response.statusText)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Open folder error:', e);
|
const totalMs = Math.max(0, nowMs() - actionStart)
|
||||||
alert(`Failed to open folder.\n\n${e}`);
|
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.
|
* Return whether MPC-BE local web control is currently reachable.
|
||||||
|
* @param port - Optional custom port (default 13579)
|
||||||
*/
|
*/
|
||||||
export async function isMpcBeReachable(): Promise<boolean> {
|
export async function isMpcBeReachable(port?: number | null): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/mpcbe/status');
|
const url = port ? `/api/mpcbe/status?port=${port}` : "/api/mpcbe/status"
|
||||||
if (!response.ok) return false;
|
const response = await fetch(url)
|
||||||
const data = await response.json().catch(() => ({}));
|
if (!response.ok) return false
|
||||||
return Boolean(data.reachable);
|
const data = await response.json().catch(() => ({}))
|
||||||
|
return Boolean(data.reachable)
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchResumePositions(): Promise<Record<string, number>> {
|
/**
|
||||||
try {
|
* Return player integration capabilities for the current OS.
|
||||||
const response = await fetch('/api/playback/resume-positions');
|
* @param port - Optional custom MPC-BE port (default 13579)
|
||||||
if (!response.ok) return {};
|
*/
|
||||||
const data = await response.json().catch(() => ({}));
|
export async function getPlayerStatus(port?: number | null): Promise<PlayerStatus> {
|
||||||
const resumePositions = data?.resume_positions;
|
const url = port ? `/api/player/status?port=${port}` : "/api/player/status"
|
||||||
return resumePositions && typeof resumePositions === 'object' ? resumePositions : {};
|
const response = await fetch(url)
|
||||||
} catch {
|
if (!response.ok) {
|
||||||
return {};
|
throw new Error(`Failed to load player status: ${response.statusText}`)
|
||||||
}
|
}
|
||||||
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,57 +403,65 @@ export async function fetchResumePositions(): Promise<Record<string, number>> {
|
|||||||
* The path comes from the server already converted to Windows format (Z:\...)
|
* 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
|
* 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) {
|
if (!coverPath) {
|
||||||
return '';
|
return ""
|
||||||
}
|
}
|
||||||
// Ignore TMDB relative paths (start with /) - these are bugs in the index
|
// Ignore TMDB relative paths (start with /) - these are bugs in the index
|
||||||
if (coverPath.startsWith('/')) {
|
if (coverPath.startsWith("/")) {
|
||||||
return '';
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert relative path to URL path for FastAPI server
|
const rid = rootId || "unknown"
|
||||||
// .mediahive/covers/Movies/... -> /media/.mediahive/covers/Movies/...
|
const assetPath = toRootAssetPath(coverPath)
|
||||||
let urlPath = coverPath;
|
if (assetPath) {
|
||||||
|
const split = splitAssetTypePath(assetPath)
|
||||||
// Remove drive letter (Z:) and convert backslashes to forward slashes
|
if (split) {
|
||||||
if (urlPath.match(/^[A-Za-z]:/)) {
|
return `/api/assets/${encodeURIComponent(rid)}/${encodeURIComponent(split.assetType)}/${encodePathSegments(split.relativePath)}`
|
||||||
urlPath = urlPath.substring(2);
|
}
|
||||||
}
|
|
||||||
urlPath = urlPath.replace(/\\/g, '/');
|
|
||||||
|
|
||||||
// Ensure path starts with /
|
|
||||||
if (!urlPath.startsWith('/')) {
|
|
||||||
urlPath = '/' + urlPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode URI components but preserve slashes
|
const mediaPath = normalizeCoverPath(coverPath)
|
||||||
const encodedPath = urlPath.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
return `/api/media/${encodeURIComponent(rid)}/${encodePathSegments(mediaPath)}`
|
||||||
|
|
||||||
return `/api/media${encodedPath}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invoke the native OS folder picker via pywebview, then switch the server's
|
* Invoke the native OS folder picker via pywebview.
|
||||||
* media folder in-place and reload the page. Only works inside the packaged
|
* Only works inside the packaged desktop app; returns null elsewhere.
|
||||||
* desktop app.
|
|
||||||
*/
|
*/
|
||||||
export async function pickFolderAndRestart(): Promise<void> {
|
export async function pickFolder(): Promise<string | null> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const api = (window as any).pywebview?.api;
|
const api = (window as any).pywebview?.api
|
||||||
if (!api) return;
|
if (!api) return null
|
||||||
const folder: string | null = await api.pick_folder();
|
const folder: string | null = await api.pick_folder()
|
||||||
if (!folder) return;
|
return folder
|
||||||
const res = await fetch('/api/change-folder', {
|
}
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
export interface UpdateStatus {
|
||||||
body: JSON.stringify({ folder }),
|
version: string
|
||||||
});
|
auto_update: boolean
|
||||||
if (res.ok) {
|
pending_version: string | null
|
||||||
// Give the server a moment to complete the background folder switch before reloading
|
}
|
||||||
setTimeout(() => window.location.reload(), 500);
|
|
||||||
} else {
|
/** Fetch version, auto-update preference, and any downloaded pending update. */
|
||||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
export async function fetchUpdateStatus(): Promise<UpdateStatus> {
|
||||||
alert(`Failed to change folder: ${err.detail || res.statusText}`);
|
const response = await fetch("/api/update")
|
||||||
}
|
if (!response.ok) throw new Error(`Failed to fetch update status: ${response.status}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enable or disable automatic update downloads (persisted server-side). */
|
||||||
|
export async function setAutoUpdate(enabled: boolean): Promise<void> {
|
||||||
|
const response = await fetch("/api/config/auto-update", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`Failed to save auto-update setting: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a downloaded update and restart the app into it. */
|
||||||
|
export async function restartForUpdate(): Promise<void> {
|
||||||
|
const response = await fetch("/api/update/restart", { method: "POST" })
|
||||||
|
if (!response.ok) throw new Error(`Failed to restart for update: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|||||||
|
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,192 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
ref="menuRef"
|
||||||
|
class="episode-release-menu"
|
||||||
|
:style="menuStyle"
|
||||||
|
tabindex="-1"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
>
|
||||||
|
<div class="episode-release-header">{{ episodeName }}</div>
|
||||||
|
<div v-if="releases.length > 0" class="episode-release-list">
|
||||||
|
<ReleaseVersionCard
|
||||||
|
v-for="(release, index) in releases"
|
||||||
|
:key="index"
|
||||||
|
:torrent="release"
|
||||||
|
:best="index === 0"
|
||||||
|
:selectable="!!release.playable_file"
|
||||||
|
:disabled="!release.playable_file"
|
||||||
|
compact-flags
|
||||||
|
variant="menu"
|
||||||
|
inert-card
|
||||||
|
show-actions
|
||||||
|
:play-label="getPlayLabel(release.playable_file)"
|
||||||
|
@play="emit('play', release.playable_file || '')"
|
||||||
|
@open-folder="emit('openFolder', release.playable_file || '')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="episode-release-empty">No versions available</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
|
||||||
|
import type { Torrent } from "../types"
|
||||||
|
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
episodeName: string
|
||||||
|
releases: Torrent[]
|
||||||
|
hasResumePosition: (filePath: string | null) => boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
play: [string]
|
||||||
|
openFolder: [string]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const menuRef = ref<HTMLElement | null>(null)
|
||||||
|
const menuLeft = ref(0)
|
||||||
|
const menuTop = ref(0)
|
||||||
|
const VIEWPORT_MARGIN = 12
|
||||||
|
|
||||||
|
const menuStyle = computed(() => ({
|
||||||
|
left: `${menuLeft.value}px`,
|
||||||
|
top: `${menuTop.value}px`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function getPlayLabel(filePath: string | null | undefined): string {
|
||||||
|
return props.hasResumePosition(filePath || null) ? "Continue" : "Play"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFocusableElements(): HTMLElement[] {
|
||||||
|
if (!menuRef.value) return []
|
||||||
|
return Array.from(menuRef.value.querySelectorAll<HTMLElement>(".ctx-btn:not(:disabled)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusNext(delta: number) {
|
||||||
|
const elements = getFocusableElements()
|
||||||
|
if (elements.length === 0) return
|
||||||
|
const currentIndex = elements.findIndex((el) => el === document.activeElement)
|
||||||
|
const nextIndex =
|
||||||
|
currentIndex < 0 ? 0 : (currentIndex + delta + elements.length) % elements.length
|
||||||
|
elements[nextIndex].focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
event.preventDefault()
|
||||||
|
focusNext(event.shiftKey ? -1 : 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||||
|
event.preventDefault()
|
||||||
|
focusNext(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault()
|
||||||
|
focusNext(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
emit("close")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampToViewport() {
|
||||||
|
const menu = menuRef.value
|
||||||
|
if (!menu) return
|
||||||
|
|
||||||
|
const width = menu.offsetWidth
|
||||||
|
const height = menu.offsetHeight
|
||||||
|
|
||||||
|
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN)
|
||||||
|
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)
|
||||||
|
|
||||||
|
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft)
|
||||||
|
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleViewportChange() {
|
||||||
|
if (!props.visible) return
|
||||||
|
clampToViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.visible, props.x, props.y, props.episodeName, props.releases.length],
|
||||||
|
async ([visible]) => {
|
||||||
|
if (!visible) return
|
||||||
|
await nextTick()
|
||||||
|
clampToViewport()
|
||||||
|
// Focus first action button for keyboard navigation
|
||||||
|
const firstBtn = menuRef.value?.querySelector(".ctx-btn:not(:disabled)") as HTMLElement | null
|
||||||
|
firstBtn?.focus()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
window.addEventListener("resize", handleViewportChange)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.removeEventListener("resize", handleViewportChange)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("resize", handleViewportChange)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.episode-release-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(20, 20, 30, 0.98);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 420px;
|
||||||
|
max-width: min(820px, calc(100vw - 24px));
|
||||||
|
max-height: calc(100vh - 24px);
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-release-header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-release-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-release-empty {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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,647 @@
|
|||||||
|
<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);
|
||||||
|
}
|
||||||
|
.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>
|
<template>
|
||||||
<div
|
<component
|
||||||
|
:is="href ? 'a' : 'div'"
|
||||||
class="media-card"
|
class="media-card"
|
||||||
v-bind="navAttributes"
|
v-bind="navAttributes"
|
||||||
:data-item-id="item.id"
|
:data-item-id="item.id"
|
||||||
@click="$emit('click')"
|
:data-item-type="item.type"
|
||||||
|
:href="href || undefined"
|
||||||
|
@click="handleClick"
|
||||||
@keydown.enter.prevent="$emit('click')"
|
@keydown.enter.prevent="$emit('click')"
|
||||||
>
|
>
|
||||||
<div class="media-card-poster">
|
<div class="media-card-poster">
|
||||||
<!-- SVG focus outline -->
|
|
||||||
<svg class="card-focus-outline" viewBox="0 0 100 150" preserveAspectRatio="none">
|
<svg class="card-focus-outline" viewBox="0 0 100 150" preserveAspectRatio="none">
|
||||||
<rect x="0" y="0" width="100" height="150" />
|
<rect x="0" y="0" width="100" height="150" />
|
||||||
</svg>
|
</svg>
|
||||||
<img
|
<img
|
||||||
v-if="coverUrl && !imageError"
|
v-if="posterImageUrl && !imageError"
|
||||||
:src="coverUrl"
|
:src="posterImageUrl"
|
||||||
:alt="item.title || 'Unknown'"
|
:alt="item.title || 'Unknown'"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
@error="imageError = true"
|
@error="imageError = true"
|
||||||
/>
|
/>
|
||||||
<div v-else class="media-card-placeholder">
|
<div v-else class="media-card-placeholder">
|
||||||
{{ item.type === 'movies' ? '🎬' : item.type === 'episode' ? '📺' : '📺' }}
|
{{ item.type === "movies" ? "🎬" : item.type === "episode" ? "📺" : "📺" }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="rating" class="media-card-rating" :class="ratingClass">
|
<div v-if="rating" class="media-card-rating" :class="ratingClass">
|
||||||
★ {{ rating.toFixed(1) }}
|
★ {{ rating.toFixed(1) }}
|
||||||
@@ -30,145 +33,202 @@
|
|||||||
<span class="media-card-title">{{ displayTitle }}</span>
|
<span class="media-card-title">{{ displayTitle }}</span>
|
||||||
<span v-if="item.year" class="media-card-year">{{ item.year }}</span>
|
<span v-if="item.year" class="media-card-year">{{ item.year }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Search match info (when searching) -->
|
|
||||||
<template v-if="item.searchMatchInfo">
|
<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">
|
<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-dim' : 'match-name'">{{
|
||||||
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'">({{ person.roles }})</span><span v-if="idx < matchedPeople.length - 1">, </span>
|
person.name
|
||||||
|
}}</span>
|
||||||
|
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'"
|
||||||
|
>({{ person.roles }})</span
|
||||||
|
><span v-if="idx < matchedPeople.length - 1">, </span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.searchMatchInfo.matchedEpisodes && item.searchMatchInfo.matchedEpisodes.length > 0" class="media-card-episodes">
|
<div
|
||||||
<div v-for="ep in item.searchMatchInfo.matchedEpisodes.slice(0, 3)" :key="ep.name" class="matched-episode">
|
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-name">{{ ep.name }}</span>
|
||||||
<span class="match-roles"> ({{ ep.location }})</span>
|
<span class="match-roles"> ({{ ep.location }})</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- Default display (browsing) -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
<div
|
||||||
<div v-if="directorAndCast" class="media-card-detail">
|
v-if="item.type === 'series' && formattedSeriesCreators"
|
||||||
<span v-if="director" class="director-name">{{ director }}</span><span v-if="director && filteredCastNames">, </span>{{ filteredCastNames }}
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from "vue"
|
||||||
import type { MediaItem, Movie, Series, EpisodeWithSeries } from '../types';
|
import type { MediaItem, Movie, Series, EpisodeWithSeries } from "../types"
|
||||||
import { getCoverUrl } from '../api';
|
import { getCoverUrl, isVideoPath } from "../api"
|
||||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
item: MediaItem;
|
item: MediaItem
|
||||||
navRow?: number;
|
navRow?: number
|
||||||
navCol?: number;
|
navCol?: number
|
||||||
}>();
|
href?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
click: [];
|
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(() => {
|
const navAttributes = computed(() => {
|
||||||
if (props.navRow !== undefined && props.navCol !== undefined) {
|
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(() => {
|
const posterImageUrl = computed(() => {
|
||||||
if (imageError.value) return null;
|
if (imageError.value) return null
|
||||||
return getCoverUrl(props.item.cover_path);
|
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(() => {
|
const rating = computed(() => {
|
||||||
if (props.item.type === 'movies') {
|
if (props.item.type === "movies") {
|
||||||
return (props.item.data as Movie).info?.rating;
|
return (props.item.data as Movie).info?.rating
|
||||||
}
|
}
|
||||||
if (props.item.type === 'episode') {
|
if (props.item.type === "episode") {
|
||||||
const epData = props.item.data as EpisodeWithSeries;
|
const epData = props.item.data as EpisodeWithSeries
|
||||||
return epData.episode.rating ?? epData.series.info?.rating;
|
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(() => {
|
const ratingClass = computed(() => {
|
||||||
if (!rating.value) return '';
|
if (!rating.value) return ""
|
||||||
if (rating.value >= 7.5) return 'rating-high';
|
if (rating.value >= 7.5) return "rating-high"
|
||||||
if (rating.value >= 6) return 'rating-medium';
|
if (rating.value >= 6) return "rating-medium"
|
||||||
return 'rating-low';
|
return "rating-low"
|
||||||
});
|
})
|
||||||
|
|
||||||
const displayTitle = computed(() => {
|
const displayTitle = computed(() => {
|
||||||
if (props.item.type === 'episode') {
|
if (props.item.type === "episode") {
|
||||||
const epData = props.item.data as EpisodeWithSeries;
|
const epData = props.item.data as EpisodeWithSeries
|
||||||
return epData.episode.name || `Episode ${epData.episode.episode_number}`;
|
return epData.episode.name || `Episode ${epData.episode.episode_number}`
|
||||||
}
|
}
|
||||||
return props.item.title;
|
return props.item.title
|
||||||
});
|
})
|
||||||
|
|
||||||
const subtitle = computed(() => {
|
const subtitle = computed(() => {
|
||||||
if (props.item.type === 'episode') {
|
if (props.item.type === "episode") {
|
||||||
const epData = props.item.data as EpisodeWithSeries;
|
const epData = props.item.data as EpisodeWithSeries
|
||||||
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`;
|
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`
|
||||||
}
|
}
|
||||||
// For series, show creators
|
return null
|
||||||
if (props.item.type === 'series') {
|
})
|
||||||
const creators = (props.item.data as Series).info?.creators;
|
|
||||||
return creators && creators.length > 0 ? creators.join(', ') : null;
|
const seriesCreators = computed(() => {
|
||||||
}
|
if (props.item.type !== "series") return null
|
||||||
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(() => {
|
const director = computed(() => {
|
||||||
if (props.item.type !== 'movies') return null;
|
if (props.item.type !== "movies") return null
|
||||||
return (props.item.data as Movie).info?.director;
|
return (props.item.data as Movie).info?.director
|
||||||
});
|
})
|
||||||
|
|
||||||
// Check if we have director and/or cast to display
|
|
||||||
const directorAndCast = computed(() => {
|
const directorAndCast = computed(() => {
|
||||||
if (props.item.type !== 'movies') return false;
|
if (props.item.type !== "movies") return false
|
||||||
return director.value || filteredCastNames.value;
|
return !!director.value || filteredCastNames.value.length > 0
|
||||||
});
|
})
|
||||||
|
|
||||||
// Cast names, excluding director if they appear in cast
|
|
||||||
const filteredCastNames = computed(() => {
|
const filteredCastNames = computed(() => {
|
||||||
if (props.item.type !== 'movies') return null;
|
if (props.item.type !== "movies") return []
|
||||||
const cast = (props.item.data as Movie).info?.cast;
|
const cast = (props.item.data as Movie).info?.cast
|
||||||
if (!cast || cast.length === 0) return null;
|
if (!cast || cast.length === 0) return []
|
||||||
|
|
||||||
const directorName = director.value?.toLowerCase();
|
const directorName = director.value?.toLowerCase()
|
||||||
const filteredCast = directorName
|
const filteredCast = directorName
|
||||||
? cast.filter(c => c.name.toLowerCase() !== directorName)
|
? cast.filter((c) => c.name.toLowerCase() !== directorName)
|
||||||
: cast;
|
: cast
|
||||||
|
|
||||||
if (filteredCast.length === 0) return null;
|
if (filteredCast.length === 0) return []
|
||||||
|
|
||||||
// Show first 3 cast members
|
return filteredCast.slice(0, 3).map((c) => c.name)
|
||||||
const names = filteredCast.slice(0, 3).map(c => c.name);
|
})
|
||||||
return names.join(', ');
|
|
||||||
});
|
const formattedCastNames = computed(() => {
|
||||||
|
return filteredCastNames.value.map((name) => formatPersonLabel(name))
|
||||||
|
})
|
||||||
|
|
||||||
// Matched people from search (from searchMatchInfo)
|
|
||||||
const matchedPeople = computed(() => {
|
const matchedPeople = computed(() => {
|
||||||
const info = props.item.searchMatchInfo;
|
const info = props.item.searchMatchInfo
|
||||||
if (!info || !info.matchedPeople) return null;
|
if (!info || !info.matchedPeople) return null
|
||||||
return info.matchedPeople;
|
return info.matchedPeople
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Blinking animation for focus outline */
|
|
||||||
@keyframes card-outline-blink {
|
@keyframes card-outline-blink {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -176,7 +236,6 @@ const matchedPeople = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SVG focus outline styles */
|
|
||||||
.card-focus-outline {
|
.card-focus-outline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -195,15 +254,21 @@ const matchedPeople = computed(() => {
|
|||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show outline on hover and focus */
|
.media-card-poster img,
|
||||||
.media-card:hover .card-focus-outline,
|
.media-card-poster video {
|
||||||
.media-card.nav-focused .card-focus-outline {
|
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;
|
opacity: 1;
|
||||||
animation: card-outline-blink 1s ease-in-out infinite;
|
animation: card-outline-blink 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brighter outline for keyboard focus */
|
html:not(.mouse-active) .media-card.nav-focused .card-focus-outline rect {
|
||||||
.media-card.nav-focused .card-focus-outline rect {
|
|
||||||
stroke: #ffffff;
|
stroke: #ffffff;
|
||||||
stroke-width: 5;
|
stroke-width: 5;
|
||||||
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
|
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
|
||||||
@@ -236,7 +301,6 @@ const matchedPeople = computed(() => {
|
|||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
/* Allow up to 2 lines with ellipsis */
|
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
@@ -244,12 +308,26 @@ const matchedPeople = computed(() => {
|
|||||||
line-height: 1.3;
|
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 {
|
.director-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search match styles */
|
|
||||||
.match-reason {
|
.match-reason {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@@ -264,7 +342,6 @@ const matchedPeople = computed(() => {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* When character name matched - highlight the role, dim the name */
|
|
||||||
.match-dim {
|
.match-dim {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
class="media-row"
|
class="media-row"
|
||||||
:class="{ 'media-row-wrap': wrap }"
|
:class="{ 'media-row-wrap': wrap }"
|
||||||
:data-sync-scroll-row="!wrap && rowIndex !== undefined ? 'true' : undefined"
|
:data-sync-scroll-row="!wrap && rowIndex !== undefined ? 'true' : undefined"
|
||||||
|
:data-sync-scroll-group="!wrap && rowIndex !== undefined ? 'browse' : undefined"
|
||||||
>
|
>
|
||||||
<MediaCard
|
<MediaCard
|
||||||
v-for="(item, index) in items"
|
v-for="(item, index) in items"
|
||||||
@@ -10,22 +11,31 @@
|
|||||||
:item="item"
|
:item="item"
|
||||||
:nav-row="rowIndex"
|
:nav-row="rowIndex"
|
||||||
:nav-col="index"
|
:nav-col="index"
|
||||||
|
:href="getItemHref(item)"
|
||||||
@click="$emit('select', item)"
|
@click="$emit('select', item)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MediaItem } from '../types';
|
import type { MediaItem, EpisodeWithSeries } from "../types"
|
||||||
import MediaCard from './MediaCard.vue';
|
import MediaCard from "./MediaCard.vue"
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
items: MediaItem[];
|
items: MediaItem[]
|
||||||
wrap?: boolean;
|
wrap?: boolean
|
||||||
rowIndex?: number;
|
rowIndex?: number
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
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>
|
</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,660 @@
|
|||||||
|
<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 ANALOG_DEADZONE = 0.25
|
||||||
const GAMEPAD_REPEAT_MS = 180;
|
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> = {
|
const KEY_BY_ACTION: Partial<Record<GamepadAction, string>> = {
|
||||||
up: 'ArrowUp',
|
up: "ArrowUp",
|
||||||
down: 'ArrowDown',
|
down: "ArrowDown",
|
||||||
left: 'ArrowLeft',
|
left: "ArrowLeft",
|
||||||
right: 'ArrowRight',
|
right: "ArrowRight",
|
||||||
select: 'Enter',
|
select: "Enter",
|
||||||
back: 'Escape',
|
back: "Escape",
|
||||||
};
|
}
|
||||||
|
|
||||||
const gamepadPressedState: Record<GamepadAction, boolean> = {
|
const gamepadPressedState: Record<GamepadAction, boolean> = {
|
||||||
up: false,
|
up: false,
|
||||||
@@ -19,112 +26,289 @@ const gamepadPressedState: Record<GamepadAction, boolean> = {
|
|||||||
right: false,
|
right: false,
|
||||||
select: false,
|
select: false,
|
||||||
back: 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,
|
up: 0,
|
||||||
down: 0,
|
down: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
select: 0,
|
|
||||||
back: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let gamepadFrameId: 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', {
|
|
||||||
key,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: number) {
|
let digitalHoldStartedAt = 0
|
||||||
const wasPressed = gamepadPressedState[action];
|
let digitalLastRepeatAt = 0
|
||||||
gamepadPressedState[action] = isPressed;
|
let digitalRepeatCount = 0
|
||||||
|
|
||||||
if (!isPressed) return;
|
let gamepadFrameId: number | null = null
|
||||||
|
let idleTimerId: number | null = null
|
||||||
|
let gamepadInstalled = false
|
||||||
|
|
||||||
const shouldRepeat = action === 'up' || action === 'down' || action === 'left' || action === 'right';
|
function dispatchKey(key: string) {
|
||||||
const canTrigger = !wasPressed || (shouldRepeat && now - gamepadLastTriggerAt[action] >= GAMEPAD_REPEAT_MS);
|
const active = document.activeElement
|
||||||
if (!canTrigger) return;
|
const target = active instanceof HTMLElement ? active : document
|
||||||
|
target.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
gamepadLastTriggerAt[action] = now;
|
function dispatchGamepadAction(action: GamepadAction): void {
|
||||||
const actionEvent = new CustomEvent('mediahive:gamepad-action', {
|
const actionEvent = new CustomEvent("mediahive:gamepad-action", {
|
||||||
detail: { action },
|
detail: { action },
|
||||||
cancelable: true,
|
cancelable: true,
|
||||||
});
|
})
|
||||||
const shouldContinueWithKeyboard = window.dispatchEvent(actionEvent);
|
const shouldContinueWithKeyboard = window.dispatchEvent(actionEvent)
|
||||||
if (!shouldContinueWithKeyboard) return;
|
if (!shouldContinueWithKeyboard) return
|
||||||
|
|
||||||
dispatchKey(KEY_BY_ACTION[action]);
|
const key = KEY_BY_ACTION[action]
|
||||||
|
if (key) {
|
||||||
|
dispatchKey(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchRawButton(button: number): void {
|
||||||
|
const event = new CustomEvent("mediahive:gamepad-button", {
|
||||||
|
detail: { button },
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
window.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function resetPressedState() {
|
||||||
gamepadPressedState.up = false;
|
gamepadPressedState.up = false
|
||||||
gamepadPressedState.down = false;
|
gamepadPressedState.down = false
|
||||||
gamepadPressedState.left = false;
|
gamepadPressedState.left = false
|
||||||
gamepadPressedState.right = false;
|
gamepadPressedState.right = false
|
||||||
gamepadPressedState.select = false;
|
gamepadPressedState.select = false
|
||||||
gamepadPressedState.back = 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() {
|
function pollGamepad() {
|
||||||
const gamepads = navigator.getGamepads?.() ?? [];
|
const gamepads = navigator.getGamepads?.() ?? []
|
||||||
const now = performance.now();
|
const now = performance.now()
|
||||||
const connectedGamepads = gamepads.filter((gp): gp is Gamepad => Boolean(gp && gp.connected));
|
const connectedGamepads = gamepads.filter((gp): gp is Gamepad => Boolean(gp && gp.connected))
|
||||||
|
|
||||||
if (connectedGamepads.length > 0) {
|
if (connectedGamepads.length > 0) {
|
||||||
let up = false;
|
let digitalUp = false
|
||||||
let down = false;
|
let digitalDown = false
|
||||||
let left = false;
|
let digitalLeft = false
|
||||||
let right = false;
|
let digitalRight = false
|
||||||
let select = false;
|
let analogUpIntensity = 0
|
||||||
let back = false;
|
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) {
|
for (const gamepad of connectedGamepads) {
|
||||||
const axisX = gamepad.axes[0] ?? 0;
|
const axisX = gamepad.axes[0] ?? 0
|
||||||
const axisY = gamepad.axes[1] ?? 0;
|
const axisY = gamepad.axes[1] ?? 0
|
||||||
|
|
||||||
up = up || Boolean(gamepad.buttons[12]?.pressed) || axisY <= -GAMEPAD_AXIS_THRESHOLD;
|
digitalUp = digitalUp || Boolean(gamepad.buttons[12]?.pressed)
|
||||||
down = down || Boolean(gamepad.buttons[13]?.pressed) || axisY >= GAMEPAD_AXIS_THRESHOLD;
|
digitalDown = digitalDown || Boolean(gamepad.buttons[13]?.pressed)
|
||||||
left = left || Boolean(gamepad.buttons[14]?.pressed) || axisX <= -GAMEPAD_AXIS_THRESHOLD;
|
digitalLeft = digitalLeft || Boolean(gamepad.buttons[14]?.pressed)
|
||||||
right = right || Boolean(gamepad.buttons[15]?.pressed) || axisX >= GAMEPAD_AXIS_THRESHOLD;
|
digitalRight = digitalRight || Boolean(gamepad.buttons[15]?.pressed)
|
||||||
|
|
||||||
// Xbox mapping on standard gamepads: A=0, B=1
|
const upIntensity = normalizeAxisIntensity(-axisY)
|
||||||
select = select || Boolean(gamepad.buttons[0]?.pressed);
|
const downIntensity = normalizeAxisIntensity(axisY)
|
||||||
back = back || Boolean(gamepad.buttons[1]?.pressed);
|
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);
|
triggerSinglePressAction("up", digitalUp)
|
||||||
applyGamepadAction('down', down, now);
|
triggerSinglePressAction("down", digitalDown)
|
||||||
applyGamepadAction('left', left, now);
|
triggerSinglePressAction("left", digitalLeft)
|
||||||
applyGamepadAction('right', right, now);
|
triggerSinglePressAction("right", digitalRight)
|
||||||
applyGamepadAction('select', select, now);
|
triggerSinglePressAction("select", select)
|
||||||
applyGamepadAction('back', back, now);
|
triggerSinglePressAction("back", back)
|
||||||
} else {
|
triggerSinglePressAction("menu", menu)
|
||||||
resetPressedState();
|
triggerSinglePressRawButton(2, raw2)
|
||||||
}
|
triggerSinglePressRawButton(3, raw3)
|
||||||
|
triggerSinglePressRawButton(4, raw4)
|
||||||
|
triggerSinglePressRawButton(5, raw5)
|
||||||
|
|
||||||
gamepadFrameId = window.requestAnimationFrame(pollGamepad);
|
const hasDigitalRepeatable =
|
||||||
|
digitalUp || digitalDown || digitalLeft || digitalRight || raw2 || raw3 || raw4 || raw5
|
||||||
|
|
||||||
|
if (!hasDigitalRepeatable) {
|
||||||
|
digitalHoldStartedAt = 0
|
||||||
|
digitalLastRepeatAt = 0
|
||||||
|
digitalRepeatCount = 0
|
||||||
|
} else {
|
||||||
|
if (digitalHoldStartedAt === 0) {
|
||||||
|
digitalHoldStartedAt = now
|
||||||
|
digitalLastRepeatAt = now
|
||||||
|
digitalRepeatCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
export function installGamepadNavigation() {
|
||||||
if (gamepadInstalled) return;
|
if (gamepadInstalled) return
|
||||||
gamepadInstalled = true;
|
gamepadInstalled = true
|
||||||
gamepadFrameId = window.requestAnimationFrame(pollGamepad);
|
gamepadFrameId = window.requestAnimationFrame(pollGamepad)
|
||||||
|
window.addEventListener("gamepadconnected", handleGamepadConnected)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uninstallGamepadNavigation() {
|
export function uninstallGamepadNavigation() {
|
||||||
if (!gamepadInstalled) return;
|
if (!gamepadInstalled) return
|
||||||
gamepadInstalled = false;
|
gamepadInstalled = false
|
||||||
if (gamepadFrameId !== null) {
|
stopPolling()
|
||||||
window.cancelAnimationFrame(gamepadFrameId);
|
resetPressedState()
|
||||||
gamepadFrameId = null;
|
window.removeEventListener("gamepadconnected", handleGamepadConnected)
|
||||||
}
|
|
||||||
resetPressedState();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,161 @@
|
|||||||
|
import { reportUserActivity } from "../api"
|
||||||
|
|
||||||
|
type InputModality = "mouse" | "keyboard" | "gamepad"
|
||||||
|
|
||||||
|
const MOUSE_IDLE_MS = 1400
|
||||||
|
const MOUSE_INTENT_DISTANCE_PX = 28
|
||||||
|
const MOUSE_INTENT_WINDOW_MS = 700
|
||||||
|
const MOUSE_OVER_INTENT_RECENCY_MS = 500
|
||||||
|
const MOUSE_INTENT_SELECTOR = [
|
||||||
|
"[data-nav-focusable]",
|
||||||
|
"button",
|
||||||
|
"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
|
||||||
|
let lastAnyMouseMoveAt = 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()
|
||||||
|
lastAnyMouseMoveAt = performance.now()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Browsers fire mouseover/mouseenter when scrolling or re-rendering moves
|
||||||
|
// content under a stationary cursor (e.g. sideways season browsing while
|
||||||
|
// the pointer happens to rest over the episode grid). Without recent real
|
||||||
|
// pointer motion this is not mouse intent — activating mouse input here
|
||||||
|
// would let hover handlers steal keyboard/gamepad focus.
|
||||||
|
if (performance.now() - lastAnyMouseMoveAt > MOUSE_OVER_INTENT_RECENCY_MS) return
|
||||||
|
|
||||||
|
// Entering an interactive target indicates likely mouse intent.
|
||||||
|
activateMouseInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
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,654 @@
|
|||||||
import { ref, readonly, onUnmounted } from 'vue';
|
import { shallowRef, readonly, onUnmounted } from "vue"
|
||||||
import type { Movie, Series, MediaIndex, TaskInfo, WsMessage } from '../types';
|
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
|
* Composable that connects to one all-roots MediaHive WebSocket and keeps
|
||||||
* the media index updated in real time.
|
* a merged 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.
|
|
||||||
*/
|
*/
|
||||||
export function useMediaWebSocket() {
|
export function useMediaWebSocket() {
|
||||||
const mediaIndex = ref<MediaIndex | null>(null);
|
type RootTaskInfo = TaskInfo & { root_id: string }
|
||||||
const loading = ref(true);
|
|
||||||
const error = ref<string | null>(null);
|
|
||||||
const connected = ref(false);
|
|
||||||
const tasks = ref<Map<string, TaskInfo>>(new Map());
|
|
||||||
|
|
||||||
let ws: WebSocket | null = null;
|
const mediaIndex = shallowRef<MediaIndex | null>(null)
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
const loading = shallowRef(true)
|
||||||
let disposed = false;
|
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 rootStates = shallowRef<Map<string, RootState>>(new Map())
|
||||||
const movieMap = new Map<string, Movie>();
|
const wsRef = shallowRef<WebSocket | null>(null)
|
||||||
const seriesMap = new Map<string, Series>();
|
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 {
|
function buildIndex(): MediaIndex {
|
||||||
const movies = Array.from(movieMap.values());
|
const movies: MovieUi[] = []
|
||||||
const series = Array.from(seriesMap.values());
|
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 {
|
return {
|
||||||
version: 0,
|
v: 1,
|
||||||
generated_at: new Date().toISOString(),
|
generated_at: new Date().toISOString(),
|
||||||
stats: {
|
movies: mergedMovies,
|
||||||
total_movies: movies.length,
|
series: mergedSeries,
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function processJson(text: string) {
|
function updateMergedState() {
|
||||||
const msg = JSON.parse(text) as WsMessage;
|
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) {
|
switch (msg.type) {
|
||||||
case 'init': {
|
case "roots": {
|
||||||
movieMap.clear();
|
const next = new Map<string, RootStatusEntry>()
|
||||||
seriesMap.clear();
|
for (const root of msg.roots || []) {
|
||||||
for (const m of msg.data.movies) movieMap.set(m.id, m);
|
next.set(root.root_id, { ...root })
|
||||||
for (const s of msg.data.series) seriesMap.set(s.id, s);
|
ensureRootState(root.root_id)
|
||||||
mediaIndex.value = buildIndex();
|
|
||||||
loading.value = false;
|
|
||||||
error.value = null;
|
|
||||||
console.log(`[WS] init: ${movieMap.size} movies, ${seriesMap.size} series`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'upsert': {
|
|
||||||
if (msg.kind === 'movie') {
|
|
||||||
movieMap.set(msg.item.id, msg.item as Movie);
|
|
||||||
} else {
|
|
||||||
seriesMap.set(msg.item.id, msg.item as Series);
|
|
||||||
}
|
}
|
||||||
// Rebuild the index ref so Vue detects the change
|
roots.value = next
|
||||||
mediaIndex.value = buildIndex();
|
pruneMissingRoots(next)
|
||||||
break;
|
updateMergedState()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
case 'remove': {
|
|
||||||
if (msg.kind === 'movie') {
|
case "init": {
|
||||||
movieMap.delete(msg.id);
|
for (const [rootId, rootData] of Object.entries(msg.roots || {})) {
|
||||||
} else {
|
applyRootInit(rootId, rootData)
|
||||||
seriesMap.delete(msg.id);
|
|
||||||
}
|
}
|
||||||
mediaIndex.value = buildIndex();
|
updateMergedState()
|
||||||
break;
|
return
|
||||||
}
|
}
|
||||||
case 'task': {
|
|
||||||
const info = msg.data;
|
case "upsert": {
|
||||||
if (info.status === 'completed' || info.status === 'cancelled' || info.status === 'error') {
|
const state = ensureRootState(msg.root_id)
|
||||||
// Keep finished tasks briefly so the UI can show completion
|
if (!state.initialized) {
|
||||||
tasks.value.set(info.id, info);
|
state.pendingMessages.push(msg)
|
||||||
setTimeout(() => {
|
return
|
||||||
tasks.value.delete(info.id);
|
|
||||||
tasks.value = new Map(tasks.value);
|
|
||||||
}, 3000);
|
|
||||||
} else {
|
|
||||||
tasks.value.set(info.id, info);
|
|
||||||
}
|
}
|
||||||
// Trigger reactivity
|
|
||||||
tasks.value = new Map(tasks.value);
|
if (msg.people) {
|
||||||
break;
|
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 {
|
||||||
|
state.seriesMap.set(
|
||||||
|
msg.id,
|
||||||
|
withSeriesIdentity(msg.id, msg.item as Series, msg.root_id, state.peopleMap),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
updateMergedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
state.seriesMap.delete(msg.id)
|
||||||
|
}
|
||||||
|
updateMergedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function handleRawMessage(event: MessageEvent) {
|
||||||
if (disposed) return;
|
const processText = (text: string) => {
|
||||||
|
try {
|
||||||
// Build WS URL relative to current page
|
processMessage(JSON.parse(text) as WsMessage)
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
} catch (e) {
|
||||||
const url = `${proto}//${location.host}/api/ws`;
|
console.error("[WS] Failed to handle message:", e)
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
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() {
|
function scheduleReconnect() {
|
||||||
if (disposed) return;
|
if (disposed) return
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
console.log('[WS] Reconnecting...');
|
reconnectTimer = null
|
||||||
connect();
|
connect()
|
||||||
}, 2000);
|
}, 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() {
|
function disconnect() {
|
||||||
disposed = true;
|
disposed = true
|
||||||
|
stopTaskSweep()
|
||||||
|
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
clearTimeout(reconnectTimer);
|
clearTimeout(reconnectTimer)
|
||||||
reconnectTimer = null;
|
reconnectTimer = null
|
||||||
}
|
}
|
||||||
if (ws) {
|
|
||||||
ws.onclose = null; // prevent reconnect
|
if (wsRef.value) {
|
||||||
ws.close();
|
wsRef.value.onclose = null
|
||||||
ws = null;
|
wsRef.value.close()
|
||||||
|
wsRef.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connected.value = false
|
||||||
|
roots.value.clear()
|
||||||
|
rootStates.value.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the connection
|
connect()
|
||||||
connect();
|
onUnmounted(disconnect)
|
||||||
|
|
||||||
// Clean up on component unmount
|
|
||||||
onUnmounted(disconnect);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mediaIndex,
|
mediaIndex,
|
||||||
@@ -181,6 +656,7 @@ export function useMediaWebSocket() {
|
|||||||
error: readonly(error),
|
error: readonly(error),
|
||||||
connected: readonly(connected),
|
connected: readonly(connected),
|
||||||
tasks: readonly(tasks),
|
tasks: readonly(tasks),
|
||||||
|
roots: readonly(roots),
|
||||||
disconnect,
|
disconnect,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { TaskInfo } from "../types"
|
||||||
|
|
||||||
|
export type RootTaskInfo = TaskInfo & { root_id: string }
|
||||||
|
|
||||||
|
export interface ProgressRootState {
|
||||||
|
rootId: string
|
||||||
|
rootLabel: string
|
||||||
|
scanTarget: string | null
|
||||||
|
phaseLabel: string
|
||||||
|
phaseDetail: string | null
|
||||||
|
progressPercent: number
|
||||||
|
progressLabel: string | null
|
||||||
|
isDeterminate: boolean
|
||||||
|
toneClass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePosixPath(value: string): string {
|
||||||
|
return value.replace(/\\/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractScanPath(detail: string): string | null {
|
||||||
|
if (!detail.startsWith("Scanning:")) return null
|
||||||
|
let value = detail.replace(/^Scanning:\s*/i, "").trim()
|
||||||
|
value = value.replace(/\s*\(\d+\s+found\)\s*$/i, "").trim()
|
||||||
|
return value || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScanTarget(rootPath: string | null, detail: string): string | null {
|
||||||
|
const rawPath = extractScanPath(detail)
|
||||||
|
if (!rawPath) return null
|
||||||
|
|
||||||
|
const posixRaw = normalizePosixPath(rawPath)
|
||||||
|
const posixRoot = rootPath ? normalizePosixPath(rootPath) : null
|
||||||
|
|
||||||
|
if (posixRoot) {
|
||||||
|
const lowRaw = posixRaw.toLowerCase()
|
||||||
|
const lowRoot = posixRoot.toLowerCase()
|
||||||
|
if (lowRaw === lowRoot) return posixRoot
|
||||||
|
if (lowRaw.startsWith(`${lowRoot}/`)) {
|
||||||
|
return posixRaw.slice(posixRoot.length).replace(/^\/+/, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return posixRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeRootProgress(
|
||||||
|
rootId: string,
|
||||||
|
rootPath: string | null,
|
||||||
|
tasksForRoot: RootTaskInfo[],
|
||||||
|
isInitialScanMode: boolean,
|
||||||
|
): ProgressRootState | null {
|
||||||
|
const running = tasksForRoot.filter((task) => task.status === "running")
|
||||||
|
const latestError = [...tasksForRoot].reverse().find((task) => task.status === "error") || null
|
||||||
|
|
||||||
|
if (running.length === 0 && !latestError) return null
|
||||||
|
|
||||||
|
const scanTask = running.find((task) => task.id.startsWith("scan-")) || null
|
||||||
|
const showreelCount = running.filter((task) => task.id.startsWith("showreel-")).length
|
||||||
|
const otherRunningCount = running.length - (scanTask ? 1 : 0) - showreelCount
|
||||||
|
|
||||||
|
let phaseLabel = "Processing media"
|
||||||
|
let phaseDetail: string | null = null
|
||||||
|
let scanTarget: string | null = null
|
||||||
|
let isDeterminate = false
|
||||||
|
let progressPercent = 0
|
||||||
|
let progressLabel: string | null = null
|
||||||
|
let toneClass = ""
|
||||||
|
|
||||||
|
if (scanTask) {
|
||||||
|
const detail = (scanTask.detail || "").trim()
|
||||||
|
scanTarget = buildScanTarget(rootPath, detail)
|
||||||
|
if (detail.startsWith("Scanning:")) {
|
||||||
|
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
|
||||||
|
} else if (/^Processing\s+\d+\s+(items|movies|series)/i.test(detail)) {
|
||||||
|
phaseLabel = "Preparing titles"
|
||||||
|
} else if (/^(Starting scan|No new items|Done|Scan cancelled)/i.test(detail)) {
|
||||||
|
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
|
||||||
|
} else {
|
||||||
|
phaseLabel = "Fetching metadata"
|
||||||
|
phaseDetail = detail || null
|
||||||
|
}
|
||||||
|
if (scanTask.progress > 0 && scanTask.progress <= 1) {
|
||||||
|
isDeterminate = true
|
||||||
|
progressPercent = Math.max(1, Math.round(scanTask.progress * 100))
|
||||||
|
progressLabel = `${progressPercent}%`
|
||||||
|
}
|
||||||
|
} else if (showreelCount > 0) {
|
||||||
|
phaseLabel = "Generating previews"
|
||||||
|
} else if (otherRunningCount > 0) {
|
||||||
|
phaseLabel = "Finalizing updates"
|
||||||
|
} else if (latestError) {
|
||||||
|
phaseLabel = "Needs attention"
|
||||||
|
phaseDetail = latestError.detail || "A background task failed"
|
||||||
|
toneClass = "activity-root-error"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootId,
|
||||||
|
rootLabel: rootId,
|
||||||
|
scanTarget,
|
||||||
|
phaseLabel,
|
||||||
|
phaseDetail,
|
||||||
|
progressPercent,
|
||||||
|
progressLabel,
|
||||||
|
isDeterminate,
|
||||||
|
toneClass,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeProgressRoots(
|
||||||
|
tasks: Iterable<RootTaskInfo>,
|
||||||
|
getRootPath: (rootId: string) => string | null,
|
||||||
|
isInitialScanMode: boolean,
|
||||||
|
): ProgressRootState[] {
|
||||||
|
const byRoot = new Map<string, RootTaskInfo[]>()
|
||||||
|
for (const task of tasks) {
|
||||||
|
const list = byRoot.get(task.root_id) || []
|
||||||
|
list.push(task)
|
||||||
|
byRoot.set(task.root_id, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: ProgressRootState[] = []
|
||||||
|
for (const [rootId, rootTasks] of byRoot) {
|
||||||
|
const row = describeRootProgress(rootId, getRootPath(rootId), rootTasks, isInitialScanMode)
|
||||||
|
if (row) rows.push(row)
|
||||||
|
}
|
||||||
|
return rows.sort((a, b) => a.rootLabel.localeCompare(b.rootLabel))
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
// Settings is an overlay, not a route: opening it must not change the URL or
|
||||||
|
// the view behind it, so the open state is plain shared local state.
|
||||||
|
const settingsOpen = ref(false)
|
||||||
|
|
||||||
|
export function useSettingsOpen() {
|
||||||
|
return settingsOpen
|
||||||
|
}
|
||||||
@@ -1,18 +1,73 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from "vue"
|
||||||
import App from './App.vue'
|
import App from "./App.vue"
|
||||||
import router from './router'
|
import router from "./router"
|
||||||
import './styles/main.css'
|
import "./styles/main.css"
|
||||||
import { installKeyboardNavigation } from './composables/useKeyboardNavigation'
|
import { installKeyboardNavigation } from "./composables/useKeyboardNavigation"
|
||||||
import { installGamepadNavigation } from './composables/useGamepadNavigation'
|
import { installGamepadNavigation } from "./composables/useGamepadNavigation"
|
||||||
|
import { installInputModalityTracking } from "./composables/useInputModality"
|
||||||
|
|
||||||
// Install global keyboard navigation handlers immediately
|
function postClientError(payload: {
|
||||||
installKeyboardNavigation()
|
message: string
|
||||||
installGamepadNavigation()
|
stack: string | null
|
||||||
|
source: string | null
|
||||||
if ('serviceWorker' in navigator && !navigator.serviceWorker.controller) {
|
}) {
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
fetch("/api/client-log", {
|
||||||
window.location.reload()
|
method: "POST",
|
||||||
}, { once: true })
|
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 { createRouter, createWebHistory } from "vue-router"
|
||||||
import { defineComponent, h } from 'vue';
|
import { defineComponent, h } from "vue"
|
||||||
|
|
||||||
// Empty component - App.vue handles all rendering based on route meta
|
// Empty component - App.vue handles all rendering based on route meta
|
||||||
const EmptyRouteComponent = defineComponent({
|
const EmptyRouteComponent = defineComponent({
|
||||||
render() {
|
render() {
|
||||||
return h('div');
|
return h("div")
|
||||||
}
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHistory(),
|
||||||
scrollBehavior() {
|
scrollBehavior() {
|
||||||
// Always scroll to top on navigation
|
// Always scroll to top on navigation
|
||||||
return { top: 0 };
|
return { top: 0 }
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: "/",
|
||||||
redirect: '/movies',
|
redirect: "/movies",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/movies',
|
path: "/movies",
|
||||||
name: 'movies',
|
name: "movies",
|
||||||
component: EmptyRouteComponent,
|
component: EmptyRouteComponent,
|
||||||
meta: { view: 'movies' },
|
meta: { view: "movies" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/movies/:id',
|
path: "/movies/:id",
|
||||||
name: 'movie-detail',
|
name: "movie-detail",
|
||||||
component: EmptyRouteComponent,
|
component: EmptyRouteComponent,
|
||||||
meta: { view: 'movies' },
|
meta: { view: "movies" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/series',
|
path: "/search/:term",
|
||||||
name: 'series',
|
name: "search",
|
||||||
component: EmptyRouteComponent,
|
component: EmptyRouteComponent,
|
||||||
meta: { view: 'series' },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/series/:id',
|
// Settings is now an overlay with no URL of its own; keep old links working.
|
||||||
name: 'series-detail',
|
path: "/settings",
|
||||||
|
redirect: "/movies",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/series",
|
||||||
|
name: "series",
|
||||||
component: EmptyRouteComponent,
|
component: EmptyRouteComponent,
|
||||||
meta: { view: 'series' },
|
meta: { view: "series" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/series/:id",
|
||||||
|
name: "series-detail",
|
||||||
|
component: EmptyRouteComponent,
|
||||||
|
meta: { view: "series" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
|
|
||||||
export default router;
|
export default router
|
||||||
|
|||||||
@@ -0,0 +1,797 @@
|
|||||||
|
// 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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html,
|
||||||
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
body {
|
||||||
|
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
height: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
overflow-y: hidden;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
@@ -40,9 +43,33 @@ html, body {
|
|||||||
-ms-hyphens: auto;
|
-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 {
|
#app {
|
||||||
|
height: 100vh;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
position: relative;
|
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 */
|
/* Scrollbar styling */
|
||||||
@@ -60,7 +87,7 @@ html, body {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
html.mouse-active ::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--text-secondary);
|
background: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,9 +102,33 @@ html, body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
|
isolation: isolate;
|
||||||
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Leave room for the fixed app-exit button in desktop (pywebview) mode. */
|
||||||
|
.header--gui {
|
||||||
|
padding-right: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header::before {
|
||||||
|
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 {
|
.header-top {
|
||||||
top: 40px;
|
top: 40px;
|
||||||
}
|
}
|
||||||
@@ -88,8 +139,8 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-after-movie-header {
|
.header-after-movie-header {
|
||||||
/* Position after movie detail collage-header (300px) */
|
/* Place header below movie detail collage-header (300px). */
|
||||||
top: 320px;
|
top: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-after-series-hero {
|
.header-after-series-hero {
|
||||||
@@ -97,6 +148,22 @@ html, body {
|
|||||||
top: clamp(450px, 70vh, 600px);
|
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 */
|
/* Spacer to reserve space for header in layout */
|
||||||
.header-spacer {
|
.header-spacer {
|
||||||
height: calc(var(--header-height) + 20px);
|
height: calc(var(--header-height) + 20px);
|
||||||
@@ -108,6 +175,11 @@ html, body {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-logo-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.header-logo {
|
.header-logo {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
@@ -133,13 +205,18 @@ html, body {
|
|||||||
padding: 0;
|
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 {
|
.header-nav-item.active {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-nav-item:focus,
|
.header-nav-item:focus,
|
||||||
.header-nav-item.nav-focused {
|
html:not(.mouse-active) .header-nav-item.nav-focused {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -170,7 +247,7 @@ html, body {
|
|||||||
transition: color var(--transition-fast);
|
transition: color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-settings-btn:hover {
|
html.mouse-active .header-settings-btn:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,84 +282,10 @@ html, body {
|
|||||||
padding-top: 40px;
|
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 */
|
/* Blinking animation for button focus */
|
||||||
@keyframes btn-outline-blink {
|
@keyframes btn-outline-blink {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.9);
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.9);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -318,8 +321,8 @@ html, body {
|
|||||||
color: var(--bg-primary);
|
color: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover,
|
html.mouse-active .btn-primary:hover,
|
||||||
.btn-primary.nav-focused {
|
html:not(.mouse-active) .btn-primary.nav-focused {
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: rgba(255, 255, 255, 0.85);
|
||||||
animation: btn-outline-blink 1s ease-in-out infinite;
|
animation: btn-outline-blink 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -329,8 +332,8 @@ html, body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover,
|
html.mouse-active .btn-secondary:hover,
|
||||||
.btn-secondary.nav-focused {
|
html:not(.mouse-active) .btn-secondary.nav-focused {
|
||||||
background: rgba(109, 109, 110, 0.5);
|
background: rgba(109, 109, 110, 0.5);
|
||||||
animation: btn-outline-blink 1s ease-in-out infinite;
|
animation: btn-outline-blink 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -346,11 +349,15 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.view-zoom-enter-active {
|
.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 {
|
.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 {
|
.view-zoom-enter-from {
|
||||||
@@ -365,7 +372,7 @@ html, body {
|
|||||||
|
|
||||||
/* Media rows */
|
/* Media rows */
|
||||||
.media-section {
|
.media-section {
|
||||||
padding: 0 var(--section-padding);
|
padding: 0;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,17 +380,22 @@ html, body {
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
padding: 0 calc(2px + var(--section-padding));
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-row {
|
.media-row {
|
||||||
|
--sync-row-tail: 0px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
margin: 0 -2px;
|
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;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
}
|
}
|
||||||
@@ -406,13 +418,21 @@ html, body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: var(--card-width);
|
width: var(--card-width);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: z-index 0s, box-shadow var(--transition-medium);
|
transition:
|
||||||
|
z-index 0s,
|
||||||
|
box-shadow var(--transition-medium);
|
||||||
position: relative;
|
position: relative;
|
||||||
outline: none;
|
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,
|
html.mouse-active .media-card:hover,
|
||||||
.media-card.nav-focused {
|
html:not(.mouse-active) .media-card.nav-focused {
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,7 +454,7 @@ html, body {
|
|||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
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);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +500,7 @@ html, body {
|
|||||||
transition: opacity var(--transition-fast);
|
transition: opacity var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-card:hover .media-card-info {
|
html.mouse-active .media-card:hover .media-card-info {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +545,14 @@ html, body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
@@ -556,7 +584,7 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-header::after {
|
.modal-header::after {
|
||||||
content: '';
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -584,7 +612,7 @@ html, body {
|
|||||||
transition: background var(--transition-fast);
|
transition: background var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close:hover {
|
html.mouse-active .modal-close:hover {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,7 +708,7 @@ html, body {
|
|||||||
transition: background var(--transition-fast);
|
transition: background var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-item:hover {
|
html.mouse-active .release-item:hover {
|
||||||
background: var(--bg-card-hover);
|
background: var(--bg-card-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,23 +815,6 @@ html, body {
|
|||||||
--section-padding: 3%;
|
--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 {
|
.modal-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -816,13 +827,6 @@ html, body {
|
|||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
:root {
|
:root {
|
||||||
--card-width: 100px;
|
--card-width: 100px;
|
||||||
--header-height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-logo {
|
|
||||||
height: 32px;
|
|
||||||
width: 32px;
|
|
||||||
margin-right: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-card-info {
|
.media-card-info {
|
||||||
|
|||||||
@@ -1,189 +1,253 @@
|
|||||||
// Type definitions for the media browser
|
// Type definitions for the media browser
|
||||||
|
|
||||||
|
export type CastGender = "female" | "male" | "non_binary" | "unknown"
|
||||||
|
|
||||||
export interface CastMember {
|
export interface CastMember {
|
||||||
name: string;
|
name: string
|
||||||
character?: string | null;
|
character?: string | null
|
||||||
profile_path: string | null;
|
profile_path: string | null
|
||||||
|
gender?: CastGender | null
|
||||||
|
id?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimilarMedia {
|
export type CastCreditWire = [character: string | null, id: number | null]
|
||||||
id: number;
|
export type PersonWire = [name: string, profile_path: string | null, gender: CastGender | null]
|
||||||
title: string;
|
|
||||||
poster_path: string | null;
|
export interface Person {
|
||||||
|
name: string
|
||||||
|
profile_path: string | null
|
||||||
|
gender?: CastGender | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Info {
|
export interface Info {
|
||||||
tmdb_id: number;
|
tmdb_id: number
|
||||||
title: string | null;
|
title: string | null
|
||||||
original_title: string | null;
|
original_title: string | null
|
||||||
alternative_titles: string[] | null;
|
original_language: string | null
|
||||||
rating: number | null;
|
alternative_titles: string[] | null
|
||||||
vote_count: number | null;
|
rating: number | null
|
||||||
overview: string | null;
|
vote_count: number | null
|
||||||
genres: string[] | null;
|
overview: string | null
|
||||||
release_date: string | null;
|
genres: string[] | null
|
||||||
runtime: number | null;
|
release_date: string | null
|
||||||
status: string | null;
|
runtime: number | null
|
||||||
tagline: string | null;
|
collection: string | null
|
||||||
poster_path: string | null;
|
status: string | null
|
||||||
backdrop_path: string | null;
|
tagline: string | null
|
||||||
similar: SimilarMedia[] | null;
|
keywords: string[] | null
|
||||||
keywords: string[] | null;
|
cast: CastMember[] | null
|
||||||
cast: CastMember[] | null;
|
director: string | null
|
||||||
director: string | null;
|
creators: string[] | null
|
||||||
creators: string[] | null;
|
number_of_seasons: number | null
|
||||||
number_of_seasons: number | null;
|
number_of_episodes: number | null
|
||||||
number_of_episodes: number | null;
|
networks: string[] | null
|
||||||
networks: string[] | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Torrent {
|
export interface Torrent {
|
||||||
title: string | null;
|
title: string | null
|
||||||
playable_file: string | null;
|
playable_file: string | null
|
||||||
resolution: string | null;
|
resolution: string | null
|
||||||
quality: string | null;
|
quality: string | null
|
||||||
codec: string | null;
|
network: string | null
|
||||||
audio: string | null;
|
codec: string | null
|
||||||
encoder: string | null;
|
audio: string | null
|
||||||
size: number | null;
|
audio_languages: string[] | null
|
||||||
added_at: number | 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 {
|
export interface Movie {
|
||||||
id: string;
|
title: string | null
|
||||||
title: string | null;
|
info: Info | null
|
||||||
info: Info | null;
|
year: number | null
|
||||||
year: number | null;
|
newest: number | null
|
||||||
newest: number | null;
|
cover_path: string | null
|
||||||
cover_path: string | null;
|
backdrop_path: string | null
|
||||||
backdrop_path: string | null;
|
showreel_images: string[] | null
|
||||||
showreel_images: string[] | null;
|
showreel_source_sets: string[][] | null
|
||||||
torrents: { [key: string]: Torrent };
|
files: { [key: string]: Torrent }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Episode {
|
export interface Episode {
|
||||||
episode_number: number;
|
episode_number: number
|
||||||
name: string | null;
|
name: string | null
|
||||||
overview: string | null;
|
overview: string | null
|
||||||
air_date: string | null;
|
air_date: string | null
|
||||||
runtime: number | null;
|
runtime: number | null
|
||||||
still_path: string | null;
|
still_path: string | null
|
||||||
rating: number | null;
|
rating: number | null
|
||||||
director: string | null;
|
director: string | null
|
||||||
reel_image: string | null;
|
reel_image: string | null
|
||||||
torrents: { [key: string]: Torrent };
|
reel_sources: string[] | null
|
||||||
|
files: { [key: string]: Torrent }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Season {
|
export interface Season {
|
||||||
season_number: number;
|
season_number: number
|
||||||
name: string | null;
|
name: string | null
|
||||||
overview: string | null;
|
overview: string | null
|
||||||
air_date: string | null;
|
air_date: string | null
|
||||||
poster_path: string | null;
|
poster_path: string | null
|
||||||
episode_count: number | null;
|
episode_count: number | null
|
||||||
episodes: Episode[];
|
episodes: Episode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Series {
|
export interface Series {
|
||||||
id: string;
|
title: string | null
|
||||||
title: string | null;
|
info: Info | null
|
||||||
info: Info | null;
|
alternative_titles: string[] | null
|
||||||
alternative_titles: string[] | null;
|
newest: number | null
|
||||||
newest: number | null;
|
cover_path: string | null
|
||||||
cover_path: string | null;
|
backdrop_path: string | null
|
||||||
backdrop_path: string | null;
|
seasons: Season[]
|
||||||
seasons: Season[];
|
}
|
||||||
|
|
||||||
|
/** A series' single continue point (last watched position). */
|
||||||
|
export interface SeriesResumePoint {
|
||||||
|
seasonNumber: number
|
||||||
|
episodeNumber: number
|
||||||
|
positionSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovieUi extends Movie {
|
||||||
|
id: string
|
||||||
|
root_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeriesUi extends Series {
|
||||||
|
id: string
|
||||||
|
root_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaStats {
|
export interface MediaStats {
|
||||||
total_movies: number;
|
total_movies: number
|
||||||
total_movie_versions?: number;
|
total_movie_versions?: number
|
||||||
total_series: number;
|
total_series: number
|
||||||
total_series_episodes?: number;
|
total_series_episodes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaIndex {
|
export interface MediaIndex {
|
||||||
version: number;
|
v: number
|
||||||
generated_at: string;
|
generated_at: string
|
||||||
stats: MediaStats;
|
movies: MovieUi[]
|
||||||
movies: Movie[];
|
series: SeriesUi[]
|
||||||
series: Series[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MediaType = 'movies' | 'series' | 'episode';
|
export type MediaType = "movies" | "series" | "episode"
|
||||||
|
|
||||||
// Matched person info for search results
|
// Matched person info for search results
|
||||||
export interface MatchedPerson {
|
export interface MatchedPerson {
|
||||||
name: string;
|
name: string
|
||||||
roles: string; // e.g., "Director", "Tony Stark", "Creator"
|
roles: string // e.g., "Director", "Tony Stark", "Creator"
|
||||||
highlightRoles: boolean; // true if the roles/character matched (vs the name)
|
highlightRoles: boolean // true if the roles/character matched (vs the name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matched episode info for search results
|
// Matched episode info for search results
|
||||||
export interface MatchedEpisode {
|
export interface MatchedEpisode {
|
||||||
name: string; // Episode name (highlighted)
|
name: string // Episode name (highlighted)
|
||||||
location: string; // "SN Episode M" (dimmed)
|
location: string // "SN Episode M" (dimmed)
|
||||||
seasonNumber: number; // For navigation to episode
|
seasonNumber: number // For navigation to episode
|
||||||
episodeNumber: number; // For navigation to episode
|
episodeNumber: number // For navigation to episode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info about why a search matched this item
|
// Info about why a search matched this item
|
||||||
export interface SearchMatchInfo {
|
export interface SearchMatchInfo {
|
||||||
// Matched people with their roles/characters
|
// Matched people with their roles/characters
|
||||||
matchedPeople?: MatchedPerson[];
|
matchedPeople?: MatchedPerson[]
|
||||||
// Matched episodes for series
|
// Matched episodes for series
|
||||||
matchedEpisodes?: MatchedEpisode[];
|
matchedEpisodes?: MatchedEpisode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaItem {
|
export interface MediaItem {
|
||||||
id: string;
|
id: string
|
||||||
title: string | null;
|
title: string | null
|
||||||
year?: number | null;
|
year?: number | null
|
||||||
cover_path: string | null;
|
cover_path: string | null
|
||||||
showreel_images?: string[] | null;
|
showreel_images?: string[] | null
|
||||||
type: MediaType;
|
showreel_source_sets?: string[][] | null
|
||||||
resolution?: string | null;
|
type: MediaType
|
||||||
data: Movie | Series | EpisodeWithSeries;
|
resolution?: string | null
|
||||||
|
data: Movie | Series | EpisodeWithSeries
|
||||||
|
root_id: string | null
|
||||||
// Optional search match info - only present in search results
|
// Optional search match info - only present in search results
|
||||||
searchMatchInfo?: SearchMatchInfo;
|
searchMatchInfo?: SearchMatchInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// Episode with parent series info for standalone display
|
// Episode with parent series info for standalone display
|
||||||
export interface EpisodeWithSeries {
|
export interface EpisodeWithSeries {
|
||||||
episode: Episode;
|
episode: Episode
|
||||||
series: Series;
|
series: SeriesUi
|
||||||
seasonNumber: number;
|
seasonNumber: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task progress info from background scanning
|
// Task progress info from background scanning
|
||||||
export interface TaskInfo {
|
export interface TaskInfo {
|
||||||
id: string;
|
id: string
|
||||||
status: string;
|
status: string
|
||||||
progress: number;
|
progress: number
|
||||||
detail: string;
|
detail: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket message types (matching server msgspec tagged structs)
|
// 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 {
|
export interface WsInitMessage {
|
||||||
type: 'init';
|
type: "init"
|
||||||
data: { movies: Movie[]; series: Series[] };
|
roots: Record<string, WsRootInitData>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WsUpsertMessage {
|
export interface WsUpsertMessage {
|
||||||
type: 'upsert';
|
type: "upsert"
|
||||||
kind: 'movie' | 'series';
|
root_id: string
|
||||||
item: Movie | Series;
|
kind: "movie" | "series"
|
||||||
|
id: string
|
||||||
|
item: Movie | Series
|
||||||
|
people?: Record<string, PersonWire>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WsRemoveMessage {
|
export interface WsRemoveMessage {
|
||||||
type: 'remove';
|
type: "remove"
|
||||||
kind: 'movie' | 'series';
|
root_id: string
|
||||||
id: string;
|
kind: "movie" | "series"
|
||||||
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WsTaskMessage {
|
export interface WsTaskMessage {
|
||||||
type: 'task';
|
type: "task"
|
||||||
data: TaskInfo;
|
root_id: string
|
||||||
|
data: TaskInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WsMessage = WsInitMessage | WsUpsertMessage | WsRemoveMessage | WsTaskMessage;
|
export type WsMessage =
|
||||||
|
| WsRootsMessage
|
||||||
|
| WsInitMessage
|
||||||
|
| WsUpsertMessage
|
||||||
|
| WsRemoveMessage
|
||||||
|
| WsTaskMessage
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
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/client" />
|
||||||
/// <reference types="vite-plugin-pwa/client" />
|
|
||||||
|
|
||||||
declare module '*.vue' {
|
declare module "*.vue" {
|
||||||
import type { DefineComponent } from 'vue'
|
import type { DefineComponent } from "vue"
|
||||||
const component: DefineComponent<{}, {}, any>
|
const component: DefineComponent<{}, {}, any>
|
||||||
export default component
|
export default component
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
* Configures Vite for FastAPI backend integration:
|
* Configures Vite for FastAPI backend integration:
|
||||||
* - Proxies /api/* requests to the FastAPI backend
|
* - Proxies /api/* requests to the FastAPI backend
|
||||||
* - Builds to the Python module's frontend-build directory
|
* - Builds to the Python module's frontend-build directory
|
||||||
|
* - Disables Vite's screen clearing on startup
|
||||||
*
|
*
|
||||||
* Options:
|
* Options:
|
||||||
* paths - Array of paths to proxy (default: ["/api"])
|
* paths - Array of paths to proxy (default: ['/api'])
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
export default function fastapiVue({ paths = ['/api'] } = {}) {
|
||||||
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8420"
|
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || 'http://localhost:8421'
|
||||||
|
|
||||||
// Build proxy configuration for each path
|
// Build proxy configuration for each path
|
||||||
const proxy = {}
|
const proxy = {}
|
||||||
@@ -24,11 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "vite-plugin-fastapi-mediahive",
|
name: 'vite-plugin-fastapi-mediahive',
|
||||||
config: () => ({
|
config: () => ({
|
||||||
|
clearScreen: false,
|
||||||
server: { proxy },
|
server: { proxy },
|
||||||
build: {
|
build: {
|
||||||
outDir: "../mediahive/frontend-build",
|
outDir: '../mediahive/frontend-build',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import { VitePWA } from "vite-plugin-pwa";
|
|
||||||
import fastapiVue from './vite-plugin-fastapi.js'
|
import fastapiVue from './vite-plugin-fastapi.js'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
@@ -8,52 +7,6 @@ export default defineConfig(async () => ({
|
|||||||
plugins: [
|
plugins: [
|
||||||
fastapiVue(),
|
fastapiVue(),
|
||||||
vue(),
|
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
|
// Vite dev server options
|
||||||
@@ -62,4 +15,8 @@ export default defineConfig(async () => ({
|
|||||||
port: 8420,
|
port: 8420,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
worker: {
|
||||||
|
format: "es",
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
pre-commit:
|
||||||
|
parallel: true
|
||||||
|
commands:
|
||||||
|
ruff-check:
|
||||||
|
glob: "*.py"
|
||||||
|
run: .venv/bin/ruff check --fix {staged_files}
|
||||||
|
stage_fixed: true
|
||||||
|
ruff-format:
|
||||||
|
glob: "*.py"
|
||||||
|
run: .venv/bin/ruff format {staged_files}
|
||||||
|
stage_fixed: true
|
||||||
|
oxlint:
|
||||||
|
glob: "frontend/src/**"
|
||||||
|
run: npm --prefix frontend run lint
|
||||||
|
stage_fixed: true
|
||||||
|
oxfmt:
|
||||||
|
glob: "frontend/src/**"
|
||||||
|
run: npm --prefix frontend run format
|
||||||
|
stage_fixed: true
|
||||||
@@ -1 +1 @@
|
|||||||
"""MediaHive - Media Browser Server"""
|
"""MediaHive - Media Browser Server."""
|
||||||
|
|||||||
@@ -1,38 +1,104 @@
|
|||||||
import argparse
|
"""MediaHive CLI entrypoint."""
|
||||||
|
|
||||||
import os
|
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
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi_vue import server
|
from fastapi_vue import env, server
|
||||||
|
|
||||||
|
from mediahive.config import config
|
||||||
|
|
||||||
DEFAULT_PORT = 8420
|
DEFAULT_PORT = 8420
|
||||||
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_media_root(path: str | None = None) -> Path:
|
def _configure_windows_event_loop_policy() -> None:
|
||||||
"""Resolve the media root folder from a path, MEDIAHIVE_PATH env, or cwd."""
|
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
|
||||||
match Path(path or os.environ.get("MEDIAHIVE_PATH") or Path.cwd()).parts:
|
if sys.platform != "win32":
|
||||||
case (*rest, ".mediahive", "index.json"):
|
return
|
||||||
...
|
import warnings
|
||||||
case (*rest, ".mediahive"):
|
|
||||||
...
|
with warnings.catch_warnings():
|
||||||
case rest:
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
...
|
policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None)
|
||||||
mediaroot = Path(*rest).resolve()
|
if policy_cls is None:
|
||||||
if not mediaroot.exists() or not mediaroot.is_dir():
|
return
|
||||||
sys.stderr.write(f"Error: Folder does not exist: {mediaroot}\n")
|
asyncio.set_event_loop_policy(policy_cls())
|
||||||
sys.exit(1)
|
|
||||||
return mediaroot
|
|
||||||
|
|
||||||
|
|
||||||
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(
|
parser = argparse.ArgumentParser(
|
||||||
description="MediaHive - Media scanning, indexing, and streaming"
|
description="MediaHive - Media scanning, indexing, and streaming"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"media_folder",
|
"media_folders",
|
||||||
nargs="?",
|
nargs="*",
|
||||||
help="Path to the media folder (default: MEDIAHIVE_PATH or current directory)",
|
metavar="MEDIA_FOLDER",
|
||||||
|
help=(
|
||||||
|
"One or more media folders to index "
|
||||||
|
"(default: none — configure via UI or API)"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-l",
|
"-l",
|
||||||
@@ -40,18 +106,67 @@ def main():
|
|||||||
action="append",
|
action="append",
|
||||||
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--gui",
|
||||||
|
action="store_true",
|
||||||
|
help="Run with GUI (fails if GUI dependencies are not installed)",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
mediaroot = resolve_media_root(args.media_folder)
|
# --listen implies server-only mode; use --gui to force GUI even with --listen.
|
||||||
os.environ["MEDIAHIVE_PATH"] = mediaroot.as_posix()
|
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(
|
server.run(
|
||||||
"mediahive.server:app",
|
"mediahive.server:app",
|
||||||
listen=args.listen,
|
listen=args.listen,
|
||||||
default_port=DEFAULT_PORT,
|
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,43 @@
|
|||||||
"""Platform-appropriate config persistence for MediaHive.
|
r"""Platform-appropriate config persistence for MediaHive.
|
||||||
|
|
||||||
Config file location:
|
Locations (via platformdirs):
|
||||||
Windows: %APPDATA%\\mediahive\\config.toml
|
Config — Windows: %LOCALAPPDATA%\mediahive\config.toml
|
||||||
macOS: ~/Library/Application Support/mediahive/config.toml
|
macOS: ~/Library/Application Support/mediahive/config.toml
|
||||||
Linux: $XDG_CONFIG_HOME/mediahive/config.toml (~/.config/mediahive/config.toml)
|
Linux: $XDG_CONFIG_HOME/mediahive/config.toml
|
||||||
|
Logs — Windows: %LOCALAPPDATA%\mediahive\mediahive.log
|
||||||
|
macOS: ~/Library/Logs/mediahive/mediahive.log
|
||||||
|
Linux: $XDG_STATE_HOME/mediahive/mediahive.log
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
import msgspec.toml
|
import msgspec.toml
|
||||||
|
from fastapi_vue import env
|
||||||
|
from platformdirs import user_config_path, user_log_path
|
||||||
|
|
||||||
|
|
||||||
class Config(msgspec.Struct):
|
class Config(msgspec.Struct, omit_defaults=True):
|
||||||
media_folder: str | None = None
|
roots: dict[str, str] | None = None
|
||||||
|
auto_update: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
# Runtime config shared between the CLI entrypoint and the server process via
|
||||||
|
# fastapi-vue's env teleport (MEDIAHIVE_CONFIG). Values set here take
|
||||||
|
# precedence over the persisted config file.
|
||||||
|
config = env(Config)
|
||||||
|
|
||||||
|
|
||||||
def config_dir() -> Path:
|
def config_dir() -> Path:
|
||||||
if sys.platform == "win32":
|
# appauthor=False: avoid the doubled %LOCALAPPDATA%\mediahive\mediahive.
|
||||||
base = Path(os.environ.get("APPDATA") or Path.home())
|
# roaming=False: config is machine-specific state, not something to sync
|
||||||
elif sys.platform == "darwin":
|
# across a domain profile.
|
||||||
base = Path.home() / "Library" / "Application Support"
|
return user_config_path("mediahive", appauthor=False, roaming=False)
|
||||||
else:
|
|
||||||
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
|
||||||
return base / "mediahive"
|
def log_dir() -> Path:
|
||||||
|
# opinion=False: no extra Logs/ subdir; mediahive.log sits beside config.
|
||||||
|
return user_log_path("mediahive", appauthor=False, opinion=False)
|
||||||
|
|
||||||
|
|
||||||
def config_path() -> Path:
|
def config_path() -> Path:
|
||||||
@@ -37,7 +49,7 @@ def load_config() -> Config:
|
|||||||
if path.exists():
|
if path.exists():
|
||||||
try:
|
try:
|
||||||
return msgspec.toml.decode(path.read_bytes(), type=Config)
|
return msgspec.toml.decode(path.read_bytes(), type=Config)
|
||||||
except Exception:
|
except OSError, msgspec.DecodeError, msgspec.ValidationError:
|
||||||
return Config()
|
return Config()
|
||||||
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:
|
Import from submodules directly:
|
||||||
from mediahive.hivescan.scanner import start, stop
|
from mediahive.hivescan.scanner import start, stop
|
||||||
@@ -9,13 +8,13 @@ Import from submodules directly:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Minimal public API - prefer importing 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
|
from mediahive.hivescan.utils import DEFAULT_OUTPUT_FOLDER, find_common_root
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ContentType",
|
|
||||||
"ContentHash",
|
|
||||||
"ParsedContent",
|
|
||||||
"DEFAULT_OUTPUT_FOLDER",
|
"DEFAULT_OUTPUT_FOLDER",
|
||||||
|
"ContentHash",
|
||||||
|
"ContentType",
|
||||||
|
"ParsedContent",
|
||||||
"find_common_root",
|
"find_common_root",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
import argparse
|
"""Hivescan CLI entrypoint."""
|
||||||
import logging
|
|
||||||
import os
|
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 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(
|
parser = argparse.ArgumentParser(
|
||||||
description="Hivescan server — continuous media scanning with live WS updates.",
|
description="Hivescan server — continuous media scanning with live WS updates.",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
@@ -16,11 +38,8 @@ Examples:
|
|||||||
|
|
||||||
Exclude paths by creating .mediahive/scanignore (gitignore syntax).
|
Exclude paths by creating .mediahive/scanignore (gitignore syntax).
|
||||||
|
|
||||||
The server exposes:
|
The server exposes a unified endpoint:
|
||||||
WS /ws Live index updates & task progress
|
WS /api/ws Live index updates, task progress, and root status changes
|
||||||
POST /api/scan Trigger a new scan
|
|
||||||
GET /api/status Current server status
|
|
||||||
GET /api/index Full index as JSON (HTTP fallback)
|
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -41,12 +60,9 @@ The server exposes:
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
media_root = Path(args.media_folder).resolve()
|
# Defer filesystem validation to the server; pass raw path via env config.
|
||||||
if not media_root.exists() or not media_root.is_dir():
|
media_root = Path(args.media_folder).expanduser()
|
||||||
print(f"Error: Folder does not exist: {media_root}")
|
config.roots = {media_root.name or "media": media_root.as_posix()}
|
||||||
exit(1)
|
|
||||||
|
|
||||||
os.environ["MEDIAHIVE_PATH"] = str(media_root)
|
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
"""TMDb image downloading functions."""
|
"""TMDb image downloading functions."""
|
||||||
|
|
||||||
import httpx
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
import httpx
|
||||||
from aiopathlib import AsyncPath
|
from aiopathlib import AsyncPath
|
||||||
|
|
||||||
from mediahive.hivescan.utils import get_media_folder_path
|
from mediahive.hivescan.utils import get_media_folder_path
|
||||||
|
|
||||||
|
|
||||||
# TMDb image configuration
|
# TMDb image configuration
|
||||||
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
|
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
|
||||||
DEFAULT_POSTER_SIZE = "w500"
|
DEFAULT_POSTER_SIZE = "w500"
|
||||||
DEFAULT_BACKDROP_SIZE = "w1280"
|
DEFAULT_BACKDROP_SIZE = "w1280"
|
||||||
|
DEFAULT_PROFILE_SIZE = "w185"
|
||||||
|
|
||||||
# Shared async HTTP client (created lazily)
|
# Shared async HTTP client (created lazily)
|
||||||
_image_client: Optional[httpx.AsyncClient] = None
|
_image_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
def _get_image_client() -> httpx.AsyncClient:
|
def _get_image_client() -> httpx.AsyncClient:
|
||||||
@@ -30,13 +30,19 @@ def _get_image_client() -> httpx.AsyncClient:
|
|||||||
return _image_client
|
return _image_client
|
||||||
|
|
||||||
|
|
||||||
async def _download_image(
|
async def close_image_client() -> None:
|
||||||
url: str, output_path: Path, description: str
|
"""Close the persistent image HTTP client if it was created."""
|
||||||
) -> Optional[str]:
|
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."""
|
"""Download an image from URL to output path."""
|
||||||
ap = AsyncPath(output_path)
|
ap = AsyncPath(output_path)
|
||||||
if await ap.exists():
|
if await ap.exists():
|
||||||
return str(output_path)
|
return output_path.as_posix()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = _get_image_client()
|
client = _get_image_client()
|
||||||
@@ -44,8 +50,8 @@ async def _download_image(
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
await AsyncPath(output_path.parent).mkdir(parents=True, exist_ok=True)
|
await AsyncPath(output_path.parent).mkdir(parents=True, exist_ok=True)
|
||||||
await ap.write_bytes(response.content)
|
await ap.write_bytes(response.content)
|
||||||
return str(output_path)
|
return output_path.as_posix()
|
||||||
except Exception as e:
|
except (httpx.HTTPError, OSError) as e:
|
||||||
print(f" Failed to download {description}: {e}")
|
print(f" Failed to download {description}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -53,11 +59,11 @@ async def _download_image(
|
|||||||
async def download_cover_image(
|
async def download_cover_image(
|
||||||
poster_path: str,
|
poster_path: str,
|
||||||
title: str,
|
title: str,
|
||||||
year: Optional[int],
|
year: int | None,
|
||||||
media_type: str,
|
media_type: str,
|
||||||
cover_dir: Path,
|
cover_dir: Path,
|
||||||
size: str = DEFAULT_POSTER_SIZE,
|
size: str = DEFAULT_POSTER_SIZE,
|
||||||
) -> Optional[str]:
|
) -> str | None:
|
||||||
"""Download a cover image from TMDb."""
|
"""Download a cover image from TMDb."""
|
||||||
if not poster_path:
|
if not poster_path:
|
||||||
return None
|
return None
|
||||||
@@ -66,7 +72,7 @@ async def download_cover_image(
|
|||||||
cover_path = media_folder / "cover.jpg"
|
cover_path = media_folder / "cover.jpg"
|
||||||
|
|
||||||
if await AsyncPath(cover_path).exists():
|
if await AsyncPath(cover_path).exists():
|
||||||
return str(cover_path)
|
return cover_path.as_posix()
|
||||||
|
|
||||||
url = f"{TMDB_IMAGE_BASE}/{size}{poster_path}"
|
url = f"{TMDB_IMAGE_BASE}/{size}{poster_path}"
|
||||||
print(f" Downloading cover: {title}")
|
print(f" Downloading cover: {title}")
|
||||||
@@ -76,11 +82,11 @@ async def download_cover_image(
|
|||||||
async def download_backdrop_image(
|
async def download_backdrop_image(
|
||||||
backdrop_path: str,
|
backdrop_path: str,
|
||||||
title: str,
|
title: str,
|
||||||
year: Optional[int],
|
year: int | None,
|
||||||
media_type: str,
|
media_type: str,
|
||||||
cover_dir: Path,
|
cover_dir: Path,
|
||||||
size: str = DEFAULT_BACKDROP_SIZE,
|
size: str = DEFAULT_BACKDROP_SIZE,
|
||||||
) -> Optional[str]:
|
) -> str | None:
|
||||||
"""Download a backdrop image from TMDb."""
|
"""Download a backdrop image from TMDb."""
|
||||||
if not backdrop_path:
|
if not backdrop_path:
|
||||||
return None
|
return None
|
||||||
@@ -89,7 +95,7 @@ async def download_backdrop_image(
|
|||||||
local_path = media_folder / "backdrop.jpg"
|
local_path = media_folder / "backdrop.jpg"
|
||||||
|
|
||||||
if await AsyncPath(local_path).exists():
|
if await AsyncPath(local_path).exists():
|
||||||
return str(local_path)
|
return local_path.as_posix()
|
||||||
|
|
||||||
url = f"{TMDB_IMAGE_BASE}/{size}{backdrop_path}"
|
url = f"{TMDB_IMAGE_BASE}/{size}{backdrop_path}"
|
||||||
print(f" Downloading backdrop: {title}")
|
print(f" Downloading backdrop: {title}")
|
||||||
@@ -100,7 +106,7 @@ async def download_season_poster(
|
|||||||
poster_path: str,
|
poster_path: str,
|
||||||
media_folder: Path,
|
media_folder: Path,
|
||||||
season_num: int,
|
season_num: int,
|
||||||
) -> Optional[str]:
|
) -> str | None:
|
||||||
"""Download a season poster image from TMDb."""
|
"""Download a season poster image from TMDb."""
|
||||||
if not poster_path:
|
if not poster_path:
|
||||||
return None
|
return None
|
||||||
@@ -108,8 +114,39 @@ async def download_season_poster(
|
|||||||
output_path = media_folder / f"season{season_num:02d}.jpg"
|
output_path = media_folder / f"season{season_num:02d}.jpg"
|
||||||
|
|
||||||
if await AsyncPath(output_path).exists():
|
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)
|
await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True)
|
||||||
url = f"{TMDB_IMAGE_BASE}/{DEFAULT_POSTER_SIZE}{poster_path}"
|
url = f"{TMDB_IMAGE_BASE}/{DEFAULT_POSTER_SIZE}{poster_path}"
|
||||||
return await _download_image(url, output_path, f"season {season_num} poster")
|
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 dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class ContentType(Enum):
|
class ContentType(Enum):
|
||||||
@@ -24,7 +23,7 @@ class ContentHash:
|
|||||||
size: int = 0
|
size: int = 0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_path(cls, path: Path) -> "ContentHash":
|
def from_path(cls, path: Path) -> ContentHash:
|
||||||
"""Generate a content hash based on torrent name."""
|
"""Generate a content hash based on torrent name."""
|
||||||
hash_val = hashlib.md5(path.name.encode()).hexdigest()[:16]
|
hash_val = hashlib.md5(path.name.encode()).hexdigest()[:16]
|
||||||
return cls(path=path, hash=hash_val)
|
return cls(path=path, hash=hash_val)
|
||||||
@@ -38,16 +37,17 @@ class ParsedContent:
|
|||||||
name: str
|
name: str
|
||||||
content_type: ContentType
|
content_type: ContentType
|
||||||
title: str
|
title: str
|
||||||
year: Optional[int] = None
|
year: int | None = None
|
||||||
resolution: Optional[str] = None
|
resolution: str | None = None
|
||||||
quality: Optional[str] = None
|
quality: str | None = None
|
||||||
codec: Optional[str] = None
|
network: str | None = None
|
||||||
audio: Optional[str] = None
|
codec: str | None = None
|
||||||
season: Optional[int] = None
|
audio: str | None = None
|
||||||
episode: Optional[int] = None
|
season: int | None = None
|
||||||
episode_name: Optional[str] = None
|
episode: int | None = None
|
||||||
encoder: Optional[str] = None
|
episode_name: str | None = None
|
||||||
language: Optional[str] = None
|
encoder: str | None = None
|
||||||
|
language: str | None = None
|
||||||
is_directory: bool = False
|
is_directory: bool = False
|
||||||
raw_parsed: dict = field(default_factory=dict)
|
raw_parsed: dict = field(default_factory=dict)
|
||||||
content_hash: Optional[ContentHash] = None
|
content_hash: ContentHash | None = None
|
||||||
|
|||||||
@@ -2,12 +2,21 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
import PTN
|
import PTN
|
||||||
from aiopathlib import AsyncPath
|
from aiopathlib import AsyncPath
|
||||||
|
|
||||||
from mediahive.hivescan.models import ContentHash, ContentType, ParsedContent
|
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:
|
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."""
|
"""Parse a downloaded torrent directory/file name."""
|
||||||
name = path.name
|
name = path.name
|
||||||
parsed = PTN.parse(name)
|
parsed = PTN.parse(name)
|
||||||
|
parsed["encoder"] = strip_edge_non_alphanumerics(parsed.get("encoder"))
|
||||||
content_type = determine_content_type(parsed)
|
content_type = determine_content_type(parsed)
|
||||||
content_hash = ContentHash.from_path(path)
|
content_hash = ContentHash.from_path(path)
|
||||||
|
|
||||||
@@ -36,8 +46,9 @@ async def parse_download(path: Path) -> ParsedContent:
|
|||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
title=parsed.get("title", name),
|
title=parsed.get("title", name),
|
||||||
year=parsed.get("year"),
|
year=parsed.get("year"),
|
||||||
resolution=parsed.get("resolution"),
|
resolution=normalize_resolution_label(parsed.get("resolution")),
|
||||||
quality=parsed.get("quality"),
|
quality=parsed.get("quality"),
|
||||||
|
network=parsed.get("network"),
|
||||||
codec=parsed.get("codec"),
|
codec=parsed.get("codec"),
|
||||||
audio=parsed.get("audio"),
|
audio=parsed.get("audio"),
|
||||||
season=parsed.get("season"),
|
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]]:
|
def parse_episode_from_filename(filename: str) -> tuple[int, int] | None:
|
||||||
"""
|
"""Parse season and episode numbers from a filename.
|
||||||
Parse season and episode numbers from a filename.
|
|
||||||
|
|
||||||
Handles formats: S01E05, 1x05, Season 1 Episode 5
|
Handles formats: S01E05, 1x05, Season 1 Episode 5
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (season_number, episode_number) or None if not found
|
Tuple of (season_number, episode_number) or None if not found
|
||||||
|
|
||||||
"""
|
"""
|
||||||
name = filename.lower()
|
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
|
Reads patterns from ``<media_root>/.mediahive/scanignore``. The file uses the
|
||||||
same syntax as ``.gitignore``:
|
same syntax as ``.gitignore``:
|
||||||
@@ -30,8 +29,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Tuple
|
|
||||||
|
|
||||||
|
|
||||||
# Built-in patterns that are always excluded (before user file)
|
# Built-in patterns that are always excluded (before user file)
|
||||||
_BUILTIN_EXCLUDES: list[str] = [
|
_BUILTIN_EXCLUDES: list[str] = [
|
||||||
@@ -75,13 +72,11 @@ def _pattern_to_regex(pattern: str) -> re.Pattern[str]:
|
|||||||
parts.append("(?:.+/)?")
|
parts.append("(?:.+/)?")
|
||||||
i += 3
|
i += 3
|
||||||
continue
|
continue
|
||||||
else:
|
parts.append(".*")
|
||||||
parts.append(".*")
|
i += 2
|
||||||
i += 2
|
continue
|
||||||
continue
|
parts.append("[^/]*")
|
||||||
else:
|
i += 1
|
||||||
parts.append("[^/]*")
|
|
||||||
i += 1
|
|
||||||
elif c == "?":
|
elif c == "?":
|
||||||
parts.append("[^/]")
|
parts.append("[^/]")
|
||||||
i += 1
|
i += 1
|
||||||
@@ -94,12 +89,7 @@ def _pattern_to_regex(pattern: str) -> re.Pattern[str]:
|
|||||||
|
|
||||||
regex_str = "".join(parts)
|
regex_str = "".join(parts)
|
||||||
|
|
||||||
if anchored or has_slash:
|
regex_str = "^" + regex_str if anchored or has_slash else "(?:^|/)" + regex_str
|
||||||
# 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
|
|
||||||
|
|
||||||
# Must match the whole remaining path or be a prefix (directory match)
|
# Must match the whole remaining path or be a prefix (directory match)
|
||||||
regex_str += "(?:/.*)?$"
|
regex_str += "(?:/.*)?$"
|
||||||
@@ -112,7 +102,7 @@ class ScanIgnore:
|
|||||||
|
|
||||||
def __init__(self, media_root: Path) -> None:
|
def __init__(self, media_root: Path) -> None:
|
||||||
self.media_root = media_root.resolve()
|
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_builtins()
|
||||||
self._load_file()
|
self._load_file()
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import glob
|
import glob
|
||||||
|
import operator
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
from aiopathlib import AsyncPath
|
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.parsing import parse_download, parse_episode_from_filename
|
||||||
from mediahive.hivescan.utils import get_media_folder_path, sanitize_filename
|
from mediahive.hivescan.utils import get_media_folder_path, sanitize_filename
|
||||||
|
|
||||||
|
|
||||||
# Video file extensions
|
# Video file extensions
|
||||||
VIDEO_EXTENSIONS = {
|
VIDEO_EXTENSIONS = {
|
||||||
".mkv",
|
".mkv",
|
||||||
@@ -26,23 +28,118 @@ VIDEO_EXTENSIONS = {
|
|||||||
".m2ts",
|
".m2ts",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Caches for expensive operations
|
# External subtitle file extensions
|
||||||
_episode_files_cache: Dict[str, Dict[Tuple[int, int], List[Tuple[str, int]]]] = {}
|
SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub"}
|
||||||
_playable_file_cache: Dict[str, Optional[str]] = {}
|
|
||||||
|
# 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]:
|
def clear_scan_caches() -> None:
|
||||||
"""
|
"""Drop all per-scan filesystem caches; called at the start of each scan."""
|
||||||
Scan download directories matching the pattern.
|
_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:
|
Args:
|
||||||
base_pattern: Glob pattern for finding download directories
|
base_pattern: Glob pattern for finding download directories
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of ParsedContent objects for each found download
|
List of ParsedContent objects for each found download
|
||||||
|
|
||||||
"""
|
"""
|
||||||
exclude_patterns = [".torrents", "incomplete", ".incomplete"]
|
exclude_patterns = [".torrents", "incomplete", ".incomplete"]
|
||||||
results: List[ParsedContent] = []
|
results: list[ParsedContent] = []
|
||||||
|
|
||||||
paths = await asyncio.to_thread(glob.glob, base_pattern)
|
paths = await asyncio.to_thread(glob.glob, base_pattern)
|
||||||
for path_str in paths:
|
for path_str in paths:
|
||||||
@@ -81,57 +178,74 @@ def categorize_downloads(
|
|||||||
|
|
||||||
async def find_episode_files(
|
async def find_episode_files(
|
||||||
path: Path,
|
path: Path,
|
||||||
) -> Dict[Tuple[int, int], List[Tuple[str, int]]]:
|
) -> dict[tuple[int, int], list[tuple[str, int]]]:
|
||||||
"""
|
"""Find all episode video files in a directory.
|
||||||
Find all episode video files in a directory.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Path to search (can be a season pack directory or single file)
|
path: Path to search (can be a season pack directory or single file)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping (season_num, episode_num) to list of (file_path, file_size) tuples
|
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:
|
if cache_key in _episode_files_cache:
|
||||||
return _episode_files_cache[cache_key]
|
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)
|
ap = AsyncPath(path)
|
||||||
|
|
||||||
if await ap.is_file():
|
if await ap.is_file():
|
||||||
if path.suffix.lower() in VIDEO_EXTENSIONS:
|
if path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||||
ep_info = parse_episode_from_filename(path.name)
|
ep_info = parse_episode_from_filename(path.name)
|
||||||
if ep_info:
|
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
|
_episode_files_cache[cache_key] = episodes
|
||||||
return episodes
|
return episodes
|
||||||
|
|
||||||
try:
|
stack = [path]
|
||||||
for f in ap.rglob("*"):
|
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() not in VIDEO_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
if "sample" in f.name.lower():
|
||||||
|
continue
|
||||||
af = AsyncPath(f)
|
af = AsyncPath(f)
|
||||||
if await af.is_file() and Path(f).suffix.lower() in VIDEO_EXTENSIONS:
|
try:
|
||||||
if "sample" in Path(f).name.lower():
|
ep_info = parse_episode_from_filename(f.name)
|
||||||
continue
|
|
||||||
ep_info = parse_episode_from_filename(Path(f).name)
|
|
||||||
if ep_info:
|
if ep_info:
|
||||||
if ep_info not in episodes:
|
if ep_info not in episodes:
|
||||||
episodes[ep_info] = []
|
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:
|
except OSError, PermissionError:
|
||||||
pass
|
continue
|
||||||
|
|
||||||
_episode_files_cache[cache_key] = episodes
|
_episode_files_cache[cache_key] = episodes
|
||||||
return episodes
|
return episodes
|
||||||
|
|
||||||
|
|
||||||
async def find_playable_file(path: Path) -> Optional[str]:
|
async def find_playable_file(path: Path) -> str | None:
|
||||||
"""
|
"""Find the main playable media file in a directory.
|
||||||
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
|
For other content: Returns the largest video file
|
||||||
"""
|
"""
|
||||||
cache_key = str(path)
|
cache_key = path.as_posix()
|
||||||
if cache_key in _playable_file_cache:
|
if cache_key in _playable_file_cache:
|
||||||
return _playable_file_cache[cache_key]
|
return _playable_file_cache[cache_key]
|
||||||
|
|
||||||
@@ -139,71 +253,306 @@ async def find_playable_file(path: Path) -> Optional[str]:
|
|||||||
|
|
||||||
if await ap.is_file():
|
if await ap.is_file():
|
||||||
if path.suffix.lower() in VIDEO_EXTENSIONS:
|
if path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||||
result = str(path)
|
result = path.as_posix()
|
||||||
_playable_file_cache[cache_key] = result
|
_playable_file_cache[cache_key] = result
|
||||||
return result
|
return result
|
||||||
_playable_file_cache[cache_key] = None
|
_playable_file_cache[cache_key] = None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check for Blu-ray disc structure
|
# 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():
|
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
|
_playable_file_cache[cache_key] = result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Check nested Blu-ray structure (e.g., MovieName/DISC1/BDMV/)
|
# Check nested Blu-ray structure (e.g., MovieName/DISC1/BDMV/)
|
||||||
try:
|
try:
|
||||||
for subdir in ap.iterdir():
|
stop_event = threading.Event()
|
||||||
if await AsyncPath(subdir).is_dir():
|
child_dirs, _ = await asyncio.to_thread(
|
||||||
nested_bdmv = Path(subdir) / "BDMV" / "index.bdmv"
|
_scandir_split,
|
||||||
if await AsyncPath(nested_bdmv).exists():
|
path,
|
||||||
result = str(nested_bdmv)
|
stop_event,
|
||||||
_playable_file_cache[cache_key] = result
|
)
|
||||||
return result
|
except asyncio.CancelledError:
|
||||||
|
stop_event.set()
|
||||||
|
raise
|
||||||
except OSError, PermissionError:
|
except OSError, PermissionError:
|
||||||
pass
|
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
|
||||||
|
|
||||||
# Find largest video file
|
# Find largest video file
|
||||||
video_files = []
|
video_files = []
|
||||||
try:
|
stack = [path]
|
||||||
for f in ap.rglob("*"):
|
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() not in VIDEO_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
if "sample" in f.name.lower():
|
||||||
|
continue
|
||||||
af = AsyncPath(f)
|
af = AsyncPath(f)
|
||||||
if await af.is_file() and Path(f).suffix.lower() in VIDEO_EXTENSIONS:
|
try:
|
||||||
if "sample" in Path(f).name.lower():
|
video_files.append((f.as_posix(), (await af.stat()).st_size))
|
||||||
continue
|
except OSError, PermissionError:
|
||||||
video_files.append((str(f), (await af.stat()).st_size))
|
continue
|
||||||
except OSError, PermissionError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not video_files:
|
if not video_files:
|
||||||
_playable_file_cache[cache_key] = None
|
_playable_file_cache[cache_key] = None
|
||||||
return 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]
|
result = video_files[0][0]
|
||||||
_playable_file_cache[cache_key] = result
|
_playable_file_cache[cache_key] = result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_subtitle_language(video_stem: str, filename: str) -> str | None:
|
||||||
|
"""Language tag from a sidecar subtitle name like `<stem>.esp.srt`, if any."""
|
||||||
|
if not filename.startswith(video_stem + "."):
|
||||||
|
return None
|
||||||
|
suffix = Path(filename).suffix.lower()
|
||||||
|
if suffix not in SUBTITLE_EXTENSIONS:
|
||||||
|
return None
|
||||||
|
middle = filename[len(video_stem) + 1 : -len(suffix)]
|
||||||
|
tokens = [t for t in middle.split(".") if t]
|
||||||
|
while tokens and tokens[-1].lower() in _SUBTITLE_FLAG_TOKENS:
|
||||||
|
tokens.pop()
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
code = tokens[-1].lower()
|
||||||
|
if not code.isalpha() or not 2 <= len(code) <= 3:
|
||||||
|
return None
|
||||||
|
code = _ISO_639_1_TO_639_2.get(code, code)
|
||||||
|
return None if code == "und" else code
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_external_subtitle_languages(video_path: Path) -> list[str]:
|
||||||
|
languages: list[str] = []
|
||||||
|
with os.scandir(video_path.parent) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.is_file(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
lang = _sidecar_subtitle_language(video_path.stem, entry.name)
|
||||||
|
if lang and lang not in languages:
|
||||||
|
languages.append(lang)
|
||||||
|
return languages
|
||||||
|
|
||||||
|
|
||||||
|
async def find_external_subtitle_languages(video_path: str | None) -> list[str]:
|
||||||
|
"""Languages of external subtitle files sitting next to a video file.
|
||||||
|
|
||||||
|
Matches sidecars named `<stem>.<lang>.<ext>` (e.g. `Movie.esp.srt` ->
|
||||||
|
``esp``), optionally with flags like ``forced``/``sdh`` after the language.
|
||||||
|
Bare `<stem>.<ext>` files carry no language tag and are ignored.
|
||||||
|
"""
|
||||||
|
if not video_path or "://" in video_path or video_path.startswith("concat:"):
|
||||||
|
return []
|
||||||
|
path = Path(video_path)
|
||||||
|
if path.suffix.lower() not in VIDEO_EXTENSIONS:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_scan_external_subtitle_languages, path)
|
||||||
|
except OSError, PermissionError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def find_metadata_probe_file(playable_path: str | None) -> str | None:
|
||||||
|
"""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(
|
async def find_cover_image(
|
||||||
title: str, year: Optional[int], media_type: str, cover_dir: Path
|
title: str, year: int | None, media_type: str, cover_dir: Path
|
||||||
) -> Optional[str]:
|
) -> str | None:
|
||||||
"""Find a cover image for the given media item."""
|
"""Find a cover image for the given media item."""
|
||||||
media_folder = get_media_folder_path(title, year, media_type, cover_dir)
|
media_folder = get_media_folder_path(title, year, media_type, cover_dir)
|
||||||
cover_path = media_folder / "cover.jpg"
|
cover_path = media_folder / "cover.jpg"
|
||||||
if await AsyncPath(cover_path).exists():
|
if await AsyncPath(cover_path).exists():
|
||||||
return str(cover_path)
|
return cover_path.as_posix()
|
||||||
|
|
||||||
# Legacy structure fallback
|
# Legacy structure fallback
|
||||||
subdir = "movies" if media_type == "movie" else "series"
|
subdir = "movies" if media_type == "movie" else "series"
|
||||||
if media_type == "movie" and year:
|
if media_type == "movie" and year:
|
||||||
legacy_path = cover_dir / subdir / f"{sanitize_filename(title)} ({year}).jpg"
|
legacy_path = cover_dir / subdir / f"{sanitize_filename(title)} ({year}).jpg"
|
||||||
if await AsyncPath(legacy_path).exists():
|
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"
|
legacy_path = cover_dir / subdir / f"{sanitize_filename(title)}.jpg"
|
||||||
if await AsyncPath(legacy_path).exists():
|
if await AsyncPath(legacy_path).exists():
|
||||||
return str(legacy_path)
|
return legacy_path.as_posix()
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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 asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -10,17 +8,16 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from aiopathlib import AsyncPath
|
from aiopathlib import AsyncPath
|
||||||
|
|
||||||
from mediahive.models.tmdb import (
|
from mediahive.models.tmdb import (
|
||||||
CastMember,
|
CastCredit,
|
||||||
EpisodeInfo,
|
EpisodeInfo,
|
||||||
Info,
|
Info,
|
||||||
|
Person,
|
||||||
SeasonInfo,
|
SeasonInfo,
|
||||||
SimilarMedia,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# TMDb API configuration
|
# 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"
|
TMDB_API_BASE = "https://api.themoviedb.org/3"
|
||||||
|
|
||||||
# API response cache directory (can be overridden via set_cache_dir)
|
# 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
|
# 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:
|
def set_cache_dir(cache_dir: Path) -> None:
|
||||||
@@ -61,11 +58,19 @@ def _get_http_client() -> httpx.AsyncClient:
|
|||||||
return _http_client
|
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"
|
# Sentinel value to distinguish "cached None" from "not in cache"
|
||||||
_NOT_FOUND = object()
|
_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."""
|
"""Generate a cache file path for an API request."""
|
||||||
# Create a stable cache key from endpoint and sorted params
|
# Create a stable cache key from endpoint and sorted params
|
||||||
cache_key = endpoint + "?" + urllib.parse.urlencode(sorted(params.items()))
|
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"):
|
if data.get("_cached_none"):
|
||||||
return None
|
return None
|
||||||
return data
|
return data
|
||||||
except Exception:
|
except OSError, TypeError, json.JSONDecodeError:
|
||||||
return _NOT_FOUND
|
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."""
|
"""Save response to cache."""
|
||||||
try:
|
try:
|
||||||
await AsyncPath(_get_cache_dir()).mkdir(parents=True, exist_ok=True)
|
await AsyncPath(_get_cache_dir()).mkdir(parents=True, exist_ok=True)
|
||||||
if data is None:
|
text = json.dumps({"_cached_none": True}) if data is None else json.dumps(data)
|
||||||
text = json.dumps({"_cached_none": True})
|
|
||||||
else:
|
|
||||||
text = json.dumps(data)
|
|
||||||
await AsyncPath(cache_path).write_text(text, encoding="utf-8")
|
await AsyncPath(cache_path).write_text(text, encoding="utf-8")
|
||||||
except Exception:
|
except OSError, TypeError, ValueError:
|
||||||
pass # Cache write failures are not critical
|
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(
|
async def tmdb_api_request(
|
||||||
endpoint: str, params: Optional[Dict[str, str]] = None
|
endpoint: str, params: dict[str, str] | None = None
|
||||||
) -> Optional[Dict[str, str]]:
|
) -> dict[str, str] | None:
|
||||||
"""Make a request to the TMDb API with disk caching and connection reuse."""
|
"""Make a request to the TMDb API with disk caching and connection reuse."""
|
||||||
params = params or {}
|
params = params or {}
|
||||||
|
|
||||||
@@ -139,35 +141,30 @@ async def tmdb_api_request(
|
|||||||
# Cache the failure (None) to avoid retrying
|
# Cache the failure (None) to avoid retrying
|
||||||
await _save_to_cache(cache_path, None)
|
await _save_to_cache(cache_path, None)
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except httpx.HTTPError:
|
||||||
# Don't cache network errors - they may be transient
|
# Don't cache network errors - they may be transient
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def fetch_movie_details(movie_id: int) -> Optional[Dict]:
|
async def fetch_movie_details(movie_id: int) -> dict | None:
|
||||||
"""Fetch detailed movie info including credits, similar, keywords, and alternative titles."""
|
"""Fetch movie info including credits, keywords, alt titles, and collection."""
|
||||||
# Use append_to_response to get multiple data in one request
|
# Use append_to_response to get multiple data in one request
|
||||||
data = await tmdb_api_request(
|
return await tmdb_api_request(
|
||||||
f"/movie/{movie_id}",
|
f"/movie/{movie_id}",
|
||||||
{"append_to_response": "credits,similar,keywords,alternative_titles"},
|
{"append_to_response": "credits,keywords,alternative_titles"},
|
||||||
)
|
)
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_series_details(series_id: int) -> Optional[Dict]:
|
async def fetch_series_details(series_id: int) -> dict | None:
|
||||||
"""Fetch detailed TV series info including credits, similar, and keywords."""
|
"""Fetch detailed TV series info including credits and keywords."""
|
||||||
# Use append_to_response to get multiple data in one request
|
# Use append_to_response to get multiple data in one request
|
||||||
data = await tmdb_api_request(
|
return await tmdb_api_request(
|
||||||
f"/tv/{series_id}", {"append_to_response": "credits,similar,keywords"}
|
f"/tv/{series_id}", {"append_to_response": "credits,keywords"}
|
||||||
)
|
)
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_season_details(
|
async def fetch_season_details(series_id: int, season_number: int) -> SeasonInfo | None:
|
||||||
series_id: int, season_number: int
|
"""Fetch detailed season info including all episodes.
|
||||||
) -> Optional[SeasonInfo]:
|
|
||||||
"""
|
|
||||||
Fetch detailed season info including all episodes.
|
|
||||||
|
|
||||||
Returns season metadata with episode list including:
|
Returns season metadata with episode list including:
|
||||||
- Episode names, overviews, air dates
|
- 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]:
|
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.
|
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))
|
variants.append(" ".join(words))
|
||||||
|
|
||||||
# Then try removing from end (most common: edition names at end)
|
# Then try removing from end (most common: edition names at end)
|
||||||
for num_words in range(len(words) - 1, min_words - 1, -1):
|
variants.extend(
|
||||||
variants.append(" ".join(words[:num_words]))
|
" ".join(words[:num_words])
|
||||||
|
for num_words in range(len(words) - 1, min_words - 1, -1)
|
||||||
|
)
|
||||||
|
|
||||||
# Then try removing from start (garbage at beginning)
|
# Then try removing from start (garbage at beginning)
|
||||||
for start in range(1, len(words) - min_words + 1):
|
variants.extend(
|
||||||
variants.append(" ".join(words[start:]))
|
" ".join(words[start:]) for start in range(1, len(words) - min_words + 1)
|
||||||
|
)
|
||||||
|
|
||||||
# Finally try middle portions (remove from both ends)
|
# Finally try middle portions (remove from both ends)
|
||||||
for start in range(1, len(words) - min_words):
|
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:
|
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
|
Uses word overlap to verify the result is relevant, preventing
|
||||||
false matches from short queries like "The" or just a year.
|
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(
|
async def _search_movie_with_fallbacks(title: str, year: int | None) -> dict | None:
|
||||||
title: str, year: Optional[int]
|
"""Search for a movie with progressive title shortening fallbacks.
|
||||||
) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Search for a movie with progressive title shortening fallbacks.
|
|
||||||
|
|
||||||
PTN often includes edition names (THEATRICAL CUT, DIRECTOR'S CUT, etc.)
|
PTN often includes edition names (THEATRICAL CUT, DIRECTOR'S CUT, etc.)
|
||||||
or garbage at the beginning/end of the title.
|
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)
|
variants = _generate_title_variants(words, min_words=2)
|
||||||
|
|
||||||
def _result_matches(
|
def _result_matches(
|
||||||
top_result: Dict, original_title: str, search_query: str
|
top_result: dict, original_title: str, search_query: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if result matches against either title or original_title."""
|
"""Check if result matches against either title or original_title."""
|
||||||
tmdb_title = top_result.get("title", "")
|
tmdb_title = top_result.get("title", "")
|
||||||
@@ -363,7 +371,10 @@ async def _search_movie_with_fallbacks(
|
|||||||
return None
|
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."""
|
"""Fetch comprehensive movie info from TMDb."""
|
||||||
data = await _search_movie_with_fallbacks(title, year)
|
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]
|
result = data["results"][0]
|
||||||
movie_id = result["id"]
|
movie_id = result["id"]
|
||||||
|
|
||||||
# Fetch full details with credits, similar movies, and keywords
|
# Fetch full details with credits, keywords, alt titles, and collection
|
||||||
details = await fetch_movie_details(movie_id)
|
details = await fetch_movie_details(movie_id)
|
||||||
if not details:
|
if not details:
|
||||||
# Fall back to basic info from search
|
# Fall back to basic info from search
|
||||||
return Info(
|
return (
|
||||||
tmdb_id=movie_id,
|
Info(
|
||||||
title=result.get("title"),
|
tmdb_id=movie_id,
|
||||||
original_title=result.get("original_title"),
|
title=result.get("title"),
|
||||||
rating=result.get("vote_average"),
|
original_title=result.get("original_title"),
|
||||||
vote_count=result.get("vote_count"),
|
original_language=result.get("original_language"),
|
||||||
overview=result.get("overview"),
|
rating=result.get("vote_average"),
|
||||||
poster_path=result.get("poster_path"),
|
vote_count=result.get("vote_count"),
|
||||||
backdrop_path=result.get("backdrop_path"),
|
overview=result.get("overview"),
|
||||||
release_date=result.get("release_date"),
|
release_date=result.get("release_date"),
|
||||||
|
),
|
||||||
|
result.get("poster_path"),
|
||||||
|
result.get("backdrop_path"),
|
||||||
|
{},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract genres
|
# 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)
|
alt_titles_set.discard(orig_title)
|
||||||
alternative_titles = sorted(alt_titles_set) if alt_titles_set else None
|
alternative_titles = sorted(alt_titles_set) if alt_titles_set else None
|
||||||
|
|
||||||
# Extract top cast (limit to 10)
|
# Extract full cast
|
||||||
credits = details.get("credits", {})
|
credits_data = details.get("credits", {})
|
||||||
cast_data = credits.get("cast", [])[:10]
|
cast_data = credits_data.get("cast", [])
|
||||||
cast = [
|
cast: list[CastCredit] = []
|
||||||
CastMember(
|
people: dict[int, Person] = {}
|
||||||
name=c["name"],
|
for c in cast_data:
|
||||||
character=c.get("character", ""),
|
person_id = c.get("id")
|
||||||
profile_path=c.get("profile_path"),
|
cast.append(
|
||||||
|
CastCredit(
|
||||||
|
character=c.get("character", "") or None,
|
||||||
|
id=person_id if isinstance(person_id, int) else None,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for c in cast_data
|
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")),
|
||||||
|
)
|
||||||
|
|
||||||
# Extract director from crew
|
# 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"]
|
directors = [c["name"] for c in crew if c.get("job") == "Director"]
|
||||||
director = directors[0] if directors else None
|
director = directors[0] if directors else None
|
||||||
|
|
||||||
# Extract similar movies (limit to 10)
|
collection_data = details.get("belongs_to_collection")
|
||||||
similar_data = details.get("similar", {}).get("results", [])[:10]
|
collection = None
|
||||||
similar = [
|
if isinstance(collection_data, dict):
|
||||||
SimilarMedia(id=s["id"], title=s["title"], poster_path=s.get("poster_path"))
|
collection_name = collection_data.get("name")
|
||||||
for s in similar_data
|
if isinstance(collection_name, str):
|
||||||
]
|
collection = collection_name or None
|
||||||
|
|
||||||
return Info(
|
return (
|
||||||
tmdb_id=movie_id,
|
Info(
|
||||||
title=details.get("title"),
|
tmdb_id=movie_id,
|
||||||
original_title=details.get("original_title"),
|
title=details.get("title"),
|
||||||
alternative_titles=alternative_titles,
|
original_title=details.get("original_title"),
|
||||||
rating=details.get("vote_average"),
|
original_language=details.get("original_language"),
|
||||||
vote_count=details.get("vote_count"),
|
alternative_titles=alternative_titles,
|
||||||
overview=details.get("overview"),
|
rating=details.get("vote_average"),
|
||||||
genres=genres if genres else None,
|
vote_count=details.get("vote_count"),
|
||||||
release_date=details.get("release_date"),
|
overview=details.get("overview"),
|
||||||
runtime=details.get("runtime"),
|
genres=genres or None,
|
||||||
status=details.get("status"),
|
release_date=details.get("release_date"),
|
||||||
tagline=details.get("tagline"),
|
runtime=details.get("runtime"),
|
||||||
poster_path=details.get("poster_path"),
|
collection=collection,
|
||||||
backdrop_path=details.get("backdrop_path"),
|
status=details.get("status"),
|
||||||
similar=similar if similar else None,
|
tagline=details.get("tagline"),
|
||||||
keywords=keywords if keywords else None,
|
keywords=keywords or None,
|
||||||
cast=cast if cast else None,
|
cast=cast or None,
|
||||||
director=director,
|
director=director,
|
||||||
|
),
|
||||||
|
details.get("poster_path"),
|
||||||
|
details.get("backdrop_path"),
|
||||||
|
people,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _search_series_with_fallbacks(title: str) -> Optional[Dict]:
|
async def _search_series_with_fallbacks(title: str) -> dict | None:
|
||||||
"""
|
"""Search for a TV series with progressive title shortening fallbacks.
|
||||||
Search for a TV series with progressive title shortening fallbacks.
|
|
||||||
|
|
||||||
PTN often includes extra text in the title at beginning or end.
|
PTN often includes extra text in the title at beginning or end.
|
||||||
Results are validated with fuzzy matching to prevent false positives.
|
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
|
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."""
|
"""Fetch comprehensive TV series info from TMDb."""
|
||||||
data = await _search_series_with_fallbacks(title)
|
data = await _search_series_with_fallbacks(title)
|
||||||
|
|
||||||
@@ -489,19 +517,23 @@ async def fetch_series_info(title: str) -> Optional[Info]:
|
|||||||
result = data["results"][0]
|
result = data["results"][0]
|
||||||
series_id = result["id"]
|
series_id = result["id"]
|
||||||
|
|
||||||
# Fetch full details with credits, similar shows, and keywords
|
# Fetch full details with credits and keywords
|
||||||
details = await fetch_series_details(series_id)
|
details = await fetch_series_details(series_id)
|
||||||
if not details:
|
if not details:
|
||||||
# Fall back to basic info from search
|
# Fall back to basic info from search
|
||||||
return Info(
|
return (
|
||||||
tmdb_id=series_id,
|
Info(
|
||||||
title=result.get("name"),
|
tmdb_id=series_id,
|
||||||
original_title=result.get("original_name"),
|
title=result.get("name"),
|
||||||
rating=result.get("vote_average"),
|
original_title=result.get("original_name"),
|
||||||
vote_count=result.get("vote_count"),
|
original_language=result.get("original_language"),
|
||||||
overview=result.get("overview"),
|
rating=result.get("vote_average"),
|
||||||
poster_path=result.get("poster_path"),
|
vote_count=result.get("vote_count"),
|
||||||
backdrop_path=result.get("backdrop_path"),
|
overview=result.get("overview"),
|
||||||
|
),
|
||||||
|
result.get("poster_path"),
|
||||||
|
result.get("backdrop_path"),
|
||||||
|
{},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract genres
|
# Extract genres
|
||||||
@@ -511,17 +543,25 @@ async def fetch_series_info(title: str) -> Optional[Info]:
|
|||||||
keywords_data = details.get("keywords", {}).get("results", [])
|
keywords_data = details.get("keywords", {}).get("results", [])
|
||||||
keywords = [k["name"] for k in keywords_data]
|
keywords = [k["name"] for k in keywords_data]
|
||||||
|
|
||||||
# Extract top cast (limit to 10)
|
# Extract full cast
|
||||||
credits = details.get("credits", {})
|
credits_data = details.get("credits", {})
|
||||||
cast_data = credits.get("cast", [])[:10]
|
cast_data = credits_data.get("cast", [])
|
||||||
cast = [
|
cast: list[CastCredit] = []
|
||||||
CastMember(
|
people: dict[int, Person] = {}
|
||||||
name=c["name"],
|
for c in cast_data:
|
||||||
character=c.get("character", ""),
|
person_id = c.get("id")
|
||||||
profile_path=c.get("profile_path"),
|
cast.append(
|
||||||
|
CastCredit(
|
||||||
|
character=c.get("character", "") or None,
|
||||||
|
id=person_id if isinstance(person_id, int) else None,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for c in cast_data
|
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")),
|
||||||
|
)
|
||||||
|
|
||||||
# Extract creators
|
# Extract creators
|
||||||
creators = [c["name"] for c in details.get("created_by", [])]
|
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
|
# Extract networks
|
||||||
networks = [n["name"] for n in details.get("networks", [])]
|
networks = [n["name"] for n in details.get("networks", [])]
|
||||||
|
|
||||||
# Extract similar series (limit to 10)
|
|
||||||
similar_data = details.get("similar", {}).get("results", [])[:10]
|
|
||||||
similar = [
|
|
||||||
SimilarMedia(id=s["id"], title=s["name"], poster_path=s.get("poster_path"))
|
|
||||||
for s in similar_data
|
|
||||||
]
|
|
||||||
|
|
||||||
# Get first air date
|
# Get first air date
|
||||||
first_air_date = details.get("first_air_date")
|
first_air_date = details.get("first_air_date")
|
||||||
|
|
||||||
return Info(
|
return (
|
||||||
tmdb_id=series_id,
|
Info(
|
||||||
title=details.get("name"),
|
tmdb_id=series_id,
|
||||||
original_title=details.get("original_name"),
|
title=details.get("name"),
|
||||||
rating=details.get("vote_average"),
|
original_title=details.get("original_name"),
|
||||||
vote_count=details.get("vote_count"),
|
original_language=details.get("original_language"),
|
||||||
overview=details.get("overview"),
|
rating=details.get("vote_average"),
|
||||||
genres=genres if genres else None,
|
vote_count=details.get("vote_count"),
|
||||||
release_date=first_air_date,
|
overview=details.get("overview"),
|
||||||
status=details.get("status"),
|
genres=genres or None,
|
||||||
tagline=details.get("tagline"),
|
release_date=first_air_date,
|
||||||
poster_path=details.get("poster_path"),
|
status=details.get("status"),
|
||||||
backdrop_path=details.get("backdrop_path"),
|
tagline=details.get("tagline"),
|
||||||
similar=similar if similar else None,
|
keywords=keywords or None,
|
||||||
keywords=keywords if keywords else None,
|
cast=cast or None,
|
||||||
cast=cast if cast else None,
|
creators=creators or None,
|
||||||
creators=creators if creators else None,
|
number_of_seasons=details.get("number_of_seasons"),
|
||||||
number_of_seasons=details.get("number_of_seasons"),
|
number_of_episodes=details.get("number_of_episodes"),
|
||||||
number_of_episodes=details.get("number_of_episodes"),
|
networks=networks or None,
|
||||||
networks=networks if networks else None,
|
),
|
||||||
|
details.get("poster_path"),
|
||||||
|
details.get("backdrop_path"),
|
||||||
|
people,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,86 +1,170 @@
|
|||||||
"""Utility functions for paths, sizes, and timestamps."""
|
"""Utility functions for paths, sizes, and timestamps."""
|
||||||
|
|
||||||
import time
|
import os
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List
|
|
||||||
|
|
||||||
from aiopathlib import AsyncPath
|
from aiopathlib import AsyncPath
|
||||||
|
|
||||||
|
|
||||||
# Default output folder name (created at common root of scanned paths)
|
# Default output folder name (created at common root of scanned paths)
|
||||||
DEFAULT_OUTPUT_FOLDER = ".mediahive"
|
DEFAULT_OUTPUT_FOLDER = ".mediahive"
|
||||||
|
|
||||||
# Threshold for considering atime "too close" to current time (1 hour)
|
|
||||||
_ATIME_FRESHNESS_THRESHOLD = 3600
|
|
||||||
|
|
||||||
# Resolution priority for quality sorting (higher = better)
|
# Resolution priority for quality sorting (higher = better)
|
||||||
RESOLUTION_PRIORITY = {
|
RESOLUTION_PRIORITY = {
|
||||||
"2160p": 4,
|
"8K": 5,
|
||||||
"4K": 4,
|
"4K": 4,
|
||||||
|
"FHD": 3,
|
||||||
|
"HD": 2,
|
||||||
|
"SD": 1,
|
||||||
|
# Backward compatibility for existing snapshot data
|
||||||
|
"4320p": 5,
|
||||||
|
"2160p": 4,
|
||||||
|
"UHD": 4,
|
||||||
"1080p": 3,
|
"1080p": 3,
|
||||||
"1080i": 3,
|
"1080i": 3,
|
||||||
"720p": 2,
|
"720p": 2,
|
||||||
|
"576p": 1,
|
||||||
"480p": 1,
|
"480p": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_added_timestamp(path: Path) -> Optional[int]:
|
def build_movie_id(title: str | None, year: int | None) -> str:
|
||||||
"""
|
"""Build a readable movie ID slug from the title and year."""
|
||||||
Get the timestamp when a torrent was added to the collection.
|
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)
|
def build_series_id(title: str | None) -> str:
|
||||||
- For files: use atime unless it's too close to current time (suggesting
|
"""Build a readable series ID slug from the title."""
|
||||||
the filesystem updates atime on reads), otherwise use max(mtime, ctime)
|
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:
|
Returns:
|
||||||
Unix timestamp as int, or None if path doesn't exist
|
Unix timestamp as int, or None if path doesn't exist
|
||||||
|
|
||||||
"""
|
"""
|
||||||
ap = AsyncPath(path)
|
ap = AsyncPath(path)
|
||||||
try:
|
try:
|
||||||
stat_info = await ap.stat()
|
stat_info = await ap.stat()
|
||||||
except OSError, PermissionError:
|
except OSError, PermissionError:
|
||||||
return None
|
return None
|
||||||
|
if os.name == "nt":
|
||||||
if await ap.is_dir():
|
|
||||||
return int(stat_info.st_ctime)
|
return int(stat_info.st_ctime)
|
||||||
|
return int(stat_info.st_mtime)
|
||||||
now = time.time()
|
|
||||||
atime = stat_info.st_atime
|
|
||||||
|
|
||||||
if now - atime < _ATIME_FRESHNESS_THRESHOLD:
|
|
||||||
return int(max(stat_info.st_mtime, stat_info.st_ctime))
|
|
||||||
|
|
||||||
return int(atime)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_directory_size(path: Path) -> int:
|
def get_directory_size(path: Path) -> int:
|
||||||
"""Calculate total size of a directory recursively."""
|
"""Calculate total size using scandir recursion in a sync worker."""
|
||||||
ap = AsyncPath(path)
|
|
||||||
total = 0
|
|
||||||
try:
|
try:
|
||||||
if await ap.is_file():
|
if path.is_file():
|
||||||
return (await ap.stat()).st_size
|
return path.stat().st_size
|
||||||
for item in ap.rglob("*"):
|
|
||||||
if await AsyncPath(item).is_file():
|
|
||||||
total += (await AsyncPath(item).stat()).st_size
|
|
||||||
except OSError, PermissionError:
|
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
|
return total
|
||||||
|
|
||||||
|
|
||||||
def format_size(size_bytes: int) -> str:
|
def format_size(size_bytes: int) -> str:
|
||||||
"""Format size in human-readable format."""
|
"""Format size in human-readable format."""
|
||||||
|
size = float(size_bytes)
|
||||||
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||||
if size_bytes < 1024:
|
if size < 1024:
|
||||||
return f"{size_bytes:.2f} {unit}"
|
return f"{size:.2f} {unit}"
|
||||||
size_bytes /= 1024
|
size /= 1024
|
||||||
return f"{size_bytes:.2f} PB"
|
return f"{size:.2f} PB"
|
||||||
|
|
||||||
|
|
||||||
async def find_common_root(paths: List[Path]) -> Optional[Path]:
|
async def find_common_root(paths: list[Path]) -> Path | None:
|
||||||
"""
|
"""Find the common root directory for a list of paths.
|
||||||
Find the common root directory for a list of paths.
|
|
||||||
|
|
||||||
Returns None if paths are on different drives/mounts or have no common ancestor.
|
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
|
# Find common prefix
|
||||||
common_parts = []
|
common_parts = []
|
||||||
for parts in zip(*all_parts):
|
for parts in zip(*all_parts, strict=False):
|
||||||
if len(set(parts)) == 1:
|
if len(set(parts)) == 1:
|
||||||
common_parts.append(parts[0])
|
common_parts.append(parts[0])
|
||||||
else:
|
else:
|
||||||
@@ -138,11 +222,8 @@ async def find_common_root(paths: List[Path]) -> Optional[Path]:
|
|||||||
return Path(*common_parts)
|
return Path(*common_parts)
|
||||||
|
|
||||||
|
|
||||||
def make_relative_path(
|
def make_relative_path(path: str | None, root: str | None = None) -> str | None:
|
||||||
path: Optional[str], root: Optional[str] = None
|
"""Convert an absolute path to a posix-style path relative to the given root.
|
||||||
) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Convert an absolute path to a posix-style path relative to the given root.
|
|
||||||
|
|
||||||
If root is None, returns the path as a posix string unchanged.
|
If root is None, returns the path as a posix string unchanged.
|
||||||
"""
|
"""
|
||||||
@@ -161,20 +242,18 @@ def sanitize_filename(name: str) -> str:
|
|||||||
"""Sanitize a string for use as a filename."""
|
"""Sanitize a string for use as a filename."""
|
||||||
for char in ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]:
|
for char in ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]:
|
||||||
name = name.replace(char, "_")
|
name = name.replace(char, "_")
|
||||||
name = name.strip(". ")
|
return name.strip(". ")
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
"""Get the folder name for a media item."""
|
||||||
sanitized_title = sanitize_filename(title)
|
if media_type == "movie":
|
||||||
if media_type == "movie" and year:
|
return build_movie_id(title, year)
|
||||||
return f"{sanitized_title} ({year})"
|
return build_series_id(title)
|
||||||
return sanitized_title
|
|
||||||
|
|
||||||
|
|
||||||
def get_media_folder_path(
|
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:
|
) -> Path:
|
||||||
"""Get the full path to a media item's folder."""
|
"""Get the full path to a media item's folder."""
|
||||||
subdir = "movies" if media_type == "movie" else "series"
|
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.
|
The IndexStore is the single source of truth for the media index.
|
||||||
All mutations happen synchronously in the asyncio event loop — no locks needed.
|
All mutations happen synchronously in the asyncio event loop — no locks needed.
|
||||||
@@ -8,33 +7,34 @@ debounced background task.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
from collections.abc import Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from aiopathlib import AsyncPath
|
from aiopathlib import AsyncPath
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
from mediahive.models.data import (
|
from mediahive.models.data import (
|
||||||
|
Episode,
|
||||||
IndexSnapshot,
|
IndexSnapshot,
|
||||||
MediaStats,
|
|
||||||
Movie,
|
Movie,
|
||||||
|
Season,
|
||||||
Series,
|
Series,
|
||||||
TaskInfo,
|
TaskInfo,
|
||||||
|
Torrent,
|
||||||
)
|
)
|
||||||
from mediahive.models.events import Remove, Task, Upsert
|
from mediahive.models.events import Remove, Task, Upsert
|
||||||
from mediahive.models.protocol import (
|
from mediahive.models.tmdb import Person
|
||||||
WsInit,
|
|
||||||
WsInitData,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger("mediahive.index_store")
|
logger = logging.getLogger("mediahive.index_store")
|
||||||
|
|
||||||
# Debounce interval for writing snapshots to disk (seconds)
|
# Debounce interval for writing snapshots to disk (seconds)
|
||||||
SNAPSHOT_DEBOUNCE = 5.0
|
SNAPSHOT_DEBOUNCE = 5.0
|
||||||
|
# Debounce interval for rebuilding the in-memory API snapshot (seconds)
|
||||||
|
SNAPSHOT_CACHE_DEBOUNCE = 0.25
|
||||||
|
|
||||||
|
|
||||||
class IndexStore:
|
class IndexStore:
|
||||||
@@ -48,20 +48,38 @@ class IndexStore:
|
|||||||
# This would make IndexStore testable without FastAPI's WebSocket.
|
# 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.snapshot_path = snapshot_path
|
||||||
self.media_root = media_root
|
self.snapshot_loaded = False
|
||||||
|
|
||||||
# The index: keyed by item id
|
# The index: keyed by item id
|
||||||
self.movies: dict[str, Movie] = {}
|
self.movies: dict[str, Movie] = {}
|
||||||
self.series: dict[str, Series] = {}
|
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
|
# Connected WebSocket clients
|
||||||
self._clients: set[WebSocket] = set()
|
self._clients: set[WebSocket] = set()
|
||||||
|
# Passive listeners for broadcast events (used by server-level WS fan-in)
|
||||||
|
self._listeners: set[Callable[[object], None]] = set()
|
||||||
|
|
||||||
# Snapshot debounce state
|
# Snapshot debounce state
|
||||||
self._snapshot_dirty = False
|
self._snapshot_dirty = False
|
||||||
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
|
# Persistence
|
||||||
@@ -70,41 +88,313 @@ class IndexStore:
|
|||||||
async def load_snapshot(self) -> None:
|
async def load_snapshot(self) -> None:
|
||||||
"""Load index from disk snapshot (recovery on startup)."""
|
"""Load index from disk snapshot (recovery on startup)."""
|
||||||
ap = AsyncPath(self.snapshot_path)
|
ap = AsyncPath(self.snapshot_path)
|
||||||
|
self.snapshot_loaded = False
|
||||||
if not await ap.exists():
|
if not await ap.exists():
|
||||||
logger.info("No snapshot found at %s, starting fresh", self.snapshot_path)
|
logger.info("No snapshot found at %s, starting fresh", self.snapshot_path)
|
||||||
|
self._schedule_snapshot_cache_refresh()
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
data = msgspec.json.decode(await ap.read_bytes(), type=IndexSnapshot)
|
raw = await ap.read_bytes()
|
||||||
for m in data.movies:
|
loaded_movies, loaded_series, loaded_people = await asyncio.to_thread(
|
||||||
self.movies[m.id] = m
|
self._load_snapshot_sync,
|
||||||
for s in data.series:
|
raw,
|
||||||
self.series[s.id] = s
|
|
||||||
logger.info(
|
|
||||||
"Loaded snapshot: %d movies, %d series",
|
|
||||||
len(self.movies),
|
|
||||||
len(self.series),
|
|
||||||
)
|
)
|
||||||
|
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:
|
except Exception:
|
||||||
logger.exception("Failed to load snapshot from %s", self.snapshot_path)
|
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:
|
async def _write_snapshot(self) -> None:
|
||||||
"""Write current index to disk (called from debounce task)."""
|
"""Write current index to disk (called from debounce task)."""
|
||||||
snapshot = self._build_snapshot()
|
# Copy values on the event loop thread, then do full snapshot build + disk I/O
|
||||||
|
# in a worker thread to keep the loop responsive.
|
||||||
await AsyncPath(self.snapshot_path.parent).mkdir(parents=True, exist_ok=True)
|
movies = dict(self.movies)
|
||||||
tmp = self.snapshot_path.with_suffix(".tmp")
|
series = dict(self.series)
|
||||||
await AsyncPath(tmp).write_bytes(
|
people = dict(self.people)
|
||||||
msgspec.json.format(msgspec.json.encode(snapshot), indent=2)
|
await asyncio.to_thread(self._write_snapshot_sync, movies, series, people)
|
||||||
)
|
|
||||||
# os.replace is atomic and overwrites on all platforms (unlike rename on Windows)
|
|
||||||
await asyncio.to_thread(os.replace, tmp, self.snapshot_path)
|
|
||||||
logger.debug("Snapshot written to %s", self.snapshot_path)
|
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:
|
def _schedule_snapshot(self) -> None:
|
||||||
"""Schedule a debounced snapshot write."""
|
"""Schedule a debounced snapshot write."""
|
||||||
self._snapshot_dirty = True
|
self._snapshot_dirty = True
|
||||||
if self._snapshot_task is None or self._snapshot_task.done():
|
if self._snapshot_task is None or self._snapshot_task.done():
|
||||||
self._snapshot_task = asyncio.create_task(self._snapshot_writer())
|
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:
|
async def _snapshot_writer(self) -> None:
|
||||||
"""Flush to disk every SNAPSHOT_DEBOUNCE seconds while dirty."""
|
"""Flush to disk every SNAPSHOT_DEBOUNCE seconds while dirty."""
|
||||||
@@ -118,68 +408,282 @@ class IndexStore:
|
|||||||
|
|
||||||
async def flush_snapshot(self) -> None:
|
async def flush_snapshot(self) -> None:
|
||||||
"""Force-write a snapshot immediately (e.g. on shutdown)."""
|
"""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():
|
if self._snapshot_task and not self._snapshot_task.done():
|
||||||
self._snapshot_task.cancel()
|
self._snapshot_task.cancel()
|
||||||
try:
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
await self._snapshot_task
|
await self._snapshot_task
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
await self._write_snapshot()
|
await self._write_snapshot()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Mutations
|
# Mutations
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def upsert_movie(self, item: Movie) -> bool:
|
def upsert_movie(
|
||||||
"""Insert or update a movie. Returns True if it was a real change."""
|
self,
|
||||||
existing = self.movies.get(item.id)
|
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 existing is not None:
|
||||||
if msgspec.json.encode(existing) == msgspec.json.encode(item):
|
if msgspec.json.encode(existing) == msgspec.json.encode(item):
|
||||||
|
if people:
|
||||||
|
self.people.update(people)
|
||||||
|
self._schedule_snapshot()
|
||||||
|
self._broadcast(
|
||||||
|
Upsert(kind="movie", id=item_id, item=item, people=people)
|
||||||
|
)
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
self.movies[item.id] = item
|
self.movies[item_id] = item
|
||||||
|
if people:
|
||||||
|
self.people.update(people)
|
||||||
self._schedule_snapshot()
|
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 True
|
||||||
|
|
||||||
def upsert_series(self, item: Series) -> bool:
|
def upsert_series(
|
||||||
"""Insert or update a series. Returns True if it was a real change."""
|
self,
|
||||||
existing = self.series.get(item.id)
|
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 existing is not None:
|
||||||
if msgspec.json.encode(existing) == msgspec.json.encode(item):
|
if msgspec.json.encode(existing) == msgspec.json.encode(item):
|
||||||
|
if people:
|
||||||
|
self.people.update(people)
|
||||||
|
self._schedule_snapshot()
|
||||||
|
self._broadcast(
|
||||||
|
Upsert(kind="series", id=item_id, item=item, people=people)
|
||||||
|
)
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
self.series[item.id] = item
|
self.series[item_id] = item
|
||||||
|
if people:
|
||||||
|
self.people.update(people)
|
||||||
self._schedule_snapshot()
|
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 True
|
||||||
|
|
||||||
def remove_movie(self, item_id: str) -> None:
|
def remove_movie(self, item_id: str) -> None:
|
||||||
"""Remove a movie from the index and broadcast."""
|
"""Remove a movie from the index and broadcast."""
|
||||||
self.movies.pop(item_id, None)
|
movie = self.movies.pop(item_id, None)
|
||||||
|
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._schedule_snapshot()
|
||||||
self._broadcast(Remove(kind="movie", id=item_id))
|
self._broadcast(Remove(kind="movie", id=item_id))
|
||||||
|
|
||||||
def remove_series(self, item_id: str) -> None:
|
def remove_series(self, item_id: str) -> None:
|
||||||
"""Remove a series from the index and broadcast."""
|
"""Remove a series from the index and broadcast."""
|
||||||
self.series.pop(item_id, None)
|
series = self.series.pop(item_id, None)
|
||||||
|
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._schedule_snapshot()
|
||||||
self._broadcast(Remove(kind="series", id=item_id))
|
self._broadcast(Remove(kind="series", id=item_id))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Scanner-driven maintenance
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def sync_torrent_paths(self, paths: set[str]) -> None:
|
||||||
|
"""Drop file entries whose torrent path no longer exists on disk.
|
||||||
|
|
||||||
|
``paths`` is the complete set of media-root-relative torrent paths the
|
||||||
|
scanner found during a fully completed discovery pass. Episodes and
|
||||||
|
seasons left without files are dropped; items left without any files
|
||||||
|
are removed entirely.
|
||||||
|
"""
|
||||||
|
for item_id, movie in list(self.movies.items()):
|
||||||
|
kept = {k: v for k, v in movie.files.items() if k in paths}
|
||||||
|
if len(kept) == len(movie.files):
|
||||||
|
continue
|
||||||
|
if not kept:
|
||||||
|
self.remove_movie(item_id)
|
||||||
|
continue
|
||||||
|
updated = msgspec.structs.replace(
|
||||||
|
movie, files=kept, newest=self._newest_from_files(kept)
|
||||||
|
)
|
||||||
|
self.movies[item_id] = updated
|
||||||
|
self._schedule_snapshot()
|
||||||
|
self._broadcast(Upsert(kind="movie", id=item_id, item=updated))
|
||||||
|
|
||||||
|
for item_id, series in list(self.series.items()):
|
||||||
|
removed_any = False
|
||||||
|
new_seasons: list[Season] = []
|
||||||
|
for season in series.seasons:
|
||||||
|
new_episodes: list[Episode] = []
|
||||||
|
for ep in season.episodes:
|
||||||
|
files = {k: v for k, v in ep.files.items() if k in paths}
|
||||||
|
if len(files) < len(ep.files):
|
||||||
|
removed_any = True
|
||||||
|
if files:
|
||||||
|
new_episodes.append(msgspec.structs.replace(ep, files=files))
|
||||||
|
else:
|
||||||
|
removed_any = True
|
||||||
|
if not new_episodes:
|
||||||
|
removed_any = True
|
||||||
|
continue
|
||||||
|
if len(new_episodes) < len(season.episodes):
|
||||||
|
season = msgspec.structs.replace(
|
||||||
|
season,
|
||||||
|
episodes=new_episodes,
|
||||||
|
episode_count=len(new_episodes),
|
||||||
|
)
|
||||||
|
new_seasons.append(season)
|
||||||
|
if not removed_any:
|
||||||
|
continue
|
||||||
|
if not new_seasons:
|
||||||
|
self.remove_series(item_id)
|
||||||
|
continue
|
||||||
|
updated_series = msgspec.structs.replace(series, seasons=new_seasons)
|
||||||
|
self.series[item_id] = updated_series
|
||||||
|
self._schedule_snapshot()
|
||||||
|
self._broadcast(Upsert(kind="series", id=item_id, item=updated_series))
|
||||||
|
|
||||||
|
def set_movie_showreel(
|
||||||
|
self,
|
||||||
|
item_id: str,
|
||||||
|
showreel_images: list[str] | None,
|
||||||
|
showreel_source_sets: list[list[str]] | None,
|
||||||
|
) -> None:
|
||||||
|
"""Update only the showreel fields of a movie (reel worker callback)."""
|
||||||
|
movie = self.movies.get(item_id)
|
||||||
|
if movie is None:
|
||||||
|
return
|
||||||
|
updated = msgspec.structs.replace(
|
||||||
|
movie,
|
||||||
|
showreel_images=showreel_images,
|
||||||
|
showreel_source_sets=showreel_source_sets,
|
||||||
|
)
|
||||||
|
if msgspec.json.encode(updated) == msgspec.json.encode(movie):
|
||||||
|
return
|
||||||
|
self.movies[item_id] = updated
|
||||||
|
self._schedule_snapshot()
|
||||||
|
self._broadcast(Upsert(kind="movie", id=item_id, item=updated))
|
||||||
|
|
||||||
|
def set_episode_reel(
|
||||||
|
self,
|
||||||
|
item_id: str,
|
||||||
|
season_num: int,
|
||||||
|
episode_num: int,
|
||||||
|
reel_image: str | None,
|
||||||
|
reel_sources: list[str] | None,
|
||||||
|
) -> None:
|
||||||
|
"""Update only the reel fields of one episode (reel worker callback)."""
|
||||||
|
series = self.series.get(item_id)
|
||||||
|
if series is None:
|
||||||
|
return
|
||||||
|
for season in series.seasons:
|
||||||
|
if season.season_number != season_num:
|
||||||
|
continue
|
||||||
|
for ep in season.episodes:
|
||||||
|
if ep.episode_number != episode_num:
|
||||||
|
continue
|
||||||
|
if ep.reel_image == reel_image and ep.reel_sources == reel_sources:
|
||||||
|
return
|
||||||
|
new_episodes = [
|
||||||
|
msgspec.structs.replace(
|
||||||
|
e, reel_image=reel_image, reel_sources=reel_sources
|
||||||
|
)
|
||||||
|
if e is ep
|
||||||
|
else e
|
||||||
|
for e in season.episodes
|
||||||
|
]
|
||||||
|
new_seasons = [
|
||||||
|
msgspec.structs.replace(s, episodes=new_episodes)
|
||||||
|
if s is season
|
||||||
|
else s
|
||||||
|
for s in series.seasons
|
||||||
|
]
|
||||||
|
updated = msgspec.structs.replace(series, seasons=new_seasons)
|
||||||
|
self.series[item_id] = updated
|
||||||
|
self._schedule_snapshot()
|
||||||
|
self._broadcast(Upsert(kind="series", id=item_id, item=updated))
|
||||||
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# WebSocket management
|
# WebSocket management
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def add_listener(self, listener: Callable[[object], None]) -> None:
|
||||||
|
"""Register a listener called for each broadcast message."""
|
||||||
|
self._listeners.add(listener)
|
||||||
|
|
||||||
|
def remove_listener(self, listener: Callable[[object], None]) -> None:
|
||||||
|
"""Unregister a previously registered broadcast listener."""
|
||||||
|
self._listeners.discard(listener)
|
||||||
|
|
||||||
async def connect(self, ws: WebSocket) -> None:
|
async def connect(self, ws: WebSocket) -> None:
|
||||||
"""Accept a WS client and send the full index as init."""
|
"""Accept a WS client and send the full index as init."""
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
self._clients.add(ws)
|
self._clients.add(ws)
|
||||||
logger.info("WS client connected (%d total)", len(self._clients))
|
logger.info("WS client connected (%d total)", len(self._clients))
|
||||||
# Send full current state
|
# Send full current state
|
||||||
msg = WsInit(
|
msg = {
|
||||||
data=WsInitData(
|
"type": "init",
|
||||||
movies=list(self.movies.values()),
|
"roots": {
|
||||||
series=list(self.series.values()),
|
"": {
|
||||||
)
|
"movies": dict(self.movies),
|
||||||
)
|
"series": dict(self.series),
|
||||||
|
"people": dict(self.people),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
await ws.send_bytes(msgspec.json.encode(msg))
|
await ws.send_bytes(msgspec.json.encode(msg))
|
||||||
|
|
||||||
def disconnect(self, ws: WebSocket) -> None:
|
def disconnect(self, ws: WebSocket) -> None:
|
||||||
@@ -189,6 +693,12 @@ class IndexStore:
|
|||||||
|
|
||||||
def _broadcast(self, msg: object) -> None:
|
def _broadcast(self, msg: object) -> None:
|
||||||
"""Broadcast a message to all connected WS clients (non-blocking)."""
|
"""Broadcast a message to all connected WS clients (non-blocking)."""
|
||||||
|
for listener in tuple(self._listeners):
|
||||||
|
try:
|
||||||
|
listener(msg)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("IndexStore listener failed")
|
||||||
|
|
||||||
data = msgspec.json.encode(msg)
|
data = msgspec.json.encode(msg)
|
||||||
dead: list[WebSocket] = []
|
dead: list[WebSocket] = []
|
||||||
for ws in self._clients:
|
for ws in self._clients:
|
||||||
@@ -202,7 +712,7 @@ class IndexStore:
|
|||||||
"""Send data to a WS client; mark as dead on failure."""
|
"""Send data to a WS client; mark as dead on failure."""
|
||||||
try:
|
try:
|
||||||
await ws.send_bytes(data)
|
await ws.send_bytes(data)
|
||||||
except Exception:
|
except OSError, RuntimeError:
|
||||||
dead.append(ws)
|
dead.append(ws)
|
||||||
|
|
||||||
def broadcast_task(self, task_info: TaskInfo) -> None:
|
def broadcast_task(self, task_info: TaskInfo) -> None:
|
||||||
@@ -213,31 +723,28 @@ class IndexStore:
|
|||||||
# Read helpers
|
# Read helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _build_snapshot(self) -> IndexSnapshot:
|
def _build_snapshot_from_maps(
|
||||||
"""Build a sorted IndexSnapshot with computed stats."""
|
self,
|
||||||
movies_list = sorted(
|
movies: dict[str, Movie],
|
||||||
self.movies.values(), key=lambda x: (x.title.lower(), x.year or 0)
|
series: dict[str, Series],
|
||||||
)
|
people: dict[int, Person],
|
||||||
series_list = sorted(self.series.values(), key=lambda x: x.title.lower())
|
) -> IndexSnapshot:
|
||||||
|
"""Build a keyed IndexSnapshot from map copies."""
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
return IndexSnapshot(
|
return IndexSnapshot(
|
||||||
generated_at=datetime.now().isoformat(),
|
generated_at=datetime.now().isoformat(),
|
||||||
media_root=self.media_root,
|
movies=movies,
|
||||||
stats=MediaStats(
|
series=series,
|
||||||
total_movies=len(movies_list),
|
people=people,
|
||||||
total_movie_versions=total_movie_versions,
|
)
|
||||||
total_series=len(series_list),
|
|
||||||
total_series_episodes=total_series_episodes,
|
def _build_snapshot(self) -> IndexSnapshot:
|
||||||
),
|
"""Build a sorted IndexSnapshot with computed stats."""
|
||||||
movies=movies_list,
|
return self._build_snapshot_from_maps(
|
||||||
series=series_list,
|
dict(self.movies),
|
||||||
|
dict(self.series),
|
||||||
|
dict(self.people),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_full_index(self) -> IndexSnapshot:
|
def get_full_index(self) -> IndexSnapshot:
|
||||||
"""Return the full index as an IndexSnapshot."""
|
"""Return the latest in-memory IndexSnapshot cache."""
|
||||||
return self._build_snapshot()
|
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.
|
All types are msgspec.Structs for fast serialization.
|
||||||
"""
|
"""
|
||||||
@@ -8,23 +7,30 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
from .tmdb import Info
|
from .tmdb import Info, Person
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Index item types (the state stored in IndexStore, sent over WS/API)
|
# 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."""
|
"""A torrent file, either for a movie or an episode."""
|
||||||
|
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
playable_file: str | None = None
|
playable_file: str | None = None
|
||||||
resolution: str | None = None
|
resolution: str | None = None
|
||||||
quality: str | None = None
|
quality: str | None = None
|
||||||
|
network: str | None = None
|
||||||
codec: str | None = None
|
codec: str | None = None
|
||||||
audio: 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
|
encoder: str | None = None
|
||||||
size: int | None = None
|
size: int | None = None
|
||||||
added_at: int | None = None
|
added_at: int | None = None
|
||||||
@@ -42,7 +48,8 @@ class Episode(msgspec.Struct):
|
|||||||
rating: float | None = None
|
rating: float | None = None
|
||||||
director: str | None = None
|
director: str | None = None
|
||||||
reel_image: 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):
|
class Season(msgspec.Struct):
|
||||||
@@ -60,7 +67,6 @@ class Season(msgspec.Struct):
|
|||||||
class Movie(msgspec.Struct):
|
class Movie(msgspec.Struct):
|
||||||
"""A movie in the index (one or more versions/releases)."""
|
"""A movie in the index (one or more versions/releases)."""
|
||||||
|
|
||||||
id: str
|
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
info: Info | None = None
|
info: Info | None = None
|
||||||
year: int | None = None
|
year: int | None = None
|
||||||
@@ -68,13 +74,13 @@ class Movie(msgspec.Struct):
|
|||||||
cover_path: str | None = None
|
cover_path: str | None = None
|
||||||
backdrop_path: str | None = None
|
backdrop_path: str | None = None
|
||||||
showreel_images: list[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):
|
class Series(msgspec.Struct):
|
||||||
"""A TV series in the index."""
|
"""A TV series in the index."""
|
||||||
|
|
||||||
id: str
|
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
info: Info | None = None
|
info: Info | None = None
|
||||||
alternative_titles: list[str] | None = None
|
alternative_titles: list[str] | None = None
|
||||||
@@ -88,29 +94,17 @@ class Series(msgspec.Struct):
|
|||||||
# Snapshot (disk format for index.json)
|
# Snapshot (disk format for index.json)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INDEX_SNAPSHOT_VERSION = 1
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class IndexSnapshot(msgspec.Struct):
|
class IndexSnapshot(msgspec.Struct):
|
||||||
"""On-disk recovery snapshot of the full index."""
|
"""On-disk recovery snapshot of the full index."""
|
||||||
|
|
||||||
version: int = 6
|
v: int = INDEX_SNAPSHOT_VERSION
|
||||||
generated_at: str = ""
|
generated_at: str = ""
|
||||||
media_root: str | None = None
|
movies: dict[str, Movie] = {}
|
||||||
stats: MediaStats = msgspec.UNSET # type: ignore[assignment]
|
series: dict[str, Series] = {}
|
||||||
movies: list[Movie] = []
|
people: dict[int, Person] = {}
|
||||||
series: list[Series] = []
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.stats is msgspec.UNSET:
|
|
||||||
self.stats = MediaStats()
|
|
||||||
|
|
||||||
|
|
||||||
class TaskInfo(msgspec.Struct):
|
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:
|
These types are used as:
|
||||||
- Internal scan events (scanner → server queue)
|
- Internal scan events (scanner → server queue)
|
||||||
@@ -11,13 +10,24 @@ from __future__ import annotations
|
|||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
from .data import Movie, Series, TaskInfo
|
from .data import Movie, Series, TaskInfo
|
||||||
|
from .tmdb import Person
|
||||||
|
|
||||||
|
|
||||||
class Upsert(msgspec.Struct, tag="upsert"):
|
class Upsert(msgspec.Struct, tag="upsert"):
|
||||||
"""Single item inserted or updated."""
|
"""Single item inserted or updated.
|
||||||
|
|
||||||
|
``scanned`` lists the media-root-relative torrent paths whose content was
|
||||||
|
(re)scanned to build this item. When present, the store merges the item
|
||||||
|
into the existing entry instead of replacing it wholesale: only data
|
||||||
|
belonging to the scanned torrents is replaced. ``None`` means full
|
||||||
|
replacement (legacy behaviour).
|
||||||
|
"""
|
||||||
|
|
||||||
kind: str # "movie" or "series"
|
kind: str # "movie" or "series"
|
||||||
|
id: str
|
||||||
item: Movie | Series
|
item: Movie | Series
|
||||||
|
people: dict[int, Person] | None = None
|
||||||
|
scanned: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class Remove(msgspec.Struct, tag="remove"):
|
class Remove(msgspec.Struct, tag="remove"):
|
||||||
@@ -27,6 +37,35 @@ class Remove(msgspec.Struct, tag="remove"):
|
|||||||
id: str
|
id: str
|
||||||
|
|
||||||
|
|
||||||
|
class Sync(msgspec.Struct, tag="sync"):
|
||||||
|
"""Full set of media-root-relative torrent paths currently on disk.
|
||||||
|
|
||||||
|
Sent by the scanner after a successfully completed discovery pass so the
|
||||||
|
store can drop entries whose files no longer exist. Internal only —
|
||||||
|
never forwarded to WebSocket clients.
|
||||||
|
"""
|
||||||
|
|
||||||
|
paths: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class MovieShowreel(msgspec.Struct, tag="movie-showreel"):
|
||||||
|
"""Reel worker result for a movie (internal, scanner → store)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
showreel_images: list[str] | None = None
|
||||||
|
showreel_source_sets: list[list[str]] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeReel(msgspec.Struct, tag="episode-reel"):
|
||||||
|
"""Reel worker result for one episode (internal, scanner → store)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
season: int
|
||||||
|
episode: int
|
||||||
|
reel_image: str | None = None
|
||||||
|
reel_sources: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class Task(msgspec.Struct, tag="task"):
|
class Task(msgspec.Struct, tag="task"):
|
||||||
"""Task progress broadcast."""
|
"""Task progress broadcast."""
|
||||||
|
|
||||||
@@ -34,4 +73,4 @@ class Task(msgspec.Struct, tag="task"):
|
|||||||
|
|
||||||
|
|
||||||
# Union of scan events (scanner → server) and WS broadcast messages
|
# Union of scan events (scanner → server) and WS broadcast messages
|
||||||
ScanEvent = Upsert | Task
|
ScanEvent = Upsert | Sync | MovieShowreel | EpisodeReel | Task
|
||||||
|
|||||||
@@ -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.
|
All types are msgspec.Structs for fast serialization.
|
||||||
"""
|
"""
|
||||||
@@ -9,41 +8,87 @@ from __future__ import annotations
|
|||||||
import msgspec
|
import msgspec
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from .data import Movie, Series
|
from .data import Movie, Series, TaskInfo
|
||||||
from .events import Remove, ScanEvent, Task, Upsert
|
from .events import ScanEvent
|
||||||
|
from .tmdb import Person
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# WebSocket message types
|
# WebSocket message types
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class WsInitData(msgspec.Struct):
|
class WsRootStatus(msgspec.Struct):
|
||||||
"""Payload of the init message."""
|
"""Current status for one configured root."""
|
||||||
|
|
||||||
movies: list[Movie]
|
root_id: str
|
||||||
series: list[Series]
|
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"):
|
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)
|
# Union of all outbound WS messages (for documentation / future decoding)
|
||||||
WsMessage = WsInit | Upsert | Remove | Task
|
WsMessage = WsRoots | WsInit | WsUpsert | WsRemove | WsTask
|
||||||
|
|
||||||
|
|
||||||
# Re-export unified types for backward compatibility
|
# Re-export unified types for backward compatibility
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Remove",
|
|
||||||
"ScanEvent",
|
"ScanEvent",
|
||||||
"Task",
|
|
||||||
"Upsert",
|
|
||||||
"WsInit",
|
"WsInit",
|
||||||
"WsInitData",
|
|
||||||
"WsMessage",
|
"WsMessage",
|
||||||
|
"WsRemove",
|
||||||
|
"WsRootInitData",
|
||||||
|
"WsRootStatus",
|
||||||
|
"WsRoots",
|
||||||
|
"WsTask",
|
||||||
|
"WsUpsert",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -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):
|
class PlayMediaRequest(msgspec.Struct):
|
||||||
"""POST /api/play body (mediahive server)."""
|
"""POST /api/play/{root_id} body."""
|
||||||
|
|
||||||
file_path: str = ""
|
file_path: str = ""
|
||||||
|
player_id: str | None = None
|
||||||
|
player_custom_cmd: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class OpenFolderRequest(msgspec.Struct):
|
class OpenFolderRequest(msgspec.Struct):
|
||||||
"""POST /api/open-folder body (mediahive server)."""
|
"""POST /api/open-folder/{root_id} body."""
|
||||||
|
|
||||||
folder_path: str = ""
|
folder_path: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ChangeFolderRequest(msgspec.Struct):
|
class RootsRequest(msgspec.Struct):
|
||||||
"""POST /api/change-folder body."""
|
"""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.
|
All types are msgspec.Structs for fast serialization.
|
||||||
"""
|
"""
|
||||||
@@ -8,26 +7,24 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sub-types (shared by TMDb results and index items)
|
# Sub-types (shared by TMDb results and index items)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class CastMember(msgspec.Struct):
|
class CastCredit(msgspec.Struct, array_like=True):
|
||||||
"""Actor/crew member."""
|
"""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
|
name: str
|
||||||
character: str | None = None
|
|
||||||
profile_path: str | None = None
|
profile_path: str | None = None
|
||||||
|
gender: str | None = None
|
||||||
|
|
||||||
class SimilarMedia(msgspec.Struct):
|
|
||||||
"""Pointer to a similar movie/series on TMDb."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
poster_path: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -68,6 +65,7 @@ class Info(msgspec.Struct):
|
|||||||
tmdb_id: int
|
tmdb_id: int
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
original_title: str | None = None
|
original_title: str | None = None
|
||||||
|
original_language: str | None = None
|
||||||
alternative_titles: list[str] | None = None
|
alternative_titles: list[str] | None = None
|
||||||
rating: float | None = None
|
rating: float | None = None
|
||||||
vote_count: int | None = None
|
vote_count: int | None = None
|
||||||
@@ -75,13 +73,11 @@ class Info(msgspec.Struct):
|
|||||||
genres: list[str] | None = None
|
genres: list[str] | None = None
|
||||||
release_date: str | None = None
|
release_date: str | None = None
|
||||||
runtime: int | None = None
|
runtime: int | None = None
|
||||||
|
collection: str | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
tagline: str | None = None
|
tagline: str | None = None
|
||||||
poster_path: str | None = None
|
|
||||||
backdrop_path: str | None = None
|
|
||||||
similar: list[SimilarMedia] | None = None
|
|
||||||
keywords: list[str] | None = None
|
keywords: list[str] | None = None
|
||||||
cast: list[CastMember] | None = None
|
cast: list[CastCredit] | None = None
|
||||||
director: str | None = None
|
director: str | None = None
|
||||||
creators: list[str] | None = None
|
creators: list[str] | None = None
|
||||||
number_of_seasons: int | 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,82 @@
|
|||||||
|
"""Optional Velopack auto-update integration (GUI builds only).
|
||||||
|
|
||||||
|
In development and portable-ZIP runs Velopack is either not installed or the
|
||||||
|
app is not a Velopack installation; every helper degrades to a no-op then, so
|
||||||
|
callers never need to special-case those environments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mediahive.config import load_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
|
||||||
|
|
||||||
|
|
||||||
|
def _manager():
|
||||||
|
"""Return a Velopack UpdateManager, or None when updates are unavailable."""
|
||||||
|
try:
|
||||||
|
import velopack
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
|
||||||
|
except RuntimeError, OSError:
|
||||||
|
# Not a Velopack installation (dev / portable run).
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pending_update() -> str | None:
|
||||||
|
"""Version of a downloaded update staged for the next launch, if any."""
|
||||||
|
mgr = _manager()
|
||||||
|
if mgr is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
asset = mgr.get_update_pending_restart()
|
||||||
|
except RuntimeError, OSError:
|
||||||
|
return None
|
||||||
|
return str(asset.Version) if asset is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_pending_and_restart() -> bool:
|
||||||
|
"""Apply the staged update and restart into it. False when nothing pending."""
|
||||||
|
mgr = _manager()
|
||||||
|
if mgr is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
asset = mgr.get_update_pending_restart()
|
||||||
|
if asset is None:
|
||||||
|
return False
|
||||||
|
logger.info("Velopack: applying staged update %s and restarting", asset.Version)
|
||||||
|
mgr.apply_updates_and_restart(asset)
|
||||||
|
except (RuntimeError, OSError) as exc:
|
||||||
|
logger.warning("Velopack: failed to apply staged update: %s", exc)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_download() -> None:
|
||||||
|
"""Download available updates in the background, unless disabled in config.
|
||||||
|
|
||||||
|
Downloaded updates are applied automatically by Velopack on the next app
|
||||||
|
start, so the running session is never interrupted. Network failures and
|
||||||
|
non-Velopack runs are expected and skipped quietly.
|
||||||
|
"""
|
||||||
|
if not load_config().auto_update:
|
||||||
|
logger.info("Velopack: automatic updates disabled, skipping check")
|
||||||
|
return
|
||||||
|
mgr = _manager()
|
||||||
|
if mgr is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
info = mgr.check_for_updates()
|
||||||
|
if info is None:
|
||||||
|
logger.info("Velopack: no update available")
|
||||||
|
return
|
||||||
|
version = info.TargetFullRelease.Version
|
||||||
|
logger.info("Velopack: downloading update %s", version)
|
||||||
|
mgr.download_updates(info)
|
||||||
|
logger.info("Velopack: update %s staged, applies on next launch", version)
|
||||||
|
except (RuntimeError, OSError) as exc:
|
||||||
|
logger.info("Velopack update check skipped: %s", exc)
|
||||||
@@ -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",
|
"aiofiles>=25.1.0",
|
||||||
"aiopathlib>=0.6.0",
|
"aiopathlib>=0.6.0",
|
||||||
"bencodepy>=0.9.5",
|
"bencodepy>=0.9.5",
|
||||||
"fastapi-vue>=0.5.2",
|
"fastapi-vue~=1.7.2",
|
||||||
"fastapi[standard]>=0.128.0",
|
"fastapi[standard]>=0.128.0",
|
||||||
"httpx[http2]>=0.28.1",
|
"httpx[http2]>=0.28.1",
|
||||||
"msgspec>=0.19",
|
"msgspec>=0.19",
|
||||||
"parse-torrent-title>=2.8.1",
|
"parse-torrent-title>=2.8.1",
|
||||||
|
"platformdirs>=4.0",
|
||||||
"tomli-w>=1.2.0",
|
"tomli-w>=1.2.0",
|
||||||
"uvicorn[standard]>=0.40.0",
|
"uvicorn[standard]>=0.40.0",
|
||||||
]
|
]
|
||||||
@@ -36,7 +37,11 @@ artifacts = ["mediahive/frontend-build"]
|
|||||||
only-packages = true
|
only-packages = true
|
||||||
|
|
||||||
[tool.hatch.build.targets.sdist.hooks.custom]
|
[tool.hatch.build.targets.sdist.hooks.custom]
|
||||||
path = "scripts/fastapi-vue/build-frontend.py"
|
path = "scripts/fastapi-vue/buildhook.py"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist.force-include]
|
||||||
|
"scripts/fastapi-vue/buildhook.py" = "scripts/fastapi-vue/buildhook.py"
|
||||||
|
"scripts/fastapi-vue/buildutil.py" = "scripts/fastapi-vue/buildutil.py"
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
package = true
|
package = true
|
||||||
@@ -46,13 +51,77 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
gui = [
|
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'",
|
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
|
||||||
"pyinstaller>=6.0",
|
"pyinstaller>=6.0",
|
||||||
|
# scripts/guibuild.py reads the version with it (same logic as hatch-vcs).
|
||||||
|
"setuptools_scm>=8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"lefthook>=2.1.14",
|
||||||
|
"ruff>=0.15.14",
|
||||||
"setuptools-scm>=8",
|
"setuptools-scm>=8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
preview = true
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["ALL"]
|
||||||
|
ignore = [
|
||||||
|
"incorrect-blank-line-before-class",
|
||||||
|
"multi-line-summary-second-line",
|
||||||
|
"docstring-missing-returns",
|
||||||
|
"missing-trailing-comma",
|
||||||
|
"print",
|
||||||
|
"EM",
|
||||||
|
"TC",
|
||||||
|
"raise-vanilla-args",
|
||||||
|
"S",
|
||||||
|
"missing-copyright-notice",
|
||||||
|
"PLR",
|
||||||
|
"PLW",
|
||||||
|
# TEMP suppressions - revisit and remove after focused cleanup passes.
|
||||||
|
"complex-structure",
|
||||||
|
"docstring-missing-exception",
|
||||||
|
"missing-return-type-undocumented-public-function",
|
||||||
|
"undocumented-public-function",
|
||||||
|
"boolean-type-hint-positional-argument",
|
||||||
|
"missing-type-function-argument",
|
||||||
|
"undocumented-public-method",
|
||||||
|
"try-consider-else",
|
||||||
|
"undocumented-public-init",
|
||||||
|
"missing-return-type-private-function",
|
||||||
|
"raise-without-from-inside-except",
|
||||||
|
"create-subprocess-in-async-function",
|
||||||
|
"line-too-long",
|
||||||
|
"implicit-namespace-package",
|
||||||
|
"boolean-default-value-positional-argument",
|
||||||
|
"import-outside-top-level",
|
||||||
|
"undocumented-public-class",
|
||||||
|
"asyncio-dangling-task",
|
||||||
|
"private-member-access",
|
||||||
|
"blocking-path-method-in-async-function",
|
||||||
|
"invalid-class-name",
|
||||||
|
"collapsible-if",
|
||||||
|
"call-datetime-now-without-tzinfo",
|
||||||
|
"useless-if-else",
|
||||||
|
"missing-terminal-punctuation",
|
||||||
|
"missing-trailing-period",
|
||||||
|
"any-type",
|
||||||
|
# Allow en-dash in docstrings (used for list formatting)
|
||||||
|
"ambiguous-unicode-character-docstring",
|
||||||
|
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
|
||||||
|
"non-lowercase-variable-in-function",
|
||||||
|
# Allow inline comments that describe output formats
|
||||||
|
"commented-out-code",
|
||||||
|
# Allow unused local variables in ctypes COM boilerplate
|
||||||
|
"unused-variable",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
RTorrent Client - Communicate with rtorrent via XMLRPC over SCGI socket.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import socket
|
|
||||||
import xmlrpc.client
|
|
||||||
from pathlib import Path
|
|
||||||
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,66 +1,108 @@
|
|||||||
# MediaHive.spec — PyInstaller build for the Windows GUI application
|
# MediaHive.spec — PyInstaller build for the MediaHive desktop GUI app
|
||||||
#
|
#
|
||||||
# Build manually (from repo root):
|
# 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
|
# pyinstaller --noconfirm --clean scripts/MediaHive.spec
|
||||||
#
|
#
|
||||||
# Or use the build script (recommended—handles versioning and packaging):
|
# Or use the build script (recommended—handles versioning and packaging):
|
||||||
# uv run scripts/build_windows_gui.py
|
# uv run scripts/guibuild.py
|
||||||
|
|
||||||
|
import sys
|
||||||
import mediahive.winmain
|
import mediahive.winmain
|
||||||
import mediahive.server
|
import mediahive.server
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from PyInstaller.utils.hooks import collect_data_files
|
||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
|
|
||||||
_pkg = Path(mediahive.server.__file__).parent
|
_pkg = Path(mediahive.server.__file__).parent
|
||||||
_frontend_build = _pkg / "frontend-build"
|
_frontend_build = _pkg / "frontend-build"
|
||||||
_icon = _pkg / "assets" / "mediahive.ico"
|
_logo_webp = _pkg / "assets" / "mediahive.webp"
|
||||||
_ffmpeg = Path(SPECPATH).parent / "build" / "ffmpeg" / "ffmpeg.exe"
|
_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
|
||||||
|
|
||||||
|
_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"),
|
||||||
|
]
|
||||||
|
# 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",
|
||||||
|
"uvicorn.loops.auto",
|
||||||
|
"uvicorn.loops.asyncio",
|
||||||
|
"uvicorn.protocols",
|
||||||
|
"uvicorn.protocols.http",
|
||||||
|
"uvicorn.protocols.http.auto",
|
||||||
|
"uvicorn.protocols.http.h11_impl",
|
||||||
|
"uvicorn.protocols.websockets",
|
||||||
|
"uvicorn.protocols.websockets.auto",
|
||||||
|
"uvicorn.protocols.websockets.websockets_impl",
|
||||||
|
"uvicorn.lifespan",
|
||||||
|
"uvicorn.lifespan.on",
|
||||||
|
# mediahive & hivescan modules imported at runtime
|
||||||
|
"mediahive.server",
|
||||||
|
"mediahive.hivescan.scanner",
|
||||||
|
"mediahive.hivescan.indexer",
|
||||||
|
"mediahive.hivescan.scanning",
|
||||||
|
"mediahive.hivescan.images",
|
||||||
|
"mediahive.hivescan.showreel",
|
||||||
|
"mediahive.hivescan.tmdb_client",
|
||||||
|
# async / ASGI internals
|
||||||
|
"anyio",
|
||||||
|
"anyio._backends._asyncio",
|
||||||
|
"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(
|
a = Analysis(
|
||||||
[mediahive.winmain.__file__],
|
[mediahive.winmain.__file__],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[
|
binaries=_binaries,
|
||||||
# Bundle ffmpeg so showreel generation works without a system install.
|
datas=_datas,
|
||||||
# Populated by build_windows_gui.py before PyInstaller runs.
|
hiddenimports=_hiddenimports,
|
||||||
(str(_ffmpeg), "."),
|
|
||||||
],
|
|
||||||
datas=[
|
|
||||||
# Bundled Vue frontend served by the FastAPI backend
|
|
||||||
(str(_frontend_build), "mediahive/frontend-build"),
|
|
||||||
(str(_icon), "mediahive/assets"),
|
|
||||||
],
|
|
||||||
hiddenimports=[
|
|
||||||
# uvicorn dynamic imports
|
|
||||||
"uvicorn.logging",
|
|
||||||
"uvicorn.loops",
|
|
||||||
"uvicorn.loops.auto",
|
|
||||||
"uvicorn.loops.asyncio",
|
|
||||||
"uvicorn.protocols",
|
|
||||||
"uvicorn.protocols.http",
|
|
||||||
"uvicorn.protocols.http.auto",
|
|
||||||
"uvicorn.protocols.http.h11_impl",
|
|
||||||
"uvicorn.protocols.websockets",
|
|
||||||
"uvicorn.protocols.websockets.auto",
|
|
||||||
"uvicorn.protocols.websockets.websockets_impl",
|
|
||||||
"uvicorn.lifespan",
|
|
||||||
"uvicorn.lifespan.on",
|
|
||||||
# mediahive & hivescan modules imported at runtime
|
|
||||||
"mediahive.server",
|
|
||||||
"mediahive.hivescan.scanner",
|
|
||||||
"mediahive.hivescan.indexer",
|
|
||||||
"mediahive.hivescan.scanning",
|
|
||||||
"mediahive.hivescan.images",
|
|
||||||
"mediahive.hivescan.showreel",
|
|
||||||
"mediahive.hivescan.tmdb_client",
|
|
||||||
# async / ASGI internals
|
|
||||||
"anyio",
|
|
||||||
"anyio._backends._asyncio",
|
|
||||||
"starlette.routing",
|
|
||||||
# msgspec TOML write backend
|
|
||||||
"tomli_w",
|
|
||||||
],
|
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[],
|
excludes=[],
|
||||||
@@ -80,12 +122,21 @@ exe = EXE(
|
|||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
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
|
# windowed=True hides the console; the backend subprocess inherits this
|
||||||
console=False,
|
console=False,
|
||||||
windowed=True,
|
windowed=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# UPX breaks .NET assemblies: packing Python.Runtime.dll strips/corrupts its
|
||||||
|
# CLR metadata and pythonnet then fails with "Failed to resolve
|
||||||
|
# Python.Runtime.Loader.Initialize from .../Python.Runtime.dll".
|
||||||
|
_upx_exclude = ["Python.Runtime.dll"] if sys.platform == "win32" else []
|
||||||
|
|
||||||
coll = COLLECT(
|
coll = COLLECT(
|
||||||
exe,
|
exe,
|
||||||
a.binaries,
|
a.binaries,
|
||||||
@@ -93,6 +144,14 @@ coll = COLLECT(
|
|||||||
a.datas,
|
a.datas,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
upx=True,
|
||||||
upx_exclude=[],
|
upx_exclude=_upx_exclude,
|
||||||
name="MediaHive",
|
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 argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import tracerite
|
||||||
|
|
||||||
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||||
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||||
from devutil import ( # type: ignore
|
from devutil import (
|
||||||
ProcessGroup,
|
ProcessGroup,
|
||||||
check_ports_free,
|
check_ports_free,
|
||||||
logger,
|
logger,
|
||||||
@@ -22,11 +24,15 @@ from devutil import ( # type: ignore
|
|||||||
|
|
||||||
DEFAULT_VITE_PORT = 8420
|
DEFAULT_VITE_PORT = 8420
|
||||||
DEFAULT_DEV_PORT = 8421
|
DEFAULT_DEV_PORT = 8421
|
||||||
|
HEALTH = "/api/health?from=devserver.py"
|
||||||
|
|
||||||
|
|
||||||
async def run_devserver(
|
async def run_devserver(
|
||||||
listen: str, backend: str, extra_args: list[str] | None = None
|
listen: str,
|
||||||
|
backend: str,
|
||||||
|
extra_args: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Start Vite and FastAPI dev servers with hot reload."""
|
||||||
reporoot = Path(__file__).parent.parent
|
reporoot = Path(__file__).parent.parent
|
||||||
front = reporoot / "frontend"
|
front = reporoot / "frontend"
|
||||||
if not (front / "package.json").exists():
|
if not (front / "package.json").exists():
|
||||||
@@ -36,20 +42,22 @@ async def run_devserver(
|
|||||||
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
|
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
|
||||||
backurl, mediahive = setup_cli("mediahive", backend, DEFAULT_DEV_PORT)
|
backurl, mediahive = setup_cli("mediahive", backend, DEFAULT_DEV_PORT)
|
||||||
|
|
||||||
# Tell the everyone by environment (vite proxy and backend devmode use these)
|
# Tell everyone via environment (vite proxy and backend devmode use these)
|
||||||
os.environ["MEDIAHIVE_VITE_URL"] = viteurl
|
os.environ["MEDIAHIVE_VITE_URL"] = viteurl
|
||||||
os.environ["MEDIAHIVE_BACKEND_URL"] = backurl
|
os.environ["MEDIAHIVE_BACKEND_URL"] = backurl
|
||||||
os.environ["MEDIAHIVE_DEV"] = "1"
|
os.environ["MEDIAHIVE_DEV"] = "1"
|
||||||
|
|
||||||
async with ProcessGroup() as pg:
|
async with ProcessGroup() as pg:
|
||||||
|
pg.create_task(check_ports_free(viteurl, backurl))
|
||||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||||
await check_ports_free(viteurl, backurl)
|
await pg.spawn(*mediahive, *(extra_args or []), vital=True)
|
||||||
await pg.spawn(*mediahive, *(extra_args or []))
|
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
||||||
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
|
await pg.spawn(*vite, cwd=front, vital=True)
|
||||||
await pg.spawn(*vite, cwd=front)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
|
"""Parse CLI arguments and run the devserver."""
|
||||||
|
tracerite.load()
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Run Vite and FastAPI development servers",
|
description="Run Vite and FastAPI development servers",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
@@ -58,21 +66,25 @@ def main():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-l",
|
"-l",
|
||||||
"--listen",
|
"--listen",
|
||||||
metavar="host:port",
|
metavar="addr",
|
||||||
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
metavar="host:port",
|
metavar="addr",
|
||||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||||
)
|
)
|
||||||
args, extra_args = parser.parse_known_args()
|
args, extra_args = parser.parse_known_args()
|
||||||
with suppress(KeyboardInterrupt):
|
try:
|
||||||
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
||||||
|
except* KeyboardInterrupt:
|
||||||
|
pass # user stopped the devserver: normal exit
|
||||||
|
except* subprocess.SubprocessError, RuntimeError:
|
||||||
|
raise SystemExit(1) from None # logged in devutil already; exit 1
|
||||||
|
|
||||||
|
|
||||||
HELP_EPILOG = """
|
HELP_EPILOG = """
|
||||||
scripts/devserver.py [args to mediahive]
|
Other options are forwarded to mediahive [args]
|
||||||
|
|
||||||
JS_RUNTIME environment variable can be used to select the JS runtime:
|
JS_RUNTIME environment variable can be used to select the JS runtime:
|
||||||
npm, deno, bun, or full path to the runtime executable (node maps to npm).
|
npm, deno, bun, or full path to the runtime executable (node maps to npm).
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
from buildutil import build
|
from buildutil import build
|
||||||
|
|
||||||
|
|
||||||
class CustomBuildHook(BuildHookInterface):
|
class CustomBuildHook(BuildHookInterface): # type: ignore[misc]
|
||||||
def initialize(self, version, build_data):
|
"""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)
|
super().initialize(version, build_data)
|
||||||
build("frontend")
|
build("frontend")
|
||||||