Compare commits
71
Commits
v0.4.0
..
039824f236
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
039824f236 | ||
|
|
f078e9bec0 | ||
|
|
ff493506ba | ||
|
|
5f3ba47098 | ||
|
|
01c353502c | ||
|
|
65fdae1546 | ||
|
|
832da6c315 | ||
|
|
3142bc2cb2 | ||
|
|
e514829737 | ||
|
|
50420984f1 | ||
|
|
f9002b19a2 | ||
|
|
37f786f5ed | ||
|
|
c64f54fe0f | ||
|
|
bb88ab7d98 | ||
|
|
303aabc181 | ||
|
|
e8d2c5773a | ||
|
|
59d2f157eb | ||
|
|
3377702e98 | ||
|
|
6dee65b4c6 | ||
|
|
c59040ce52 | ||
|
|
734b7993a2 | ||
|
|
b01e8a5c3d | ||
|
|
b231f450bf | ||
|
|
67489e05bc | ||
|
|
3ae4ce9f9f | ||
|
|
99e757bc6f | ||
|
|
5300fd0c9c | ||
|
|
ff10c86e84 | ||
|
|
c11cbd4250 | ||
|
|
9779857dcd | ||
|
|
9ec4f877eb | ||
|
|
6a6a012efe | ||
|
|
2c28ab1f25 | ||
|
|
e6eadb2ecd | ||
|
|
ff6b195973 | ||
|
|
36e2fdd5ff | ||
|
|
8f462b9e1d | ||
|
|
838db5b55c | ||
|
|
e5300eaac0 | ||
|
|
d1f1b9ecb8 | ||
|
|
26af7c633b | ||
|
|
c072f15cb5 | ||
|
|
23030cd1c4 | ||
|
|
2a39e1f0ea | ||
|
|
d3addadf14 | ||
|
|
0a4d54c1b7 | ||
|
|
c2776d2e2d | ||
|
|
e5bc736ffa | ||
|
|
258fc79753 | ||
|
|
7a60ef5384 | ||
|
|
22454f2d29 | ||
|
|
589c789d4d | ||
|
|
5f2454d8e8 | ||
|
|
2fa15132fb | ||
|
|
76cd0224de | ||
|
|
b0d13a67a0 | ||
|
|
568605b09a | ||
|
|
b09118c8cb | ||
|
|
f79c044250 | ||
|
|
a4b0cd916a | ||
|
|
e1a961d21f | ||
|
|
1a6a3f0cf2 | ||
|
|
626fb9a0ae | ||
|
|
b6742f0f27 | ||
|
|
6d0e6d41a7 | ||
|
|
932c5af1ff | ||
|
|
eb49eb713e | ||
|
|
d7eea51334 | ||
|
|
01f424dbfb | ||
|
|
df9025760d | ||
|
|
09e51acd14 |
@@ -0,0 +1,51 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
# Runner host prerequisites: git, uv, node/npm, .NET SDK.
|
||||
jobs:
|
||||
gui-build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos
|
||||
shell: bash
|
||||
- os: windows
|
||||
shell: cmd
|
||||
- os: linux
|
||||
shell: bash
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
# Plain git clone: full history so setuptools_scm sees tags, and both
|
||||
# shells work. Windows uses cmd: bash resolves to WSL (refuses SYSTEM
|
||||
# accounts) and powershell hits the script execution policy under SYSTEM.
|
||||
- name: Checkout
|
||||
shell: ${{ matrix.shell }}
|
||||
run: |
|
||||
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||
git checkout -f "${{ gitea.sha }}"
|
||||
|
||||
- name: Build GUI app and dist packages
|
||||
shell: ${{ matrix.shell }}
|
||||
run: uv run --extra gui scripts/guibuild.py
|
||||
|
||||
# Every platform converges on the one release for the tag; release.py
|
||||
# reuses an existing release and skips already-uploaded assets.
|
||||
# Only the linux job publishes the wheel/sdist (identical across platforms).
|
||||
- name: Create Gitea release and upload assets
|
||||
if: matrix.os == 'linux'
|
||||
shell: ${{ matrix.shell }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: uv run scripts/release.py
|
||||
|
||||
- name: Attach platform artifact to the Gitea release
|
||||
if: matrix.os != 'linux'
|
||||
shell: ${{ matrix.shell }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: uv run scripts/release.py --no-dist
|
||||
@@ -19,3 +19,4 @@ package-lock.json
|
||||
# Dotfiles
|
||||
.*
|
||||
!.gitignore
|
||||
!.gitea/
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# MediaHive agent notes
|
||||
|
||||
## Python 3.14 syntax: unparenthesized `except` is valid
|
||||
|
||||
This project targets Python **>= 3.14** (see `requires-python` in
|
||||
`pyproject.toml`). Per [PEP 758](https://peps.python.org/pep-0758/) (Final,
|
||||
Python 3.14), multiple exception types may be caught **without parentheses**:
|
||||
|
||||
```python
|
||||
except OSError, ValueError: # valid Python 3.14+, equivalent to except (OSError, ValueError):
|
||||
```
|
||||
|
||||
Parentheses are still required when an `as` clause is used:
|
||||
`except (OSError, ValueError) as e:`.
|
||||
|
||||
Do not "fix" these into tuple form, and do not flag them as Python 2 remnant
|
||||
syntax errors — that rule is obsolete training data. Any syntax validation,
|
||||
compilation check, or linting of this codebase must run under Python 3.14+
|
||||
(e.g. `python -m py_compile` with a 3.14 interpreter); older interpreters
|
||||
will report false SyntaxErrors on this and other 3.14-only constructs.
|
||||
@@ -4,17 +4,23 @@
|
||||
|
||||
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
|
||||
|
||||
**[Windows and Mac portable ZIP downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
|
||||
**[Windows, Mac and Linux downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
|
||||
|
||||
## Getting Started
|
||||
|
||||
- Windows: Download `*-win64-setup.exe` from the releases page and run it (no admin needed; auto-updates included). A `-win64-portable.zip` is also available.
|
||||
- macOS: Download `*-macos-setup.pkg` and install (auto-updates included).
|
||||
- Linux: Download the `.AppImage`, `chmod +x` it, and run. Alternatively install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run directly with `uvx --from mediahive[gui] mediahive`.
|
||||
|
||||
## What It Does
|
||||
|
||||
- Scans your chosen media folder for all movies and series that can be found
|
||||
- Produces preview video clips and downloads metadata
|
||||
- Search on cast and character names, not just titles
|
||||
- Search on names and other metadata, not just titles
|
||||
- Remembers per-episode playback positions and offers series continue points
|
||||
- Hand off playback to your preferred system player
|
||||
- Implement gamepad controls for MPC-BE on Windows (where needed)
|
||||
|
||||
Extract the ZIP in some place and run MediaHive.exe to start the app. Currently we have no installer, but you can pin to start/taskbar for easier access. On the first startup the app asks for your media folder, that can later be changed by clicking in-app folder icon.
|
||||
Windows and macOS builds are currently portable-only (no installer). On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
|
||||
|
||||
Note that `.mediahive` folder is created in your media folder to hold all the metadata and preview clips, avoiding the lengthy processing that you will see on initial startup.
|
||||
|
||||
@@ -25,7 +31,7 @@ MediaHive is designed to work with a mouse, keyboard, or gamepad.
|
||||
| Input | Controls |
|
||||
| --- | --- |
|
||||
| Mouse | Click posters, rows, search, play, and folder actions directly. |
|
||||
| Keyboard | Arrow keys move focus, `Enter` activates the focused item, `Escape` goes back, and `/` jumps to search. |
|
||||
| Keyboard | Arrow keys move focus, `Enter` activates the focused item, `Escape` goes back, and `/` or `Ctrl`/`Cmd`+`F` jumps to search. |
|
||||
| Gamepad | D-pad or left stick moves focus, `A` selects or plays, and `B` goes back. `RB`/`LB` browses adjacent items, and the Search bar has an OSD keyboard. Player controls during playback. |
|
||||
|
||||
## Recommended Players
|
||||
|
||||
+28
-12
@@ -10,23 +10,39 @@ All media paths are scoped to a **root**, identified by a friendly `root_id`
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/health` | Lightweight health check. |
|
||||
| `GET` | `/api/config` | Returns the current root configuration. |
|
||||
| `GET` | `/api/roots` | List all active roots with status. |
|
||||
| `PUT` | `/api/roots` | Atomically replace the full root set. |
|
||||
| `POST` | `/api/play/{root_id}` | Opens a media file with the system player. |
|
||||
| `POST` | `/api/open-folder/{root_id}` | Opens a folder in the system file explorer. |
|
||||
| `GET` | `/api/meta/{root_id}/{meta_key}` | Returns allowed metadata from `<root>/.mediahive`. |
|
||||
| `GET` | `/api/player/status` | Returns whether remote player control is currently available. |
|
||||
| `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable. |
|
||||
| `PUT` | `/api/config/roots` | Atomically replace the full root set. Returns `{ "status": "ok", "accepted": [{path, root_id}], "failed": [...] }`. |
|
||||
| `POST` | `/api/play/{root_id}` | Opens a media file with a media player. Also starts an assumed-playback session (see notes). |
|
||||
| `GET` | `/api/players` | Lists detected media players. Returns `{ "players": [{id, name, family, path}] }`, including synthetic `default` and `custom` entries. |
|
||||
| `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. |
|
||||
| `POST` | `/api/open-folder/{root_id}` | Opens a folder in the system file explorer; given a file path, selects the file instead. |
|
||||
| `GET` | `/api/meta/{root_id}/{meta_key}` | Returns allowed metadata from `<root>/.mediahive`, as `{ "key": meta_key, "data": ... }`. |
|
||||
| `GET` | `/api/meta/playback-state` | Returns merged resume positions across all roots, as `{ "key": "playback-state", "data": ... }`. Series entries carry one continue point per series (`season`/`episode` = last watched) plus a per-episode watch map (`episodes`: `"S<season>E<episode>"` → `{pos, ts, done}`); completing an episode marks it done and advances the point to the next episode. |
|
||||
| `POST` | `/api/meta/playback-state` | Updates one resume entry (`root_id`, `file_path`, `pos`; null `pos` clears a movie or advances a series' continue point). Returns `{ "status": "ok", "slug", "pos" }` plus `season`/`episode` when the continue point advances. |
|
||||
| `GET` | `/api/player/status` | Returns whether remote player control is currently available. Accepts an optional `?port=` override (default 13579). |
|
||||
| `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable, as `{ "reachable": true|false }`. Accepts an optional `?port=` override; always `false` on non-Windows. |
|
||||
| `GET` | `/api/media/{root_id}/{file_path:path}` | Serves files from the specified root. |
|
||||
| `GET` | `/api/assets/{root_id}/{asset_type}/{asset_path:path}` | Serves typed assets from `<root>/.mediahive`. |
|
||||
| `WS` | `/api/ws/{root_id}` | Streams live index updates and task progress for one root. |
|
||||
| `WS` | `/api/ws` | Streams roots, index updates, and task progress for all roots (see WebSocket notes). |
|
||||
|
||||
## Notes
|
||||
|
||||
- `PUT /api/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.
|
||||
- `GET /api/media/{root_id}/{file_path:path}` is constrained to the specified root; path traversal outside the root is rejected.
|
||||
- `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 }`.
|
||||
- `GET /api/mpcbe/status` returns `false` on non-Windows platforms.
|
||||
- Roots may also be provided at startup via the `MEDIAHIVE_ROOTS` environment variable (JSON dict of name → path), which overrides the persisted configuration.
|
||||
- Assumed playback: after `POST /api/play/{root_id}` the launched item is assumed to be playing while the frontend reports no input activity. On the next `POST /api/activity` the guessed position (`resume base + elapsed`, capped at the TMDb runtime) is written once; watches under 5 minutes are discarded (a peek is not progress). A resume entry written by another tracker (e.g. the GUI's MPC-BE tracker) during the session overrides the guess. The MPC-BE tracker likewise ignores sessions shorter than 5 minutes.
|
||||
|
||||
## WebSocket
|
||||
|
||||
`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.
|
||||
|
||||
+8
-6
@@ -1,6 +1,6 @@
|
||||
# Development
|
||||
|
||||
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) is aimed at Windows end users.
|
||||
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) covers end-user startup across platforms (installer/AppImage downloads, `uvx --from mediahive[gui] mediahive` on Linux/other).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -40,13 +40,14 @@ uv run --extra gui python -m mediahive.winmain /path/to/media/folder
|
||||
|
||||
This launches the same pywebview-based desktop flow used by the Windows build.
|
||||
|
||||
## Migrate Existing Index Snapshots
|
||||
## Building And Releasing
|
||||
|
||||
```bash
|
||||
uv run python scripts/indexmigr.py /path/to/media/root --write
|
||||
```
|
||||
The helper scripts are directly executable via their `uv run` shebang (on Windows, run them with `uv run scripts/<name>.py`):
|
||||
|
||||
This applies versioned snapshot migrations to `.mediahive/index.json` outside the main application. Use it before starting a newer build against an older index.
|
||||
- `./scripts/guibuild.py` builds the PyInstaller desktop app and packages it with Velopack under `build/`: per-user `Setup.exe` (Windows), `.pkg` installer (macOS), `.AppImage` (Linux), plus the update feed in `build/velopack/`. On Windows it also creates a `-win64-portable.zip` (no auto-updates). Requires node/npm and the .NET SDK (>= 10 runtime) installed on the build host; `vpk` and ffmpeg are downloaded once into a persistent user cache (`~/.cache/mediahive-build`, `%LOCALAPPDATA%\mediahive-build` on Windows).
|
||||
- `./scripts/release.py` publishes a release to the Gitea releases page, uploading the platform artifacts and the Velopack update feed files — installed apps auto-update from the latest release.
|
||||
|
||||
Python packaging builds the frontend automatically through the hatch build hook `scripts/fastapi-vue/buildhook.py` (see `pyproject.toml`), so wheels and sdists always ship a fresh `mediahive/frontend-build`.
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -54,3 +55,4 @@ This applies versioned snapshot migrations to `.mediahive/index.json` outside th
|
||||
- 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).
|
||||
|
||||
+3
-1
@@ -611,7 +611,9 @@ Current native command usage is centered on:
|
||||
|
||||
- `889` for play/pause
|
||||
- `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
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# Multi-Root Implementation Notes
|
||||
|
||||
## Overview
|
||||
|
||||
MediaHive now supports multiple independent media roots. Each root is a filesystem directory with its own index, scanner, and WebSocket stream. The frontend merges per-root state into a single reactive view.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Root Identity
|
||||
|
||||
- **Root ID**: friendly root name derived from configured path basename.
|
||||
- **Name/ID collision handling**: suffixes `2`, `3`, … are appended to keep each root ID unique.
|
||||
- **Path normalization**: lower-case Windows drive letter, strip trailing slashes, forward slashes only (`as_posix()`).
|
||||
|
||||
### Per-Root Runtime (`RootContext`)
|
||||
|
||||
Each active root gets an isolated `RootContext` managed by the `Supervisor`:
|
||||
|
||||
- `root_id`, `root_path` — stable identifiers
|
||||
- `IndexStore` — owns snapshot at `<root>/.mediahive/index.json`
|
||||
- `RootScanner` — per-root scanning instance (replaced legacy global scanner)
|
||||
- `asyncio.Queue` + consumer task — bridges scanner events to WebSocket
|
||||
- `status`: `idle` | `loading` | `ready` | `scanning` | `error`
|
||||
|
||||
### Supervisor
|
||||
|
||||
- Holds `dict[str, RootContext]` keyed by `root_id`.
|
||||
- `replace_roots(new_roots)` atomically swaps the active set:
|
||||
1. Validate & canonicalize paths.
|
||||
2. Derive unique friendly `root_id` for each.
|
||||
3. Prepare new `RootContext`s (load snapshots).
|
||||
4. Swap dict atomically.
|
||||
5. Stop removed contexts in background with bounded timeout.
|
||||
- Exposes merged read helpers (`merged_index`, `all_statuses`).
|
||||
|
||||
### Item IDs
|
||||
|
||||
`root_id` is stored separately on each item.
|
||||
|
||||
- `Movie.id` uses a slug built from the movie title and year, for example `spider-man-no-way-home-2021`.
|
||||
- `Series.id` uses a slug built from the series title, for example `lost`.
|
||||
- Legacy snapshot migrations are handled by `scripts/indexmigr.py`, not during app startup.
|
||||
|
||||
## API
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /api/roots` | List all roots (name, path, root_id, status) |
|
||||
| `PUT /api/roots` | Atomically replace full root map `{name: path}` |
|
||||
| `WS /api/ws/{root_id}` | Per-root WebSocket (init/upsert/remove/task + status/task events) |
|
||||
| `GET /api/media/{root_id}/{path:path}` | Serve media file scoped to root |
|
||||
| `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` | Serve `.mediahive/{asset_type}` assets (`movies`, `series`, `people`) |
|
||||
| `POST /api/play/{root_id}` | Play file within root |
|
||||
| `POST /api/open-folder/{root_id}` | Open folder within root |
|
||||
| `GET /api/meta/{root_id}/{meta_key}` | Per-root metadata (for example `playback-state`) |
|
||||
| `POST /api/ui/pick-folder` | Native OS folder picker (returns path) |
|
||||
|
||||
> **Removed legacy endpoints**: `/api/change-folder`, `/api/index`, `/api/scan`, `/api/status`, `/api/playback/resume-positions`. No backwards compatibility is maintained.
|
||||
|
||||
## macOS Startup Safety
|
||||
|
||||
The server **must not** touch the filesystem during startup, because macOS may show permission dialogs that block the event loop and prevent the HTTP server from accepting requests.
|
||||
|
||||
- `lifespan()` creates a background task (`_activate_all_roots()`) and immediately yields.
|
||||
- All filesystem validation (`exists()`, `is_dir()`, `resolve()`) runs in a thread pool via `asyncio.to_thread()`.
|
||||
- CLI entry points (`__main__.py`, `winmain.py`, `hivescan/__main__.py`) pass raw paths via the `MEDIAHIVE_ROOTS` environment variable; they do **not** validate paths before starting the server.
|
||||
|
||||
## POSIX Path Enforcement
|
||||
|
||||
All stored and transmitted paths use forward slashes exclusively:
|
||||
|
||||
- `_normalize_path()` always returns POSIX paths.
|
||||
- Config stores `p.as_posix()`.
|
||||
- URLs use `/` separators.
|
||||
- `Path(root_path) / relative_path` works correctly on Windows because `Path` accepts POSIX separators.
|
||||
|
||||
## Config Migration
|
||||
|
||||
- Old `media_folder` string is auto-migrated to `roots: {basename: path}` on load.
|
||||
- `roots` is persisted back to TOML config.
|
||||
|
||||
## Scanner
|
||||
|
||||
- Legacy global module-level scanner API was removed from `hivescan/scanner.py`.
|
||||
- `RootScanner` is the only scanning interface.
|
||||
- Each `RootScanner` owns its own `showreel_queue`, `scan_task`, `rescan_worker_task`, and `_seen_mtimes`.
|
||||
|
||||
## Frontend
|
||||
|
||||
- `useMediaWebSocket.ts` manages one WebSocket per active root.
|
||||
- `App.vue` merges per-root `movieMap`/`seriesMap` into a single `mediaIndex`.
|
||||
- `Header.vue` provides add/remove root UI via `PUT /api/roots`.
|
||||
- Playback URLs are root-qualified (`/api/media/{root_id}/...`).
|
||||
- Metadata cache assets use typed root paths (`/api/assets/{root_id}/{asset_type}/...`) rather than exposing `.mediahive` in URLs.
|
||||
@@ -0,0 +1,352 @@
|
||||
# Scanner review — findings, fixes, and measured results
|
||||
|
||||
Date: 2026-09-03 (review); fixes implemented same day.
|
||||
Scope: `mediahive/hivescan/*`, `mediahive/index_store.py`, `mediahive/root_registry.py`,
|
||||
`mediahive/models/events.py`
|
||||
Method: code review plus instrumented runs of the real `RootScanner` (monkeypatched
|
||||
timers around every ffmpeg invocation, HTTP request, and filesystem primitive).
|
||||
|
||||
Structure of this document:
|
||||
|
||||
- **Section 1** records the findings as measured against the *pre-fix* code
|
||||
(line references are from that revision).
|
||||
- **Section 2** describes the fixes that were implemented for each finding.
|
||||
- **Section 3** gives before/after measurements.
|
||||
- **Appendix A** (blob storage options) is kept for reference only; it was
|
||||
explicitly decided **not** to change the on-disk storage format for now.
|
||||
|
||||
Test environment (details omitted intentionally): the media library lives on a
|
||||
network-mounted filesystem (SMB/CIFS). Two roots were measured:
|
||||
|
||||
- **Subset root**: 163 torrents (121 movies, 40 series entries), stale/empty index.
|
||||
- **Full library root**: existing index with 1127 movies, 159 series,
|
||||
1608 episodes, ~2983 video files, ~35k people records.
|
||||
|
||||
Environment characteristic that dominates several measurements:
|
||||
`stat`/`scandir`/`exists` on the network mount are ~0.1 ms (attribute caching
|
||||
works), but **every small file write costs ~0.2 s** (synchronous write-through).
|
||||
The scanner writes thousands of small files into `.mediahive/` on that mount.
|
||||
|
||||
---
|
||||
|
||||
## 1. Findings (pre-fix)
|
||||
|
||||
### F1 — Partial rescan replaces whole entries (the "disappearing seasons" bug)
|
||||
|
||||
**This is a correctness bug, not a performance issue.**
|
||||
|
||||
Chain of events:
|
||||
|
||||
1. Discovery mtime-gates per torrent directory/file. Touching one season
|
||||
directory of a series yields a `downloads` list containing *only that
|
||||
season*.
|
||||
2. `_process_series` rebuilds the `Series` object from only the items it was
|
||||
given.
|
||||
3. `IndexStore.upsert_series` **replaced the entire entry** and broadcast the
|
||||
partial series to all connected clients.
|
||||
|
||||
Measured end-to-end on a 5-season series (5 separate season torrents,
|
||||
110 episodes):
|
||||
|
||||
```
|
||||
full scan -> index entry seasons [1,2,3,4,5] (110 episodes)
|
||||
touch season 4 -> rescan 0.2 s, emits ONE upsert: seasons [4] (22 episodes)
|
||||
-> index entry is now seasons [4] — seasons 1-3,5 gone
|
||||
```
|
||||
|
||||
The missing seasons returned only when a scan happened to include all seasons
|
||||
again — in practice the next **process restart**, because the seen-mtimes map
|
||||
(F2) was memory-only and forced a full rediscovery at startup.
|
||||
|
||||
Same bug class, other variants:
|
||||
|
||||
- **Movies**: a touched version directory dropped the other versions of the
|
||||
same movie from the listing.
|
||||
- **TMDb dedupe collapse**: when a partial entry arrived under a *different*
|
||||
item id for an already-known TMDb id, the old **complete** entry was
|
||||
explicitly deleted.
|
||||
- **Deletions were never detected**: discovery only ever added to
|
||||
`_seen_mtimes`; nothing emitted removals. A torrent deleted from disk stayed
|
||||
in the index forever.
|
||||
|
||||
### F2 — No persistent scan state: every restart was a full reprocess
|
||||
|
||||
All scanner state was process memory: `_seen_mtimes`, the ffmpeg probe cache,
|
||||
and the episode/playable-file/bluray-probe caches.
|
||||
|
||||
Consequences measured:
|
||||
|
||||
- **Steady-state rescan within one process: 0.2 s** (subset root, nothing
|
||||
changed) — mtime gating worked fine while the process lived.
|
||||
- **Warm-restart scan (fresh process caches, all disk caches warm): 56.5 s**
|
||||
for the same 163 items, of which **52.9 s (94 %) was re-running ffmpeg probes
|
||||
on all 469 video files** (21 s `ffmpeg -i` + 32 s `showinfo` passes on HDR
|
||||
files). TMDb was 100 % disk-cache hits and cost 0.8 s total.
|
||||
- Scaled to the full library: every application restart re-probed **~2983
|
||||
files ≈ 6–8 minutes** of sequential ffmpeg, during which the whole index was
|
||||
re-derived and re-upserted item by item (see F4).
|
||||
|
||||
### F3 — Preview (showreel) generation: restart storms and infinite retries
|
||||
|
||||
- **Every scan that included an item enqueued all of its reel tasks**, whether
|
||||
or not the reels existed. Existence was only checked later by the serial
|
||||
worker. A full scan of the subset root enqueued **467** tasks; the full
|
||||
library would enqueue ~2700.
|
||||
- **Nothing was persisted about the queue.** A restart before the queue drained
|
||||
started everything over.
|
||||
- **Failures were never recorded.** In the drain test, 3 of 18 movies failed
|
||||
deterministically (DoVi profile 7 titles require `libplacebo` tonemapping,
|
||||
which fails on GPU-less machines; one file has a matroska demux error). The
|
||||
same files were retried on every subsequent drain — ~1.5 s of probing plus
|
||||
crop detection plus an error task broadcast to every client, **forever**.
|
||||
- The reel-existence check required **all five** reels; short videos
|
||||
legitimately produce fewer, so they were treated as "missing" and re-queued
|
||||
on every scan, generating nothing new each time.
|
||||
- Measured generation pace with software AV1 encoding: ~16–28 s per movie
|
||||
(5 clips), ~4–5 s per episode clip.
|
||||
- The reel worker rebroadcast **whole items** built from stale scan data,
|
||||
clobbering newer store state.
|
||||
|
||||
### F4 — Degraded operation while a full scan is in progress
|
||||
|
||||
- Items were re-upserted one by one as processed; combined with F1, any
|
||||
partial rescan interleaved with normal use made listings lose data until the
|
||||
next restart.
|
||||
- The showreel worker broadcast an **error task for every permanent failure on
|
||||
every scan** (F3), producing user-visible noise.
|
||||
- The index snapshot was rewritten every 5 s while dirty; with 1286+ items
|
||||
that is a ~7.5 MB serialize + write per flush, continuously, for the
|
||||
duration of a scan.
|
||||
- `upsert_*` rebuilt the TMDb-id lookup maps on **every** upsert — O(n²) per
|
||||
scan. Measured negligible; fixed anyway as part of the merge work.
|
||||
|
||||
### F5 — Cold-scan cost was serialized small-file I/O, not TMDb
|
||||
|
||||
Cold scan of the subset root: **975 s for 163 items**.
|
||||
|
||||
| Time | Share | Where |
|
||||
|---|---|---|
|
||||
| 763 s | 78 % | `download_cast_profile`: 3494 cast images, strictly serialized; ≈0.22 s each ≈ 0.19 s network-mount write + 0.03 s HTTP |
|
||||
| ~100 s | 10 % | ffmpeg probes (469 files, incl. 83 HDR `showinfo` passes) |
|
||||
| 73 s | 7 % | TMDb API layer: 217 uncached requests (27 s HTTP) **plus ~41 s writing per-request cache JSON files** to the network mount |
|
||||
| ~75 s | 8 % | covers / backdrops / season posters (same small-write cost) |
|
||||
| 0.3 s | — | filesystem discovery walk |
|
||||
|
||||
Notes:
|
||||
|
||||
- The TMDb disk cache itself is fine: once warm it serves 282 requests in
|
||||
0.8 s. Cache *reads* need no optimization.
|
||||
- The full cast of every title was downloaded sequentially, one tiny file per
|
||||
person (the full library has ~35k people records). Re-runs are cheap
|
||||
(exists-check), so this was a cold-scan-only cost — but it made the first
|
||||
scan of a new root take ~8× longer than everything else combined.
|
||||
|
||||
### F6 — Measured as noise (not worth effort)
|
||||
|
||||
- The 30-second rescan loop's tree walk: 3.5–6 s per pass over the full
|
||||
library (~1900 directories). Continuous but light.
|
||||
- `get_directory_size` per torrent: 0.1 s total in the scan.
|
||||
- TMDb disk-cache reads: sub-second per scan.
|
||||
- `trigger_scan` was dead code — nothing called it.
|
||||
- `_seen_mtimes` was updated *before* processing; a cancelled/failed scan
|
||||
permanently lost that update until the next restart.
|
||||
- The in-process probe cache was keyed by path only; a replaced file kept
|
||||
stale probe data until restart.
|
||||
|
||||
---
|
||||
|
||||
## 2. Implemented fixes
|
||||
|
||||
All proposals P1–P5 from the review were implemented, keeping the existing
|
||||
on-disk format unchanged (no blob storage — see Appendix A).
|
||||
|
||||
### F1 → merge-semantics upserts + deletion sync
|
||||
|
||||
- The `Upsert` event now carries `scanned: list[str]` — the media-root-relative
|
||||
torrent paths whose content was (re)scanned to build the item
|
||||
(`mediahive/models/events.py`; `_process_movies`/`_process_series` yield it).
|
||||
- `IndexStore.upsert_movie/upsert_series` (`mediahive/index_store.py`) merge a
|
||||
partial rebuild into the existing entry instead of replacing it:
|
||||
file entries belonging to scanned torrents are replaced, everything else is
|
||||
preserved, episodes/seasons emptied by the merge are dropped, same-episode
|
||||
multi-release files are unioned, and non-None scalar fields from the fresh
|
||||
scan win. The store broadcasts only when the merged result actually changed.
|
||||
- The TMDb dedupe collapse now folds duplicates through the same merge (the
|
||||
complete entry's scalars win), so a partial candidate can no longer delete a
|
||||
complete entry; the reverse TMDb-id map is fixed up incrementally instead of
|
||||
rebuilding both maps per upsert (also F4/P5).
|
||||
- **Deletion sync**: discovery collects the full set of candidate torrent
|
||||
paths; after a fully completed scan the scanner emits a new `Sync` event and
|
||||
`IndexStore.sync_torrent_paths` drops file entries whose torrent path is
|
||||
gone, cascading to empty episodes/seasons/items with proper removals.
|
||||
|
||||
### F2 → persisted scan state and probe cache
|
||||
|
||||
Three small JSON files under `.mediahive/` per root, loaded at scanner start
|
||||
and written atomically (tmp + rename) **only when changed**:
|
||||
|
||||
- `scan-state.json`: relpath → mtime. A restart over an unchanged library now
|
||||
discovers "0 new items" and finishes in walk time. Mtimes are committed
|
||||
**after** the scan completes successfully (fixes the pre-commit nit from F6):
|
||||
a cancelled/failed scan retries its items.
|
||||
- `probe-cache.json`: path → {mtime, size, probe fields} for every probed
|
||||
file, **failures included**. Keying by mtime+size makes it self-invalidating
|
||||
when a file is replaced (also fixes the stale-probe nit from F6). Non-plain
|
||||
paths (bluray:/concat: URIs) fail `stat` and stay memory-cached only.
|
||||
- `reel-state.json`: see F3 below.
|
||||
|
||||
### F3 → reel-state persistence, backoff, and queue gating
|
||||
|
||||
- `reel-state.json` records, per media folder (movies) or per episode
|
||||
(`folder#SxxEyy`), the video's mtime+size, status (`done`/`failed`),
|
||||
attempt count, and last-attempt timestamp.
|
||||
- `_reel_needed` gates both scan-time queueing and the worker:
|
||||
`done` entries are skipped; `failed` entries back off exponentially
|
||||
(6 h → 12 h → … capped at 1 week); entries with no record fall back to a
|
||||
cheap on-disk existence check, and existing reels are silently recorded as
|
||||
`done` so future scans take the cheap path. This ends both the infinite
|
||||
retries of unreadable files and the re-queueing of short videos.
|
||||
- The worker no longer rebroadcasts whole (stale) items: it sends narrow
|
||||
`MovieShowreel` / `EpisodeReel` events, and the store updates only the reel
|
||||
fields of the current entry (`set_movie_showreel` / `set_episode_reel`).
|
||||
- Reel state is persisted at scan finalize, on scanner `stop()`, and as soon
|
||||
as the reel queue drains (a crash between scans no longer loses records).
|
||||
|
||||
### F5 → bounded parallelism for downloads and TMDb fetches
|
||||
|
||||
- Cast-profile downloads: `asyncio.gather` with a semaphore of 8.
|
||||
- TMDb title lookups (movies and series) are prefetched in parallel
|
||||
(semaphore of 4) before the grouping loops, which then read the per-call
|
||||
caches.
|
||||
- Season-detail fetches are prefetched in parallel (semaphore of 4) before the
|
||||
season loop.
|
||||
- Per-item cover/backdrop/poster logic is unchanged (exists-check-fast when
|
||||
warm).
|
||||
|
||||
### Housekeeping (P5)
|
||||
|
||||
- Dead `trigger_scan` removed (`is_scanning` kept).
|
||||
- TMDb-id index bookkeeping is incremental (see F1 above).
|
||||
- `_rebuild_tmdb_indexes` remains only for snapshot load and dedupe.
|
||||
|
||||
---
|
||||
|
||||
## 3. Measured results
|
||||
|
||||
Subset root (163 items, 121 movies / 40 series entries, 469 video files),
|
||||
same network mount:
|
||||
|
||||
| Scenario | Before | After |
|
||||
|---|---|---|
|
||||
| Touch one season of a 5-season series | other 4 seasons vanish until next full scan | 0.3 s rescan, one partial upsert (`scanned=[S04]`), store keeps all 5 seasons |
|
||||
| Steady rescan, nothing changed (same process) | 0.2 s | 0.2 s, 0 upserts, no writes |
|
||||
| Warm restart, unchanged library (fresh process) | 56.5 s (94 % ffmpeg re-probes) + 467 reel tasks queued | **0.2 s, 0 upserts, reel queue 0** |
|
||||
| First scan with warm TMDb cache but no probe cache | 56.5 s | 51.6 s once — writes `probe-cache.json` (469 records, 207 KB); subsequent runs skip all probing |
|
||||
| Permanently unreadable files (3 DoVi/libplacebo movies) | retried on every scan and every startup, error broadcast each time | recorded as failed once, skipped within backoff |
|
||||
| Cold scan, nothing cached | 975 s | not re-measured end-to-end; the dominant terms are now 8-way parallel (cast images: 3494 downloads measured at 3.7 s when warm) |
|
||||
| Deleted torrent | stayed in index forever | removed on the next completed scan via `Sync` |
|
||||
|
||||
Unit-level checks (synthetic `IndexStore` + scanner state, no filesystem
|
||||
library involved): partial upsert preserves untouched seasons and replaces
|
||||
rescanned torrent files; multi-release episode union; sync removal cascades;
|
||||
single-episode reel updates; probe-cache save/load roundtrip; reel backoff
|
||||
math; reel-state persistence roundtrip across scanner instances.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Blob storage options: scan-time vs runtime concerns
|
||||
|
||||
**Status: reference only — not implemented.** The current on-disk format
|
||||
(one file per artifact) was deliberately kept. This appendix stays as
|
||||
documentation of the options if write amplification or file counts ever
|
||||
become an operational issue.
|
||||
|
||||
The two concerns have opposite constraints, so they should be decided
|
||||
separately:
|
||||
|
||||
- **Scan-time data** is written and read only by the scanner. Nothing in the
|
||||
server serves it. Storage format is therefore a pure implementation detail
|
||||
and can be changed freely.
|
||||
- **Runtime data** is delivered to the frontend as plain files via
|
||||
`/api/assets/{root}/{movies|series|people}/{path}` (covers, posters,
|
||||
backdrops, person photos) and `/api/media/{root}/{path}` (media files), with
|
||||
etag/range streaming. Anything that replaces files here must keep an HTTP
|
||||
serving story working.
|
||||
|
||||
### A.1 Artifact inventory (measured)
|
||||
|
||||
| Artifact | Class | Avg size | Count (full library) | Total | Written |
|
||||
|---|---|---|---|---|---|
|
||||
| TMDb response cache JSON | scan-time | 25 KB (median 9 KB) | ~8,500 | ~215 MB | on every uncached API request |
|
||||
| Probe results / scan state / reel state | scan-time | ~0.5 KB/record | ~3,000 records | ~1.5 MB | per processed file |
|
||||
| Person photos | runtime | 11.4 KB | ~22,500 | ~262 MB | once per person |
|
||||
| `cover.jpg` / season posters | runtime | ~77 KB | ~2,100 | ~164 MB | once per title/season |
|
||||
| `backdrop.jpg` | runtime | ~147 KB | ~1,460 | ~220 MB | once per title |
|
||||
| Reel clips (WebM/AV1) | runtime | ~450 KB | ~8,700 | ~4.0 GB | once per title/episode |
|
||||
| `index.json` snapshot | both | 7.5 MB | 1 | 7.5 MB | debounced, only when dirty |
|
||||
|
||||
Reference point for the write-amplification math: on the network mount one
|
||||
small-file write costs ~0.2 s, while a single 7.5 MB sequential write costs
|
||||
the same ~0.2 s (~40 MB/s). So ~31,000 tiny files ≈ 1.7 hours of serialized
|
||||
write time, versus ~6 s for the same bytes as one bulk dump.
|
||||
|
||||
### A.2 Scan-time blob store (TMDb cache, probe cache, scan state)
|
||||
|
||||
Nothing here is served, so the only requirement is fast lookup + cheap
|
||||
persistence. Two workable shapes:
|
||||
|
||||
- **RAM map + debounced atomic dump.** Plain dicts keyed by request hash /
|
||||
file path, dumped as one binary file (length-prefixed msgspec or JSON blob,
|
||||
optionally zstd-compressed) with tmp-write + rename, on the same
|
||||
dirty-flag + debounce discipline `index.json` already uses. Effects: the
|
||||
~8,500 individual cache writes collapse into a handful of bulk flushes;
|
||||
warm lookups become dict hits with zero filesystem calls. TMDb JSON
|
||||
compresses ~10× (215 MB → ~20–25 MB), so a full dump is a sub-second write.
|
||||
Caveat: holding all responses parsed in RAM costs ~200 MB for the full
|
||||
library; storing raw response *bytes* and parsing lazily, or capping to
|
||||
entries referenced by known index items, keeps this modest.
|
||||
- **SQLite (stdlib, WAL mode).** One database file, incremental commits, crash
|
||||
safety without full dumps, and kernel page cache instead of explicit RAM
|
||||
management. Better fit if the cache is allowed to grow unbounded, at the
|
||||
price of slightly more code.
|
||||
|
||||
Either way, keep the existing cache *semantics* unchanged: cache HTTP-level
|
||||
failures, never cache network errors. With persisted scan state in place the
|
||||
TMDb cache becomes write-rarely (new items only), which further lowers the
|
||||
value of elaborate engineering here — the simple dump is likely enough.
|
||||
|
||||
### A.3 Runtime-served artifacts
|
||||
|
||||
- **Reels, covers, backdrops, season posters: keep as files.** They are few
|
||||
per title, tens-to-hundreds of KB, written exactly once, and benefit from
|
||||
the existing etag/range file serving. No write-amplification problem.
|
||||
- **Person photos** (~22.5k files × 11.4 KB) are the one runtime class where
|
||||
tiny files hurt at scan time. Three options, in increasing invasiveness:
|
||||
1. **Keep files, fix only the scan-time behavior** — bounded-parallel
|
||||
downloads, optionally capped to top-N billed cast. Zero changes to
|
||||
serving; the cold-scan cost drops ~8× but the file count stays.
|
||||
*(This is the option currently implemented.)*
|
||||
2. **Blob db + serve from the db.** Person photos move into the same store
|
||||
as above; the assets handler gains one branch for the `people` asset
|
||||
type that streams bytes from the db instead of the filesystem.
|
||||
Eliminates all 22.5k tiny files. If full RAM residency (262 MB) is
|
||||
undesirable, use SQLite and let the page cache handle it.
|
||||
3. **Hybrid lazy materialization.** The db is authoritative at scan time
|
||||
(no tiny writes during scans); the assets handler writes the photo to the
|
||||
conventional path on first request and serves it as a file thereafter.
|
||||
Serving logic and URLs stay unchanged; disk usage appears only for
|
||||
people actually viewed.
|
||||
|
||||
Note that `index.json` itself is already the right shape: one atomic 7.5 MB
|
||||
file, rewritten only when dirty. The goal for everything else is simply to
|
||||
reach the same shape per concern.
|
||||
|
||||
### A.4 Recommendation (if revisited)
|
||||
|
||||
| Concern | Recommended treatment |
|
||||
|---|---|
|
||||
| TMDb response cache | RAM map + debounced single-file dump (SQLite if growth matters) |
|
||||
| Probe cache, seen-mtimes, reel-failure records | same dump mechanism, separate small files *(currently: three small JSON files, written only when dirty)* |
|
||||
| Person photos | option 1 now (parallel + lazy fetching); option 2 or 3 if file count becomes an operational issue |
|
||||
| Covers, posters, backdrops, reels | unchanged — plain files |
|
||||
| `index.json` | unchanged |
|
||||
+96
-69
@@ -37,6 +37,7 @@
|
||||
<Header
|
||||
:current-view="headerCurrentView"
|
||||
:search-query="searchQuery"
|
||||
:roots="headerRoots"
|
||||
:mpc-be-connected="mpcBeConnected"
|
||||
:nav-row="1"
|
||||
:position="headerPosition"
|
||||
@@ -70,7 +71,7 @@
|
||||
<!-- Browse/Search page (left panel) -->
|
||||
<main
|
||||
ref="browsePanelRef"
|
||||
class="main-content page-slider-panel"
|
||||
class="main-content page-slider-panel scrollbar-hidden"
|
||||
data-nav-scope="browse"
|
||||
@scroll.passive="handlePanelScroll('browse')"
|
||||
>
|
||||
@@ -165,7 +166,7 @@
|
||||
<!-- Detail page (right panel) -->
|
||||
<main
|
||||
ref="detailPanelRef"
|
||||
class="main-content page-slider-panel page-slider-detail-panel"
|
||||
class="main-content page-slider-panel page-slider-detail-panel scrollbar-hidden"
|
||||
data-nav-scope="detail"
|
||||
@scroll.passive="handlePanelScroll('detail')"
|
||||
>
|
||||
@@ -176,6 +177,8 @@
|
||||
:all-movies="mediaIndex?.movies ?? []"
|
||||
:focus-episode="focusEpisode"
|
||||
:has-resume-position="hasResumePosition"
|
||||
:get-resume-point="getResumePoint"
|
||||
:get-resume-episodes="getResumeEpisodes"
|
||||
:get-root-name="getRootName"
|
||||
@close="closeDetail"
|
||||
@play="handlePlay"
|
||||
@@ -200,18 +203,20 @@ import type {
|
||||
MediaItem,
|
||||
EpisodeWithSeries,
|
||||
TaskInfo,
|
||||
SeriesResumePoint,
|
||||
} from "./types"
|
||||
import {
|
||||
playMedia,
|
||||
openFolder,
|
||||
isMpcBeReachable,
|
||||
fetchResumePositions,
|
||||
normalizeMediaPath,
|
||||
getPlayerStatus,
|
||||
fetchRoots,
|
||||
type ResumePositionEntry,
|
||||
type EpisodeWatchEntry,
|
||||
} from "./api"
|
||||
import { useSettings } from "./composables/useSettings"
|
||||
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
|
||||
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
|
||||
import { useMediaWebSocket } from "./composables/useMediaWebSocket"
|
||||
import Header from "./components/Header.vue"
|
||||
import CollageHero from "./components/CollageHero.vue"
|
||||
@@ -220,7 +225,13 @@ import MediaDetail from "./components/MediaDetail.vue"
|
||||
import type { SearchResultItem, SearchResponseMessage } from "./search-worker"
|
||||
|
||||
// Initialize keyboard navigation
|
||||
const { getFocusState, restoreFocusState, focusElement } = useKeyboardNavigation()
|
||||
const {
|
||||
getFocusState,
|
||||
restoreFocusState,
|
||||
focusElement,
|
||||
snapshotSyncedRowScroll,
|
||||
restoreSyncedRowScroll,
|
||||
} = useKeyboardNavigation()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -268,7 +279,7 @@ const {
|
||||
error,
|
||||
connected: wsConnected,
|
||||
tasks,
|
||||
setActiveRoots,
|
||||
roots: rootStatuses,
|
||||
} = useMediaWebSocket()
|
||||
|
||||
type RootTaskInfo = TaskInfo & { root_id: string }
|
||||
@@ -287,14 +298,9 @@ interface ProgressRootState {
|
||||
|
||||
const activeTasks = computed<RootTaskInfo[]>(() => Array.from(tasks.value.values()))
|
||||
|
||||
// Poll for active roots and connect WS to them
|
||||
const rootStatuses = ref<
|
||||
Map<string, { name: string; path: string; status: string; snapshotLoaded: boolean }>
|
||||
>(new Map())
|
||||
|
||||
function getRootName(rootId: string | null | undefined): string | null {
|
||||
if (!rootId) return null
|
||||
return rootStatuses.value.get(rootId)?.name || null
|
||||
return rootStatuses.value.get(rootId)?.root_id || null
|
||||
}
|
||||
|
||||
function normalizePosixPath(value: string): string {
|
||||
@@ -427,10 +433,14 @@ const hasLibraryItems = computed(() => {
|
||||
return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0
|
||||
})
|
||||
const hasAnySnapshotLoaded = computed(() =>
|
||||
Array.from(rootStatuses.value.values()).some((root) => root.snapshotLoaded),
|
||||
Array.from(rootStatuses.value.values()).some((root) => root.snapshot_loaded),
|
||||
)
|
||||
const isInitialScanMode = computed(() => !hasLibraryItems.value && !hasAnySnapshotLoaded.value)
|
||||
|
||||
const headerRoots = computed(() =>
|
||||
Array.from(rootStatuses.value.values()).sort((a, b) => a.root_id.localeCompare(b.root_id)),
|
||||
)
|
||||
|
||||
const showProgressPanel = computed(() => {
|
||||
if (isInitialScanMode.value) {
|
||||
return !wsConnected.value || progressRoots.value.length > 0
|
||||
@@ -480,51 +490,7 @@ watch(
|
||||
{ deep: false },
|
||||
)
|
||||
|
||||
async function refreshRoots() {
|
||||
try {
|
||||
const roots = await fetchRoots()
|
||||
const newMap = new Map<
|
||||
string,
|
||||
{ name: string; path: string; status: string; snapshotLoaded: boolean }
|
||||
>()
|
||||
const activeIds: string[] = []
|
||||
for (const r of roots) {
|
||||
newMap.set(r.root_id, {
|
||||
name: r.root_id,
|
||||
path: r.path,
|
||||
status: r.status,
|
||||
snapshotLoaded: Boolean(r.snapshot_loaded),
|
||||
})
|
||||
if (r.status === "ready" || r.status === "scanning") {
|
||||
activeIds.push(r.root_id)
|
||||
}
|
||||
}
|
||||
rootStatuses.value = newMap
|
||||
setActiveRoots(activeIds)
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch roots:", e)
|
||||
}
|
||||
}
|
||||
|
||||
let rootsPollTimer: number | null = null
|
||||
function startRootsPolling() {
|
||||
if (rootsPollTimer !== null) return
|
||||
void refreshRoots()
|
||||
rootsPollTimer = window.setInterval(refreshRoots, 5000)
|
||||
}
|
||||
function stopRootsPolling() {
|
||||
if (rootsPollTimer !== null) {
|
||||
window.clearInterval(rootsPollTimer)
|
||||
rootsPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startRootsPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRootsPolling()
|
||||
if (libraryUpdateToastTimer !== null) {
|
||||
window.clearTimeout(libraryUpdateToastTimer)
|
||||
libraryUpdateToastTimer = null
|
||||
@@ -536,7 +502,7 @@ const settings = useSettings()
|
||||
const searchResults = ref<MediaItem[]>([])
|
||||
const isSearching = ref(false)
|
||||
const mpcBeConnected = ref(false)
|
||||
const resumePositions = ref<Record<string, number>>({})
|
||||
const resumePositions = ref<Record<string, ResumePositionEntry>>({})
|
||||
const searchQuery = ref(getRouteSearchQuery())
|
||||
const searchReturnPath = ref<string | null>(null)
|
||||
const browsePanelRef = ref<HTMLElement | null>(null)
|
||||
@@ -579,6 +545,10 @@ async function refreshResumePositions() {
|
||||
resumePositions.value = await fetchResumePositions()
|
||||
}
|
||||
|
||||
function refreshResumePositionsAsEvent() {
|
||||
void refreshResumePositions()
|
||||
}
|
||||
|
||||
async function refreshPlayerStatus() {
|
||||
if (!isMpcFamilySelected()) {
|
||||
mpcBeConnected.value = false
|
||||
@@ -592,10 +562,27 @@ async function refreshPlayerStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
function hasResumePosition(filePath: string | null) {
|
||||
if (!filePath) return false
|
||||
const normalizedPath = normalizeMediaPath(filePath)
|
||||
return Number(resumePositions.value[normalizedPath] || 0) > 0
|
||||
function hasResumePosition(mediaId: string | null) {
|
||||
if (!mediaId) return false
|
||||
return (resumePositions.value[mediaId]?.pos || 0) > 0
|
||||
}
|
||||
|
||||
function getResumePoint(mediaId: string | null): SeriesResumePoint | null {
|
||||
if (!mediaId) return null
|
||||
const entry = resumePositions.value[mediaId]
|
||||
if (!entry || entry.season === null || entry.episode === null) return null
|
||||
return {
|
||||
seasonNumber: entry.season,
|
||||
episodeNumber: entry.episode,
|
||||
positionSeconds: entry.pos,
|
||||
}
|
||||
}
|
||||
|
||||
function getResumeEpisodes(
|
||||
mediaId: string | null,
|
||||
): Record<string, EpisodeWatchEntry> | null {
|
||||
if (!mediaId) return null
|
||||
return resumePositions.value[mediaId]?.episodes ?? null
|
||||
}
|
||||
|
||||
function startMpcBePolling() {
|
||||
@@ -721,6 +708,23 @@ const searchCategories = ref<{ name: string; items: MediaItem[] }[]>([])
|
||||
const focusStateMap = new Map<string, { row: number; col: number }>()
|
||||
// Track the last viewed item ID to restore focus to the right card
|
||||
const lastViewedItemId = ref<string | null>(null)
|
||||
let browseScrollSnapshot: { panelTop: number; rows: SyncedRowScrollSnapshot } | null = null
|
||||
|
||||
function captureBrowseScrollSnapshot() {
|
||||
browseScrollSnapshot = {
|
||||
panelTop: browsePanelRef.value?.scrollTop ?? 0,
|
||||
rows: snapshotSyncedRowScroll(),
|
||||
}
|
||||
}
|
||||
|
||||
function restoreBrowseScrollSnapshot() {
|
||||
if (!browseScrollSnapshot) return
|
||||
if (browsePanelRef.value) {
|
||||
browsePanelRef.value.scrollTop = browseScrollSnapshot.panelTop
|
||||
}
|
||||
restoreSyncedRowScroll(browseScrollSnapshot.rows)
|
||||
browseScrollSnapshot = null
|
||||
}
|
||||
|
||||
// Save current focus state for a page
|
||||
function saveFocusForPage(page: string) {
|
||||
@@ -740,22 +744,24 @@ function restoreFocusForPage(page: string) {
|
||||
if (lastViewedItemId.value) {
|
||||
// Use nextTick + timeout to ensure DOM is updated after navigation
|
||||
setTimeout(() => {
|
||||
restoreBrowseScrollSnapshot()
|
||||
const itemId = lastViewedItemId.value
|
||||
// Find the element with matching item id
|
||||
const element = document.querySelector(`[data-item-id="${itemId}"]`) as HTMLElement | null
|
||||
if (element) {
|
||||
focusElement(element)
|
||||
focusElement(element, { preserveScroll: true })
|
||||
lastViewedItemId.value = null
|
||||
return
|
||||
}
|
||||
// Fallback to saved focus state
|
||||
const state = focusStateMap.get(page)
|
||||
restoreFocusState(state || null)
|
||||
restoreFocusState(state || null, { preserveScroll: true })
|
||||
lastViewedItemId.value = null
|
||||
}, 100)
|
||||
} else {
|
||||
restoreBrowseScrollSnapshot()
|
||||
const state = focusStateMap.get(page)
|
||||
restoreFocusState(state || null)
|
||||
restoreFocusState(state || null, { preserveScroll: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -957,6 +963,7 @@ onMounted(() => {
|
||||
document.addEventListener("keydown", handleDetailAdjacentKey)
|
||||
window.addEventListener("mediahive:gamepad-action", onGamepadAction as EventListener)
|
||||
window.addEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener)
|
||||
window.addEventListener("mediahive:resume-updated", refreshResumePositionsAsEvent)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -964,6 +971,7 @@ onUnmounted(() => {
|
||||
document.removeEventListener("keydown", handleDetailAdjacentKey)
|
||||
window.removeEventListener("mediahive:gamepad-action", onGamepadAction as EventListener)
|
||||
window.removeEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener)
|
||||
window.removeEventListener("mediahive:resume-updated", refreshResumePositionsAsEvent)
|
||||
stopMpcBePolling()
|
||||
})
|
||||
|
||||
@@ -1072,6 +1080,12 @@ function showDetail(item: MediaItem) {
|
||||
const currentPage = route.path === "/series" ? "series" : "movies"
|
||||
saveFocusForPage(currentPage)
|
||||
|
||||
// Capture the exact browse scroll positions to restore on return,
|
||||
// but only when leaving the browse page (not for detail-to-detail hops)
|
||||
if (!isDetailOpen.value) {
|
||||
captureBrowseScrollSnapshot()
|
||||
}
|
||||
|
||||
// Check if there are matched episodes to focus on
|
||||
if (item.type === "series" && item.searchMatchInfo?.matchedEpisodes?.length) {
|
||||
const firstMatch = item.searchMatchInfo.matchedEpisodes[0]
|
||||
@@ -1137,10 +1151,15 @@ function focusDetailEntryTarget(item: MediaItem): boolean {
|
||||
'[data-nav-release-item="true"][data-nav-row="2"][data-nav-col="0"][data-nav-focusable="true"]',
|
||||
) as HTMLElement | null
|
||||
} else if (item.type === "series") {
|
||||
// Initial episode tile (first season, first episode) maps to row 2 / col 0.
|
||||
// Row 2 is the season selector strip; land on the selected season poster.
|
||||
target = detailPanel.querySelector(
|
||||
'.episode-tile[data-nav-row="2"][data-nav-col="0"][data-nav-focusable="true"]',
|
||||
'.season-poster-card.season-poster-card--selected[data-nav-focusable="true"]',
|
||||
) as HTMLElement | null
|
||||
if (!target) {
|
||||
target = detailPanel.querySelector(
|
||||
'.episode-tile[data-nav-focusable="true"]',
|
||||
) as HTMLElement | null
|
||||
}
|
||||
}
|
||||
|
||||
if (target) {
|
||||
@@ -1641,6 +1660,7 @@ function findRootIdForPath(filePath: string): string | null {
|
||||
}
|
||||
|
||||
async function handlePlay(filePath: string) {
|
||||
const actionStart = performance.now()
|
||||
const rootId = findRootIdForPath(filePath)
|
||||
if (!rootId) {
|
||||
console.error("Cannot play: unknown root for path", filePath)
|
||||
@@ -1650,7 +1670,10 @@ async function handlePlay(filePath: string) {
|
||||
mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS
|
||||
}
|
||||
try {
|
||||
await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd)
|
||||
await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd, {
|
||||
actionStartedAt: actionStart,
|
||||
source: "App.handlePlay",
|
||||
})
|
||||
if (isMpcFamilySelected()) {
|
||||
const connected = await tryConnectMpcBe()
|
||||
if (connected) {
|
||||
@@ -1664,13 +1687,17 @@ async function handlePlay(filePath: string) {
|
||||
}
|
||||
|
||||
async function handleOpenFolder(folderPath: string, explicitRootId?: string | null) {
|
||||
const actionStart = performance.now()
|
||||
const rootId = explicitRootId || findRootIdForPath(folderPath)
|
||||
if (!rootId) {
|
||||
console.error("Cannot open folder: unknown root for path", folderPath)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await openFolder(rootId, folderPath)
|
||||
await openFolder(rootId, folderPath, {
|
||||
actionStartedAt: actionStart,
|
||||
source: "App.handleOpenFolder",
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("Failed to open folder:", e)
|
||||
}
|
||||
|
||||
+169
-39
@@ -9,18 +9,43 @@ export interface PlayerInfo {
|
||||
path: string | null
|
||||
}
|
||||
|
||||
export interface RootStatus {
|
||||
export interface RootEntry {
|
||||
root_id: string
|
||||
path: string
|
||||
status: string
|
||||
error: string | null
|
||||
snapshot_loaded: boolean
|
||||
movies: number
|
||||
series: number
|
||||
}
|
||||
|
||||
export interface RootsResponse {
|
||||
roots: RootStatus[]
|
||||
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 {
|
||||
@@ -123,49 +148,97 @@ function splitAssetTypePath(assetPath: string): { assetType: string; relativePat
|
||||
return { assetType: assetType.toLowerCase(), relativePath: rest.join("/") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch active roots and their statuses
|
||||
*/
|
||||
export async function fetchRoots(): Promise<RootStatus[]> {
|
||||
const response = await fetch("/api/roots")
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load roots: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.roots || []
|
||||
/** 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>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch merged resume positions from all roots.
|
||||
*/
|
||||
export async function fetchResumePositions(): Promise<Record<string, number>> {
|
||||
export async function fetchResumePositions(): Promise<Record<string, ResumePositionEntry>> {
|
||||
try {
|
||||
const roots = await fetchRoots()
|
||||
const merged: Record<string, number> = {}
|
||||
await Promise.all(
|
||||
roots.map(async (root) => {
|
||||
const response = await fetch(`/api/meta/${encodeURIComponent(root.root_id)}/playback-state`)
|
||||
if (!response.ok) return
|
||||
const data = await response.json().catch(() => ({}))
|
||||
const positions = data?.data?.resume_positions
|
||||
if (positions && typeof positions === "object") {
|
||||
Object.assign(merged, positions)
|
||||
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 }
|
||||
}
|
||||
}),
|
||||
)
|
||||
return merged
|
||||
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: RootStatus[]; failed: unknown[] }> {
|
||||
const response = await fetch("/api/roots", {
|
||||
): Promise<{ accepted: RootEntry[]; failed: unknown[] }> {
|
||||
const response = await fetch("/api/config/roots", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roots }),
|
||||
@@ -197,23 +270,50 @@ export async function playMedia(
|
||||
filePath: string,
|
||||
playerId?: string | null,
|
||||
playerCustomCmd?: string | null,
|
||||
timing?: ActionTimingContext,
|
||||
): Promise<void> {
|
||||
const normalizedPath = normalizeMediaPath(filePath)
|
||||
const body: Record<string, unknown> = { file_path: normalizedPath }
|
||||
if (playerId) body.player_id = playerId
|
||||
if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd
|
||||
const actionStart = timing?.actionStartedAt ?? nowMs()
|
||||
const traceId = makeTraceId("play")
|
||||
try {
|
||||
const fetchStart = nowMs()
|
||||
const actionToFetchMs = Math.max(0, fetchStart - actionStart)
|
||||
const clientSentMs = Date.now()
|
||||
const actionStartEpochMs = clientSentMs - actionToFetchMs
|
||||
const response = await fetch(`/api/play/${encodeURIComponent(rootId)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-MediaHive-Trace-Id": traceId,
|
||||
"X-MediaHive-Client-Sent-Ms": clientSentMs.toFixed(3),
|
||||
"X-MediaHive-Client-Action-Start-Ms": actionStartEpochMs.toFixed(3),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const fetchMs = Math.max(0, nowMs() - fetchStart)
|
||||
const totalMs = Math.max(0, nowMs() - actionStart)
|
||||
const serverTiming = response.headers.get("server-timing")
|
||||
const responseTraceId = response.headers.get("x-mediahive-trace-id") || traceId
|
||||
logActionTiming(
|
||||
"play",
|
||||
responseTraceId,
|
||||
response.status,
|
||||
actionToFetchMs,
|
||||
fetchMs,
|
||||
totalMs,
|
||||
serverTiming,
|
||||
timing?.source,
|
||||
)
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || response.statusText)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Play media error:", e)
|
||||
const totalMs = Math.max(0, nowMs() - actionStart)
|
||||
console.error(`Play media error after ${totalMs.toFixed(1)}ms (trace=${traceId}):`, e)
|
||||
alert(`Failed to play media.\n\n${e}`)
|
||||
}
|
||||
}
|
||||
@@ -221,20 +321,50 @@ export async function playMedia(
|
||||
/**
|
||||
* Open a folder in the system file manager
|
||||
*/
|
||||
export async function openFolder(rootId: string, folderPath: string): Promise<void> {
|
||||
export async function openFolder(
|
||||
rootId: string,
|
||||
folderPath: string,
|
||||
timing?: ActionTimingContext,
|
||||
): Promise<void> {
|
||||
const normalizedPath = normalizeMediaPath(folderPath)
|
||||
const actionStart = timing?.actionStartedAt ?? nowMs()
|
||||
const traceId = makeTraceId("open-folder")
|
||||
try {
|
||||
const fetchStart = nowMs()
|
||||
const actionToFetchMs = Math.max(0, fetchStart - actionStart)
|
||||
const clientSentMs = Date.now()
|
||||
const actionStartEpochMs = clientSentMs - actionToFetchMs
|
||||
const response = await fetch(`/api/open-folder/${encodeURIComponent(rootId)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-MediaHive-Trace-Id": traceId,
|
||||
"X-MediaHive-Client-Sent-Ms": clientSentMs.toFixed(3),
|
||||
"X-MediaHive-Client-Action-Start-Ms": actionStartEpochMs.toFixed(3),
|
||||
},
|
||||
body: JSON.stringify({ folder_path: normalizedPath }),
|
||||
})
|
||||
const fetchMs = Math.max(0, nowMs() - fetchStart)
|
||||
const totalMs = Math.max(0, nowMs() - actionStart)
|
||||
const serverTiming = response.headers.get("server-timing")
|
||||
const responseTraceId = response.headers.get("x-mediahive-trace-id") || traceId
|
||||
logActionTiming(
|
||||
"open-folder",
|
||||
responseTraceId,
|
||||
response.status,
|
||||
actionToFetchMs,
|
||||
fetchMs,
|
||||
totalMs,
|
||||
serverTiming,
|
||||
timing?.source,
|
||||
)
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || response.statusText)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Open folder error:", e)
|
||||
const totalMs = Math.max(0, nowMs() - actionStart)
|
||||
console.error(`Open folder error after ${totalMs.toFixed(1)}ms (trace=${traceId}):`, e)
|
||||
alert(`Failed to open folder.\n\n${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="episode-release-menu"
|
||||
:style="menuStyle"
|
||||
tabindex="-1"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div class="episode-release-header">{{ episodeName }}</div>
|
||||
<div v-if="releases.length > 0" class="episode-release-list">
|
||||
<ReleaseVersionCard
|
||||
v-for="(release, index) in releases"
|
||||
:key="index"
|
||||
:torrent="release"
|
||||
:best="index === 0"
|
||||
:selectable="!!release.playable_file"
|
||||
:disabled="!release.playable_file"
|
||||
compact-flags
|
||||
variant="menu"
|
||||
inert-card
|
||||
show-actions
|
||||
:play-label="getPlayLabel(release.playable_file)"
|
||||
@play="emit('play', release.playable_file || '')"
|
||||
@open-folder="emit('openFolder', release.playable_file || '')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="episode-release-empty">No versions available</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
|
||||
import type { Torrent } from "../types"
|
||||
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
episodeName: string
|
||||
releases: Torrent[]
|
||||
hasResumePosition: (filePath: string | null) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [string]
|
||||
openFolder: [string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const menuLeft = ref(0)
|
||||
const menuTop = ref(0)
|
||||
const VIEWPORT_MARGIN = 12
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${menuLeft.value}px`,
|
||||
top: `${menuTop.value}px`,
|
||||
}))
|
||||
|
||||
function getPlayLabel(filePath: string | null | undefined): string {
|
||||
return props.hasResumePosition(filePath || null) ? "Continue" : "Play"
|
||||
}
|
||||
|
||||
function getFocusableElements(): HTMLElement[] {
|
||||
if (!menuRef.value) return []
|
||||
return Array.from(
|
||||
menuRef.value.querySelectorAll<HTMLElement>(
|
||||
'.ctx-btn:not(:disabled)'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function focusNext(delta: number) {
|
||||
const elements = getFocusableElements()
|
||||
if (elements.length === 0) return
|
||||
const currentIndex = elements.findIndex((el) => el === document.activeElement)
|
||||
const nextIndex =
|
||||
currentIndex < 0 ? 0 : (currentIndex + delta + elements.length) % elements.length
|
||||
elements[nextIndex].focus()
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault()
|
||||
focusNext(event.shiftKey ? -1 : 1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
focusNext(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
focusNext(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
emit("close")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function clampToViewport() {
|
||||
const menu = menuRef.value
|
||||
if (!menu) return
|
||||
|
||||
const width = menu.offsetWidth
|
||||
const height = menu.offsetHeight
|
||||
|
||||
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN)
|
||||
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)
|
||||
|
||||
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft)
|
||||
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop)
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (!props.visible) return
|
||||
clampToViewport()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.x, props.y, props.episodeName, props.releases.length],
|
||||
async ([visible]) => {
|
||||
if (!visible) return
|
||||
await nextTick()
|
||||
clampToViewport()
|
||||
// Focus first action button for keyboard navigation
|
||||
const firstBtn = menuRef.value?.querySelector(
|
||||
".ctx-btn:not(:disabled)",
|
||||
) as HTMLElement | null
|
||||
firstBtn?.focus()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
window.addEventListener("resize", handleViewportChange)
|
||||
return
|
||||
}
|
||||
window.removeEventListener("resize", handleViewportChange)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", handleViewportChange)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.episode-release-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: rgba(20, 20, 30, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
min-width: 420px;
|
||||
max-width: min(820px, calc(100vw - 24px));
|
||||
max-height: calc(100vh - 24px);
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.episode-release-header {
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.episode-release-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.episode-release-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -42,6 +42,10 @@
|
||||
type="search"
|
||||
class="search-input"
|
||||
placeholder="Search..."
|
||||
:spellcheck="false"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
v-model="localSearch"
|
||||
v-bind="navAttrs(navRow, 2)"
|
||||
:data-nav-entry-col="localSearch ? 2 : undefined"
|
||||
@@ -105,7 +109,7 @@
|
||||
<div class="settings-header-spacer"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="settings-content scrollbar-hidden">
|
||||
<section class="settings-section">
|
||||
<h2 class="settings-section-title">Media Roots</h2>
|
||||
<p class="settings-section-desc">Folders scanned and indexed by MediaHive.</p>
|
||||
@@ -302,7 +306,7 @@ import { ref, watch, computed, onMounted, onUnmounted } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
import logoUrl from "../assets/mediahive.webp"
|
||||
import { fetchRoots, replaceRoots, pickFolderAndAddRoot, fetchPlayers } from "../api"
|
||||
import { replaceRoots, pickFolderAndAddRoot, fetchPlayers } from "../api"
|
||||
import type { PlayerInfo } from "../api"
|
||||
import HexKeyboard from "./HexKeyboard.vue"
|
||||
import {
|
||||
@@ -330,6 +334,7 @@ interface RootEntry {
|
||||
const props = defineProps<{
|
||||
currentView: "movies" | "series" | "search"
|
||||
searchQuery: string
|
||||
roots: RootEntry[]
|
||||
mpcBeConnected: boolean
|
||||
navRow: number
|
||||
position: "top" | "after-hero" | "after-movie-header" | "after-series-hero"
|
||||
@@ -353,7 +358,7 @@ window.addEventListener("pywebviewready", _onPywebviewReady, { once: true })
|
||||
onUnmounted(() => window.removeEventListener("pywebviewready", _onPywebviewReady))
|
||||
|
||||
const showSettings = computed(() => route.path === "/settings")
|
||||
const roots = ref<RootEntry[]>([])
|
||||
const roots = computed(() => props.roots)
|
||||
|
||||
function openSettings() {
|
||||
if (showSettings.value) return
|
||||
@@ -404,25 +409,11 @@ async function refreshPlayers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRoots() {
|
||||
try {
|
||||
const data = await fetchRoots()
|
||||
roots.value = data.map((r) => ({
|
||||
root_id: r.root_id,
|
||||
path: r.path,
|
||||
status: r.status,
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch roots:", e)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRoot(rootId: string) {
|
||||
const filtered = roots.value.filter((r) => r.root_id !== rootId)
|
||||
const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path]))
|
||||
try {
|
||||
await replaceRoots(newRoots)
|
||||
await refreshRoots()
|
||||
} catch (e) {
|
||||
console.error("Failed to remove root:", e)
|
||||
alert("Failed to remove root")
|
||||
@@ -437,7 +428,6 @@ async function addRoot() {
|
||||
newRoots[suggestedId] = folder
|
||||
try {
|
||||
await replaceRoots(newRoots)
|
||||
await refreshRoots()
|
||||
closeSettings()
|
||||
} catch (e) {
|
||||
console.error("Failed to add root:", e)
|
||||
@@ -447,7 +437,6 @@ async function addRoot() {
|
||||
|
||||
watch(showSettings, (visible) => {
|
||||
if (visible) {
|
||||
void refreshRoots()
|
||||
void refreshPlayers()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
:src="posterImageUrl"
|
||||
:alt="item.title || 'Unknown'"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="imageError = true"
|
||||
/>
|
||||
<div v-else class="media-card-placeholder">
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
:series="item.data as Series"
|
||||
:all-movies="allMovies"
|
||||
:focus-episode="focusEpisode"
|
||||
:has-resume-position="hasResumePosition"
|
||||
:resume-point="getResumePoint(item.id)"
|
||||
:resume-episodes="getResumeEpisodes(item.id)"
|
||||
:get-root-name="getRootName"
|
||||
@close="$emit('close')"
|
||||
@play="handlePlay"
|
||||
@@ -17,7 +18,7 @@
|
||||
<div v-else class="movie-page">
|
||||
<div class="movie-page-content">
|
||||
<!-- Diagonal collage header -->
|
||||
<div class="collage-header">
|
||||
<div ref="collageHeaderRef" class="collage-header">
|
||||
<!-- Background collage of showreel videos -->
|
||||
<div class="collage-grid">
|
||||
<div
|
||||
@@ -30,6 +31,7 @@
|
||||
<div class="collage-fallback-tile" :class="`collage-fallback-${slot.index + 1}`"></div>
|
||||
<video
|
||||
v-if="slot.sourcePaths.length > 0"
|
||||
:key="`${item.id}-${slot.index}-${slot.sourcePaths.join('|')}`"
|
||||
:ref="(el) => setVideoRef(el as HTMLVideoElement, slot.index)"
|
||||
:class="{ 'is-ready': isVideoReady(slot.index) }"
|
||||
:autoplay="safariAutoplay"
|
||||
@@ -179,31 +181,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="item.type === 'movies' && similarMovies.length > 0" class="similar-movies-section">
|
||||
<h2 class="similar-movies-title">Similar In Library</h2>
|
||||
<section
|
||||
v-if="item.type === 'movies' && collectionMovies.length > 1"
|
||||
class="similar-movies-section"
|
||||
>
|
||||
<div class="similar-movies-grid" data-sync-scroll-row="true" data-sync-scroll-group="similar">
|
||||
<button
|
||||
v-for="(movie, similarIndex) in similarMovies"
|
||||
:key="movie.tmdbId"
|
||||
type="button"
|
||||
class="similar-movie-card cast-card media-card"
|
||||
v-bind="navAttrs(similarNavRow, similarIndex)"
|
||||
@click="handleSelectMovie(movie.localId)"
|
||||
<a
|
||||
v-for="(movie, collectionIndex) in collectionMovies"
|
||||
:key="movie.localId"
|
||||
href="#"
|
||||
class="similar-movie-card media-card"
|
||||
:class="{ 'similar-movie-card--current': movie.isCurrent }"
|
||||
:aria-current="movie.isCurrent ? 'true' : undefined"
|
||||
v-bind="navAttrs(collectionNavRow, collectionIndex)"
|
||||
@click.prevent="handleSelectCollectionMovie(movie.localId, movie.isCurrent)"
|
||||
>
|
||||
<img
|
||||
v-if="movie.coverPath"
|
||||
:src="getCoverUrl(movie.coverPath, movie.rootId)"
|
||||
:alt="movie.title || 'Movie'"
|
||||
class="similar-movie-poster cast-photo"
|
||||
class="similar-movie-poster"
|
||||
/>
|
||||
<div v-else class="similar-movie-poster cast-photo similar-movie-poster-fallback"></div>
|
||||
<div class="similar-movie-meta cast-copy">
|
||||
<span class="similar-movie-name cast-name">{{ movie.title }}</span>
|
||||
<span class="similar-movie-sub cast-character">
|
||||
{{ movie.year || "Unknown Year" }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div v-else class="similar-movie-poster similar-movie-poster-fallback"></div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -226,6 +226,7 @@
|
||||
:play-label="getPlayLabel(versionActionMenu.filePath)"
|
||||
@play="handlePlayVersion(versionActionMenu.filePath)"
|
||||
@open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
|
||||
@close="closeVersionActionMenu"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
@@ -233,7 +234,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
|
||||
import type { CastMember, MediaItem, Movie, MovieUi, Series, Torrent } from "../types"
|
||||
import type { CastMember, MediaItem, Movie, MovieUi, Series, SeriesResumePoint, Torrent } from "../types"
|
||||
import type { EpisodeWatchEntry } from "../api"
|
||||
import {
|
||||
getCoverUrl,
|
||||
getVideoPreviewUrl,
|
||||
@@ -251,13 +253,17 @@ import {
|
||||
navAttrs,
|
||||
registerOutOfBoundsNavigationHandler,
|
||||
FOCUSABLE_ATTR,
|
||||
setModalOpen,
|
||||
} from "../composables/useKeyboardNavigation"
|
||||
import { useIdlePreviewPlayback } from "../composables/useIdlePreviewPlayback"
|
||||
|
||||
const props = defineProps<{
|
||||
item: MediaItem
|
||||
allMovies: MovieUi[]
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
|
||||
hasResumePosition: (filePath: string | null) => boolean
|
||||
hasResumePosition: (mediaId: string | null) => boolean
|
||||
getResumePoint: (mediaId: string | null) => SeriesResumePoint | null
|
||||
getResumeEpisodes: (mediaId: string | null) => Record<string, EpisodeWatchEntry> | null
|
||||
getRootName: (rootId: string | null | undefined) => string | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
@@ -277,6 +283,22 @@ const safariAutoplay = isSafariBrowser()
|
||||
const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2]
|
||||
const DESKTOP_NAV_SHORTCUT_MIN_WIDTH = 900
|
||||
|
||||
const collageHeaderRef = ref<HTMLElement | null>(null)
|
||||
let collageHeaderVisible = true
|
||||
let collageHeaderObserver: IntersectionObserver | null = null
|
||||
const staggerTimers = new Set<ReturnType<typeof setTimeout>>()
|
||||
const COLLAGE_STOP_STEP_MS = 500
|
||||
let staggerToken = 0
|
||||
|
||||
const { stopped: previewPlaybackStopped } = useIdlePreviewPlayback({
|
||||
onStop: stopPreviews,
|
||||
onRestart: () => startStaggeredPlayback(),
|
||||
})
|
||||
|
||||
function previewsSuppressed(): boolean {
|
||||
return previewPlaybackStopped.value || !collageHeaderVisible || document.hidden
|
||||
}
|
||||
|
||||
let disposeOutOfBoundsHandler: (() => void) | null = null
|
||||
let lastReleaseShortcutRow: number | null = null
|
||||
|
||||
@@ -361,15 +383,35 @@ function isVideoReady(index: number): boolean {
|
||||
return videoStates.value[index] === "ready"
|
||||
}
|
||||
|
||||
function cancelStaggeredPlayback() {
|
||||
staggerToken += 1
|
||||
for (const timer of staggerTimers) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
staggerTimers.clear()
|
||||
}
|
||||
|
||||
function scheduleStaggeredStart(token: number, start: () => void, delayMs: number) {
|
||||
const timer = setTimeout(() => {
|
||||
staggerTimers.delete(timer)
|
||||
if (token !== staggerToken || previewsSuppressed()) return
|
||||
start()
|
||||
}, delayMs)
|
||||
staggerTimers.add(timer)
|
||||
}
|
||||
|
||||
// Start staggered video playback
|
||||
function startStaggeredPlayback() {
|
||||
cancelStaggeredPlayback()
|
||||
const token = staggerToken
|
||||
const videos = videoRefs.value.filter((v) => v !== null) as HTMLVideoElement[]
|
||||
if (videos.length === 0) return
|
||||
if (videos.length === 0 || previewsSuppressed()) return
|
||||
|
||||
if (safariAutoplay) {
|
||||
videos.forEach((video, index) => {
|
||||
const offset = COLLAGE_START_OFFSETS_SECONDS[index] ?? 0
|
||||
const startVideo = () => {
|
||||
if (token !== staggerToken || previewsSuppressed()) return
|
||||
video.currentTime = offset
|
||||
video.play().catch(() => {})
|
||||
}
|
||||
@@ -389,11 +431,46 @@ function startStaggeredPlayback() {
|
||||
|
||||
// Set up staggered start for remaining videos
|
||||
for (let i = 1; i < videos.length; i++) {
|
||||
setTimeout(() => {
|
||||
const video = videos[i]
|
||||
if (!video) return
|
||||
video.play().catch(() => {})
|
||||
}, i * 2000)
|
||||
scheduleStaggeredStart(
|
||||
token,
|
||||
() => {
|
||||
const video = videos[i]
|
||||
if (!video) return
|
||||
video.play().catch(() => {})
|
||||
},
|
||||
i * 2000,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStaggeredStop(token: number, stop: () => void, delayMs: number) {
|
||||
const timer = setTimeout(() => {
|
||||
staggerTimers.delete(timer)
|
||||
if (token !== staggerToken) return
|
||||
stop()
|
||||
}, delayMs)
|
||||
staggerTimers.add(timer)
|
||||
}
|
||||
|
||||
// Stop all collage previews with a stagger, and cancel pending staggered starts
|
||||
function stopPreviews() {
|
||||
cancelStaggeredPlayback()
|
||||
const token = staggerToken
|
||||
clearHoverAudioIdleTimer()
|
||||
hoveredVideoIndex = null
|
||||
for (const interval of volumeFadeIntervals.values()) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
volumeFadeIntervals.clear()
|
||||
|
||||
let stopIndex = 0
|
||||
for (const video of videoRefs.value) {
|
||||
if (video && !video.paused && !video.ended) {
|
||||
scheduleStaggeredStop(token, () => video.pause(), stopIndex * COLLAGE_STOP_STEP_MS)
|
||||
stopIndex += 1
|
||||
} else {
|
||||
video?.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +573,19 @@ onMounted(() => {
|
||||
startStaggeredPlayback()
|
||||
}, 100)
|
||||
|
||||
// Pause the collage videos while the header is scrolled out of view
|
||||
if (collageHeaderRef.value) {
|
||||
collageHeaderObserver = new IntersectionObserver((entries) => {
|
||||
collageHeaderVisible = entries[0]?.isIntersecting ?? true
|
||||
if (collageHeaderVisible) {
|
||||
startStaggeredPlayback()
|
||||
} else {
|
||||
stopPreviews()
|
||||
}
|
||||
})
|
||||
collageHeaderObserver.observe(collageHeaderRef.value)
|
||||
}
|
||||
|
||||
registerMovieOutOfBoundsShortcut()
|
||||
})
|
||||
|
||||
@@ -555,6 +645,11 @@ watch(
|
||||
)
|
||||
videoStates.value = slots.map((slot) => (slot.sourcePaths.length > 0 ? "loading" : "missing"))
|
||||
await nextTick()
|
||||
for (let i = 0; i < videoRefs.value.length; i++) {
|
||||
if (slots[i]?.sourcePaths.length > 0) {
|
||||
videoRefs.value[i]?.load()
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
startStaggeredPlayback()
|
||||
}, 100)
|
||||
@@ -650,73 +745,102 @@ const movieKeywords = computed(() => {
|
||||
|
||||
const viewportWidth = ref(typeof window !== "undefined" ? window.innerWidth : 1920)
|
||||
|
||||
const similarNavRow = computed(() => 3 + movieVersions.value.length)
|
||||
const collectionNavRow = computed(() => 3 + movieVersions.value.length)
|
||||
|
||||
const castNavRow = computed(() => {
|
||||
const hasDesktopSimilarShortcut =
|
||||
viewportWidth.value > DESKTOP_NAV_SHORTCUT_MIN_WIDTH && similarMovies.value.length > 0
|
||||
viewportWidth.value > DESKTOP_NAV_SHORTCUT_MIN_WIDTH && collectionMovies.value.length > 1
|
||||
// Desktop with similar row: keep visual cast placement but move it below similar in nav rows.
|
||||
// Narrow layout (or no similar): preserve existing cast row directly after releases.
|
||||
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
|
||||
})
|
||||
|
||||
const similarMovies = computed((): Array<{
|
||||
tmdbId: number
|
||||
const collectionMovies = computed((): Array<{
|
||||
title: string
|
||||
localId: string
|
||||
coverPath: string | null
|
||||
rootId: string | null
|
||||
year: string | null
|
||||
hyphenLang: string | null
|
||||
isCurrent: boolean
|
||||
}> => {
|
||||
if (props.item.type !== "movies") return []
|
||||
|
||||
const movie = props.item.data as Movie
|
||||
const similar = movie.info?.similar || []
|
||||
if (similar.length === 0) return []
|
||||
|
||||
const byTmdbId = new Map<number, MovieUi>()
|
||||
for (const libraryMovie of props.allMovies || []) {
|
||||
const tmdbId = libraryMovie.info?.tmdb_id
|
||||
if (libraryMovie.id === props.item.id) continue
|
||||
|
||||
if (typeof tmdbId === "number" && !byTmdbId.has(tmdbId)) {
|
||||
byTmdbId.set(tmdbId, libraryMovie)
|
||||
}
|
||||
}
|
||||
const collectionName = movie.info?.collection?.trim()
|
||||
if (!collectionName) return []
|
||||
const normalizedCollectionName = collectionName.toLowerCase()
|
||||
|
||||
const matches: Array<{
|
||||
tmdbId: number
|
||||
title: string
|
||||
localId: string
|
||||
coverPath: string | null
|
||||
rootId: string | null
|
||||
year: string | null
|
||||
hyphenLang: string | null
|
||||
isCurrent: boolean
|
||||
}> = []
|
||||
|
||||
const seenTmdbIds = new Set<number>()
|
||||
for (const similarEntry of similar) {
|
||||
if (seenTmdbIds.has(similarEntry.id)) continue
|
||||
seenTmdbIds.add(similarEntry.id)
|
||||
let hasCurrentInMatches = false
|
||||
|
||||
const matched = byTmdbId.get(similarEntry.id)
|
||||
if (!matched) continue
|
||||
for (const libraryMovie of props.allMovies || []) {
|
||||
const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
|
||||
if (otherCollectionName !== normalizedCollectionName) continue
|
||||
|
||||
const title = matched.title || matched.info?.title || similarEntry.title
|
||||
const title = libraryMovie.title || libraryMovie.info?.title
|
||||
if (!title) continue
|
||||
|
||||
const isCurrent = libraryMovie.id === props.item.id
|
||||
if (isCurrent) hasCurrentInMatches = true
|
||||
|
||||
matches.push({
|
||||
tmdbId: similarEntry.id,
|
||||
title,
|
||||
localId: matched.id,
|
||||
coverPath: matched.cover_path || null,
|
||||
rootId: matched.root_id || null,
|
||||
year: matched.year ? String(matched.year) : matched.info?.release_date?.slice(0, 4) || null,
|
||||
localId: libraryMovie.id,
|
||||
coverPath: libraryMovie.cover_path || null,
|
||||
rootId: libraryMovie.root_id || null,
|
||||
year: libraryMovie.year
|
||||
? String(libraryMovie.year)
|
||||
: libraryMovie.info?.release_date?.slice(0, 4) || null,
|
||||
hyphenLang: normalizeHyphenationLang(libraryMovie.info?.original_language),
|
||||
isCurrent,
|
||||
})
|
||||
}
|
||||
|
||||
return matches.slice(0, 24)
|
||||
if (!hasCurrentInMatches) {
|
||||
matches.push({
|
||||
title: props.item.title || (props.item.data as Movie).info?.title || "Current movie",
|
||||
localId: props.item.id,
|
||||
coverPath: props.item.cover_path || null,
|
||||
rootId: props.item.root_id || null,
|
||||
year: props.item.year
|
||||
? String(props.item.year)
|
||||
: (props.item.data as Movie).info?.release_date?.slice(0, 4) || null,
|
||||
hyphenLang: normalizeHyphenationLang((props.item.data as Movie).info?.original_language),
|
||||
isCurrent: true,
|
||||
})
|
||||
}
|
||||
|
||||
return matches
|
||||
.sort((a, b) => {
|
||||
const yearA = parseInt(a.year || "", 10)
|
||||
const yearB = parseInt(b.year || "", 10)
|
||||
const hasYearA = Number.isFinite(yearA)
|
||||
const hasYearB = Number.isFinite(yearB)
|
||||
|
||||
if (hasYearA && hasYearB && yearA !== yearB) return yearA - yearB
|
||||
if (hasYearA !== hasYearB) return hasYearA ? -1 : 1
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
.slice(0, 24)
|
||||
})
|
||||
|
||||
function normalizeHyphenationLang(language: string | null | undefined): string | null {
|
||||
if (!language) return null
|
||||
const normalized = language.trim()
|
||||
if (!/^[A-Za-z]{2,3}(?:-[A-Za-z]{2,4})?$/.test(normalized)) return null
|
||||
return normalized.toLowerCase()
|
||||
}
|
||||
|
||||
function formatKeywordLabel(keyword: string): string {
|
||||
// Keep multi-word keywords together while visually narrowing internal spacing.
|
||||
return keyword.trim().replace(/\s+/g, "\u202F")
|
||||
@@ -763,14 +887,6 @@ const ratingClass = computed(() => {
|
||||
return "rating-low"
|
||||
})
|
||||
|
||||
const seasons = computed(() => {
|
||||
if (props.item.type !== "series") return []
|
||||
const series = props.item.data as Series
|
||||
return series.seasons || []
|
||||
})
|
||||
|
||||
const selectedSeasonIndex = ref<number>(0)
|
||||
|
||||
const versionActionMenu = ref<{
|
||||
visible: boolean
|
||||
x: number
|
||||
@@ -788,14 +904,19 @@ const versionActionMenu = ref<{
|
||||
})
|
||||
|
||||
function closeVersionActionMenu() {
|
||||
const wasVisible = versionActionMenu.value.visible
|
||||
versionActionMenu.value.visible = false
|
||||
versionActionMenu.value.filePath = null
|
||||
versionActionMenu.value.rootName = null
|
||||
versionActionMenu.value.rootId = null
|
||||
if (wasVisible) {
|
||||
setModalOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
function getPlayLabel(filePath: string | null): string {
|
||||
return props.hasResumePosition(filePath) ? "Continue" : "Play"
|
||||
if (!filePath || props.item.type !== "movies") return "Play"
|
||||
return props.hasResumePosition(props.item.id) ? "Continue" : "Play"
|
||||
}
|
||||
|
||||
function handlePlayVersion(filePath: string | null) {
|
||||
@@ -808,6 +929,7 @@ function handlePlayVersion(filePath: string | null) {
|
||||
function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setModalOpen(true)
|
||||
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
|
||||
versionActionMenu.value = {
|
||||
visible: true,
|
||||
@@ -851,17 +973,6 @@ function handleMovieMenuKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// Select first season by default
|
||||
watch(
|
||||
seasons,
|
||||
(s) => {
|
||||
if (s.length > 0 && selectedSeasonIndex.value >= s.length) {
|
||||
selectedSeasonIndex.value = 0
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function handlePlay(filePath: string | null) {
|
||||
if (filePath) {
|
||||
emit("play", filePath)
|
||||
@@ -893,21 +1004,65 @@ function handleSelectMovie(movieId: string) {
|
||||
emit("selectMovie", movieId)
|
||||
}
|
||||
|
||||
function handleSelectCollectionMovie(movieId: string, isCurrent: boolean) {
|
||||
if (isCurrent) return
|
||||
handleSelectMovie(movieId)
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
viewportWidth.value = window.innerWidth
|
||||
}
|
||||
|
||||
function handleGamepadAction(event: Event) {
|
||||
const actionEvent = event as CustomEvent<{ action?: string }>
|
||||
if (actionEvent.detail?.action !== "menu") return
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (!active || !active.hasAttribute("data-nav-release-item")) return
|
||||
|
||||
const row = parseInt(active.getAttribute("data-nav-row") || "-1", 10)
|
||||
if (row < 0) return
|
||||
|
||||
const index = row - 2 // releases start at nav row 2
|
||||
const version = movieVersions.value[index]
|
||||
if (!version) return
|
||||
|
||||
actionEvent.preventDefault()
|
||||
setModalOpen(true)
|
||||
const rect = active.getBoundingClientRect()
|
||||
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
|
||||
versionActionMenu.value = {
|
||||
visible: true,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
filePath: version.playable_file || null,
|
||||
rootName: props.getRootName(rootId) || null,
|
||||
rootId,
|
||||
}
|
||||
nextTick(() => {
|
||||
const firstAction = document.querySelector(
|
||||
".version-action-menu .version-action-item:not(:disabled)",
|
||||
) as HTMLElement | null
|
||||
firstAction?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("keydown", handleMovieMenuKeydown, true)
|
||||
window.addEventListener("resize", handleResize)
|
||||
window.addEventListener("mousemove", handleHoverAudioMouseMove, { passive: true })
|
||||
window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", handleMovieMenuKeydown, true)
|
||||
window.removeEventListener("resize", handleResize)
|
||||
window.removeEventListener("mousemove", handleHoverAudioMouseMove)
|
||||
window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
|
||||
clearHoverAudioIdleTimer()
|
||||
cancelStaggeredPlayback()
|
||||
collageHeaderObserver?.disconnect()
|
||||
collageHeaderObserver = null
|
||||
disposeOutOfBoundsHandler?.()
|
||||
disposeOutOfBoundsHandler = null
|
||||
lastReleaseShortcutRow = null
|
||||
@@ -933,6 +1088,9 @@ onUnmounted(() => {
|
||||
|
||||
.similar-movies-section {
|
||||
margin-top: 20px;
|
||||
position: relative;
|
||||
left: calc(-50vw + 50%);
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.similar-movies-title {
|
||||
@@ -943,7 +1101,12 @@ onUnmounted(() => {
|
||||
|
||||
.similar-movies-grid {
|
||||
--sync-row-tail: 0px;
|
||||
--sync-row-right-deadzone: 32px;
|
||||
--similar-safe-start: 32px;
|
||||
--similar-safe-end: 32px;
|
||||
--sync-row-left-deadzone: var(--similar-safe-start);
|
||||
--sync-row-right-deadzone: var(--similar-safe-end);
|
||||
margin: 0;
|
||||
padding: 0 calc(var(--similar-safe-end) + var(--sync-row-tail)) 0 var(--similar-safe-start);
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
@@ -963,33 +1126,52 @@ onUnmounted(() => {
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border-radius: 0;
|
||||
/* Keep poster clipping local to the poster element. */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.similar-movie-card--current {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.similar-movie-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0 solid rgba(255, 255, 255, 0.95);
|
||||
pointer-events: none;
|
||||
transition: border-width 120ms ease;
|
||||
}
|
||||
|
||||
.similar-movie-card:focus-visible,
|
||||
html:not(.mouse-active) .similar-movie-card.nav-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.similar-movie-card:focus-visible::after,
|
||||
html:not(.mouse-active) .similar-movie-card.nav-focused::after {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.similar-movie-poster {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 0 0.4rem black;
|
||||
transition: filter 140ms ease;
|
||||
}
|
||||
|
||||
.similar-movie-card--current .similar-movie-poster {
|
||||
filter: sepia(0.85);
|
||||
}
|
||||
|
||||
.similar-movie-poster-fallback {
|
||||
background: linear-gradient(135deg, #282d3a, #171b24);
|
||||
}
|
||||
|
||||
.similar-movie-meta {
|
||||
inset: auto 0 0 0;
|
||||
}
|
||||
|
||||
.similar-movie-name {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.similar-movie-sub {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.movie-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1110,6 +1292,7 @@ onUnmounted(() => {
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0.4rem black;
|
||||
}
|
||||
|
||||
.synopsis-poster {
|
||||
@@ -1453,6 +1636,13 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
|
||||
margin-left: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.similar-movies-grid {
|
||||
--similar-safe-start: 32px;
|
||||
--similar-safe-end: 32px;
|
||||
--sync-row-left-deadzone: 32px;
|
||||
--sync-row-right-deadzone: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Showreel gallery */
|
||||
@@ -1473,20 +1663,14 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.showreel-images::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.showreel-images::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.showreel-images::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.showreel-image {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<template>
|
||||
<div v-if="visible" ref="menuRef" class="version-action-menu" :style="menuStyle">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="version-action-menu"
|
||||
:style="menuStyle"
|
||||
tabindex="-1"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div class="version-action-path" :title="resolvedPath">
|
||||
{{ resolvedPath }}
|
||||
</div>
|
||||
@@ -47,6 +54,7 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
play: []
|
||||
openFolder: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
@@ -73,6 +81,44 @@ const menuStyle = computed(() => ({
|
||||
|
||||
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
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
class="version-row"
|
||||
:class="{
|
||||
'version-best': best,
|
||||
'version-selectable': isSelectable,
|
||||
'version-selectable': isSelectable && !inertCard,
|
||||
'version-disabled': isDisabled,
|
||||
'version-menu': variant === 'menu',
|
||||
'version-with-actions': showActions,
|
||||
'version-inert': inertCard,
|
||||
}"
|
||||
tabindex="0"
|
||||
:tabindex="inertCard ? undefined : 0"
|
||||
:title="resolvedTitle"
|
||||
v-bind="$attrs"
|
||||
@click="handleActivate"
|
||||
@@ -57,6 +58,12 @@
|
||||
: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"
|
||||
@@ -70,14 +77,16 @@
|
||||
tabindex="0"
|
||||
@click.stop="emit('play')"
|
||||
:disabled="!torrent.playable_file"
|
||||
:title="playLabel"
|
||||
>
|
||||
▶ {{ playLabel }}
|
||||
▶
|
||||
</button>
|
||||
<button
|
||||
class="ctx-btn ctx-btn-folder"
|
||||
tabindex="0"
|
||||
@click.stop="emit('openFolder')"
|
||||
:disabled="!torrent.playable_file"
|
||||
title="Open Folder"
|
||||
>
|
||||
📁
|
||||
</button>
|
||||
@@ -100,6 +109,7 @@ 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,
|
||||
@@ -116,6 +126,9 @@ const props = withDefaults(
|
||||
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,
|
||||
@@ -126,6 +139,7 @@ const props = withDefaults(
|
||||
playLabel: "Play",
|
||||
title: undefined,
|
||||
variant: "default",
|
||||
inertCard: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -317,6 +331,18 @@ const showHdrBadge = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const hdr10PlusPattern = /hdr10\+|hdr10plus/i
|
||||
|
||||
const hasHdr10Plus = computed(() => {
|
||||
if (props.torrent.hdr10plus) return true
|
||||
const text = [props.torrent.title, props.torrent.quality, props.torrent.codec, props.torrent.audio]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
return hdr10PlusPattern.test(text)
|
||||
})
|
||||
|
||||
const showHdr10PlusLogo = computed(() => hasHdr10Plus.value && !hasDolbyVision.value)
|
||||
|
||||
const isSelectable = computed(() => {
|
||||
if (props.selectable !== undefined) return props.selectable
|
||||
return Boolean(props.torrent.playable_file)
|
||||
@@ -335,7 +361,7 @@ const resolvedTitle = computed(() => {
|
||||
})
|
||||
|
||||
function handleActivate(event: MouseEvent | KeyboardEvent) {
|
||||
if (!isSelectable.value || isDisabled.value) return
|
||||
if (props.inertCard || !isSelectable.value || isDisabled.value) return
|
||||
emit("activate", event)
|
||||
}
|
||||
</script>
|
||||
@@ -390,6 +416,15 @@ html.mouse-active .version-row.version-best:hover {
|
||||
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;
|
||||
@@ -500,6 +535,15 @@ html.mouse-active .version-row.version-best:hover {
|
||||
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;
|
||||
@@ -579,31 +623,32 @@ html.mouse-active .version-row.version-best:hover {
|
||||
}
|
||||
|
||||
.ctx-btn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.78rem;
|
||||
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) {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ctx-btn:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ctx-btn-folder {
|
||||
width: 34px;
|
||||
width: auto;
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import { onUnmounted, ref, type Ref } from "vue"
|
||||
|
||||
export const PREVIEW_IDLE_MS = 30_000
|
||||
|
||||
const ACTIVITY_EVENTS = ["mousemove", "mousedown", "wheel", "keydown", "touchstart"] as const
|
||||
const GAMEPAD_ACTIVITY_EVENT = "mediahive:gamepad-action"
|
||||
|
||||
interface IdlePreviewPlaybackOptions {
|
||||
idleMs?: number
|
||||
onStop: () => void
|
||||
onRestart: () => void
|
||||
}
|
||||
|
||||
// Stops preview videos after a period without user input and restarts them
|
||||
// (via the component's staggered startup) when activity resumes. Also stops
|
||||
// previews while the tab is hidden. Activity listeners run in the capture
|
||||
// phase so the stopped flag clears before hover/focus handlers react.
|
||||
export function useIdlePreviewPlayback(options: IdlePreviewPlaybackOptions): {
|
||||
stopped: Ref<boolean>
|
||||
} {
|
||||
const idleMs = options.idleMs ?? PREVIEW_IDLE_MS
|
||||
const stopped = ref(false)
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearIdleTimer() {
|
||||
if (idleTimer !== null) {
|
||||
clearTimeout(idleTimer)
|
||||
idleTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearIdleTimer()
|
||||
if (stopped.value) return
|
||||
stopped.value = true
|
||||
options.onStop()
|
||||
}
|
||||
|
||||
function handleActivity() {
|
||||
if (document.hidden) return
|
||||
if (stopped.value) {
|
||||
stopped.value = false
|
||||
options.onRestart()
|
||||
}
|
||||
clearIdleTimer()
|
||||
idleTimer = setTimeout(stop, idleMs)
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
stop()
|
||||
} else {
|
||||
handleActivity()
|
||||
}
|
||||
}
|
||||
|
||||
for (const eventName of ACTIVITY_EVENTS) {
|
||||
window.addEventListener(eventName, handleActivity, { passive: true, capture: true })
|
||||
}
|
||||
window.addEventListener(GAMEPAD_ACTIVITY_EVENT, handleActivity, { passive: true, capture: true })
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
idleTimer = setTimeout(stop, idleMs)
|
||||
|
||||
onUnmounted(() => {
|
||||
for (const eventName of ACTIVITY_EVENTS) {
|
||||
window.removeEventListener(eventName, handleActivity, { capture: true })
|
||||
}
|
||||
window.removeEventListener(GAMEPAD_ACTIVITY_EVENT, handleActivity, { capture: true })
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
clearIdleTimer()
|
||||
})
|
||||
|
||||
return { stopped }
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
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
|
||||
@@ -90,6 +93,7 @@ function registerMouseIntentTravel(event: MouseEvent): boolean {
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
reportUserActivity()
|
||||
showPointerFromMotion()
|
||||
|
||||
if (modality === "mouse") {
|
||||
@@ -112,6 +116,7 @@ function handleMouseOver(event: MouseEvent) {
|
||||
}
|
||||
|
||||
function handleMouseIntentAction(event: MouseEvent | WheelEvent) {
|
||||
reportUserActivity()
|
||||
pointerVisible = true
|
||||
if (isMouseIntentTarget(event.target)) {
|
||||
activateMouseInput()
|
||||
@@ -124,6 +129,7 @@ function handleMouseIntentAction(event: MouseEvent | WheelEvent) {
|
||||
|
||||
function handleKeyboardActivity(event: KeyboardEvent) {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return
|
||||
reportUserActivity()
|
||||
activateNonMouseInput("keyboard")
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ const activeNavigationScope = ref<string | null>(null)
|
||||
const desiredCol = ref<number | null>(null)
|
||||
// Track if global handlers are installed
|
||||
let handlersInstalled = false
|
||||
// Track open modal count — when > 0, global keyboard navigation is suspended
|
||||
let modalOpenCount = 0
|
||||
|
||||
// Data attribute names
|
||||
const FOCUSABLE_ATTR = "data-nav-focusable"
|
||||
@@ -54,6 +56,7 @@ let syncedRowsCurrentOffset = 0
|
||||
let syncedRowsTargetOffset = 0
|
||||
let lastSyncedAnchorCol: number | null = null
|
||||
let lastSyncedRowsAnimationAt: number | null = null
|
||||
let activeSyncedScrollGroup = DEFAULT_SYNC_SCROLL_GROUP
|
||||
|
||||
// Global metrics measured once from the first synced row. All calculations use
|
||||
// these same values for every row to avoid per-row DOM query inconsistencies.
|
||||
@@ -131,6 +134,13 @@ function getMetrics(group: string): ScrollMetrics | null {
|
||||
// the first synced row is already scrolled to. This avoids animating from 0
|
||||
// every time the view is entered.
|
||||
function initCurrentOffsetFromDOM(group: string) {
|
||||
if (activeSyncedScrollGroup !== group) {
|
||||
stopSyncedRowAnimation()
|
||||
activeSyncedScrollGroup = group
|
||||
syncedRowsCurrentOffset = 0
|
||||
syncedRowsTargetOffset = 0
|
||||
}
|
||||
|
||||
if (syncedRowsCurrentOffset !== 0) return
|
||||
const rows = getSyncRowsByGroup(group)
|
||||
for (const row of rows) {
|
||||
@@ -172,13 +182,19 @@ function clampRowScrollOffset(row: HTMLElement, offset: number): number {
|
||||
return Math.min(Math.max(offset, 0), getRowMaxScroll(row))
|
||||
}
|
||||
|
||||
function applySyncedRowScroll(offset: number, rows: HTMLElement[] = getSyncedRows()) {
|
||||
function applySyncedRowScroll(
|
||||
offset: number,
|
||||
rows: HTMLElement[] = getSyncRowsByGroup(activeSyncedScrollGroup),
|
||||
) {
|
||||
for (const row of rows) {
|
||||
row.scrollLeft = clampRowScrollOffset(row, offset)
|
||||
}
|
||||
}
|
||||
|
||||
function setAllRowTails(tailPx: number, rows: HTMLElement[] = getSyncedRows()) {
|
||||
function setAllRowTails(
|
||||
tailPx: number,
|
||||
rows: HTMLElement[] = getSyncRowsByGroup(activeSyncedScrollGroup),
|
||||
) {
|
||||
const value = `${Math.max(0, tailPx)}px`
|
||||
for (const row of rows) {
|
||||
row.style.setProperty(SYNC_SCROLL_TAIL_VAR, value)
|
||||
@@ -224,7 +240,7 @@ function stopSyncedRowAnimation() {
|
||||
}
|
||||
|
||||
function animateSyncedRows(now: number) {
|
||||
const rows = getSyncedRows()
|
||||
const rows = getSyncRowsByGroup(activeSyncedScrollGroup)
|
||||
if (rows.length === 0) {
|
||||
stopSyncedRowAnimation()
|
||||
return
|
||||
@@ -383,7 +399,7 @@ function handleSyncedRowResize() {
|
||||
resetSyncedRows(true)
|
||||
return
|
||||
}
|
||||
updateSyncedRowTarget(lastSyncedAnchorCol)
|
||||
updateSyncedRowTarget(lastSyncedAnchorCol, activeRow || null)
|
||||
}
|
||||
|
||||
function ensureElementVisibleVertically(element: HTMLElement) {
|
||||
@@ -686,7 +702,7 @@ function findNextElement(
|
||||
}
|
||||
}
|
||||
|
||||
function focusElement(element: HTMLElement | null) {
|
||||
function focusElement(element: HTMLElement | null, options?: { preserveScroll?: boolean }) {
|
||||
if (!element) return
|
||||
if (!isElementInActiveScope(element)) return
|
||||
|
||||
@@ -698,8 +714,10 @@ function focusElement(element: HTMLElement | null) {
|
||||
element.classList.add("nav-focused")
|
||||
element.focus({ preventScroll: true })
|
||||
|
||||
ensureElementVisibleVertically(element)
|
||||
syncRowsToElement(element)
|
||||
if (!options?.preserveScroll) {
|
||||
ensureElementVisibleVertically(element)
|
||||
syncRowsToElement(element)
|
||||
}
|
||||
|
||||
focusedElement.value = element
|
||||
}
|
||||
@@ -711,13 +729,16 @@ function getFocusState(): { row: number; col: number } | null {
|
||||
return { row, col }
|
||||
}
|
||||
|
||||
function restoreFocusState(state: { row: number; col: number } | null) {
|
||||
function restoreFocusState(
|
||||
state: { row: number; col: number } | null,
|
||||
options?: { preserveScroll?: boolean },
|
||||
) {
|
||||
if (!state) return
|
||||
|
||||
const target = findElementAt(state.row, state.col)
|
||||
if (target) {
|
||||
setTimeout(() => {
|
||||
focusElement(target.element)
|
||||
focusElement(target.element, options)
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
@@ -746,6 +767,8 @@ function shouldAllowNavigationFromInput(target: HTMLElement, direction: string):
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (modalOpenCount > 0) return
|
||||
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
const direction = {
|
||||
@@ -795,6 +818,8 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
}
|
||||
|
||||
function handleEnterKey(event: KeyboardEvent) {
|
||||
if (modalOpenCount > 0) return
|
||||
|
||||
if (event.key !== "Enter") return
|
||||
if (event.defaultPrevented) return
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
@@ -821,6 +846,14 @@ export function setActiveNavigationScope(scope: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend global keyboard navigation while a modal/popup is open.
|
||||
* Call with `true` when opening, `false` when closing. Supports nesting.
|
||||
*/
|
||||
export function setModalOpen(open: boolean) {
|
||||
modalOpenCount = Math.max(0, modalOpenCount + (open ? 1 : -1))
|
||||
}
|
||||
|
||||
export function installKeyboardNavigation() {
|
||||
if (handlersInstalled) return
|
||||
handlersInstalled = true
|
||||
@@ -865,6 +898,32 @@ export function installKeyboardNavigation() {
|
||||
})
|
||||
}
|
||||
|
||||
export interface SyncedRowScrollSnapshot {
|
||||
rows: [HTMLElement, number][]
|
||||
offset: number
|
||||
targetOffset: number
|
||||
}
|
||||
|
||||
function snapshotSyncedRowScroll(): SyncedRowScrollSnapshot {
|
||||
return {
|
||||
rows: getSyncedRows().map((row) => [row, row.scrollLeft]),
|
||||
offset: syncedRowsCurrentOffset,
|
||||
targetOffset: syncedRowsTargetOffset,
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSyncedRowScroll(snapshot: SyncedRowScrollSnapshot) {
|
||||
stopSyncedRowAnimation()
|
||||
for (const [row, scrollLeft] of snapshot.rows) {
|
||||
if (row.isConnected) {
|
||||
row.scrollLeft = scrollLeft
|
||||
}
|
||||
}
|
||||
syncedRowsCurrentOffset = snapshot.offset
|
||||
syncedRowsTargetOffset = snapshot.targetOffset
|
||||
lastSyncedRowsAnimationAt = null
|
||||
}
|
||||
|
||||
export function useKeyboardNavigation() {
|
||||
return {
|
||||
focusedElement,
|
||||
@@ -873,6 +932,8 @@ export function useKeyboardNavigation() {
|
||||
focusAt,
|
||||
getFocusState,
|
||||
restoreFocusState,
|
||||
snapshotSyncedRowScroll,
|
||||
restoreSyncedRowScroll,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,31 +13,24 @@ import type {
|
||||
MediaIndex,
|
||||
TaskInfo,
|
||||
WsMessage,
|
||||
WsRootStatus,
|
||||
} from "../types"
|
||||
|
||||
interface RootState {
|
||||
rootId: string
|
||||
ws: WebSocket | null
|
||||
movieMap: Map<string, MovieUi>
|
||||
seriesMap: Map<string, SeriesUi>
|
||||
peopleMap: Map<number, Person>
|
||||
connected: boolean
|
||||
initialized: boolean
|
||||
pendingMessages: WsMessage[]
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
export interface RootStatusEntry extends WsRootStatus {}
|
||||
|
||||
const MERGED_KEY_DELIMITER = "::"
|
||||
|
||||
/**
|
||||
* Composable that connects to per-root MediaHive WebSockets and keeps
|
||||
* Composable that connects to one all-roots MediaHive WebSocket and keeps
|
||||
* a merged media index updated in real time.
|
||||
*
|
||||
* The server sends per-root:
|
||||
* - "init" → full index (movies + series) on connect
|
||||
* - "upsert" → single item inserted or updated
|
||||
* - "remove" → single item removed
|
||||
* - "task" → background task progress
|
||||
*/
|
||||
export function useMediaWebSocket() {
|
||||
type RootTaskInfo = TaskInfo & { root_id: string }
|
||||
@@ -47,8 +40,11 @@ export function useMediaWebSocket() {
|
||||
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())
|
||||
|
||||
const roots = shallowRef<Map<string, RootState>>(new Map())
|
||||
const rootStates = shallowRef<Map<string, RootState>>(new Map())
|
||||
const wsRef = shallowRef<WebSocket | null>(null)
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let disposed = false
|
||||
|
||||
// Single periodic sweep for completed tasks instead of one timeout per task
|
||||
@@ -190,16 +186,6 @@ export function useMediaWebSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSimilarMember(member: unknown): { id: number; title: string } {
|
||||
if (!Array.isArray(member)) {
|
||||
return { id: 0, title: "" }
|
||||
}
|
||||
return {
|
||||
id: typeof member[0] === "number" ? member[0] : 0,
|
||||
title: typeof member[1] === "string" ? member[1] : "",
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePerson(member: unknown): Person | null {
|
||||
if (!Array.isArray(member)) return null
|
||||
const gender = normalizeCastGender(member[2])
|
||||
@@ -223,7 +209,7 @@ export function useMediaWebSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInfo<T extends { cast?: unknown; similar?: unknown }>(
|
||||
function normalizeInfo<T extends { cast?: unknown }>(
|
||||
info: T | null,
|
||||
people: Map<number, Person>,
|
||||
): T | null {
|
||||
@@ -235,10 +221,6 @@ export function useMediaWebSocket() {
|
||||
.filter((member) => member.name.length > 0)
|
||||
next = { ...next, cast } as T
|
||||
}
|
||||
if (Array.isArray((info as { similar?: unknown }).similar)) {
|
||||
const similar = ((info as { similar?: unknown[] }).similar || []).map(normalizeSimilarMember)
|
||||
next = { ...next, similar } as T
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -387,10 +369,41 @@ export function useMediaWebSocket() {
|
||||
return merged
|
||||
}
|
||||
|
||||
function ensureRootState(rootId: string): RootState {
|
||||
const existing = rootStates.value.get(rootId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created: RootState = {
|
||||
movieMap: new Map(),
|
||||
seriesMap: new Map(),
|
||||
peopleMap: new Map(),
|
||||
initialized: false,
|
||||
pendingMessages: [],
|
||||
}
|
||||
rootStates.value.set(rootId, created)
|
||||
return created
|
||||
}
|
||||
|
||||
function pruneMissingRoots(nextRoots: Map<string, RootStatusEntry>) {
|
||||
for (const rootId of rootStates.value.keys()) {
|
||||
if (!nextRoots.has(rootId)) {
|
||||
rootStates.value.delete(rootId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [taskKey, task] of tasks.value.entries()) {
|
||||
if (!nextRoots.has(task.root_id)) {
|
||||
tasks.value.delete(taskKey)
|
||||
}
|
||||
}
|
||||
tasks.value = new Map(tasks.value)
|
||||
}
|
||||
|
||||
function buildIndex(): MediaIndex {
|
||||
const movies: MovieUi[] = []
|
||||
const series: SeriesUi[] = []
|
||||
for (const state of roots.value.values()) {
|
||||
for (const state of rootStates.value.values()) {
|
||||
movies.push(...state.movieMap.values())
|
||||
series.push(...state.seriesMap.values())
|
||||
}
|
||||
@@ -406,68 +419,84 @@ export function useMediaWebSocket() {
|
||||
|
||||
function updateMergedState() {
|
||||
mediaIndex.value = buildIndex()
|
||||
// Consider a root "connected" only after init is received.
|
||||
|
||||
let anyInitialized = false
|
||||
for (const state of roots.value.values()) {
|
||||
if (state.connected && state.initialized) {
|
||||
for (const state of rootStates.value.values()) {
|
||||
if (state.initialized) {
|
||||
anyInitialized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (anyInitialized) {
|
||||
|
||||
if (anyInitialized || roots.value.size === 0) {
|
||||
loading.value = false
|
||||
error.value = null
|
||||
}
|
||||
connected.value = anyInitialized
|
||||
|
||||
connected.value = wsRef.value?.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
function processJson(state: RootState, text: string) {
|
||||
const msg = JSON.parse(text) as WsMessage
|
||||
function applyRootInit(rootId: string, rootData: { movies: Record<string, Movie>; series: Record<string, Series>; people?: Record<string, unknown> }) {
|
||||
const state = ensureRootState(rootId)
|
||||
|
||||
// Prevent out-of-order corruption: buffer delta messages until we receive
|
||||
// the initial full-state payload.
|
||||
if (msg.type !== "init" && !state.initialized) {
|
||||
state.pendingMessages.push(msg)
|
||||
return
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "init": {
|
||||
state.peopleMap.clear()
|
||||
for (const [id, person] of Object.entries(msg.data.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.movieMap.clear()
|
||||
state.seriesMap.clear()
|
||||
for (const [id, m] of Object.entries(msg.data.movies || {})) {
|
||||
state.movieMap.set(id, withMovieIdentity(id, m, state.rootId, state.peopleMap))
|
||||
}
|
||||
for (const [id, s] of Object.entries(msg.data.series || {})) {
|
||||
state.seriesMap.set(id, withSeriesIdentity(id, s, state.rootId, state.peopleMap))
|
||||
}
|
||||
state.initialized = true
|
||||
state.initialized = true
|
||||
|
||||
// Replay any deltas that arrived before init completed.
|
||||
if (state.pendingMessages.length > 0) {
|
||||
const queued = state.pendingMessages
|
||||
state.pendingMessages = []
|
||||
for (const queuedMsg of queued) {
|
||||
processJson(state, JSON.stringify(queuedMsg))
|
||||
}
|
||||
}
|
||||
|
||||
updateMergedState()
|
||||
console.log(
|
||||
`[WS ${state.rootId}] init: ${state.movieMap.size} movies, ${state.seriesMap.size} series`,
|
||||
)
|
||||
break
|
||||
if (state.pendingMessages.length > 0) {
|
||||
const queued = state.pendingMessages
|
||||
state.pendingMessages = []
|
||||
for (const queuedMsg of queued) {
|
||||
processMessage(queuedMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processMessage(msg: WsMessage) {
|
||||
switch (msg.type) {
|
||||
case "roots": {
|
||||
const next = new Map<string, RootStatusEntry>()
|
||||
for (const root of msg.roots || []) {
|
||||
next.set(root.root_id, { ...root })
|
||||
ensureRootState(root.root_id)
|
||||
}
|
||||
roots.value = next
|
||||
pruneMissingRoots(next)
|
||||
updateMergedState()
|
||||
return
|
||||
}
|
||||
|
||||
case "init": {
|
||||
for (const [rootId, rootData] of Object.entries(msg.roots || {})) {
|
||||
applyRootInit(rootId, rootData)
|
||||
}
|
||||
updateMergedState()
|
||||
return
|
||||
}
|
||||
|
||||
case "upsert": {
|
||||
const state = ensureRootState(msg.root_id)
|
||||
if (!state.initialized) {
|
||||
state.pendingMessages.push(msg)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.people) {
|
||||
for (const [id, person] of Object.entries(msg.people)) {
|
||||
const parsed = Number(id)
|
||||
@@ -481,160 +510,109 @@ export function useMediaWebSocket() {
|
||||
if (msg.kind === "movie") {
|
||||
state.movieMap.set(
|
||||
msg.id,
|
||||
withMovieIdentity(msg.id, msg.item as Movie, state.rootId, state.peopleMap),
|
||||
withMovieIdentity(msg.id, msg.item as Movie, msg.root_id, state.peopleMap),
|
||||
)
|
||||
} else {
|
||||
state.seriesMap.set(
|
||||
msg.id,
|
||||
withSeriesIdentity(msg.id, msg.item as Series, state.rootId, state.peopleMap),
|
||||
withSeriesIdentity(msg.id, msg.item as Series, msg.root_id, state.peopleMap),
|
||||
)
|
||||
}
|
||||
updateMergedState()
|
||||
break
|
||||
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()
|
||||
break
|
||||
return
|
||||
}
|
||||
|
||||
case "task": {
|
||||
const info = msg.data
|
||||
const taskKey = `${state.rootId}:${info.id}`
|
||||
tasks.value.set(taskKey, { ...info, root_id: state.rootId })
|
||||
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()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(state: RootState, event: MessageEvent) {
|
||||
try {
|
||||
let text: string
|
||||
if (event.data instanceof Blob) {
|
||||
event.data.text().then((t) => processJson(state, t))
|
||||
return
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
text = new TextDecoder().decode(event.data)
|
||||
} else {
|
||||
text = event.data as string
|
||||
}
|
||||
processJson(state, text)
|
||||
} catch (e) {
|
||||
console.error(`[WS ${state.rootId}] Failed to handle message:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
function connectRoot(rootId: string) {
|
||||
if (disposed) return
|
||||
const existing = roots.value.get(rootId)
|
||||
if (existing?.ws) {
|
||||
// Already connecting or connected
|
||||
function handleRawMessage(event: MessageEvent) {
|
||||
const processText = (text: string) => {
|
||||
try {
|
||||
processMessage(JSON.parse(text) as WsMessage)
|
||||
} catch (e) {
|
||||
console.error("[WS] Failed to handle message:", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.data instanceof Blob) {
|
||||
void event.data.text().then(processText)
|
||||
return
|
||||
}
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
processText(new TextDecoder().decode(event.data))
|
||||
return
|
||||
}
|
||||
processText(event.data as string)
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (disposed) return
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
connect()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (disposed) return
|
||||
if (wsRef.value && wsRef.value.readyState <= WebSocket.OPEN) return
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:"
|
||||
const url = `${proto}//${location.host}/api/ws/${encodeURIComponent(rootId)}`
|
||||
const url = `${proto}//${location.host}/api/ws`
|
||||
|
||||
const state: RootState = {
|
||||
rootId,
|
||||
ws: null,
|
||||
movieMap: new Map(),
|
||||
seriesMap: new Map(),
|
||||
peopleMap: new Map(),
|
||||
connected: false,
|
||||
initialized: false,
|
||||
pendingMessages: [],
|
||||
reconnectTimer: null,
|
||||
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")
|
||||
}
|
||||
roots.value.set(rootId, state)
|
||||
|
||||
function doConnect() {
|
||||
if (disposed) return
|
||||
console.log(`[WS ${rootId}] Connecting to ${url}...`)
|
||||
const ws = new WebSocket(url)
|
||||
state.ws = ws
|
||||
ws.onmessage = (ev) => handleRawMessage(ev)
|
||||
|
||||
ws.onopen = () => {
|
||||
state.connected = true
|
||||
state.initialized = false
|
||||
state.pendingMessages = []
|
||||
updateMergedState()
|
||||
console.log(`[WS ${rootId}] Connected`)
|
||||
}
|
||||
|
||||
ws.onmessage = (ev) => handleMessage(state, ev)
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
state.connected = false
|
||||
state.initialized = false
|
||||
state.pendingMessages = []
|
||||
state.ws = null
|
||||
updateMergedState()
|
||||
console.log(`[WS ${rootId}] Closed (code=${ev.code})`)
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = (ev) => {
|
||||
console.error(`[WS ${rootId}] Error:`, ev)
|
||||
if (!mediaIndex.value) {
|
||||
error.value = "WebSocket connection failed"
|
||||
}
|
||||
ws.onclose = (ev) => {
|
||||
if (wsRef.value === ws) {
|
||||
wsRef.value = null
|
||||
}
|
||||
connected.value = false
|
||||
console.log(`[WS] Closed (code=${ev.code})`)
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (disposed) return
|
||||
if (state.reconnectTimer) clearTimeout(state.reconnectTimer)
|
||||
state.reconnectTimer = setTimeout(() => {
|
||||
console.log(`[WS ${rootId}] Reconnecting...`)
|
||||
doConnect()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
doConnect()
|
||||
}
|
||||
|
||||
function disconnectRoot(rootId: string) {
|
||||
const state = roots.value.get(rootId)
|
||||
if (!state) return
|
||||
if (state.reconnectTimer) {
|
||||
clearTimeout(state.reconnectTimer)
|
||||
state.reconnectTimer = null
|
||||
}
|
||||
if (state.ws) {
|
||||
state.ws.onclose = null
|
||||
state.ws.close()
|
||||
state.ws = null
|
||||
}
|
||||
state.connected = false
|
||||
roots.value.delete(rootId)
|
||||
updateMergedState()
|
||||
}
|
||||
|
||||
function setActiveRoots(rootIds: string[]) {
|
||||
if (disposed) return
|
||||
const desired = new Set(rootIds)
|
||||
const current = new Set(roots.value.keys())
|
||||
|
||||
// Add new roots
|
||||
for (const rid of desired) {
|
||||
if (!current.has(rid)) {
|
||||
connectRoot(rid)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old roots
|
||||
for (const rid of current) {
|
||||
if (!desired.has(rid)) {
|
||||
disconnectRoot(rid)
|
||||
ws.onerror = (ev) => {
|
||||
console.error("[WS] Error:", ev)
|
||||
if (!mediaIndex.value) {
|
||||
error.value = "WebSocket connection failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -642,18 +620,24 @@ export function useMediaWebSocket() {
|
||||
function disconnect() {
|
||||
disposed = true
|
||||
stopTaskSweep()
|
||||
for (const state of roots.value.values()) {
|
||||
if (state.reconnectTimer) {
|
||||
clearTimeout(state.reconnectTimer)
|
||||
}
|
||||
if (state.ws) {
|
||||
state.ws.onclose = null
|
||||
state.ws.close()
|
||||
}
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
if (wsRef.value) {
|
||||
wsRef.value.onclose = null
|
||||
wsRef.value.close()
|
||||
wsRef.value = null
|
||||
}
|
||||
|
||||
connected.value = false
|
||||
roots.value.clear()
|
||||
rootStates.value.clear()
|
||||
}
|
||||
|
||||
connect()
|
||||
onUnmounted(disconnect)
|
||||
|
||||
return {
|
||||
@@ -662,7 +646,7 @@ export function useMediaWebSocket() {
|
||||
error: readonly(error),
|
||||
connected: readonly(connected),
|
||||
tasks: readonly(tasks),
|
||||
setActiveRoots,
|
||||
roots: readonly(roots),
|
||||
disconnect,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,27 @@ import { installKeyboardNavigation } from "./composables/useKeyboardNavigation"
|
||||
import { installGamepadNavigation } from "./composables/useGamepadNavigation"
|
||||
import { installInputModalityTracking } from "./composables/useInputModality"
|
||||
|
||||
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()
|
||||
|
||||
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
|
||||
if ("serviceWorker" in navigator) {
|
||||
|
||||
@@ -621,7 +621,7 @@ async function performSearch(
|
||||
movie.info?.keywords?.join(" "),
|
||||
movie.info?.overview,
|
||||
movie.info?.tagline,
|
||||
movie.info?.similar?.map((s) => s.title).join(" "),
|
||||
movie.info?.collection,
|
||||
),
|
||||
getMoviePathScore(movie, query),
|
||||
)
|
||||
@@ -719,7 +719,6 @@ async function performSearch(
|
||||
seriesItem.info?.keywords?.join(" "),
|
||||
seriesItem.info?.overview,
|
||||
seriesItem.info?.tagline,
|
||||
seriesItem.info?.similar?.map((s) => s.title).join(" "),
|
||||
seriesItem.info?.networks?.join(" "),
|
||||
),
|
||||
getSeriesPathScore(seriesItem, query),
|
||||
|
||||
@@ -61,6 +61,17 @@ html:not(.pointer-visible) * {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollbar-hidden {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.scrollbar-hidden::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@@ -409,6 +420,10 @@ html:not(.mouse-active) .btn-secondary.nav-focused {
|
||||
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;
|
||||
}
|
||||
|
||||
html.mouse-active .media-card:hover,
|
||||
@@ -525,6 +540,14 @@ html.mouse-active .media-card:hover .media-card-info {
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.modal-overlay::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
|
||||
+40
-12
@@ -19,15 +19,11 @@ export interface Person {
|
||||
gender?: CastGender | null
|
||||
}
|
||||
|
||||
export interface SimilarMedia {
|
||||
id: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface Info {
|
||||
tmdb_id: number
|
||||
title: string | null
|
||||
original_title: string | null
|
||||
original_language: string | null
|
||||
alternative_titles: string[] | null
|
||||
rating: number | null
|
||||
vote_count: number | null
|
||||
@@ -35,9 +31,9 @@ export interface Info {
|
||||
genres: string[] | null
|
||||
release_date: string | null
|
||||
runtime: number | null
|
||||
collection: string | null
|
||||
status: string | null
|
||||
tagline: string | null
|
||||
similar: SimilarMedia[] | null
|
||||
keywords: string[] | null
|
||||
cast: CastMember[] | null
|
||||
director: string | null
|
||||
@@ -113,6 +109,13 @@ export interface Series {
|
||||
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
|
||||
@@ -193,17 +196,35 @@ export interface TaskInfo {
|
||||
}
|
||||
|
||||
// WebSocket message types (matching server msgspec tagged structs)
|
||||
export interface WsRootStatus {
|
||||
root_id: string
|
||||
path: string
|
||||
status: string
|
||||
error: string | null
|
||||
snapshot_loaded: boolean
|
||||
movies: number
|
||||
series: number
|
||||
}
|
||||
|
||||
export interface WsRootInitData {
|
||||
movies: Record<string, Movie>
|
||||
series: Record<string, Series>
|
||||
people?: Record<string, PersonWire>
|
||||
}
|
||||
|
||||
export interface WsRootsMessage {
|
||||
type: "roots"
|
||||
roots: WsRootStatus[]
|
||||
}
|
||||
|
||||
export interface WsInitMessage {
|
||||
type: "init"
|
||||
data: {
|
||||
movies: Record<string, Movie>
|
||||
series: Record<string, Series>
|
||||
people?: Record<string, PersonWire>
|
||||
}
|
||||
roots: Record<string, WsRootInitData>
|
||||
}
|
||||
|
||||
export interface WsUpsertMessage {
|
||||
type: "upsert"
|
||||
root_id: string
|
||||
kind: "movie" | "series"
|
||||
id: string
|
||||
item: Movie | Series
|
||||
@@ -212,13 +233,20 @@ export interface WsUpsertMessage {
|
||||
|
||||
export interface WsRemoveMessage {
|
||||
type: "remove"
|
||||
root_id: string
|
||||
kind: "movie" | "series"
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface WsTaskMessage {
|
||||
type: "task"
|
||||
root_id: string
|
||||
data: TaskInfo
|
||||
}
|
||||
|
||||
export type WsMessage = WsInitMessage | WsUpsertMessage | WsRemoveMessage | WsTaskMessage
|
||||
export type WsMessage =
|
||||
| WsRootsMessage
|
||||
| WsInitMessage
|
||||
| WsUpsertMessage
|
||||
| WsRemoveMessage
|
||||
| WsTaskMessage
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
* Configures Vite for FastAPI backend integration:
|
||||
* - Proxies /api/* requests to the FastAPI backend
|
||||
* - Builds to the Python module's frontend-build directory
|
||||
* - Disables Vite's screen clearing on startup
|
||||
*
|
||||
* Options:
|
||||
* paths - Array of paths to proxy (default: ["/api"])
|
||||
*/
|
||||
|
||||
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
|
||||
const proxy = {}
|
||||
@@ -26,6 +27,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
return {
|
||||
name: "vite-plugin-fastapi-mediahive",
|
||||
config: () => ({
|
||||
clearScreen: false,
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../mediahive/frontend-build",
|
||||
|
||||
+79
-2
@@ -33,6 +33,54 @@ def _derive_name(path: str) -> str:
|
||||
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()
|
||||
|
||||
@@ -54,9 +102,30 @@ def main() -> None:
|
||||
action="append",
|
||||
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gui",
|
||||
action="store_true",
|
||||
help="Run with GUI (fails if GUI dependencies are not installed)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# --listen implies server-only mode; use --gui to force GUI even with --listen.
|
||||
use_gui = args.gui or not args.listen
|
||||
|
||||
if use_gui:
|
||||
try:
|
||||
from mediahive.winmain import gui_main
|
||||
except ImportError as exc:
|
||||
if args.gui:
|
||||
raise RuntimeError(
|
||||
"GUI dependencies are not installed. "
|
||||
"Install with: uv pip install mediahive[gui]"
|
||||
) from exc
|
||||
else:
|
||||
gui_main()
|
||||
return
|
||||
|
||||
if args.media_folders:
|
||||
roots: dict[str, str] = {}
|
||||
for path in args.media_folders:
|
||||
@@ -73,13 +142,21 @@ def main() -> None:
|
||||
roots[name] = p.as_posix()
|
||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots)
|
||||
|
||||
dev = {"reload": True, "reload_dirs": ["mediahive"]}
|
||||
if (
|
||||
DEVMODE
|
||||
and sys.platform == "win32"
|
||||
and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1"
|
||||
):
|
||||
_dev_reload_supervisor()
|
||||
return
|
||||
|
||||
server.run(
|
||||
"mediahive.server:app",
|
||||
listen=args.listen,
|
||||
default_port=DEFAULT_PORT,
|
||||
server_header=False,
|
||||
loop="none" if sys.platform == "win32" else "auto",
|
||||
**(dev if DEVMODE and sys.platform != "win32" else {}),
|
||||
reload=Path(__file__).parent if DEVMODE and sys.platform != "win32" else False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Welcome to the MediaHive installer.
|
||||
|
||||
During installation and on first launch, macOS may ask you to allow
|
||||
permissions (for example, access to your media folders or the local
|
||||
network). Please allow these so MediaHive can find and play your media.
|
||||
|
||||
Click Continue to begin.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
+18
-14
@@ -1,32 +1,36 @@
|
||||
r"""Platform-appropriate config persistence for MediaHive.
|
||||
|
||||
Config file location:
|
||||
Windows: %APPDATA%\mediahive\config.toml
|
||||
macOS: ~/Library/Application Support/mediahive/config.toml
|
||||
Linux: $XDG_CONFIG_HOME/mediahive/config.toml (~/.config/mediahive/config.toml)
|
||||
Locations (via platformdirs):
|
||||
Config — Windows: %LOCALAPPDATA%\mediahive\config.toml
|
||||
macOS: ~/Library/Application Support/mediahive/config.toml
|
||||
Linux: $XDG_CONFIG_HOME/mediahive/config.toml
|
||||
Logs — Windows: %LOCALAPPDATA%\mediahive\mediahive.log
|
||||
macOS: ~/Library/Logs/mediahive/mediahive.log
|
||||
Linux: $XDG_STATE_HOME/mediahive/mediahive.log
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
import msgspec.toml
|
||||
from 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
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA") or Path.home())
|
||||
elif sys.platform == "darwin":
|
||||
base = Path.home() / "Library" / "Application Support"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
||||
return base / "mediahive"
|
||||
# appauthor=False: avoid the doubled %LOCALAPPDATA%\mediahive\mediahive.
|
||||
# roaming=False: config is machine-specific state, not something to sync
|
||||
# across a domain profile.
|
||||
return user_config_path("mediahive", appauthor=False, roaming=False)
|
||||
|
||||
|
||||
def log_dir() -> Path:
|
||||
# opinion=False: no extra Logs/ subdir; mediahive.log sits beside config.
|
||||
return user_log_path("mediahive", appauthor=False, opinion=False)
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
|
||||
@@ -33,8 +33,8 @@ Examples:
|
||||
|
||||
Exclude paths by creating .mediahive/scanignore (gitignore syntax).
|
||||
|
||||
The server exposes per-root endpoints:
|
||||
WS /api/ws/{root_id} Live index updates & task progress
|
||||
The server exposes a unified endpoint:
|
||||
WS /api/ws Live index updates, task progress, and root status changes
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
|
||||
+103
-17
@@ -148,20 +148,30 @@ async def _cache_people_profiles(
|
||||
if not info or not info.cast:
|
||||
return info, people
|
||||
|
||||
semaphore = asyncio.Semaphore(8)
|
||||
|
||||
async def fetch_profile(cast_credit, person):
|
||||
async with semaphore:
|
||||
return cast_credit.id, await download_cast_profile(
|
||||
person.profile_path,
|
||||
media_folder,
|
||||
person.name,
|
||||
cast_credit.id,
|
||||
)
|
||||
|
||||
tasks = []
|
||||
for cast_credit in info.cast:
|
||||
if cast_credit.id is None:
|
||||
continue
|
||||
person = people.get(cast_credit.id)
|
||||
if person is None or not person.profile_path:
|
||||
continue
|
||||
downloaded_path = await download_cast_profile(
|
||||
person.profile_path,
|
||||
media_folder,
|
||||
person.name,
|
||||
cast_credit.id,
|
||||
)
|
||||
tasks.append(fetch_profile(cast_credit, person))
|
||||
|
||||
for cast_id, downloaded_path in await asyncio.gather(*tasks):
|
||||
if downloaded_path:
|
||||
people[cast_credit.id] = Person(
|
||||
person = people[cast_id]
|
||||
people[cast_id] = Person(
|
||||
name=person.name,
|
||||
profile_path=Path(downloaded_path).name,
|
||||
gender=person.gender,
|
||||
@@ -378,6 +388,25 @@ async def _build_seasons_data(
|
||||
seasons_map[season_num] = {}
|
||||
seasons_map[season_num][episode_num] = files
|
||||
|
||||
# Prefetch all missing season details in parallel; the loop below then
|
||||
# reads them straight from season_cache.
|
||||
if tmdb_id:
|
||||
missing = [
|
||||
season_num
|
||||
for season_num in seasons_map
|
||||
if (tmdb_id, season_num) not in season_cache
|
||||
]
|
||||
if missing:
|
||||
semaphore = asyncio.Semaphore(4)
|
||||
|
||||
async def prefetch(num: int) -> None:
|
||||
async with semaphore:
|
||||
season_cache[tmdb_id, num] = await fetch_season_details(
|
||||
tmdb_id, num
|
||||
)
|
||||
|
||||
await asyncio.gather(*(prefetch(num) for num in missing))
|
||||
|
||||
seasons_data = []
|
||||
for season_num in sorted(seasons_map.keys()):
|
||||
episodes_in_season = seasons_map[season_num]
|
||||
@@ -442,11 +471,15 @@ async def _process_movies(
|
||||
generate_showreels: bool,
|
||||
media_root: str | None = None,
|
||||
root_id: str | None = None,
|
||||
) -> AsyncIterator[tuple[str, Movie, tuple[str, Path, str] | None, dict[int, Person]]]:
|
||||
) -> AsyncIterator[
|
||||
tuple[str, Movie, tuple[str, Path, str] | None, dict[int, Person], list[str]]
|
||||
]:
|
||||
"""Async generator that processes all movies.
|
||||
|
||||
Yields:
|
||||
Tuples of ``(Movie, showreel_task_or_None)`` as each movie is processed.
|
||||
Tuples of ``(movie_id, Movie, showreel_task_or_None, people, scanned)``
|
||||
as each movie is processed. ``scanned`` lists the media-root-relative
|
||||
torrent paths whose content was (re)scanned to build the movie.
|
||||
|
||||
"""
|
||||
_ = root_id
|
||||
@@ -499,6 +532,21 @@ async def _process_movies(
|
||||
len(categories[ContentType.MOVIE]),
|
||||
) if movie_groups else None
|
||||
|
||||
# Prefetch TMDb lookups for all unique titles in parallel; the grouping
|
||||
# loop below then reads them straight from movie_tmdb_cache.
|
||||
if movie_groups:
|
||||
semaphore = asyncio.Semaphore(4)
|
||||
first_by_key = {
|
||||
f"{items[0].title.lower()}:{items[0].year}": items[0]
|
||||
for items in movie_groups.values()
|
||||
}
|
||||
|
||||
async def prefetch(item: ParsedContent) -> None:
|
||||
async with semaphore:
|
||||
await get_movie_tmdb(item.title, item.year)
|
||||
|
||||
await asyncio.gather(*(prefetch(item) for item in first_by_key.values()))
|
||||
|
||||
for idx, (_movie_key, items) in enumerate(movie_groups.items(), 1):
|
||||
first_item = items[0]
|
||||
logger.debug(
|
||||
@@ -640,7 +688,10 @@ async def _process_movies(
|
||||
showreel_source_sets=showreel_source_sets or None,
|
||||
files=files,
|
||||
)
|
||||
yield item_id, movie, showreel_task, people
|
||||
scanned = [
|
||||
make_relative_path(item.path.as_posix(), media_root) for item in items
|
||||
]
|
||||
yield item_id, movie, showreel_task, people, scanned
|
||||
|
||||
# Process movies without TMDb info
|
||||
for group_data in no_tmdb_movie_groups.values():
|
||||
@@ -712,7 +763,10 @@ async def _process_movies(
|
||||
showreel_source_sets=showreel_source_sets or None,
|
||||
files=files,
|
||||
)
|
||||
yield item_id, movie, showreel_task, {}
|
||||
scanned = [
|
||||
make_relative_path(item.path.as_posix(), media_root) for item in items
|
||||
]
|
||||
yield item_id, movie, showreel_task, {}, scanned
|
||||
|
||||
|
||||
async def _process_series(
|
||||
@@ -723,12 +777,16 @@ async def _process_series(
|
||||
media_root: str | None = None,
|
||||
root_id: str | None = None,
|
||||
) -> AsyncIterator[
|
||||
tuple[str, Series, list[tuple[str, Path, int, int, str]], dict[int, Person]]
|
||||
tuple[
|
||||
str, Series, list[tuple[str, Path, int, int, str]], dict[int, Person], list[str]
|
||||
]
|
||||
]:
|
||||
"""Async generator that processes all series.
|
||||
|
||||
Yields:
|
||||
Tuples of ``(Series, episode_reel_tasks)`` as each series is processed.
|
||||
Tuples of ``(series_id, Series, episode_reel_tasks, people, scanned)``
|
||||
as each series is processed. ``scanned`` lists the media-root-relative
|
||||
torrent paths whose content was (re)scanned to build the series.
|
||||
|
||||
"""
|
||||
_ = root_id
|
||||
@@ -783,6 +841,20 @@ async def _process_series(
|
||||
len(categories[ContentType.SERIES]),
|
||||
)
|
||||
|
||||
# Prefetch TMDb lookups for all unique titles in parallel; the grouping
|
||||
# loop below then reads them straight from series_tmdb_cache.
|
||||
if series_groups:
|
||||
semaphore = asyncio.Semaphore(4)
|
||||
first_by_key = {
|
||||
items[0].title.lower(): items[0] for items in series_groups.values()
|
||||
}
|
||||
|
||||
async def prefetch(item: ParsedContent) -> None:
|
||||
async with semaphore:
|
||||
await get_series_tmdb(item.title)
|
||||
|
||||
await asyncio.gather(*(prefetch(item) for item in first_by_key.values()))
|
||||
|
||||
for idx, (_series_key, items) in enumerate(series_groups.items(), 1):
|
||||
first_item = items[0]
|
||||
logger.debug(" [%d/%d] %s", idx, len(series_groups), first_item.title)
|
||||
@@ -879,7 +951,11 @@ async def _process_series(
|
||||
)
|
||||
|
||||
if not seasons_data:
|
||||
logger.info(" Skipping %s - no episodes found", display_title)
|
||||
logger.info(
|
||||
" Skipping %s - no episodes in the %d scanned torrent(s)",
|
||||
display_title,
|
||||
len(items),
|
||||
)
|
||||
continue
|
||||
|
||||
different_titles = sorted(
|
||||
@@ -898,7 +974,10 @@ async def _process_series(
|
||||
backdrop_path=make_relative_path(backdrop_path, media_root),
|
||||
seasons=seasons_data,
|
||||
)
|
||||
yield series_id, series, ep_reel_tasks, people
|
||||
scanned = [
|
||||
make_relative_path(item.path.as_posix(), media_root) for item in items
|
||||
]
|
||||
yield series_id, series, ep_reel_tasks, people, scanned
|
||||
|
||||
# Process series without TMDb info
|
||||
for group_data in no_tmdb_groups.values():
|
||||
@@ -928,7 +1007,11 @@ async def _process_series(
|
||||
)
|
||||
|
||||
if not seasons_data:
|
||||
logger.info(" Skipping %s - no episodes found", title)
|
||||
logger.info(
|
||||
" Skipping %s - no episodes in the %d scanned torrent(s)",
|
||||
title,
|
||||
len(items),
|
||||
)
|
||||
continue
|
||||
|
||||
item_timestamps = [await get_added_timestamp(item.path) for item in items]
|
||||
@@ -941,4 +1024,7 @@ async def _process_series(
|
||||
cover_path=make_relative_path(cover_path, media_root),
|
||||
seasons=seasons_data,
|
||||
)
|
||||
yield series_id, series, ep_reel_tasks, {}
|
||||
scanned = [
|
||||
make_relative_path(item.path.as_posix(), media_root) for item in items
|
||||
]
|
||||
yield series_id, series, ep_reel_tasks, {}, scanned
|
||||
|
||||
+831
-229
File diff suppressed because it is too large
Load Diff
@@ -28,12 +28,23 @@ VIDEO_EXTENSIONS = {
|
||||
".m2ts",
|
||||
}
|
||||
|
||||
# Caches for expensive operations
|
||||
# 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] = {}
|
||||
|
||||
|
||||
def clear_scan_caches() -> None:
|
||||
"""Drop all per-scan filesystem caches; called at the start of each scan."""
|
||||
_episode_files_cache.clear()
|
||||
_playable_file_cache.clear()
|
||||
_bluray_probe_file_cache.clear()
|
||||
|
||||
|
||||
def _scandir_split(
|
||||
directory: Path,
|
||||
stop_event: threading.Event,
|
||||
|
||||
@@ -7,13 +7,14 @@ and HDR passthrough.
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -425,6 +426,85 @@ class MediaProbeInfo:
|
||||
|
||||
|
||||
_media_probe_cache: dict[str, MediaProbeInfo] = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistent probe records
|
||||
#
|
||||
# Probe results are keyed by (path, mtime, size) and persisted to
|
||||
# ``probe-cache.json`` under the root's .mediahive folder so that process
|
||||
# restarts do not re-run ffmpeg on unchanged files. The in-RAM structures
|
||||
# are process-global (keyed by absolute path, so sharing across roots is
|
||||
# safe); each root loads/saves its own file, merging into the same dict.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_probe_records: dict[str, dict] = {}
|
||||
_probe_records_path: Path | None = None
|
||||
_probe_records_dirty = False
|
||||
|
||||
|
||||
def load_probe_records(path: Path) -> None:
|
||||
"""Load persisted probe records from ``path`` (missing file is fine)."""
|
||||
global _probe_records_path
|
||||
_probe_records_path = path
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError, ValueError:
|
||||
logger.exception("Failed to load probe cache from %s", path)
|
||||
return
|
||||
records = data.get("records")
|
||||
if isinstance(records, dict):
|
||||
_probe_records.update(records)
|
||||
logger.info("Loaded probe cache: %d records from %s", len(records), path)
|
||||
|
||||
|
||||
def probe_records_dirty() -> bool:
|
||||
return _probe_records_dirty
|
||||
|
||||
|
||||
def save_probe_records() -> None:
|
||||
"""Persist probe records if any were added since the last save."""
|
||||
global _probe_records_dirty
|
||||
if not _probe_records_dirty or _probe_records_path is None:
|
||||
return
|
||||
_probe_records_dirty = False
|
||||
try:
|
||||
payload = json.dumps({"version": 1, "records": _probe_records})
|
||||
tmp = _probe_records_path.with_suffix(".tmp")
|
||||
tmp.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp.write_text(payload, encoding="utf-8")
|
||||
tmp.replace(_probe_records_path)
|
||||
except OSError, TypeError, ValueError:
|
||||
logger.exception("Failed to save probe cache to %s", _probe_records_path)
|
||||
|
||||
|
||||
def _record_probe(video_path: str, stat_info, info: MediaProbeInfo) -> None:
|
||||
global _probe_records_dirty
|
||||
if stat_info is None:
|
||||
return # Non-plain paths (bluray:/concat: URIs) are not persisted
|
||||
_probe_records[video_path] = {
|
||||
"mtime": int(stat_info.st_mtime),
|
||||
"size": stat_info.st_size,
|
||||
"info": asdict(info),
|
||||
}
|
||||
_probe_records_dirty = True
|
||||
|
||||
|
||||
def _lookup_probe_record(video_path: str, stat_info) -> MediaProbeInfo | None:
|
||||
rec = _probe_records.get(video_path)
|
||||
if rec is None or stat_info is None:
|
||||
return None
|
||||
if rec.get("mtime") != int(stat_info.st_mtime):
|
||||
return None
|
||||
if rec.get("size") != stat_info.st_size:
|
||||
return None
|
||||
try:
|
||||
return MediaProbeInfo(**rec["info"])
|
||||
except TypeError, KeyError:
|
||||
return None
|
||||
|
||||
|
||||
_duration_re = re.compile(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)")
|
||||
_dimension_re = re.compile(r"(\d{2,5})x(\d{2,5})")
|
||||
_dovi_profile_re = re.compile(
|
||||
@@ -432,6 +512,8 @@ _dovi_profile_re = re.compile(
|
||||
)
|
||||
_audio_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Audio:")
|
||||
_subtitle_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Subtitle:")
|
||||
# showinfo emits e.g. "side data - HDR Dynamic Metadata SMPTE2094-40 (HDR10+)"
|
||||
_showinfo_hdr10plus_re = re.compile(r"SMPTE2094-40|HDR Dynamic Metadata", re.IGNORECASE)
|
||||
|
||||
|
||||
def _lang_code(raw: str | None) -> str | None:
|
||||
@@ -447,11 +529,23 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Stat once: used both to validate persisted records and to key new ones.
|
||||
# Non-plain paths (bluray:/concat: URIs) fail stat and stay memory-cached.
|
||||
stat_info = None
|
||||
with contextlib.suppress(OSError, ValueError):
|
||||
stat_info = await AsyncPath(video_path).stat()
|
||||
|
||||
recorded = _lookup_probe_record(video_path, stat_info)
|
||||
if recorded is not None:
|
||||
_media_probe_cache[video_path] = recorded
|
||||
return recorded
|
||||
|
||||
info = MediaProbeInfo()
|
||||
cmd = ["ffmpeg", "-hide_banner", "-i", video_path]
|
||||
ffmpeg_result = await _run_ffmpeg(cmd, timeout_seconds=30, allow_nonzero_exit=True)
|
||||
if ffmpeg_result is None:
|
||||
_media_probe_cache[video_path] = info
|
||||
_record_probe(video_path, stat_info, info)
|
||||
return info
|
||||
|
||||
stdout, stderr = ffmpeg_result
|
||||
@@ -492,6 +586,35 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
|
||||
or "dynamic hdr" in lower_text
|
||||
)
|
||||
|
||||
# `ffmpeg -i` only reads container headers, not frame-level side data.
|
||||
# Run a tiny showinfo decode (5 frames) to detect HDR10+ dynamic metadata
|
||||
# (SMPTE ST 2094-40) when the header scan didn't already confirm it.
|
||||
if info.hdr and not info.hdr10plus and not info.dovi:
|
||||
showinfo_cmd = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-ss",
|
||||
"0",
|
||||
"-i",
|
||||
video_path,
|
||||
"-vf",
|
||||
"showinfo",
|
||||
"-frames:v",
|
||||
"5",
|
||||
"-an",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
]
|
||||
showinfo_result = await _run_ffmpeg(
|
||||
showinfo_cmd, timeout_seconds=15, allow_nonzero_exit=True
|
||||
)
|
||||
if showinfo_result is not None:
|
||||
si_stdout, si_stderr = showinfo_result
|
||||
si_text = (si_stderr + si_stdout).decode("utf-8", errors="replace")
|
||||
if _showinfo_hdr10plus_re.search(si_text):
|
||||
info.hdr10plus = True
|
||||
|
||||
dovi_match = _dovi_profile_re.search(text)
|
||||
if dovi_match:
|
||||
info.dovi_profile = int(dovi_match.group(1))
|
||||
@@ -521,6 +644,7 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
|
||||
info.subtitle_languages = subtitle_languages or None
|
||||
|
||||
_media_probe_cache[video_path] = info
|
||||
_record_probe(video_path, stat_info, info)
|
||||
return info
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ from mediahive.models.tmdb import (
|
||||
Info,
|
||||
Person,
|
||||
SeasonInfo,
|
||||
SimilarMedia,
|
||||
)
|
||||
|
||||
# TMDb API configuration
|
||||
@@ -148,19 +147,19 @@ async def tmdb_api_request(
|
||||
|
||||
|
||||
async def fetch_movie_details(movie_id: int) -> dict | None:
|
||||
"""Fetch movie info including credits, similar, keywords, and alt titles."""
|
||||
"""Fetch movie info including credits, keywords, alt titles, and collection."""
|
||||
# Use append_to_response to get multiple data in one request
|
||||
return await tmdb_api_request(
|
||||
f"/movie/{movie_id}",
|
||||
{"append_to_response": "credits,similar,keywords,alternative_titles"},
|
||||
{"append_to_response": "credits,keywords,alternative_titles"},
|
||||
)
|
||||
|
||||
|
||||
async def fetch_series_details(series_id: int) -> dict | None:
|
||||
"""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
|
||||
return await tmdb_api_request(
|
||||
f"/tv/{series_id}", {"append_to_response": "credits,similar,keywords"}
|
||||
f"/tv/{series_id}", {"append_to_response": "credits,keywords"}
|
||||
)
|
||||
|
||||
|
||||
@@ -385,7 +384,7 @@ async def fetch_movie_info(
|
||||
result = data["results"][0]
|
||||
movie_id = result["id"]
|
||||
|
||||
# Fetch full details with credits, similar movies, and keywords
|
||||
# Fetch full details with credits, keywords, alt titles, and collection
|
||||
details = await fetch_movie_details(movie_id)
|
||||
if not details:
|
||||
# Fall back to basic info from search
|
||||
@@ -394,6 +393,7 @@ async def fetch_movie_info(
|
||||
tmdb_id=movie_id,
|
||||
title=result.get("title"),
|
||||
original_title=result.get("original_title"),
|
||||
original_language=result.get("original_language"),
|
||||
rating=result.get("vote_average"),
|
||||
vote_count=result.get("vote_count"),
|
||||
overview=result.get("overview"),
|
||||
@@ -450,15 +450,19 @@ async def fetch_movie_info(
|
||||
directors = [c["name"] for c in crew if c.get("job") == "Director"]
|
||||
director = directors[0] if directors else None
|
||||
|
||||
# Extract similar movies (limit to 10)
|
||||
similar_data = details.get("similar", {}).get("results", [])[:10]
|
||||
similar = [SimilarMedia(id=s["id"], title=s["title"]) for s in similar_data]
|
||||
collection_data = details.get("belongs_to_collection")
|
||||
collection = None
|
||||
if isinstance(collection_data, dict):
|
||||
collection_name = collection_data.get("name")
|
||||
if isinstance(collection_name, str):
|
||||
collection = collection_name or None
|
||||
|
||||
return (
|
||||
Info(
|
||||
tmdb_id=movie_id,
|
||||
title=details.get("title"),
|
||||
original_title=details.get("original_title"),
|
||||
original_language=details.get("original_language"),
|
||||
alternative_titles=alternative_titles,
|
||||
rating=details.get("vote_average"),
|
||||
vote_count=details.get("vote_count"),
|
||||
@@ -466,9 +470,9 @@ async def fetch_movie_info(
|
||||
genres=genres or None,
|
||||
release_date=details.get("release_date"),
|
||||
runtime=details.get("runtime"),
|
||||
collection=collection,
|
||||
status=details.get("status"),
|
||||
tagline=details.get("tagline"),
|
||||
similar=similar or None,
|
||||
keywords=keywords or None,
|
||||
cast=cast or None,
|
||||
director=director,
|
||||
@@ -513,7 +517,7 @@ async def fetch_series_info(
|
||||
result = data["results"][0]
|
||||
series_id = result["id"]
|
||||
|
||||
# Fetch full details with credits, similar shows, and keywords
|
||||
# Fetch full details with credits and keywords
|
||||
details = await fetch_series_details(series_id)
|
||||
if not details:
|
||||
# Fall back to basic info from search
|
||||
@@ -522,6 +526,7 @@ async def fetch_series_info(
|
||||
tmdb_id=series_id,
|
||||
title=result.get("name"),
|
||||
original_title=result.get("original_name"),
|
||||
original_language=result.get("original_language"),
|
||||
rating=result.get("vote_average"),
|
||||
vote_count=result.get("vote_count"),
|
||||
overview=result.get("overview"),
|
||||
@@ -564,10 +569,6 @@ async def fetch_series_info(
|
||||
# Extract networks
|
||||
networks = [n["name"] for n in details.get("networks", [])]
|
||||
|
||||
# Extract similar series (limit to 10)
|
||||
similar_data = details.get("similar", {}).get("results", [])[:10]
|
||||
similar = [SimilarMedia(id=s["id"], title=s["name"]) for s in similar_data]
|
||||
|
||||
# Get first air date
|
||||
first_air_date = details.get("first_air_date")
|
||||
|
||||
@@ -576,6 +577,7 @@ async def fetch_series_info(
|
||||
tmdb_id=series_id,
|
||||
title=details.get("name"),
|
||||
original_title=details.get("original_name"),
|
||||
original_language=details.get("original_language"),
|
||||
rating=details.get("vote_average"),
|
||||
vote_count=details.get("vote_count"),
|
||||
overview=details.get("overview"),
|
||||
@@ -583,7 +585,6 @@ async def fetch_series_info(
|
||||
release_date=first_air_date,
|
||||
status=details.get("status"),
|
||||
tagline=details.get("tagline"),
|
||||
similar=similar or None,
|
||||
keywords=keywords or None,
|
||||
cast=cast or None,
|
||||
creators=creators or None,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
@@ -11,9 +10,6 @@ from aiopathlib import AsyncPath
|
||||
# Default output folder name (created at common root of scanned paths)
|
||||
DEFAULT_OUTPUT_FOLDER = ".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 = {
|
||||
"8K": 5,
|
||||
@@ -108,10 +104,9 @@ def normalize_resolution_label(value: str | None) -> str | None:
|
||||
async def get_added_timestamp(path: Path) -> int | None:
|
||||
"""Get the timestamp when a torrent was added to the collection.
|
||||
|
||||
Heuristic:
|
||||
- For directories: use ctime (most accurate for torrent folder creation)
|
||||
- For files: use atime unless it's too close to current time (suggesting
|
||||
the filesystem updates atime on reads), otherwise use max(mtime, ctime)
|
||||
Best-effort rule:
|
||||
- On Windows: use ctime (creation-time semantics)
|
||||
- On other OSes: use mtime (ctime is metadata-change time on Unix)
|
||||
|
||||
Returns:
|
||||
Unix timestamp as int, or None if path doesn't exist
|
||||
@@ -122,17 +117,9 @@ async def get_added_timestamp(path: Path) -> int | None:
|
||||
stat_info = await ap.stat()
|
||||
except OSError, PermissionError:
|
||||
return None
|
||||
|
||||
if await ap.is_dir():
|
||||
if os.name == "nt":
|
||||
return int(stat_info.st_ctime)
|
||||
|
||||
now = time.time()
|
||||
atime = stat_info.st_atime
|
||||
|
||||
if now - atime < _ATIME_FRESHNESS_THRESHOLD:
|
||||
return int(max(stat_info.st_mtime, stat_info.st_ctime))
|
||||
|
||||
return int(atime)
|
||||
return int(stat_info.st_mtime)
|
||||
|
||||
|
||||
def get_directory_size(path: Path) -> int:
|
||||
|
||||
+318
-29
@@ -9,6 +9,7 @@ debounced background task.
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,16 +18,15 @@ from aiopathlib import AsyncPath
|
||||
from fastapi import WebSocket
|
||||
|
||||
from mediahive.models.data import (
|
||||
Episode,
|
||||
IndexSnapshot,
|
||||
Movie,
|
||||
Season,
|
||||
Series,
|
||||
TaskInfo,
|
||||
Torrent,
|
||||
)
|
||||
from mediahive.models.events import Remove, Task, Upsert
|
||||
from mediahive.models.protocol import (
|
||||
WsInit,
|
||||
WsInitData,
|
||||
)
|
||||
from mediahive.models.tmdb import Person
|
||||
|
||||
logger = logging.getLogger("mediahive.index_store")
|
||||
@@ -64,6 +64,8 @@ class IndexStore:
|
||||
|
||||
# Connected WebSocket clients
|
||||
self._clients: set[WebSocket] = set()
|
||||
# Passive listeners for broadcast events (used by server-level WS fan-in)
|
||||
self._listeners: set[Callable[[object], None]] = set()
|
||||
|
||||
# Snapshot debounce state
|
||||
self._snapshot_dirty = False
|
||||
@@ -148,6 +150,120 @@ class IndexStore:
|
||||
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()
|
||||
@@ -190,22 +306,34 @@ class IndexStore:
|
||||
self._rebuild_tmdb_indexes()
|
||||
|
||||
def _collapse_movie_tmdb_duplicates(self, tmdb_id: int, keep_id: str) -> None:
|
||||
"""Remove other movie entries that share a TMDb id."""
|
||||
"""Fold other entries that share a TMDb id into the kept one."""
|
||||
for item_id, movie in list(self.movies.items()):
|
||||
if item_id == keep_id:
|
||||
continue
|
||||
if self._get_tmdb_id(movie) == tmdb_id:
|
||||
self.movies.pop(item_id, None)
|
||||
self._rebuild_tmdb_indexes()
|
||||
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:
|
||||
"""Remove other series entries that share a TMDb id."""
|
||||
"""Fold other entries that share a TMDb id into the kept one."""
|
||||
for item_id, series in list(self.series.items()):
|
||||
if item_id == keep_id:
|
||||
continue
|
||||
if self._get_tmdb_id(series) == tmdb_id:
|
||||
self.series.pop(item_id, None)
|
||||
self._rebuild_tmdb_indexes()
|
||||
if self._get_tmdb_id(series) != tmdb_id:
|
||||
continue
|
||||
kept = self.series.get(keep_id)
|
||||
if kept is not None:
|
||||
# Preserve any seasons/episodes the duplicate alone carried.
|
||||
self.series[keep_id] = self._merge_series(series, kept, set())
|
||||
self.series.pop(item_id, None)
|
||||
if self._series_tmdb_ids.get(tmdb_id) == item_id:
|
||||
self._series_tmdb_ids[tmdb_id] = keep_id
|
||||
|
||||
async def _write_snapshot(self) -> None:
|
||||
"""Write current index to disk (called from debounce task)."""
|
||||
@@ -301,8 +429,14 @@ class IndexStore:
|
||||
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."""
|
||||
"""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:
|
||||
@@ -312,6 +446,10 @@ class IndexStore:
|
||||
if tmdb_id is not None:
|
||||
self._movie_tmdb_ids[tmdb_id] = item_id
|
||||
self._collapse_movie_tmdb_duplicates(tmdb_id, item_id)
|
||||
existing = self.movies.get(item_id)
|
||||
|
||||
if existing is not None and scanned is not None:
|
||||
item = self._merge_movie(existing, item, set(scanned))
|
||||
|
||||
if existing is not None:
|
||||
if msgspec.json.encode(existing) == msgspec.json.encode(item):
|
||||
@@ -335,8 +473,14 @@ class IndexStore:
|
||||
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."""
|
||||
"""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
|
||||
@@ -348,6 +492,10 @@ class IndexStore:
|
||||
if tmdb_id is not None:
|
||||
self._series_tmdb_ids[tmdb_id] = item_id
|
||||
self._collapse_series_tmdb_duplicates(tmdb_id, item_id)
|
||||
existing = self.series.get(item_id)
|
||||
|
||||
if existing is not None and scanned is not None:
|
||||
item = self._merge_series(existing, item, set(scanned))
|
||||
|
||||
if existing is not None:
|
||||
if msgspec.json.encode(existing) == msgspec.json.encode(item):
|
||||
@@ -368,39 +516,174 @@ class IndexStore:
|
||||
|
||||
def remove_movie(self, item_id: str) -> None:
|
||||
"""Remove a movie from the index and broadcast."""
|
||||
self.movies.pop(item_id, None)
|
||||
for tmdb_id, mapped_id in list(self._movie_tmdb_ids.items()):
|
||||
if mapped_id == item_id:
|
||||
self._movie_tmdb_ids.pop(tmdb_id, None)
|
||||
movie = self.movies.pop(item_id, None)
|
||||
if movie is None:
|
||||
return
|
||||
tmdb_id = self._get_tmdb_id(movie)
|
||||
if tmdb_id is not None and self._movie_tmdb_ids.get(tmdb_id) == item_id:
|
||||
self._movie_tmdb_ids.pop(tmdb_id, None)
|
||||
self._schedule_snapshot()
|
||||
self._broadcast(Remove(kind="movie", id=item_id))
|
||||
|
||||
def remove_series(self, item_id: str) -> None:
|
||||
"""Remove a series from the index and broadcast."""
|
||||
self.series.pop(item_id, None)
|
||||
for tmdb_id, mapped_id in list(self._series_tmdb_ids.items()):
|
||||
if mapped_id == item_id:
|
||||
self._series_tmdb_ids.pop(tmdb_id, None)
|
||||
series = self.series.pop(item_id, None)
|
||||
if series is None:
|
||||
return
|
||||
tmdb_id = self._get_tmdb_id(series)
|
||||
if tmdb_id is not None and self._series_tmdb_ids.get(tmdb_id) == item_id:
|
||||
self._series_tmdb_ids.pop(tmdb_id, None)
|
||||
self._schedule_snapshot()
|
||||
self._broadcast(Remove(kind="series", id=item_id))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scanner-driven maintenance
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def sync_torrent_paths(self, paths: set[str]) -> None:
|
||||
"""Drop file entries whose torrent path no longer exists on disk.
|
||||
|
||||
``paths`` is the complete set of media-root-relative torrent paths the
|
||||
scanner found during a fully completed discovery pass. Episodes and
|
||||
seasons left without files are dropped; items left without any files
|
||||
are removed entirely.
|
||||
"""
|
||||
for item_id, movie in list(self.movies.items()):
|
||||
kept = {k: v for k, v in movie.files.items() if k in paths}
|
||||
if len(kept) == len(movie.files):
|
||||
continue
|
||||
if not kept:
|
||||
self.remove_movie(item_id)
|
||||
continue
|
||||
updated = msgspec.structs.replace(
|
||||
movie, files=kept, newest=self._newest_from_files(kept)
|
||||
)
|
||||
self.movies[item_id] = updated
|
||||
self._schedule_snapshot()
|
||||
self._broadcast(Upsert(kind="movie", id=item_id, item=updated))
|
||||
|
||||
for item_id, series in list(self.series.items()):
|
||||
removed_any = False
|
||||
new_seasons: list[Season] = []
|
||||
for season in series.seasons:
|
||||
new_episodes: list[Episode] = []
|
||||
for ep in season.episodes:
|
||||
files = {k: v for k, v in ep.files.items() if k in paths}
|
||||
if len(files) < len(ep.files):
|
||||
removed_any = True
|
||||
if files:
|
||||
new_episodes.append(msgspec.structs.replace(ep, files=files))
|
||||
else:
|
||||
removed_any = True
|
||||
if not new_episodes:
|
||||
removed_any = True
|
||||
continue
|
||||
if len(new_episodes) < len(season.episodes):
|
||||
season = msgspec.structs.replace(
|
||||
season,
|
||||
episodes=new_episodes,
|
||||
episode_count=len(new_episodes),
|
||||
)
|
||||
new_seasons.append(season)
|
||||
if not removed_any:
|
||||
continue
|
||||
if not new_seasons:
|
||||
self.remove_series(item_id)
|
||||
continue
|
||||
updated_series = msgspec.structs.replace(series, seasons=new_seasons)
|
||||
self.series[item_id] = updated_series
|
||||
self._schedule_snapshot()
|
||||
self._broadcast(Upsert(kind="series", id=item_id, item=updated_series))
|
||||
|
||||
def set_movie_showreel(
|
||||
self,
|
||||
item_id: str,
|
||||
showreel_images: list[str] | None,
|
||||
showreel_source_sets: list[list[str]] | None,
|
||||
) -> None:
|
||||
"""Update only the showreel fields of a movie (reel worker callback)."""
|
||||
movie = self.movies.get(item_id)
|
||||
if movie is None:
|
||||
return
|
||||
updated = msgspec.structs.replace(
|
||||
movie,
|
||||
showreel_images=showreel_images,
|
||||
showreel_source_sets=showreel_source_sets,
|
||||
)
|
||||
if msgspec.json.encode(updated) == msgspec.json.encode(movie):
|
||||
return
|
||||
self.movies[item_id] = updated
|
||||
self._schedule_snapshot()
|
||||
self._broadcast(Upsert(kind="movie", id=item_id, item=updated))
|
||||
|
||||
def set_episode_reel(
|
||||
self,
|
||||
item_id: str,
|
||||
season_num: int,
|
||||
episode_num: int,
|
||||
reel_image: str | None,
|
||||
reel_sources: list[str] | None,
|
||||
) -> None:
|
||||
"""Update only the reel fields of one episode (reel worker callback)."""
|
||||
series = self.series.get(item_id)
|
||||
if series is None:
|
||||
return
|
||||
for season in series.seasons:
|
||||
if season.season_number != season_num:
|
||||
continue
|
||||
for ep in season.episodes:
|
||||
if ep.episode_number != episode_num:
|
||||
continue
|
||||
if ep.reel_image == reel_image and ep.reel_sources == reel_sources:
|
||||
return
|
||||
new_episodes = [
|
||||
msgspec.structs.replace(
|
||||
e, reel_image=reel_image, reel_sources=reel_sources
|
||||
)
|
||||
if e is ep
|
||||
else e
|
||||
for e in season.episodes
|
||||
]
|
||||
new_seasons = [
|
||||
msgspec.structs.replace(s, episodes=new_episodes)
|
||||
if s is season
|
||||
else s
|
||||
for s in series.seasons
|
||||
]
|
||||
updated = msgspec.structs.replace(series, seasons=new_seasons)
|
||||
self.series[item_id] = updated
|
||||
self._schedule_snapshot()
|
||||
self._broadcast(Upsert(kind="series", id=item_id, item=updated))
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WebSocket management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_listener(self, listener: Callable[[object], None]) -> None:
|
||||
"""Register a listener called for each broadcast message."""
|
||||
self._listeners.add(listener)
|
||||
|
||||
def remove_listener(self, listener: Callable[[object], None]) -> None:
|
||||
"""Unregister a previously registered broadcast listener."""
|
||||
self._listeners.discard(listener)
|
||||
|
||||
async def connect(self, ws: WebSocket) -> None:
|
||||
"""Accept a WS client and send the full index as init."""
|
||||
await ws.accept()
|
||||
self._clients.add(ws)
|
||||
logger.info("WS client connected (%d total)", len(self._clients))
|
||||
# Send full current state
|
||||
msg = WsInit(
|
||||
data=WsInitData(
|
||||
movies=dict(self.movies),
|
||||
series=dict(self.series),
|
||||
people=dict(self.people),
|
||||
)
|
||||
)
|
||||
msg = {
|
||||
"type": "init",
|
||||
"roots": {
|
||||
"": {
|
||||
"movies": dict(self.movies),
|
||||
"series": dict(self.series),
|
||||
"people": dict(self.people),
|
||||
}
|
||||
},
|
||||
}
|
||||
await ws.send_bytes(msgspec.json.encode(msg))
|
||||
|
||||
def disconnect(self, ws: WebSocket) -> None:
|
||||
@@ -410,6 +693,12 @@ class IndexStore:
|
||||
|
||||
def _broadcast(self, msg: object) -> None:
|
||||
"""Broadcast a message to all connected WS clients (non-blocking)."""
|
||||
for listener in tuple(self._listeners):
|
||||
try:
|
||||
listener(msg)
|
||||
except Exception:
|
||||
logger.exception("IndexStore listener failed")
|
||||
|
||||
data = msgspec.json.encode(msg)
|
||||
dead: list[WebSocket] = []
|
||||
for ws in self._clients:
|
||||
|
||||
@@ -14,12 +14,20 @@ from .tmdb import Person
|
||||
|
||||
|
||||
class Upsert(msgspec.Struct, tag="upsert"):
|
||||
"""Single item inserted or updated."""
|
||||
"""Single item inserted or updated.
|
||||
|
||||
``scanned`` lists the media-root-relative torrent paths whose content was
|
||||
(re)scanned to build this item. When present, the store merges the item
|
||||
into the existing entry instead of replacing it wholesale: only data
|
||||
belonging to the scanned torrents is replaced. ``None`` means full
|
||||
replacement (legacy behaviour).
|
||||
"""
|
||||
|
||||
kind: str # "movie" or "series"
|
||||
id: str
|
||||
item: Movie | Series
|
||||
people: dict[int, Person] | None = None
|
||||
scanned: list[str] | None = None
|
||||
|
||||
|
||||
class Remove(msgspec.Struct, tag="remove"):
|
||||
@@ -29,6 +37,35 @@ class Remove(msgspec.Struct, tag="remove"):
|
||||
id: str
|
||||
|
||||
|
||||
class Sync(msgspec.Struct, tag="sync"):
|
||||
"""Full set of media-root-relative torrent paths currently on disk.
|
||||
|
||||
Sent by the scanner after a successfully completed discovery pass so the
|
||||
store can drop entries whose files no longer exist. Internal only —
|
||||
never forwarded to WebSocket clients.
|
||||
"""
|
||||
|
||||
paths: list[str]
|
||||
|
||||
|
||||
class MovieShowreel(msgspec.Struct, tag="movie-showreel"):
|
||||
"""Reel worker result for a movie (internal, scanner → store)."""
|
||||
|
||||
id: str
|
||||
showreel_images: list[str] | None = None
|
||||
showreel_source_sets: list[list[str]] | None = None
|
||||
|
||||
|
||||
class EpisodeReel(msgspec.Struct, tag="episode-reel"):
|
||||
"""Reel worker result for one episode (internal, scanner → store)."""
|
||||
|
||||
id: str
|
||||
season: int
|
||||
episode: int
|
||||
reel_image: str | None = None
|
||||
reel_sources: list[str] | None = None
|
||||
|
||||
|
||||
class Task(msgspec.Struct, tag="task"):
|
||||
"""Task progress broadcast."""
|
||||
|
||||
@@ -36,4 +73,4 @@ class Task(msgspec.Struct, tag="task"):
|
||||
|
||||
|
||||
# Union of scan events (scanner → server) and WS broadcast messages
|
||||
ScanEvent = Upsert | Task
|
||||
ScanEvent = Upsert | Sync | MovieShowreel | EpisodeReel | Task
|
||||
|
||||
@@ -8,8 +8,8 @@ from __future__ import annotations
|
||||
import msgspec
|
||||
from fastapi.responses import Response
|
||||
|
||||
from .data import Movie, Series
|
||||
from .events import Remove, ScanEvent, Task, Upsert
|
||||
from .data import Movie, Series, TaskInfo
|
||||
from .events import ScanEvent
|
||||
from .tmdb import Person
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -17,33 +17,78 @@ from .tmdb import Person
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WsInitData(msgspec.Struct):
|
||||
"""Payload of the init message."""
|
||||
class WsRootStatus(msgspec.Struct):
|
||||
"""Current status for one configured root."""
|
||||
|
||||
root_id: str
|
||||
path: str
|
||||
status: str
|
||||
error: str | None = None
|
||||
snapshot_loaded: bool = False
|
||||
movies: int = 0
|
||||
series: int = 0
|
||||
|
||||
|
||||
class WsRootInitData(msgspec.Struct):
|
||||
"""Initial full index payload for one root."""
|
||||
|
||||
movies: dict[str, Movie]
|
||||
series: dict[str, Series]
|
||||
people: dict[int, Person]
|
||||
|
||||
|
||||
class WsInit(msgspec.Struct, tag="init"):
|
||||
"""Full index sent on WS connect."""
|
||||
class WsRoots(msgspec.Struct, tag="roots"):
|
||||
"""Root list and status update."""
|
||||
|
||||
data: WsInitData
|
||||
roots: list[WsRootStatus]
|
||||
|
||||
|
||||
class WsInit(msgspec.Struct, tag="init"):
|
||||
"""Full index payload keyed by root_id."""
|
||||
|
||||
roots: dict[str, WsRootInitData]
|
||||
|
||||
|
||||
class WsUpsert(msgspec.Struct, tag="upsert"):
|
||||
"""Single item inserted or updated for one root."""
|
||||
|
||||
root_id: str
|
||||
kind: str # "movie" or "series"
|
||||
id: str
|
||||
item: Movie | Series
|
||||
people: dict[int, Person] | None = None
|
||||
|
||||
|
||||
class WsRemove(msgspec.Struct, tag="remove"):
|
||||
"""Single item removed for one root."""
|
||||
|
||||
root_id: str
|
||||
kind: str
|
||||
id: str
|
||||
|
||||
|
||||
class WsTask(msgspec.Struct, tag="task"):
|
||||
"""Task progress update for one root."""
|
||||
|
||||
root_id: str
|
||||
data: TaskInfo
|
||||
|
||||
|
||||
# Union of all outbound WS messages (for documentation / future decoding)
|
||||
WsMessage = WsInit | Upsert | Remove | Task
|
||||
WsMessage = WsRoots | WsInit | WsUpsert | WsRemove | WsTask
|
||||
|
||||
|
||||
# Re-export unified types for backward compatibility
|
||||
__all__ = [
|
||||
"Remove",
|
||||
"ScanEvent",
|
||||
"Task",
|
||||
"Upsert",
|
||||
"WsInit",
|
||||
"WsInitData",
|
||||
"WsMessage",
|
||||
"WsRemove",
|
||||
"WsRootInitData",
|
||||
"WsRootStatus",
|
||||
"WsRoots",
|
||||
"WsTask",
|
||||
"WsUpsert",
|
||||
]
|
||||
|
||||
|
||||
@@ -67,11 +112,19 @@ class OpenFolderRequest(msgspec.Struct):
|
||||
|
||||
|
||||
class RootsRequest(msgspec.Struct):
|
||||
"""PUT /api/roots body."""
|
||||
"""PUT /api/config/roots body."""
|
||||
|
||||
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."""
|
||||
|
||||
@@ -80,7 +133,7 @@ class RootEntryResponse(msgspec.Struct):
|
||||
|
||||
|
||||
class RootStatusResponse(msgspec.Struct):
|
||||
"""Per-root status in GET /api/roots."""
|
||||
"""Legacy per-root status shape kept for non-WS callers."""
|
||||
|
||||
root_id: str
|
||||
path: str
|
||||
|
||||
@@ -27,13 +27,6 @@ class Person(msgspec.Struct, array_like=True):
|
||||
gender: str | None = None
|
||||
|
||||
|
||||
class SimilarMedia(msgspec.Struct, array_like=True):
|
||||
"""Pointer to a similar movie/series on TMDb."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TMDb result types
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -72,6 +65,7 @@ class Info(msgspec.Struct):
|
||||
tmdb_id: int
|
||||
title: str | None = None
|
||||
original_title: str | None = None
|
||||
original_language: str | None = None
|
||||
alternative_titles: list[str] | None = None
|
||||
rating: float | None = None
|
||||
vote_count: int | None = None
|
||||
@@ -79,9 +73,9 @@ class Info(msgspec.Struct):
|
||||
genres: list[str] | None = None
|
||||
release_date: str | None = None
|
||||
runtime: int | None = None
|
||||
collection: str | None = None
|
||||
status: str | None = None
|
||||
tagline: str | None = None
|
||||
similar: list[SimilarMedia] | None = None
|
||||
keywords: list[str] | None = None
|
||||
cast: list[CastCredit] | None = None
|
||||
director: str | None = None
|
||||
|
||||
@@ -11,7 +11,14 @@ import msgspec
|
||||
|
||||
from mediahive.config import load_config, save_config
|
||||
from mediahive.index_store import IndexStore
|
||||
from mediahive.models.events import ScanEvent, Task, Upsert
|
||||
from mediahive.models.events import (
|
||||
EpisodeReel,
|
||||
MovieShowreel,
|
||||
ScanEvent,
|
||||
Sync,
|
||||
Task,
|
||||
Upsert,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mediahive.root_registry")
|
||||
|
||||
@@ -158,9 +165,27 @@ class RootContext:
|
||||
event = await self._events.get()
|
||||
if isinstance(event, Upsert):
|
||||
if event.kind == "movie":
|
||||
self.store.upsert_movie(event.id, event.item, event.people)
|
||||
self.store.upsert_movie(
|
||||
event.id, event.item, event.people, event.scanned
|
||||
)
|
||||
else:
|
||||
self.store.upsert_series(event.id, event.item, event.people)
|
||||
self.store.upsert_series(
|
||||
event.id, event.item, event.people, event.scanned
|
||||
)
|
||||
elif isinstance(event, Sync):
|
||||
self.store.sync_torrent_paths(set(event.paths))
|
||||
elif isinstance(event, MovieShowreel):
|
||||
self.store.set_movie_showreel(
|
||||
event.id, event.showreel_images, event.showreel_source_sets
|
||||
)
|
||||
elif isinstance(event, EpisodeReel):
|
||||
self.store.set_episode_reel(
|
||||
event.id,
|
||||
event.season,
|
||||
event.episode,
|
||||
event.reel_image,
|
||||
event.reel_sources,
|
||||
)
|
||||
elif isinstance(event, Task):
|
||||
self.store.broadcast_task(event.data)
|
||||
except asyncio.CancelledError:
|
||||
@@ -280,7 +305,7 @@ class Supervisor:
|
||||
base_name = _derive_root_name(configured_path)
|
||||
unique_name = base_name
|
||||
suffix = 2
|
||||
existing_names = {e.name for e in candidates}
|
||||
existing_names = {e.root_id for e in candidates}
|
||||
while unique_name in existing_names:
|
||||
unique_name = f"{base_name}{suffix}"
|
||||
suffix += 1
|
||||
@@ -328,6 +353,10 @@ class Supervisor:
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
async with self._lock:
|
||||
for ctx in list(self._contexts.values()):
|
||||
await ctx.stop()
|
||||
# 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()
|
||||
|
||||
+1011
-58
File diff suppressed because it is too large
Load Diff
+471
-82
@@ -9,6 +9,7 @@ import asyncio
|
||||
import contextlib
|
||||
import ctypes
|
||||
import html
|
||||
import importlib.metadata
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -25,9 +26,13 @@ from pathlib import Path
|
||||
|
||||
import msgspec.structs
|
||||
import uvicorn
|
||||
import velopack
|
||||
import webview
|
||||
from fastapi_vue.logging import patch_log_config
|
||||
from fastapi_vue.startupbox import print_box
|
||||
from tracerite.html import html_traceback
|
||||
|
||||
from mediahive.config import load_config, save_config
|
||||
from mediahive.config import load_config, log_dir, save_config
|
||||
from mediahive.volume_control import get_volume, set_volume, volume_max
|
||||
|
||||
logger = logging.getLogger("mediahive.winmain")
|
||||
@@ -38,6 +43,7 @@ HEALTH_TIMEOUT = 2 # seconds
|
||||
BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds
|
||||
BACKEND_HEALTH_POLL_SECONDS = 0.25
|
||||
MPC_BE_URL = "http://127.0.0.1:13579"
|
||||
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
|
||||
GAMEPAD_REPEAT_SECONDS = 0.008
|
||||
GAMEPAD_POLL_SECONDS = 0.008
|
||||
MPC_BE_FRAME_REPEAT_SECONDS = 0.016
|
||||
@@ -53,6 +59,9 @@ MPC_BE_STATE_RUNNING = 2
|
||||
MPC_BE_SEEK_BEGIN_COMMAND = 1085
|
||||
MPC_BE_RESUME_APPLY_THRESHOLD_MS = 15000
|
||||
MPC_BE_RESUME_CLEAR_MARGIN_MS = 15000
|
||||
# Watching (or presumably watching) less than this leaves no position data:
|
||||
# brief peeks and seeks back to re-view a scene are not true progress.
|
||||
MPC_BE_RESUME_MIN_WATCH_MS = 5 * 60 * 1000
|
||||
MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS = 1.0
|
||||
VOLUME_MIN = 0.0
|
||||
VOLUME_MAX = 1.5
|
||||
@@ -120,59 +129,218 @@ _DURATION_RE = re.compile(r'<p id="duration">(\d+)</p>')
|
||||
def _default_playback_state() -> dict[str, object]:
|
||||
return {
|
||||
"current": None,
|
||||
"resume_positions": {},
|
||||
}
|
||||
|
||||
|
||||
def _load_playback_state(path: Path) -> dict[str, object]:
|
||||
def _normalize_media_path(path: str) -> str:
|
||||
return path.replace("\\", "/").lstrip("/")
|
||||
|
||||
|
||||
def _expand_playable_file(file_key: str, playable_file: str | None) -> str:
|
||||
if not playable_file:
|
||||
return file_key
|
||||
if playable_file.startswith("concat:") or "://" in playable_file:
|
||||
return playable_file
|
||||
if playable_file.startswith(f"{file_key}/"):
|
||||
return playable_file
|
||||
if playable_file.startswith("/"):
|
||||
return playable_file.lstrip("/")
|
||||
return f"{file_key}/{playable_file}"
|
||||
|
||||
|
||||
def _fetch_resume_positions(
|
||||
backend_url: str,
|
||||
) -> tuple[
|
||||
dict[str, tuple[int, int | None, int | None]],
|
||||
dict[tuple[str, int, int], int],
|
||||
]:
|
||||
"""Fetch resume state from the backend.
|
||||
|
||||
Returns (continue_points, episode_positions): continue_points map a slug
|
||||
to (pos_ms, season_number, episode_number) — season/episode set for the
|
||||
series' single continue point, None for movies. episode_positions map
|
||||
(slug, season, episode) to pos_ms for partially watched episodes;
|
||||
fully watched episodes are absent.
|
||||
"""
|
||||
req = urllib.request.Request(
|
||||
url=f"{backend_url}/api/meta/playback-state",
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
with urllib.request.urlopen(req, timeout=2) as resp:
|
||||
raw = json.loads(resp.read().decode("utf-8"))
|
||||
except OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError:
|
||||
return {}, {}
|
||||
|
||||
data = raw.get("data") if isinstance(raw, dict) else None
|
||||
positions = data.get("resume_positions") if isinstance(data, dict) else None
|
||||
if not isinstance(positions, dict):
|
||||
return {}, {}
|
||||
|
||||
cleaned: dict[str, tuple[int, int | None, int | None]] = {}
|
||||
episode_positions: dict[tuple[str, int, int], int] = {}
|
||||
for slug, value in positions.items():
|
||||
if not isinstance(slug, str) or not isinstance(value, dict):
|
||||
continue
|
||||
pos = value.get("pos")
|
||||
season = value.get("season")
|
||||
episode = value.get("episode")
|
||||
if isinstance(pos, int) and pos > 0:
|
||||
cleaned[slug] = (
|
||||
pos * 1000,
|
||||
season if isinstance(season, int) else None,
|
||||
episode if isinstance(episode, int) else None,
|
||||
)
|
||||
elif pos == 0 and isinstance(season, int) and isinstance(episode, int):
|
||||
# Series episode boundary marker (previous episode completed).
|
||||
cleaned[slug] = (0, season, episode)
|
||||
|
||||
episodes = value.get("episodes")
|
||||
if not isinstance(episodes, dict):
|
||||
continue
|
||||
for key, watch in episodes.items():
|
||||
match = re.fullmatch(r"S(\d+)E(\d+)", str(key))
|
||||
if not match or not isinstance(watch, dict):
|
||||
continue
|
||||
ep_pos = watch.get("pos")
|
||||
if watch.get("done") or not isinstance(ep_pos, int) or ep_pos <= 0:
|
||||
continue
|
||||
episode_positions[slug, int(match.group(1)), int(match.group(2))] = (
|
||||
ep_pos * 1000
|
||||
)
|
||||
return cleaned, episode_positions
|
||||
|
||||
|
||||
def _post_resume_position(
|
||||
backend_url: str,
|
||||
root_id: str,
|
||||
file_path: str,
|
||||
pos: int | None,
|
||||
) -> bool:
|
||||
body = json.dumps({
|
||||
"root_id": root_id,
|
||||
"file_path": file_path,
|
||||
"pos": pos,
|
||||
}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url=f"{backend_url}/api/meta/playback-state",
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=2):
|
||||
return True
|
||||
except OSError, TimeoutError, urllib.error.URLError:
|
||||
logger.warning("Failed to post playback-state update for %s", file_path)
|
||||
return False
|
||||
|
||||
|
||||
def _media_file_key(
|
||||
mapping: dict[str, str], file_key: str, torrent: object, media_key: str
|
||||
) -> None:
|
||||
"""Map both the raw file key and its expanded playable path to a media key."""
|
||||
mapping[_normalize_media_path(file_key)] = media_key
|
||||
playable_file = torrent.get("playable_file") if isinstance(torrent, dict) else None
|
||||
expanded = _expand_playable_file(
|
||||
file_key, playable_file if isinstance(playable_file, str) else None
|
||||
)
|
||||
mapping[_normalize_media_path(expanded)] = media_key
|
||||
|
||||
|
||||
def _split_media_key(media_key: str) -> tuple[str, int | None, int | None]:
|
||||
"""Split a media key into (slug, season_number, episode_number)."""
|
||||
slug, separator, ep_ref = media_key.partition("#")
|
||||
if not separator:
|
||||
return slug, None, None
|
||||
match = re.fullmatch(r"S(\d+)E(\d+)", ep_ref)
|
||||
if not match:
|
||||
return slug, None, None
|
||||
return slug, int(match.group(1)), int(match.group(2))
|
||||
|
||||
|
||||
def _load_media_key_map(index_path: Path) -> dict[str, str]:
|
||||
"""Map normalized playable file paths to media keys.
|
||||
|
||||
Movies map to their movie id; series episodes map to
|
||||
"<series_id>#S<season>E<episode>" so episode switches are detected while
|
||||
the backend keeps a single continue point per series.
|
||||
"""
|
||||
try:
|
||||
raw = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except OSError, TypeError, json.JSONDecodeError:
|
||||
return _default_playback_state()
|
||||
return {}
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
return _default_playback_state()
|
||||
return {}
|
||||
|
||||
current = raw.get("current")
|
||||
resume_positions = raw.get("resume_positions")
|
||||
normalized: dict[str, object] = {
|
||||
"current": current if isinstance(current, dict) else None,
|
||||
"resume_positions": {},
|
||||
}
|
||||
mapping: dict[str, str] = {}
|
||||
|
||||
if isinstance(resume_positions, dict):
|
||||
cleaned_positions: dict[str, int] = {}
|
||||
for key, value in resume_positions.items():
|
||||
if isinstance(key, str) and isinstance(value, (int, float)):
|
||||
cleaned_positions[key] = max(0, int(value))
|
||||
normalized["resume_positions"] = cleaned_positions
|
||||
movies = raw.get("movies")
|
||||
if isinstance(movies, dict):
|
||||
for movie_id, movie in movies.items():
|
||||
if not isinstance(movie_id, str) or not isinstance(movie, dict):
|
||||
continue
|
||||
files = movie.get("files")
|
||||
if not isinstance(files, dict):
|
||||
continue
|
||||
for file_key, torrent in files.items():
|
||||
if not isinstance(file_key, str):
|
||||
continue
|
||||
_media_file_key(mapping, file_key, torrent, movie_id)
|
||||
|
||||
return normalized
|
||||
series = raw.get("series")
|
||||
if isinstance(series, dict):
|
||||
for series_id, show in series.items():
|
||||
if not isinstance(series_id, str) or not isinstance(show, dict):
|
||||
continue
|
||||
seasons = show.get("seasons")
|
||||
if not isinstance(seasons, list):
|
||||
continue
|
||||
for season in seasons:
|
||||
if not isinstance(season, dict):
|
||||
continue
|
||||
season_number = season.get("season_number")
|
||||
episodes = season.get("episodes")
|
||||
if not isinstance(season_number, int) or not isinstance(episodes, list):
|
||||
continue
|
||||
for episode in episodes:
|
||||
if not isinstance(episode, dict):
|
||||
continue
|
||||
episode_number = episode.get("episode_number")
|
||||
files = episode.get("files")
|
||||
if not isinstance(episode_number, int) or not isinstance(
|
||||
files, dict
|
||||
):
|
||||
continue
|
||||
media_key = f"{series_id}#S{season_number}E{episode_number}"
|
||||
for file_key, torrent in files.items():
|
||||
if not isinstance(file_key, str):
|
||||
continue
|
||||
_media_file_key(mapping, file_key, torrent, media_key)
|
||||
|
||||
|
||||
def _save_playback_state(path: Path, state: dict[str, object]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
|
||||
tmp_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
|
||||
tmp_path.replace(path)
|
||||
return mapping
|
||||
|
||||
|
||||
def _media_key_for_filepath(
|
||||
filepath: str, roots: list[Path]
|
||||
) -> tuple[str, Path] | None:
|
||||
"""Resolve a filepath to a (relative_key, matched_root) tuple."""
|
||||
for root in roots:
|
||||
filepath: str, roots: dict[str, Path]
|
||||
) -> tuple[str | None, str, str] | None:
|
||||
"""Resolve a filepath to a (media_key, root_id, relative_key) tuple."""
|
||||
for root_id, root in roots.items():
|
||||
try:
|
||||
relative = Path(filepath).resolve().relative_to(root.resolve())
|
||||
return relative.as_posix(), root
|
||||
relative_key = relative.as_posix()
|
||||
index_path = root / ".mediahive" / "index.json"
|
||||
media_key = _load_media_key_map(index_path).get(
|
||||
_normalize_media_path(relative_key)
|
||||
)
|
||||
return media_key, root_id, relative_key
|
||||
except OSError, RuntimeError, ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _should_clear_resume(position_ms: int, duration_ms: int) -> bool:
|
||||
if position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS:
|
||||
return True
|
||||
if duration_ms <= 0:
|
||||
return False
|
||||
return duration_ms - position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS
|
||||
@@ -248,7 +416,7 @@ def _mpcbe_fetch_status() -> tuple[str, int, int, int] | None:
|
||||
|
||||
|
||||
def _start_gamepad_remote(
|
||||
stop_event: threading.Event, roots: list[Path]
|
||||
stop_event: threading.Event, roots: dict[str, Path], backend_url: str
|
||||
) -> threading.Thread:
|
||||
"""Start background XInput polling and send mapped commands to MPC-BE."""
|
||||
get_state = _load_xinput_get_state()
|
||||
@@ -278,18 +446,11 @@ def _start_gamepad_remote(
|
||||
status_updated_at = 0.0
|
||||
status_miss_count = 0
|
||||
|
||||
# Use the first root's playback state path as primary
|
||||
primary_root = roots[0] if roots else Path.cwd()
|
||||
playback_state_path = primary_root / ".mediahive" / "playback-state.json"
|
||||
playback_state = _load_playback_state(playback_state_path)
|
||||
resume_positions = playback_state["resume_positions"]
|
||||
if not isinstance(resume_positions, dict):
|
||||
resume_positions = {}
|
||||
playback_state["resume_positions"] = resume_positions
|
||||
if playback_state.get("current") is not None:
|
||||
playback_state["current"] = None
|
||||
_save_playback_state(playback_state_path, playback_state)
|
||||
playback_state = _default_playback_state()
|
||||
resume_positions, episode_positions = _fetch_resume_positions(backend_url)
|
||||
tracked_media_key: str | None = None
|
||||
tracked_root_id: str | None = None
|
||||
tracked_relative_path = ""
|
||||
tracked_filepath = ""
|
||||
resume_applied_for_key: str | None = None
|
||||
last_playback_state_flush_at = 0.0
|
||||
@@ -302,12 +463,11 @@ def _start_gamepad_remote(
|
||||
request_pool.submit(_seek_mpcbe_to_position, position_ms)
|
||||
)
|
||||
|
||||
def flush_playback_state() -> None:
|
||||
_save_playback_state(playback_state_path, playback_state)
|
||||
|
||||
def clear_tracked_current(*, clear_resume_applied: bool) -> None:
|
||||
nonlocal \
|
||||
tracked_media_key, \
|
||||
tracked_root_id, \
|
||||
tracked_relative_path, \
|
||||
tracked_filepath, \
|
||||
last_playback_state_flush_at, \
|
||||
resume_applied_for_key
|
||||
@@ -316,38 +476,72 @@ def _start_gamepad_remote(
|
||||
resume_applied_for_key = None
|
||||
return
|
||||
tracked_media_key = None
|
||||
tracked_root_id = None
|
||||
tracked_relative_path = ""
|
||||
tracked_filepath = ""
|
||||
playback_state["current"] = None
|
||||
last_playback_state_flush_at = 0.0
|
||||
if clear_resume_applied:
|
||||
resume_applied_for_key = None
|
||||
flush_playback_state()
|
||||
|
||||
def finalize_tracked_current() -> None:
|
||||
nonlocal \
|
||||
tracked_media_key, \
|
||||
tracked_root_id, \
|
||||
tracked_relative_path, \
|
||||
tracked_filepath, \
|
||||
resume_applied_for_key, \
|
||||
last_playback_state_flush_at
|
||||
if tracked_media_key is None:
|
||||
if playback_state.get("current") is not None:
|
||||
playback_state["current"] = None
|
||||
flush_playback_state()
|
||||
return
|
||||
|
||||
position_ms = player_position_ms or 0
|
||||
duration_ms = player_duration_ms or 0
|
||||
tracked_slug, tracked_season, tracked_episode = _split_media_key(
|
||||
tracked_media_key
|
||||
)
|
||||
if _should_clear_resume(position_ms, duration_ms):
|
||||
resume_positions.pop(tracked_media_key, None)
|
||||
resume_positions.pop(tracked_slug, None)
|
||||
if tracked_season is not None and tracked_episode is not None:
|
||||
episode_positions.pop(
|
||||
(tracked_slug, tracked_season, tracked_episode), None
|
||||
)
|
||||
if tracked_root_id and tracked_relative_path:
|
||||
_post_resume_position(
|
||||
backend_url, tracked_root_id, tracked_relative_path, None
|
||||
)
|
||||
elif position_ms < MPC_BE_RESUME_MIN_WATCH_MS:
|
||||
# Peeks and brief seeks are not true progress; keep the previous
|
||||
# saved resume position.
|
||||
pass
|
||||
else:
|
||||
resume_positions[tracked_media_key] = position_ms
|
||||
position_seconds = max(0, position_ms // 1000)
|
||||
resume_positions[tracked_slug] = (
|
||||
position_ms,
|
||||
tracked_season,
|
||||
tracked_episode,
|
||||
)
|
||||
if tracked_season is not None and tracked_episode is not None:
|
||||
episode_positions[tracked_slug, tracked_season, tracked_episode] = (
|
||||
position_ms
|
||||
)
|
||||
if tracked_root_id and tracked_relative_path:
|
||||
_post_resume_position(
|
||||
backend_url,
|
||||
tracked_root_id,
|
||||
tracked_relative_path,
|
||||
position_seconds,
|
||||
)
|
||||
|
||||
tracked_media_key = None
|
||||
tracked_root_id = None
|
||||
tracked_relative_path = ""
|
||||
tracked_filepath = ""
|
||||
playback_state["current"] = None
|
||||
resume_applied_for_key = None
|
||||
last_playback_state_flush_at = 0.0
|
||||
flush_playback_state()
|
||||
|
||||
def persist_tracked_current(now: float, *, force: bool = False) -> None:
|
||||
nonlocal last_playback_state_flush_at
|
||||
@@ -367,7 +561,6 @@ def _start_gamepad_remote(
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
last_playback_state_flush_at = now
|
||||
flush_playback_state()
|
||||
|
||||
def maybe_apply_resume(now: float) -> None:
|
||||
nonlocal player_position_ms, resume_applied_for_key
|
||||
@@ -376,8 +569,36 @@ def _start_gamepad_remote(
|
||||
if resume_applied_for_key == tracked_media_key:
|
||||
return
|
||||
|
||||
saved_position = resume_positions.get(tracked_media_key)
|
||||
if not isinstance(saved_position, int):
|
||||
tracked_slug, tracked_season, tracked_episode = _split_media_key(
|
||||
tracked_media_key
|
||||
)
|
||||
if tracked_season is not None and tracked_episode is not None:
|
||||
# Series: the episode's own saved position wins; fall back to the
|
||||
# series continue point when it points at this very episode.
|
||||
saved_position = episode_positions.get((
|
||||
tracked_slug,
|
||||
tracked_season,
|
||||
tracked_episode,
|
||||
))
|
||||
if saved_position is None:
|
||||
saved = resume_positions.get(tracked_slug)
|
||||
if saved is None or (saved[1], saved[2]) != (
|
||||
tracked_season,
|
||||
tracked_episode,
|
||||
):
|
||||
# The series continue point belongs to a different episode.
|
||||
resume_applied_for_key = tracked_media_key
|
||||
return
|
||||
saved_position = saved[0]
|
||||
else:
|
||||
saved = resume_positions.get(tracked_slug)
|
||||
if saved is None:
|
||||
resume_applied_for_key = tracked_media_key
|
||||
return
|
||||
saved_position = saved[0]
|
||||
|
||||
if saved_position <= 0:
|
||||
# Episode boundary marker (previous episode completed): start at 0.
|
||||
resume_applied_for_key = tracked_media_key
|
||||
return
|
||||
if player_position_ms is None or player_duration_ms is None:
|
||||
@@ -386,9 +607,12 @@ def _start_gamepad_remote(
|
||||
resume_applied_for_key = tracked_media_key
|
||||
return
|
||||
if _should_clear_resume(saved_position, player_duration_ms):
|
||||
resume_positions.pop(tracked_media_key, None)
|
||||
resume_positions.pop(tracked_slug, None)
|
||||
if tracked_season is not None and tracked_episode is not None:
|
||||
episode_positions.pop(
|
||||
(tracked_slug, tracked_season, tracked_episode), None
|
||||
)
|
||||
resume_applied_for_key = tracked_media_key
|
||||
flush_playback_state()
|
||||
return
|
||||
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
|
||||
return
|
||||
@@ -409,6 +633,8 @@ def _start_gamepad_remote(
|
||||
status_updated_at, \
|
||||
status_miss_count, \
|
||||
tracked_media_key, \
|
||||
tracked_root_id, \
|
||||
tracked_relative_path, \
|
||||
tracked_filepath, \
|
||||
resume_applied_for_key
|
||||
if status_future is None or not status_future.done():
|
||||
@@ -437,6 +663,8 @@ def _start_gamepad_remote(
|
||||
filepath, position_ms, duration_ms, state = status
|
||||
resolved = _media_key_for_filepath(filepath, roots) if filepath else None
|
||||
media_key = resolved[0] if resolved else None
|
||||
root_id = resolved[1] if resolved else None
|
||||
relative_path = resolved[2] if resolved else ""
|
||||
|
||||
if tracked_media_key is not None and media_key != tracked_media_key:
|
||||
finalize_tracked_current()
|
||||
@@ -445,6 +673,8 @@ def _start_gamepad_remote(
|
||||
clear_tracked_current(clear_resume_applied=True)
|
||||
elif tracked_media_key != media_key:
|
||||
tracked_media_key = media_key
|
||||
tracked_root_id = root_id
|
||||
tracked_relative_path = relative_path
|
||||
tracked_filepath = filepath
|
||||
resume_applied_for_key = None
|
||||
|
||||
@@ -605,33 +835,82 @@ def _start_gamepad_remote(
|
||||
return thread
|
||||
|
||||
|
||||
def _rotate_and_open_log(log_path: Path):
|
||||
"""Rotate mediahive.log to .log.1 and open a fresh log file.
|
||||
|
||||
Raises OSError when a previous MediaHive instance still holds the file
|
||||
open (Windows forbids renaming a file that is open without delete
|
||||
sharing) — callers treat that as "previous instance not dead yet".
|
||||
"""
|
||||
prev = log_path.with_suffix(".log.1")
|
||||
if log_path.exists():
|
||||
if prev.exists():
|
||||
prev.unlink()
|
||||
log_path.rename(prev)
|
||||
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
|
||||
return os.fdopen(fd, "w", encoding="utf-8", buffering=1) # line-buffered
|
||||
|
||||
|
||||
def _wait_for_previous_instance(log_path: Path, timeout: float = 15.0):
|
||||
"""Show a waiting notice while a previous MediaHive instance exits.
|
||||
|
||||
Returns an open log file handle, or None on timeout.
|
||||
"""
|
||||
result: list = []
|
||||
window = webview.create_window(
|
||||
"MediaHive", html=_WAIT_HTML, width=520, height=280, resizable=False
|
||||
)
|
||||
|
||||
def poll() -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
result.append(_rotate_and_open_log(log_path))
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.5)
|
||||
window.destroy()
|
||||
|
||||
webview.start(func=poll, icon=_icon_path(), **_webview_start_kwargs())
|
||||
return result[0] if result else None
|
||||
|
||||
|
||||
def _setup_logging() -> Path:
|
||||
"""Redirect stdout/stderr and configure logging to a file in %APPDATA%/mediahive/.
|
||||
"""Redirect stdout/stderr and configure logging to a file in the platform log dir.
|
||||
|
||||
In a PyInstaller --windowed build there is no console, so any print() or
|
||||
unhandled exception traceback would be lost. This ensures everything ends
|
||||
up in a persistent log file the user can send for bug reports.
|
||||
Returns the path to the log file.
|
||||
"""
|
||||
from mediahive.config import config_dir
|
||||
log_directory = log_dir()
|
||||
log_directory.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_directory / "mediahive.log"
|
||||
|
||||
log_dir = config_dir()
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / "mediahive.log"
|
||||
try:
|
||||
log_file = _rotate_and_open_log(log_path)
|
||||
except OSError:
|
||||
# A previous instance still holds the log file. It is usually on its
|
||||
# way out — give it a couple of seconds silently first.
|
||||
log_file = None
|
||||
deadline = time.monotonic() + 2.0
|
||||
while log_file is None and time.monotonic() < deadline:
|
||||
time.sleep(0.25)
|
||||
with contextlib.suppress(OSError):
|
||||
log_file = _rotate_and_open_log(log_path)
|
||||
if log_file is None:
|
||||
log_file = _wait_for_previous_instance(log_path)
|
||||
if log_file is None:
|
||||
# Never fail startup over logging: fall back to a per-process file.
|
||||
log_path = log_directory / f"mediahive-{os.getpid()}.log"
|
||||
with contextlib.suppress(OSError):
|
||||
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
|
||||
log_file = os.fdopen(fd, "w", encoding="utf-8", buffering=1)
|
||||
|
||||
# Rotate: keep previous run as .log.1
|
||||
prev = log_path.with_suffix(".log.1")
|
||||
if log_path.exists():
|
||||
if prev.exists():
|
||||
prev.unlink()
|
||||
log_path.rename(prev)
|
||||
|
||||
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
|
||||
log_file = os.fdopen(fd, "w", encoding="utf-8", buffering=1) # line-buffered
|
||||
|
||||
# Redirect raw stdout/stderr so print() and tracebacks go to the file
|
||||
sys.stdout = log_file
|
||||
sys.stderr = log_file
|
||||
if log_file is not None:
|
||||
# Redirect raw stdout/stderr so print() and tracebacks go to the file
|
||||
sys.stdout = log_file
|
||||
sys.stderr = log_file
|
||||
|
||||
# force=True removes handlers added by uvicorn/fastapi during import so that
|
||||
# basicConfig actually takes effect (without it, it's a silent no-op)
|
||||
@@ -660,6 +939,81 @@ _SETUP_HTML = """<!DOCTYPE html>
|
||||
<div><h1>MediaHive</h1><p>Choose a folder that contains your media…</p></div>
|
||||
</body></html>"""
|
||||
|
||||
# Shown when a previous instance is still shutting down.
|
||||
_WAIT_HTML = """<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #141414; color: #fff;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100vh; text-align: center; }
|
||||
h1 { font-size: 2rem; color: #e50914; margin-bottom: .5rem; }
|
||||
p { color: #aaa; }
|
||||
</style></head><body>
|
||||
<div><h1>MediaHive</h1>
|
||||
<p>Waiting for the previous MediaHive instance to finish exiting…</p></div>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _show_fatal_error(exc: BaseException) -> None:
|
||||
"""Show an unhandled exception as a TraceRite HTML page in a webview.
|
||||
|
||||
Frozen --windowed builds otherwise surface crashes only as PyInstaller's
|
||||
plain-text error dialog (or nothing at all).
|
||||
"""
|
||||
fragment = str(html_traceback(exc))
|
||||
page = (
|
||||
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
||||
"<title>MediaHive — Error</title></head>"
|
||||
f"<body style='margin:1.5rem'>{fragment}</body></html>"
|
||||
)
|
||||
webview.create_window("MediaHive — Error", html=page, width=1100, height=750)
|
||||
webview.start(icon=_icon_path(), **_webview_start_kwargs())
|
||||
|
||||
|
||||
def _velopack_startup() -> None:
|
||||
"""Handle Velopack install/update/uninstall hooks and pending updates.
|
||||
|
||||
Must be the first thing at startup: when Velopack launches the app with
|
||||
--veloapp-* hook arguments (during install/update/uninstall), run()
|
||||
executes the hook and exits the process, so the GUI never starts.
|
||||
Applies downloaded-but-pending updates. No-op in development and
|
||||
portable-ZIP runs.
|
||||
"""
|
||||
velopack.App().run()
|
||||
|
||||
|
||||
def _check_for_updates() -> None:
|
||||
"""Download available updates in the background.
|
||||
|
||||
Downloaded updates are applied automatically by Velopack on the next app
|
||||
start (via _velopack_startup), so the running session is never
|
||||
interrupted. Not a Velopack install (dev/portable) and network failures
|
||||
are expected and skipped quietly.
|
||||
"""
|
||||
try:
|
||||
mgr = velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
|
||||
info = mgr.check_for_updates()
|
||||
if info is None:
|
||||
logger.info("Velopack: no update available")
|
||||
return
|
||||
version = info.TargetFullRelease.Version
|
||||
logger.info("Velopack: downloading update %s", version)
|
||||
mgr.download_updates(info)
|
||||
logger.info("Velopack: update %s staged, applies on next launch", version)
|
||||
except (RuntimeError, OSError) as exc:
|
||||
logger.info("Velopack update check skipped: %s", exc)
|
||||
|
||||
|
||||
def gui_main() -> None:
|
||||
"""Run the GUI, rendering fatal exceptions as a TraceRite HTML window."""
|
||||
_velopack_startup()
|
||||
try:
|
||||
winmain()
|
||||
except Exception as exc:
|
||||
logger.exception("Fatal error")
|
||||
_show_fatal_error(exc)
|
||||
|
||||
|
||||
class JsApi:
|
||||
"""Python methods exposed to the frontend via window.pywebview.api."""
|
||||
@@ -802,8 +1156,26 @@ def _configure_windows_event_loop_policy() -> None:
|
||||
asyncio.set_event_loop_policy(policy_cls())
|
||||
|
||||
|
||||
def _strip_mark_of_the_web() -> None:
|
||||
"""Remove Zone.Identifier streams from bundled DLLs (frozen Windows only).
|
||||
|
||||
Files extracted from a downloaded ZIP carry the Mark-of-the-Web, and the
|
||||
.NET Framework CLR refuses to load such assemblies — pythonnet then fails
|
||||
with "Failed to resolve Python.Runtime.Loader.Initialize from
|
||||
.../Python.Runtime.dll". Strip the mark from the bundled DLLs before
|
||||
pywebview loads the CLR.
|
||||
"""
|
||||
if not getattr(sys, "frozen", False) or sys.platform != "win32":
|
||||
return
|
||||
meipass = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||
for dll in meipass.rglob("*.dll"):
|
||||
with contextlib.suppress(OSError):
|
||||
Path(f"{dll}:Zone.Identifier").unlink()
|
||||
|
||||
|
||||
def winmain() -> None:
|
||||
_configure_windows_event_loop_policy()
|
||||
_strip_mark_of_the_web()
|
||||
|
||||
parser = argparse.ArgumentParser(description="MediaHive")
|
||||
parser.add_argument(
|
||||
@@ -811,7 +1183,7 @@ def winmain() -> None:
|
||||
nargs="?",
|
||||
help="Path to the media folder (default: saved config or initial setup dialog)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
args, _unknown = parser.parse_known_args()
|
||||
|
||||
_prepend_meipass_to_path()
|
||||
|
||||
@@ -854,14 +1226,27 @@ def winmain() -> None:
|
||||
backend_url = f"http://{BACKEND_HOST}:{backend_port}"
|
||||
os.environ["MEDIAHIVE_BACKEND_URL"] = backend_url
|
||||
|
||||
# Run the FastAPI backend on a background thread
|
||||
# Startup banner, same as fastapi-vue's server.run() prints in CLI mode.
|
||||
# Goes to stderr, which frozen builds redirect to the log file.
|
||||
try:
|
||||
version = importlib.metadata.version("mediahive")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
version = "dev"
|
||||
print_box(f"MediaHive {version}\n{backend_url}")
|
||||
|
||||
# Run the FastAPI backend on a background thread. fastapi-vue's patched
|
||||
# log config wires up its access-log middleware, emoji level prefixes and
|
||||
# tracerite tracebacks (colors are auto-disabled when stderr is not a tty,
|
||||
# e.g. redirected to the log file in frozen builds).
|
||||
config = uvicorn.Config(
|
||||
"mediahive.server:app",
|
||||
host=BACKEND_HOST,
|
||||
port=backend_port,
|
||||
loop="asyncio",
|
||||
log_level="warning",
|
||||
server_header=False,
|
||||
timeout_graceful_shutdown=0,
|
||||
access_log=False, # fastapi-vue's middleware replaces uvicorn's
|
||||
log_config=patch_log_config(uvicorn.config.LOGGING_CONFIG),
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
backend_thread = threading.Thread(
|
||||
@@ -872,7 +1257,7 @@ def winmain() -> None:
|
||||
def _activate_initial_roots() -> None:
|
||||
body = json.dumps({"roots": initial_roots}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url=f"{backend_url}/api/roots",
|
||||
url=f"{backend_url}/api/config/roots",
|
||||
data=body,
|
||||
method="PUT",
|
||||
headers={"Content-Type": "application/json"},
|
||||
@@ -887,6 +1272,10 @@ def winmain() -> None:
|
||||
server.should_exit = True
|
||||
raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s")
|
||||
|
||||
threading.Thread(
|
||||
target=_check_for_updates, daemon=True, name="mediahive-update-check"
|
||||
).start()
|
||||
|
||||
api = JsApi()
|
||||
logger.info("Configured pywebview backend: %s", _selected_webview_backend())
|
||||
window = webview.create_window(
|
||||
@@ -900,7 +1289,7 @@ def winmain() -> None:
|
||||
poll_thread: threading.Thread | None = None
|
||||
|
||||
# Resolve all root paths for gamepad remote
|
||||
gamepad_roots = [Path(p) for p in initial_roots.values()]
|
||||
gamepad_roots = {root_id: Path(p) for root_id, p in initial_roots.items()}
|
||||
|
||||
def on_shown() -> None:
|
||||
api._window = window
|
||||
@@ -913,7 +1302,7 @@ def winmain() -> None:
|
||||
|
||||
nonlocal poll_thread
|
||||
if poll_thread is None and _supports_gamepad_remote():
|
||||
poll_thread = _start_gamepad_remote(poll_stop, gamepad_roots)
|
||||
poll_thread = _start_gamepad_remote(poll_stop, gamepad_roots, backend_url)
|
||||
|
||||
threading.Thread(
|
||||
target=_activate_initial_roots,
|
||||
@@ -956,4 +1345,4 @@ def winmain() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
winmain()
|
||||
gui_main()
|
||||
|
||||
+12
-4
@@ -8,11 +8,12 @@ dependencies = [
|
||||
"aiofiles>=25.1.0",
|
||||
"aiopathlib>=0.6.0",
|
||||
"bencodepy>=0.9.5",
|
||||
"fastapi-vue>=0.5.2",
|
||||
"fastapi-vue>=1.4.1",
|
||||
"fastapi[standard]>=0.128.0",
|
||||
"httpx[http2]>=0.28.1",
|
||||
"msgspec>=0.19",
|
||||
"parse-torrent-title>=2.8.1",
|
||||
"platformdirs>=4.0",
|
||||
"tomli-w>=1.2.0",
|
||||
"uvicorn[standard]>=0.40.0",
|
||||
]
|
||||
@@ -36,7 +37,11 @@ artifacts = ["mediahive/frontend-build"]
|
||||
only-packages = true
|
||||
|
||||
[tool.hatch.build.targets.sdist.hooks.custom]
|
||||
path = "scripts/fastapi-vue/build-frontend.py"
|
||||
path = "scripts/fastapi-vue/buildhook.py"
|
||||
|
||||
[tool.hatch.build.targets.sdist.force-include]
|
||||
"scripts/fastapi-vue/buildhook.py" = "scripts/fastapi-vue/buildhook.py"
|
||||
"scripts/fastapi-vue/buildutil.py" = "scripts/fastapi-vue/buildutil.py"
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
@@ -46,10 +51,13 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
|
||||
|
||||
[project.optional-dependencies]
|
||||
gui = [
|
||||
"pywebview>=6.2.1; platform_system != 'Darwin'",
|
||||
"pywebview[qt5]>=6.2.1; platform_system == 'Darwin'",
|
||||
"pywebview[qt]>=6.2.1; platform_system != 'Windows'",
|
||||
"pywebview>=6.2.1; platform_system == 'Windows'",
|
||||
"velopack>=1.2",
|
||||
"qtpy>=2.4.1; platform_system == 'Darwin'",
|
||||
"PyQt5>=5.15.11; platform_system == 'Darwin'",
|
||||
# pywebview[qt] no longer pulls this in; the macOS Qt backend needs it
|
||||
"PyQtWebEngine>=5.15.7; platform_system == 'Darwin'",
|
||||
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
|
||||
"pyinstaller>=6.0",
|
||||
]
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RTorrent Client - Communicate with rtorrent via XMLRPC over SCGI socket."""
|
||||
|
||||
import socket
|
||||
import xmlrpc.client
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SCGITransport(xmlrpc.client.Transport):
|
||||
"""SCGI transport for communicating with rtorrent via Unix socket."""
|
||||
|
||||
def __init__(self, socket_path: str) -> None:
|
||||
super().__init__()
|
||||
self.socket_path = socket_path
|
||||
|
||||
def single_request(self, _host, _handler, request_body, _verbose=False):
|
||||
# Create SCGI request
|
||||
headers = f"CONTENT_LENGTH\x00{len(request_body)}\x00SCGI\x001\x00"
|
||||
request = f"{len(headers)}:{headers},{request_body.decode('utf-8')}"
|
||||
|
||||
# Connect to socket
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(self.socket_path)
|
||||
sock.send(request.encode("utf-8"))
|
||||
|
||||
# Read response
|
||||
response = b""
|
||||
while True:
|
||||
data = sock.recv(4096)
|
||||
if not data:
|
||||
break
|
||||
response += data
|
||||
sock.close()
|
||||
|
||||
# Parse response - skip HTTP headers
|
||||
if b"\r\n\r\n" in response:
|
||||
response = response.split(b"\r\n\r\n", 1)[1]
|
||||
|
||||
return self.parse_response(response)
|
||||
|
||||
def parse_response(self, response_body):
|
||||
p, u = xmlrpc.client.getparser()
|
||||
p.feed(response_body)
|
||||
p.close()
|
||||
return u.close()
|
||||
|
||||
|
||||
class RTorrentClient:
|
||||
"""Client for communicating with rtorrent via XMLRPC over SCGI socket."""
|
||||
|
||||
def __init__(
|
||||
self, socket_path: str = "/home/user/rtorrent/.session/rpc.socket"
|
||||
) -> None:
|
||||
self.socket_path = socket_path
|
||||
transport = SCGITransport(socket_path)
|
||||
self.proxy = xmlrpc.client.ServerProxy(
|
||||
"http://localhost/RPC2", transport=transport
|
||||
)
|
||||
|
||||
def get_loaded_hashes(self) -> set[str]:
|
||||
"""Get set of info hashes for all currently loaded torrents."""
|
||||
try:
|
||||
downloads = self.proxy.download_list("")
|
||||
return {h.upper() for h in downloads}
|
||||
except (OSError, xmlrpc.client.Error) as e:
|
||||
print(f"Error getting loaded torrents: {e}")
|
||||
return set()
|
||||
|
||||
def load_torrent(self, torrent_path: Path, download_dir: Path) -> bool:
|
||||
"""Load a torrent file and set its download directory.
|
||||
|
||||
Uses load.start_verbose to load and immediately start/hash-check.
|
||||
|
||||
Args:
|
||||
torrent_path: Path to the .torrent file
|
||||
download_dir: Directory where the data already exists
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
|
||||
"""
|
||||
try:
|
||||
# load.start_verbose with d.directory.set to specify download location
|
||||
# This will hash-check existing files instead of re-downloading
|
||||
self.proxy.load.start_verbose(
|
||||
"", str(torrent_path), f'd.directory.set="{download_dir}"'
|
||||
)
|
||||
return True
|
||||
except (OSError, xmlrpc.client.Error) as e:
|
||||
print(f"Error loading torrent {torrent_path}: {e}")
|
||||
return False
|
||||
|
||||
def get_torrent_info(self, info_hash: str) -> dict | None:
|
||||
"""Get info about a loaded torrent."""
|
||||
try:
|
||||
name = self.proxy.d.name(info_hash)
|
||||
message = self.proxy.d.message(info_hash)
|
||||
tied_file = self.proxy.d.tied_to_file(info_hash)
|
||||
directory = self.proxy.d.directory(info_hash)
|
||||
base_path = self.proxy.d.base_path(info_hash) # Actual data path
|
||||
is_multi_file = self.proxy.d.is_multi_file(info_hash)
|
||||
return {
|
||||
"hash": info_hash,
|
||||
"name": name,
|
||||
"message": message,
|
||||
"tied_file": tied_file,
|
||||
"directory": directory,
|
||||
"base_path": base_path, # Full path to data (file or folder)
|
||||
"is_multi_file": is_multi_file,
|
||||
}
|
||||
except (OSError, xmlrpc.client.Error) as e:
|
||||
print(f"Error getting torrent info for {info_hash}: {e}")
|
||||
return None
|
||||
|
||||
def get_unregistered_torrents(self) -> list[dict]:
|
||||
"""Find all torrents with 'unregistered' or 'not registered' tracker errors.
|
||||
|
||||
Returns:
|
||||
List of torrent info dicts for torrents with registration errors
|
||||
|
||||
"""
|
||||
unregistered = []
|
||||
try:
|
||||
hashes = self.proxy.download_list("")
|
||||
for info_hash in hashes:
|
||||
try:
|
||||
message = self.proxy.d.message(info_hash)
|
||||
if message and (
|
||||
"unregistered" in message.lower()
|
||||
or "not registered" in message.lower()
|
||||
):
|
||||
info = self.get_torrent_info(info_hash)
|
||||
if info:
|
||||
unregistered.append(info)
|
||||
except OSError, xmlrpc.client.Error:
|
||||
continue
|
||||
except (OSError, xmlrpc.client.Error) as e:
|
||||
print(f"Error scanning for unregistered torrents: {e}")
|
||||
return unregistered
|
||||
|
||||
def remove_torrent(self, info_hash: str, delete_files: bool = False) -> bool:
|
||||
"""Remove a torrent from rtorrent.
|
||||
|
||||
Args:
|
||||
info_hash: The info hash of the torrent to remove
|
||||
delete_files: If True, also delete downloaded files (default: False)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
|
||||
"""
|
||||
try:
|
||||
if delete_files:
|
||||
# This would delete the data - NOT what we want
|
||||
self.proxy.d.erase(info_hash)
|
||||
else:
|
||||
# Just remove from rtorrent, keep files
|
||||
self.proxy.d.erase(info_hash)
|
||||
return True
|
||||
except (OSError, xmlrpc.client.Error) as e:
|
||||
print(f"Error removing torrent {info_hash}: {e}")
|
||||
return False
|
||||
+20
-3
@@ -5,12 +5,13 @@
|
||||
# pyinstaller --noconfirm --clean scripts/MediaHive.spec
|
||||
#
|
||||
# Or use the build script (recommended—handles versioning and packaging):
|
||||
# uv run scripts/winbuild.py
|
||||
# uv run scripts/guibuild.py
|
||||
|
||||
import sys
|
||||
import mediahive.winmain
|
||||
import mediahive.server
|
||||
from pathlib import Path
|
||||
from PyInstaller.utils.hooks import collect_data_files
|
||||
|
||||
block_cipher = None
|
||||
|
||||
@@ -19,7 +20,15 @@ _frontend_build = _pkg / "frontend-build"
|
||||
_logo_webp = _pkg / "assets" / "mediahive.webp"
|
||||
_icon_win = _pkg / "assets" / "mediahive.ico"
|
||||
_icon_mac = _pkg / "assets" / "mediahive.icns"
|
||||
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
|
||||
# ffmpeg staging lives in the persistent build cache (same logic as
|
||||
# scripts/guibuild.py); fall back to the legacy build/ffmpeg location.
|
||||
from platformdirs import user_cache_path
|
||||
|
||||
_tools_dir = (
|
||||
user_cache_path("mediahive-build", appauthor=False, opinion=False) / "ffmpeg"
|
||||
)
|
||||
if not _tools_dir.exists():
|
||||
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
|
||||
_tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg"]
|
||||
|
||||
_binaries = []
|
||||
@@ -32,6 +41,9 @@ _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():
|
||||
@@ -119,6 +131,11 @@ exe = EXE(
|
||||
windowed=True,
|
||||
)
|
||||
|
||||
# UPX breaks .NET assemblies: packing Python.Runtime.dll strips/corrupts its
|
||||
# CLR metadata and pythonnet then fails with "Failed to resolve
|
||||
# Python.Runtime.Loader.Initialize from .../Python.Runtime.dll".
|
||||
_upx_exclude = ["Python.Runtime.dll"] if sys.platform == "win32" else []
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
@@ -126,7 +143,7 @@ coll = COLLECT(
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
upx_exclude=_upx_exclude,
|
||||
name="MediaHive",
|
||||
)
|
||||
|
||||
|
||||
Regular → Executable
+15
-7
@@ -9,9 +9,11 @@ import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
import tracerite
|
||||
|
||||
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||
from devutil import ( # type: ignore[import-not-found]
|
||||
from devutil import (
|
||||
ProcessGroup,
|
||||
check_ports_free,
|
||||
logger,
|
||||
@@ -22,11 +24,15 @@ from devutil import ( # type: ignore[import-not-found]
|
||||
|
||||
DEFAULT_VITE_PORT = 8420
|
||||
DEFAULT_DEV_PORT = 8421
|
||||
HEALTH = "/api/health?from=devserver.py"
|
||||
|
||||
|
||||
async def run_devserver(
|
||||
listen: str, backend: str, extra_args: list[str] | None = None
|
||||
listen: str,
|
||||
backend: str,
|
||||
extra_args: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Start Vite and FastAPI dev servers with hot reload."""
|
||||
reporoot = Path(__file__).parent.parent
|
||||
front = reporoot / "frontend"
|
||||
if not (front / "package.json").exists():
|
||||
@@ -36,7 +42,7 @@ async def run_devserver(
|
||||
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
|
||||
backurl, mediahive = setup_cli("mediahive", backend, DEFAULT_DEV_PORT)
|
||||
|
||||
# Tell the everyone by environment (vite proxy and backend devmode use these)
|
||||
# Tell everyone via environment (vite proxy and backend devmode use these)
|
||||
os.environ["MEDIAHIVE_VITE_URL"] = viteurl
|
||||
os.environ["MEDIAHIVE_BACKEND_URL"] = backurl
|
||||
os.environ["MEDIAHIVE_DEV"] = "1"
|
||||
@@ -45,11 +51,13 @@ async def run_devserver(
|
||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
await pg.spawn(*mediahive, *(extra_args or []))
|
||||
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
|
||||
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
||||
await pg.spawn(*vite, cwd=front)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Parse CLI arguments and run the devserver."""
|
||||
tracerite.load()
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run Vite and FastAPI development servers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
@@ -58,12 +66,12 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--listen",
|
||||
metavar="host:port",
|
||||
metavar="addr",
|
||||
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
metavar="host:port",
|
||||
metavar="addr",
|
||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||
)
|
||||
args, extra_args = parser.parse_known_args()
|
||||
@@ -72,7 +80,7 @@ def main() -> None:
|
||||
|
||||
|
||||
HELP_EPILOG = """
|
||||
scripts/devserver.py [args to mediahive]
|
||||
Other options are forwarded to mediahive [args]
|
||||
|
||||
JS_RUNTIME environment variable can be used to select the JS runtime:
|
||||
npm, deno, bun, or full path to the runtime executable (node maps to npm).
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Hatch build hook for building Vue frontend during package build."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import ( # type: ignore[import-not-found]
|
||||
BuildHookInterface,
|
||||
)
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from buildutil import build
|
||||
|
||||
|
||||
class CustomBuildHook(BuildHookInterface):
|
||||
def initialize(self, version, build_data) -> None:
|
||||
super().initialize(version, build_data)
|
||||
build("frontend")
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Hatch build hook for building Vue frontend during package build."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from buildutil import build
|
||||
|
||||
|
||||
class CustomBuildHook(BuildHookInterface): # type: ignore[misc]
|
||||
"""Hatch build hook that builds Vue frontend during package build."""
|
||||
|
||||
def initialize(self, version: str, build_data: dict) -> None: # type: ignore[override]
|
||||
"""Build frontend before package is built."""
|
||||
super().initialize(version, build_data)
|
||||
build("frontend")
|
||||
@@ -7,6 +7,8 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
MIN_NODE_VERSION = 20
|
||||
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
@@ -31,81 +33,118 @@ def _check_node_version(node_path: str) -> None:
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[node_path, "--version"], capture_output=True, text=True, check=True
|
||||
[node_path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
version_str = result.stdout.strip()
|
||||
# Parse version like "v20.10.0" or "v18.17.1"
|
||||
match = re.match(r"v(\d+)", version_str)
|
||||
if match:
|
||||
major_version = int(match.group(1))
|
||||
if major_version >= 20:
|
||||
if major_version >= MIN_NODE_VERSION:
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
||||
)
|
||||
msg = f"Node.js {version_str} found, but v{MIN_NODE_VERSION}+ required"
|
||||
raise RuntimeError(msg)
|
||||
except subprocess.CalledProcessError, FileNotFoundError, ValueError:
|
||||
pass
|
||||
raise RuntimeError("Could not determine Node.js version")
|
||||
msg = "Could not determine Node.js version"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def _validate_npm_runtime(tool: str) -> bool:
|
||||
"""Validate npm runtime by checking Node.js version. Returns True if valid."""
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
return False
|
||||
try:
|
||||
_check_node_version(node_path)
|
||||
except RuntimeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _find_runtime_from_env(options: list[str]) -> tuple[str, str] | None:
|
||||
"""Find runtime specified by JS_RUNTIME environment variable."""
|
||||
js_runtime_env = os.environ.get("JS_RUNTIME")
|
||||
if not js_runtime_env:
|
||||
return None
|
||||
|
||||
js_runtime = js_runtime_env
|
||||
js_path = Path(js_runtime)
|
||||
runtime_name = js_path.name
|
||||
|
||||
# Map node to npm
|
||||
if runtime_name == "node":
|
||||
runtime_name = "npm"
|
||||
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
||||
|
||||
for option in options:
|
||||
if option != runtime_name and not runtime_name.startswith(option):
|
||||
continue
|
||||
|
||||
tool = shutil.which(js_runtime)
|
||||
if tool is None:
|
||||
msg = f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if option == "npm":
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
msg = f"JS_RUNTIME={js_runtime_env}: node not found"
|
||||
raise RuntimeError(msg)
|
||||
_check_node_version(node_path)
|
||||
|
||||
return tool, option
|
||||
|
||||
msg = f"JS_RUNTIME={js_runtime_env} not recognized"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def _auto_detect_runtime(options: list[str]) -> tuple[str, str]:
|
||||
"""Auto-detect JavaScript runtime from available options."""
|
||||
node_version_error: RuntimeError | None = None
|
||||
|
||||
for option in options:
|
||||
tool = shutil.which(option)
|
||||
if not tool:
|
||||
continue
|
||||
|
||||
if option == "npm" and not _validate_npm_runtime(tool):
|
||||
try:
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path:
|
||||
_check_node_version(node_path)
|
||||
except RuntimeError as e:
|
||||
node_version_error = e
|
||||
continue
|
||||
|
||||
return tool, option
|
||||
|
||||
if node_version_error:
|
||||
raise node_version_error
|
||||
msg = "Node.js (v20+), Deno or Bun is required but none was found"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def find_js_runtime() -> tuple[str, str]:
|
||||
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||
|
||||
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||
Raises JSRuntimeError if no suitable runtime is found.
|
||||
Raises RuntimeError if no suitable runtime is found.
|
||||
"""
|
||||
options = ["npm", "deno", "bun"]
|
||||
node_version_error: RuntimeError | None = None
|
||||
|
||||
# Check for JS_RUNTIME environment variable
|
||||
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
||||
js_runtime = js_runtime_env
|
||||
js_path = Path(js_runtime)
|
||||
runtime_name = js_path.name
|
||||
# Map node to npm
|
||||
if runtime_name == "node":
|
||||
runtime_name = "npm"
|
||||
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
||||
for option in options:
|
||||
if option == runtime_name or runtime_name.startswith(option):
|
||||
tool = shutil.which(js_runtime)
|
||||
if tool is None:
|
||||
raise RuntimeError(
|
||||
f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
||||
)
|
||||
# Check Node.js version if using npm
|
||||
if option == "npm":
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
raise RuntimeError(
|
||||
f"JS_RUNTIME={js_runtime_env}: node not found"
|
||||
)
|
||||
_check_node_version(node_path) # Raises on failure
|
||||
return tool, option
|
||||
raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized")
|
||||
if result := _find_runtime_from_env(options):
|
||||
return result
|
||||
|
||||
# Auto-detect
|
||||
for option in options:
|
||||
if tool := shutil.which(option):
|
||||
# Check Node.js version if using npm
|
||||
if option == "npm":
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
continue
|
||||
try:
|
||||
_check_node_version(node_path)
|
||||
except RuntimeError as e:
|
||||
node_version_error = e
|
||||
continue # Try next runtime
|
||||
return tool, option
|
||||
|
||||
# No runtime found - provide helpful error
|
||||
if node_version_error:
|
||||
raise node_version_error
|
||||
raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found")
|
||||
return _auto_detect_runtime(options)
|
||||
|
||||
|
||||
def find_build_tool():
|
||||
def find_build_tool() -> tuple[list[str], list[str]]:
|
||||
"""Find JavaScript runtime and construct install/build commands.
|
||||
|
||||
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||
@@ -143,9 +182,7 @@ def find_dev_tool() -> list[str]:
|
||||
|
||||
if name == "bun":
|
||||
logger.warning(
|
||||
"Bun has a bug in WS proxying "
|
||||
"(https://github.com/oven-sh/bun/issues/9882). "
|
||||
"Consider using npm instead."
|
||||
"Bun has a WS proxy bug (github.com/oven-sh/bun/issues/9882). Consider npm.",
|
||||
)
|
||||
|
||||
return [tool, *dev_args[name]]
|
||||
@@ -178,9 +215,9 @@ def build(folder: str = "frontend") -> None:
|
||||
install_cmd, build_cmd = find_build_tool()
|
||||
except RuntimeError as e:
|
||||
logger.warning(e)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
def run(cmd) -> None:
|
||||
def run(cmd: list[str]) -> None:
|
||||
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||
logger.info("### %s", " ".join(display_cmd))
|
||||
subprocess.run(cmd, check=True, cwd=folder)
|
||||
@@ -190,4 +227,4 @@ def build(folder: str = "frontend") -> None:
|
||||
logger.info("")
|
||||
run(build_cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
"""Utilities for the devserver script in the source repository.
|
||||
|
||||
Used only with development dependencies.
|
||||
"""
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Coroutine
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any, Self
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Coroutine
|
||||
|
||||
|
||||
class ProcessGroup:
|
||||
"""Manage async subprocesses with automatic cleanup.
|
||||
|
||||
Acts like TaskGroup for processes.
|
||||
"""
|
||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize empty process tracking."""
|
||||
self._procs: list[asyncio.subprocess.Process] = []
|
||||
self._cmds: dict[int, str] = {} # pid -> command name
|
||||
|
||||
async def spawn(
|
||||
self, *cmd: str, cwd: str | None = None
|
||||
self,
|
||||
*cmd: str,
|
||||
cwd: str | None = None,
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a subprocess and track it."""
|
||||
cmd_name = Path(cmd[0]).stem
|
||||
@@ -38,7 +37,8 @@ class ProcessGroup:
|
||||
return proc
|
||||
|
||||
async def wait(
|
||||
self, *waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any]
|
||||
self,
|
||||
*waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any],
|
||||
) -> None:
|
||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||
|
||||
@@ -59,18 +59,14 @@ class ProcessGroup:
|
||||
raise SystemExit(1) from None
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Return this process group context manager."""
|
||||
"""Enter the async context manager."""
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
*_: object,
|
||||
) -> None:
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
|
||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||
await self._cleanup(immediate=exc_type is not None)
|
||||
|
||||
async def _cleanup(self, immediate: bool = False) -> None:
|
||||
async def _cleanup(self, *, immediate: bool = False) -> None:
|
||||
running = [p for p in self._procs if p.returncode is None]
|
||||
if not running:
|
||||
return
|
||||
@@ -98,7 +94,7 @@ class ProcessGroup:
|
||||
asyncio.wait_for(
|
||||
asyncio.gather(*[p.wait() for p in still_running]),
|
||||
timeout=10,
|
||||
)
|
||||
),
|
||||
)
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
@@ -108,46 +104,71 @@ class ProcessGroup:
|
||||
await p.wait()
|
||||
|
||||
|
||||
async def check_ports_free(*urls: str) -> None:
|
||||
"""Verify URLs are not responding (ports are free).
|
||||
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
||||
"""GET url with plain asyncio streams, return the response Server header.
|
||||
|
||||
Raise SystemExit if any endpoint responds.
|
||||
Returns an empty string when the server responds without a Server header,
|
||||
and None when the server is unreachable or doesn't answer in time.
|
||||
"""
|
||||
parts = urlsplit(url)
|
||||
host = parts.hostname or "localhost"
|
||||
port = parts.port or (443 if parts.scheme == "https" else 80)
|
||||
path = parts.path or "/"
|
||||
if parts.query:
|
||||
path += f"?{parts.query}"
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
reader, writer = await asyncio.open_connection(host, port)
|
||||
try:
|
||||
writer.write(f"GET {path} HTTP/1.0\r\nHost: {host}\r\n\r\n".encode())
|
||||
await writer.drain()
|
||||
data = await reader.readuntil(b"\r\n\r\n")
|
||||
finally:
|
||||
writer.close()
|
||||
except OSError, EOFError, ValueError, TimeoutError:
|
||||
return None
|
||||
for line in data.decode("latin-1").split("\r\n"):
|
||||
if line.lower().startswith("server:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return ""
|
||||
|
||||
async def check(client: httpx.AsyncClient, url: str) -> None:
|
||||
with suppress(httpx.RequestError):
|
||||
res = await client.get(url, timeout=0.1)
|
||||
server = res.headers.get("server", "server")
|
||||
logger.warning("Conflicting %s already running at %s", server, url)
|
||||
|
||||
async def check_ports_free(*urls: str) -> None:
|
||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||
|
||||
async def check(url: str) -> None:
|
||||
server = await http_get_server(url, timeout=0.1)
|
||||
if server is not None:
|
||||
logger.warning(
|
||||
"Conflicting %s already running at %s", server or "server", url
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
await asyncio.gather(*[check(client, url) for url in urls])
|
||||
await asyncio.gather(*[check(url) for url in urls])
|
||||
|
||||
|
||||
async def ready(url: str, path: str = "") -> None:
|
||||
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||
"""Wait for the server to be ready by polling an endpoint.
|
||||
|
||||
Use empty path to disable the check and make this return immediately.
|
||||
Raises SystemExit(1) if server doesn't start in time.
|
||||
"""
|
||||
max_attempts = 50
|
||||
full_url = f"{url}{path}"
|
||||
if not path:
|
||||
return
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
await client.get(full_url, timeout=1.0)
|
||||
logger.info("✓ Backend ready!")
|
||||
return
|
||||
except httpx.RequestError:
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
await asyncio.sleep(0.1)
|
||||
for attempt in range(max_attempts):
|
||||
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
||||
logger.info("✓ Backend ready!")
|
||||
return
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
def setup_vite(
|
||||
endpoint: str, default_port: int = 5173
|
||||
endpoint: str,
|
||||
default_port: int = 5173,
|
||||
) -> tuple[str, list[str], list[str]]:
|
||||
"""Parse frontend endpoint and build commands.
|
||||
|
||||
@@ -173,7 +194,9 @@ def setup_vite(
|
||||
|
||||
|
||||
def setup_fastapi(
|
||||
endpoint: str, module: str, default_port: int = 8000
|
||||
endpoint: str,
|
||||
module: str,
|
||||
default_port: int = 8000,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Parse backend endpoint and build uvicorn command.
|
||||
|
||||
@@ -205,7 +228,9 @@ def setup_fastapi(
|
||||
|
||||
|
||||
def setup_cli(
|
||||
cli: str, endpoint: str, default_port: int = 8000
|
||||
cli: str,
|
||||
endpoint: str,
|
||||
default_port: int = 8000,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Parse backend endpoint and build CLI command.
|
||||
|
||||
@@ -221,5 +246,7 @@ def setup_cli(
|
||||
host = endpoints[0]["host"]
|
||||
port = endpoints[0]["port"]
|
||||
|
||||
cmd = [cli, f"--listen={host}:{port}"]
|
||||
# Run the package as a module with the current interpreter, instead of
|
||||
# relying on a PATH-installed CLI entry point.
|
||||
cmd = [sys.executable, "-m", cli, f"--listen={host}:{port}"]
|
||||
return f"http://{host}:{port}", cmd
|
||||
|
||||
Regular → Executable
+312
-30
@@ -1,7 +1,8 @@
|
||||
"""Build the desktop GUI application and package it as a version-numbered ZIP.
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Build the desktop GUI application and package it with Velopack.
|
||||
|
||||
Usage:
|
||||
uv run scripts/winbuild.py
|
||||
uv run scripts/guibuild.py
|
||||
|
||||
This runs in the project environment where dependencies
|
||||
are available via pyproject.toml.
|
||||
@@ -9,14 +10,18 @@ are available via pyproject.toml.
|
||||
This script:
|
||||
1. Reads the version from pyproject.toml
|
||||
2. Runs `uv build` to produce the wheel/sdist
|
||||
3. On Windows, downloads the latest ffmpeg.exe for bundling
|
||||
4. On macOS arm64, downloads a prebuilt ffmpeg binary for bundling
|
||||
3. On Windows/macOS, downloads the ffmpeg binary for bundling
|
||||
4. Builds MediaHive using PyInstaller
|
||||
5. Creates a ZIP file with the version number
|
||||
5. Packages with Velopack: Setup.exe (Windows), .pkg (macOS),
|
||||
.AppImage (Linux), plus the update feed in build/velopack/
|
||||
that release.py uploads for in-app auto-updates
|
||||
6. On Windows, also creates a portable ZIP (no auto-updates)
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -24,8 +29,10 @@ import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import setuptools_scm
|
||||
from platformdirs import user_cache_path
|
||||
|
||||
# BtbN automated builds always publish a 'latest' tag with this asset.
|
||||
_FFMPEG_URL = (
|
||||
@@ -35,29 +42,59 @@ _FFMPEG_URL = (
|
||||
_MACOS_ARM64_TOOL_URLS = {
|
||||
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
|
||||
}
|
||||
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
|
||||
_REPO_ROOT = Path(__file__).parent.parent
|
||||
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
|
||||
|
||||
|
||||
def _platform_zip_suffix() -> str:
|
||||
machine = platform.machine().lower()
|
||||
arch = {
|
||||
"x86_64": "x64",
|
||||
"amd64": "x64",
|
||||
"arm64": "arm64",
|
||||
"aarch64": "arm64",
|
||||
}.get(machine, machine or "unknown")
|
||||
def _build_cache_dir() -> Path:
|
||||
"""Return the persistent cross-build cache dir for downloaded tools (CI wipes build/)."""
|
||||
return user_cache_path("mediahive-build", appauthor=False, opinion=False)
|
||||
|
||||
|
||||
_FFMPEG_STAGING = _build_cache_dir() / "ffmpeg"
|
||||
|
||||
# Velopack CLI (dotnet tool package). Runs on the machine's .NET runtime; the
|
||||
# produced Setup.exe/Update.exe are native and need no runtime on end-user
|
||||
# machines. Pin a version whose tools target an installed .NET major.
|
||||
_VPK_VERSION = "1.2.158"
|
||||
_VPK_URL = (
|
||||
f"https://api.nuget.org/v3-flatcontainer/vpk/{_VPK_VERSION}"
|
||||
f"/vpk.{_VPK_VERSION}.nupkg"
|
||||
)
|
||||
_VPK_STAGING = _build_cache_dir() / f"vpk-{_VPK_VERSION}"
|
||||
|
||||
|
||||
class _Platform(NamedTuple):
|
||||
"""Per-platform naming/packaging constants.
|
||||
|
||||
tag is the release artifact suffix. Only Windows keeps an arch marker
|
||||
(win64); macOS builds are arm64-only and we ship one Linux flavor.
|
||||
"""
|
||||
|
||||
tag: str # win64 / macos / linux
|
||||
channel: str # Velopack update channel: win / osx / linux
|
||||
rid: str # Velopack runtime id
|
||||
dist_dir: str # PyInstaller output dir under build/
|
||||
icon: str # file in mediahive/assets
|
||||
main_exe: str
|
||||
setup_ext: str
|
||||
|
||||
|
||||
def _platform() -> _Platform:
|
||||
if sys.platform == "win32":
|
||||
return "win64"
|
||||
return _Platform("win64", "win", "win-x64", "MediaHive", "mediahive.ico", "MediaHive.exe", ".exe")
|
||||
if sys.platform == "darwin":
|
||||
return f"macos-{arch}"
|
||||
return f"linux-{arch}"
|
||||
return _Platform("macos", "osx", "osx-arm64", "MediaHive.app", "mediahive.icns", "MediaHive", ".pkg")
|
||||
return _Platform("linux", "linux", "linux-x64", "MediaHive", "mediahive.png", "MediaHive", ".AppImage")
|
||||
|
||||
|
||||
def setup_artifact_name(version: str) -> str:
|
||||
p = _platform()
|
||||
return f"MediaHive-{version}-{p.tag}-setup{p.setup_ext}"
|
||||
|
||||
|
||||
def fetch_ffmpeg() -> Path:
|
||||
"""Download latest ffmpeg.exe from BtbN builds into build/ffmpeg/."""
|
||||
"""Download latest ffmpeg.exe from BtbN builds into the persistent build cache."""
|
||||
dest = _FFMPEG_STAGING / "ffmpeg.exe"
|
||||
if dest.exists():
|
||||
print(f"ffmpeg already staged at {dest}, skipping download.")
|
||||
@@ -82,7 +119,7 @@ def fetch_ffmpeg() -> Path:
|
||||
|
||||
|
||||
def fetch_macos_arm64_binaries() -> dict[str, Path]:
|
||||
"""Download prebuilt macOS arm64 ffmpeg binary into build/ffmpeg/."""
|
||||
"""Download prebuilt macOS arm64 ffmpeg binary into the persistent build cache."""
|
||||
if sys.platform != "darwin" or platform.machine().lower() not in {
|
||||
"arm64",
|
||||
"aarch64",
|
||||
@@ -179,6 +216,244 @@ def ensure_macos_icon() -> Path:
|
||||
return icon_icns
|
||||
|
||||
|
||||
_VPK_TFM = "net10.0"
|
||||
_VPK_REQUIRED_DOTNET_MAJOR = int(re.fullmatch(r"net(\d+)\.0", _VPK_TFM).group(1))
|
||||
|
||||
|
||||
def fetch_vpk() -> Path:
|
||||
"""Download the Velopack CLI package into the persistent build cache.
|
||||
|
||||
Returns the path to vpk.dll, runnable with `dotnet vpk.dll ...`.
|
||||
"""
|
||||
vpk_dll = _VPK_STAGING / "tools" / _VPK_TFM / "any" / "vpk.dll"
|
||||
if vpk_dll.exists():
|
||||
print(f"vpk already staged at {_VPK_STAGING}, skipping download.")
|
||||
return vpk_dll
|
||||
|
||||
_VPK_STAGING.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Downloading vpk from {_VPK_URL} ...")
|
||||
with urllib.request.urlopen(_VPK_URL) as resp:
|
||||
data = resp.read()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
zf.extractall(_VPK_STAGING)
|
||||
|
||||
if not vpk_dll.exists():
|
||||
raise RuntimeError(f"vpk.dll not found in package at {vpk_dll}")
|
||||
print(f"vpk staged at {_VPK_STAGING}")
|
||||
return vpk_dll
|
||||
|
||||
|
||||
def _dotnet_runtime_major(exe: Path) -> int | None:
|
||||
"""Return the highest installed Microsoft.NETCore.App major version, or None."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
majors = []
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0] == "Microsoft.NETCore.App":
|
||||
try:
|
||||
majors.append(int(parts[1].split(".")[0]))
|
||||
except ValueError:
|
||||
continue
|
||||
return max(majors, default=None)
|
||||
|
||||
|
||||
def fetch_dotnet() -> str:
|
||||
"""Resolve a system dotnet host able to run vpk (needs .NET >= 10).
|
||||
|
||||
The .NET SDK is a build prerequisite installed on the build machine —
|
||||
downloading a runtime per build is slow and flaky. Several dotnet
|
||||
installations may coexist (PATH may resolve to a runtime-only .NET 8
|
||||
while scoop holds the SDK 10), so probe known locations and pick the
|
||||
newest runtime rather than the first that runs.
|
||||
"""
|
||||
exe_name = "dotnet.exe" if sys.platform == "win32" else "dotnet"
|
||||
candidates: list[Path] = []
|
||||
root = os.environ.get("DOTNET_ROOT")
|
||||
if root:
|
||||
candidates.append(Path(root) / exe_name)
|
||||
which = shutil.which("dotnet")
|
||||
if which:
|
||||
candidates.append(Path(which))
|
||||
if sys.platform == "win32":
|
||||
candidates += [
|
||||
Path(r"C:\ProgramData\scoop\apps\dotnet-sdk\current") / exe_name,
|
||||
Path(r"C:\Program Files\dotnet") / exe_name,
|
||||
]
|
||||
elif sys.platform == "darwin":
|
||||
candidates += [
|
||||
Path("/opt/homebrew/bin") / exe_name,
|
||||
Path("/usr/local/share/dotnet") / exe_name,
|
||||
]
|
||||
else:
|
||||
candidates += [
|
||||
Path("/usr/share/dotnet") / exe_name,
|
||||
Path("/usr/lib/dotnet") / exe_name,
|
||||
Path.home() / ".dotnet" / exe_name,
|
||||
]
|
||||
|
||||
best: tuple[int, Path] | None = None
|
||||
for exe in candidates:
|
||||
if not exe.exists():
|
||||
continue
|
||||
major = _dotnet_runtime_major(exe)
|
||||
if major is not None and (best is None or major > best[0]):
|
||||
best = (major, exe)
|
||||
|
||||
if best is not None and best[0] >= _VPK_REQUIRED_DOTNET_MAJOR:
|
||||
print(f"Using dotnet at {best[1]} (.NET {best[0]})")
|
||||
return str(best[1])
|
||||
|
||||
found = f"newest found is .NET {best[0]} at {best[1]}" if best else "none found"
|
||||
raise RuntimeError(
|
||||
f"vpk requires Microsoft.NETCore.App >= {_VPK_REQUIRED_DOTNET_MAJOR} ({found}). "
|
||||
"Install the current .NET SDK on this build machine "
|
||||
"(Windows: `scoop install dotnet-sdk`; macOS: `brew install dotnet-sdk`; "
|
||||
"Linux: distro `dotnet-sdk` package or the dotnet-install script)."
|
||||
)
|
||||
|
||||
|
||||
def build_velopack(version: str) -> Path:
|
||||
"""Build the Velopack installer/bundle for this platform.
|
||||
|
||||
Windows: per-user Setup.exe. macOS: .pkg installer. Linux: .AppImage.
|
||||
Also produces the update feed (releases.<channel>.json, *.nupkg) in
|
||||
build/velopack/ for release.py to upload — in-app auto-updates read it
|
||||
from the Gitea release. Velopack installs carry no Mark-of-the-Web, so
|
||||
the .NET CLR loads pythonnet/pywebview assemblies that it refuses from
|
||||
a downloaded ZIP.
|
||||
"""
|
||||
plat = _platform()
|
||||
dist_folder = _REPO_ROOT / "build" / plat.dist_dir
|
||||
if not dist_folder.exists():
|
||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||
|
||||
vpk_dll = fetch_vpk()
|
||||
releases_dir = _REPO_ROOT / "build" / "velopack"
|
||||
|
||||
cmd = [
|
||||
fetch_dotnet(),
|
||||
str(vpk_dll),
|
||||
"pack",
|
||||
"--packId",
|
||||
"MediaHive",
|
||||
"--packVersion",
|
||||
version,
|
||||
"--packDir",
|
||||
str(dist_folder),
|
||||
"--mainExe",
|
||||
plat.main_exe,
|
||||
"--packAuthors",
|
||||
"MediaHive",
|
||||
"--packTitle",
|
||||
"MediaHive",
|
||||
"--icon",
|
||||
str(_ASSETS_DIR / plat.icon),
|
||||
"--runtime",
|
||||
plat.rid,
|
||||
"--outputDir",
|
||||
str(releases_dir),
|
||||
]
|
||||
if sys.platform == "darwin":
|
||||
cmd += ["--instWelcome", str(_ASSETS_DIR / "macos-installer-welcome.txt")]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
try:
|
||||
result = subprocess.run(cmd, cwd=_REPO_ROOT, capture_output=True, text=True)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"vpk failed to start: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"vpk pack failed with exit code {result.returncode}\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
setup = next(iter(sorted(releases_dir.glob(f"*Setup*{plat.setup_ext}"))), None)
|
||||
if setup is None:
|
||||
setup = next(iter(sorted(releases_dir.glob(f"*{plat.setup_ext}"))), None)
|
||||
if setup is None:
|
||||
raise RuntimeError(f"vpk produced no *{plat.setup_ext} in {releases_dir}")
|
||||
if sys.platform == "darwin":
|
||||
force_macos_user_install(setup)
|
||||
artifact = _REPO_ROOT / "build" / setup_artifact_name(version)
|
||||
artifact.unlink(missing_ok=True)
|
||||
setup.rename(artifact)
|
||||
rename_feed_package(releases_dir, version, plat.channel)
|
||||
return artifact
|
||||
|
||||
|
||||
def rename_feed_package(releases_dir: Path, version: str, channel: str) -> None:
|
||||
"""Rename this platform's update-feed nupkg in place.
|
||||
|
||||
vpk hardcodes MediaHive-{ver}[-{channel}]-full.nupkg (Windows, the legacy
|
||||
default channel, gets no marker). Rename all to the uniform
|
||||
mediahive-{ver}-{channel}-full.nupkg: lowercase groups them with the
|
||||
wheel/sdist below the capitalized user downloads on the release page,
|
||||
and every platform carries its channel. releases.<channel>.json
|
||||
references the filename, so patch it too.
|
||||
"""
|
||||
old_name = f"MediaHive-{version}-full.nupkg"
|
||||
if not (releases_dir / old_name).exists():
|
||||
old_name = f"MediaHive-{version}-{channel}-full.nupkg"
|
||||
nupkg = releases_dir / old_name
|
||||
if not nupkg.exists():
|
||||
raise RuntimeError(f"vpk produced no {old_name} in {releases_dir}")
|
||||
|
||||
new_name = f"mediahive-{version}-{channel}-full.nupkg"
|
||||
manifest = releases_dir / f"releases.{channel}.json"
|
||||
text = manifest.read_text()
|
||||
if old_name not in text:
|
||||
raise RuntimeError(f"{manifest.name} does not reference {old_name}")
|
||||
manifest.write_text(text.replace(old_name, new_name))
|
||||
nupkg.rename(nupkg.with_name(new_name))
|
||||
|
||||
|
||||
def force_macos_user_install(pkg: Path) -> None:
|
||||
"""Restrict the Velopack-generated pkg to per-user installs (~/Applications).
|
||||
|
||||
Velopack hardcodes two install domains (currentUserHome + localSystem) in
|
||||
the distribution XML. System installs land in /Applications, which the
|
||||
user may not own — Velopack's UpdateMac then cannot replace the .app on
|
||||
auto-update. With a single domain, macOS Installer skips the Destination
|
||||
Select page and installs to ~/Applications without admin rights.
|
||||
|
||||
Also drops the `sudo -u "$USER"` prefix from Velopack's postinstall
|
||||
script: under a per-user install the script already runs as the
|
||||
installing user, and sudo would fail for lack of a tty.
|
||||
"""
|
||||
expanded = pkg.with_name(pkg.stem + "-expanded")
|
||||
shutil.rmtree(expanded, ignore_errors=True)
|
||||
# --expand-full also expands the component pkg, exposing its Scripts dir
|
||||
subprocess.run(["pkgutil", "--expand-full", str(pkg), str(expanded)], check=True)
|
||||
|
||||
dist_xml = expanded / "Distribution"
|
||||
xml = dist_xml.read_text()
|
||||
new_xml, count = re.subn(
|
||||
r"<domains [^>]*/>",
|
||||
'<domains enable_anywhere="false" enable_currentUserHome="true" enable_localSystem="false" />',
|
||||
xml,
|
||||
)
|
||||
if count != 1:
|
||||
raise RuntimeError("Unexpected distribution.xml: <domains> not found")
|
||||
dist_xml.write_text(new_xml)
|
||||
|
||||
postinstalls = list(expanded.glob("*.pkg/Scripts/postinstall"))
|
||||
if len(postinstalls) != 1:
|
||||
raise RuntimeError(f"Unexpected pkg layout: postinstalls={postinstalls}")
|
||||
postinstall = postinstalls[0]
|
||||
script = postinstall.read_text()
|
||||
postinstall.write_text(script.replace('sudo -u "$USER" ', ""))
|
||||
|
||||
subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True)
|
||||
shutil.rmtree(expanded)
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
|
||||
return setuptools_scm.get_version(root=str(_REPO_ROOT))
|
||||
@@ -216,18 +491,18 @@ def build_executable() -> None:
|
||||
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
|
||||
|
||||
|
||||
def create_zip(version: str) -> Path:
|
||||
"""Create a version-numbered ZIP file of the build/MediaHive folder."""
|
||||
repo_root = _REPO_ROOT
|
||||
dist_folder = repo_root / "build" / "MediaHive"
|
||||
def create_portable_zip(version: str) -> Path:
|
||||
"""Create the Windows portable ZIP of the build/MediaHive folder.
|
||||
|
||||
Velopack-less plain-folder distribution for users who cannot or do not
|
||||
want to run Setup.exe. No auto-updates; the app strips Mark-of-the-Web
|
||||
from bundled DLLs at first run instead.
|
||||
"""
|
||||
dist_folder = _REPO_ROOT / "build" / "MediaHive"
|
||||
if not dist_folder.exists():
|
||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||
|
||||
zip_name = f"MediaHive-{version}-{_platform_zip_suffix()}.zip"
|
||||
zip_path = repo_root / "build" / zip_name
|
||||
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
zip_path = _REPO_ROOT / "build" / f"MediaHive-{version}-win64-portable.zip"
|
||||
print(f"Creating {zip_path}...")
|
||||
shutil.make_archive(
|
||||
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
|
||||
@@ -238,6 +513,9 @@ def create_zip(version: str) -> Path:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Windows consoles default to cp1252, which can't encode ✓/✗
|
||||
sys.stdout.reconfigure(errors="replace")
|
||||
sys.stderr.reconfigure(errors="replace")
|
||||
try:
|
||||
version = read_version()
|
||||
print(f"MediaHive version: {version}")
|
||||
@@ -254,10 +532,14 @@ def main() -> None:
|
||||
)
|
||||
build_wheel()
|
||||
build_executable()
|
||||
zip_path = create_zip(version)
|
||||
|
||||
print(f"✓ Built successfully: {zip_path}")
|
||||
print(f" Size: {zip_path.stat().st_size / (1024 * 1024):.1f} MB")
|
||||
artifacts = [build_velopack(version)]
|
||||
if sys.platform == "win32":
|
||||
artifacts.append(create_portable_zip(version))
|
||||
|
||||
for artifact_path in artifacts:
|
||||
print(f"✓ Built successfully: {artifact_path}")
|
||||
print(f" Size: {artifact_path.stat().st_size / (1024 * 1024):.1f} MB")
|
||||
except (FileNotFoundError, OSError, RuntimeError, ValueError) as e:
|
||||
print(f"✗ Build failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
Regular → Executable
+101
-28
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Publish a MediaHive release to Gitea.
|
||||
|
||||
Usage:
|
||||
@@ -8,10 +9,14 @@ Reads from [project.urls] Repository in pyproject.toml.
|
||||
Token: GITEA_TOKEN environment variable
|
||||
|
||||
Steps:
|
||||
1. Find clean-versioned ZIPs in build/ and matching dist/ wheels/sdists
|
||||
2. Abort if any dist files are missing for a found ZIP version
|
||||
3. Create a Gitea release for each version and upload all assets
|
||||
1. Find clean-versioned platform artifacts in build/ and matching dist/ wheels/sdists
|
||||
2. Abort if any dist files are missing for a found artifact version
|
||||
3. Create a Gitea release for each version (or reuse the existing one
|
||||
for the tag, skipping already-uploaded assets) and upload all assets
|
||||
4. Remind the user to run: uv publish
|
||||
|
||||
Parallel CI platform builds converge on one release per tag; pass --no-dist
|
||||
on all but one platform so only it uploads the wheel/sdist.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -62,17 +67,20 @@ def load_token() -> str:
|
||||
# ZIP + dist helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-macos-arm64.zip, etc.
|
||||
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip
|
||||
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.zip$")
|
||||
# Matches MediaHive-1.2.3-win64-portable.zip, MediaHive-1.2.3-win64-setup.exe,
|
||||
# MediaHive-1.2.3-macos-setup.pkg, MediaHive-1.2.3-linux-setup.AppImage, etc.
|
||||
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64-portable.zip
|
||||
_CLEAN_ARTIFACT_RE = re.compile(
|
||||
r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.(?:zip|dmg|exe|pkg|AppImage)$"
|
||||
)
|
||||
|
||||
|
||||
def find_releasable_zips() -> list[tuple[Path, str, str]]:
|
||||
"""Return (path, version, platform_tag) for clean-versioned ZIPs in build/."""
|
||||
def find_releasable_artifacts() -> list[tuple[Path, str, str]]:
|
||||
"""Return (path, version, platform_tag) for clean-versioned artifacts in build/."""
|
||||
build_dir = REPO_ROOT / "build"
|
||||
results = []
|
||||
for p in sorted(build_dir.glob("MediaHive-*.zip")):
|
||||
m = _CLEAN_ZIP_RE.match(p.name)
|
||||
for p in sorted(build_dir.glob("MediaHive-*")):
|
||||
m = _CLEAN_ARTIFACT_RE.match(p.name)
|
||||
if m:
|
||||
results.append((p, m.group(1), m.group(2)))
|
||||
return results
|
||||
@@ -107,6 +115,23 @@ def find_dist_files(version: str) -> list[Path]:
|
||||
return [wheel, sdist]
|
||||
|
||||
|
||||
def find_velopack_feed_files() -> list[Path]:
|
||||
"""Velopack update feed files produced by vpk pack in build/velopack/.
|
||||
|
||||
Only what the in-app updater (GiteaSource) reads from the latest
|
||||
release: this channel's releases.<channel>.json index and the nupkg
|
||||
payload it points to. The legacy RELEASES and assets.*.json manifests
|
||||
(Squirrel compat / setup bootstrap) are not uploaded.
|
||||
"""
|
||||
releases_dir = REPO_ROOT / "build" / "velopack"
|
||||
if not releases_dir.exists():
|
||||
return []
|
||||
files: list[Path] = []
|
||||
for pattern in ("releases.*.json", "*.nupkg"):
|
||||
files.extend(sorted(releases_dir.glob(pattern)))
|
||||
return files
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea API helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -116,6 +141,18 @@ def gitea_headers(token: str) -> dict:
|
||||
return {"Authorization": f"token {token}", "Accept": "application/json"}
|
||||
|
||||
|
||||
def get_release_by_tag(
|
||||
client: httpx.Client, base_url: str, repo: str, tag: str
|
||||
) -> dict | None:
|
||||
"""Return the existing release for a tag, or None."""
|
||||
url = f"{base_url}/api/v1/repos/{repo}/releases/tags/{tag}"
|
||||
resp = client.get(url)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def create_release(
|
||||
client: httpx.Client,
|
||||
base_url: str,
|
||||
@@ -124,8 +161,12 @@ def create_release(
|
||||
version: str,
|
||||
notes: str,
|
||||
draft: bool,
|
||||
) -> int:
|
||||
"""Create a Gitea release and return its id."""
|
||||
) -> tuple[int, set[str]]:
|
||||
"""Create a Gitea release, or reuse the existing one for the tag.
|
||||
|
||||
Returns (release_id, names of assets already attached), so parallel
|
||||
platform builds can converge on one release without conflicts.
|
||||
"""
|
||||
url = f"{base_url}/api/v1/repos/{repo}/releases"
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
@@ -136,11 +177,17 @@ def create_release(
|
||||
}
|
||||
resp = client.post(url, json=payload)
|
||||
if resp.status_code == 409:
|
||||
raise RuntimeError(f"A release for tag '{tag}' already exists on Gitea.")
|
||||
existing = get_release_by_tag(client, base_url, repo, tag)
|
||||
if existing is None:
|
||||
raise RuntimeError(f"Release for tag '{tag}' conflicts but cannot be read.")
|
||||
release_id = existing["id"]
|
||||
assets = {a["name"] for a in existing.get("assets", [])}
|
||||
print(f"Release for tag '{tag}' already exists (id={release_id}), reusing it.")
|
||||
return release_id, assets
|
||||
resp.raise_for_status()
|
||||
release_id = resp.json()["id"]
|
||||
print(f"Created release id={release_id} (draft={draft})")
|
||||
return release_id
|
||||
return release_id, set()
|
||||
|
||||
|
||||
def upload_asset(
|
||||
@@ -153,7 +200,10 @@ def upload_asset(
|
||||
"""Upload a file to the release and return the download URL."""
|
||||
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
|
||||
size_mb = path.stat().st_size / (1024 * 1024)
|
||||
mime = "application/zip" if path.suffix == ".zip" else "application/octet-stream"
|
||||
mime = {
|
||||
".zip": "application/zip",
|
||||
".dmg": "application/x-apple-diskimage",
|
||||
}.get(path.suffix, "application/octet-stream")
|
||||
print(f"Uploading {path.name} ({size_mb:.1f} MB) ...")
|
||||
with Path(path).open("rb") as fh:
|
||||
resp = client.post(
|
||||
@@ -173,6 +223,10 @@ def upload_asset(
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Windows consoles default to cp1252, which can't encode ✓/✗
|
||||
sys.stdout.reconfigure(errors="replace")
|
||||
sys.stderr.reconfigure(errors="replace")
|
||||
|
||||
parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea")
|
||||
parser.add_argument(
|
||||
"--draft", action="store_true", help="Create as a draft release"
|
||||
@@ -180,16 +234,21 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"--notes", default="", metavar="TEXT", help="Release notes body"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-dist",
|
||||
action="store_true",
|
||||
help="Skip wheel/sdist upload (for parallel platform builds; one job uploads them)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
cfg = load_gitea_config()
|
||||
token = load_token()
|
||||
|
||||
zips = find_releasable_zips()
|
||||
if not zips:
|
||||
artifacts = find_releasable_artifacts()
|
||||
if not artifacts:
|
||||
print(
|
||||
"No clean-versioned ZIPs found in build/.\n"
|
||||
"No clean-versioned platform artifacts found in build/.\n"
|
||||
"Run scripts/guibuild.py first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -197,28 +256,42 @@ def main() -> None:
|
||||
|
||||
# Validate all dist files exist before touching Gitea
|
||||
dist_files: dict[str, list[Path]] = {}
|
||||
for _, version, _platform_tag in zips:
|
||||
dist_files[version] = find_dist_files(version)
|
||||
if not args.no_dist:
|
||||
for _, version, _platform_tag in artifacts:
|
||||
dist_files[version] = find_dist_files(version)
|
||||
|
||||
base_url = cfg["url"].rstrip("/")
|
||||
repo = cfg["repo"]
|
||||
|
||||
with httpx.Client(headers=gitea_headers(token)) as client:
|
||||
release_ids_by_version: dict[str, int] = {}
|
||||
for zip_path, version, platform_tag in zips:
|
||||
releases: dict[str, tuple[int, set[str]]] = {}
|
||||
for artifact_path, version, platform_tag in artifacts:
|
||||
print(f"\nReleasing {version} ...")
|
||||
tag = f"v{version}"
|
||||
release_id = release_ids_by_version.get(version)
|
||||
if release_id is None:
|
||||
release_id = create_release(
|
||||
if version not in releases:
|
||||
releases[version] = create_release(
|
||||
client, base_url, repo, tag, version, args.notes, args.draft
|
||||
)
|
||||
release_ids_by_version[version] = release_id
|
||||
for path in dist_files[version]:
|
||||
release_id, uploaded = releases[version]
|
||||
for path in dist_files.get(version, []):
|
||||
if path.name in uploaded:
|
||||
print(f"Skipping {path.name}, already on the release.")
|
||||
continue
|
||||
upload_asset(client, base_url, repo, release_id, path)
|
||||
|
||||
release_id, uploaded = releases[version]
|
||||
if artifact_path.name in uploaded:
|
||||
print(f"Skipping {artifact_path.name}, already on the release.")
|
||||
continue
|
||||
print(f"Uploading platform artifact: {platform_tag}")
|
||||
upload_asset(client, base_url, repo, release_id, zip_path)
|
||||
upload_asset(client, base_url, repo, release_id, artifact_path)
|
||||
uploaded.add(artifact_path.name)
|
||||
for feed_file in find_velopack_feed_files():
|
||||
if feed_file.name in uploaded:
|
||||
print(f"Skipping {feed_file.name}, already on the release.")
|
||||
continue
|
||||
upload_asset(client, base_url, repo, release_id, feed_file)
|
||||
uploaded.add(feed_file.name)
|
||||
print(f" ✓ {tag} published")
|
||||
|
||||
print("\nDone. To publish to PyPI, run:")
|
||||
|
||||
@@ -1,467 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Torrent Scanner - Scans for .torrent files and analyzes their trackers."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import shutil
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import bencodepy
|
||||
|
||||
from rtorrent_client import RTorrentClient
|
||||
|
||||
|
||||
def _expand_path_pattern(pattern: str) -> list[Path]:
|
||||
"""Expand a user-provided path or glob pattern with pathlib."""
|
||||
expanded = Path(pattern).expanduser()
|
||||
pattern_text = str(expanded)
|
||||
has_glob = any(ch in pattern_text for ch in "*?[")
|
||||
|
||||
if not has_glob:
|
||||
return [expanded] if expanded.exists() else []
|
||||
|
||||
normalized = pattern_text.replace("\\", "/")
|
||||
if expanded.is_absolute():
|
||||
root = Path(expanded.anchor)
|
||||
remainder = normalized[len(expanded.anchor) :].lstrip("/")
|
||||
return list(root.glob(remainder)) if remainder else []
|
||||
return list(Path().glob(normalized))
|
||||
|
||||
|
||||
@dataclass
|
||||
class TorrentInfo:
|
||||
"""Information extracted from a torrent file."""
|
||||
|
||||
path: Path
|
||||
name: str
|
||||
trackers: list[str]
|
||||
size: int | None = None
|
||||
files: list[str] | None = None
|
||||
info_hash: str | None = None
|
||||
is_multi_file: bool = False
|
||||
|
||||
def has_tracker(self, domain: str) -> bool:
|
||||
"""Check if any tracker URL contains the given domain."""
|
||||
return any(domain.lower() in tracker.lower() for tracker in self.trackers)
|
||||
|
||||
def get_download_directory(self) -> Path:
|
||||
"""Get the download directory (parent of .torrents folder).
|
||||
|
||||
Assumes .torrent files are in <download_dir>/.torrents/
|
||||
so the actual downloads are one level up.
|
||||
"""
|
||||
return self.path.parent.parent
|
||||
|
||||
def get_expected_data_path(self) -> Path:
|
||||
"""Get the expected path where downloaded data should exist.
|
||||
|
||||
For multi-file torrents: download_dir/torrent_name/ (directory)
|
||||
For single-file torrents: download_dir/torrent_name (file)
|
||||
"""
|
||||
return self.get_download_directory() / self.name
|
||||
|
||||
def verify_download_exists(self) -> tuple[bool, str]:
|
||||
"""Verify that the downloaded data exists on disk.
|
||||
|
||||
Returns:
|
||||
Tuple of (exists: bool, message: str)
|
||||
|
||||
"""
|
||||
expected_path = self.get_expected_data_path()
|
||||
|
||||
if self.is_multi_file:
|
||||
# Multi-file torrent: expect a directory
|
||||
if not expected_path.exists():
|
||||
return False, f"Directory not found: {expected_path}"
|
||||
if not expected_path.is_dir():
|
||||
return False, f"Expected directory but found file: {expected_path}"
|
||||
# Optionally check if at least some files exist
|
||||
existing_files = list(expected_path.rglob("*"))
|
||||
file_count = sum(1 for f in existing_files if f.is_file())
|
||||
if file_count == 0:
|
||||
return False, f"Directory exists but is empty: {expected_path}"
|
||||
return True, f"Directory exists with {file_count} files"
|
||||
# Single-file torrent: expect a file
|
||||
if not expected_path.exists():
|
||||
return False, f"File not found: {expected_path}"
|
||||
if expected_path.is_dir():
|
||||
return False, f"Expected file but found directory: {expected_path}"
|
||||
return True, f"File exists: {expected_path}"
|
||||
|
||||
|
||||
def parse_torrent(filepath: Path) -> TorrentInfo | None:
|
||||
"""Parse a .torrent file and extract relevant information.
|
||||
|
||||
Args:
|
||||
filepath: Path to the .torrent file
|
||||
|
||||
Returns:
|
||||
TorrentInfo object or None if parsing fails
|
||||
|
||||
"""
|
||||
try:
|
||||
data = bencodepy.decode(Path(filepath).read_bytes())
|
||||
except (OSError, ValueError, TypeError) as e:
|
||||
print(f"Error parsing {filepath}: {e}")
|
||||
return None
|
||||
|
||||
# Extract trackers
|
||||
trackers = []
|
||||
|
||||
# Main announce URL
|
||||
if b"announce" in data:
|
||||
announce = data[b"announce"]
|
||||
if isinstance(announce, bytes):
|
||||
trackers.append(announce.decode("utf-8", errors="replace"))
|
||||
|
||||
# Announce list (multiple trackers)
|
||||
if b"announce-list" in data:
|
||||
for tier in data[b"announce-list"]:
|
||||
for tracker in tier:
|
||||
if isinstance(tracker, bytes):
|
||||
url = tracker.decode("utf-8", errors="replace")
|
||||
if url not in trackers:
|
||||
trackers.append(url)
|
||||
|
||||
# Extract name
|
||||
info = data.get(b"info", {})
|
||||
name = info.get(b"name", b"Unknown").decode("utf-8", errors="replace")
|
||||
|
||||
# Calculate info hash
|
||||
info_hash = hashlib.sha1(bencodepy.encode(info)).hexdigest().upper()
|
||||
|
||||
# Extract size and files
|
||||
size = None
|
||||
files = None
|
||||
is_multi_file = False
|
||||
|
||||
if b"length" in info:
|
||||
# Single file torrent
|
||||
size = info[b"length"]
|
||||
files = [name]
|
||||
is_multi_file = False
|
||||
elif b"files" in info:
|
||||
# Multi-file torrent
|
||||
files = []
|
||||
size = 0
|
||||
is_multi_file = True
|
||||
for file_info in info[b"files"]:
|
||||
file_path = "/".join(
|
||||
p.decode("utf-8", errors="replace") for p in file_info.get(b"path", [])
|
||||
)
|
||||
files.append(file_path)
|
||||
size += file_info.get(b"length", 0)
|
||||
|
||||
return TorrentInfo(
|
||||
path=filepath,
|
||||
name=name,
|
||||
trackers=trackers,
|
||||
size=size,
|
||||
files=files,
|
||||
info_hash=info_hash,
|
||||
is_multi_file=is_multi_file,
|
||||
)
|
||||
|
||||
|
||||
def scan_torrent_directories(paths: list[str]) -> Iterator[Path]:
|
||||
"""Scan directories for .torrent files.
|
||||
|
||||
Args:
|
||||
paths: List of directory paths or glob patterns to scan
|
||||
|
||||
Yields:
|
||||
Path objects for each .torrent file found
|
||||
|
||||
"""
|
||||
for pattern in paths:
|
||||
for torrent_dir in _expand_path_pattern(pattern):
|
||||
if torrent_dir.is_dir():
|
||||
yield from torrent_dir.glob("*.torrent")
|
||||
|
||||
|
||||
def find_torrents_with_tracker(
|
||||
tracker_domain: str, paths: list[str]
|
||||
) -> list[TorrentInfo]:
|
||||
"""Find all torrents that have a specific tracker domain.
|
||||
|
||||
Args:
|
||||
tracker_domain: Domain to search for in tracker URLs (e.g., "hdbits.org")
|
||||
paths: List of directory paths or glob patterns to scan
|
||||
|
||||
Returns:
|
||||
List of TorrentInfo objects for matching torrents
|
||||
|
||||
"""
|
||||
matching_torrents = []
|
||||
|
||||
for torrent_path in scan_torrent_directories(paths):
|
||||
info = parse_torrent(torrent_path)
|
||||
if info and info.has_tracker(tracker_domain):
|
||||
matching_torrents.append(info)
|
||||
|
||||
return matching_torrents
|
||||
|
||||
|
||||
def format_size(size_bytes: int | None) -> str:
|
||||
"""Format bytes as human-readable size."""
|
||||
if size_bytes is None:
|
||||
return "Unknown"
|
||||
|
||||
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes:.2f} {unit}"
|
||||
size_bytes /= 1024
|
||||
return f"{size_bytes:.2f} PB"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the torrent scanner command-line workflow."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Scan and manage torrent files",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s /path/to/torrents*/.torrents/
|
||||
%(prog)s /mnt/disk1/torrents/.torrents/ /mnt/disk2/torrents/.torrents/
|
||||
%(prog)s /torrents*/.torrents/ --tracker hdbits.org
|
||||
%(prog)s /torrents*/.torrents/ --dry
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
"paths",
|
||||
nargs="+",
|
||||
help="Directories or glob patterns containing .torrent files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry",
|
||||
action="store_true",
|
||||
help="Dry run - show what would be done without making changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tracker",
|
||||
default="hdbits.org",
|
||||
help="Tracker domain to filter by (default: hdbits.org)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
dry_run = args.dry
|
||||
tracker_domain = args.tracker
|
||||
|
||||
# Expand glob patterns
|
||||
expanded_paths = []
|
||||
for pattern in args.paths:
|
||||
matches = [str(path) for path in _expand_path_pattern(pattern)]
|
||||
if matches:
|
||||
expanded_paths.extend(matches)
|
||||
else:
|
||||
expanded_paths.append(pattern)
|
||||
|
||||
if dry_run:
|
||||
print("=" * 60)
|
||||
print("DRY RUN MODE - No changes will be made")
|
||||
print("=" * 60)
|
||||
|
||||
print("Scanning for torrents...")
|
||||
print(f"Search paths: {expanded_paths}")
|
||||
print("-" * 60)
|
||||
|
||||
# Parse all torrents
|
||||
all_torrents: list[TorrentInfo] = []
|
||||
for torrent_path in scan_torrent_directories(expanded_paths):
|
||||
info = parse_torrent(torrent_path)
|
||||
if info:
|
||||
all_torrents.append(info)
|
||||
|
||||
# Separate by tracker
|
||||
with_hdbits = [t for t in all_torrents if t.has_tracker(tracker_domain)]
|
||||
without_hdbits = [t for t in all_torrents if not t.has_tracker(tracker_domain)]
|
||||
|
||||
# Print stats
|
||||
print(f"\n{'=' * 60}")
|
||||
print("SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f"Total torrents scanned: {len(all_torrents)}")
|
||||
print(f"With {tracker_domain}: {len(with_hdbits)}")
|
||||
print(f"Without {tracker_domain}: {len(without_hdbits)}")
|
||||
|
||||
# Add hdbits torrents to rtorrent
|
||||
if with_hdbits:
|
||||
print(f"\n{'=' * 60}")
|
||||
print("VERIFYING DOWNLOADS & ADDING TO RTORRENT")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
# First, verify which torrents have their data
|
||||
verified = []
|
||||
missing_data = []
|
||||
|
||||
for torrent in with_hdbits:
|
||||
exists, message = torrent.verify_download_exists()
|
||||
if exists:
|
||||
verified.append(torrent)
|
||||
else:
|
||||
missing_data.append((torrent, message))
|
||||
|
||||
print("\nVerification results:")
|
||||
print(f" Downloads found: {len(verified)}")
|
||||
print(f" Downloads missing: {len(missing_data)}")
|
||||
|
||||
# Report missing downloads
|
||||
if missing_data:
|
||||
print(f"\n{'=' * 60}")
|
||||
print("TORRENTS WITH MISSING DATA (will not add)")
|
||||
print(f"{'=' * 60}")
|
||||
for torrent, message in missing_data:
|
||||
print(f"\n Name: {torrent.name}")
|
||||
print(f" Torrent: {torrent.path}")
|
||||
print(f" Reason: {message}")
|
||||
|
||||
# Now add verified torrents to rtorrent
|
||||
if verified:
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"ADDING {len(verified)} VERIFIED TORRENTS TO RTORRENT")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
client = RTorrentClient()
|
||||
loaded_hashes = client.get_loaded_hashes()
|
||||
print(f"Currently loaded in rtorrent: {len(loaded_hashes)} torrents")
|
||||
|
||||
added = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
for torrent in verified:
|
||||
if torrent.info_hash and torrent.info_hash in loaded_hashes:
|
||||
print(f"Skipping (already loaded): {torrent.name}")
|
||||
skipped += 1
|
||||
else:
|
||||
download_dir = torrent.get_download_directory()
|
||||
if dry_run:
|
||||
print(f"Would add: {torrent.name}")
|
||||
print(f" Download dir: {download_dir}")
|
||||
added += 1
|
||||
else:
|
||||
print(f"Adding: {torrent.name}")
|
||||
print(f" Download dir: {download_dir}")
|
||||
if client.load_torrent(torrent.path, download_dir):
|
||||
added += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if dry_run:
|
||||
print(f"\nDry run: {added} would be added, {skipped} already loaded")
|
||||
else:
|
||||
print(
|
||||
f"\nRtorrent results: {added} added, "
|
||||
f"{skipped} skipped, {failed} failed"
|
||||
)
|
||||
|
||||
# Clean up unregistered torrents from rtorrent
|
||||
print(f"\n{'=' * 60}")
|
||||
print("CHECKING FOR UNREGISTERED TORRENTS")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
client = RTorrentClient()
|
||||
unregistered = client.get_unregistered_torrents()
|
||||
|
||||
if unregistered:
|
||||
print(f"Found {len(unregistered)} unregistered torrent(s):\n")
|
||||
|
||||
removed_from_rtorrent = 0
|
||||
removed_torrent_files = 0
|
||||
removed_downloads = 0
|
||||
|
||||
for torrent_info in unregistered:
|
||||
# Determine the download path (base_path is the actual file/folder)
|
||||
download_path = (
|
||||
Path(torrent_info["base_path"]) if torrent_info["base_path"] else None
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
status = "[DRY]"
|
||||
if download_path:
|
||||
print(f" {status} {download_path}")
|
||||
else:
|
||||
print(f" {status} {torrent_info['name']} (no data path)")
|
||||
# Remove from rtorrent (keeps downloaded files)
|
||||
elif client.remove_torrent(torrent_info["hash"]):
|
||||
removed_from_rtorrent += 1
|
||||
|
||||
# Delete the .torrent file if it exists
|
||||
tied_file = torrent_info["tied_file"]
|
||||
if tied_file:
|
||||
torrent_file = Path(tied_file)
|
||||
if torrent_file.exists():
|
||||
try:
|
||||
torrent_file.unlink()
|
||||
removed_torrent_files += 1
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Delete the downloaded files
|
||||
if download_path and download_path.exists():
|
||||
try:
|
||||
if download_path.is_dir():
|
||||
shutil.rmtree(download_path)
|
||||
else:
|
||||
download_path.unlink()
|
||||
removed_downloads += 1
|
||||
print(f" [DEL] {download_path}")
|
||||
except OSError as e:
|
||||
print(f" [ERR] {download_path}: {e}")
|
||||
else:
|
||||
print(f" [DEL] {torrent_info['name']} (no data)")
|
||||
else:
|
||||
print(f" [ERR] {torrent_info['name']}: failed to remove from rtorrent")
|
||||
|
||||
print()
|
||||
if dry_run:
|
||||
print(
|
||||
f"Dry run: {len(unregistered)} would be removed "
|
||||
"(rtorrent + .torrent + downloads)"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"Cleanup: {removed_from_rtorrent} from rtorrent, "
|
||||
f"{removed_torrent_files} .torrents, {removed_downloads} downloads"
|
||||
)
|
||||
else:
|
||||
print("No unregistered torrents found.")
|
||||
|
||||
# List torrents without hdbits.org
|
||||
if without_hdbits:
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"TORRENTS WITHOUT {tracker_domain.upper()}")
|
||||
print(f"{'=' * 60}")
|
||||
for torrent in without_hdbits:
|
||||
print(f"\nName: {torrent.name}")
|
||||
print(f"Path: {torrent.path}")
|
||||
print(f"Size: {format_size(torrent.size)}")
|
||||
if torrent.trackers:
|
||||
print("Trackers:")
|
||||
for tracker in torrent.trackers:
|
||||
print(f" - {tracker}")
|
||||
else:
|
||||
print("Trackers: (none)")
|
||||
|
||||
# Remove the non-hdbits torrent files
|
||||
print(f"\n{'=' * 60}")
|
||||
if dry_run:
|
||||
print(f"WOULD REMOVE {len(without_hdbits)} TORRENT FILE(S)")
|
||||
print(f"{'=' * 60}")
|
||||
for torrent in without_hdbits:
|
||||
print(f"Would remove: {torrent.path}")
|
||||
else:
|
||||
print(f"REMOVING {len(without_hdbits)} TORRENT FILE(S)")
|
||||
print(f"{'=' * 60}")
|
||||
for torrent in without_hdbits:
|
||||
try:
|
||||
torrent.path.unlink()
|
||||
print(f"Removed: {torrent.path}")
|
||||
except OSError as e:
|
||||
print(f"Failed to remove {torrent.path}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user