175 Commits
Author SHA1 Message Date
LeoVasanko 82d5eb28fb Add screenshot of the new series view. 2026-09-24 00:52:04 +00:00
LeoVasanko 1149e7cdfd Add pre-commit hooks and blame-ignore for the lint/format commit
release / gui-build (linux, bash) (push) Successful in 56s
release / gui-build (windows, cmd) (push) Successful in 1m21s
release / gui-build (macos, bash) (push) Successful in 1m50s
2026-09-24 00:37:25 +00:00
LeoVasanko 38f3ccebd3 Apply linters and formatters
- frontend: oxfmt across src, fix no-useless-escape in search-worker
- ruff check --fix: replace numeric rule codes with text names in
  pyproject.toml lint.ignore
- ruff format: scanning.py, guibuild.py, release.py
2026-09-24 00:26:53 +00:00
LeoVasanko c41504e70c Fix stale preview reels when switching between series
Episode tiles were keyed by season/episode index only, so switching
series reused the DOM: text bindings updated but <video> elements were
kept, and browsers ignore patched <source> children without load() —
the previous series' reels kept playing.

- Key SeriesFullView by item id so a series switch fully remounts it.
- Include a seriesIdentity (id, else root_id+title) in episode tile
  keys so tiles/videos remount even on an in-place series swap.
- Reset episode cursor, audio owner and preview startup timers when
  the series identity changes, and re-gate video mounting.
2026-09-23 21:35:03 +00:00
LeoVasanko bb70279e61 Clean up README 2026-09-23 21:30:51 +00:00
LeoVasanko 931f00f647 Fix season browser focus: land on first episode on down, ignore parked-cursor hover
- Add data-nav-entry-col-from-above so moving down from the season
  selector always focuses the first episode instead of the tile
  visually closest to the selected season poster; moving back up
  still returns to the current season.
- Require recent real mouse movement before treating mouseover as
  mouse intent: scroll/re-render under a stationary cursor no longer
  activates mouse mode, so sideways season browsing no longer loses
  focus to the episode tile under the parked pointer.
2026-09-23 21:30:17 +00:00
LeoVasanko 9041061e86 Fix image link in README to work also on PyPI. 2026-09-23 20:43:25 +00:00
LeoVasanko 5fc20c5578 Versionless installer asset names, stable latest-download README links
release / gui-build (linux, bash) (push) Successful in 1m0s
release / gui-build (windows, cmd) (push) Successful in 1m18s
release / gui-build (macos, bash) (push) Successful in 1m41s
2026-09-23 19:29:02 +00:00
LeoVasanko 550131d43b Publish wheel/sdist to PyPI from the linux release job 2026-09-23 18:56:33 +00:00
LeoVasanko f2fc6f657f Updated fastapi-vue-setup 1.7.2
release / gui-build (linux, bash) (push) Successful in 55s
release / gui-build (windows, cmd) (push) Successful in 1m19s
release / gui-build (macos, bash) (push) Failing after 1m14s
- DEVMODE removed; FASTAPI_VUE=MEDIAHIVE is set at entrypoints and
  fastapi_vue.env drives dev-mode checks
- CLI roots now reach the server via fastapi-vue's env(Config) teleport
  (mediahive.config.config) instead of MEDIAHIVE_ROOTS
- Legacy media_folder config field and migration removed
- mediahive.* logger level set via log_config (DEBUG in dev, INFO in prod)
  in both server.run() and winmain's patched uvicorn config
2026-09-23 18:41:51 +00:00
LeoVasanko 1477c240a1 Installers for all platforms and related fixes (#1)
- Windows portable ZIP remains, but has been fixed so that it isn't poisoned by being a web download
- Velopack installer offered on all platforms: Linux AppImage, Windows setup.exe, Mac setup.pkg
- Mac updated to Qt6 which has a modern Chromium as opposed to Qt5 that didn't
- Application log now shown on settings panel
- Automated cross-platform builds via Gitea actions

Reviewed-on: #1
2026-09-23 17:41:21 +00:00
LeoVasanko c891bc84a8 Detect sidecar subtitle languages and show their flags in the UI 2026-09-23 01:45:04 +00:00
LeoVasanko c11cbd4250 Fix console encoding crash in release script summary output 2026-09-10 21:33:43 +00:00
LeoVasanko 9779857dcd Update documentation for the v0.5.0 release 2026-09-10 21:30:24 +00:00
LeoVasanko 9ec4f877eb Resume tracking: per-episode positions, player-agnostic fallback, series continue point
- Fix merged playback-state dropping season/episode, which broke series
  resume restore entirely.
- Track watch progress per episode (episodes map keyed S..E.., with done
  markers on completion) while keeping one continue point per series as
  the last watched episode, used for spoiler protection and season
  selection. MPC-BE tracker resumes from each episode's own position.
- Assumed-playback fallback for any player and server-only mode: a launch
  starts a session and the guessed position (base + elapsed, capped at
  runtime) is written when frontend activity resumes; the real MPC-BE
  tracker overrides guesses via timestamps. Frontend reports input
  activity (throttled) via POST /api/activity.
- Ignore watches under 5 minutes in both trackers.
- Episode tiles show a small quadrant-circle watch indicator; near end
  counts as fully watched, no data shows nothing.
- Fix episode audio ownership: single audioOwnerKey shared by mouse hover
  and keyboard/gamepad focus, no longer clobbered by playback sync;
  idle fade can be re-armed by continued activity.
2026-09-10 20:56:03 +00:00
LeoVasanko 6a6a012efe Series detail: curtain poster browser for seasons
Replace the 3D ring jukebox with a flat curtain browser: selected season
faces the screen as top of the left stack, side cards rotate about their
outer edge and recede into the screen, each side evenly spaced with pitch
adapting to its card count so cards never leave the stage.

- Mount episode videos only after the season switch settles, then
  preload=auto + autoplay (avoids mid-animation media setup jank)
- Start videos immediately (no stagger) once an episode is pointed at;
  keep staggered starts while navigating the season selector
- Keyboard/gamepad entry into the season row lands on the selected
  season via data-nav-entry-col instead of the x-closest card
- Black episode tiles; hidden (ahead-of-cursor) tiles get a sheen and
  vignette veil that cross-fades on reveal/conceal
- Nudge season info panel 8px left for balanced gaps
2026-09-10 16:47:33 +00:00
LeoVasanko 2c28ab1f25 Series detail: one season at a time with 3D jukebox season selector
Render only the selected season's episodes; switch via a perspective-arc
poster carousel with floating season info, deferred video loading with
still-image fade-in, and spoiler fade for episodes ahead of the cursor.
2026-09-10 03:34:52 +00:00
LeoVasanko e6eadb2ecd Working dev reload on Windows; fast quiet scanner shutdown
uvicorn's reloader is unusable on Windows for this app: it restarts the
server child via CTRL_C_EVENT, which is never delivered to a plain spawn
child (no console process group of its own), so the child kept running
old code and the reloader blocked in process.join() after the first
reload — the scanner included.  And when a child does restart, uvicorn
hands it parent-bound sockets that ProactorEventLoop cannot register
with IOCP (WinError 87 on accept), while the selector loop would break
asyncio subprocesses (ffmpeg/ffprobe showreel generation).

In dev mode on Windows, mediahive now runs a small supervisor instead:
it watches the package with watchfiles and respawns a fresh child
process (which binds its own sockets) on every change, and terminates
the child on Ctrl-C.  POSIX keeps uvicorn's native reload.

Shutdown: RootScanner.stop() no longer does a final best-effort state
save — those to_thread writes ran on the asyncio default executor, whose
non-daemon threads blocked interpreter exit, so log lines appeared after
the shell prompt returned.  All scanner state is already persisted after
each completed scan and when the showreel queue drains.  Shutdown-time
log lines (scan cancelled, showreel worker stopping) move to debug.
2026-09-09 15:15:27 +00:00
LeoVasanko ff6b195973 Fix rescan loop: short-circuited registration dropped files from state
The directory worker registered video files with
'node["changed"] = node["changed"] or await _register_candidate(...)',
so once one file in a directory was found changed, the rest were never
registered: they fell out of known_paths, were pruned from the persisted
seen-mtimes at finalize, and were rediscovered as new on every scan.
That rotating ~10-item subset was reprocessed (metadata, TMDb lookups,
logs) every 30 seconds forever.

Also fix the self-sustaining reprocess loop for items that produce no
index entries (no playable file, no parseable episodes, non-media):
they never appear in the index, so the DB-aware mtime gating alone could
never skip them.  They are now recorded in a persisted empty_mtimes map
(scan-state.json) and skipped while their mtime is unchanged.

Episode/playable/blu-ray finder caches are now cleared at the start of
each scan: an empty result cached before a download finished (or during
a transient network-mount error) previously stuck for the process
lifetime and reported "no episodes found" for series that have episodes.

Logging: a no-change scan now emits a single line with item count and
duration; per-item "up to date" chatter moved to debug; change scans
report the new/changed count plus per-item reason lines (stored vs
current mtime, index/empty-marker membership), finalize prune/commit
traces, and scan-state load/save confirmations.

Add AGENTS.md noting the project's Python 3.14 baseline (PEP 758
unparenthesized except) so tooling does not flag it as a syntax error.
2026-09-09 14:40:32 +00:00
LeoVasanko 36e2fdd5ff Preserve browse scroll positions exactly when returning from details
Skip ensure-visible and synced-row animations on focus restore, and
snapshot/restore panel scrollTop plus synced-row scrollLeft around
detail navigation so the browse page comes back pixel-identical.
2026-09-09 04:51:10 +00:00
LeoVasanko 8f462b9e1d Track backoff state only for large empty subtrees
dir_state now records only subtrees with >= _MIN_TRACKED_DIRS (8)
directories that yielded no items of interest; hot or tiny trees fall
back to default every-pass scheduling with no bookkeeping. Per-directory
classification already works at any depth, so e.g. torrents/Music is
pruned at its own root while torrents/Movies stays hot; backed-off
subtrees are still re-examined when their (exponentially growing, 1h
cap) timer expires.
2026-09-09 04:27:15 +00:00
LeoVasanko 838db5b55c Make rescans robust: DB-aware mtime gating, wipe-proof discovery
- RootScanner takes indexed_paths (store torrent keys); a candidate is
  skipped only when its mtime is unchanged AND it is present in the index,
  so an emptied/partial index self-heals on the next pass (non-indexable
  OTHER candidates still skip on mtime alone)
- discovery: a directory that cannot be listed (transient SMB failure)
  now carries its previously known paths into the Sync set instead of
  dropping them, and an unreadable media root fails the whole scan —
  previously both produced a partial/empty known_paths that made
  sync_torrent_paths delete items that still exist on disk
- worker: broad per-directory exception guard (carry known paths, stay
  hot, keep other workers running); node completion hardened against
  double-decrement; backoff state not updated from cancelled walks
2026-09-09 04:21:17 +00:00
LeoVasanko e5300eaac0 Faster GUI exit, graceful second-instance startup, pretty crash window
- scanner.stop: await cancelled tasks concurrently under one 2s budget
  and persist scan/reel/probe state concurrently (was sequential)
- supervisor.shutdown: stop root contexts concurrently (per-root scanner
  waits and network-mount snapshot flushes no longer add up)
- winmain: retry log-file rotation while a previous instance still holds
  mediahive.log (Windows denies rename of an open file — the old instance
  dying slowly made new instances crash with a PermissionError); after 2s
  show a 'waiting for previous instance' window, retry up to 15s, then
  fall back to a per-pid log file
- winmain: gui_main() wraps winmain() and renders unhandled exceptions as
  a TraceRite HTML page in a pywebview window instead of PyInstaller's
  plain-text fatal-error dialog; __main__ GUI mode uses it too
2026-09-09 03:46:27 +00:00
LeoVasanko d1f1b9ecb8 Parallel adaptive discovery walk; fix PyInstaller tracerite data files
- scanner: replace serial DFS with 16-worker priority work-queue, one
  scandir round trip per directory (DirEntry mtimes), per-subtree
  exponential backoff (persisted in scan-state.json v2), leaf candidates
  gated on max(dir, newest video file) mtime so growing downloads and
  lazy NTFS dir mtimes are handled
- spec: collect_data_files('tracerite') so style.css/script.js ship
- guibuild: tolerate non-UTF-8 console when printing status glyphs
- ruff format pass on indexer.py/index_store.py (hook requirement)
2026-09-09 03:37:31 +00:00
LeoVasanko 26af7c633b Remove rtorrent scripting from the repository. 2026-09-09 02:14:11 +00:00
LeoVasanko c072f15cb5 Make helper scripts directly executable with uv run shebang
guibuild.py and release.py use the project environment; rtorrent-manager.py
is standalone and declares its own dependency via uv script metadata.
2026-09-09 02:08:49 +00:00
LeoVasanko 23030cd1c4 Remove wheel build hook, following upstream fastapi-vue convention
The wheel hook was a local addition (76cd022) that rebuilt the frontend
on every wheel build. Only the sdist build should invoke node; wheel
builds (including wheel-from-sdist) package the prebuilt frontend-build.
Restores scripts/fastapi-vue/buildhook.py to its pristine upstream state.
2026-09-09 02:02:25 +00:00
LeoVasanko 2a39e1f0ea Fix scanner rescan bugs and wasted work
Bugs fixed:
- Partial rescans no longer replace whole index entries: Upsert events now
  carry the scanned torrent paths and IndexStore merges partial rebuilds,
  so touching one season no longer drops the others from listings.
- Deleted torrents are now detected: discovery reports the full candidate
  set via a new Sync event after each completed scan, and the store prunes
  file entries/episodes/seasons/items whose torrents vanished.
- Seen mtimes are committed only after a scan completes successfully, so
  cancelled/failed scans retry their items.
- Showreel worker no longer rebroadcasts stale whole items; it sends narrow
  MovieShowreel/EpisodeReel events that update only reel fields.
- Permanently failing reel generations (e.g. DoVi/libplacebo on GPU-less
  machines) and short-video re-queueing are no longer retried every scan:
  reel outcomes persist in reel-state.json with exponential backoff.

Optimizations:
- Persist scan state (scan-state.json) and ffmpeg probe results
  (probe-cache.json, keyed by mtime+size, failures included) under
  .mediahive/, written only when changed. A warm restart over an unchanged
  library drops from ~57 s + 467 queued reel tasks to ~0.2 s with an empty
  queue (measured on a 163-item root).
- Bounded parallelism (semaphores + gather) for cast-profile downloads,
  TMDb title lookups, and season-detail fetches.
- Incremental TMDb-id index bookkeeping instead of rebuilding per upsert;
  removed dead trigger_scan.

See docs/scanning-review.md for the full findings/fixes/measurements report.
2026-09-04 00:00:53 +00:00
LeoVasanko d3addadf14 Work around Starlette StreamingResponse bug with not aclosing the async generator by implementing a custom StreamingFileResponse class. 2026-09-03 13:20:18 +00:00
LeoVasanko 0a4d54c1b7 Update GUI logging to fastapi-vue too. 2026-09-03 12:30:53 +00:00
LeoVasanko c2776d2e2d fastapi-vue-setup 1.4.1 upgrade, replaces our own access logging and more. 2026-09-03 12:19:50 +00:00
LeoVasanko e5bc736ffa Fix episode preview playback and add staggered stopping
- Observe the video element itself instead of el.closest('.episode-tile'):
  ref callbacks can fire before ancestors are attached, so no tile was
  ever observed and the visibility allowlist blocked all playback.
- Use a blocklist for off-screen culling (play until reported off-screen)
  so observer lag/failure degrades to the old always-on behavior.
- Default to the first season playing on page view again.
- Keep preload="auto" so tiles show a first-frame preview picture;
  playback gating already prevents GPU use from inactive videos.
- Stop videos staggered (same 500ms step as startup) on idle stop,
  season switch and scroll culling; same for movie page showreels.
2026-09-03 03:19:35 +00:00
LeoVasanko 258fc79753 Reduce preview video GPU load: idle stop, browse-gated seasons, viewport culling
- Add useIdlePreviewPlayback composable: stops preview videos after 30s
  without input and restarts them staggered on activity; also stops
  while the tab is hidden.
- SeriesFullView: no episode videos play until a season is browsed
  (keyboard/gamepad focus or mouse hover); only near-viewport tiles of
  the active season play (IntersectionObserver); inactive seasons no
  longer preload video data; sync is incremental so playing tiles are
  not rewound on scroll.
- MediaDetail: header showreel videos use cancellable stagger timers,
  pause when scrolled out of view, and honor the idle stop.
- Images: lazy/async decoding on series page posters; content-visibility:
  auto on media cards to skip rendering off-screen row items.
2026-09-03 02:23:22 +00:00
LeoVasanko 7a60ef5384 Implement Ctrl-R/F5 reload handler with intent to allow reloads in GUI build. 2026-09-02 19:42:17 +00:00
LeoVasanko 22454f2d29 Update README 2026-06-03 00:41:52 +00:00
LeoVasanko 589c789d4d Fix pywebview qt dep on non-Windows platforms. 2026-06-03 00:33:37 +00:00
LeoVasanko 5f2454d8e8 Simpler and cross-platform added_on timestamp determination. 2026-06-03 00:01:06 +00:00
LeoVasanko 2fa15132fb Make default script run with GUI if possible. Add --gui to force GUI. 2026-06-02 23:11:08 +00:00
LeoVasanko 76cd0224de Attempt build fix to allow running from git. 2026-06-02 22:45:09 +00:00
LeoVasanko b0d13a67a0 Fix config saving by not trying to save None default values. 2026-06-02 22:35:44 +00:00
LeoVasanko 568605b09a Add missing file from previous commit 2026-06-02 22:22:49 +00:00
LeoVasanko b09118c8cb Clean up series nested context menus and implement keyboard/gamepad navigation for these functions. 2026-06-02 22:17:47 +00:00
LeoVasanko f79c044250 Tighten media cache headers 2026-05-31 02:45:16 +00:00
LeoVasanko a4b0cd916a Use image logo for HDR10+ badge 2026-05-31 02:31:35 +00:00
LeoVasanko e1a961d21f Detect and badge HDR10+ (SMPTE ST 2094-40) via ffmpeg showinfo 2026-05-31 02:05:10 +00:00
LeoVasanko 1a6a3f0cf2 Don't show collection where the current movie is the only item we have 2026-05-31 01:51:15 +00:00
LeoVasanko 626fb9a0ae Unify playback-state read/write via /api/meta/playback-state 2026-05-31 01:43:56 +00:00
LeoVasanko b6742f0f27 refactor: unify roots updates over single websocket 2026-05-31 00:26:51 +00:00
LeoVasanko 6d0e6d41a7 feat(media): use collection-based recommendations and refine poster display 2026-05-31 00:12:54 +00:00
LeoVasanko 932c5af1ff fix(frontend): scope synced row scrolling by group 2026-05-30 23:54:11 +00:00
LeoVasanko eb49eb713e Hide app scrollbars 2026-05-30 22:59:14 +00:00
LeoVasanko d7eea51334 Align custom access and websocket logging with paskia 2026-05-30 22:46:40 +00:00
LeoVasanko 01f424dbfb Force reel videos to remount on movie change 2026-05-30 22:33:23 +00:00
LeoVasanko df9025760d Add custom FastAPI access logging middleware 2026-05-30 22:25:21 +00:00
LeoVasanko 09e51acd14 Disable spellcheck and autocorrect in search input 2026-05-30 19:18:09 +00:00
LeoVasanko a029eabded Update README 2026-05-30 05:44:43 +00:00
LeoVasanko 4926b72167 Add adjacent detail navigation for keyboard and gamepad 2026-05-30 05:35:56 +00:00
LeoVasanko cd336d0bdb Add fixed-interval hold repeat for gamepad volume 2026-05-30 05:30:15 +00:00
LeoVasanko c75fb30921 Refine scan activity UI and initial-index visibility 2026-05-30 05:18:25 +00:00
LeoVasanko 28c50cab0b Enable SPA on backend (fix 404 accessing other than root) 2026-05-30 05:03:24 +00:00
LeoVasanko 14b76520a6 Stop eager reel loading in cards 2026-05-30 05:02:36 +00:00
LeoVasanko c9d2bcfdcc Fix frontend type mismatches 2026-05-30 04:52:59 +00:00
LeoVasanko 7a0247d78f Refine hover reel audio fades and idle behavior 2026-05-30 04:38:33 +00:00
LeoVasanko bb4bd3723f Improved serie episode preview video handling 2026-05-30 04:28:34 +00:00
LeoVasanko 7675940029 Implement JS-driven season poster sizing from episode grid remainder 2026-05-30 04:19:14 +00:00
LeoVasanko 613b516785 Fix series preview videos to render paused frames and stagger active season 2026-05-30 04:13:05 +00:00
LeoVasanko 2f72d1e7e2 Fix series full view vertical navigation to follow episode rows 2026-05-30 04:05:20 +00:00
LeoVasanko b51a7ea73d Separate scrolling for browse and details page even though they are laid out side by side on the layout (with one hidden out of screen). 2026-05-30 04:00:53 +00:00
LeoVasanko fe5be2087d Refine movie similar strip and desktop nav flow 2026-05-30 03:50:28 +00:00
LeoVasanko 46eecd8dbf Add related movies on Series page. Uses heuristics to show serie movies only. 2026-05-30 03:36:35 +00:00
LeoVasanko c2ee25ba3d Fix synced row deadzone behavior and isolate cast scroll metrics 2026-05-30 02:57:34 +00:00
LeoVasanko 649992a10a Align frontend with new cast and people wire format 2026-05-30 02:40:36 +00:00
LeoVasanko ada61e1400 Remove long hex root ids and use only friendly names 2026-05-30 02:28:58 +00:00
LeoVasanko 3551808351 Fix root-relative playable path normalization 2026-05-30 02:19:33 +00:00
LeoVasanko 0ef6896181 Refactor API to flat root routes and WS/meta structure 2026-05-30 02:09:24 +00:00
LeoVasanko 72963aeb7a Fix loading of index that was bad because of slop code. 2026-05-30 01:43:46 +00:00
LeoVasanko 352835a664 Add logical root asset URLs and harden media path serving 2026-05-30 01:36:47 +00:00
LeoVasanko 900fcea638 refactor index schema and metadata format and directory tree 2026-05-30 01:05:27 +00:00
LeoVasanko 550f67531a Faster startup by loading existing index without any checks, then incremental scanning to update. 2026-05-29 22:25:13 +00:00
LeoVasanko 25e9bab57b Improve scanner cancellation and offload heavy index I/O 2026-05-29 21:48:23 +00:00
LeoVasanko 359ca0c5b4 Add cross-platform system volume control with linear amplification mapping
- New mediahive/volume_control.py module controls master volume on
  Windows (IAudioEndpointVolume via ctypes), macOS (osascript), and
  Linux/PipeWire (wpctl).
- UI slider x in [0, 1.5] maps to linear amp via:
    amp = 0.02*x            if x < 0.1
    amp = (exp(6*x)-1)/402.42879349  otherwise
- PipeWire supports >100% amplification natively (up to x=1.5).
- Windows and macOS clamp at x=1.0 (100%) since their system mixers
  do not support amplification above unity.
- Gamepad D-pad up/down now controls system volume in 0.01 steps.
- JsApi exposes set_volume(), get_volume(), and volume_max() for the
  frontend to query platform limits and control volume directly.
- No external dependencies: uses ctypes on Windows, subprocess on
  Linux/macOS. No PulseAudio fallback.
- Add ruff ignore rules for ctypes COM boilerplate patterns.
2026-05-29 18:21:18 +00:00
LeoVasanko cac3348ec1 Fix detail-view focus scope and entry targets 2026-05-29 18:18:30 +00:00
LeoVasanko 0b3cd15589 feat: configurable media player selection with auto-detection
Backend:
- Add mediahive/players.py with cross-platform player detection:
  - Windows: MPC-BE, MPC-HC, VLC, PotPlayer, mpv (registry + known paths)
  - macOS: IINA, VLC (/Applications bundle scanning)
  - Linux: VLC, SMPlayer (PATH via which)
- Add GET /api/players endpoint returning detected players
- Extend POST /api/roots/{id}/play to accept player_id and player_custom_cmd
- Make MPC-BE web UI port configurable via ?port= query param

Frontend:
- Add player selection to settings (localStorage, per-client)
- Two-column layout: player list (9.5em) + contextual options panel
- Contextual options: Custom Command input, MPC Web UI Port input
- Empty MPC port disables all Web UI polling/connection attempts
- MPC-BE polling only runs when MPC family player is selected and port is set
2026-05-29 00:44:23 +00:00
LeoVasanko 17d9426d0a Fix scanner attach race during root activation 2026-05-27 22:05:37 +00:00
LeoVasanko ed4a5b2900 Route settings dialog to /settings and fix worker null handling 2026-05-27 21:58:09 +00:00
LeoVasanko 1383fc8da3 Add preferred format settings and release preference sorting 2026-05-27 21:52:36 +00:00
LeoVasanko fe71e1be8b Remove search worker debug logging 2026-05-27 21:23:48 +00:00
LeoVasanko a0d1433154 Switch SPA navigation to history paths 2026-05-27 21:21:49 +00:00
LeoVasanko 02a89aee68 Use /search/:term path-based search routing 2026-05-27 21:15:58 +00:00
LeoVasanko 29cda926a2 Improve search-detail back navigation and focus restore 2026-05-27 21:12:39 +00:00
LeoVasanko 81be850e0c Sliding layout between details and category views. 2026-05-27 20:58:19 +00:00
LeoVasanko e78774613a More compact and consistent layout for the release badges. Bluer release group color. 2026-05-27 20:50:43 +00:00
LeoVasanko 96c2d52ee0 perf(frontend): fix resource leaks and disable deep reactivity on media index
- useMediaWebSocket: shallowRef mediaIndex/tasks/loading/error/connected
  to eliminate Proxy overhead on the entire library data structure.
- useMediaWebSocket: replace per-task setTimeout leak with single 3s
  sweep interval for completed task cleanup.
- App.vue: remove {deep:true} watcher on mediaIndex; shallow ref change
  is sufficient to trigger search worker sync.
- useGamepadNavigation: rAF only when gamepads are connected; idle
  fallback to 500ms setTimeout to stop permanent 60fps CPU drain.
- useKeyboardNavigation: deduplicate synced scroll rAF requests to
  prevent overlapping animation frames.
- MediaDetail/SeriesFullView/CollageHero: pause, clear src, and load()
  video elements on unmount and before ref replacement to release
  decoder/memory resources.
- MediaDetail/SeriesFullView: clear all volume fade intervals on
  unmount to stop interval timer leaks.
2026-05-27 20:28:51 +00:00
LeoVasanko 79d3f40162 Unify gamepad repeat model with accelerating hold and analog scaling 2026-05-27 14:40:49 +00:00
LeoVasanko f982785850 Fix HexKeyboard X/Y gamepad keypress animations 2026-05-27 14:28:08 +00:00
LeoVasanko 4c33cee4ac Move frontend search to worker and improve people-name matching 2026-05-26 20:43:10 +00:00
LeoVasanko cf2b90809a HexKeyboard: refactor to row-based flex layout
Replace 39 per-key absolute positioning CSS rules with 4 row divs
using flexbox. Rows overlap via negative margin-top to preserve
honeycomb staggering. Horizontal offsets via 4 margin-left rules.

- Add RowKeyDef with globalIndex for row-based rendering
- Add rows computed to group layoutKeys into 4 arrays
- Remove all .hex-key-N { top; left } positioning rules
- Keys are now flex children, position: relative, fixed width

Also includes prior changes:
- Row-based greyscale gradients (lighter top, darker home row)
- Per-key re-triggerable press animation (DOM reflow reset)
- Green-to-white exponential fade on keypress
- Gamepad repeat: 90ms horizontal/buttons, 140ms vertical
2026-05-26 16:37:12 +00:00
LeoVasanko 43ca8ea1d1 Implement OSD keyboard for gamepads. Using hex tiles of course. 2026-05-26 04:52:13 +00:00
LeoVasanko 8cb7ab5938 Adjust no search results label. 2026-05-26 04:49:28 +00:00
LeoVasanko 7fa8ddad65 Fix movie details header layout and space division with cast display. 2026-05-25 19:21:21 +00:00
LeoVasanko f219840967 refactor(synced-scroll): rewrite with global metrics + per-row clamping
Replace the fragile per-row tail/maxVirtual state with a clean global-
metrics approach:

- Measure cardWidth, stride, paddingLeft, viewportWidth, rightDeadzone
  once from the first synced row; use the same values for all rows.
- Per-row max scroll computed purely from math:
    max(0, paddingLeft + (N-1)*stride - maxVisibleLeft)
  where maxVisibleLeft = viewportWidth - cardWidth - rightDeadzone.
- Global virtual offset (syncedRowsCurrentOffset/targetOffset) is shared
  across all rows; each row clamps independently in applySyncedRowScroll.
- Tail is a global constant (= rightDeadzone) applied to all rows.
- Fix scrolling-back bug: desiredOffset was stuck at currentOffset when
  moving left; now always computed from anchor column position.
- Avoid full animation sweep on view entry: init current/target offset
  from existing DOM scrollLeft on first use.

Also removes per-row --sync-row-max-virtual CSS var and simplifies
.media-row padding to use only --sync-row-tail.
2026-05-25 18:54:21 +00:00
LeoVasanko eee8c973ed fix: per-row tail padding for synced horizontal scroll
- Tail is now computed and applied per-row instead of globally.
- Right-side-only tail padding so item positions don't shift when tail grows.
- Removed anchor-row clamping from effectiveAnchorOffset and targetOffset;
  each row independently clamps the shared virtual offset.
- Rows that fit entirely on screen (maxOffset == 0) keep left alignment.
- Short rows retain their scrolled position when focus moves to longer rows.

Fixes erratic scrolling and zig-zag when navigating across rows of different
lengths in movie/series/search categories.
2026-05-25 17:30:06 +00:00
LeoVasanko a3f5ef5dac Smooth-scroll focused release items into view 2026-05-25 16:20:50 +00:00
LeoVasanko fbf35f7242 Refine movie detail cast rail and nav behavior 2026-05-25 16:18:35 +00:00
LeoVasanko ab1dfffdf3 Restore flex-based people layout in media cards 2026-05-25 15:45:13 +00:00
LeoVasanko 5b57640949 fix: clicking search result no longer redirects to category page
When clicking a search result, showDetail navigates to the detail page.
The route.fullPath watcher then clears searchQuery, which propagates to
Header's localSearch watcher, which emits @search='', which calls
updateSearchQuery(''). This in turn called clearSearch(), which did a
router.replace() to the category page because it didn't check whether
the current route actually had a search query.

The fix: in clearSearch(), if there's no search query in the current
URL, just clear reactive state and return without navigating. This
breaks the circular flow that was overwriting the detail-page
navigation.
2026-05-25 15:32:33 +00:00
LeoVasanko f484ab2606 Clean up movie details metadata layout 2026-05-25 15:24:05 +00:00
LeoVasanko 598cef8926 Adjust media row edge spacing without section padding 2026-05-25 14:57:42 +00:00
LeoVasanko f39473e26e Link header logo to front page 2026-05-25 14:52:36 +00:00
LeoVasanko 31fedcea6c Disable browser fullscreen auto-toggle 2026-05-25 14:49:31 +00:00
LeoVasanko b8cdd460b6 Scroll to view only on mouseup to avoid erratic scrolling during clicks. 2026-05-25 14:43:16 +00:00
LeoVasanko cfa1781192 refactor: hero featured item is a plain link like all other cards
Remove the separate Play and Info buttons from the CollageHero featured
item. All collage items (including the featured one) are now uniform
<a href> links that navigate to the detail page.

- CollageHero: remove hasResumePosition prop, play/info emits, and
  associated play button logic
- App.vue: remove @play and @info bindings from CollageHero
- Delete unused HeroSection.vue component and its styles
2026-05-25 14:25:58 +00:00
LeoVasanko 76c426546b refactor: media cards use proper <a href> links instead of JS navigation
MediaCard, MediaRow, CollageHero, HeroSection, and MediaDetail cast
cards now render as <a> tags with real hrefs. Modified clicks
(ctrl+click, middle-click) navigate natively, enabling open-in-new-tab
and link copying. Plain left-clicks continue through the existing
showDetail flow for side-effects like focus-state saving.
2026-05-25 14:21:13 +00:00
LeoVasanko 1d3d682b60 fix: don't steal focus from search input during focus restoration
When typing in the search bar on a detail page, navigating to search
results would trigger restoreFocusForPage(), which focused the 'last
viewed' card and interrupted typing. Skip focus restoration whenever
the search input is currently the active element.
2026-05-25 13:20:38 +00:00
LeoVasanko 0330a3023a frontend: clamp series hero synopsis to 10 lines 2026-05-25 13:11:12 +00:00
LeoVasanko 1cf3e6a08f frontend: place releases beside poster on narrow movie detail 2026-05-25 13:11:08 +00:00
LeoVasanko 17f8f60f6e fix: constant slant angle for season poster collage hero
The clip-path slant on collage slices previously varied with the number
of posters (hard-coded 10% steps). Express the slant as a percentage
of each slice's own width so the horizontal offset always equals the
fixed 5% container overlap, giving a constant angle for any count >= 2.
2026-05-25 13:02:15 +00:00
LeoVasanko 23379dc2c1 frontend: reduce movie detail poster width under 900px 2026-05-25 03:31:35 +00:00
LeoVasanko b1e775a29b Tuning server.run args for Windows: disable reloads (broken) and use Proactor loop regardless of dev mode. 2026-05-25 03:17:41 +00:00
LeoVasanko f6a40babc9 frontend: add OXC lint/format setup and apply semicolon fixes 2026-05-25 02:54:30 +00:00
LeoVasanko 836a563897 Pathlib fixes. Remaining errors to suppression for now. 2026-05-25 02:42:38 +00:00
LeoVasanko 17c5a47936 Misc. fixes #2. 2026-05-25 02:37:51 +00:00
LeoVasanko e86f1e22de Ignore copyright WTF rule and pylint complexity errors. 2026-05-25 02:32:08 +00:00
LeoVasanko 6646068adc Line too long errors 2026-05-25 02:27:39 +00:00
LeoVasanko 6115a3ff0a Misc. ruff fixes. 2026-05-25 02:22:55 +00:00
LeoVasanko 8d3e872d41 More ignores. 2026-05-25 02:21:44 +00:00
LeoVasanko 3768967e7d BLE001 except Exception replaced for more specific types. 2026-05-25 02:19:41 +00:00
LeoVasanko 54a4b57230 Unsafe fixes. 2026-05-25 02:12:31 +00:00
LeoVasanko 95081ca50e Suppress useless families. 2026-05-25 02:07:38 +00:00
LeoVasanko 0e5251ab91 Ruff automatic fixes and formatting. 2026-05-25 01:57:17 +00:00
LeoVasanko 5f69b5365f Add strict ruff config. 2026-05-25 01:55:51 +00:00
LeoVasanko b835b82e94 Add ETag and 10-minute cache for /api/media 2026-05-24 22:39:29 +00:00
LeoVasanko 93d89e32e2 Handle ffmpeg probe non-zero exits separately 2026-05-24 19:59:12 +00:00
LeoVasanko eeb28e991f Use Windows Shell API for reliable Explorer file selection
Use SHParseDisplayName + SHOpenFolderAndSelectItems to open the parent folder and select files with special-character paths. Keep explorer.exe /n,/select as a fallback when the API path fails.
2026-05-24 19:32:36 +00:00
LeoVasanko 780f223b82 Fix uvicorn forcing SelectorEventLoop on Windows dev mode
Uvicorn's asyncio_loop_factory returns SelectorEventLoop on Windows
whenever reload=True (use_subprocess=True). SelectorEventLoop does not
support asyncio subprocess APIs, causing NotImplementedError in the app.

Fix: pass loop='none' so uvicorn respects the ProactorEventLoop policy
we configure, instead of overriding it. Also suppress the Py3.14
DeprecationWarning on the policy class itself.
2026-05-24 19:14:31 +00:00
LeoVasanko 8586bd26e1 Remove PWA/SW and fix blocking startup I/O
- Remove vite-plugin-pwa and all service worker code. The app no longer
  registers a service worker; legacy SWs are unregistered on load.
- Move snapshot parsing (thousands of sync Path.exists() calls) into a
  thread pool via asyncio.to_thread() so the health check responds
  immediately on startup.
- Wrap aiopathlib AsyncPath.iterdir() (sync os.scandir under the hood)
  in asyncio.to_thread() in scanner and scanning modules so directory
  walks do not monopolize the event loop.
2026-05-24 19:01:13 +00:00
LeoVasanko b574c974a7 Harden ffmpeg subprocess handling and Windows loop policy 2026-05-24 18:06:07 +00:00
LeoVasanko 94f2f10dcd Fix root path handling and release action menus 2026-05-24 17:40:55 +00:00
LeoVasanko 487e410a8f Remove scrollbar. 2026-05-24 02:49:57 +00:00
LeoVasanko 36fc2a77d9 feat: merge duplicate items across roots with combined releases
- Add root_id to Torrent type for per-torrent root tracking
- Merge movies: combine all torrents, keep best metadata, sort by quality
- Merge series: combine seasons/episodes/torrents across roots
- Torrents are quality-sorted (resolution, HDR, DV, Atmos) across all roots
- App.vue play/openFolder use torrent-specific root_id when available
2026-05-24 02:37:09 +00:00
LeoVasanko 06565f688c feat: deduplicate movies/series across roots in merged index
Items with the same content hash from multiple roots are now shown as a
single card. When duplicates exist, the item with the best metadata
(info, cover, backdrop, showreels, torrent count) is displayed.
2026-05-24 02:24:36 +00:00
LeoVasanko 4097c3c9ac ui: replace folder dropdown with full-screen settings view
- Change header icon from folder to settings cog
- Replace roots dropdown panel with full-screen settings overlay
- Keep media roots as the only setting section for now
2026-05-23 22:23:16 +00:00
LeoVasanko 9c75fa4569 refactor: remove legacy single-root APIs, defer filesystem I/O, enforce POSIX paths
- Remove legacy endpoints: /api/change-folder, /api/index, /api/scan, /api/status,
  /api/playback/resume-positions
- Remove legacy global scanner module-level API from hivescan/scanner.py
- Defer all filesystem validation to background task in server lifespan (macOS-safe)
- CLI and winmain pass raw paths via MEDIAHIVE_ROOTS; no pre-startup validation
- Enforce POSIX paths everywhere (as_posix(), no backslash leakage)
- Remove MEDIAHIVE_PATH and MEDIAHIVE_DEFER_INITIAL_ROOT env vars
- Update frontend api.ts to use per-root resume positions
- Update docs/API.md and docs/multi-index-plan.md
- Fix Python 2 style except clauses in hivescan/utils.py and scanning.py
2026-05-23 22:21:35 +00:00
LeoVasanko 2f83193ff4 fix: use html classes instead of :global() for input modality styles
The :global() pseudo-class in Vue scoped SFC styles was being mangled
by the Vue compiler, producing broken CSS that applied properties like
z-index and opacity directly to the <html> element and dropped the
intended component selectors entirely.

Replace the html[data-mouse-active] / html[data-pointer-visible] data
attribute approach with plain CSS classes (mouse-active, pointer-visible)
toggled on document.documentElement. In Vue scoped styles, prefixing a
selector with html.mouse-active or html:not(.mouse-active) compiles
correctly — the html part stays global while the component selector
receives the scoped attribute — avoiding :global() entirely.

This keeps all component styles inside <style scoped> and fixes the
layout breakage caused by the original commit.
2026-05-23 21:57:59 +00:00
LeoVasanko 79dc1b25cc Fix search exit flow and history behavior 2026-05-23 20:33:48 +00:00
LeoVasanko 0f404e5e53 Refine input modality to prevent hover/focus highlight conflicts 2026-05-23 18:58:53 +00:00
LeoVasanko 075f6a50cc fix: kill ffmpeg on cancellation and use POSIX paths everywhere
Bug 1 (shutdown hang):
- Add _kill_proc() helper to force-kill ffmpeg subprocesses immediately
  when a showreel generation task is cancelled or fails.
- Unlink the incomplete output file after killing the process.
- Applies to both generate_showreel_images() and generate_episode_reel().

Bug 2 (path inconsistency):
- Use .as_posix() instead of str() for all persisted and stored paths
  across the codebase (config, env vars, index store, API responses,
  torrent paths, playable files, cover images, episode reels, etc.).
- Ensures forward slashes are used exclusively even on Windows.

Files changed:
- mediahive/hivescan/showreel.py
- mediahive/server.py
- mediahive/hivescan/scanner.py
- mediahive/hivescan/scanning.py
- mediahive/hivescan/indexer.py
- mediahive/hivescan/images.py
- mediahive/hivescan/__main__.py
2026-05-23 18:36:43 +00:00
LeoVasanko 0b5c47f4d1 Normalize path search separators 2026-05-23 17:28:47 +00:00
LeoVasanko 169eb122b5 Improve media search ranking 2026-05-23 17:23:57 +00:00
LeoVasanko 7fbfb27d95 Normalize PTN scene tag edges 2026-05-23 17:12:41 +00:00
LeoVasanko 4ac40e3585 fix: more consistent synced row scrolling 2026-05-23 03:43:48 +00:00
LeoVasanko 52ce50e548 style(ui): refine release badge order, spacing, and palette 2026-05-23 03:21:17 +00:00
LeoVasanko b8e23587b8 feat(ui): refine source logos and normalize TV/CAM quality badges 2026-05-23 03:01:40 +00:00
LeoVasanko fbfe05588d feat(ui): show WEB provider logos from PTN network metadata 2026-05-23 02:41:08 +00:00
LeoVasanko 6820937a4b feat(ui): add DVD disc icon and strict disc-file logo rules 2026-05-23 02:17:45 +00:00
LeoVasanko c5d09c5177 feat(hivescan): add DVD indexing and metadata probing 2026-05-23 01:53:34 +00:00
LeoVasanko 00454f0929 fix(hivescan): prefer MovieObject and probe Blu-ray streams 2026-05-23 01:08:07 +00:00
LeoVasanko 6b74a6d308 feat(ui): add Blu-ray disc logo badge for disc releases 2026-05-23 00:38:27 +00:00
LeoVasanko 04aa858338 feat(ui): refine detail navigation and cast row layout 2026-05-22 21:20:05 +00:00
LeoVasanko 491e707b6f refactor(ui): anchor detail header darkening to reel geometry 2026-05-22 19:20:59 +00:00
LeoVasanko c3dee9bed9 feat(series): improve release popup navigation and controls 2026-05-22 18:48:34 +00:00
LeoVasanko a748adf87e feat(ui): redesign release card presentation 2026-05-22 18:26:29 +00:00
LeoVasanko e23ea7d14e defer GUI root activation until backend is running 2026-05-22 16:49:04 +00:00
LeoVasanko 155c5712c2 Prefer ffmpeg-derived resolution and normalize to SD/HD/FHD/4K/8K 2026-05-22 16:44:08 +00:00
LeoVasanko be140bc286 Simplify startup flow and defer media root activation 2026-05-22 16:30:19 +00:00
LeoVasanko 33b59bbace Improve mac startup splash UX and logo rendering 2026-05-22 15:36:38 +00:00
LeoVasanko 8d3de512eb Rename the winbuild script to guibuild.py. 2026-05-22 03:09:25 +00:00
LeoVasanko 0660b4e9d2 Use ffmpeg parsing for media probe metadata and remove ffprobe bundling 2026-05-22 03:08:36 +00:00
LeoVasanko 7eb81877fd Add Qt mac backend support, Safari/Chrome playback fixes, and AV1 reel updates 2026-05-22 01:39:28 +00:00
LeoVasanko eeed9f3ef7 Implement Safari video startup fixes and range streaming support 2026-05-21 04:06:44 +00:00
LeoVasanko 2e9928a411 Scope reel poster fallback to browser cards 2026-05-21 02:38:39 +00:00
LeoVasanko e40c953617 Movie details layout
- Grid layout: poster/releases left, cast spanning center+right top, versions center, metadata right
- Cast gallery moved to wider center column with photo-card grid (2:3 aspect, text overlay)
- Full cast now stored in index (removed 10-item truncation)
- Cast gender field added to metadata model, TMDB client, indexer, and frontend types
- Gender-specific cast placeholder SVGs (male suit silhouette, female portrait)
- Synopsis text moved from left poster box into right metadata card
- Format badge backgrounds made opaque (res/qual/codec/audio solid colors)
- Releases list moved under poster image in left column
- Cast photo and character font sizes reduced for compact display
2026-05-21 00:43:09 +00:00
LeoVasanko 7aeec90e4f Add slash key to access search. 2026-05-20 04:11:52 +00:00
LeoVasanko 98fbdc63f1 User-centric docs. 2026-05-20 04:09:01 +00:00
LeoVasanko ed37238122 Add screenshot to README 2026-05-20 03:33:09 +00:00
103 changed files with 22553 additions and 6855 deletions
+3
View File
@@ -0,0 +1,3 @@
# Mass lint/format commits that add noise to git blame.
# Enable locally with: git config blame.ignoreRevsFile .git-blame-ignore-revs
38f3cce # Apply linters and formatters
+60
View File
@@ -0,0 +1,60 @@
name: release
on:
push:
tags:
- "v*"
# Runner host prerequisites: git, uv, node/npm, .NET SDK.
jobs:
gui-build:
strategy:
fail-fast: false
matrix:
include:
- os: macos
shell: bash
- os: windows
shell: cmd
- os: linux
shell: bash
runs-on: ${{ matrix.os }}
steps:
# Plain git clone: full history so setuptools_scm sees tags, and both
# shells work. Windows uses cmd: bash resolves to WSL (refuses SYSTEM
# accounts) and powershell hits the script execution policy under SYSTEM.
- name: Checkout
shell: ${{ matrix.shell }}
run: |
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
git checkout -f "${{ gitea.sha }}"
- name: Build GUI app and dist packages
shell: ${{ matrix.shell }}
run: uv run --extra gui scripts/guibuild.py
# Every platform converges on the one release for the tag; release.py
# reuses an existing release and skips already-uploaded assets.
# Only the linux job publishes the wheel/sdist (identical across platforms).
- name: Create Gitea release and upload assets
if: matrix.os == 'linux'
shell: ${{ matrix.shell }}
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: uv run scripts/release.py
# Wheel/sdist are platform-independent; the linux job also pushes them
# to PyPI. Token is the PYPI_TOKEN repository secret.
- name: Publish to PyPI
if: matrix.os == 'linux'
shell: ${{ matrix.shell }}
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: uv publish
- name: Attach platform artifact to the Gitea release
if: matrix.os != 'linux'
shell: ${{ matrix.shell }}
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: uv run scripts/release.py --no-dist
+3
View File
@@ -19,3 +19,6 @@ package-lock.json
# Dotfiles # Dotfiles
.* .*
!.gitignore !.gitignore
!.gitea/
!.git-blame-ignore-revs
!.pre-commit-config.yaml
+20
View File
@@ -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.
+38 -75
View File
@@ -1,96 +1,59 @@
![MediaHive](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/mediahive.avif)
# MediaHive # MediaHive
Media scanning, indexing, and Netflix-style web streaming for your torrent collection. Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
**[Windows portable ZIP download](https://git.zi.fi/LeoVasanko/mediahive/releases)** ## Downloads
## Project Structure - **Windows**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-win64-setup.exe) · [Portable ZIP](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-win64-portable.zip)
- **macOS**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-macos.pkg)
- **Linux**: [AppImage](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage)
You may also run or install with [UV](https://docs.astral.sh/uv/getting-started/installation/):
``` ```
hivescan/ Indexing & previews (library + CLI) uvx --from mediahive[gui] mediahive
indexer.py Media index generation
scanning.py File system scanning
parsing.py Torrent name parsing (PTN)
models.py Data models
images.py TMDb cover/backdrop downloading
showreel.py Video preview clip generation (ffmpeg)
tmdb_client.py TMDb API client with caching
utils.py Path, size, and timestamp helpers
mediahive/ FastAPI web server + Vue frontend
server.py FastAPI app (API + media serving)
__main__.py CLI entry point
frontend/ Vue 3 frontend source
src/
components/ Vue components (Netflix-style UI)
styles/ CSS styles
api.ts API calls to FastAPI backend
types.ts TypeScript interfaces
scripts/
devserver.py Development server (Vite + FastAPI)
rtorrent-manager.py Torrent scanning & rtorrent management
fastapi-vue/ Build utilities for frontend
rtorrent_client.py RTorrent XMLRPC/SCGI client
``` ```
## Quick Start ## What It Does
```bash - Scans your chosen media folder for all movies and series that can be found
pip install -e . - Produces preview video clips and downloads metadata
``` - Search on names and other metadata, not just titles
- Remembers per-episode playback positions and offers series continue points
- Hand off playback to your preferred system player
### 1. Scan & Index Media On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
```bash Note that `.mediahive` folder is created in your media folder to hold all the metadata and preview clips, avoiding the lengthy processing that you will see on initial startup.
# Scan downloads, auto-detect common root, create .mediahive folder
hivescan /media/torrents/*
# Scan multiple locations ## Controls
hivescan /mnt/disk1/* /mnt/disk2/*
# Override output directory MediaHive is designed to work with a mouse, keyboard, or gamepad.
hivescan /media/torrents/* -o /srv/media/.mediahive
# Skip cover/showreel generation | Input | Controls |
hivescan /media/torrents/* --no-covers --no-showreels | --- | --- |
``` | Mouse | Click posters, rows, search, play, and folder actions directly. |
| Keyboard | Arrow keys move focus, `Enter` activates the focused item, `Escape` goes back, and `/` or `Ctrl`/`Cmd`+`F` jumps to search. |
| Gamepad | D-pad or left stick moves focus, `A` selects or plays, and `B` goes back. `RB`/`LB` browses adjacent items, and the Search bar has an OSD keyboard. Player controls during playback. |
### 2. Serve & Browse ## Recommended Players
```bash - Windows: [MPC-BE](https://github.com/Aleksoid1978/MPC-BE/releases)
# Start the web server - macOS: [IINA](https://iina.io/)
mediahive /path/to/your/media/folder - Linux: SMPlayer
# Server starts at http://localhost:8420 MediaHive opens files with the OS default player, but one specific player may be configured via settings. You are of course free to use any player instead.
```
The app expects `<media-folder>/.mediahive/index.json` generated by hivescan. - `A` toggles play and pause.
- `B` closes the player.
- `Y` toggles mute.
- D-pad up and down change volume.
- D-pad left and right seek during playback, or step frames while paused.
### 3. Development ## Background
```bash This project started as a personal project that I have used for browsing my warez for some time now. It is still in early development, but I have just now made it public for a wider audience.
cd frontend && npm install && cd ..
python scripts/devserver.py
```
Starts Vite dev server + FastAPI backend with auto-reload. ![Series view](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/seriesview.avif)
## API Endpoints
- `GET /api/index` — Load media index
- `POST /api/play` — Open media file with system player
- `POST /api/open-folder` — Open folder in file explorer
- `GET /api/media/{path}` — Serve media files (images, video)
## RTorrent Manager
```bash
python scripts/rtorrent-manager.py /media/torrents*/.torrents/
```
Scans `.torrent` files, verifies downloads exist, loads into rtorrent.
## Requirements
- Python ≥ 3.14
- Node.js 18+ (frontend development)
- ffmpeg (showreel generation)
+48
View File
@@ -0,0 +1,48 @@
# API
MediaHive exposes a small local API used by the desktop app and frontend.
All media paths are scoped to a **root**, identified by a friendly `root_id`
(same identifier shown as the root name).
## Endpoints
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/health` | Lightweight health check. |
| `GET` | `/api/config` | Returns the current root configuration. |
| `PUT` | `/api/config/roots` | Atomically replace the full root set. Returns `{ "status": "ok", "accepted": [{path, root_id}], "failed": [...] }`. |
| `POST` | `/api/play/{root_id}` | Opens a media file with a media player. Also starts an assumed-playback session (see notes). |
| `GET` | `/api/players` | Lists detected media players. Returns `{ "players": [{id, name, family, path}] }`, including synthetic `default` and `custom` entries. |
| `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. |
| `POST` | `/api/open-folder/{root_id}` | Opens a folder in the system file explorer; given a file path, selects the file instead. |
| `GET` | `/api/meta/{root_id}/{meta_key}` | Returns allowed metadata from `<root>/.mediahive`, as `{ "key": meta_key, "data": ... }`. |
| `GET` | `/api/meta/playback-state` | Returns merged resume positions across all roots, as `{ "key": "playback-state", "data": ... }`. Series entries carry one continue point per series (`season`/`episode` = last watched) plus a per-episode watch map (`episodes`: `"S<season>E<episode>"``{pos, ts, done}`); completing an episode marks it done and advances the point to the next episode. |
| `POST` | `/api/meta/playback-state` | Updates one resume entry (`root_id`, `file_path`, `pos`; null `pos` clears a movie or advances a series' continue point). Returns `{ "status": "ok", "slug", "pos" }` plus `season`/`episode` when the continue point advances. |
| `GET` | `/api/player/status` | Returns whether remote player control is currently available. Accepts an optional `?port=` override (default 13579). |
| `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable, as `{ "reachable": true|false }`. Accepts an optional `?port=` override; always `false` on non-Windows. |
| `GET` | `/api/media/{root_id}/{file_path:path}` | Serves files from the specified root. |
| `GET` | `/api/assets/{root_id}/{asset_type}/{asset_path:path}` | Serves typed assets from `<root>/.mediahive`. |
| `WS` | `/api/ws` | Streams roots, index updates, and task progress for all roots (see WebSocket notes). |
## Notes
- `PUT /api/config/roots` accepts `{ "roots": { "name": "/absolute/path", ... } }`, validates paths, and atomically swaps the active set.
- `POST /api/play/{root_id}` and `POST /api/open-folder/{root_id}` expect JSON request bodies with `file_path` / `folder_path` relative to the root. The play body additionally accepts `player_id` (a value from `GET /api/players`; unknown ids yield 400) and `player_custom_cmd` (command template used when `player_id` is `custom`).
- `GET /api/media/{root_id}/{file_path:path}` is constrained to the specified root; path traversal outside the root is rejected. Single-range requests are supported (`206` with `Content-Range`, `416` on invalid ranges), responses carry a weak `ETag` (`If-None-Match` yields `304`) and `Cache-Control: public, max-age=604800, immutable`.
- `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` is constrained to `<root>/.mediahive/{asset_type}` where `asset_type` is one of `movies`, `series`, `people`.
- `GET /api/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`.
- `GET /api/player/status` returns `{ "remote": true|false }`.
- Roots may also be provided at startup via CLI arguments (`mediahive /path/to/media ...`), which are passed to the server through fastapi-vue's env config (`mediahive.config.config`) and override the persisted configuration.
- Assumed playback: after `POST /api/play/{root_id}` the launched item is assumed to be playing while the frontend reports no input activity. On the next `POST /api/activity` the guessed position (`resume base + elapsed`, capped at the TMDb runtime) is written once; watches under 5 minutes are discarded (a peek is not progress). A resume entry written by another tracker (e.g. the GUI's MPC-BE tracker) during the session overrides the guess. The MPC-BE tracker likewise ignores sessions shorter than 5 minutes.
## WebSocket
`GET /api/ws` sends tagged msgspec JSON messages as binary frames (message shapes are defined in `mediahive/models/protocol.py`):
- `roots` — full root list and per-root status: `{roots: [{root_id, path, status, error, snapshot_loaded, movies, series}]}`.
- `init` — full index payload `{roots: {root_id: {movies, series, people}}}`, re-sent when the root set changes or a snapshot finishes loading.
- `upsert` — single item inserted or updated: `{root_id, kind ("movie"|"series"), id, item, people?}`.
- `remove` — single item removed: `{root_id, kind, id}`.
- `task` — background task progress: `{root_id, data}`.
Clients must send (any) text frame to keep the receive loop alive.
+58
View File
@@ -0,0 +1,58 @@
# Development
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) covers end-user startup across platforms (installer/AppImage downloads, `uvx --from mediahive[gui] mediahive` on Linux/other).
## Requirements
- Python 3.14+
- `uv`
- Node.js 18+
## Install Dependencies
```bash
uv sync --extra gui --group dev
cd frontend
npm install
```
## Run The Backend Directly
```bash
uv run mediahive /path/to/media/folder
```
This starts the FastAPI backend and serves the built frontend.
## Run Frontend + Backend In Development
```bash
uv run scripts/devserver.py /path/to/media/folder
```
This starts the FastAPI backend with auto-reload plus the Vite frontend dev server.
## Run The Desktop App In Development
```bash
uv run --extra gui python -m mediahive.winmain /path/to/media/folder
```
This launches the same pywebview-based desktop flow used by the Windows build.
## Building And Releasing
The helper scripts are directly executable via their `uv run` shebang (on Windows, run them with `uv run scripts/<name>.py`):
- `./scripts/guibuild.py` builds the PyInstaller desktop app and packages it with Velopack under `build/`: per-user `Setup.exe` (Windows), `.pkg` installer (macOS), `.AppImage` (Linux), plus the update feed in `build/velopack/`. On Windows it also creates a `-win64-portable.zip` (no auto-updates). Requires node/npm and the .NET SDK (>= 10 runtime) installed on the build host; `vpk` and ffmpeg are downloaded once into a persistent user cache (`~/.cache/mediahive-build`, `%LOCALAPPDATA%\mediahive-build` on Windows).
- `./scripts/release.py` publishes a release to the Gitea releases page, uploading the platform artifacts and the Velopack update feed files — installed apps auto-update from the latest release.
Python packaging builds the frontend automatically through the hatch build hook `scripts/fastapi-vue/buildhook.py` (see `pyproject.toml`), so wheels and sdists always ship a fresh `mediahive/frontend-build`.
## Notes
- The selected media folder is scanned continuously by the backend.
- The desktop app remembers the chosen folder between launches.
- HTTP and WebSocket endpoints are documented in [API.md](API.md).
- MPC-BE integration details (Windows only) live in [mpc-be.md](mpc-be.md).
- Scanner/indexer design notes and the v0.5.0 rescan fixes are reviewed in [scanning-review.md](scanning-review.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

+3 -1
View File
@@ -611,7 +611,9 @@ Current native command usage is centered on:
- `889` for play/pause - `889` for play/pause
- `816` for exit - `816` for exit
- `-1&position=HH:MM:SS` for exact 4-second seeking - `-1&position=HH:MM:SS` to seek to the stored resume position when playback starts
The GUI also polls `/variables.html` for the live position and duration and posts resume positions back to the MediaHive backend (`/api/meta/playback-state`), which is how per-episode resume positions and series continue points are tracked. Sessions shorter than 5 minutes are ignored.
## Guidance ## Guidance
+352
View File
@@ -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 ≈ 68 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: ~1628 s per movie
(5 clips), ~45 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.56 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 P1P5 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 → ~2025 MB), so a full dump is a sub-second write.
Caveat: holding all responses parsed in RAM costs ~200 MB for the full
library; storing raw response *bytes* and parsing lazily, or capping to
entries referenced by known index items, keeps this modest.
- **SQLite (stdlib, WAL mode).** One database file, incremental commits, crash
safety without full dumps, and kernel page cache instead of explicit RAM
management. Better fit if the cache is allowed to grow unbounded, at the
price of slightly more code.
Either way, keep the existing cache *semantics* unchanged: cache HTTP-level
failures, never cache network errors. With persisted scan state in place the
TMDb cache becomes write-rarely (new items only), which further lowers the
value of elaborate engineering here — the simple dump is likely enough.
### A.3 Runtime-served artifacts
- **Reels, covers, backdrops, season posters: keep as files.** They are few
per title, tens-to-hundreds of KB, written exactly once, and benefit from
the existing etag/range file serving. No write-amplification problem.
- **Person photos** (~22.5k files × 11.4 KB) are the one runtime class where
tiny files hurt at scan time. Three options, in increasing invasiveness:
1. **Keep files, fix only the scan-time behavior** — bounded-parallel
downloads, optionally capped to top-N billed cast. Zero changes to
serving; the cold-scan cost drops ~8× but the file count stays.
*(This is the option currently implemented.)*
2. **Blob db + serve from the db.** Person photos move into the same store
as above; the assets handler gains one branch for the `people` asset
type that streams bytes from the db instead of the filesystem.
Eliminates all 22.5k tiny files. If full RAM residency (262 MB) is
undesirable, use SQLite and let the page cache handle it.
3. **Hybrid lazy materialization.** The db is authoritative at scan time
(no tiny writes during scans); the assets handler writes the photo to the
conventional path on first request and serves it as a file thereafter.
Serving logic and URLs stay unchanged; disk usage appears only for
people actually viewed.
Note that `index.json` itself is already the right shape: one atomic 7.5 MB
file, rewritten only when dirty. The goal for everything else is simply to
reach the same shape per concern.
### A.4 Recommendation (if revisited)
| Concern | Recommended treatment |
|---|---|
| TMDb response cache | RAM map + debounced single-file dump (SQLite if growth matters) |
| Probe cache, seen-mtimes, reel-failure records | same dump mechanism, separate small files *(currently: three small JSON files, written only when dirty)* |
| Person photos | option 1 now (parallel + lazy fetching); option 2 or 3 if file count becomes an operational issue |
| Covers, posters, backdrops, reels | unchanged — plain files |
| `index.json` | unchanged |
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

+5 -1
View File
@@ -2,7 +2,11 @@
"tasks": { "tasks": {
"dev": "deno run -A npm:vite", "dev": "deno run -A npm:vite",
"build": "deno run -A npm:vue-tsc --noEmit && deno run -A npm:vite build", "build": "deno run -A npm:vue-tsc --noEmit && deno run -A npm:vite build",
"preview": "deno run -A npm:vite preview" "preview": "deno run -A npm:vite preview",
"lint": "deno run -A npm:oxlint --vue-plugin --import-plugin src",
"lint:fix": "deno task lint --fix",
"format": "deno run -A npm:oxfmt --config oxfmt.json src",
"format:check": "deno run -A npm:oxfmt --config oxfmt.json --check src"
}, },
"imports": { "imports": {
"vue": "npm:vue@^3.4.0" "vue": "npm:vue@^3.4.0"
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"ignorePatterns": []
}
+8 -2
View File
@@ -6,17 +6,23 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview" "preview": "vite preview",
"lint": "oxlint --vue-plugin --import-plugin src",
"lint:fix": "npm run lint -- --fix",
"format": "oxfmt --config oxfmt.json src",
"format:check": "oxfmt --config oxfmt.json --check src"
}, },
"dependencies": { "dependencies": {
"country-flag-icons": "^1.6.17",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.0.0",
"oxfmt": "^0.51.0",
"oxlint": "^1.66.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-plugin-pwa": "^1.3.0",
"vue-tsc": "^2.0.0" "vue-tsc": "^2.0.0"
} }
} }
+1406 -778
View File
File diff suppressed because it is too large Load Diff
+382 -93
View File
@@ -1,89 +1,399 @@
import type { MediaIndex } from './types'; export interface PlayerStatus {
remote: boolean
}
export interface PlayerInfo {
id: string
name: string
family: string
path: string | null
}
export interface RootEntry {
root_id: string
path: string
}
interface ActionTimingContext {
actionStartedAt?: number
source?: string
}
function nowMs(): number {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return performance.now()
}
return Date.now()
}
function makeTraceId(action: string): string {
const suffix = Math.random().toString(16).slice(2, 8)
return `${action}-${Date.now().toString(36)}-${suffix}`
}
function logActionTiming(
action: string,
traceId: string,
status: number,
actionToFetchMs: number,
fetchMs: number,
totalMs: number,
serverTiming: string | null,
source?: string,
) {
const sourceTag = source ? ` source=${source}` : ""
const serverTag = serverTiming ? ` serverTiming=${serverTiming}` : ""
console.info(
`[timing:${action}] trace=${traceId}${sourceTag} status=${status} actionToFetch=${actionToFetchMs.toFixed(1)}ms fetch=${fetchMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms${serverTag}`,
)
}
export function normalizeMediaPath(input: string): string { export function normalizeMediaPath(input: string): string {
return input return input
.replace(/\\/g, '/') .replace(/\\/g, "/")
.replace(/^[A-Za-z]:\//, '') .replace(/^[A-Za-z]:\//, "")
.replace(/^\/+/, ''); .replace(/^\/+/, "")
}
export function isVideoPath(path: string | null | undefined): boolean {
return Boolean(path && /\.(webm|mp4|mkv|avi|mov)$/i.test(path))
}
export interface VideoSourceAttributes {
type: string
codecs: string
}
export function isSafariBrowser(): boolean {
if (typeof navigator === "undefined") {
return false
}
const ua = navigator.userAgent
return /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|FxiOS/i.test(ua)
}
export function getVideoPreviewUrl(url: string): string {
if (!url || !isSafariBrowser()) {
return url
}
if (url.includes("#")) {
return url
}
// Safari often needs a tiny time offset to paint the first frame before playback.
return `${url}#t=0.001`
}
export function getVideoSourceAttributes(path: string | null | undefined): VideoSourceAttributes {
if (!path) {
return { type: "video/mp4", codecs: "hvc1" }
}
if (/\.webm$/i.test(path)) {
return { type: "video/webm", codecs: "av1" }
}
if (/\.mp4$/i.test(path) || /\.m4v$/i.test(path)) {
return { type: "video/mp4", codecs: "hvc1" }
}
if (/\.mov$/i.test(path)) {
return { type: "video/quicktime", codecs: "hvc1" }
}
if (/\.avi$/i.test(path)) {
return { type: "video/x-msvideo", codecs: "" }
}
if (/\.mkv$/i.test(path)) {
return { type: "video/x-matroska", codecs: "" }
}
return { type: "video/mp4", codecs: "hvc1" }
}
function encodePathSegments(path: string): string {
return path
.split("/")
.filter((segment) => segment.length > 0)
.map((segment) => encodeURIComponent(segment))
.join("/")
}
function normalizeCoverPath(path: string): string {
return path
.replace(/\\/g, "/")
.replace(/^[A-Za-z]:\//, "")
.replace(/^\/+/, "")
}
function toRootAssetPath(path: string): string | null {
const normalized = normalizeCoverPath(path)
const marker = ".mediahive/"
const idx = normalized.toLowerCase().indexOf(marker)
if (idx < 0) return null
const logical = normalized.slice(idx + marker.length)
return logical.length > 0 ? logical : null
}
function splitAssetTypePath(assetPath: string): { assetType: string; relativePath: string } | null {
const parts = assetPath.split("/").filter((segment) => segment.length > 0)
if (parts.length < 2) return null
const [assetType, ...rest] = parts
if (!assetType || !["movies", "series", "people"].includes(assetType.toLowerCase())) {
return null
}
return { assetType: assetType.toLowerCase(), relativePath: rest.join("/") }
}
/** Watch progress for one episode of a series. */
export interface EpisodeWatchEntry {
pos: number
done: boolean
}
/** One stored continue point. season/episode are set for series, null for movies. */
export interface ResumePositionEntry {
pos: number
season: number | null
episode: number | null
/** Per-episode watch progress for series, keyed "S<season>E<episode>". */
episodes?: Record<string, EpisodeWatchEntry>
} }
/** /**
* Load the media index from the server * Fetch merged resume positions from all roots.
*/ */
export async function loadMediaIndex(): Promise<MediaIndex> { export async function fetchResumePositions(): Promise<Record<string, ResumePositionEntry>> {
const response = await fetch('/api/index');
if (!response.ok) {
throw new Error(`Failed to load media index: ${response.statusText}`);
}
return response.json();
}
/**
* Play a media file with the system's default player
*/
export async function playMedia(filePath: string): Promise<void> {
const normalizedPath = normalizeMediaPath(filePath);
try { try {
const response = await fetch('/api/play', { const response = await fetch("/api/meta/playback-state")
method: 'POST', if (!response.ok) return {}
headers: { 'Content-Type': 'application/json' }, const data = await response.json().catch(() => ({}))
body: JSON.stringify({ file_path: normalizedPath }), const positions = data?.data?.resume_positions
}); if (!positions || typeof positions !== "object") {
return {}
}
const normalized: Record<string, ResumePositionEntry> = {}
for (const [slug, value] of Object.entries(positions as Record<string, unknown>)) {
if (!value || typeof value !== "object") continue
const entry = value as { pos?: unknown; season?: unknown; episode?: unknown }
if (typeof entry.pos !== "number" || !Number.isFinite(entry.pos) || entry.pos < 0) {
continue
}
normalized[slug] = {
pos: entry.pos,
season: typeof entry.season === "number" ? entry.season : null,
episode: typeof entry.episode === "number" ? entry.episode : null,
}
const rawEpisodes = (entry as { episodes?: unknown }).episodes
if (rawEpisodes && typeof rawEpisodes === "object") {
const watches: Record<string, EpisodeWatchEntry> = {}
for (const [key, watch] of Object.entries(rawEpisodes as Record<string, unknown>)) {
if (!watch || typeof watch !== "object") continue
const w = watch as { pos?: unknown; done?: unknown }
if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue
watches[key] = { pos: w.pos, done: w.done === true }
}
if (Object.keys(watches).length > 0) {
normalized[slug].episodes = watches
}
}
}
return normalized
} catch {
return {}
}
}
/**
* Report that the user is actively interacting with the UI.
*
* Ends any server-side assumed-playback session (launched item is assumed
* watched while the UI sees no input). Throttled; fire-and-forget.
*/
let lastActivityReportAt = 0
export function reportUserActivity(): void {
const now = Date.now()
if (now - lastActivityReportAt < 5000) return
lastActivityReportAt = now
void fetch("/api/activity", { method: "POST" })
.then(async (response) => {
if (!response.ok) return
const data = await response.json().catch(() => null)
if (data?.finalized) {
// An assumed-playback position was just written; let views refetch.
window.dispatchEvent(new Event("mediahive:resume-updated"))
}
})
.catch(() => {})
}
/**
* Replace the full root set atomically
*/
export async function replaceRoots(
roots: Record<string, string>,
): Promise<{ accepted: RootEntry[]; failed: unknown[] }> {
const response = await fetch("/api/config/roots", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roots }),
})
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const err = await response.json().catch(() => ({ detail: response.statusText }))
throw new Error(error.detail || response.statusText); throw new Error(err.detail || response.statusText)
}
return response.json()
}
/**
* Fetch detected media players from the backend.
*/
export async function fetchPlayers(): Promise<PlayerInfo[]> {
const response = await fetch("/api/players")
if (!response.ok) {
throw new Error(`Failed to load players: ${response.statusText}`)
}
const data = await response.json()
return data.players || []
}
/**
* Play a media file with the selected player.
*/
export async function playMedia(
rootId: string,
filePath: string,
playerId?: string | null,
playerCustomCmd?: string | null,
timing?: ActionTimingContext,
): Promise<void> {
const normalizedPath = normalizeMediaPath(filePath)
const body: Record<string, unknown> = { file_path: normalizedPath }
if (playerId) body.player_id = playerId
if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd
const actionStart = timing?.actionStartedAt ?? nowMs()
const traceId = makeTraceId("play")
try {
const fetchStart = nowMs()
const actionToFetchMs = Math.max(0, fetchStart - actionStart)
const clientSentMs = Date.now()
const actionStartEpochMs = clientSentMs - actionToFetchMs
const response = await fetch(`/api/play/${encodeURIComponent(rootId)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-MediaHive-Trace-Id": traceId,
"X-MediaHive-Client-Sent-Ms": clientSentMs.toFixed(3),
"X-MediaHive-Client-Action-Start-Ms": actionStartEpochMs.toFixed(3),
},
body: JSON.stringify(body),
})
const fetchMs = Math.max(0, nowMs() - fetchStart)
const totalMs = Math.max(0, nowMs() - actionStart)
const serverTiming = response.headers.get("server-timing")
const responseTraceId = response.headers.get("x-mediahive-trace-id") || traceId
logActionTiming(
"play",
responseTraceId,
response.status,
actionToFetchMs,
fetchMs,
totalMs,
serverTiming,
timing?.source,
)
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || response.statusText)
} }
} catch (e) { } catch (e) {
console.error('Play media error:', e); const totalMs = Math.max(0, nowMs() - actionStart)
alert(`Failed to play media.\n\n${e}`); console.error(`Play media error after ${totalMs.toFixed(1)}ms (trace=${traceId}):`, e)
alert(`Failed to play media.\n\n${e}`)
} }
} }
/** /**
* Open a folder in Windows Explorer * Open a folder in the system file manager
*/ */
export async function openFolder(folderPath: string): Promise<void> { export async function openFolder(
const normalizedPath = normalizeMediaPath(folderPath); rootId: string,
folderPath: string,
timing?: ActionTimingContext,
): Promise<void> {
const normalizedPath = normalizeMediaPath(folderPath)
const actionStart = timing?.actionStartedAt ?? nowMs()
const traceId = makeTraceId("open-folder")
try { try {
const response = await fetch('/api/open-folder', { const fetchStart = nowMs()
method: 'POST', const actionToFetchMs = Math.max(0, fetchStart - actionStart)
headers: { 'Content-Type': 'application/json' }, const clientSentMs = Date.now()
const actionStartEpochMs = clientSentMs - actionToFetchMs
const response = await fetch(`/api/open-folder/${encodeURIComponent(rootId)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-MediaHive-Trace-Id": traceId,
"X-MediaHive-Client-Sent-Ms": clientSentMs.toFixed(3),
"X-MediaHive-Client-Action-Start-Ms": actionStartEpochMs.toFixed(3),
},
body: JSON.stringify({ folder_path: normalizedPath }), body: JSON.stringify({ folder_path: normalizedPath }),
}); })
const fetchMs = Math.max(0, nowMs() - fetchStart)
const totalMs = Math.max(0, nowMs() - actionStart)
const serverTiming = response.headers.get("server-timing")
const responseTraceId = response.headers.get("x-mediahive-trace-id") || traceId
logActionTiming(
"open-folder",
responseTraceId,
response.status,
actionToFetchMs,
fetchMs,
totalMs,
serverTiming,
timing?.source,
)
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json()
throw new Error(error.detail || response.statusText); throw new Error(error.detail || response.statusText)
} }
} catch (e) { } catch (e) {
console.error('Open folder error:', e); const totalMs = Math.max(0, nowMs() - actionStart)
alert(`Failed to open folder.\n\n${e}`); console.error(`Open folder error after ${totalMs.toFixed(1)}ms (trace=${traceId}):`, e)
alert(`Failed to open folder.\n\n${e}`)
} }
} }
/** /**
* Return whether MPC-BE local web control is currently reachable. * Return whether MPC-BE local web control is currently reachable.
* @param port - Optional custom port (default 13579)
*/ */
export async function isMpcBeReachable(): Promise<boolean> { export async function isMpcBeReachable(port?: number | null): Promise<boolean> {
try { try {
const response = await fetch('/api/mpcbe/status'); const url = port ? `/api/mpcbe/status?port=${port}` : "/api/mpcbe/status"
if (!response.ok) return false; const response = await fetch(url)
const data = await response.json().catch(() => ({})); if (!response.ok) return false
return Boolean(data.reachable); const data = await response.json().catch(() => ({}))
return Boolean(data.reachable)
} catch { } catch {
return false; return false
} }
} }
export async function fetchResumePositions(): Promise<Record<string, number>> { /**
try { * Return player integration capabilities for the current OS.
const response = await fetch('/api/playback/resume-positions'); * @param port - Optional custom MPC-BE port (default 13579)
if (!response.ok) return {}; */
const data = await response.json().catch(() => ({})); export async function getPlayerStatus(port?: number | null): Promise<PlayerStatus> {
const resumePositions = data?.resume_positions; const url = port ? `/api/player/status?port=${port}` : "/api/player/status"
return resumePositions && typeof resumePositions === 'object' ? resumePositions : {}; const response = await fetch(url)
} catch { if (!response.ok) {
return {}; throw new Error(`Failed to load player status: ${response.statusText}`)
} }
return response.json()
} }
/** /**
@@ -93,57 +403,36 @@ export async function fetchResumePositions(): Promise<Record<string, number>> {
* The path comes from the server already converted to Windows format (Z:\...) * The path comes from the server already converted to Windows format (Z:\...)
* Paths starting with '/' are TMDB relative paths that weren't fetched - ignore them * Paths starting with '/' are TMDB relative paths that weren't fetched - ignore them
*/ */
export function getCoverUrl(coverPath: string | null): string { export function getCoverUrl(coverPath: string | null, rootId?: string | null): string {
if (!coverPath) { if (!coverPath) {
return ''; return ""
} }
// Ignore TMDB relative paths (start with /) - these are bugs in the index // Ignore TMDB relative paths (start with /) - these are bugs in the index
if (coverPath.startsWith('/')) { if (coverPath.startsWith("/")) {
return ''; return ""
} }
// Convert relative path to URL path for FastAPI server const rid = rootId || "unknown"
// .mediahive/covers/Movies/... -> /media/.mediahive/covers/Movies/... const assetPath = toRootAssetPath(coverPath)
let urlPath = coverPath; if (assetPath) {
const split = splitAssetTypePath(assetPath)
// Remove drive letter (Z:) and convert backslashes to forward slashes if (split) {
if (urlPath.match(/^[A-Za-z]:/)) { return `/api/assets/${encodeURIComponent(rid)}/${encodeURIComponent(split.assetType)}/${encodePathSegments(split.relativePath)}`
urlPath = urlPath.substring(2);
} }
urlPath = urlPath.replace(/\\/g, '/');
// Ensure path starts with /
if (!urlPath.startsWith('/')) {
urlPath = '/' + urlPath;
} }
// Encode URI components but preserve slashes const mediaPath = normalizeCoverPath(coverPath)
const encodedPath = urlPath.split('/').map(segment => encodeURIComponent(segment)).join('/'); return `/api/media/${encodeURIComponent(rid)}/${encodePathSegments(mediaPath)}`
return `/api/media${encodedPath}`;
} }
/** /**
* Invoke the native OS folder picker via pywebview, then switch the server's * Invoke the native OS folder picker via pywebview, then add the selected
* media folder in-place and reload the page. Only works inside the packaged * folder to the server's root list. Only works inside the packaged desktop app.
* desktop app.
*/ */
export async function pickFolderAndRestart(): Promise<void> { export async function pickFolderAndAddRoot(): Promise<string | null> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const api = (window as any).pywebview?.api; const api = (window as any).pywebview?.api
if (!api) return; if (!api) return null
const folder: string | null = await api.pick_folder(); const folder: string | null = await api.pick_folder()
if (!folder) return; return folder
const res = await fetch('/api/change-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder }),
});
if (res.ok) {
// Give the server a moment to complete the background folder switch before reloading
setTimeout(() => window.location.reload(), 500);
} else {
const err = await res.json().catch(() => ({ detail: res.statusText }));
alert(`Failed to change folder: ${err.detail || res.statusText}`);
}
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -0,0 +1,37 @@
<svg width="119" height="140" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="imagebot_9">
<stop stop-color="#b3925d" id="imagebot_65"/>
<stop stop-color="#5d4014" id="imagebot_64" offset="1"/>
</linearGradient>
<linearGradient id="imagebot_24">
<stop stop-color="#fff" id="imagebot_63"/>
<stop stop-opacity="0" stop-color="#fff" offset="1" id="imagebot_62"/>
</linearGradient>
<linearGradient x1="3.83529" y2="0.26302" x2="3.83529" y1="-0.34629" id="imagebot_16" xlink:href="#imagebot_24"/>
<linearGradient x1="2.18514" y2="0.37278" x2="2.18514" y1="-0.01478" id="imagebot_20" xlink:href="#imagebot_24"/>
<linearGradient x1="-0.55279" y2="1.01379" x2="-0.55279" y1="-0.01379" id="imagebot_17" xlink:href="#imagebot_9"/>
<linearGradient y2="1" x2="0.5" y1="0" x1="0.5" id="imagebot_82">
<stop offset="0" stop-opacity="0.99219" stop-color="#542800"/>
<stop offset="1" stop-opacity="0.99219" stop-color="#662000"/>
</linearGradient>
<linearGradient id="imagebot_83" y2="1" x2="0.5" y1="0" x1="0.5">
<stop offset="0" stop-opacity="0.99219" stop-color="#542800"/>
<stop offset="1" stop-opacity="0.99219" stop-color="#662000"/>
</linearGradient>
</defs>
<g>
<title>Layer 1</title>
<path id="imagebot_79" stroke-width="0.5" stroke="#57401e" fill-rule="evenodd" fill="url(#imagebot_17)"/>
<g transform="translate(0 3.06434)" id="imagebot_102">
<path d="M 22.74422 74.74535044870376 C 31.91926 58.45145044870377 37.64859 41.81313044870377 42.85091 29.248270448703767 C 41.90924 13.406620448703766 52.72854 8.192040448703766 59.79554 8.192040448703766 C 66.86253 8.192040448703766 78.99256 12.669340448703766 76.0848 29.534990448703766 C 80.95944 42.30465044870377 86.3611 59.270660448703765 95.53614 76.05607044870376 C 115.8523 130.92610044870378 73.92952 131.87786044870376 59.79554 131.87786044870376 C 45.66155 131.87786044870376 3.4111000000000002 116.83586044870377 22.74422 74.74535044870376 z" id="imagebot_80" stroke-linecap="null" stroke-linejoin="null" stroke-dasharray="null" stroke="#000000" fill="url(#imagebot_83)"/>
<path transform="translate(0 0.512) matrix(1 0 0 1 0 -37.4937)" id="imagebot_92" stroke-width="1.143" stroke="url(#imagebot_16)" fill="none" opacity="0.586" d="M 59.47936427844316 46.39889503216985 C 51.40949466195967 46.6597993524647 46.7912370464379 50.28419053324464 44.19117979551757 55.13965915058735 C 50.034283878930864 61.52322215922001 68.81172041094496 64.17701625988963 74.92515422056397 54.788942632751485 C 72.23901202492665 50.05236559006813 67.80056983488612 46.54991700945619 61.10049593995004 46.39889503216985 C 60.54621353239983 46.386403240385086 59.993902535797375 46.38226202213887 59.47936427844316 46.39889503216985 z"/>
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="1.113" stroke="#323232" fill-rule="evenodd" fill="#000000" d="M 0.7986898918151866 174.22619862772942 C -0.097920108184816 164.68506862772944 0.9079298918151864 145.87931862772942 14.117579891815183 142.82343862772942 L 57.65248989181518 133.5089986277294 L 103.1211998918152 142.15179862772942 C 116.5566498918152 145.02410862772942 118.02105989181518 163.55207862772943 118.42437989181519 173.9827686277294 C 0.5719998918151816 174.42936862772942 118.47697989181518 173.89226862772944 0.7986898918151866 174.22619862772942 z" id="imagebot_2"/>
<path stroke-width="1.113" stroke="url(#imagebot_20)" fill="#000000" opacity="0.593" d="M 57.1773 97.07883872083664 L 14.434240000000003 106.71762872083664 L 14.39866 106.71762872083664 C 8.436970000000002 108.11243872083665 5.2424800000000005 112.98359872083665 3.5303200000000032 118.97568872083664 C 1.9480099999999965 124.51329872083664 1.8499800000000022 130.70706872083665 2.1762299999999972 135.29601872083663 C 30.032970000000006 135.21761872083664 45.100809999999996 135.15751872083663 52.241870000000006 135.15347872083663 C 55.91150999999999 135.15147872083665 57.73647 135.18207872083664 58.655969999999996 135.18907872083665 C 59.11572000000001 135.19307872083664 59.32381000000001 135.18307872083665 59.47555 135.18907872083665 C 59.52641 135.19107872083663 59.61632 135.21627872083664 59.68934999999999 135.22467872083664 C 59.69961000000001 135.22501872083663 59.77909 135.22430872083663 59.7962 135.22467872083664 C 59.89894000000001 135.22667872083665 60.04916 135.22367872083663 60.43761000000001 135.22467872083664 C 61.34398999999999 135.22767872083665 63.17347000000001 135.23367872083665 66.85171 135.22467872083664 C 73.98958 135.20697872083664 89.13769000000002 135.15137872083665 116.88172 135.04650872083664 C 116.64753000000002 130.02106872083664 116.11918 123.72255872083664 114.28044 118.19166872083665 C 112.27774 112.16755872083664 108.95868999999999 107.39762872083665 102.87759 106.07614872083664 L 57.1773 97.07883872083664 z" id="imagebot_3"/>
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="1.113" stroke="#000000" fill-rule="evenodd" fill="#ffcdb2" d="M 45.24016 124.68690958276748 C 45.47224 130.3192395827675 44.13723 133.56532958276748 41.49851000000001 138.3393595827675 C 46.63544 147.78121958276748 51.76527999999999 153.7549695827675 59.62002 160.2540295827675 C 67.47475 152.82669958276747 73.54878 146.3342795827675 77.70689 137.80701958276748 C 76.45166 134.35116958276748 75.19644 129.66651958276748 73.94121 124.57226958276749 L 45.24016 124.68690958276748 z" id="imagebot_4"/>
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="0.976" stroke-linejoin="round" stroke="#000000" fill="#ffcdb2" d="M 87.18131 100.67885958276749 C 87.18131 119.35216958276749 75.33934 134.0977195827675 58.60526 138.1937195827675 C 45.14797999999999 134.0977195827675 32.48680999999999 119.35216958276749 32.48680999999999 100.67885958276749 C 32.48680999999999 82.00555958276749 44.73838000000001 66.85040958276748 59.834059999999994 66.85040958276748 C 74.92974 66.85040958276748 87.18131 82.00555958276749 87.18131 100.67885958276749 z" id="imagebot_5"/>
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke="#000000" fill-rule="evenodd" fill="url(#imagebot_82)" d="M 58.73652 60.55180958276749 C 32.629369999999994 61.25627958276748 27.718890000000002 80.5286295827675 29.11278 95.02406958276748 C 29.329250000000002 97.27517958276749 34.27735 131.44748958276747 35.90773 133.26849958276748 C 37.13297 121.83706958276748 35.742279999999994 80.90836958276749 49.40835 74.09559958276749 L 59.11117 77.18910958276749 L 69.46934 74.38438958276748 C 83.18997 80.30845958276748 80.91984 128.0179995827675 81.90542 133.42349958276748 C 83.38494 131.42852958276748 88.61500000000001 96.54846958276748 89.01375000000002 93.86481958276748 C 91.08852000000002 79.90128958276749 83.21866 60.963189582767484 61.350390000000004 60.55180958276749 C 60.4636 60.535119582767486 59.57871 60.52907958276749 58.73652 60.55180958276749 z" id="imagebot_6"/>
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="1.143" stroke="url(#imagebot_16)" fill="none" opacity="0.586" d="M 58.762820000000005 61.683199582767486 C 45.99126 62.027819582767485 38.6823 66.81515958276749 34.56739 73.22858958276748 C 43.81482 81.66042958276749 73.53242 85.16573958276749 83.20768 72.76533958276748 C 78.95653 66.50894958276749 71.93215000000001 61.88267958276749 61.32846000000001 61.683199582767486 C 60.45124 61.666699582767485 59.57714 61.66122958276749 58.762820000000005 61.683199582767486 z" id="imagebot_7"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -0,0 +1,55 @@
<?xml version="1.0"?>
<svg
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:ns1="http://sozi.baierouge.fr"
xmlns:xlink="http://www.w3.org/1999/xlink"
id="svg2"
viewBox="0 0 600 600"
version="1.1"
>
<g
id="layer1"
transform="translate(-743.2 97.566)"
>
<path
id="path2996"
opacity=".5"
d="m1026.5-34.044c-14.911 1.272-30.456 4.7566-42.996 12.924-10.972 7.1462-19.233 18.368-25.552 29.836-4.6901 8.5125-6.9924 18.276-8.5878 27.864-2.5245 15.17-1.4223 46.115-1.4223 46.115l-5.4062 8.8695c-1.3634 10.729 0.37951 21.116 0.77301 31.844 0.30666 8.362 1.245 16.724 2.9677 25.086l8.5076 9.971s1.979 11.852 5.4611 20.055c3.4821 8.2036 17.093 26.995 17.093 26.995-12.537 4.3352-31.155 8.049-37.59 13.009-4.6728 3.6014-5.7623 10.625-8.1937 16.387l-115.3 21.118-14.36 21.202v30.663l-58.708 194.54 600 0.00002-18.415-62.086-22.554-126.28-1.3515-25.257-8.8696-15.036-46.375-17.063-66.226-11.573c-7.0675-7.2927-16-17.43-21.202-21.878-5.1891-4.4364-28.664-10.925-42.996-16.387l-2.7031-9.5453s7.6682-9.2528 10.221-16.387c2.5528-7.1346 3.3788-25.933 3.3788-25.933l4.8149 1.3516 4.8149-7.518 2.0273-27.284s0.3969-32.34-1.3515-36.154c-1.7485-3.8134-5.4907-4.8149-5.4907-4.8149s0.9972-29.564-2.7031-43.672c-3.8499-14.678-10.425-29.076-19.851-40.969-7.7283-9.7506-17.535-18.652-28.986-23.516-16.34-6.9413-35.177-7.9803-52.867-6.4713zm-47.135 251.89 10.897 19.766 38.941 32.775 19.09 1.3515 44.432-23.905 19.766-27.284-2.0274 10.221-23.905 68.929-19.851 64.874-12.924 59.383-13.009 72.392-32.015-193.19-19.175-40.293zm84.649 145.37-16.383 81.916-6.8263 49.524-22.835-154.12 7.8543-20.737 20.183-2.001-19.196-0.32134-12.317-25.607-6.626 5.2982 17.919-23.38 24.575 1.3653 26.964 31.742-15.647-13.051-14.73 23.632 16.383 34.814z"
fill="#999"
/>
</g>
<metadata>
<rdf:RDF>
<cc:Work>
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<cc:license rdf:resource="http://creativecommons.org/licenses/publicdomain/"/>
<dc:publisher>
<cc:Agent rdf:about="http://openclipart.org/">
<dc:title>Openclipart</dc:title>
</cc:Agent>
</dc:publisher>
<dc:title>Generic Profile Image Placeholder - Suit</dc:title>
<dc:date>2013-04-26T23:09:54</dc:date>
<dc:description>Generic Profile Image Placeholder - Suit</dc:description>
<dc:source>https://openclipart.org/detail/177482/generic-profile-image-placeholder---suit-by-naught101-177482</dc:source>
<dc:creator>
<cc:Agent>
<dc:title>naught101</dc:title>
</cc:Agent>
</dc:creator>
</cc:Work>
<cc:License rdf:about="http://creativecommons.org/licenses/publicdomain/">
<cc:permits rdf:resource="http://creativecommons.org/ns#Reproduction"/>
<cc:permits rdf:resource="http://creativecommons.org/ns#Distribution"/>
<cc:permits rdf:resource="http://creativecommons.org/ns#DerivativeWorks"/>
</cc:License>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+747
View File
@@ -0,0 +1,747 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg9"
width="589"
height="320"
viewBox="0 0 589 320"
sodipodi:docname="en_dplus_lg_r_2x_54572343.svg"
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs13" />
<sodipodi:namedview
id="namedview11"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="1.865625"
inkscape:cx="294.27136"
inkscape:cy="160"
inkscape:window-width="1368"
inkscape:window-height="842"
inkscape:window-x="-6"
inkscape:window-y="-6"
inkscape:window-maximized="1"
inkscape:current-layer="g15" />
<g
inkscape:groupmode="layer"
inkscape:label="Image"
id="g15">
<image
width="589"
height="320"
preserveAspectRatio="none"
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAk0AAAFACAYAAAChjEgbAAAAAXNSR0IArs4c6QAAAARnQU1BAACx
jwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAJrkSURBVHhe7Z0HgBzF0bZnLynnLCGBJBASGUQw
AgM2NhgHHPBnbOzPOQE2Thj8O3yADRgnTM5gMpgMIgkRJCGQAOWcc8758s5fz2zN3t7e7m2avdu7
q0eam5memd400/12dXV1yDEMw2hKQqGQEyopCRUVFzshWfy1LKGyDh2css6dQ2VduoRKZLukfftQ
abt2rCML22VloZKOHZ2ioiKnuLTUKZa8KMmKS8u8vARXXgO81wJZu+GaaidcXeOEa2tlkXV1tVtb
Xe3UlJc7NZWVTk1FhVtdUeFU7t/vVstStX+/U3nggFu5b59TK8fDNTVOLdfW1HjXse+6rpe/YRht
AhNNhmEETgiBUyriB3FTKsLHE0AdOoRKZSGtrGNHXxQhhEKIoeIyFT2IHS+XiOjx/tWJH02LHEc4
QVGRnieCiRP88yB+21uz+Ney7Z8TOU4erJ0i0iVVRJJbU1HuVCGwysvdqgMHHFlcEVVOtQqrij17
3PK6heNeHoZhtBoiBYNhGEbaiJAowlJUUuKtEUdlXbuG2nXrFmrfo4cTtRKJCBIhFPLEEBYhWXyh
4okUzYs1+9hs/HRPwLDmH2n+vm7H7kOxiCbZw8LE9ZzQ8LzIsXrpniiq23dFfbEOqVjyrvHP8da+
0JJteVFeyNt3a2pc31rlrasqPYvV/u3bnf07d7p7t293923d6u7dts2p4pyqKqdWFrNUGUaLIvLA
G4ZhJEJEQqi0UycWp6RLl6Kyzp0dBBLCqFTWWIo8USRiAhFVLIvX1YbA4HpZ+2LIy490XRrd17Ip
9hj4lqXoeZ6FyXGLQhGxI4nR1613raw5w9vXxd/nehVLXkIxxyPbjtfZp+dDIjHl7RYXud4R2ed6
Pz0cDnvdgU5trSvrUFiE0v5du9x9O3cgqNx9soigQlg5B3bvDsu+U1Nd7V1rGEbBEXmwDcMwAJFU
EhFGRWXduiGOHCxIpSKS2olIKu3YEeXiiQNPcLBErotcr2LBs8QI/jnyp95+ND0mLXabf/X24477
25FuOXyYvNeXJNnjHD0ee66Xpu8z7v3FdseBvx15F+zL4l0p1/tpUXElRERTBM/+pMdYe9uykJ+I
vrqLEGtCuYio/Tt2OAipvVu2uLs2bw7v2rTJ2blxo7tn2zbHFeFlGEZBEPv8GobRlsAyFCotpSst
VNajB11roXaylGBZwudIBFJxu3Z1Fb/g/dX9etu6Hyue/EX+1EuDWIuRv449LlsN0+K2fT8mwUXs
SGL9c3Udn84+r+6dE0nnei9B9xsKJk33jrPvb7NOJZgiO1xDQiSvyNoTTfI5fNHH+SG6+LA87d+9
2923fZu7Y/16d9vata4IKXf/rl1OdWWl51tlXXuG0eT4j7phGK0dus2KccRmRFqXLqH2vXsXte/T
xxNKvt8RQooKPVoySMXsV/6eAGAR6qUl2K8nihKc458Xux29JoHwaLAfsTBxZnSknH/cX/tL7L78
qRM8pGHtIY19LzlyXN6Ml79H3XHvOra969n3/JoiFqN4wRRdyyJ5cjCaj6ZFXluNT/LX6+LDGoVD
vD8SsKaqKlRbWelWVVQ4e7ZuCW9audLdtGyZJ6T8rj26AQ3DyDv6ABuG0Sopbt++qH3fviHEUVn3
7p7DNj5Kkh4RBlgrpOKmcvdLg3hrEfiCJnpuzLFE53n7pEd2ouf458Xvxwombx13PPa8OMFEsqz0
So5HEiLXaLqeVyd4IunJu+X0nHr7Mdu67/JeIngiKHos9nySJSG6L0vd60Y+S9QfykuLXUtGuvYW
ugR1n5F74Z0bN4Z2bdkc3rxypbNh8eLwxhUrsFBZl55h5IfIw2kYRstH6tdQEUP4O3QoKuvVK9Rx
wICidr17I5BchvQTGwmR5Hfr1Kvc40RRZDOyHT1PF47WOxY5Obrv7ap4IT2yijlfl/h0b59/jR1n
O4GVicU/Dr7QiE0nLe68xN1yui0bkXy8zfrbek5UNPlWJv9af63XcCCyzTpWrBWFXO+b0n2IOc9P
jwqnqGO6wG9Woo73xI2q3L/PLd+9x928ZnV43eLFzup581z8ovbu3OmdbxhGztQ9qIZhtDykwixq
JwLJsyTJGuftki5dQgzvj5zgPeNSMXvb4KWx9o742zHprGMFgreOEVVseaJItmPzgHqWJtYx+/6S
dJ9/SY77+dZ1ZTXslvOWyE7kOt33t733rWmy0bBbjmOR/ej58oLRPKPXqy8SgkmPRq/T4/757ES/
S02LdslJasTCpMeAc+POr5eHl7+u/Wv848V8R7rN90Xwzq3r17sbly9z1y9eHF69cKG7YflyT2AZ
hpEV+tAZhtEiEBESKmrXzhvu375/f88nidFuJZ06eRYHfJBkkTqVutarWD0nZRI8AUNaZD+6+Psc
89dUvPHHwKu89Zi3eIkR6eCngX9NfFrs4qf5a+9fguOx+4lEk7946THr2HT/83h7Kpg0HfztyPm6
yLcV2Y85pvuJRZMs3vcjkC/7uu2vE3fLgaR757HWfeAXi1oBBbL18/PO0fP9NH1/0f3ikhK3uLQk
VFNR6e7eutXdJiJq7aKF7trFi91NK1a4e3budIgZZRhGWujDZxhGwYI1CX+kEkIA9OhBl5sjosmz
4uAsLOrBe5K9RStrr/JEMEUqz7qK1z9X9yOne+fWbcekR/f983XbS+c4+3HnRbZizmNb17GWqNhj
GmepLk3zqVt7ryWfx9uX07y/3vn+4l3Htl6nCfgB+d+Dd0IDgeFvy1JPkLDSbT+8gJxXTzD5ecSu
JQ8OePt+frr2rUySB9cL7Os54G2Txgl129HzomfqPnjpug1+HqDvO8z37p9D92y4pia0Z9u28Kr5
89yVc+e6qxctcjetWhWJKWUYRjLqHjTDMAoHEUqh4g4ditr361fUrm/fUGm3bk4x8635o9u0AvTF
gVdPR9ZeZesdV7FAGmv/PG+tlai/LxvR/eg5/nZMWqzo8dM54p/rnxf7viB6Ha8bs5at6DFvpJ63
QlvIlu975f1lzXH+ut7cb0447LhSycv5njXN25a0aB6MQOM9ca3gjQ6UpaSo2CmWY0Qr5/v0RIa8
B9byXlx5b5zupdHlxftjkXTvDfFuveO69t+vl8K5vFXy0nNAtjlJDnvnpbIwRYWdf4y96GvIOpo3
294q5nyQdTSOVCjkeYV7/lDy/mKu4aO61ZVVzvb1652Nq1eFF3/4obt4xgx3744dFmTTMBqiD5Vh
GAVBEZPRdulS1H7gwKJ2dL116uTFSqKiA6/ipOLTbdI5omnUg6wjosS3ikSInBt7Tey+pkW3SffP
0YrWv8ZLJ//YfY7757LvH4+8j8h5Ii68UV2IHV/wsC+qIywVdHV5uetNnitr2fbmdPMmyq2qikys
W1WFnw4+OXKBd50nWFh7+Ps+vK5ugtd9KYgAkrcjgpK1iKbSsjJvDjxZh5gTTxbHmziY+FXy/Xfo
3DnUvktX0mtFbBUR9byktNQVweWJLt8ZWwVLRPD470O25RvxBBMgUrxvxt+vv5aTI98ff0nzvjfB
+w4jG7Hiqt45Xh7+tixeui+YNN0/rvuhIp0KBlEYFvbv2hXavmGDu/CjD90lIp42r1njMhrPQhoY
hoc+SIZhNBuMavOmJOne3QsPUErcpMhot0jFRwUs60g1J2u/YuWYLlpxen5L0fPU0uSnRzYi53rp
/jo+XaAShUbP07VXcfuvizCR94vYQfzUVlR4wod51tiu3r/fE0Oydqt0u6aiIiqgdPFEkbfw2cMi
huRfU8DnUKsSS+T7JA3LVGmp075Tp1A7EVEdunZ1OhAlvXNn1g77HGvfqXNRe/Zlad+ls3w3ReHI
NxeBzxP93XTb24fIFxsVRb7w4aj/fUd+FYFtjpEuxORRJ5jkm4uep8cgRkDxESPHPEJOqdxzAjGh
QuX79uE4Hl4yY3p46ezZzrqlS50q+S0Now1T97gYhtF0UFsVUcn27VtU1rs33W/sRypI74RIhehX
ilTc3pp9/xgJdekQe9xTGV5qXVpkiRyol6d3Tsy2t8+aRISDpiEoEDOekJMFsVNbXh6u3L3bqdq7
163cs8etksq2htn/RRgR3VrEksMktt51rZh2nmiSJSKaQh26dXM69+jhdBIR3LVPn1C33n1CXXr1
ckpFEBcXF7siwiJBLPmuffheBV8QeYuXIvD967n+2hN03oame5uyrRamWMEUc8wTS16y9xfqdedF
rpO8NS28c8sWZ/Wihe6CDz5w502Z4u7cts2pranxTjeMNkTdI2MYRv5h5FtJ9+7F7QcODBEqoKhD
B8+i5FeWPJFasXnr2O3oWvAqzbo08NK9NX453h71XkQY6XmRxd/XY9F0XTjmWVukQucchJGIICxG
rggkt3LXLqdyx45wxc6dDiIJaxHdZmGpRDnXqA/CqKSsLIRYatehQ6h7//6hXoMHh3oPGRLq2X+A
06l791D7zp1drxtQhbPXHRb7XfKb8NsIUcHk/1aRHS/NFzl+uncOyLoxCxN4x/WcGIHl7dMFiRWq
UgTyto0b3bnvv+fOEfGE9WnPjh3eOYbRBog8EIZh5BGpo0QohdphVerTx3Pq9gULlZJfOfE0Rrdj
0qGe+GHlC57IWk6NyacRX6b4Cpfr2Y5dwlVVYU8Y7drlVu7c6Vbu3u1W6SICybveCIxQ5x49Ql16
93a69OwZ6tG/v9N78OCiPkMOdvodckhIhJR3kv+7xYrraJqs2fb2GrMw1e3LHan7clb0uJ4TK57A
F1v+OZ5zfHGRU11V7a5ftsxdOntWeMbEiTiQm++T0dqpezAMwwgQqWGK2rcn4GRRmYglEU3M+xap
eKTi8yugaGXFduSyaHqD7Ui+dft16aJ9VERF0pP7MiGSvEqv2OsuIwBimHnN9uwJl2/e7B6QBXFE
lxp+SFiQjKZFfhvPZ6pTt26hPkOGhAYdemho4IgRDt17Hbt2DXXs0sVzXve7R6P3U7wPE8h2jIXJ
WxXLlpxEQmSfdD0WK7Zi9zV/Lz1WZJVF3oe7e/t2d+WCBe57r74SXjh9urNr27bI+YbRutAHwTCM
IAgRKqC0Z89QWb9+Rayjc7zFiRcqHG9f06mcvDQ97ldSsdd5a/Jioy4dIscj6/pWJs4nPXKd172G
KMKKVL1zp1uxa1e4kgCHBw7U+SoZBQW/H6P0Oolg6jlgQKj34MGhvrIMHC5iSgSVCCmEjyeY4n+/
eBEUEUx6z8ni3SP+MV2zHyu8vHME73y9Nia/yHFJ55rq6urwljVr3JmT33VnTprkLps716m1Lluj
9eA/FoZh5AAxgEIlPXtiVXIY/VbUoQMKpt6EuP46WlH5+2QQnxa/rXjixz/GX63EdB2Ny+RZkmpr
3dqaGjeMH9L27eHyLVu87raaffsYtea45sjbYuH37tGvH915of5DhxYNGTnSGTB0WKhbjx4uo/vK
EOsoqHCdiJI7RLvlIvdTPQuTLP59Bb6A8tIUT0j5+2zLKiq0QPIuZrQg96K89ObVq52FM6a7s957
z10ye7a7e8cOjFJ6smG0SGJueMMwMgNx0q6dg2N3u/79i0q7d3edkhIqtEhVwl+tVHxxE+1G07VX
+ejSYF8WLyddEpwHkTTJT/5KnST1pIgkEUZuBUJp06ZwxdatXlebiaTWCb8/TuYdu3QpGjh8eOiQ
o48ODRo+3Ok9cJDTU0RVmRyLtUDFWpG4j+rty30UiWYVwb9PvTTuMxbdj9x30SsRXmFy8tJlwfcp
HA6HDuzdG16zZInz/hvj3Dnvv+9uXr9erzCMFkfdDW8YRvoUtW+Pn1JRaa9eoRKNqyQVRUNfIl1T
iUC8aPIrGL3OS/fToJ4DeMx5nkgC71CIGEjh6t27WRzEUsWmTZ7TtnW5tS24F4gn1aFz56KhRx4Z
Ouz440MDhw0P9Rk82O2GBZQRkf49BJyvm2z7Aip6Dmlx+2xFhZaKJu8cJ+KEHnsulihv33VDB0TI
i2gKT379NXfhjBnOXrlXDaOFoTe3YRjpEAoRNbp/f28UXHGXLr74kXpBniVfNLHHmgMc97dl7aXH
rf3tROd426SreIrmJ2LIraqKdLtt2OASAqB6zx4vqKRhxBDq3L270+egg0JDDj+86IiTTwkNGTnS
7dipk9eFhyj3R7x5wifmnuM+89Miu5F1fJecnONZmCD23NhrZcPbLikuDlXu3x9eNGeOO+nlse6H
EyY4+0w8GS2HmJvfMIykhNq1w6pU1G7AgFBx586RKTmw4EQqBakPZB0vmmSpt+ZEX/zEpPvbkNCy
JGlYCBBKOHKLOHJFKIXLN24M48AdtlnqjTQhFlQvEf2HHHGEMwoBNeKwUO8BAx268LzJeuO68SB6
HwopLUz+cVlHu/QUtriee7y0tNTB8rR45kz3rZdedGdPners3Lo1cqJhFC51N7RhGA3wwgaU9uvn
WZaI2O1VClKxxFiQpK6QdTIrE2v2ZVvPjuyzZrcun/qCifMj50X8k0QgVW7b5lbt3Ilosi43IxB6
ioAaOmpUaNTJJztHf+zUoj6DBukRQS1Q/v0ctTCpYJK7NKx3rHdOg/N0zd9E3X5sEzSTiYHXLV/u
znhvsvvGs8+661asiJxjGIWH3sCGYdQhpXlxx46h4kiMpVBRx451T4pf+NeJHfyXPAXjVQjyR9f+
vrf2tiMnNUxnETx/E8kXb26cuau2bQvvX7vWrdq+3YubZELJyBfEhurRu3do5Iknhk785NlFBx9+
uNO9Vy+nXfv2Tm1tjeOGpaHgnRi5V+XO9QSTf1/766glKibdF1a+5Sp6TBbvfFmwPHHfb964wZn0
8svu608/7W5Yvdo7zzAKCL2JDcOAouKuXUOlffoUlfToQZdctDLwRBJrf5/uMm9X9htzAJc1R/S8
ujXHWHOev66sDFeIQKrcvDlcsXmzW7N3L2cZRpNSVFQUGjhsmDPsiCOKRo4+MXTUySeHevXr5x1j
dGZIND3bes9H1w0sTLLmKWCtR7xjbMeKK38NjLjj/C3r1rkTXn3FfeO559x1K1d6xwyjAIjeyobR
lqHrrai0b18CUnpiKbYw9yoFXUhToQORdKlLdDuSVneOt5Dmpeq2t6ZykIUpS2p273YZ7Va5bVu4
dt8+81EyCgbmyus3eHDo6FNOKTr57E+FBh96aLhz165OLQMRamsj97OQzMIUe+/7+OIq3vIE/nUE
8wzJa6xdudJ988UXwm+/+KKzecOGyEmG0XzU3ayG0QaJiKVevYpKevVyQmVlDcSOd5K/HZsuf3Qd
6Zrjukhq5JzIfqRi8NO8BC/dFXEUxqJUuWWLW71rV2S6Eut+MwoUuo67dOsWOuzYY4uOGzPGOerk
UxwRU55lKHrfcp97KxU+uu+h97+fFiuY/LNI87Z17TcsqiornVWLFrmTxr3uTps0yV2+aBFnGUZz
EI3QYRhti6KiUHH37kVlAwdGBFNpaaSwpjCPWQPb0f3INkT39ZzIlool9tj3Uin8Ze2KwKo9cCBc
sXZt+MCKFeHyNWsconN784cZRgFDt1xlebmzYdUqYiyFNsu9u2fnzlDHzl0QU56o8gSUwN/ovQ+6
9tP8Z8NDt6OCSfDP8ZDXLSspcfoNHOgccdzxoYOHH+rFHtu4dq3nQG4YTYyJJqNNEXKKi4mvVFw6
YIDXHVfcqVOksNZC2rcQeYW2Ft7+tr/PH28dObF+Wt1+pKUsa7emBmtSRCwtXx4uX7fOm8rELEtG
S0TEiot4WjRzprNkzmxn45o1oQ7t27vtO3UKddBJqb2nQ5+DyGbIE0ax1iQWP927Rhf/fH/tbbtu
qKysLDTo4IOdo0ePDg2UNdOy7Ni6NRpnyjCaABNNRtuhqEOHopL+/YtL+/cPlXTtGgpJCxa8Qtkv
nFni0rzt+mIK2GV6+fpp7COWpOUdrqwMV+/YET6wcmUtYqly0yYmzDWxZLQKsPTs2LzZXTxrljvj
3XdDWzasd9p36FjUo2dPp4M0RrBOAc9FIutTonTWsdtQzwol/zt16RIaecwxzgljxkgbqMjZsHat
U75/v55hGHnFvxUNo/USKi0tKmFEHN1wjIjTZCmV5a8W0gnW4MVOitlXS5Sc4qXVdwBHLIkkErHk
VhJXafNmnLyt+81oM7Rr3z505IknFn38vM+GTjrzTLdrjx6RZ8i3BvnPkaw9wSTrSEpk2yf+eOwx
2fFWpYToCIXCC2fPdp59+CH37ZdfdioIzWEY+cMsTUYrRkrU4u7di0sHDy4q7tGjzrLE/4j4qeuO
i19TWOsif7xFt4FT3Oi5xcUcjPgrrVlTu2/hwrCIJmItmVXJaFPU1tTQXedOf3eSO/P990LhcK3T
uUtXB/FELCa60nh6sB6Bv9Znylv727HHIltCzDHPkuW6oUFDhoROOePM0GGjRrnMZ8coO+uyM/JE
9FY0jNZEqKhz56KS3r1Dxd26YQGScldHuVHoeqWwt/b366+9DOrEU1RgRc6BSLdcJN0lZEDlhg3h
qs2bXQsXYBj16TtwYGj06aeHTv/MeaHjTj3VKZXGiz+RtPdMAc9TZKt+d5y/7Z8n+GLKD13AX55F
oovv27PHff/tt91nHn7InfnBB95xwwgQszQZrYpQqH17xJLnt+RPe0LJ6xXOsvDXF0Hevp+eaM0p
nFu37wWv9NJqasLVTJa7enVt+cqVbvWOHdYNZxgJ2L93r7Ns/nx3xuTJzuply5zOXbs5vfr0CbXv
0MEJx/g9QWOCyTum27Gxnvw08ipt1y40YtQoZ/SY00K9e/d216xY4eyzILFGcJhoMloFoVBZWVFx
r17Fpf36+dalugLXL1gja/7UT+c0FVKeKNI077ifThrb1dVu1datnmN3+Zo1bi0+SzU13jmGYSSn
orzcWblokTtl/Hhn3apVTrt27UO9Bwxw2pWVef5LvmDiWYuKJ12zD5znPcIxaR66zV/XdUPdunUL
HXfSyUXHnXyyU37ggIN4qrVGjZE7JpqMFo6UoEVduhSpWKrzW0LgUJD6hWlE/MjZmqaLVwDHbEfX
kZOj5znhsFuzc2dtOaPg1q1zahmtY/5KhpExVVVVzgoRTzOnTHE2rF4V6tajh8OIu5LSUmmXaGMH
9DmMFUfe8yrraBrrmPP8M/Vpd/oPHBg66bTTQ30HDnRXLV3q7GWya8PIHhNNRgtGStii7t2LS/r0
CYU6dIjM+6YFaD3Bo/uy8KduPyKkvG3SE65FGoUrKtzK9evDFatWubUUuuZkahg5Q5iAlUuWOAtn
z3L279sX6tatu9NdxBNTqPh4z6msEUS+KPKWyLPpPaeRVf3zvDTvr+O0l7Lh8COODA0/bIS7eeNG
Z4ss5ihuZImJJqNlQndcSe/eLHXRvDmgwskXPmz4294G21yvabHb3mFfeIVCrltZ6VZv2eKKWApX
bd3quBaB2DACBfGyc9s2d9706e7cadNC5eUHnD79+oe6de8eeSblHJo23jOq+KLIe05ZeZsxXXpK
9DzXxYoVGjx0aOj4k092Onbs6GzasMGsTkY2mGgyWhhSOhZ17Vpc0r9/UVHnzvW64XzxE92OnO9t
+91z8aKqwVqOu1VV4Wom0F27NlxF6IDKSuuKM4w8Eq6tJbq3O23yZGf+rJlOcXFx6OBhw4j7VF8U
8ZwC25oePRY54q19weQf97blf69evUInjTnNGXXUUaHKygpn5bJl+EB5xw0jDUw0GS0InL179y4m
lADWJa8opED0C8aYRQvKSJedtxHZ9477+/55rIuLGf0Wrtm6tbZ8xYpI+AALlGcYTc5WabBMnTjB
mT9zhtNDnvWBgwZ54glh5aHPsC+UfEuUt81aj/triG6LQCouKvIE2Wmf+ITTp29fZ+nixYQqiBw3
jMYx0WQUPiJoiop79vSienthBGKFkL+ta0mMpOvib8uaP/XTOZ3rQqFwzfbttZU4eW/YgKXJO2YY
RvOAQFq/Zo078fXXnJXLlrq9+/QN9RXxRCwmLEOIo6hYilk8YrZjj8k1Xnw1z64kebRr1y503Ekn
hU4/6yxn29atzvIlS7xrDKMRTDQZBQ1xl4pL+vYNhbp08axLmuoVgv52ZCXr+G1Z/G1Z86cuXcSS
JLlhonivXh2uWrvWMcuSYRQWNTU1zorFi53333nb833q1qNHqG+/fp54QvjI0xxZeK7jtr3nnJW3
GUnzxJZ/nG45WfoOHBD6+CfPdrp16+YsnD/fC1FgGEkw0WQUJlLIFRV37053nOPg11BUFCnpKPAo
DHXd2LZ3ekQccSCS5oslnLw3bqytXL3aDWOapwA1DKMgObB/vzN3+nTnvbfecrZu3uwwdUrPPn2i
AijyiMc89+yzjmx7DzcBMSNH67rx2McZvaxdu9AJJ53kHH/iSaHt27Y6a1atkiLBygSjASaajMKD
CXaLevYMFRGk0o+75JWIgnbDebu6eNsN08E/zh/Ocd2qKrd2x45w1bp1dMk5jgW8M4wWw/59+5x5
M2a4C+bMdkpLSkODBg922nfqFJmWBfxnXja1+85TPp5I8tecI3h/Nd2zWknDbPDQQ0KjTz7FO2fz
pk02ws6Ix0STUVB406AU9+oVmQIFIUQBh+qJrCMLJ/oiSdfx6dFrZN/bkGZj7d694er168PV0lL1
RsQZhtEiYVLeqRMnOtu3bnEGDhkc6knoEZ51OYYw8ixQIpgoDXzBRDEQKSVkm8Xfj5zLlie+uvfo
ETr5lI+FDh1xuEtogo3r13vHDEMw0WQUDqEOHYqKpMCSdf3CDNVTtx05V7dj06PbkRO8fRYpCD3r
UvWGDQgn64ozjFZATXW1s2T+fGftqlWh3n36EP3bKS4piQoj8MST4m/z1ysrvJ2643463XJl7do5
Bw8d6gwffqizbcsWZ83q1dZdZ4CJJqMAKC6mK66oGMGkzt5RARSxHCWN9p1wm7+R61y3osKlKw7r
ko2KM4zWBUJGRJM7ZeLE0O6dO50BgwaFevTq5Zao1QkoF3xrk7f2ywkWJZqu296a0ARDh4ZOO/NM
J+yGnXlz5ji1Ns9kW8dEk9GshELt2hUX47/UubOUbDHCKCJ62IdIun8sZt8/L+b8SDrWpV27wlVr
1rjhffvMumQYrZjyAwfc2dOmObM++sgplYbXoCFDnE6dO3vPfaTUqBNDHjHbsen+tvdXtumu69q1
a+jk005z+vbt58ydNdPZz7yTRlvFRJPRbBBGgLnjCFpZT/TUE0meZqrbT7ktxSRhBHzfJdccvQ2j
zbBtyxZ34vg33MXz5jn9Bg4MDRk61CsbvJLBJ1JORC1P3rasY7f9a0JFke660uKS0OiTTgqdIuJp
+bJlzro1a7xzjTaHiSajGSgqColYKirq0oWuuWirzkMLLG8/skB0P+m2iC63ujpcs2VLuGbDBidc
Xh7JzzCMNgUiZ83Klc7b4153amtrQiNHHeF0krLGm6SX8kLgr1d+xG9TlnhbEWLDFLhh1xl8yCGh
Mz/xSXfH9m1eJPFaa5S1NUw0GU1KiIl2EUwhRsdRUFEo6do7wbc4Rdb1fJnqnetvc9x1w2G64tav
d8O7dxN4JXK+YRhtlsqKCmfqpEnunJkznCFDh3rhCYqkvKA08cUR1iWvHIlN8xc/NJy37227+DR1
7dYtdMYnzw717NnTWbZksbOHMsdoK5hoMpoQ/Je6d0c4Rfb9AstfvKSYfdmJ7kcO1hNVHPPmi9u0
CQuT41RXR44ZhmEodKW9+/ZbCKbQ8BEjnM6+r5Nfrghs+3teCaOCCbGkR1z+IrKwZLVv18459oQT
Qsccd3xo757dzlKbgqWtYKLJaAKksAp17FhU1LVrKESwSgorFT+xvkwsUZEUWepNuBt7TLZx8PbC
CHjWJSnIDMMwEkFQzOlTp7jLly5x+vTuE+o3cKBTWhoJnOtZm1jrgmCq+yfFDYLJL3/YlzXCqVjK
psFDhjijTzo5VFRc5CyYN0/abdZwa+WYaDLyTEQwhbzuuOJiKW8iosdbBL8g8tPqHeds1t5hWfsC
iw643bvd2s2bXdd8lwzDSAMEzbLFi0Mrli0L9ejR0xM87cvKnLAIILrivDJGFi1xPLHkbUXTSI3b
l+3uPXs6Rx93nFNZUe7MmTXL/JxaNyaajDxSXIzvEqKJbVLqfJQoeHQdXTih7riXoKtIelGR69TU
uOFdu9yaLVtc11p1hmFkyMb16905s2Y6u3fuDB0y/FCnh4gex434QfrdcZFSJ1L+sM3SwP9Jt7Fy
d+rcOXT86NEhwh3Mmz3bqbQZB1orJpqMPFFS4o2OI7q3ZyGSEsYrZGKW6D7oOuYcqJdOoMrabdsi
c8aZs7dhGFmyZ9cuZ9rUqc6aVStDI0aOdAb0H6BHPFxfIFH6eIuWQz4N9mXBV+pjp44Jde/W3flg
yvsmnFonJpqMwImMkCOcANG9KVwiC8Tuk9TYPkQEF6Pjdu7E2dsNW2A5wzACYvnSpe6kt992evft
Gzp81CjXH13nQxnklUNKvX3d9vcJhMk0LsRzGjb8UGfWzJnOrp07vWNGq8FEkxEoTLgbCjE6BYfv
SIq/QEQEkaxr75ifVneutyPn0AUXxneplsLHfAUMwwiY3bt2OW+9/rpz4MB+59jjT/AiiRPTifJI
SqIoXvmkxB+Ldt3pgJTjjz8udPQxxzrz5s51Nm3a5KUZrQITTUZgMNFuxOEbEUQB4i/8jRVJsngF
TORY3T5EzguFiouxKtXWbtzomLO3YRj5pKamxvlwyhRn4fx5ocNHjmIOu6go8gRSorJKYBvB5OOd
K2vyGzpsWOjjZ54VwkEc8eQF1zRaOiaajACQkgKHb6d9+1hH7rolcka9tOh+5GhkX7cFnL3Dtdu2
WewlwzCajJXLlyOeQr1793GGHHyw065dOy+8gE+0nBLYrttTa5NuA6Po+vbp45xw4kmhLl06O7Nn
zjQ/p5aPiSYjRzSkgJQudeKHJdayxDn+ti5JLE94L7nhHTvcMN1x1jIzDKOJ2bZ1qzN54kRn08aN
oRGHj3R69u7tdbv5gol1rEBiXc/axKLnYF3CQfzEk04KDRo4yPnoo4+8mFFGi8VEk5EDxF1q3z4q
mFjqiSF/LSWIt8dfPe4XQKR52yy1ta67c6cb3rMn6htgGIbR1JSXlzsL5s93du7YHjr4kKFOv379
vHRKrWjZJfhll5/iH4/u67llZWXOYSNGhDp2wuI0w9lvA1paKiaajKwIeYKpXbuQlAZeoeGJIQoL
tv0FIukQTfeOxBznGA7f4e3bifJtgskwjGaHeeaI8r1owYLQkUcd7QyM8XMC39rkLbLt7fvlmhB7
nDKNGE6jR48Odeve3Xl/8mTrqmuZmGgysoCglR06hLwRcjGWpVhhFLdAdD/uPNfdv5/uOIvubRhG
QYE/E3PXzZo+LXTwIYc4Q4cP99LrdcdRpum2T+xxYI/ziouLnWOPP94hltO0aR85Bw4ciJxgtBRM
NBkZIc0pKQ58wRQRPdElTgyx+NRLj+7jtbRnDz5M5vBtGEbBsnnzZmfypImh/v36O0cedZQUg9JY
FEHlW5N8KNsaCCpdwJ+z7qSTTnJ69+rlvPfeZK8r0GgxmGgyMoAuOSJ8y9oTPXGLVzDEWJ5I8NLi
Fs/vKSyY/5JhGC2EvXv3Om+/+SaiKDT6xBMbjqxjoYxT/P26lEikceDvCSeOdgYOHOTMmjXL2bVr
l5duFDwmmoy0iPgwtW9fJ5gA8eMXAvXFUmQbq1TsuQLnuTU14fC2ba5rpmnDMFoQVVVVzuRJk5wd
27eHjj/+eKdrt26ecPIsTn5ZKEvsvuCy7QsmQGpx3YnHH+ccOuJwZ/68eZ41yyh4TDQZaeALpohI
qhNNrHXbKyAaLuAd133XIcI38Zek8DEMw2hpIHZmTJ/urFy5IjRq1BFOv/799UiEWHEkeIKpXorg
iSpZh0U9jRhxmHPUUUc7SxYvctauXRs5wShUTDQZjeJZmAgp4FmSIsInIppiFzkmfxukeSmsI9uu
W1ER9vyXamrYNwzDaLEsWbzYmfLee07HDh1CRx1zjOer5JWDEepN+usTKT7rp9HDN3jIEOfkk092
tm3b6ixYsECPGAWIiSajEYqKIiEFKAwQQvyJCKA6caSLVwiwHXucv5HjjIwLh3fvNsFkGEarYeuW
Lc7MGTNCvXr2co4W4YSDuCvEWZs8SIkXTL7FifQB/fs5xx53vLN8+TJn2bJlkROMQsNEk5EAHngs
TKWlrL19FT9sRM6JE0/e8Zh9EgTKD8c5cCDsxV8ywWQYRitj7549zpxZs5z27duHjj32WJd4TF65
F4NvdfJhy1ti0riiV+9ezhGjjnDmzpvrrLOuukLERJORAASTH4NJRZA6eteJptg1x+P2SfAkE8Np
TTAZhtGK2b17tzNr1kyn/4D+zpFHakgCgXIxkdUpXkQBeyyDBg5wBg06yPnwww+cHbgzGIWEiSaj
Hl4cJs/CFBE+UYuS94Dzx09nATmux+ovdNXv3++6e/faHHKGYbR69knjcNKECU63rl0dQhKUYKWP
szglE1Gk+CKK9WGHHurgZD5hwjvOHsKyGIWCiSYjBp5WBJNvYYqkRR7myFJnafLP8ZKixyMLRcW+
fYimBoWGYRhGa6WiosKZ/O67Tm98nI491iECuA8lo1dWxhEpVuvSvSJTluHDhzkHHTTYee+997wY
UUZBYKLJUHhqY6dFSSyKIqIpbpG/dfs883v3WgwmwzDaJMRyeuedtz1L0ykf+5jTvgwfJ4pHr3yM
wr7vCJ4Ijh155BHOwQcf4rz77iTPkmU0OyaaDIGnlxZRjFDylsgx77CKqeSiiePhsOvu2UNoAVIM
wzDaJDU1NZ7Qqa2pdkaOOsLp0a2rI6WjHo0IIq/cTADpxUV1R4844ginf/8BztSpU0w4NT8mmto8
CB/8mHwzcmS/ThCxjqRHBBMksELh841gsqCVhmEY3kxRzpSpU51NmzY6x54w2unds4c3Qi6RTxOQ
SlGc6DjO5QMHDnTmzp1rzuHNi4mmNg9iyRdE/sMas+2LIm9D03TfW7zjWJj27TPBZBiGEYMrwmnB
/PnO4kWLnOOPP8Hp37dPAzdPSlUsS14RG0mKJ8SMVEcddVRo+PBDnakixHbu3KmHjCbGRFObBsFD
00YFkLf46bo0EE3+EumucwgrED5wwHUqK719wzAMow4pIp0VK1Y4mzdvck4+5WNOzx7do8IpmWVJ
4YB/UErgUNGwYcNCXbt2dd99913P6dxockw0tVnqBFB9MRS3JBVNLOGw65SXm2AyDMNoBITTkiVL
nC1btjinnXa606VLZ2+EXCOCCTjoL1AkhEaNGkXXnzt58rteF6DRpJhoapPUFz/JRVNRdE454jd5
l2q6K0+r41ZURARTvL3ZMAzDqAcCZ9HChc6B8gPO6ad/3Gnfvp0eaQDFbuxC4csaQqWlJUVM8Lt9
+3Z3xowZmmw0ESaa2hyIHn8dWSKiqb4oim57Z8cLK5y+ifRtFibDMIy0QTh99NFHTrt2Zc6YMafV
i+MkUNzGLxCfVtyxY4fQ8cef4M6cOcNdtWqVJBlNhImmNgeix1/HW5ASLJzCUd13o4LJnL4NwzCy
goCVoVCRc9ppCKciCtn4hYLZtzDFb3vrrl27Fp966pjwhx9+6K5fv16SjCbARFObIyKCImvfglS3
32DxRFOMpckEk2EYRm5gccKZW4RPaPTo0U5JSbEUsB6s47cT7bMU9+7di6662jfffNOb/87IOyaa
2hyIH4hYmKJiqMEix+Vv3T6dciaYDMMwAgHn8EmTJoXat+9QdNJJJ4VEOPlWJJZY6xILlXVsur+U
DB58UPGQIQfXvPXWm045ZbSRT0w0tTkiAiiyjrEgRZeG/kwsrov/kgkmwzCMwCBy+IwZMyhki0aP
PjFUWlpKsesvwJpCOXYdu0DxqFGjSrp06VqN9arSfE3ziYmmNgeiqE4kJbc0ySJ/WbtudbU5fRuG
YeSB8vJyd9asmQimohNOOKFIhRMCyV/Y95fY9Nil+Oijjw7t37+vdt68edLGtfI6T5hoanMgiPx1
rGhK4BAufx23tjZiYbKwAoZhGHmBbrUlS5YU9ezZs+joo48pinMOp3BOtS4qKSkpHTp0mLtnz+7q
+fPne1YsI3BMNLU5YkQRe4nEEovnAF5bG4nDZAHUDMMw8sru3bvdOXNmF40aNar4sMMO8wURS6Lt
2LW/XdyzZ49iHMMXLJhfu2zZMkkyAsZEU5sjVhzxoEXW9dNlCRG8EguTCSbDMIwmAeE0f/68kjFj
Tivq168fhTNLvGhKJJj8/ZLu3buXDhs2vGr69OnhTZs2SZIRICaa2hRxwoiUBmksDOswC5NhGEZT
427evNldtWpl2TnnnFvUqVMnXxjFiyN/nWgpGzz4oJKBAweVv/76a67NURcoJpraFPXFUWLBJA+t
58NkgskwDKM5CC9btsxdu3ZN6Wc/+7nimBF1scLIT4tNp0L3j5UeeuhhIWn+lr/zztuyawSEiaY2
BaKozoepvmgiHQuTCSbDMIzmpnb+/Pmh8vIDZWeeeRZO3r4w8heEkS+SYtNYvP2iolDHE088qXb7
9m2V06ZNkyQjAEw0tSl8gRTZjrc0uQ6hBUwwGYZhFAI1M2bMKOnWrVvZSSedLCLIa/H64iheKMWm
+UtxaWlJB7m2/P33369Zu3atJBk5YqKpzVAnlvx1RDRFLE+uw/BUwgsYhmEYBUFtbW3N9OnTSgYP
HlJ25JFHYj3yxJAssULJtzj56bHHSzp37tRu5MhR+8ePHx/es2ePJBk5YKKpzRCxJkUWHqbYUANY
lyymh2EYRqHhlpeX1yxevLjsuOOOKxkyZAgFOIV3rFiKXTjun+Mfbz9kyGBpJhfte/vtt6S4t96E
HDDR1GaIF02RNbiehcmCVxqGYRQi4e3bt4fXrVuLczcj42IFkr8du+9bmmLT28u1VUuXLq0QAea5
rxpZYaKpzZBYNLmelclaHoZhGAWLiJza1atXuzt37uxw2mmnl3Tt2sUXRLHCKN7yFCueSjt27Nhu
wICB+2fMmFGzefNmSTKywERTm6GhaHI965L5MRmGYRQ84XC4ZunSpU5NTXXHM888CyfveJEUv/iC
yT+vw8EHDynr27ff3smTJ9fu3btXkowMMdHUZogVTSwmmAzDMFoUIpyqZs+eXXbQQQd1OOGEExBF
LLHCyF/89Nh9zukwfPih4f3CtGnT3GpGTBuZYKKpzRDr+A3WJWcYhtHiqKmpqfjoow87HHvscSKA
hlGwxwsmf/HTY4+XFBcXd5Jrw/v27d07depUSTIywERTm8EXS4CVyRwBDcMwWiIiePZVLliwoNt5
5322pFu3rrFWpWQCioVjnFvWoUP7DocdNgLRVLl+/XpJMtLERFObwSxMhmEYrYWajRs31m7evLn7
ued+pqSsrIwCPl4w+fv+Ovacsp49e3Q85JChO8aPH18rIkySjDQw0dRm8P2YDMMwjNZAxbx580o6
duzUZcyYMbERw0t0TQWPUPLTfcHkL53o3isqKt7+1ltvSvVg9UMamGhqE5hgMgzDaI1ULl++vPMp
p3ys45AhgxFCsSLJ348VS7FCiu2OQ4cO2zV58uTydevWya6RAhNNbYJYfybDMAyjtVC7e/fu2u3b
t3c7++xPlXbq1DFeGMUv8SKqXefOncv69Om7aezYl2w0XWpMNBmGYRhGC6Zy1apVJR07dux6yimn
FJeUlCSzLsWLKf+cToccMrR669YtOz/88EPZNRrBRJNhGIZhtGDc2tra8oULF3YbPXp0x+HDh/li
yF/7Qsnf932e/GNlJSXFXUaMOHzTuHGvV23fvl2SjCSYaDIMwzCMFg4j4CpWrFjRZdSoIzrGzE/H
QkXvL75Q8rf9pVPPnj1LOnfusumNN95wa2wC92SYaDIMwzCMVkDF2rVrK1avXt3rzDPPate9ezdf
EMULpPg0tlm6jxw5snz9+nU7ZsyYIbtGAkw0GYZhGEYroXzVqlVYkvp84hOfJPp3bNdcvFCK3y4r
LS3pOnLkqM2TJk2ssEl9E2KiyTAMwzBaC67r7l+8eHGHfv369zrhhOP9rjhfGMWKJX/x0zi3U69e
vUrbt++wfebMmVW7d++WJCMGE02GYRiG0YoIV1RU7F+2bFn/T3/6HERQrLUpdu0v8fvdDz/88Mrt
27dtnjJligW9rI+JJsMwDMNoZTAKrqRDh/Z9zzzzLL+bjgqfdayIik33xVNZaWlpu65du22dMWPG
gY0bN0qSofA9FRxlQjuBdUkM8rsXhwSNFh9FhLAbFvx1TQzVSqXAMb3EMAzDMFo17Xr37j3mkUce
HfyZz5yLuYiKM1Y0xW/HprlVVdWLHn744Um//OUvqvfv3y9JhsB30ywggPoIA4QeQi+hp8BadnuS
1l3oLHSM0EHEryeiZF2q2XgglqoEXySVCweUvXv37hH27hR27969izUJOwQR4tu3Klyr2RmGYRhG
q6DfWWed9Ylnn32uQ8+ePdhvTDjF71du375j4m9/e/n8//znP7JrCHw3eQWLESJHBG/vw4RRwogR
Iw4/REAkQRehm9CpU6eOelneqK2tDYto3r9PEBG1e5eyRli5cuWKxcJSYaOA+EKMyTW1erlhGIZh
tBjomjnxH//45zG//vWvYgWRvwa/e87f948TBHPdBx98+NznP/+58m3btnGsrcP3Eij9hSECAmnk
yJGjjhSGCaS1FxBR8hsG/rpBUVlZWUVXHpao1atXr1q4cOEi0VBLAGElaasRW3q6YRiGYRQ0HQcO
HHjOSy+NHTB69AnsUwH7IgnBBP6+fww4Fq6tDU++6qr/e/+6666LpLZt/O8ma7ASHS6cJpxwwgmj
RwiHCr179+6lp7QaRDOtXaYsWLBgvo917xmGYRiFzNAvfelLZz/88CMdunTpzH6sSEq0BtYsu9eu
XffUF77w+U2zZ8/2DrRhshJNWI7GCKeffvrHWQ8ZMmRw586duxQXF/vfdauHbj58pvCPWiFMFKZO
nTqFrr31Al2AeqphGIZhNCtFJSUlZ9933/0jv/Wtb1FXpxJNvjhgm266WY899vjz3/3udxhp5R1o
o/jfS1p8TPj2t7/9nU8IWJMY0KaHDEHupVq67+jKmydMmzbtow8ERJRZogzDMIzmpOcRRxxx7v33
PzDw5JNPDhUVhVKJJn9hv/rAgfInLrjgK0tef/112W2z8H2khBFtP//5zy8Tgfqt4cOHD9NkIwVb
tmzZuk7A+vT222+/9a6wQTCfKMMwDKM5GHXRRRedfdNNN3fp06e3H4YglWhiwdq08JVXXn3swgu/
VtWGe1JSWopOFP7yl79c+x2hf//+/TTZSINOAiEVRgr4fH3uc5/7/OjRo08cPHjwQTjE04VHF5+e
bhiGYRh5Zf/GjRt7DR9+aL+jjz7aC3woaYgkxACLL6L8tNj9rgMHDtyxYsWKjXPmzJHdNklS0USY
gJOEv/71rzecf/75X5A6vkwPGRlCME4CTRGXSu7Toz/96U+fc8opp3xs2LBhw7HiEXQT8URoA2JO
6WWGYRiGESg1Utew9D/++BO69e3bN1YgxYoktmMFFeuOZWVl7bt27Tb/5ZdfJiCiJLU5koqmzws3
3njjv0899dSPaZIREIRckHu1j2jSE7E+nXPOOed+UhBN1ZuuOyxQ5gNlGIZh5IPda9asKREBNPzs
sz9VLBWSL5h8oeSLJNZ0y/lCimM9Bw0atHHevHkb58+fL7ttDr6TBjAi7u9///s/jjvuuGM1ycgT
REbH2jRixIjDsED9j3DMMccci6UP6xMRzPVUwzAMw8gZ5hvbs3bt2kOkru81ePDgWGsSa3/xxZK/
kFZWUlLcpV+//ksmTJhABSVJbQq+g3ocLNx22223f+xjHztFk4wmAgsU08YcffTRR31V+OxnP/u5
E0888SS5v2s3CxWCnmoYhmEYWVO1d+/efZs2bTrqi1/8UllZWanv7J1MLPlrzus9aNBB+7Zv3750
0qRJstumqCeasHpceeWVv7tQwA9Hk41molevXj2PFc4///wvfulLX/oyPlCS7Mq9vhcrVOQsoyXC
9EEHCTRSiHtGBH2CxALR8xlAwNRCPJN01+plhmEYgYG1qdvAgYOGnXjiaPbjRVLsNmt/u0wa+F37
9eu/ZubMmTvWrVsnSW0GRGOUU4UXXnjhRfxtNCkjiFOEUzOV+hZhm4CPjj/Pm1/ZV1ZWVvh+O0yw
yzWaRT3oohIFXObN1tuhA0sHtrsKVCjgT/bL5L5M5ovYU+p9ttZCeXl5xQxh3Lhxr78oEA8q2fdn
FAYIn0ECgwCOF0QXjWR/oIB4wroot3Z7Pd2joqKikueHGF/LhQ8//PCDKcIcwSyOhmEExYBjjjnm
0jfeGN+9X7++7FNxIoxY+1YV9husw2F38oMPPvjUH/7w+92bNm3yDrQBosICwSGf/6FvfvObF2lS
o2zdunUb/jbyXW0imOPixYsXMb3IKoEo2czfxmS3wKgwxBEiiW3NImOofAAxBbxn1ggrBBXiiYmB
mf9OGvGDGdovjfaDqZiY7oXKiXV8BdVS2bBhw8Z3hMcee+xRhBRdeHrIaGYQ8ViNThHOPvvsTx0l
YDlE6OspGcGjs114X7j//vvve1tgQmk9bBiGkTXnX3fd9Z/9/e//H0O3E4mmROKJZf/Onbv++8c/
/mH8HXfc4R1oA0RF03HCSy+9NBahoUkN2Lhx4yaEEfOtMWUI65UCliTEEDT3kHnCTmBmQlz5a6xR
dIEcItAdIo38g+j+IARAX8xqQteuXbtoFi2KsKh9xOsbwosvvvjCRwJRyfkt9BSjCUG4I5QYFQnc
X4j6IC2fiOW77rrrzvsEeSY3arJhGEZW9JMG3mXjxr3R9+CDh1BQ+Ysvnvx1ovRZr78+7uZvfvOi
fTt27JDdVg+f2+O3wg033PC3RIX7unXr1tO6pZWLVYko11iN9HCLwhdVWJyo0PoJWKYQVaOEI444
4ki2sUphyUJ76aUFD12gs2bNmj1ZePrpp5+i687EU9PA/XSe8LWvfe3C0wW5rTxTd77AkDtW+KO0
8hYLmmwYhpEVn//Tn/50wZ///Ge2fXEUa2HyRZJ/DHAc37Nz5657L730kslPPPFEJLV14312rDGP
PPLIo9/4xje+7qXGgCXjVeG66667dq2APwW0NudUhJTfvcd6uIDvCd0qQwU/nV5BvaRgoUKlIn1N
ePbZZ59BPFlXTn6guw3Lkj8n48CBAwfoobzDs4mF8bLLLvs5U/VosmEYRsb0lwrv16+99vqAoUMP
YT9WNMWLp9jFlXJo5rhx4/554YVfO7B3715JatXwmR0cq0UXvXbaaaeN8VLjEI1UuUtYIyCccE6V
FbtrZguttaWLVQqfKSxSoyOceIyAoGLC4vbt27fTUwsWunL+K2B5YvJgcxoPBhoaZwhYlggPgabW
Q03OM8888+wPf/jDH2Bp1CTDMIyM+frf/vb3z15++eWl2uMUK5pI8LtdfMHkp+3fvXvPLVIGTZbC
SHZbNd4Xg9Pqa6Iws4nNtHDhwkVPPfXUf/8utIVh8HTZMSScLrwjhFNOOeVjjIgirVOnTh31tIJj
8+bNW94SHnzwwf9MFRjJqIeMDBkh/PrXv/7NVwSiuGtys4GTuJRVz4wdO/YlGjA0ZnAaN4FsGEYm
9Bk6dOjlL7740tCjjz7KF0Tx1qX4fSiVZcrLL79y3de/fiHdUJHUVgyOqs8///wLOHFny08Furg0
yzYDFgcsdWcK11133fWTJk16Fx8w/VoKjn379u0fP378m8TiYlShfgwjDfitvyWILlmiX2dBwQBV
Ka8OLF26dNk///nPf0kj6GNt8Zk0DCN7zrnkkkuerKiofF7KFETBS7KMleUVWV6T5XVZxsnyhizj
dXlLlterqqpPPOecczSb1s83hF27du2Wz54VdO8hHjS7Nkt7gYCU3xbuvvvue6hg6d7Ur6lgwO/p
3Xffnfx9Aad3fftGEhh5ed99991PnCz9CgseutSfeOKJJ4m/ph/DMAyjUbr379//+smT33tZypB4
sRQrkt6OWd6RZZIs173wwoutvaHmd1k6iwTCBwwcOHAQFoiysjIsbmnDSJ433nhjXFsfrcWoQuIl
4ev15ptvjscRWwTlK+vXr9+AjxTBOQuhGw+rCV2KTBZ85plnnoVzP6MiWesphoKD91133XX3F77w
hc/TPavJBQ8CnoEM55133mfD4XAt92RLHfVqGEbTULFv3z7KucNGjz6xa5cuXeiCQyj4C4oodh27
9B4woP+qBQsWrGmlfs7A5/TA/4FRVq8Lspq7atWq1QSwxF8CZ+h2ApW+nu6N3NmzZ8/e+fPnz3vy
ySf/e9NNN/17RxuJ05AuVFAE+iTgJ8EInxOmT58+je+Wm5KIB7KK/gbNAaMBCV/1+c9//guEXCAK
NeKJ+0FPabNw39Md9+9///sm0R5HanKLg3AIn/zkJ8+WhqDDYAATToZhNMbWlStXDhk5cuShxxxz
TCJx5C+0IBEFnMPSVRpqJe3atX/3xRdfrG2l5UxUBMVDl41G1j6I4I84i1P44v9EdG8CWhJUkQpW
hOUCugL0UiMFVMZ04TFUHSvPCQIhDbp169bs3Ztz586d98wzzzz9gsD0Ha0ttES6cL+fL1x99dXX
DNUhuC0dGkF/+tOf/vi4YAMBDMNojE9edNFFP7/tttt79OjRnX1fGPkiiXXsAqRvk3LmF+eee86i
mTNnRlLbOLHWJiN36ApFPF0pTJw4cdK2bdu2u80MPljTp0+f8TuBuWj0rbYZ8M37vSDtgYJ16M+W
NWvWrMV/UT+qYRhGQvpK2f/n5557frKUG+/K8p4sU2SZKstHukzXZYYsM3WZK8tPrrrqqtbq24SF
zWhGCDpJ3Cuirb8sLF68eNGBAwfKseox/Yue1qTQZcg0M2ecccaZxxxzzLF79+7dw9QsbaFbBz+g
nwuiYX+XazgBHc22X9oZ3nQ+mtysYM3s3r17j0mCWYcNw0hG+b59+8JS5p987rmf6dS+fTsUkN8t
52+zjl9I796rV+8JY8eO3bdnzx7ZbVUUREFuRCDOFX5lzwvjx49/g7n9CFLes2dPIpFn5JgfBFT0
BPLE3+lIYYXQmuc6o2X0hz/84Y+/+93v/p9o1oxHFFZVVVUvWrRo8VPCXcKTTz75hGRZzMS98vMF
7kAu5Vktr8lrZGIBZuogutffFaRR2KxzRRqGUZhQNmyVBv1BQ4cecsxxxx1HQUEhlkg4xaax9JSK
a9O6detmT506VXYNo+noJJwr3HLLLbfib8SN3FzQdfiXv/zlWqaV0bfXqsDChJVPP27aEIaAya7/
R2BaFfIiivy999573+7du/foaYHAb0AssFtvvfW2HwtEI/+VQFRwApjqaSmZM2fOXPwVvQ9uGIaR
hFOk/nl37959s6XcYJkjy3xZFsiyUJZFsiyWheB1/rJalifffXdy34EDB2o2htH0IFaIqzR27NiX
CWIo92WzwKTAPxQQBvrWWjxfEnZlGKeMWFcTJkyY+DWBbj3NyhkpvPXWW4QvCQR+65dffvmVXwpM
BoyTur5UFCxNnxQ4DwuUXpoUnMKJaK6XG4ZhJKS9NNzvGDfuDUQSrXbWvkhaqstyWVbELCtlWSyN
yS9+61vf0mwMo/noIFB5PvDAA/9pLmdl/HWweCAYWnpwTLoelyxZwrOfNvPmzZv/HYFJnDUbDyYo
nDlz5iw9LScQZS+88MKLZwtYHPUlGmWQcOedd96VSjjx+/1B0MsMwzCScrY0KufifiBlB4JpmSy+
OFolC5alNTEL+xtkuVfKr27N5JtrGA3AB4dwBVcLCxYsWFhbWxuW+7RJ2bdv3366hhBx+rZaFAQb
ffbZZ5/Tj5MSxAhiFT8lzSLKWUKm4isZWPO+KWQjSI8Tli1bRuOvUejy1UsMwzCS0lWEz3/Gj38T
gUTB4oslBNI6WRBILBt18bfnbtu2/TNf/vKXqas0K8NofnASP0a49tprr5s/f/4CLBRyvzYpVNLM
P4gVTN9Wi+B/hXSnRuF7/fvf//4PYmrp5VGY5w0/IT01a3zrEnG8NOuMQQjShatZJoVpfvQSwzCM
RvnmJZdcskIa5rFiiW4OxNEmWTbLglMlC9sspN/91FNPdTdrk1GIEF+ImE9/FKZMmTI1Hd+WIMFP
hujZwwR9SwUNYRWYf0/ffqNgUbvhhhv+Ft8dB3zndFXqqVmzadOmzddff/1fDxY066xARNNFp9km
5Y477rhTLzEMw2iUwVKuT165chVCicUXSltl2SYLAQZ36MI+6aRNk2vOPf/88zUbwyg8cAo+WaD7
ZcuWLdy7TQpD7o8Q9O0UJHxHBHkkkKe+7Ua5//77H/BHxsWCQHzxxReZCDwnCFXAKLgg/MMQTYyu
06yTgtVMLzEMw0jJjQ8++KDfBYdFCXGESNqlCyNpWNjeKQvHNlZWVt149933FBcVEZXAMAoX5rf7
uPDQQw893NSRxnGGRpQUancdDtPjx49/U99uoyCKEo0UxJ+MUAO5+JJx7bhx495gUuCg+v2ZJzKV
nxbdgL8W9BLDMIyUnHLGGWcs27t3n29d8oUScVX2ybJfl72axnHWUxcuXHT08ccfr9kYRmFDZcxw
9KeffvqZoOMGNQavdfPNN99yqKBvpSBg/sTvCelYmWbMmDFzhKCXRjlaoAtUT8sKXv/222+/g25C
zTYQCE2Ryr+KrsDPCnqJYRhGSmgE30c9ImUIgghxhFgiuB2OoRSoLGwTE4djnLO1urrm51dccQUW
fs3KMAofum3OEx577LHHM41JlAuzZ8+e822hUB4YRMrbb7/9jr69pGzcuHHTpwW9LApR0t977733
9bSsIPbSL4TY+E5Bwai7VM7tjM5jQm69xDAMIy0+/5WvfGWzlC++ZQnBVC1LjSw40bKwzWgkjvnC
6SERW51aeHgao41C982nBEIFpDtyLFdwpMZJvBACYhL3CEGkby0hfC/4BcXPGcfIOUa36WlZsX37
9h2ISM0yUEYJr7322uv6Ugkh6vk///nPf8V/NsMwjFT06tOnz4QPP/yIioMFwZTIPwHxhNUJ4YS4
elzKzS5du3bVbAyj5YGV4+tCrlaTTMDCM0bQt9AsXC6kEovvv//+lOPj+uD5vu655557c/FhWrVq
1Wqij2uWgcLIPqZuSTVqcvHixUtOEvQywzCMjPiq1BsLV65cVZmiLKQgwuK0affuPZf88pe/DLWC
eE3Wv2h4E7gyPcuPfvSjHw8dOvQQTc4ba9euXXf11Vdf9aTAJMWa3CTgz8Sowp/85Cc/1qQG4Gv0
D+HPf/7zNSJAakjDL4zuNEaclZSUZGWhQTBdfPHFP31d0KTAwHp4pcBkwx06dEja5RcOh93bBDnv
ShGO5ZpsGIaRNpSHx48ePfoTZ5111oD+/Qcw6AiRpIejkLBHmDZjxvS3xo8ff2D//v2RI4bRwqGr
5jMCI8qymbQ2UxjNJxX37xLFPconjJp77rnnnte3kZD169dv+KKgl3icJuQyZc3q1avXfFfIh18X
eX5LQIzqyyWFyPGJ/LQMwzAyBbFEQ7Qx8KXNR7lntHCo/JnE9lKB0WIt9SZh5BXBMVeuXLkq38Ex
9+7du48gjLymvnzeYZ65VJPpMhVKbNccfkzpRNhOBiLsJ0K+fIjOF5YvX85UUI1CmIHfCoUaBsIw
DMNoA2Cq/POf//wXImLv2LFjJ9GhzxT0cIuDaTjOFR5//PEn8m11oiuMmEJYcvTl88qJwuTJk9/T
l08IPj+EFNBLHKaHydZhfpfwG4HvVLMLFKyDvF99uUbhvhws6KWGYRiG0fQw79uGDRsIkhqFudi+
IugpLZIuAtYz5rPTj5U3sJQwDxwCVF8+L4wWUk2dQjfc5wXOJ7I6vkh6KCMQTFcI+bDs4MN0mZBq
FKAPcZkYNaiXG4ZhGEbTQ5/uzwStm+qBBYCZ7/XUFsthAqOysrW2pAsi4/+EfHYf0XX66quvvqYv
mZCqqqrqG2+88d/EYyJatyZnBHnQFYa40ZcODMIKYAVMd2JmzrtYyLcgNQzDMIxG6SM05u8yYcKE
ibFdPS0VfLQuElasWLFSP1pewI+KKNlYufSlA4V8mVZGXy4pCF7EVXV1NXHaMgZfraDFH75V+Ebh
c6UvkxY3CCaYDMMwjGaH+cdw9NX6qQG08pnsNR8Wh+YAKwcRxfPpJE4cJKZ8GSLoywbKVUK2Yigd
8JkKMto2wpx54oinhQVLXyYl/Eail/7WWu49wzAMowXDUEqchIl2rfVUQvB3Ol3Qy1o8XYWfC0zF
oR8xLzDK7XBBXzYw8DXDaV9fJlDwHQpqEADdovgtTRPS7YrzoSv1mmuu+bMJJsMwDKMg6Cw88MAD
/0nH6sIUIsSs0EtbBVjZsDqlM/FttkycOHFS0N2b+CrNnDlzlr5EYGC9QuTkEm6Ce4SRhNddd931
2YpSJkpGMBHBXLM1DMMwjOaFiV/TnemeoIJNGY+oqUCA/OMf//gnFhb9qIHz0ksvjUWgBeWXg18T
IjaX6VDiQTjj25ZJsE4+D1a7gQKj9Ah+iS/UtGnTpmfbfUhXMb8H/k/6MoZhGIbR/BAAkdACWl81
CvGOmOdNL21VEIPoEiE+7EKQIJxOEfQlcwJL0BlCkO93z549e4nKjSDrJGDl8cEhHKtkTwGBxAg+
ROBXhWuvvfY6RugRTBQLkWaXFYRGoNsUIaYf1TAMwwgQC22eA18QGIrfr1+/tGbuv/vuu+/56U9/
+hPdbXV8WcDKMXz48GGaFCg4Qv/pT3/64zuCJmUNIuaqq666+vLLL/+NJuUElqGpgoiflcy1VCEg
ZDhGJHB8ixAzWKJwEj9YYJ9jRUVFOT+HjPZjrrynhBpBkw3DMAyjMPixkMoJPBYcehkJpZe3So4V
Uk1TkgsE2vycoC+XE4zOmz59+gzNusXCaD2CdurHMgzDMPJEXubCaisQNfpTQrqOvx06dOj49ttv
v7Va0KRWx2Zh3Lhxr/fo0aMn87yVlJQEeo8hOseMGXPavHnz5mLV0eSs2C2sW7du7fnnn//Fdu3a
tTgnfaxb9ws/+9nPLl0iaLJhGEazMEJgIMsRwsgkELamn7BVkDKsWi81Wjv4qRCEURv7aUNwQs2i
VcP3c6Wwbdu27frRA4VpVz4h6MtlDYL3jjvuuFOzbTGsXbt2HfcSYS/0oxiGYTQb+InOnTt3nhZR
jYIPKO4q3QW93Gjt4JPyzDPPPKv3QNpQQePHotm0avic+Dml+yBlCsKJUWf6cllzqpCrE3ZTQfyl
559//oXjBH37hmEYzQoDXgjirMVU2lA/aBYtBptaIUvoJgLdTRtiDuGErLutmlpBKvjnv/vd737n
ueeee16TA2PYsGFDccRnwmRNyor1wlpBdwsSuuI++OCDD+mK+853vvPtWYIeMgzDaFaYg5VR1Lqb
Nscff/wJbcWI0OY5R5g3b958FcxpQxwdhp1rNm2GQwSEk34NgfLKK6+8mst3yrX5CHYZFIQj+H8C
0dGDilVlGIYRFLg5MHJai6y0YeJxrFSajdGa+YawcePGTfrbpw3xmk4UNJs2BU6AdC3pVxEYTDFy
00033ZxJYMlYBgtz5syZq9kVDPgt3XXXXXczLUtbsU4ahtEy+ZmgRVfaMKKc2HaahdGauVjIZvoQ
ullaa5DLdDhKmDBhwkT9OgIDn6TfCNmIC0IP5MvvKhu4r1577bXXEeZWoBiG0RI4X9i/f/8BLcbS
Ys2aNWvphdAsjNbMnwT93TPmD4Jm0yZhWKoIpwn6dQQGk/AygXKmI8qwUE2dOvUDzaZZYBoWIpQ/
9dRTT+McmY2/nGEYRnNBgxj3Ey3S0mL79u07zhU0C6O1Qv8t3UH6u2fMPffcc69m1WZhKpHx48e/
qV9JYDCVyPcFfiN9qZTgJ5TuSEgecroDdTdn1q1btx6r0u8FAlSaU6RhGC0RrPwEH9aiLS0YDcwk
55qF0VrBce3hhx9+RH/3jEEsaFZtGib7zYfFCV+gTGM4/VrA2qNZJAR/tG8LBDVleC1dersEHvxU
10JVVVX13r1793EdoSe+JhAAlClW9G0YhmG0WGgAanGXNsQ71MtbBDb3XBZ0Ex577LHHP/e5z31W
kzKCkVonn3zySTZHmOMQOVYExB0f//jHzwhiDjafWbNmzf7f//3fb80TNKlR6Fd/++233xk6dGjS
/nUmDb7gggu+wu9GFyBDbOnaGyYQt2vw4MFDyIeAbQzBJdqtCK0DRL7dsGHD+uWCtMTmy/YG0u33
b5kg9r8idO3atRuR6ScLhK3Qw4bRZrnxxhv//atf/eqXupsWjH7++te/fuE+QZOM1gYh4KdMmTJV
hXLGLFiwYCEz3mt2bR76wrMJFJqKV1999bUegr5MSi4Xamtrw3p5PbAojRH0VKONglB+4403xutt
4Q3soCxoiUH6DCNomKVAH420wZ+UqVU0C6M1whD1RYsWLdbfPGOIZI1lQrMzBMIR0OLQrygQampq
anG6T9dPCOdrQiJwnWbhQcV4ww03/A3rkZ5qtFHwO2MKCL01ouAAi19aV0FPNYw2xycFfSTSZunS
pcs+LWgWRmuE0V/ZxGjyYZgleWh2hsJ0JrNnz56jX1MgMKKOB1lfIiUnCC+//PIr+ClhdcIHiQBs
Bwl6itGG+R8hWagRhltfe+2115lwMtoquFtQZuojkRaU0d8VNAujNUJwylwmomVoea5Tf7RWLhCC
nuSXuFADBH2JlOCz8mPhagHH71yijRutix8IOPTrrdUABBWRkfF71EsMo81A4zLTQMFY8rHSahZG
awTLxY4dO3bqb54xW7Zs2XqSoNkZMZQJ11xzzZ/1qwoEKjlGx+lLpI2F9zfiQUSnCjmBxYlpb7iX
9TLDaBPgQzp27NiX9VFIm3//+983ZRpfr7mweayyAGfQXOYAwzfGCtTEiMBhSpR/v/DCCy9qUs7I
s1hy+eWX//YsQZPSokLQTcPwYORjqlGPUjx0+MMf/vDHSwQLJ2G0JfYKjBLW3bRhcFVLsc6aaMqC
XB2Cub6DoLtGHDsFhq0SmkGTcmbAgAH9//KXv1zLlCmaZBgZs0jYL+huUjp16tSR++2bQq7lhZE9
WC+okK28bRpoUKxcuXKF7qZNX4FQLbpb0JhoygK6bXKxNPEgWwu0cVYJv//97//fbkGTcub0008/
7TuCFaBGtmwRZgm62yidO3fu9Oc///kv3xJMODU9DGO/5ZZbbv3oo4+m/fWvf70hlzLbSJ9NQjgc
dnU3LRi53FIGUNiUDVlAXKEvfOEL54vuybaLLfS8sFDQfSMB0mJZicDUwJeBFHiHH374yLXCXEGT
2jR8r76Ip9t5kHCYQIVztMCgB+JTyW/wcUY3xvIx4XiB85gWh2sxsSMQpNAM+78Z296LtQJqhf5C
ukOkpR7oMnr06BOXKJrcouF3BUJ5AL93MtRlJaMKNFd4XYQq01XJz/QpuSW7YmF+8MEH/1Mu6GlG
nsBqdMEFF3w1k/oRkfWyQGNZkwoWiwieBVgrbr311tu6dOmS8Yz6wA3yjW984+tPCZpkJIEgoP/9
73+f+tSnPnW2JuXMO++8M+H73//+91rCAxokCBoimOOsScFGrLBDDjlkKKMFiT1GNwZWVESU1DvF
oVAoWjFSSWo29UAQAWKCFSNhZKmmixVxivBdvXr1qmUC3zeWGlqi+K5pFi0OfONefPHFlxBEmpSS
d999d7IUG9/m+9CkgqOTgiUWAc29wDb3Dc8h3Scgn7sb5yG0Oc4aocI9wpyP3AusuQ/kt189ffr0
aR8J3AP6UnmD98XI1yuvvPJ3cjv31WSHOSNF45/SFO+hrUMjiulUpA2V9qjjffv27f/BD37w/ZZQ
J5poyoIfCXj7y/PZUZMy5iLhCUF3jUagknrggQf+09gUJ5nA6KabhKuvvvqqVE69mUBFAYgITWo2
eB+II6yixwqHHXbYCEQSQ4IRR1SCvhhS8lIWRLRURFQRVZ1KlIprzpw5s6cIUqFOZ0oZPb1FgNVC
Ht0nx4wZc6ompQQxeY+Arx5iQpObFH5vukD4/ekOwWJGKA5W7COOVBR1ZRsBwiSsCCPvxo6B/HSz
3n3DfSQ/d9SyxG9fKfCbvyDcfvvttzGtkB4OlN7CZZdd9ouLBdnspckehDFBNGXjpGxkBvcUU1KN
HDnycE1KCaE6cMe4RaCs0OSCxERTFvxCuP766//KKBlNyhhiuYgaf5WCicKM1j1rPVwPClnMyhQ+
zM+DIyoVDa110hjlBUEKgEKC7+a66667ntFIuQjVWBYvXrwEa9P7giblBBHNv/vd734PMfLhhx9+
8IawRtDDeYfKi9fGgkT3Gl1CCCYRmkOZD0/0U0E5WXIL012Ff9AHH3wwFbBEBenDli8Y+YqvzE9+
8pMfa1JaEDX8m9/85kUTBU0KHKxDlCkInS4CMcawIipDuD9EF3WTQ10RSAjrpro3KLpoKN5www1/
XSFociDwWa644oorfyiI/uutyVE2b968RdoOx8h6syYZeYJ7D8uqlEEnaFJKpCqr9huy1oXaCiEG
i/yuFdpdnxVMxcCDTAuIBfMxsZ8SLRwjauqmTZs2ExhT6pbV06ZNm860I3fdddfdBAbDcnWmQNBM
KkoqUMwH+pZbPESaZS45/fpyRvRlbVCza1NgP/LII49iVSFvIuLSfdNUAUypDb8g3HHHHXfOmzdv
PvcTlg3vg7YA+L4IiCeV6Q3HCXT16EcrWH4mSLulXD9C2tD9EGS4EbrGsB5yr50rXCZwH4wfP/5N
pqegjBEdukfaVo3GlmoqeEbkK3gaq5B+hJyhsSmC6QpEmb5MA5YtW7YcMamXGHkGS5N+9WlBefXQ
Qw89jIVTszBaOlg7qJzOEJjtvtAqJUQAhbg04FZOmjTpXSpxUe3XEIyP7i0cdWmF6sdpkTApKlPQ
6EfOGSqVwwXNPiuw8PyvkGg+MgqOkwU9NVB4XSxJWD3ffPPNt7Dc6Mu2aJjTkUYJvlf6UbOCipRu
pnwJsNOFtWvXrtO3nTYIxFzm2eJz4XBPOUR08n/+85//ev3118etXLlyFcLIF+6Fzs0333wLFgn9
WDnxDYGAwZp1QngWEZh6SeDQjYlvIKIfAUsXLnWGHm5z0H2tX31acN+OGzfuDSyhmoXRUsCkTV8/
N/6XhCuF++67736m4pg/f/6CoKf4aAqwVNEdhckUy9TPBQpdPmdQBVdTQAXI3F76sQKBCX01+6yg
cmeuOs2uAcyIT/eYnp4zVJrMj8fviMVRX6bVgejPxiKDbw4NBaywH3744Uc4pDKvFY7Nekog0CKe
PHnye/p2M4L7hXJGs0oL+tCwJmOpmjt37jwsz5pdi4RGHs8e4l8/YlacJ2CB12yTQndqkJZ3yiJE
EvcazyIz9SP4V69evYbnkroCEfAbgcZNa7L6p8Pf//73f/Ab69efFnxnNAg0C6NQofVB64ACiZns
MWtjzaD7LdMfvSVBHzKf89lnn33ud8JnhJYwxxoF1QcffPChfoycwWyf7YNKgc9QfBycNbsG8D0z
hUuurU4K3VMEul5aonDPlIqKispMxSbWn7feeuvtRM8tLd8gxSu/B352jXUJJYN74quCZpUS7k+6
e7lOs2gVcB+fLejHzBgaD1jYNLtGwRqsl+UMDU7CGeCjlo5lj3L2b3/729/pqdAsWj2/FHiG9StI
CybBxx9TszAKBUQSFpbPChR6WAJac4s9XejemTFjxswHH3zwoe8JdOcVohWKyooCMMjuUayJmn1G
YJIn6nMqcT1lypSpuXQD4qNGiAv82TTLVg8VKlYj/QpSQjwp7l+9PCG0/EcIeknOMAdltr8JljDK
Is2qUSir9LJWByIXnyz9qGlD+ZRu4wlL1JGCXpo1+FXefffd92C516wzYuLEiZMYyarZtWq+LmTq
MsD3Suw3zcJoTjDNM0Euo6+Y08y3JOlvZcSBIMFHAD8AWgy06Aqpfx4n9wmCvt2cwSycTSsQCwD+
Y5pNUvA1oRDRyzLi88Ls2bPnaFZthieffPK/dEXq19AoOOLj16OXNgqWZEY66qU5we//3nvvva9Z
ZwTlD47bmlWjjBFw6NZLWxXZdNPx/D/zzDPPahYpefjhhx+hsaWXZwy+cTjYZ+PDFs80AWu5Zt1q
wY82kZ9nYzBggQEtmoXR1CCU6Haj0qcADtKBuK2Bczlxkr4oUIDoV9ysEPE3U/NvY+BQrVmnDaMV
07FSYsLHYTeTgptzCdLXlqxLPnR3niPoV5GS7wuZ3As0nCgb9PKswdKI5UGzzRisuukIQ565TB1r
WxLMMYmlUD9uo2D9ZoBLul2VjD7GIqiXZwyWIX6nIEcfPvroo49RP+lLtEqYUaAxt4VE0NVNL4dm
YTQFFGKYbYnY/fzzz79AZW8WpeCgy4QKhwqtuR96KhLM3frWcoYQDpkOg0ZopWuqJ1xCuqMXqUgZ
0p5podMaoDK88cYb/42jrX4djYKVCSdcvTwtsG7QPcYIJ80ma7gHsvV9zGT0Js9ca7U4Ytkmbl2q
54PnAl+wTJzgH3/88SfSvZdioXyjkRik/6QPAoyRwPpSrRKssJla5qirLxc0CyOfEOmYUQy0+qZP
nz6Dyj3bgsxoHL5XnC+JiI4TdCbWk6BhyLW+rZyhsqaQ1KxTQncC91u6Fg4qPAoSvbxRzhfWrVu3
Xi9tM9AlTOWZyYCEC4Vs4iXxu9122223I741q6xg9BaNM802IxALjOzTrBoF/6dfCZl2ebQUcAVg
xLJ+3IRgMcpEIEubYzcDXPTytGFELBMtI2rzFcIBX9rWHJOIqPOM8tSPmxaUwXzvmoWRD+jbxpz3
9NNPP5Otc56RPTg445TdXEHjGA3F6Dd9OzlDaIl0fSsYDo9/jF6aEoYiM7pLL08KTt/ZDmVvqeAw
io8aoTCwHOlXkRIE+2OPPfa4ZpMxWPJobGl2WYF/VC5BV+n2Tvcz43dHKAa9tFUxa9as2Y11oxG/
h5G+mQwAySa0A0KGAUIILs0mLyD0Edz6sq0O6mZC3OjHTQsE6k033XSzZmEECS3RSwUqF+t+a15w
3mNkVxBdHZlCpclDpm8lZ5YsWbKU7l3NvlGowPDF0EtTgm9FKqsCQox4MnpJq4EWJP4KfAf4aC1f
vnwF3aH46RDJ+VMC81Xp15A2CJZcR75iTchlxA4WICKZa3YZw/snjo9ml5JhAq4HenmrYezYsS83
NrLxt0ImfmuEA8Biq5enBRYmrMfp+kvlCoI53UZaSwOxShBo/ahpQ1RwzcIIAiwLTMaYqQ+DkX8w
r2dS+AfFaQIWA30bOUFhma7lgddNN0YM4MdwlaCXJ4SRcoUSg4kWPRYgvltGbtFdSDcUAfyY8oRu
8Pfff38KfmVEJMfaQmVO8EWsIffee+99+CZdLdCthGBkZAyjWAn5gZ9JrhUGE2fr280JWsSZhDeI
h4jURPrW7DKC7m4+RybfBd28NFSyfc1Cgh4C7hu6+vXjNQALbSbPGt/pnXfeeVcmvkx0JzFgSLNo
Egg43FrjEuGf9qCgHzVt8JmlMazZGNmCaZZYOpn2kRpNC/OejRb0Z2sScA7FP0DfQs5Q2afj54JD
aqZdwvfff/8DenkD8MtjTi49NVAQbFh6EECIH3/eQgq1f/3rXzf+UaAxQlgEYgIxXBhRyBQwhJs4
WmA0DAU8DRfi6iB8aJnTtUT3LBaXpizssMrRLa8fMWcQIZlUsrEQvwcxqVllDBa3TEN6cN/TncXQ
e37XTLqtmhLeF11RWKQR34htxMn/CThD48fUWPc+x7hXNbu0wHrIfatZpAQRSuR4vbzJQNxdIOjb
aFVQFuD3qh81bSjLc/UzbNNQkFCQ06I1x+6WAYViU1ucfiroy+cMwhyBoFknBf+bTIch0wWRrGLG
WkHlp6dmDc7UWIII5IgAJNo7z9CpAoLHFzm0BHm+qHz1LbQoEG7pTJ+RLnT9ZOvfhIDLxL8tHrp5
6WrU7DKC16ZbC99OZjRgeDzlZb7dFhBCNBoI5bJw4cJFCHGsjtzjxEUixAYNXUYyM9cewSWx5nHv
8Z717aeEZzuTz4J1lAj86b4G3exEW9fLmxymG+E70bfTqrjmmmv+rB8zbYh7h4jVLIxMoO+eUPXZ
jIwxmhecejMZAZUrtPSDGm1G5ZmOg+b111//V70kbegGShTWgOHNjOTS0zKC0VQUNPjVII4IhMiz
g+VHs2+V0EIP2rpC92O2EZsZ9afZZAxD6BkJqlnlDFN2ZOL/Azjh0l2E6wNRuukmoZuVaXsQY0yi
jB/pNwUsRIRAoEsNiw5hE6joEOS5drnGgqDBeq1vMS1oLCCoNYtG4T3nyz8s3QYV33U2/nwtAebd
04+ZNswX2Zhvm5EEHsZ0Ii23ZGil0U9PZY/Zmn1M2IjEQjW1ZwKioqlaUMTnymUEUzy0kBuzwCBI
cOLU09MGCxCCRrOJQqGZ6UgTLEr4DNGVRuVSiFPe5JMgBwDEgpUk0xFXgJjIdng61yGaM7HAJAOn
diw/mnXa0KV1vMCADsQEPj50v9JVwr2VbddlttC9g5VU315a4PyNxVazaBQse3RPBx1SgLIcCy+j
iunypUzXQwnBUpeOZTtdEK2UT80N5SczcOjHTBtEMlZxrOCaVbPDvahfr0dBee7z5hhJQ6vtmGOO
OVqTm5RwuP4zJAKmer+wT9gr8GNqSz6nAu4///nPg88888zTVPgUSkCXCVYHAb3RiQJLym9O6cI2
LTlgWCzn8oDIvVmQTnMUHtJ4/v4rgiblFVrXV1xxxW91NycYwfHTn/70J9Jar9CkelCZ0A3y+c9/
/nOalBaEHfjiF794/mxBkzyorPBNkdtqqCalBP+oiy+++Kfcn5rUZuCBwM/ljDPOSOo8nC00Wvhe
HxOkQq3V5JQQYZyGHs+rJmUE08D87Gc/u3S5oEkZQ8EhOuP//V7ItFzAqs89T5mnSc0Ko1gJGXD4
4YenbXXgM/z2t7+9fI+gSQmhkUJYAYSNaMHAuqd5viXfa6X8eKhKYNg9o/GwzCX7PbAUy+EvvS1o
UlZQd+JPisFBqogemtxsSLFUJW/nRMo7TUoLjAfyu49lVCmfSZObFSkT9r8vvCdkUibkHQQAJt8F
CxYs9NRKHpEftIZ+cnWO3Y1ZnvAFmKR58OjqYIgrJnNueOaI4makcsPvIQhfij8J+tGTgkBDINHV
hcmSqQZw/uQ9YSpHXBK7BJ8IRl3xeTDLF4r/F9F4qeD04+QVCsBMHbOTgW8GIlWzbgCWnWzmHGPI
faJYTfyetJL1tLTAMkVXHBUl94lm1SbAMT2T0VSZgl8ejvD6cmmBYzyxhjSLjGH+w2wCMcaC4342
ViZgYINmUxAwH52+tbTAYpPOIJQews0333xL0EFCeX4vEuIregZYMHu/ntYAfLC4Tk/PGsoCJqwO
2nJmRKDruuDiaiEIsp38sjEQEAgjQrpj+iN2BP30tMYwqVM4Yh7FSZYWCK0DrDzJzNHcnEH4z+Ak
p1lmBSITQUJhTauMAhPTNMPasZTgm0DFQkXdXCKK1gLzs+lbzisUmLmMYIqFCozvVLNuAD4c2bwW
fg40DDSbKMydRnebnpYWFI6IfUaQMfUAFS6+XYzC457AIolpWV+iVfE5gcpGv4rAoVH1yCOPPIpF
UV8yJZQZXKNZZAxlFA0hzS4rePZ575pl2lCeZeuIng8YLY1w1beXFozUStV4oMuIuGB813pZIEh+
u5jjNNHrE0akMSFLmcCgEj09awixoFkaeYJAuqnusSYDKwrOw/recgJrC5UeozjwE8AZjZY8ozco
2HI1+WHpaazlkC7XXnvtdZpl4CCo+HERggwhp1K966677maEDy2yphJRxD2iMAvCVyMV+KEE5QeH
wG6sVYHFMZP5r2JJNMT4J0K2+fnwm9J6JnAk82XROKBrgGkJmMcOJ3FGMfHeuS8as6QVMtzbVFD6
sfMGMZCozDIpJLNxfI2F8A98Ps0uIwgHke3cdJSVdPdrVs0OFv5MRqZyz9Ng0MuT8kOBRqReFggI
MJ6xZGUcPRRYgPT0BtD4IZZZtr870MAnTp5maeQJRohiVNGvvfmg+4lupmwqcgQSIoDgevRRY2LG
4sJUFAgkfYlAwVKAv46+haxhqGkuD0o2YH3Aqna2cJlAyxiLSdAtr1joRqKS1reQV/g82bS046Fg
xaye7PfBOplt1GAKbs0mCub5IIfPJ4LvhRZxzBDxaYzcofWEPxhChOcHR+LhAtYqCgismcmsrs0B
93A2TvjZgKWUZ0VfOiUE78z2vgACg/J9a3YZwcCLbF6b+wKHa82m2cG6l6kAoNdAL08KjcdcGybx
kB/hDRqra04ReNb0koQwOjGXugDBZqIp/2D9zMT6nDdoATO1gr6vlFCh4X/EyISvCYzyyNV6lAlB
iSZalU35vpOBhYbPRMWJ+Azis8VCF2EmFU8uUHEEESUZMY5PRbLfB5N7tr4DiSoohH6u04EEBY0X
ur7o+qO7HJ85fEDw8UPcca8wzLy5RuzRbZpp100uYLHDD0ZfvlEI1JhL1z3lGiPXNLu0QeTic6HZ
ZARlL7+nZtWsIBwQN5k0fPAjSxXNne70bL+fZOA7ShgGuvz0ZRLC6NZUvm5Y4000FT4FIZpwYk3H
DwB/D3w36MaggqG1qVk0ObQmg2ix5BKBOF9QCRKnBvM43zdWiVwdC+kywndMXyKv0OoLwhmcFjut
v2QFIoMB9NSMYZCBZhOFQh/BqqcUNHSb0D2NfyDdoQQGpNDHL4spUrBO5dOXaowQtBNvY1A54iuk
L98oWM1TWRUagwYGlmDNLm0Q4ppFxmBtxFLCs8+aRhSfA6HIb0lcMeIexYLPEccoh+nm9a/PpCsz
EVjZGEWqby0llE0Ieb08IYjQCRMmTNRLAgOfVD63vkxSviik8n8kfEYuDWgTTU1DQYgmum0aa2Fz
jK47CuN0btCmgIcgiIr59ttvv4ObXbMtOBC0FOD0tzORrb7tjKHViNjVbPMKc3hl6lCdCApj/OGS
iSZ8XfTUjOF3j29V8jp0j7XkQK68d6zAtOgfffTRx4jkzHMbZCFDpUyE6SC6YDMBHzcGqujbaJRc
pnah+5RGoWaVFogYfDg1i4whdhjxihC/CPcpU6ZMRRBT9mI1wxJF13HsgmjmO1m2bNlyRu7ij+qP
PMZvTt9axtCVlYmljkCIiYLF+uCnlQ8HaaaBSbfhnmr0HOTiywYmmpqGghBNjKxKFCKfSosgczxE
hWaNCUo03XffffcXkvNlMqjQsT5lOv+TD1abHwuaXV7B2hFU9yIjLJPde/hQ6GkZg6BIlC+VH6+p
p7VoeH753akAiT9E1y+VKUJcP25WYAHBAqgv06RQHvEs6FtJCvP46SUZQ7dwqpn5sfDQhUXXEIKA
CNi5iEh+K3/RpKzw88A6TbmtbzdtEA10Aafr28r9xXeglzeAZwzBEvQoS7pQGxtZGw++q6mc2nEr
MNFU+PiiqVl9aog/FF8Q8eA9IfzhD3/4/QeCPEQ1eqggyOXmjgVzLOhuwSK/Ry3BGP/v//7vTxSI
mpw2UhHs2y3obl4J6rfhHiSwJQ+KJtWjU6dOWceeogsi0e8uAmPdLbfccjM+NFSCmtwikY/H6M2S
QYMGDTz33HPP+ZVA9HKsxp8Q+A701IygkZHt0HisYES9JnitJmUEYQ7S8c2bL3D/6G5GcF8Q9kR3
64Flg5AidwnM6ya6/Q8EozznnHM+LfogpZhLBr+Vv2hSVvh5DB48+KDzzjvvs5qcNkQiP/nkk0+R
6iCtMnG90FjgXO6zyy677Bei0wPzuyMWkzQAblgmaFKj0GWJv5lomqTdltwrxE5OVtYYhUezVtoU
nvEV3Ubhr3/96/VrBU0qKOgiSKfFmQpaB0Hk01SIyp6OGV5302abQAGnu3mFQkoK7pzvaREt1UR/
T1aQ4fehmxmDD0iy332RcPnll//mWQELrCa3eBBQRx111JGXXHLJxcQQY+RmNmZuyotMWvmxvCX8
9re/vXyVoEkZ0bNnzx6XXnrpz1L5ay0UuH90NyO4d5N1N31FkHLxhv8RRo0aNVK+ioJwV0gE4Q90
M20IhZFJ1yTl0TxBd+tBrLJf//rXvxF9fbgm5QwWsFtvvfWWlwVNSglCENGkuwnBKLBT0N2sMMHV
NPjfc7OKpkSt+eXLly9rqko2G6jwgrBoBFG5NzXZVAa0yqSFv1R38wqCBFGru1lDQbZv3769utsA
Km/dzBh5i10a++35ri6++OKfIp6In4NPSbaWi0IESwRxbXB+zTQUBQ7I6fqSxCN17DT5Psfee++9
92QrSLFeYHHS3YTQ6Nsu6G5GUK7I/dFNd6Pgz0nwUtECQzSpoMm0bEOInnDCCaNFYwzWpEbBWjhu
3LjXZd3gucCB/Ve/+tWvgw6q+/zzzz93pxBfXzUGVtFUokme7dpcRRN5HBB018gT9JpUCs1acWNN
ir/xpUXXCyuM7rZaWpqliYefeQF1Ny0Y/i/11EtbBU3KK/369eufyhKQDlUCXYqJCkgqNixaupsx
3bp1657qd8ch+A7hwgsv/Jp85Wd/4xvf+Dp+D4wsIlAeTrg4vDOKDH8JvazFIB+/6Fvf+tY3cUDO
xHJEd362foArBcqa+4R3BdnMuHXepUuXzldeeeXv8CvSpAaIICufK+huRiA2qPS5xzTJA+vToEGD
0pq5vyWCZegMQXdTskaYLOhuFPyYGChALDR5RAPzF8XZnRGK8rw1OqddLDzj8ux+OpVFUNqhVbn2
qnBfv/TSSy8yGEOTjDzw2muvvbpfCMQHJFtoaTLPV2wLQ26imgsuuOArtAo1qaAgrhSRlrEYaFJW
SMvlhR/84Affz7WV0RQQC4sJaqWxe64mpQXOifJ1XdgUoomKhu+UkBBS9+R0X2PdufTSSy95QdCk
KLwOI4XOP//8L2hSRiB2mIQUYaRJGUHFQMWKgACCUBKyQOpVlt50HWKZYA1YxbDAsS16o+CmVWHU
FhPV4tOlSUmhW48uqkz9VBCWTK78psA+Tuk4UUs7YJh3QgYgtvDvExF7fTJRje/Wb37zm19rUtpg
UXxa+OY3v3kRFaEme6KCeRzHjBlzqiYVNExY+11Bd1PC78GoQ2lQpBWlnu+IQR+0/DXJQxoZFxL+
YsCAAf01KWcYzIDvWGP+U4mgbiOcQ6pJuPETpWGUqzWeRjijh7/4xS9+iWddk5sNng15S6XSJjos
3d/Vh8E8NHJ0t9mhIYRl8xaBbU1uHmixUxnzBcdCUL1sfB6aAkTT7t279+hbzRpGoxXqZ4yFyVEZ
AaVvO20QBUxfo9nkHYQd04foy+cE0zIkGwGENeCNN94Yr6dmDN9LMmffoKCLkpFmxKhhiiL8RZjO
gS4eKhZGFTECkAqG0XxMr8PQc8QiDtNSF+1ndJK+5byDxYmuN337SWEUHsJCL0sbCmHCH2g2nkM5
UwvRQNNTMoKKtDHLCHHO9NSM4LMh/OMtkXRJIi71tIJH6pZb9a2nBfHV0v1duS85Xy+Nwj3Oc6un
BQLPQbbhUpiQHT8ozSoplCU0fPSynKFOpV6hAdWc0LDDtw2jiH7UtKE8wJpbCJ8DaITq11sY4NhI
IaTfVxQevFyHKOeDoEQTQgQLgWZbcEgLadglArFY9C2nDYXF1VdffU22XSnZgL8JUaz1LeQEcWqI
eK1Z14MKjWHHemrGiGbanY2jbL7g89BK5VmjoGOORqJ+05InRtf999//APcq3YKMHspGtKSCygkr
ElYafVsN4D2mEwg3EQSNjJ+bjN+XKX70lIxB3FCoa3b1wHKSaph5MmhM8ZtoVlEoD/WUgieTycj5
XRM1nJPBPcj9qZd7YEll1KmeEgh0dWExzMZ/EQvwggULFmpWjcJIyIKrlAMCsZ9NA5PQK5pFQdLs
zshvC4m6QYjtwwSYQTj2FiJ8rkSFY3NDVwAtOboDGCaeTRcGpmy58W/H0V+T8g4WMaxNupsTjPjD
IqS7DchFDGKpCrKQ5L1QefP5qUyIvs6zw2/IVDD4YmBVYQb9/xWI8YMVjfkZsUZRadEVhCMpXcUM
mZeC7o0HBEQTXchf/vKXv4R/Fd1GoqW+xzyPzz333PMEsqQBgcVG305WyNfR8Rvf+MZFsdageGjx
UQjrbkZgUufz6a4H3YGPPvroI1SOmpQRfNdY7RLdC3RHbxF0NyPoftXNeixcuHCB3JZZOZg3JXRf
MphHd1PC/UcDTXdTwrMp+S/XXQ+6pbCi6m4gPCaIUL05vgswHXgGmbpFdxtl7ty5c+LvzdYC9Vtj
DaFkZHNNmwMrQaLI4BTIRBYOwrk3KIKyNBHaP9/dNJnAjUrBQ+ubikTfZsZghclmKohcQIgQLFTf
Qs5gXUlUGQKVmhR08/TUjMGqwoS/ml1W0PrlO/6ecO+99943bdq06VjZiDyM2MMBX3RCBV0ZWDxE
u1YS5I9jRHTGj4Io70RUZpJYLAPM48jcaQTZpOWuL5UQBD9CDSduBgdwPb97LtHYea+07JM1JAiw
mq1lCCtZIkFNGlPB6GkZgzXhVEGzi8JQ88Zmtm+MV1999bVE3wERyYnWracVJFghiSqOwNW3nRK+
KyaQ1ixSQtgTuqD0cm/eRgZG6OFAwPGbRoi+REYglhLVZYkgSDITZOulrQ7KCLr+9eOmDWWaZmEk
g0qA1iuFu35vUXggmLaiUIRTUKIJvy18TjTbZod+9WwLeh+6FvAt0CybDCo/BIC+jZwhMnGyyhvB
kMu0FQjScwXNLiPw+6E7m+kqqLDxP0r0zGQCViKEHAU4hT0iAtGIlQpLymghlWWM43R34cPG5L7Z
fj+IPxxoNdt6fFJINfFpMqZOnfpBMisVXdDZdjnSDY1Debw/Fo642VQWyXyagO5TKhM9tdlAfGPx
Qngzp9r06dNn0ABkYue//OUv12baYEI0rF69eo1m3yh831jAfUsE4p7X1cOBIO2K3Vhkkz3/jYGY
Y5CQZpUSfDCD9GcqNLIVTfg00RDWbIxk8LAla0lRmHMjF4JwCko08cBQ0Wi2zQ798Nn4LwGVLoUZ
/jCaXZPCJK4Udvp2cgLLTGOxeKi8MmkZx4MFKFMHeQoQuoMQS1RWmlXewSqFiMKfholRsQroW0oK
zyjv9YknnniSz6pZpQXPFaMfNat6XCBk6+iLFYyGmWZVD7opcxHBOM8zxF2z86BSf+qpp57WU9IG
0URYiWQVBq+Ty2TFCGSskFu3bt2GHynfJ/57CB++I0Z7vfzyy68wkg3/MUQaIphwF/8n0Jj4mYCF
E8skzwnxkI4TaAD6YiYTmJaK+0zfYqNwP8U6mRMhnbJHDwcC88olu1dSQdc3DRnNKiX4cmUjzloK
2YqmBx544D/Z3EttDr6krwp0Ieh3Vw8eLPw0UrV6801QoolWdTqVUFPB90/33NixY1+mYk6ni47f
BCdhKtRUXTr55AZB31LOYJpvzB8BPyB8efT0jEGUfUvQ7FKChQTfIhxgNYtmgcqWbkkq0bOEZJYb
H6wt+JpkInSo1G+99dbbEokGRvxlO+cjYiCZrxCvRbmip2YFwiO+O4fWsh5OGywpREznWdRs6oG1
Ldu5FflurxJOEOiGpYHDfY4/EX6MiB4stlR0PMs0DvA3SvZegoKRhukKDe5BBBzX4difi9hNBI1z
LKveG8sQRqlKmT5Ns0oJ5QAxpfTyVkm2oumuu+66W7MwUsFDSgWYbKgmpmFmhG7OCjoo0cQQb1q5
mm3BQOWC/witWn4LLAbEW6JrBAsLrVK6EGh5MhdXMt+fpgL/iVwsP/HQkm1MmPN6S5cuXaanZwyV
F47aml2jcJ/T6tJLCwaeQ4bAY/1N1p3mwwS0mVRudBFTYevlUZgIl+9OT8sIhL1mkxAEQ7qjnZKB
2KHrVrN0KPj1UNrw+W6//fY7kgkVukyz7Y5FcGY7b18+YbAPYkjfZqNwHtHkqSfoQtbkQKDO+aWg
bysjEJtY5zSrtECg4T+oWbRKshVNdHlrFkY60EJN1U+N6ZvuJL2kSQlKNFFIZzJqpDmh8ub7xjJG
H3yyVntzwFD1bCvTeKiQ6HbQrBNCAZltNyZQOPOeNbukYLbPxD+iOaA7CasblrDGomQjEukS0ssa
BevlUYJeGkX0+9/0lIzB106zSQoVZrLGWjpgOcD3UrNzshG75IFfkGZRD5zucbLWUzOGod+UrZpd
wUC8sHQHnnAelk66B3MZrJIIRoNm4/5BeYB1VLNJGxr/mkWrhe8Gx339yGlT6KKp4JytpDWx94or
rvgtXQGa1ID/+Z//+SoWkJY88oBWaaJuiEKE6QM2CGsEqdQ2SeVSELPwE+fq29/+9ndEwwXiF8Dn
+1DQ3byAFSGRJSUeIipTOehuQSK3b2jEiBGH0e2DBSCZfwbdFgzD191GwXKJQNDdKOl8Z8lI536l
O22GoLsZU1ZWVsoUK1hfcQjOxopA6Idt27Yl/J6I/YQFWHczRlr8b+wXdLdgoAxMZlmLBwsTgyh+
85vfXC5fcWAWbvzSRPdfJaK1UpPSgp4CxE+mATCxmD355JNP6G6rRX/bjOs4hJNuFiQFWWkTh4PQ
9bToNakBZ5xxxscxidIvzMOkyS0GKhhuKt01soB4KKNHjz5Bd3OGmGGrBd3NC5QjogsanQaE0VgX
X3zxJaKrk1r0cIDFWokFAeddHHkZaUrXGRYgPa3JwIEbi6Tu1oMuvGTH4qG1H29F5lnJRTRVCbqZ
FHwpicuD1VKTMmbQoEEDsTDhF5hNgw7RtFnQ3Sg4Jkt5d2bv3r2zmkGAe4XGAPlrUsEg96pn3dPd
RqHIPPzww0cceeSR9QKV5gLWxbvuuuvOOYImpQVdnViYiF3W2HOaCOLYzRJ014gjnee1OSnYSvt9
4Xe/+92VOD5qUgOkMTeIqSCYj6qpnKopeIJQwi3J0lSI4Mh6ySWXXKq7OUPr7/HHH38siN82FVgi
dDMhhG3g8+luPejmomL+6le/egHzqX33u99lgtIfMKmvNMLP+frXv37hVVdd9X90SeE3oZflnbnC
AUF3ozDR80UXXfTNdIdW0/UbL7AQUrkMABEhVK2bjfKaMEnQ3ayQYmjw2Wef/ckuXbpkPAILixgW
Xd2NwndInCbdzZglwiJBdwuKciEdS2C++EC4X9DdtMDih+8jA2c0KW127ty56+GHH34o3XuyJYMF
EXQ3bZqiDG618IXjKIifA19kMmgdMlSX4a+pKqRc+ZKQ7SieWAhEGD+1g5EeWB2C9vfBpyEdawb9
9Ln4NAF+GZpdA7jn8fPQU+tBA+IKIZWAIA+EB9YfHIuxSBE8UrMJFKxadKUT5FJfPgpdVAQdxfql
p6eE/Oju0yw8GLHI76OnZAwiU7NKCdbLoIexpwsjJBP5czG0HouInpYxOKlrVgUH3dBBlKfZwO+c
SRc4jVzqmGz8dHwIaUAjQLNs1RBSh3iE+tHTBkOIZlGQFLSlgy/wLuGf//znP3C+1uQG4NNywQUX
fAUHMmKJMBpGDwUO70k3c8IsTdmBIDhPIPCiJuUMguKxxx57NJGlJB80Vmji0zNixIgGIQ8QE/8V
7hFS+aZwj+KHNlaQNsevv/Od73z7+uuvv45Rhji762k5Q6v5ZeFnP/vZpXRtarIHQ9v9+E6iRdNu
yGDJjfctoXuO50V3MyaTVj2fZ6Kgu00KXXNSkdebtoP7/cQTTzyJrilNygjE1tSpU6fobsHBdDOZ
+hIFBeKHZ0R3G4VGCLNTELsK4aTJGYHvlDRibmuuz9vUYDUG3U2bQrfCFXylTQUhN+q9NwkU0pqc
kFGjRo2UhviVtOQbm8eqEEAwUSDqrpEmxJjhN+7WrVtgYSemCxSgupt3GrNoIQ4STeRMt82bb745
Ht8bTUoLEUkVHwmEjxCd+bXrrrvuWvyfUj1LjYGVlAlSL7744p9ecsklFyMyEDscwwrGCEQqF+as
y9RhF8EXX2jyrOQimjKppKRxtlve+z2NuQXki0SiCRGdS5R97hdp7b+nu4HB70z4DRqojAImODFd
ypkG7F0vcI/qbpNBFPJHHnnkYeay06SEcN/RNUoDgBhRhx12WINBCungN3pEwE7VpFYPgimb57bQ
fZpaDFQ0vxBSddX54M9xpYBpX7MIBOLOMI2AvkzWYBomkq5ma6QBwfhwstWvMBBoidNFoC+REpy0
c4nTBITM0OwawP362muvva6nRiGAIkEl9bSc6CF8XpB2yM2MbMP6pC+TEL4jPjNdZP9PwMk5vjCk
gCT4It0PolGymuEf6MqL7zLBH4pYYXpKxjCTvGaVFjheU0GKdgsklEW6IDTjv1dGaOUS2BSBzG+j
2WUMg2zwFyXqPsGHCZdB+IdHH330sXHjxr0ht4837yHiDL9AgsNS7nKPaRaNQqgKYr/p220SsCzT
iGis8UJ4BixKiCWip+ulWcPvwG+p2bcJENF03etXkDbx3fNGDmCmx6eIh1S/35QQDZhpHYKy6jDk
lVaoZp81TAmQSwuyrUFFzUgxWmz6FQbChAkTJmYSvwZRw2S3enlWIPw0uwZQkFMh6alREDbE8MHy
oKcGApNGM4Ewc7BRQTAdDqIOPyAqR7okPi7gjJzotem24PnimiB8U4gOHe8fhTUj28l64dprr71O
s0obIjw35QS5CDQCeOrLR6G8yyUu3NVXX32NZpUUykYsRzQI6FYlaCm/Pb8p3wFzzFHmpRLXsRAA
Nx2rE6Lssccee1wvaxJogCTyHUNc4odH9PmJEydOSje2WCpocBAyQl+mzUD9ls3sCb8TNAsjKGj1
ZBLBF4GCcxmWCs0ia6ggghBNVP65jIhpSzCTfK6TCScCax+Vkr5MWiCwqEQ0i6xA/Gl2DaArisjP
emo9aMlzDEuInt4sIJROF4gKj5gJ0iJDtwkVl76UB/u01PWUjOA54zvTrDKCQLbpWrZzZeXKlauw
5OhLR/mTkO33S7l3hqBZRcFyh5WbueMQCIhlGpd0uwbZKKFxwMAJfdmk/EoIujGUDJ757wv60p5o
Y0AOE2ETZZzvQE8NBAQvPSRBNdpbEicLmRg4AKs294NmYQQJfeivvvrqa/pdpwU/ICOTaEVmexPT
YgiqIC10v6tCgMltcx2tlgysKpmOZKH7JNcpN+hq0uwSgqUlWcRjur647xnunImFLBewfmGRYjQe
/oJYH3YFNEFyLFScdPnEd1HlIpoQHNlOj8G98fe///0fmlVeYbqJRI06JnXVUzIG0YQjPkPk6YKm
O5bvlymc6PLLd/cj+WM14P7Rj5MQBDjzXepleYXuQ7p/ETIMHMKihFDXw4HC58d6SCNDP2qbAgs2
seP060gL7lms3pqFETQU5E8JmQyn5lzmwmLodqp5sxJBV0UuPgaxUFhotkYctABpEQbd8vOh4qAl
pC+XNpjws+mnj2XSpEnv0tWsWTYAP49UXUO0YKkAmRQXM3i6PiSNgVhhvigiciPo8d8j5AfPGBPv
YunSl88LPJuJTPO5iCZEZrpz/SWCBtbUqVM/0OzyAq1rrD3cW/qyHlgdE/m3ZQL3Cd2myUR4vkGk
YCnWj5QQugUfeeSRR/WSvEIXI/dxJl2N2UK3Y6qJrVsz+GBmamDgt2HeU83CyAc8cNddd931OJDq
954WPDQ4wTLDOeZqzS4liKYgHAMhkenciHQf0CrGuVS/qkDh96PrhUpJXzJtEDu5+rpwfap4Ysz+
TmWqlyQFoYEFlcqVmEw44RIln64XCi2EIQII2Ob+pYsZCx6+K7TqcLy844477sSJGyFGVyjfEa2+
dN5DUDAkO1EFS+OI0Y16WkYgFohzpFllDBbprwj5uheBhgEWIX3JKFgSsxWLhQLlLMJeP1JC+I6/
LWzZsmWrXtbi4XcrxAmSmxKs4ZlapBH4xErTLIx8wdBXnD0pdPW7TxtGwjEq6AKBwlmzTMopQlDm
3HiH17YOLW0qdrok8tUyRlzfeOON/87WZI7QymXiVECs41CuWSYEaxMCRi9JGwop7k+6EGfNmjX7
ww8//Ch2QRBhKcNBkxGmtATx89DLmxXCGCSymBGCIVuLCy1XRgpqVlmBeMmlmywVWLISzbeHP1BT
jywLGrpcGXGZqoFC2IJUE7W3FKiHcr3nWgM0zDIdSct3x3WahZFPaLnTT00AP/3+M4KWJJMAo47p
otBsG4DzNpWNXpYTvJZm2+ahYsL6gzlfv568QMGcSwuQVvELL7zwomaXFbNnz56DhVSzTEo25u2W
CiOVGKmnH70eCMxsv3NEJANHNKuswS8oX74vt9122+3xXXPAUH9GeulpLRZcIXhu9GMlhW6ZXMJV
FAK8fwIsp/N5WztYePVrSRsGRGAJ1ywKkoIPbpku5cLtguimywi2p8lpQ8EsdfaF9K1TiOFPw6iK
eKfUGsEP5JcriQrKtgbOtsT3YYgz3/txxx2X9UzuqWDo70033fTvXOfhOiDoZlbwu+OzpbtJ4T7+
xz/+8XesY5rUahkniDB6QXfrwTNXIehuRnDtDkF3s4YJXZmZgEpRkwJhz549e19//fXXeJ+a1Kqg
y02eOy9EhyYlZbyAFVZ3WxzyEzL57123Cul83tYOYSx0M214zvcJums0FcR1oQLOpYuHBwCLEs58
dN35UZqJV0Plq6flBL4S3htug9ASw8+Gbo+mmHuK1/iioC+fE7nOe4c1FCuCZtcoiHb8lAqlCy0f
MDCjMWdhunay/c4JD0L3vWaVEwjdoLvpaKTRFasvUY+BAl2semqLBH85GqT6kRqF3/nngl7a4hC9
dHfQcdRaMvgL61eTNvQ0MOuDZmE0JTjs4ugaVFeaNICmU3kFmSexQfTttikIoIejd1DB41IR9DBW
okVr1llBcEx8ODS7lFBZ4xeSr5GEzQkO54jZVFZXIjjrJRlBuIogKzJG8gU1mo77v7GuQ0Ze5Xvk
Xj5hsubGXB3ioYseFwm9vMXAYAzCF6QrDtsKVwv6FaUNg2QwTGgWRnNAFxsPYlAxSTCnB9Xv3pZG
CWCq/YTw8MMPP9KUfjo4ojK6MlWlnAnZtKBioSI/TNDs0gLL3NlCLpGxCw2sf+k2HIi1xLOnl6YN
o+7iu9hzBb9G/NL0JbKC8ohwDvyumm0DsLy88sorr+olLQas9MS3ynSwBQNjgpiiqikhpMPf/va3
v5uFqT7cu//6179u1K8pbYhhl46/p5FncBKnosPJTH+bgoCh4foWWyVYSIghdKlA4Z+vEXHJQDDl
w2SejYNjLHTvJhotlQ7MX0VXVVN/l0GDrwvDzPVjpYTRSNlEYifKc2PCJFtoANCtqC+TEdyXhIdI
57689dZbb9PLWgQIYSzymQpVrDQt7bNyP1KGU7/oxzAUyn6ePf2q0obAvUGX10YOEKGU0VOMlNPf
qFlJFb+kJUL0XwICMhIKfw2GttPy1I/cZFAxEXcomb9ILjCqLZOAqvFk4tOUCKZRobsxm3mdCgEC
ZTJSUj9OWnBPZRPqgQpcswgcLCMEKuVe05dLCV3FxMRKt9vqW0JLEciIyAuFbEQqXbSZTrnRnGAR
YQCLvn0jDrpasxnxynyHmoVRKGD6Y0qBDz744MN8TyOQilwiFRcKFJDE1qG7ia6Whx566GF8dvId
PboxEDQ8fOlMGJoNOCrmEuA03ZADjcH3TjfR008//UwuE7o2JYgLYkXRzagfI22wXGCd0azSgtej
MtYs8gKDGWgcpPMbEIMGx+hMuq0Q1/maRigoEIJYCJhUW992RiAg8X/S7AoaXDOeeeaZZ4OYx7Q1
Q2M1m8Cs99xzz72ahVFIUAATdwWH3uaMg0NcKX1LLQoKOQpIrAVM2socbgRMZIb6XCwwQYAVkQcv
E0frTMl11n2cHYMyQTOqk+jhTLhK9GV9iYIDp2ecZRGc+Dvo288IGjuZCETmM0s0m32QIF6JYM97
Q8DGx3LCD4sRcHSpEqU902HYfFcEY9XsCg66qHD4zWZKKh8sdk0191wuUFdcc801f2YwgL51IwkE
Zs10jk56JAg9o1kYhQgFGPGYaP3q79akYOIlEBqhB4g0zo2GwzIFsb7FZoP3QIHN+2HINlYNRBKz
rmNNouLPJgJ7PiGQIQ6ouRTg6YDgyaa/3ofJWTWrwKARIHXXNcS5oeWvL9Xs8F64V34m+GE7suVI
gVGsmnVKiCJON4FenncQ04Qn4Zn+y1/+ci1igi54unESRTpPl6ac0DZdsLgQsZ7ozXQX61vNGMoZ
ZnPIpIuzOaBLnZ6BVJMPGxEQlpmO9qXRxyhhzaJgafbKuRDgB/7hD3/4IxxThw4dmtcKNxG0ngno
JevdIkQ2LBFWrly5YrPAPsH5pPIpJ/CXFFZRqjDrCJpNo1A4EYYBEYSlzQeHPUAIUMHQaiY+jDCI
bRyPEXN0KTAEWk7JuoDMJ8T3IPjgi8J+QZPzBmKbGf+lvsg4gBuWsJ/85Cd56ZqlS5IuI9HhFxD7
6GBBfvYmD2JLfKQPhMcee+zR94T1gh7KGu5XGhhMbtu3b99GBRiPhui0S+8VNKnFwjPLQJYrr7zy
dz179sx5YuZcwXJ2++233/aSsEXQ5KzAIvz888+/cMwxxxytSQUFk7MTdPW22267NdeguG2JowWs
8d26dUu7KxrfvV/96le/lPLxHk0qSEw0xUD8oEsuueRSWk+FUDj5oI8QTtuFnQJdUKz3YPsXEE9y
mheBFjHlRyz3BZJoonYIJqJv01LCwkbrEJGEGGLkCl1urIuKilrcPYFz/x//+Mc/NGWhhtDGb0p0
SUZ+HJigRW997xFBk/IG08V8Vvj4xz9+xmhB9FReuxXwERTxOlMKy3dffvnlsZMF+byBRrrmfiZW
mgjkf/Xo0SPhDPK8D77fX/7yl7/g+dDkFg3P6VVXXXU1XfrNIYIBIfzggw/+505htaDJOYFVDgup
FEsFNQINv8y3BUQ/97EmG2mC3yKO4Jk0KvGDpWx8RtAkoyVAl9RxAn4E+ZprygiGtWvXrmtOk3k2
w+CZgBUnSc2iSUA8EzAOnxq6jV588cWXcC5meDhTtOhbSxusOOh1fDzotiAOGpGcmWwZ8a0vm1cY
CTt27NiXietDC5XPgd8UI+wYBt5c90Q+ofvvgQce+I/+DE0G3fB0ewft/IzleuLEiZP0ZZodnge6
zrEi5zK61XCci4RMR31iDCCUh2ZRsJilKQmIJ0aEfU+QuvELRx555BF6yGhmqCCfF+iOmy1ocpOD
9e5MASEyZsyYpNOA+BCb6Ic//OEPpLIfq0nNAiIKSyO+Xz6DBw8egt8N6XTV0p3LeayxXtLlSRcy
3cUiVtesUuhCpuu4VtDsmwy6lbGm+RGERQguY54zurG9E1ohdJkjYAiMm0+rMMJYBPFC5sV7XGDu
PT0UGEzMyki0ILv88Y3C4oy1fYDQu3fvhCIebwes99zPchuv/Eh4S5gvcL/raUaW0KBkYIQUJe00
KSX47ckt8enFgiYVJCaa0oB+968Kop++j5BqLvN4W0fKsqpJwh133HH7K0K6/lz5BgsAvjZf/vKX
v0JfPqIDQUXrCTGB4Jgq3HnnnXe8IehlBQtiBLHEZ/BFU9DdbEb2YM374x//+Cec60XXFmtyIGC5
Y+LcZ5999hmEhFRka/RQoHBvMZryRz/60Q81KRDoVrvkkksuRjwjmrBm4XaA5ZGGMK4M+I7Sbcsa
0b9R4FnVLIwA4B7FKop4SkfcY+WjEfxvodBFq4mmNMGXghbtxRdffAlDvDt16mSjKJoI+roRS2PH
jn2J2fCxcOihggGrDKPDEFCAvxitXQrmdQIFc65Os4bhgxhgZB4+mIcccsjBmpwVO3bs2Il15n1B
2iIvzxXwn9TDeYGG6IQJEyYG6Wcnj5v7oPCLX/ziMqyimmw0E1iBGXEt9+dQBKsm14NGGQL2vffe
m/yqgK+uHjJaC9wITINBy8TIPwQh/ZWA47X+BIZhKLTkmaKI7iZ9ZNKCLm7CNxA6A/FFFy0VmGab
d5gxgEER+nYCAR+7tjSfZ0uAewrBlAwsjqz19BaBWZoyhJhFxFc6+uij8xo0ry1DNxzWJFoezzzz
zNN0bWG10cOGYcSAg/YXhFNPPXXMKAGLp1ZGXvmOnw9+ZyIqNjJViSwr8EubJ+ATiAOul1ETwXvD
3+XLX/7ylzQpEN55550J3/ve974b1Mg+w0iEiaYMQRUTHj7ToeZGahhtgRPg888//9yTwnLBxJJh
pAddwozMBMKKMJEsFhhEEeBbR1dIc3dd4ff3xhtvjO/fv38/TcoZrFZXXHHFb28S+MyabBhGIUAw
Nh5MIxhw3nz00UcfYyoKAmnq12wYRiuEWFP66AcGFjT8pPQlDMMoJIhIzJBcfV6NLMD/4Lnnnnue
eB74U+BIrV+vYRitFCxgxELSYiAwbrvtttsZrKMvYxhGIUEU20wDdxmuu2rVqtVY6XDstlahYbQ9
iEyfqdN6KiiLCdyqL2EYRqHBpJy7M5htPRnr1q1bT1wUppTTpFYDo3OIJIzTPDOD46h6qIATqH6N
hmG0Ma677rrrtYgIDCaExvldX8Iw8oo5gmcBM/4zg3qyaLPpwIiW88477zM4ZjLB6qhRo45AVBCQ
jai/ueTdHNB6JLouE7MSSfj9999/jxn31wrSEjxAkEc91TCMNgixpV5//fVxp5xyysmaFAhESL/y
yiuv0F3DyCsmmrKAmeQZ/TFy5Mis52LCEnPaaaeNYcgvsSyA/v6BAjGJeA1iQh155JFHEYUcIcWE
u1hqGMHHSrNqMhihAkTi3iQwtJfRbsxewTBmogcDAcps1JthGLGcJTC5ds8AJ0NnDsTPfOYz504R
NMkw8oqJpixgSO9///vfpz71qU+drUkZQ9h4KUPOJFaKJjWAlplanXoTlh6HaWEok0kiqrBKIbQQ
UqBiylsjwtjUrFIiWiiMIELs+GumzmCbOZqwGBE7SVi5REA0IY62MkuqwLmalWEYRgOY1PkWQXcD
4aOPPppGOSziaY8mGUZeMdGUBQiTG2+88d+XXXbZzzUpY1auXLmKFhICRJNSwusSe4W5zYBtX1Ah
sHyYdLWsrKwd8y1xDiCiNBsP13XD+FIxFxOWI43fwsqbkwlBBIgi4rswCSoB8oBrNBvDMIyUUEYx
wu3rX//6hZoUCFcL1wi6axhGofJtASNLxBUxc3CQtphEhmG0BfADXb169Rot/gKBUXOnCfoShtEk
2Gz9WcJM4Eweq7sZg5M0VhzdNQzDaJVgFT9VwKtAkwJhhjBf0F3DaBJMNGUJs9Y//vjjjxGkUZPS
hh4xZnXGb0iTDMMwWiW4EHzmM585T3cDAbfLyZMnv8v0MJpkGEahUyb8WBD9tEktxmnx6quvvsY8
UZqNYRhGq+XjAo1LLf4CYfPmzVtOF/QlDMNoKeCcTeDG6dOnz9DnOSkMRnvllVdeJYSAXm4YhtGq
+aVQHnAU8AkCjVZ9CcNoMiw6c47wADMC7p133nmb8fiVlZVVmI4ZmcboM5wf6Xd/Q7jrrrvu/JdA
LCO93DAMo9XCCN4f/vCHPxo9evQJmhQIDwhS5r6ju4ZhtESIj0T//cHCcIH51YinxCg5wgDoaYZh
GG2CY4T33nvvfc88FBCMmvukoC9hGIZhGIbR8vmGsGrVqtWqdwJhzpw5c4cK+hKG0aTY6DnDMAwj
Lxx77LHHMXOB7gbCBwLzXOquYTQpJpoMwzCMwOkqjBLKyspKNSlnmLNz6tSpUyoFTTKMJsVEk2EY
hhE4zJvJZJm6GwjrBAID665hNDkmmgzDMIzAGSgEKZrC4bC7SLAo4EZzYqLJMAzDCJwjBUIO6G7O
MJH4pEmTJtYImmQYTY6JJsMwDCNQSoVjjjnmWMKwaFLOMFenxWYymhsTTYZhGEagtBNGCqKZQpqU
E3TNzRZWC5pkGM2CiSbDMAwjUAjmG6Q/U7Xw/vvvv3dA0CTDaBZMNBmGYRiBQmymToLu5kxFRUX5
LMFCDRjNjYkmwzAMI1CI2E0Xne7mzLJly5avEIgIrkmG0SyYaDIMwzACha65MkF3c2amsEPQXcNo
Nkw0GYZhGIEycODAQUFFAscJfN68eXP3C5pkGM2GiSbDMAwjUPoJupkzWJgWC9Y1ZxQCJpoMwzCM
QOkp6GbOrF69ehWRwHXXMJoVE02GYRhGYISEIP2ZRDStYc453TWMZsVEk2EYhhEYRAMH3c0Z5pqz
qVOMQsFEk2EYhhEYTJ0CupsTVVVV1cuWLVuqu4bR7JhoMgzDMAqScmGeoLuG0eyYaDIMwzACg1Fu
YUF3c2KzsFbQXcNodkw0GYZhGIFRpehuTswRbL45o5Aw0WQYhmEEBpamoAJRzp8/f16FoLuG0eyY
aDIMwzACZbWgmzmBpalW0F3DaHZMNBmGYRiBwrQnFRUVlbqbFfv27du/QdBdwygITDQZhmEYgfKB
sEfQ3azYKGwVdNcwCgITTYZhGEagrBIWCrqbFYyc2ybormEUBCaaDMMwjEDBEXyCUFNTk7U/0hYh
V2uVYQSNiSbDMAwjUIjT9Oabb47PpXttjcBIPN01jILARJNhGIYRODOEiYLuZkRlZWXV0qVLl+iu
YRQMJpoMwzCMwCEo5RNPPPH4smXLlmtS2lRUVJTLdct01zAMwzAMo3VTIlx99dXXVFdX19DVli7r
1q1bf7Cg2RiGYRiGYbR+Dhfeeuutt1UPpcX8+fMXlAqahWEYhmEYRusnJJwizJkzZ65qopS8+uqr
r+nlhmEYhmEYbYci4UJh+/btO1QXNcodd9xxp15qGAWFOYIbhmEYeYUQBM8Lt9566y21tbVhTU7K
unXr1uqmYRiGYRhG26Of8NBDDz2cyjH8fwW9xDAMwzAMo23SWbj55ptv2bt37z7VSPXYunXrtmMF
Pd0wDMMwDKPtwsi4HwqMkmOqFdVLLtt33333Pe0FPdUwDMMwDMMYJFwhzJw5c9bGjRs33X///Q/0
FfSwYRQYjvP/AXEwrvXiqFUIAAAAAElFTkSuQmCC
"
id="image17" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
<template>
<div v-if="showDolbyLogo" class="dolby-badges" :class="{ compact }">
<img :src="dolbyLogoSrc" :alt="dolbyLogoAlt" class="dolby-logo" />
</div>
</template>
<script setup lang="ts">
import { computed } from "vue"
import dolbyAtmosUrl from "../assets/dolby-atmos.webp"
import dolbyVisionUrl from "../assets/dolby-vision.webp"
import dolbyVisionAtmosUrl from "../assets/dolby-vision-atmos.webp"
const props = defineProps<{
hasDolbyVision?: boolean | null
hasDolbyAtmos?: boolean | null
isHdr?: boolean | null
compact?: boolean
}>()
const hasDolbyVision = computed(() => props.hasDolbyVision === true)
const hasDolbyAtmos = computed(() => props.hasDolbyAtmos === true)
const compact = computed(() => props.compact === true)
const showDolbyLogo = computed(() => hasDolbyVision.value || hasDolbyAtmos.value)
const dolbyLogoSrc = computed(() => {
if (hasDolbyVision.value && hasDolbyAtmos.value) return dolbyVisionAtmosUrl
if (hasDolbyVision.value) return dolbyVisionUrl
return dolbyAtmosUrl
})
const dolbyLogoAlt = computed(() => {
if (hasDolbyVision.value && hasDolbyAtmos.value) return "Dolby Vision + Dolby Atmos"
if (hasDolbyVision.value) return "Dolby Vision"
return "Dolby Atmos"
})
</script>
<style scoped>
.dolby-badges {
display: flex;
align-items: center;
justify-content: flex-end;
height: 100%;
width: max-content;
}
.dolby-logo {
height: 100%;
max-height: 44px;
min-height: 30px;
width: auto;
display: block;
border-radius: 3px;
filter: invert(1) brightness(1.1) contrast(1.05);
}
.dolby-badges.compact .dolby-logo {
max-height: 34px;
min-height: 24px;
}
</style>
@@ -0,0 +1,192 @@
<template>
<div
v-if="visible"
ref="menuRef"
class="episode-release-menu"
:style="menuStyle"
tabindex="-1"
@keydown="handleKeydown"
>
<div class="episode-release-header">{{ episodeName }}</div>
<div v-if="releases.length > 0" class="episode-release-list">
<ReleaseVersionCard
v-for="(release, index) in releases"
:key="index"
:torrent="release"
:best="index === 0"
:selectable="!!release.playable_file"
:disabled="!release.playable_file"
compact-flags
variant="menu"
inert-card
show-actions
:play-label="getPlayLabel(release.playable_file)"
@play="emit('play', release.playable_file || '')"
@open-folder="emit('openFolder', release.playable_file || '')"
/>
</div>
<div v-else class="episode-release-empty">No versions available</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
import type { Torrent } from "../types"
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
const props = defineProps<{
visible: boolean
x: number
y: number
episodeName: string
releases: Torrent[]
hasResumePosition: (filePath: string | null) => boolean
}>()
const emit = defineEmits<{
play: [string]
openFolder: [string]
close: []
}>()
const menuRef = ref<HTMLElement | null>(null)
const menuLeft = ref(0)
const menuTop = ref(0)
const VIEWPORT_MARGIN = 12
const menuStyle = computed(() => ({
left: `${menuLeft.value}px`,
top: `${menuTop.value}px`,
}))
function getPlayLabel(filePath: string | null | undefined): string {
return props.hasResumePosition(filePath || null) ? "Continue" : "Play"
}
function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return []
return Array.from(menuRef.value.querySelectorAll<HTMLElement>(".ctx-btn:not(:disabled)"))
}
function focusNext(delta: number) {
const elements = getFocusableElements()
if (elements.length === 0) return
const currentIndex = elements.findIndex((el) => el === document.activeElement)
const nextIndex =
currentIndex < 0 ? 0 : (currentIndex + delta + elements.length) % elements.length
elements[nextIndex].focus()
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Tab") {
event.preventDefault()
focusNext(event.shiftKey ? -1 : 1)
return
}
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
event.preventDefault()
focusNext(1)
return
}
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
event.preventDefault()
focusNext(-1)
return
}
if (event.key === "Escape") {
event.preventDefault()
emit("close")
return
}
}
function clampToViewport() {
const menu = menuRef.value
if (!menu) return
const width = menu.offsetWidth
const height = menu.offsetHeight
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN)
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft)
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop)
}
function handleViewportChange() {
if (!props.visible) return
clampToViewport()
}
watch(
() => [props.visible, props.x, props.y, props.episodeName, props.releases.length],
async ([visible]) => {
if (!visible) return
await nextTick()
clampToViewport()
// Focus first action button for keyboard navigation
const firstBtn = menuRef.value?.querySelector(".ctx-btn:not(:disabled)") as HTMLElement | null
firstBtn?.focus()
},
{ immediate: true },
)
watch(
() => props.visible,
(visible) => {
if (visible) {
window.addEventListener("resize", handleViewportChange)
return
}
window.removeEventListener("resize", handleViewportChange)
},
{ immediate: true },
)
onBeforeUnmount(() => {
window.removeEventListener("resize", handleViewportChange)
})
</script>
<style scoped>
.episode-release-menu {
position: fixed;
z-index: 1000;
background: rgba(20, 20, 30, 0.98);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
min-width: 420px;
max-width: min(820px, calc(100vw - 24px));
max-height: calc(100vh - 24px);
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
padding: 8px;
}
.episode-release-header {
padding: 10px 12px;
font-weight: 600;
font-size: 0.9rem;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.episode-release-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.episode-release-empty {
padding: 16px;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 0.85rem;
}
</style>
+821 -77
View File
@@ -1,7 +1,9 @@
<template> <template>
<header class="header" :class="[`header-${position}`]"> <header class="header" :class="[`header-${position}`]">
<div class="header-left"> <div class="header-left">
<RouterLink to="/" class="header-logo-link" aria-label="Go to front page">
<img :src="logoUrl" alt="MediaHive" class="header-logo" /> <img :src="logoUrl" alt="MediaHive" class="header-logo" />
</RouterLink>
<nav class="header-nav"> <nav class="header-nav">
<!-- Browse mode: show both Movies and Series --> <!-- Browse mode: show both Movies and Series -->
<template v-if="!isDetailPage"> <template v-if="!isDetailPage">
@@ -26,19 +28,12 @@
</template> </template>
<!-- Detail mode: show current category + Details --> <!-- Detail mode: show current category + Details -->
<template v-else> <template v-else>
<button <button class="header-nav-item" v-bind="navAttrs(navRow, 0)" @focus="goToCategory">
class="header-nav-item" {{
v-bind="navAttrs(navRow, 0)" currentView === "search" ? "Search" : currentView === "movies" ? "Movies" : "Series"
@focus="goToCategory" }}
>
{{ currentView === 'movies' ? 'Movies' : 'Series' }}
</button>
<button
class="header-nav-item active"
v-bind="navAttrs(navRow, 1, 1)"
>
Details
</button> </button>
<button class="header-nav-item active" v-bind="navAttrs(navRow, 1, 1)">Details</button>
</template> </template>
</nav> </nav>
</div> </div>
@@ -49,12 +44,23 @@
type="search" type="search"
class="search-input" class="search-input"
placeholder="Search..." placeholder="Search..."
:spellcheck="false"
autocorrect="off"
autocapitalize="off"
autocomplete="off"
v-model="localSearch" v-model="localSearch"
v-bind="navAttrs(navRow, 2)" v-bind="navAttrs(navRow, 2)"
:data-nav-entry-col="localSearch ? 2 : undefined" :data-nav-entry-col="localSearch ? 2 : undefined"
@focus="handleSearchFocus" @focus="handleSearchFocus"
@keydown.escape="handleEscape" @keydown.escape="handleEscape"
/> />
<HexKeyboard
v-model="localSearch"
:visible="hexKeyboardVisible"
:search-ref="searchInputRef"
@close="hexKeyboardVisible = false"
@submit="hexKeyboardVisible = false"
/>
</div> </div>
<div v-if="mpcBeConnected" class="player-indicator" title="MPC-BE is connected"> <div v-if="mpcBeConnected" class="player-indicator" title="MPC-BE is connected">
@@ -62,129 +68,543 @@
<span>Player Open</span> <span>Player Open</span>
</div> </div>
<div v-if="isDesktopApp" class="header-settings"> <div class="header-settings">
<button <button class="header-settings-btn" title="Settings" @click="openSettings">
class="header-settings-btn" <svg
title="Change media folder" xmlns="http://www.w3.org/2000/svg"
@click="changeFolder" width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <circle cx="12" cy="12" r="3" />
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/> <path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 5 15.4 1.65 1.65 0 0 0 3.4 15H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg> </svg>
</button> </button>
<!-- Full-screen settings view -->
<div v-if="showSettings" class="settings-view">
<div class="settings-header">
<button class="settings-back" @click="closeSettings">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
<span>Back</span>
</button>
<h1 class="settings-title">Settings</h1>
<div class="settings-header-spacer"></div>
</div>
<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>
<div class="roots-list">
<div
v-for="root in roots"
:key="root.root_id"
class="roots-item"
:class="`roots-item--${root.status}`"
>
<div class="roots-item-info">
<span class="roots-item-name">{{ root.root_id }}</span>
<span class="roots-item-path">{{ root.path }}</span>
</div>
<div class="roots-item-meta">
<span class="roots-item-status">{{ root.status }}</span>
<button
v-if="roots.length > 1"
class="roots-item-remove"
@click="removeRoot(root.root_id)"
title="Remove root"
>
×
</button>
</div>
</div>
</div>
<div class="roots-actions">
<button v-if="isDesktopApp" class="roots-add-btn" @click="addRoot">
+ Add Folder
</button>
</div>
</section>
<section class="settings-section">
<h2 class="settings-section-title">Player</h2>
<p class="settings-section-desc">Choose which media player to launch files with.</p>
<div class="player-layout">
<div class="player-list">
<label
v-for="player in detectedPlayers"
:key="player.id"
class="player-radio-label"
>
<input
class="player-radio"
type="radio"
name="player-selection"
:checked="settings.playerId === player.id"
@change="setPlayer(player.id)"
/>
{{ player.name }}
</label>
</div>
<div class="player-options">
<template v-if="settings.playerId === 'custom'">
<div class="player-option-group">
<label class="player-option-label">Custom Command</label>
<input
class="player-option-input"
type="text"
placeholder='C:\Player\player.exe "%s"'
v-model="customCmd"
@change="setCustomCmd(customCmd)"
/>
<p class="player-option-hint">Use %s as placeholder for the file path.</p>
</div>
</template>
<template v-if="selectedPlayerFamily === 'mpc'">
<div class="player-option-group">
<label class="player-option-label" for="mpc-port">MPC Web UI Port</label>
<input
id="mpc-port"
class="player-option-input player-option-input--short"
type="number"
placeholder="13579"
:value="settings.playerMpcPort ?? ''"
@input="onMpcPortInput"
/>
<p class="player-option-hint">
Port for MPC-BE/HC web interface. Leave empty to disable remote control.
</p>
<p v-if="settings.playerMpcPort !== null" class="player-family-note">
Web remote control enabled
</p>
</div>
</template>
</div>
</div>
</section>
<section class="settings-section">
<h2 class="settings-section-title">Preferred Format</h2>
<p class="settings-section-desc">
Preferred format when multiple versions are available.
</p>
<div class="format-grid">
<div class="format-row format-row-stack">
<label class="format-radio-label" for="resolution-hd">
<input
id="resolution-hd"
class="format-radio"
type="radio"
name="resolution-preference"
:checked="settings.preferredResolution === 'r2'"
@change="setPreferredResolution('r2')"
/>
HD or lower
</label>
<label class="format-radio-label" for="resolution-fhd">
<input
id="resolution-fhd"
class="format-radio"
type="radio"
name="resolution-preference"
:checked="settings.preferredResolution === 'r3'"
@change="setPreferredResolution('r3')"
/>
Full HD
</label>
<label class="format-radio-label" for="resolution-4k">
<input
id="resolution-4k"
class="format-radio"
type="radio"
name="resolution-preference"
:checked="settings.preferredResolution === 'r4'"
@change="setPreferredResolution('r4')"
/>
4K
</label>
<label class="format-radio-label" for="resolution-highest">
<input
id="resolution-highest"
class="format-radio"
type="radio"
name="resolution-preference"
:checked="settings.preferredResolution === 'rmax'"
@change="setPreferredResolution('rmax')"
/>
Highest
</label>
</div>
<div class="format-row format-row-stack">
<label class="format-radio-label" for="hdr-none">
<input
id="hdr-none"
class="format-radio"
type="radio"
name="hdr-preference"
:checked="settings.preferredHdr === 'none'"
@change="setPreferredHdr('none')"
/>
No HDR
</label>
<label class="format-radio-label" for="hdr-hdr10plus">
<input
id="hdr-hdr10plus"
class="format-radio"
type="radio"
name="hdr-preference"
:checked="settings.preferredHdr === 'hdr10plus'"
@change="setPreferredHdr('hdr10plus')"
/>
HDR10+
</label>
<label class="format-radio-label" for="hdr-dovi">
<input
id="hdr-dovi"
class="format-radio"
type="radio"
name="hdr-preference"
:checked="settings.preferredHdr === 'dovi'"
@change="setPreferredHdr('dovi')"
/>
Dolby Vision
</label>
</div>
</div>
</section>
<section class="settings-section">
<h2 class="settings-section-title">Diagnostics</h2>
<p class="settings-section-desc">Application log for troubleshooting.</p>
<div class="diag-log-header">
<span class="diag-label">Application log</span>
</div>
<pre ref="logEl" class="diag-log" @scroll="onLogScroll">{{ appLog }}</pre>
</section>
</div>
</div>
</div> </div>
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'; import { ref, watch, computed, onMounted, onUnmounted, nextTick } from "vue"
import { useRouter } from 'vue-router'; import { useRouter, useRoute } from "vue-router"
import { navAttrs } from '../composables/useKeyboardNavigation'; import { navAttrs } from "../composables/useKeyboardNavigation"
import logoUrl from '../assets/mediahive.webp'; import logoUrl from "../assets/mediahive.webp"
import { pickFolderAndRestart } from '../api'; import { replaceRoots, pickFolderAndAddRoot, fetchPlayers } from "../api"
import type { PlayerInfo } from "../api"
import HexKeyboard from "./HexKeyboard.vue"
import {
useSettings,
type ResolutionPreference,
type HdrPreference,
} from "../composables/useSettings"
const settings = useSettings()
const detectedPlayers = ref<PlayerInfo[]>([])
const customCmd = ref(settings.playerCustomCmd || "")
const selectedPlayerFamily = computed(() => {
const p = detectedPlayers.value.find((p) => p.id === settings.playerId)
return p?.family ?? "default"
})
interface RootEntry {
root_id: string
path: string
status: string
}
const props = defineProps<{ const props = defineProps<{
currentView: 'movies' | 'series'; currentView: "movies" | "series" | "search"
searchQuery: string; searchQuery: string
mpcBeConnected: boolean; roots: RootEntry[]
navRow: number; mpcBeConnected: boolean
position: 'top' | 'after-hero' | 'after-movie-header' | 'after-series-hero'; navRow: number
}>(); position: "top" | "after-hero" | "after-movie-header" | "after-series-hero"
}>()
const emit = defineEmits<{ const emit = defineEmits<{
search: [string]; search: [string]
clearSearch: []; goBack: []
goBack: []; }>()
}>();
const router = useRouter(); const router = useRouter()
const searchInputRef = ref<HTMLInputElement | null>(null); const route = useRoute()
const localSearch = ref(props.searchQuery); const searchInputRef = ref<HTMLInputElement | null>(null)
const localSearch = ref(props.searchQuery)
// True only when running inside the packaged pywebview desktop app. const isDesktopApp = ref(typeof (window as any).pywebview !== "undefined")
// pywebview injects window.pywebview asynchronously, so we listen for the function _onPywebviewReady() {
// 'pywebviewready' event rather than checking at component creation time. isDesktopApp.value = true
const isDesktopApp = ref(typeof (window as any).pywebview !== 'undefined');
function _onPywebviewReady() { isDesktopApp.value = true; }
window.addEventListener('pywebviewready', _onPywebviewReady, { once: true });
onUnmounted(() => window.removeEventListener('pywebviewready', _onPywebviewReady));
async function changeFolder() {
await pickFolderAndRestart();
} }
window.addEventListener("pywebviewready", _onPywebviewReady, { once: true })
onUnmounted(() => window.removeEventListener("pywebviewready", _onPywebviewReady))
const showSettings = computed(() => route.path === "/settings")
const roots = computed(() => props.roots)
function openSettings() {
if (showSettings.value) return
void router.push("/settings")
}
function closeSettings() {
if (!showSettings.value) return
if (window.history.length > 1) {
router.back()
return
}
void router.replace("/movies")
}
function setPreferredResolution(value: ResolutionPreference) {
settings.preferredResolution = value
}
function setPreferredHdr(value: HdrPreference) {
settings.preferredHdr = value
}
function setPlayer(id: string) {
settings.playerId = id
}
function setCustomCmd(cmd: string) {
settings.playerCustomCmd = cmd.trim() || null
}
function onMpcPortInput(e: Event) {
const target = e.target as HTMLInputElement
const value = target.value.trim()
if (value === "") {
settings.playerMpcPort = null
} else {
const num = parseInt(value, 10)
settings.playerMpcPort = isNaN(num) || num <= 0 ? null : num
}
}
async function refreshPlayers() {
try {
detectedPlayers.value = await fetchPlayers()
} catch (e) {
console.error("Failed to fetch players:", e)
}
}
const appLog = ref("")
const logEl = ref<HTMLElement | null>(null)
let logSocket: WebSocket | null = null
let pinnedToBottom = true
function onLogScroll() {
const el = logEl.value
if (!el) return
pinnedToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}
function connectLogSocket() {
if (logSocket) return
const proto = location.protocol === "https:" ? "wss" : "ws"
const ws = new WebSocket(`${proto}://${location.host}/api/log/ws`)
logSocket = ws
pinnedToBottom = true
ws.onmessage = async (ev) => {
appLog.value = String(ev.data)
await nextTick()
const el = logEl.value
if (el && pinnedToBottom) el.scrollTop = el.scrollHeight
}
ws.onclose = () => {
if (logSocket === ws) logSocket = null
if (showSettings.value) setTimeout(connectLogSocket, 3000)
}
}
function disconnectLogSocket() {
const ws = logSocket
logSocket = null
ws?.close()
}
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)
} catch (e) {
console.error("Failed to remove root:", e)
alert("Failed to remove root")
}
}
async function addRoot() {
const folder = await pickFolderAndAddRoot()
if (!folder) return
const suggestedId = folder.split("/").pop() || folder.split("\\").pop() || "media"
const newRoots = Object.fromEntries(roots.value.map((r) => [r.root_id, r.path]))
newRoots[suggestedId] = folder
try {
await replaceRoots(newRoots)
closeSettings()
} catch (e) {
console.error("Failed to add root:", e)
alert("Failed to add root")
}
}
watch(showSettings, (visible) => {
if (visible) {
void refreshPlayers()
connectLogSocket()
} else {
disconnectLogSocket()
}
})
// Check if we're on a detail page // Check if we're on a detail page
const isDetailPage = computed(() => { const isDetailPage = computed(() => {
return props.position === 'after-movie-header' || props.position === 'after-series-hero'; return props.position === "after-movie-header" || props.position === "after-series-hero"
}); })
// Check if search is active (has query and not on detail page) // Check if search is active (has query and not on detail page)
const isSearchActive = computed(() => { const isSearchActive = computed(() => {
return !isDetailPage.value && !!localSearch.value; return !isDetailPage.value && !!localSearch.value
}); })
// Switch views on focus (no Enter required) - only in browse mode // Switch views on focus (no Enter required) - only in browse mode
function switchToMovies() { function switchToMovies() {
if (!isDetailPage.value && props.currentView !== 'movies') { if (!isDetailPage.value && props.currentView !== "movies") {
emit('clearSearch'); router.push("/movies")
router.push('/movies');
} }
} }
function switchToSeries() { function switchToSeries() {
if (!isDetailPage.value && props.currentView !== 'series') { if (!isDetailPage.value && props.currentView !== "series") {
emit('clearSearch'); router.push("/series")
router.push('/series');
} }
} }
// Go back to category list from detail page // Go back to category list from detail page
function goToCategory() { function goToCategory() {
// Emit goBack to let App.vue handle navigation and focus restoration // Emit goBack to let App.vue handle navigation and focus restoration
emit('goBack'); emit("goBack")
} }
// Handle search input focus - navigate to search if we have a query // Handle search input focus - navigate to search if we have a query
function handleSearchFocus() { function handleSearchFocus() {
// If on detail page, go back to browse first // Intentionally no-op: focusing search should not navigate away from detail.
if (isDetailPage.value) {
goToCategory();
}
} }
// Sync local search to parent // Sync local search to parent
watch(localSearch, (val) => { watch(localSearch, (val) => {
emit('search', val); emit("search", val)
}); })
// Sync parent search to local (for external clears) // Sync parent search to local (for external clears)
watch(() => props.searchQuery, (val) => { watch(
() => props.searchQuery,
(val) => {
if (val !== localSearch.value) { if (val !== localSearch.value) {
localSearch.value = val; localSearch.value = val
} }
}); },
)
function handleEscape() { function handleEscape() {
if (hexKeyboardVisible.value) {
hexKeyboardVisible.value = false
return
}
// Clear search and blur // Clear search and blur
localSearch.value = ''; localSearch.value = ""
searchInputRef.value?.blur(); searchInputRef.value?.blur()
}
const hexKeyboardVisible = ref(false)
function onGamepadAction(event: Event) {
const customEvent = event as CustomEvent<{ action?: string }>
const action = customEvent.detail?.action
if (!action) return
// Only handle when search input is focused
const active = document.activeElement
if (!active || !searchInputRef.value || active !== searchInputRef.value) return
if (action === "select") {
event.preventDefault()
if (!hexKeyboardVisible.value) {
hexKeyboardVisible.value = true
}
}
}
function focusSearchInput() {
searchInputRef.value?.focus()
searchInputRef.value?.select()
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') { const target = e.target as HTMLElement | null
e.preventDefault(); const isTypingTarget = Boolean(
searchInputRef.value?.focus(); target &&
searchInputRef.value?.select(); (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable),
)
const isSearchShortcut = (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "f"
const isSlashShortcut = !e.ctrlKey && !e.metaKey && !e.altKey && e.code === "Slash"
if (isTypingTarget && !isSearchShortcut) {
return
}
if (isSearchShortcut || isSlashShortcut) {
e.preventDefault()
focusSearchInput()
} }
} }
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', handleKeydown); window.addEventListener("keydown", handleKeydown)
}); window.addEventListener("mediahive:gamepad-action", onGamepadAction)
})
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown); window.removeEventListener("keydown", handleKeydown)
}); window.removeEventListener("mediahive:gamepad-action", onGamepadAction)
disconnectLogSocket()
})
</script> </script>
<style scoped> <style scoped>
@@ -204,4 +624,328 @@ onUnmounted(() => {
background: #22c55e; background: #22c55e;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.18); box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.18);
} }
.header-settings {
position: relative;
}
.settings-view {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: var(--bg-primary);
z-index: 2000;
display: flex;
flex-direction: column;
overflow: hidden;
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.settings-back {
display: inline-flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: var(--text-secondary);
font-size: 0.9rem;
cursor: pointer;
padding: 8px 0;
transition: color 0.2s;
}
.settings-back:hover {
color: var(--text-primary);
}
.settings-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
}
.settings-header-spacer {
width: 80px;
}
.settings-content {
flex: 1;
overflow-y: auto;
padding: 32px 24px;
max-width: 720px;
margin: 0 auto;
width: 100%;
}
.settings-section {
margin-bottom: 40px;
}
.settings-section-title {
font-size: 1rem;
font-weight: 600;
margin: 0 0 6px;
}
.settings-section-desc {
font-size: 0.85rem;
color: var(--text-secondary);
margin: 0 0 20px;
}
.roots-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.roots-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
gap: 8px;
}
.roots-item--ready {
border-left: 3px solid #22c55e;
}
.roots-item--scanning {
border-left: 3px solid #f59e0b;
}
.roots-item--loading {
border-left: 3px solid #3b82f6;
}
.roots-item--error {
border-left: 3px solid #ef4444;
}
.roots-item-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.roots-item-name {
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.roots-item-path {
font-size: 0.75rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.roots-item-meta {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.roots-item-status {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.roots-item-remove {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1rem;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.roots-item-remove:hover {
color: #ef4444;
}
.roots-actions {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.roots-add-btn {
width: 100%;
padding: 8px;
background: rgba(255, 255, 255, 0.08);
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s;
}
.roots-add-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.format-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 0;
}
.format-grid {
display: grid;
grid-template-columns: 9.5em 9.5em;
gap: 0.75em 1.25em;
justify-content: start;
}
.format-row-stack {
flex-direction: column;
gap: 8px;
}
.format-radio-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--text-primary);
font-size: 0.85rem;
}
.format-radio {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--accent, #3b82f6);
}
.player-layout {
display: grid;
grid-template-columns: 9.5em 1fr;
gap: 1.25em;
align-items: start;
}
.player-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.player-radio-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--text-primary);
font-size: 0.85rem;
}
.player-radio {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--accent, #3b82f6);
}
.player-options {
display: flex;
flex-direction: column;
gap: 14px;
}
.player-option-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.player-option-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-primary);
}
.player-option-input {
width: 100%;
max-width: 400px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
outline: none;
}
.player-option-input:focus {
border-color: var(--accent, #3b82f6);
}
.player-option-input--short {
width: 120px;
max-width: none;
}
.player-option-hint {
margin: 2px 0 0;
font-size: 0.75rem;
color: var(--text-secondary);
}
.player-family-note {
margin: 4px 0 0;
font-size: 0.8rem;
color: #22c55e;
}
.diag-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.diag-log-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.diag-log {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-all;
width: 100%;
max-height: 320px;
overflow-y: auto;
margin: 0;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
color: var(--text-secondary);
}
</style> </style>
-149
View File
@@ -1,149 +0,0 @@
<template>
<section class="hero">
<div
v-if="coverUrl"
class="hero-background"
:style="{ backgroundImage: `url('${coverUrl}')` }"
></div>
<div class="hero-content">
<h1 class="hero-title">{{ item.title }}</h1>
<div class="hero-meta">
<span v-if="item.year" class="hero-year">{{ item.year }}</span>
<span v-if="rating" class="hero-rating" :class="ratingClass">
{{ rating.toFixed(1) }}
</span>
<span v-if="resolution" class="hero-quality">{{ resolution }}</span>
<span v-if="quality" class="hero-quality">{{ quality }}</span>
</div>
<p v-if="overview" class="hero-overview">{{ overview }}</p>
<div class="hero-buttons">
<button
class="btn btn-primary"
@click="handlePlay"
:disabled="!playableFile"
>
Play
</button>
<button class="btn btn-secondary" @click="$emit('info', item)">
More Info
</button>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { MediaItem, Movie, Series } from '../types';
import { getCoverUrl } from '../api';
const props = defineProps<{
item: MediaItem;
}>();
const emit = defineEmits<{
play: [string];
info: [MediaItem];
}>();
const coverUrl = computed(() => {
return getCoverUrl(props.item.cover_path);
});
const resolution = computed(() => {
if (props.item.type === 'movies') {
const movie = props.item.data as Movie;
const torrents = Object.values(movie.torrents || {});
return torrents.length > 0 ? torrents[0].resolution : null;
}
return null;
});
const quality = computed(() => {
if (props.item.type === 'movies') {
const movie = props.item.data as Movie;
const torrents = Object.values(movie.torrents || {});
return torrents.length > 0 ? torrents[0].quality : null;
}
return null;
});
const rating = computed(() => {
if (props.item.type === 'movies') {
return (props.item.data as Movie).info?.rating;
}
return (props.item.data as Series).info?.rating;
});
const ratingClass = computed(() => {
if (!rating.value) return '';
if (rating.value >= 7.5) return 'rating-high';
if (rating.value >= 6) return 'rating-medium';
return 'rating-low';
});
const overview = computed(() => {
if (props.item.type === 'movies') {
const o = (props.item.data as Movie).info?.overview;
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null;
}
const o = (props.item.data as Series).info?.overview;
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null;
});
const playableFile = computed(() => {
if (props.item.type === 'movies') {
const movie = props.item.data as Movie;
const torrents = Object.values(movie.torrents || {});
return torrents.length > 0 ? torrents[0].playable_file : null;
}
// For series, get first available file from episodes
const series = props.item.data as Series;
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
const torrents = Object.values(episode.torrents || {});
for (const torrent of torrents) {
if (torrent.playable_file) {
return torrent.playable_file;
}
}
}
}
return null;
});
function handlePlay() {
if (playableFile.value) {
emit('play', playableFile.value);
}
}
</script>
<style scoped>
.hero-rating {
font-weight: 600;
padding: 4px 10px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
}
.rating-high {
color: #46d369;
}
.rating-medium {
color: #f9a825;
}
.rating-low {
color: #e53935;
}
.hero-overview {
max-width: 500px;
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.5;
margin-top: 12px;
}
</style>
+647
View File
@@ -0,0 +1,647 @@
<template>
<Teleport to="body">
<Transition name="hex-keyboard-fade">
<div
v-if="visible"
ref="keyboardRef"
class="hex-keyboard"
@click.stop
@keydown="handleKeyDown"
>
<div ref="gridRef" class="hex-keyboard-grid">
<div
v-for="(row, rowIndex) in rows"
:key="rowIndex"
class="hex-keyboard-row"
:class="`hex-keyboard-row-${rowIndex}`"
>
<button
v-for="key in row"
:key="key.id"
:ref="(el: unknown) => setKeyRef(el as HTMLElement | null, key.globalIndex)"
class="hex-key"
:class="[
`hex-key-row-${getCoord(key.globalIndex).row}`,
{
'hex-key-blue': key.bg === 'blue',
'hex-key-yellow': key.bg === 'yellow',
'hex-key-red': key.bg === 'red',
'hex-key-pressed': isPressed(key.globalIndex),
},
]"
tabindex="-1"
@click="handleKeyClick(key)"
>
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{
key.label
}}</span>
</button>
</div>
<!-- Green focus outline rendered separately on top -->
<svg
v-if="focusedIndex !== null"
class="hex-key-focus"
viewBox="-5 -5 96.6 110"
preserveAspectRatio="none"
:style="focusSvgStyle"
>
<polygon points="43.3,0 86.6,25 86.6,75 43.3,100 0,75 0,25" />
</svg>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted, onUnmounted, computed } from "vue"
interface KeyDef {
id: string
label: string
value?: string
action?: "backspace" | "space" | "close"
bg?: "blue" | "yellow" | "red"
}
interface RowKeyDef extends KeyDef {
globalIndex: number
}
const props = defineProps<{
visible: boolean
modelValue: string
searchRef?: HTMLInputElement | null
}>()
const emit = defineEmits<{
"update:modelValue": [string]
close: []
submit: []
}>()
const focusedIndex = ref<number | null>(null)
const pressedTimestamps = ref<Map<number, number>>(new Map())
const keyRefs = ref<(HTMLElement | null)[]>([])
const keyboardRef = ref<HTMLElement | null>(null)
const gridRef = ref<HTMLElement | null>(null)
const pressedTimeouts = new Map<number, ReturnType<typeof setTimeout>>()
function isPressed(index: number): boolean {
const ts = pressedTimestamps.value.get(index)
if (!ts) return false
return Date.now() - ts < 1000
}
const rows = computed<RowKeyDef[][]>(() => {
const result: RowKeyDef[][] = [[], [], [], []]
for (let i = 0; i < layoutKeys.length; i++) {
const key = layoutKeys[i]
const coord = getCoord(i)
result[coord.row].push({ ...key, globalIndex: i })
}
return result
})
// Four-row layout with honeycomb staggering
// Each row shifted 0.5 cell left relative to the one below:
// Row 0 (numbers): shift 0
// Row 1 (qwerty): shift 0.5
// Row 2 (asdf): shift 1.0
// Row 3 (zxcv): shift 1.5
const layoutKeys: KeyDef[] = [
// Row 0 (number row) — shift 0
{ id: "1", label: "1", value: "1" },
{ id: "2", label: "2", value: "2" },
{ id: "3", label: "3", value: "3" },
{ id: "4", label: "4", value: "4" },
{ id: "5", label: "5", value: "5" },
{ id: "6", label: "6", value: "6" },
{ id: "7", label: "7", value: "7" },
{ id: "8", label: "8", value: "8" },
{ id: "9", label: "9", value: "9" },
{ id: "0", label: "0", value: "0" },
{ id: "bs", label: "\u2190", action: "backspace", bg: "blue" },
// Row 1 (QWERTY) — shift 0.5
{ id: "q", label: "Q", value: "q" },
{ id: "w", label: "W", value: "w" },
{ id: "e", label: "E", value: "e" },
{ id: "r", label: "R", value: "r" },
{ id: "t", label: "T", value: "t" },
{ id: "y", label: "Y", value: "y" },
{ id: "u", label: "U", value: "u" },
{ id: "i", label: "I", value: "i" },
{ id: "o", label: "O", value: "o" },
{ id: "p", label: "P", value: "p" },
// Row 2 (ASDF) — shift 1.0
{ id: "a", label: "A", value: "a" },
{ id: "s", label: "S", value: "s" },
{ id: "d", label: "D", value: "d" },
{ id: "f", label: "F", value: "f" },
{ id: "g", label: "G", value: "g" },
{ id: "h", label: "H", value: "h" },
{ id: "j", label: "J", value: "j" },
{ id: "k", label: "K", value: "k" },
{ id: "l", label: "L", value: "l" },
// Row 3 (ZXCV + Space + Close) — shift 1.5
{ id: "z", label: "Z", value: "z" },
{ id: "x", label: "X", value: "x" },
{ id: "c", label: "C", value: "c" },
{ id: "v", label: "V", value: "v" },
{ id: "b", label: "B", value: "b" },
{ id: "n", label: "N", value: "n" },
{ id: "m", label: "M", value: "m" },
{ id: "sp", label: "\u2423", value: " ", bg: "yellow" },
{ id: "cls", label: "Close", action: "close", bg: "red" },
]
const ROW_0_START = 0
const ROW_0_COUNT = 11
const ROW_1_START = 11
const ROW_1_COUNT = 10
const ROW_2_START = 21
const ROW_2_COUNT = 9
const ROW_3_START = 30
const ROW_3_COUNT = 9
function getCoord(index: number): { row: number; col: number } {
if (index >= ROW_0_START && index < ROW_0_START + ROW_0_COUNT) {
return { row: 0, col: index - ROW_0_START }
}
if (index >= ROW_1_START && index < ROW_1_START + ROW_1_COUNT) {
return { row: 1, col: index - ROW_1_START }
}
if (index >= ROW_2_START && index < ROW_2_START + ROW_2_COUNT) {
return { row: 2, col: index - ROW_2_START }
}
if (index >= ROW_3_START && index < ROW_3_START + ROW_3_COUNT) {
return { row: 3, col: index - ROW_3_START }
}
return { row: 0, col: 0 }
}
function getRowRange(row: number): { start: number; count: number } {
switch (row) {
case 0:
return { start: ROW_0_START, count: ROW_0_COUNT }
case 1:
return { start: ROW_1_START, count: ROW_1_COUNT }
case 2:
return { start: ROW_2_START, count: ROW_2_COUNT }
case 3:
return { start: ROW_3_START, count: ROW_3_COUNT }
default:
return { start: 0, count: 0 }
}
}
function findIndexAt(row: number, col: number): number | null {
for (let i = 0; i < layoutKeys.length; i++) {
const c = getCoord(i)
if (c.row === row && c.col === col) return i
}
return null
}
function setKeyRef(el: HTMLElement | null, index: number) {
keyRefs.value[index] = el
}
function triggerPress(index: number) {
pressedTimestamps.value.set(index, Date.now())
const existing = pressedTimeouts.get(index)
if (existing) clearTimeout(existing)
const timeout = setTimeout(() => {
pressedTimestamps.value.delete(index)
pressedTimeouts.delete(index)
}, 1000)
pressedTimeouts.set(index, timeout)
// Restart CSS animation immediately by touching the DOM directly
const el = keyRefs.value[index]
const label = el?.querySelector(".hex-key-label") as HTMLElement | null
if (label) {
label.style.animation = "none"
// Force reflow
void label.offsetWidth
label.style.animation = ""
}
}
function handleKeyClick(key: KeyDef) {
const index = layoutKeys.findIndex((k) => k.id === key.id)
if (index !== -1) triggerPress(index)
if (key.action === "backspace") {
emit("update:modelValue", props.modelValue.slice(0, -1))
return
}
if (key.action === "space") {
emit("update:modelValue", props.modelValue + " ")
return
}
if (key.action === "close") {
close()
return
}
if (key.value) {
emit("update:modelValue", props.modelValue + key.value)
}
}
function activateKeyById(keyId: string): boolean {
const key = layoutKeys.find((candidate) => candidate.id === keyId)
if (!key) return false
handleKeyClick(key)
return true
}
function close() {
emit("close")
}
function findNext(currentIdx: number, direction: "up" | "down" | "left" | "right"): number | null {
const current = getCoord(currentIdx)
if (direction === "left") {
const targetCol = current.col - 1
if (targetCol < 0) {
const range = getRowRange(current.row)
return range.start + range.count - 1
}
let next = findIndexAt(current.row, targetCol)
if (next !== null) return next
if (current.row > 0) {
next = findIndexAt(current.row - 1, targetCol)
if (next !== null) return next
}
if (current.row < 3) {
next = findIndexAt(current.row + 1, targetCol)
if (next !== null) return next
}
return null
}
if (direction === "right") {
const targetCol = current.col + 1
const range = getRowRange(current.row)
if (targetCol >= range.count) {
return range.start
}
let next = findIndexAt(current.row, targetCol)
if (next !== null) return next
if (current.row > 0) {
next = findIndexAt(current.row - 1, targetCol)
if (next !== null) return next
}
if (current.row < 3) {
next = findIndexAt(current.row + 1, targetCol)
if (next !== null) return next
}
return null
}
if (direction === "up") {
const targetRow = current.row - 1
if (targetRow < 0) return null
let next = findIndexAt(targetRow, current.col)
if (next !== null) return next
next = findIndexAt(targetRow, current.col - 1)
if (next !== null) return next
next = findIndexAt(targetRow, current.col + 1)
if (next !== null) return next
return null
}
if (direction === "down") {
const targetRow = current.row + 1
if (targetRow > 3) return null
let next = findIndexAt(targetRow, current.col)
if (next !== null) return next
next = findIndexAt(targetRow, current.col - 1)
if (next !== null) return next
next = findIndexAt(targetRow, current.col + 1)
if (next !== null) return next
return null
}
return null
}
function handleKeyDown(e: KeyboardEvent) {
const direction = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
}[e.key] as "up" | "down" | "left" | "right" | undefined
if (!direction) {
if (e.key === "Enter" && focusedIndex.value !== null) {
e.preventDefault()
e.stopPropagation()
handleKeyClick(layoutKeys[focusedIndex.value])
}
return
}
const current = focusedIndex.value ?? 0
const next = findNext(current, direction)
if (next !== null && keyRefs.value[next]) {
e.preventDefault()
e.stopPropagation()
focusedIndex.value = next
}
}
// Compute focus outline position from focused key element
const focusSvgStyle = computed(() => {
const idx = focusedIndex.value
if (idx === null || !keyRefs.value[idx] || !gridRef.value) return {}
const el = keyRefs.value[idx]!
const grid = gridRef.value
const gridRect = grid.getBoundingClientRect()
const elRect = el.getBoundingClientRect()
return {
left: `${(elRect.left - gridRect.left) / 0.8}px`,
top: `${(elRect.top - gridRect.top) / 0.8}px`,
width: `${elRect.width / 0.8}px`,
height: `${elRect.height / 0.8}px`,
}
})
// Position keyboard under search input
function updatePosition() {
if (!keyboardRef.value || !props.searchRef) return
const searchRect = props.searchRef.getBoundingClientRect()
const keyboardEl = keyboardRef.value
keyboardEl.style.left = `${searchRect.left + searchRect.width / 2}px`
keyboardEl.style.top = `${searchRect.bottom + 8}px`
keyboardEl.style.bottom = "auto"
keyboardEl.style.transform = "translateX(-50%) scale(0.8)"
}
// Track focus loss to close keyboard
function onFocusIn(event: FocusEvent) {
if (!props.visible) return
const target = event.target as HTMLElement | null
if (keyboardRef.value && !keyboardRef.value.contains(target)) {
close()
}
}
// Initial focus on 'A' (index 21)
watch(
() => props.visible,
(visible) => {
if (visible) {
nextTick(() => {
focusedIndex.value = 21
updatePosition()
})
} else {
focusedIndex.value = null
}
},
)
function onGamepadAction(event: Event) {
if (!props.visible) return
const customEvent = event as CustomEvent<{ action?: string }>
const action = customEvent.detail?.action
if (!action) return
if (action === "select") {
event.preventDefault()
if (focusedIndex.value !== null) {
triggerPress(focusedIndex.value)
handleKeyClick(layoutKeys[focusedIndex.value])
}
return
}
if (action === "back") {
event.preventDefault()
close()
return
}
const direction = {
up: "up",
down: "down",
left: "left",
right: "right",
}[action] as "up" | "down" | "left" | "right" | undefined
if (direction && focusedIndex.value !== null) {
event.preventDefault()
const next = findNext(focusedIndex.value, direction)
if (next !== null && keyRefs.value[next]) {
focusedIndex.value = next
}
}
}
// Custom handler for X (backspace), Y (space) and shoulder buttons (LB/RB)
function onRawGamepad(event: Event) {
if (!props.visible) return
const customEvent = event as CustomEvent<{ action?: string; button?: number }>
const button = customEvent.detail?.button
if (button === undefined) return
// X button = backspace (button 2)
if (button === 2) {
event.preventDefault()
activateKeyById("bs")
return
}
// Y button = space (button 3)
if (button === 3) {
event.preventDefault()
activateKeyById("sp")
return
}
// LB = move cursor left in search input (button 4)
if (button === 4) {
event.preventDefault()
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }))
return
}
// RB = move cursor right in search input (button 5)
if (button === 5) {
event.preventDefault()
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))
return
}
}
onMounted(() => {
window.addEventListener("mediahive:gamepad-action", onGamepadAction)
window.addEventListener("mediahive:gamepad-button", onRawGamepad)
document.addEventListener("focusin", onFocusIn)
window.addEventListener("resize", updatePosition)
})
onUnmounted(() => {
window.removeEventListener("mediahive:gamepad-action", onGamepadAction)
window.removeEventListener("mediahive:gamepad-button", onRawGamepad)
document.removeEventListener("focusin", onFocusIn)
window.removeEventListener("resize", updatePosition)
})
</script>
<style scoped>
.hex-keyboard {
position: fixed;
z-index: 3000;
transform-origin: top center;
}
.hex-keyboard-grid {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
--key-h: 56px;
--key-w: calc(0.866 * var(--key-h));
}
.hex-keyboard-row {
display: flex;
}
/* Overlap: each row overlaps the previous by 0.25 * key-h */
.hex-keyboard-row:not(:first-child) {
margin-top: calc(var(--key-h) * -0.25);
}
/* Row horizontal offsets for honeycomb staggering */
.hex-keyboard-row-0 {
margin-left: 0;
}
.hex-keyboard-row-1 {
margin-left: calc(var(--key-w) * 0.5);
}
.hex-keyboard-row-2 {
margin-left: calc(var(--key-w) * 1);
}
.hex-keyboard-row-3 {
margin-left: calc(var(--key-w) * 1.5);
}
.hex-key {
position: relative;
height: var(--key-h);
width: var(--key-w);
border: none;
color: #ffffff;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
transition:
transform 0.15s ease,
color 0.3s ease;
outline: none;
transform: scale(0.97);
}
/* Row-based greyscale gradients — lighter top, darker home row */
.hex-key-row-0 {
background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%);
}
.hex-key-row-1,
.hex-key-row-3 {
background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%);
}
.hex-key-row-2 {
background: linear-gradient(180deg, #1f2937d0 0%, #111827d0 100%);
}
/* No hover/active scale — keyboard is gamepad-operated */
/* Keypress feedback: instant green, then exponential fade to white */
.hex-key-pressed .hex-key-label {
animation: hex-label-fade 1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes hex-label-fade {
0% {
color: #22c55e;
}
100% {
color: #ffffff;
}
}
/* Special key backgrounds override row gradients */
.hex-key-blue {
background: linear-gradient(180deg, #60a5fad0 0%, #3b82f6d0 100%);
}
.hex-key-yellow {
background: linear-gradient(180deg, #facc15d0 0%, #eab308d0 100%);
}
.hex-key-red {
background: linear-gradient(180deg, #f87171d0 0%, #ef4444d0 100%);
}
.hex-key-label {
position: relative;
z-index: 2;
pointer-events: none;
user-select: none;
}
.hex-key-label-large {
font-size: 2em;
line-height: 1;
margin-top: -0.15em;
}
/* Green focus outline rendered separately on top of the grid */
.hex-key-focus {
position: absolute;
pointer-events: none;
z-index: 20;
overflow: visible;
}
.hex-key-focus polygon {
fill: none;
stroke: #22c55e;
stroke-width: 3;
vector-effect: non-scaling-stroke;
animation: hex-outline-blink 1s ease-in-out infinite;
}
@keyframes hex-outline-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* Transition */
.hex-keyboard-fade-enter-active,
.hex-keyboard-fade-leave-active {
transition: opacity 0.25s ease;
}
.hex-keyboard-fade-enter-from,
.hex-keyboard-fade-leave-to {
opacity: 0;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<template>
<div v-if="hasContent" class="language-flags" :class="{ compact }">
<span v-if="label" class="language-flags-label">{{ label }}</span>
<span class="language-flag-list">
<span
v-for="entry in flagEntries"
:key="entry.countryCode"
class="language-flag"
:title="formatLanguageFlagTitle(entry, externalCodes)"
v-html="entry.svg"
></span>
<span
v-for="code in unmappedCodes"
:key="`raw-${code}`"
class="language-code-fallback"
:title="code"
>{{ code }}</span
>
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { buildLanguageFlags, formatLanguageFlagTitle } from "../utils/languageFlags"
const props = defineProps<{
label?: string
codes: string[] | null | undefined
externalCodes?: string[] | null
compact?: boolean
}>()
const mapped = computed(() => buildLanguageFlags(props.codes))
const flagEntries = computed(() => mapped.value.flags)
const unmappedCodes = computed(() => mapped.value.unmappedCodes)
const hasContent = computed(() => flagEntries.value.length > 0 || unmappedCodes.value.length > 0)
const compact = computed(() => props.compact === true)
</script>
<style scoped>
.language-flags {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
white-space: nowrap;
vertical-align: middle;
}
.language-flags-label {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.7);
flex: 0 0 auto;
}
.language-flag-list {
display: inline-flex;
align-items: center;
gap: 0.25rem;
min-width: 0;
flex-wrap: nowrap;
white-space: nowrap;
}
.language-flag {
display: inline-flex;
flex: 0 0 auto;
width: 18px;
height: 12px;
border-radius: 2px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.28);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35) inset;
}
.language-flag :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
.language-code-fallback {
flex: 0 0 auto;
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 3px;
padding: 0.08rem 0.25rem;
text-transform: uppercase;
}
.language-flags.compact .language-flags-label {
font-size: 0.58rem;
}
.language-flags.compact .language-flag {
width: 16px;
height: 11px;
}
.language-flags.compact .language-code-fallback {
font-size: 0.52rem;
}
</style>
+177 -100
View File
@@ -1,25 +1,28 @@
<template> <template>
<div <component
:is="href ? 'a' : 'div'"
class="media-card" class="media-card"
v-bind="navAttributes" v-bind="navAttributes"
:data-item-id="item.id" :data-item-id="item.id"
@click="$emit('click')" :data-item-type="item.type"
:href="href || undefined"
@click="handleClick"
@keydown.enter.prevent="$emit('click')" @keydown.enter.prevent="$emit('click')"
> >
<div class="media-card-poster"> <div class="media-card-poster">
<!-- SVG focus outline -->
<svg class="card-focus-outline" viewBox="0 0 100 150" preserveAspectRatio="none"> <svg class="card-focus-outline" viewBox="0 0 100 150" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="150" /> <rect x="0" y="0" width="100" height="150" />
</svg> </svg>
<img <img
v-if="coverUrl && !imageError" v-if="posterImageUrl && !imageError"
:src="coverUrl" :src="posterImageUrl"
:alt="item.title || 'Unknown'" :alt="item.title || 'Unknown'"
loading="lazy" loading="lazy"
decoding="async"
@error="imageError = true" @error="imageError = true"
/> />
<div v-else class="media-card-placeholder"> <div v-else class="media-card-placeholder">
{{ item.type === 'movies' ? '🎬' : item.type === 'episode' ? '📺' : '📺' }} {{ item.type === "movies" ? "🎬" : item.type === "episode" ? "📺" : "📺" }}
</div> </div>
<div v-if="rating" class="media-card-rating" :class="ratingClass"> <div v-if="rating" class="media-card-rating" :class="ratingClass">
{{ rating.toFixed(1) }} {{ rating.toFixed(1) }}
@@ -30,145 +33,202 @@
<span class="media-card-title">{{ displayTitle }}</span> <span class="media-card-title">{{ displayTitle }}</span>
<span v-if="item.year" class="media-card-year">{{ item.year }}</span> <span v-if="item.year" class="media-card-year">{{ item.year }}</span>
</div> </div>
<!-- Search match info (when searching) -->
<template v-if="item.searchMatchInfo"> <template v-if="item.searchMatchInfo">
<div v-if="matchedPeople && matchedPeople.length > 0" class="media-card-detail match-reason"> <div
v-if="matchedPeople && matchedPeople.length > 0"
class="media-card-detail match-reason"
>
<template v-for="(person, idx) in matchedPeople" :key="person.name"> <template v-for="(person, idx) in matchedPeople" :key="person.name">
<span :class="person.highlightRoles ? 'match-dim' : 'match-name'">{{ person.name }}</span> <span :class="person.highlightRoles ? 'match-dim' : 'match-name'">{{
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'">({{ person.roles }})</span><span v-if="idx < matchedPeople.length - 1">, </span> person.name
}}</span>
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'"
>({{ person.roles }})</span
><span v-if="idx < matchedPeople.length - 1">, </span>
</template> </template>
</div> </div>
<div v-if="item.searchMatchInfo.matchedEpisodes && item.searchMatchInfo.matchedEpisodes.length > 0" class="media-card-episodes"> <div
<div v-for="ep in item.searchMatchInfo.matchedEpisodes.slice(0, 3)" :key="ep.name" class="matched-episode"> v-if="
item.searchMatchInfo.matchedEpisodes && item.searchMatchInfo.matchedEpisodes.length > 0
"
class="media-card-episodes"
>
<div
v-for="ep in item.searchMatchInfo.matchedEpisodes.slice(0, 3)"
:key="ep.name"
class="matched-episode"
>
<span class="match-name">{{ ep.name }}</span> <span class="match-name">{{ ep.name }}</span>
<span class="match-roles"> ({{ ep.location }})</span> <span class="match-roles"> ({{ ep.location }})</span>
</div> </div>
<div v-if="item.searchMatchInfo.matchedEpisodes.length > 3" class="matched-episode-more">+{{ item.searchMatchInfo.matchedEpisodes.length - 3 }} more</div> <div v-if="item.searchMatchInfo.matchedEpisodes.length > 3" class="matched-episode-more">
+{{ item.searchMatchInfo.matchedEpisodes.length - 3 }} more
</div>
</div> </div>
</template> </template>
<!-- Default display (browsing) -->
<template v-else> <template v-else>
<div v-if="subtitle" class="media-card-detail">{{ subtitle }}</div> <div
<div v-if="directorAndCast" class="media-card-detail"> v-if="item.type === 'series' && formattedSeriesCreators"
<span v-if="director" class="director-name">{{ director }}</span><span v-if="director && filteredCastNames">, </span>{{ filteredCastNames }} class="media-card-detail person-list"
>
<span
v-for="(creatorName, creatorIndex) in formattedSeriesCreators"
:key="`${creatorName}-${creatorIndex}`"
class="person-token"
>{{ creatorName }}</span
>
</div>
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
<div v-if="directorAndCast" class="media-card-detail person-list">
<span v-if="director" class="director-name person-token">{{
formatPersonLabel(director)
}}</span>
<span
v-for="(castName, castIndex) in formattedCastNames"
:key="`${castName}-${castIndex}`"
class="person-token"
>{{ castName }}</span
>
</div> </div>
</template> </template>
</div> </div>
</div> </component>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from "vue"
import type { MediaItem, Movie, Series, EpisodeWithSeries } from '../types'; import type { MediaItem, Movie, Series, EpisodeWithSeries } from "../types"
import { getCoverUrl } from '../api'; import { getCoverUrl, isVideoPath } from "../api"
import { navAttrs } from '../composables/useKeyboardNavigation'; import { navAttrs } from "../composables/useKeyboardNavigation"
const props = defineProps<{ const props = defineProps<{
item: MediaItem; item: MediaItem
navRow?: number; navRow?: number
navCol?: number; navCol?: number
}>(); href?: string
}>()
defineEmits<{ const emit = defineEmits<{
click: []; click: []
}>(); }>()
function handleClick(event: MouseEvent) {
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
return
}
// Prevent default navigation for plain left-clicks and synthetic clicks
// so that parent handlers can manage side-effects and routing
event.preventDefault()
emit("click")
}
// Navigation attributes for keyboard navigation
const navAttributes = computed(() => { const navAttributes = computed(() => {
if (props.navRow !== undefined && props.navCol !== undefined) { if (props.navRow !== undefined && props.navCol !== undefined) {
return navAttrs(props.navRow, props.navCol); return navAttrs(props.navRow, props.navCol)
} }
return {}; return {}
}); })
const imageError = ref(false); const imageError = ref(false)
const coverUrl = computed(() => { const posterImageUrl = computed(() => {
if (imageError.value) return null; if (imageError.value) return null
return getCoverUrl(props.item.cover_path); if (!props.item.cover_path || isVideoPath(props.item.cover_path)) {
}); return null
}
return getCoverUrl(props.item.cover_path, props.item.root_id)
})
const rating = computed(() => { const rating = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === "movies") {
return (props.item.data as Movie).info?.rating; return (props.item.data as Movie).info?.rating
} }
if (props.item.type === 'episode') { if (props.item.type === "episode") {
const epData = props.item.data as EpisodeWithSeries; const epData = props.item.data as EpisodeWithSeries
return epData.episode.rating ?? epData.series.info?.rating; return epData.episode.rating ?? epData.series.info?.rating
} }
return (props.item.data as Series).info?.rating; return (props.item.data as Series).info?.rating
}); })
const ratingClass = computed(() => { const ratingClass = computed(() => {
if (!rating.value) return ''; if (!rating.value) return ""
if (rating.value >= 7.5) return 'rating-high'; if (rating.value >= 7.5) return "rating-high"
if (rating.value >= 6) return 'rating-medium'; if (rating.value >= 6) return "rating-medium"
return 'rating-low'; return "rating-low"
}); })
const displayTitle = computed(() => { const displayTitle = computed(() => {
if (props.item.type === 'episode') { if (props.item.type === "episode") {
const epData = props.item.data as EpisodeWithSeries; const epData = props.item.data as EpisodeWithSeries
return epData.episode.name || `Episode ${epData.episode.episode_number}`; return epData.episode.name || `Episode ${epData.episode.episode_number}`
} }
return props.item.title; return props.item.title
}); })
const subtitle = computed(() => { const subtitle = computed(() => {
if (props.item.type === 'episode') { if (props.item.type === "episode") {
const epData = props.item.data as EpisodeWithSeries; const epData = props.item.data as EpisodeWithSeries
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`; return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`
} }
// For series, show creators return null
if (props.item.type === 'series') { })
const creators = (props.item.data as Series).info?.creators;
return creators && creators.length > 0 ? creators.join(', ') : null; const seriesCreators = computed(() => {
if (props.item.type !== "series") return null
const creators = (props.item.data as Series).info?.creators
return creators && creators.length > 0 ? creators : null
})
function formatPersonLabel(name: string): string {
return name.trim().replace(/\s+/g, "\u202F")
} }
return null;
}); const formattedSeriesCreators = computed(() => {
if (!seriesCreators.value) return null
return seriesCreators.value.map((name) => formatPersonLabel(name))
})
// Director for movies
const director = computed(() => { const director = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== "movies") return null
return (props.item.data as Movie).info?.director; return (props.item.data as Movie).info?.director
}); })
// Check if we have director and/or cast to display
const directorAndCast = computed(() => { const directorAndCast = computed(() => {
if (props.item.type !== 'movies') return false; if (props.item.type !== "movies") return false
return director.value || filteredCastNames.value; return !!director.value || filteredCastNames.value.length > 0
}); })
// Cast names, excluding director if they appear in cast
const filteredCastNames = computed(() => { const filteredCastNames = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== "movies") return []
const cast = (props.item.data as Movie).info?.cast; const cast = (props.item.data as Movie).info?.cast
if (!cast || cast.length === 0) return null; if (!cast || cast.length === 0) return []
const directorName = director.value?.toLowerCase(); const directorName = director.value?.toLowerCase()
const filteredCast = directorName const filteredCast = directorName
? cast.filter(c => c.name.toLowerCase() !== directorName) ? cast.filter((c) => c.name.toLowerCase() !== directorName)
: cast; : cast
if (filteredCast.length === 0) return null; if (filteredCast.length === 0) return []
// Show first 3 cast members return filteredCast.slice(0, 3).map((c) => c.name)
const names = filteredCast.slice(0, 3).map(c => c.name); })
return names.join(', ');
}); const formattedCastNames = computed(() => {
return filteredCastNames.value.map((name) => formatPersonLabel(name))
})
// Matched people from search (from searchMatchInfo)
const matchedPeople = computed(() => { const matchedPeople = computed(() => {
const info = props.item.searchMatchInfo; const info = props.item.searchMatchInfo
if (!info || !info.matchedPeople) return null; if (!info || !info.matchedPeople) return null
return info.matchedPeople; return info.matchedPeople
}); })
</script> </script>
<style scoped> <style scoped>
/* Blinking animation for focus outline */
@keyframes card-outline-blink { @keyframes card-outline-blink {
0%, 100% { 0%,
100% {
opacity: 1; opacity: 1;
} }
50% { 50% {
@@ -176,7 +236,6 @@ const matchedPeople = computed(() => {
} }
} }
/* SVG focus outline styles */
.card-focus-outline { .card-focus-outline {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -195,15 +254,21 @@ const matchedPeople = computed(() => {
vector-effect: non-scaling-stroke; vector-effect: non-scaling-stroke;
} }
/* Show outline on hover and focus */ .media-card-poster img,
.media-card:hover .card-focus-outline, .media-card-poster video {
.media-card.nav-focused .card-focus-outline { display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
html.mouse-active .media-card:hover .card-focus-outline,
html:not(.mouse-active) .media-card.nav-focused .card-focus-outline {
opacity: 1; opacity: 1;
animation: card-outline-blink 1s ease-in-out infinite; animation: card-outline-blink 1s ease-in-out infinite;
} }
/* Brighter outline for keyboard focus */ html:not(.mouse-active) .media-card.nav-focused .card-focus-outline rect {
.media-card.nav-focused .card-focus-outline rect {
stroke: #ffffff; stroke: #ffffff;
stroke-width: 5; stroke-width: 5;
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8)); filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
@@ -236,7 +301,6 @@ const matchedPeople = computed(() => {
font-size: 0.65rem; font-size: 0.65rem;
color: var(--text-muted); color: var(--text-muted);
margin-top: 1px; margin-top: 1px;
/* Allow up to 2 lines with ellipsis */
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@@ -244,12 +308,26 @@ const matchedPeople = computed(() => {
line-height: 1.3; line-height: 1.3;
} }
.media-card-detail.person-list {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
row-gap: 1px;
column-gap: 0.5ch;
-webkit-line-clamp: unset;
-webkit-box-orient: unset;
max-height: calc(1.3em * 2);
}
.person-token {
white-space: nowrap;
}
.director-name { .director-name {
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Search match styles */
.match-reason { .match-reason {
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -264,7 +342,6 @@ const matchedPeople = computed(() => {
font-weight: 400; font-weight: 400;
} }
/* When character name matched - highlight the role, dim the name */
.match-dim { .match-dim {
color: var(--text-muted); color: var(--text-muted);
font-weight: 400; font-weight: 400;
File diff suppressed because it is too large Load Diff
+18 -8
View File
@@ -3,6 +3,7 @@
class="media-row" class="media-row"
:class="{ 'media-row-wrap': wrap }" :class="{ 'media-row-wrap': wrap }"
:data-sync-scroll-row="!wrap && rowIndex !== undefined ? 'true' : undefined" :data-sync-scroll-row="!wrap && rowIndex !== undefined ? 'true' : undefined"
:data-sync-scroll-group="!wrap && rowIndex !== undefined ? 'browse' : undefined"
> >
<MediaCard <MediaCard
v-for="(item, index) in items" v-for="(item, index) in items"
@@ -10,22 +11,31 @@
:item="item" :item="item"
:nav-row="rowIndex" :nav-row="rowIndex"
:nav-col="index" :nav-col="index"
:href="getItemHref(item)"
@click="$emit('select', item)" @click="$emit('select', item)"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MediaItem } from '../types'; import type { MediaItem, EpisodeWithSeries } from "../types"
import MediaCard from './MediaCard.vue'; import MediaCard from "./MediaCard.vue"
defineProps<{ defineProps<{
items: MediaItem[]; items: MediaItem[]
wrap?: boolean; wrap?: boolean
rowIndex?: number; rowIndex?: number
}>(); }>()
defineEmits<{ defineEmits<{
select: [MediaItem]; select: [MediaItem]
}>(); }>()
function getItemHref(item: MediaItem): string | undefined {
if (item.type === "episode") {
const epData = item.data as EpisodeWithSeries
return `/series/${epData.series.id}`
}
return `/${item.type}/${item.id}`
}
</script> </script>
@@ -0,0 +1,232 @@
<template>
<div
v-if="visible"
ref="menuRef"
class="version-action-menu"
:style="menuStyle"
tabindex="-1"
@keydown="handleKeydown"
>
<div class="version-action-path" :title="resolvedPath">
{{ resolvedPath }}
</div>
<button class="version-action-item" :disabled="disabled" @click="emit('play')">
<span class="version-action-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" focusable="false">
<path
d="M4 3.2c0-.54.6-.86 1.05-.56l6.2 4.14a.67.67 0 0 1 0 1.12l-6.2 4.14A.67.67 0 0 1 4 11.44V3.2Z"
/>
</svg>
</span>
{{ playLabel }}
</button>
<button class="version-action-item" :disabled="disabled" @click="emit('openFolder')">
<span class="version-action-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" focusable="false">
<path
d="M1.4 4.3c0-.72.58-1.3 1.3-1.3h3.55c.3 0 .58.13.77.35l.72.85h5.56c.72 0 1.3.58 1.3 1.3v.92H1.4V4.3Zm0 3.22h13.2v4.2c0 .72-.58 1.3-1.3 1.3H2.7c-.72 0-1.3-.58-1.3-1.3v-4.2Z"
/>
</svg>
</span>
Open Folder
</button>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
const props = withDefaults(
defineProps<{
visible: boolean
x: number
y: number
filePath: string | null
rootName?: string | null
playLabel?: string
}>(),
{
rootName: null,
playLabel: "Play",
},
)
const emit = defineEmits<{
play: []
openFolder: []
close: []
}>()
const menuRef = ref<HTMLElement | null>(null)
const menuLeft = ref(0)
const menuTop = ref(0)
const VIEWPORT_MARGIN = 12
function toPosixPath(value: string | null | undefined): string {
return (value || "").replace(/\\/g, "/")
}
const resolvedPath = computed(() => {
if (!props.filePath) return "No playable file"
const normalizedFilePath = toPosixPath(props.filePath)
const rootName = toPosixPath((props.rootName || "").trim())
if (!rootName) return normalizedFilePath
return `${rootName}/${normalizedFilePath}`
})
const menuStyle = computed(() => ({
left: `${menuLeft.value}px`,
top: `${menuTop.value}px`,
}))
const disabled = computed(() => !props.filePath)
function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return []
return Array.from(
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)"),
)
}
function focusNext(delta: number) {
const elements = getFocusableElements()
if (elements.length === 0) return
const currentIndex = elements.findIndex((el) => el === document.activeElement)
const nextIndex =
currentIndex < 0 ? 0 : (currentIndex + delta + elements.length) % elements.length
elements[nextIndex].focus()
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Tab") {
event.preventDefault()
focusNext(event.shiftKey ? -1 : 1)
return
}
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
event.preventDefault()
focusNext(1)
return
}
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
event.preventDefault()
focusNext(-1)
return
}
if (event.key === "Escape") {
event.preventDefault()
emit("close")
}
}
function clampToViewport() {
const menu = menuRef.value
if (!menu) return
const width = menu.offsetWidth
const height = menu.offsetHeight
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN)
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft)
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop)
}
function handleViewportChange() {
if (!props.visible) return
clampToViewport()
}
watch(
() => [props.visible, props.x, props.y, resolvedPath.value],
async ([visible]) => {
if (!visible) return
await nextTick()
clampToViewport()
},
{ immediate: true },
)
watch(
() => props.visible,
(visible) => {
if (visible) {
window.addEventListener("resize", handleViewportChange)
return
}
window.removeEventListener("resize", handleViewportChange)
},
{ immediate: true },
)
onBeforeUnmount(() => {
window.removeEventListener("resize", handleViewportChange)
})
</script>
<style scoped>
.version-action-menu {
position: fixed;
z-index: 1001;
min-width: 260px;
max-width: min(680px, calc(100vw - 24px));
background: rgba(18, 20, 28, 0.98);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
overflow: hidden;
}
.version-action-path {
padding: 8px 12px;
font-size: 0.74rem;
line-height: 1.35;
color: rgba(255, 255, 255, 0.78);
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
word-break: break-all;
white-space: normal;
}
.version-action-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #fff;
text-align: left;
padding: 10px 12px;
font-size: 0.82rem;
cursor: pointer;
}
html.mouse-active .version-action-item:hover:not(:disabled),
.version-action-item:focus-visible:not(:disabled) {
background: rgba(255, 255, 255, 0.12);
outline: none;
}
.version-action-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.version-action-icon {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.85);
flex: 0 0 16px;
}
.version-action-icon svg {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>
@@ -0,0 +1,660 @@
<template>
<div
class="version-row"
:class="{
'version-best': best,
'version-selectable': isSelectable && !inertCard,
'version-disabled': isDisabled,
'version-menu': variant === 'menu',
'version-with-actions': showActions,
'version-inert': inertCard,
}"
:tabindex="inertCard ? undefined : 0"
:title="resolvedTitle"
v-bind="$attrs"
@click="handleActivate"
@keydown.enter.prevent="handleActivate"
@keydown.space.prevent="handleActivate"
>
<div class="version-main">
<div class="version-badges">
<span v-if="torrent.resolution" class="v-badge res">{{ torrent.resolution }}</span>
<span v-if="showHdrBadge" class="v-badge hdr">HDR</span>
<span v-if="displayCodecBadge" class="v-badge codec">{{ displayCodecBadge }}</span>
<span v-if="displayQualityBadge" class="v-badge qual">{{ displayQualityBadge }}</span>
<span v-if="displayAudioBadge" class="v-badge audio">{{ displayAudioBadge }}</span>
<span v-if="displayReleaseGroupBadge" class="v-badge group">{{
displayReleaseGroupBadge
}}</span>
</div>
<div class="version-language-flags">
<LanguageFlags
class="language-flags-audio"
:codes="torrent.audio_languages"
:compact="compactFlags"
/>
<span
v-if="
hasLanguageDisplay(torrent.audio_languages) &&
hasLanguageDisplay(torrent.subtitle_languages)
"
class="language-separator"
>•</span
>
<LanguageFlags
class="language-flags-subs"
:codes="torrent.subtitle_languages"
:external-codes="torrent.external_subtitle_languages"
:compact="compactFlags"
/>
</div>
</div>
<div class="version-dolby-cell">
<img v-if="showBlurayLogo" class="version-disc-logo" :src="blurayLogoUrl" alt="Blu-ray" />
<img v-else-if="showDvdLogo" class="version-disc-logo" :src="dvdLogoUrl" alt="DVD" />
<img
v-if="streamingServiceLogo"
class="version-service-logo"
:src="streamingServiceLogo.src"
:alt="streamingServiceLogo.alt"
:title="streamingServiceLogo.alt"
/>
<img
v-if="showHdr10PlusLogo"
class="version-hdr10plus-logo"
:src="hdr10plusLogoUrl"
alt="HDR10+"
/>
<DolbyBadges
class="version-dolby"
:has-dolby-vision="hasDolbyVision"
:has-dolby-atmos="hasDolbyAtmos"
:is-hdr="hasHdr"
/>
</div>
<div v-if="showActions" class="version-actions">
<button
class="ctx-btn ctx-btn-play"
tabindex="0"
@click.stop="emit('play')"
:disabled="!torrent.playable_file"
:title="playLabel"
>
</button>
<button
class="ctx-btn ctx-btn-folder"
tabindex="0"
@click.stop="emit('openFolder')"
:disabled="!torrent.playable_file"
title="Open Folder"
>
📁
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue"
import type { Torrent } from "../types"
import LanguageFlags from "./LanguageFlags.vue"
import DolbyBadges from "./DolbyBadges.vue"
import { buildLanguageFlags } from "../utils/languageFlags"
import blurayLogoUrl from "../assets/bluray.webp"
import dvdLogoUrl from "../assets/dvd.webp"
import amazonLogoUrl from "../assets/service-amazon.webp"
import appleTvLogoUrl from "../assets/service-apple-tv.webp"
import netflixLogoUrl from "../assets/service-netflix.webp"
import hboMaxLogoUrl from "../assets/service-hbo-max.webp"
import huluLogoUrl from "../assets/service-hulu.webp"
import disneyLogoUrl from "../assets/service-disney.svg"
import itunesLogoUrl from "../assets/service-itunes.png"
import hdr10plusLogoUrl from "../assets/hdr10plus-logo.png"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<{
torrent: Torrent
best?: boolean
selectable?: boolean
disabled?: boolean
compactFlags?: boolean
showActions?: boolean
playLabel?: string
title?: string
variant?: "default" | "menu"
/** When true, the card itself is not interactive (no tabindex, no click/keyboard handlers).
* Use with showActions to make only the inline buttons interactive. */
inertCard?: boolean
}>(),
{
best: false,
selectable: undefined,
disabled: undefined,
compactFlags: false,
showActions: false,
playLabel: "Play",
title: undefined,
variant: "default",
inertCard: false,
},
)
const emit = defineEmits<{
activate: [MouseEvent | KeyboardEvent]
play: []
openFolder: []
}>()
const dolbyTagPattern = /\b(dolby|atmos|vision|dovi|dv)\b/i
const dolbyVisionPattern = /\b(dolby\s*vision|dovi|\bdv\b)\b/i
const dolbyAtmosPattern = /\b(dolby\s*atmos|atmos)\b/i
const hdrPattern = /\bhdr\b|smpte\s*2084|bt\s*2020|hlg/i
const blurayTagPattern = /\bblu[\s.-]*ray\b/i
const blurayPlayablePattern = /(?:^|[\\/])(movieobject|index)\.bdmv$/i
const dvdPlayablePattern = /(?:^|[\\/])video_ts\.ifo$/i
const webQualityPattern = /^web(?:[ .-]?dl|[ .-]?rip)$/i
const serviceLogoMap: Array<{ aliases: string[]; src: string; alt: string }> = [
{
aliases: ["amazon studios", "amazon prime video", "prime video", "amazon", "amzn"],
src: amazonLogoUrl,
alt: "Amazon Prime Video",
},
{
aliases: ["apple tv+", "apple tv plus", "apple tv", "atvp"],
src: appleTvLogoUrl,
alt: "Apple TV+",
},
{ aliases: ["itunes", "it"], src: itunesLogoUrl, alt: "iTunes" },
{ aliases: ["netflix", "nf", "nflx"], src: netflixLogoUrl, alt: "Netflix" },
{ aliases: ["hbo max", "max", "hmax"], src: hboMaxLogoUrl, alt: "HBO Max" },
{
aliases: ["disney plus", "disney+", "disney plus hotstar", "dsnp"],
src: disneyLogoUrl,
alt: "Disney+",
},
{ aliases: ["hulu"], src: huluLogoUrl, alt: "Hulu" },
]
function hasDolbyTag(value: string | null | undefined): boolean {
return Boolean(value && dolbyTagPattern.test(value))
}
function hasAnyTag(pattern: RegExp, ...values: Array<string | null | undefined>): boolean {
return values.some((value) => Boolean(value && pattern.test(value)))
}
function hasLanguageDisplay(codes: string[] | null | undefined): boolean {
const mapped = buildLanguageFlags(codes)
return mapped.flags.length > 0 || mapped.unmappedCodes.length > 0
}
function hasHdrTag(value: string | null | undefined): boolean {
return Boolean(value && hdrPattern.test(value))
}
function normalizeProviderName(value: string | null | undefined): string {
return (value || "")
.toLowerCase()
.replace(/[^a-z0-9+]+/g, " ")
.trim()
}
function normalizeQualityBadge(value: string): string {
const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "")
if (
normalized === "hdtv" ||
normalized === "pdtv" ||
normalized === "tvrip" ||
normalized === "sdtv"
) {
return "TV"
}
if (
normalized.includes("cam") ||
normalized === "telesync" ||
normalized === "ts" ||
normalized === "hdts" ||
normalized === "telecine" ||
normalized === "tc"
) {
return "CAM"
}
return value
}
const hasDolbyVision = computed(() => {
return (
props.torrent.dovi === true ||
hasAnyTag(
dolbyVisionPattern,
props.torrent.quality,
props.torrent.codec,
props.torrent.audio,
props.torrent.title,
)
)
})
const hasDolbyAtmos = computed(() => {
return (
props.torrent.atmos === true ||
hasAnyTag(
dolbyAtmosPattern,
props.torrent.quality,
props.torrent.codec,
props.torrent.audio,
props.torrent.title,
)
)
})
const hasHdr = computed(() => {
return (
props.torrent.hdr === true ||
hasAnyTag(
hdrPattern,
props.torrent.quality,
props.torrent.codec,
props.torrent.audio,
props.torrent.title,
)
)
})
const isBlurayDisc = computed(() => {
if (!props.torrent.playable_file) return false
return blurayPlayablePattern.test(props.torrent.playable_file)
})
const isDvdDisc = computed(() => {
if (!props.torrent.playable_file) return false
return dvdPlayablePattern.test(props.torrent.playable_file)
})
const showBlurayLogo = computed(() => {
return isBlurayDisc.value
})
const showDvdLogo = computed(() => {
return isDvdDisc.value
})
const streamingServiceLogo = computed(() => {
if (!props.torrent.quality || !webQualityPattern.test(props.torrent.quality)) {
return null
}
const network = normalizeProviderName(props.torrent.network)
if (!network) return null
const found = serviceLogoMap.find((entry) => entry.aliases.includes(network))
return found ? { src: found.src, alt: found.alt } : null
})
const displayQualityBadge = computed(() => {
if (!props.torrent.quality || hasDolbyTag(props.torrent.quality)) return null
if (streamingServiceLogo.value) return null
if (blurayTagPattern.test(props.torrent.quality)) return null
return normalizeQualityBadge(props.torrent.quality)
})
const displayCodecBadge = computed(() => {
if (!props.torrent.codec || hasDolbyTag(props.torrent.codec)) return null
return props.torrent.codec
})
const displayAudioBadge = computed(() => {
if (!props.torrent.audio || hasDolbyTag(props.torrent.audio)) return null
return props.torrent.audio
})
const displayReleaseGroupBadge = computed(() => {
const value = props.torrent.encoder?.trim()
if (!value) return null
return value
})
const showHdrBadge = computed(() => {
if (!hasHdr.value) return false
return (
!hasHdrTag(props.torrent.quality) &&
!hasHdrTag(props.torrent.codec) &&
!hasHdrTag(props.torrent.audio)
)
})
const hdr10PlusPattern = /hdr10\+|hdr10plus/i
const hasHdr10Plus = computed(() => {
if (props.torrent.hdr10plus) return true
const text = [
props.torrent.title,
props.torrent.quality,
props.torrent.codec,
props.torrent.audio,
]
.filter(Boolean)
.join(" ")
return hdr10PlusPattern.test(text)
})
const showHdr10PlusLogo = computed(() => hasHdr10Plus.value && !hasDolbyVision.value)
const isSelectable = computed(() => {
if (props.selectable !== undefined) return props.selectable
return Boolean(props.torrent.playable_file)
})
const isDisabled = computed(() => {
if (props.disabled !== undefined) return props.disabled
return !isSelectable.value
})
const resolvedTitle = computed(() => {
if (props.title !== undefined) return props.title
return isSelectable.value
? "Click to play/continue. Alt+Click to open folder."
: "No playable file"
})
function handleActivate(event: MouseEvent | KeyboardEvent) {
if (props.inertCard || !isSelectable.value || isDisabled.value) return
emit("activate", event)
}
</script>
<style scoped>
.version-row {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: stretch;
column-gap: 8px;
row-gap: 6px;
padding: 10px 12px;
background: rgba(10, 14, 22, 0.2);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
transition:
background 0.2s,
border-color 0.2s;
}
.version-row.version-menu {
padding: 10px 16px;
}
.version-row.version-with-actions {
grid-template-columns: minmax(0, 1fr) max-content max-content;
}
html.mouse-active .version-row:hover {
background: rgba(10, 14, 22, 0.28);
border-color: rgba(255, 255, 255, 0.2);
}
.version-row.version-best {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(10, 14, 22, 0.2);
}
html.mouse-active .version-row.version-best:hover {
background: rgba(10, 14, 22, 0.28);
border-color: rgba(255, 255, 255, 0.2);
}
.version-row.version-selectable {
cursor: pointer;
}
.version-row.version-selectable:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: 2px;
}
.version-row.version-inert {
cursor: default;
}
.version-row.version-inert .version-main,
.version-row.version-inert .version-dolby-cell {
pointer-events: none;
}
.version-row.version-disabled {
cursor: not-allowed;
opacity: 0.75;
}
.version-main {
grid-column: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
min-width: 0;
}
.version-dolby-cell {
grid-column: 2;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
height: 36px;
min-width: 0;
}
.version-dolby {
align-self: stretch;
height: 100%;
}
.version-disc-logo {
align-self: stretch;
display: block;
width: auto;
height: 100%;
max-height: 100%;
object-fit: contain;
filter: drop-shadow(0 0 0.4px rgba(0, 0, 0, 0.5));
}
.version-badges {
--badge-row-height: 1.2rem;
/* Regular hex geometry: horizontal inset = h / (2 * sqrt(3)) ~= 0.288675 * h */
--badge-hex-inset: calc(var(--badge-row-height) * 0.288675);
--badge-slant: 0.25em;
display: flex;
flex-wrap: nowrap;
align-items: stretch;
gap: 0;
height: var(--badge-row-height);
}
.v-badge {
display: flex;
align-items: center;
height: 100%;
font-size: 0.78rem;
line-height: 1;
padding: 0 0.5em 0 0.3em;
font-weight: 600;
text-transform: uppercase;
margin-left: calc(-1 * var(--badge-slant));
clip-path: polygon(var(--badge-slant) 0, 100% 0, 100% 100%, 0 100%);
}
.v-badge:first-child {
margin-left: 0;
padding-left: 0.5em;
clip-path: polygon(
var(--badge-hex-inset) 0,
100% 0,
100% 100%,
var(--badge-hex-inset) 100%,
0 50%
);
}
.v-badge:last-child {
padding-right: 0.5em;
clip-path: polygon(
var(--badge-slant) 0,
calc(100% - var(--badge-hex-inset)) 0,
100% 50%,
calc(100% - var(--badge-hex-inset)) 100%,
0 100%
);
}
.v-badge:only-child {
margin-left: 0;
padding-left: 6px;
padding-right: calc(6px + var(--badge-hex-inset));
clip-path: polygon(
var(--badge-hex-inset) 0,
calc(100% - var(--badge-hex-inset)) 0,
100% 50%,
calc(100% - var(--badge-hex-inset)) 100%,
var(--badge-hex-inset) 100%,
0 50%
);
}
.version-service-logo {
align-self: stretch;
display: block;
width: auto;
height: 100%;
max-height: 100%;
object-fit: contain;
}
.version-hdr10plus-logo {
align-self: stretch;
display: block;
width: auto;
height: 100%;
max-height: 100%;
object-fit: contain;
}
.v-badge.res {
background: #111111;
color: #f8fafc;
}
.v-badge.qual {
background: #334155;
color: #f1f5f9;
}
.v-badge.codec {
background: #1f2937;
color: #f3f4f6;
}
.v-badge.audio {
background: #475569;
color: #f1f5f9;
}
.v-badge.hdr {
background: #d4a017;
color: #1c1917;
}
.v-badge.group {
background: #1c54a1;
color: #f8fafc;
}
.version-language-flags {
display: inline-flex;
align-items: center;
gap: 2px;
width: 100%;
white-space: nowrap;
overflow: hidden;
min-width: 0;
}
.version-language-flags > .language-flags-audio {
flex: 0 0 auto;
}
.version-language-flags > .language-flags-subs {
flex: 1 1 auto;
min-width: 0;
-webkit-mask-image: linear-gradient(to right, black calc(100% - 14px), transparent);
mask-image: linear-gradient(to right, black calc(100% - 14px), transparent);
}
.version-language-flags > .language-flags-subs :deep(.language-flags) {
display: inline-flex;
max-width: 100%;
overflow: hidden;
}
.version-language-flags > .language-flags-subs :deep(.language-flag-list) {
width: max-content;
max-width: none;
overflow: hidden;
}
.language-separator {
color: rgba(255, 255, 255, 0.8);
font-size: 0.9rem;
font-weight: 700;
line-height: 1;
margin: 0;
}
.version-actions {
grid-column: 3;
display: flex;
align-items: center;
gap: 6px;
}
.ctx-btn {
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.65);
border-radius: 6px;
padding: 4px 8px;
font-size: 2em;
line-height: 1;
cursor: pointer;
transition: color 0.15s ease;
}
html.mouse-active .ctx-btn:hover:not(:disabled),
html:not(.mouse-active) .ctx-btn.nav-focused:not(:disabled),
.ctx-btn:focus-visible:not(:disabled) {
color: #fff;
outline: none;
}
.ctx-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ctx-btn-folder {
width: auto;
text-align: center;
padding: 4px 8px;
}
</style>
File diff suppressed because it is too large Load Diff
+264 -80
View File
@@ -1,16 +1,23 @@
type GamepadAction = 'up' | 'down' | 'left' | 'right' | 'select' | 'back'; type GamepadAction = "up" | "down" | "left" | "right" | "select" | "back" | "menu"
type DirectionAction = "up" | "down" | "left" | "right"
const GAMEPAD_AXIS_THRESHOLD = 0.55; const ANALOG_DEADZONE = 0.25
const GAMEPAD_REPEAT_MS = 180; const DIGITAL_INITIAL_REPEAT_DELAY_MS = 150
const DIGITAL_REPEAT_START_MS = 90
const DIGITAL_REPEAT_MIN_MS = 16
const DIGITAL_ACCEL_RAMP_MS = 3500
const ANALOG_REPEAT_MAX_MS = 180
const ANALOG_REPEAT_MIN_MS = 16
const IDLE_POLL_MS = 500
const KEY_BY_ACTION: Record<GamepadAction, string> = { const KEY_BY_ACTION: Partial<Record<GamepadAction, string>> = {
up: 'ArrowUp', up: "ArrowUp",
down: 'ArrowDown', down: "ArrowDown",
left: 'ArrowLeft', left: "ArrowLeft",
right: 'ArrowRight', right: "ArrowRight",
select: 'Enter', select: "Enter",
back: 'Escape', back: "Escape",
}; }
const gamepadPressedState: Record<GamepadAction, boolean> = { const gamepadPressedState: Record<GamepadAction, boolean> = {
up: false, up: false,
@@ -19,112 +26,289 @@ const gamepadPressedState: Record<GamepadAction, boolean> = {
right: false, right: false,
select: false, select: false,
back: false, back: false,
}; menu: false,
}
const gamepadLastTriggerAt: Record<GamepadAction, number> = { const rawButtonPressedState: Record<number, boolean> = {
2: false,
3: false,
4: false,
5: false,
}
const analogLastTriggerAt: Record<DirectionAction, number> = {
up: 0, up: 0,
down: 0, down: 0,
left: 0, left: 0,
right: 0, right: 0,
select: 0, }
back: 0,
};
let gamepadFrameId: number | null = null; let digitalHoldStartedAt = 0
let gamepadInstalled = false; let digitalLastRepeatAt = 0
let digitalRepeatCount = 0
let gamepadFrameId: number | null = null
let idleTimerId: number | null = null
let gamepadInstalled = false
function dispatchKey(key: string) { function dispatchKey(key: string) {
const active = document.activeElement; const active = document.activeElement
const target = active instanceof HTMLElement ? active : document; const target = active instanceof HTMLElement ? active : document
target.dispatchEvent(new KeyboardEvent('keydown', { target.dispatchEvent(
new KeyboardEvent("keydown", {
key, key,
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
})); }),
)
} }
function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: number) { function dispatchGamepadAction(action: GamepadAction): void {
const wasPressed = gamepadPressedState[action]; const actionEvent = new CustomEvent("mediahive:gamepad-action", {
gamepadPressedState[action] = isPressed;
if (!isPressed) return;
const shouldRepeat = action === 'up' || action === 'down' || action === 'left' || action === 'right';
const canTrigger = !wasPressed || (shouldRepeat && now - gamepadLastTriggerAt[action] >= GAMEPAD_REPEAT_MS);
if (!canTrigger) return;
gamepadLastTriggerAt[action] = now;
const actionEvent = new CustomEvent('mediahive:gamepad-action', {
detail: { action }, detail: { action },
cancelable: true, cancelable: true,
}); })
const shouldContinueWithKeyboard = window.dispatchEvent(actionEvent); const shouldContinueWithKeyboard = window.dispatchEvent(actionEvent)
if (!shouldContinueWithKeyboard) return; if (!shouldContinueWithKeyboard) return
dispatchKey(KEY_BY_ACTION[action]); const key = KEY_BY_ACTION[action]
if (key) {
dispatchKey(key)
}
}
function dispatchRawButton(button: number): void {
const event = new CustomEvent("mediahive:gamepad-button", {
detail: { button },
cancelable: true,
})
window.dispatchEvent(event)
}
function triggerSinglePressAction(action: GamepadAction, isPressed: boolean): void {
const wasPressed = gamepadPressedState[action]
gamepadPressedState[action] = isPressed
if (isPressed && !wasPressed) {
dispatchGamepadAction(action)
}
}
function triggerSinglePressRawButton(button: number, isPressed: boolean): void {
const wasPressed = rawButtonPressedState[button] ?? false
rawButtonPressedState[button] = isPressed
if (isPressed && !wasPressed) {
dispatchRawButton(button)
}
}
function getDigitalRepeatIntervalMs(holdMs: number): number {
const progress = Math.min(Math.max(holdMs, 0), DIGITAL_ACCEL_RAMP_MS) / DIGITAL_ACCEL_RAMP_MS
return Math.round(
DIGITAL_REPEAT_START_MS - (DIGITAL_REPEAT_START_MS - DIGITAL_REPEAT_MIN_MS) * progress,
)
}
function getAnalogRepeatIntervalMs(intensity: number): number {
const normalized = Math.min(Math.max(intensity, 0), 1)
return Math.round(
ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized,
)
}
function normalizeAxisIntensity(rawValue: number): number {
if (rawValue <= ANALOG_DEADZONE) return 0
return Math.min((rawValue - ANALOG_DEADZONE) / (1 - ANALOG_DEADZONE), 1)
}
function applyAnalogDirection(action: DirectionAction, intensity: number, now: number): void {
if (intensity <= 0) {
analogLastTriggerAt[action] = 0
return
}
const last = analogLastTriggerAt[action]
const repeatMs = getAnalogRepeatIntervalMs(intensity)
if (last === 0 || now - last >= repeatMs) {
analogLastTriggerAt[action] = now
dispatchGamepadAction(action)
}
} }
function resetPressedState() { function resetPressedState() {
gamepadPressedState.up = false; gamepadPressedState.up = false
gamepadPressedState.down = false; gamepadPressedState.down = false
gamepadPressedState.left = false; gamepadPressedState.left = false
gamepadPressedState.right = false; gamepadPressedState.right = false
gamepadPressedState.select = false; gamepadPressedState.select = false
gamepadPressedState.back = false; gamepadPressedState.back = false
gamepadPressedState.menu = false
rawButtonPressedState[2] = false
rawButtonPressedState[3] = false
rawButtonPressedState[4] = false
rawButtonPressedState[5] = false
analogLastTriggerAt.up = 0
analogLastTriggerAt.down = 0
analogLastTriggerAt.left = 0
analogLastTriggerAt.right = 0
digitalHoldStartedAt = 0
digitalLastRepeatAt = 0
digitalRepeatCount = 0
}
function stopPolling() {
if (gamepadFrameId !== null) {
window.cancelAnimationFrame(gamepadFrameId)
gamepadFrameId = null
}
if (idleTimerId !== null) {
window.clearTimeout(idleTimerId)
idleTimerId = null
}
}
function scheduleIdlePoll() {
if (idleTimerId !== null) return
idleTimerId = window.setTimeout(() => {
idleTimerId = null
if (gamepadInstalled) {
pollGamepad()
}
}, IDLE_POLL_MS)
} }
function pollGamepad() { function pollGamepad() {
const gamepads = navigator.getGamepads?.() ?? []; const gamepads = navigator.getGamepads?.() ?? []
const now = performance.now(); const now = performance.now()
const connectedGamepads = gamepads.filter((gp): gp is Gamepad => Boolean(gp && gp.connected)); const connectedGamepads = gamepads.filter((gp): gp is Gamepad => Boolean(gp && gp.connected))
if (connectedGamepads.length > 0) { if (connectedGamepads.length > 0) {
let up = false; let digitalUp = false
let down = false; let digitalDown = false
let left = false; let digitalLeft = false
let right = false; let digitalRight = false
let select = false; let analogUpIntensity = 0
let back = false; let analogDownIntensity = 0
let analogLeftIntensity = 0
let analogRightIntensity = 0
let select = false
let back = false
let menu = false
let raw2 = false
let raw3 = false
let raw4 = false
let raw5 = false
for (const gamepad of connectedGamepads) { for (const gamepad of connectedGamepads) {
const axisX = gamepad.axes[0] ?? 0; const axisX = gamepad.axes[0] ?? 0
const axisY = gamepad.axes[1] ?? 0; const axisY = gamepad.axes[1] ?? 0
up = up || Boolean(gamepad.buttons[12]?.pressed) || axisY <= -GAMEPAD_AXIS_THRESHOLD; digitalUp = digitalUp || Boolean(gamepad.buttons[12]?.pressed)
down = down || Boolean(gamepad.buttons[13]?.pressed) || axisY >= GAMEPAD_AXIS_THRESHOLD; digitalDown = digitalDown || Boolean(gamepad.buttons[13]?.pressed)
left = left || Boolean(gamepad.buttons[14]?.pressed) || axisX <= -GAMEPAD_AXIS_THRESHOLD; digitalLeft = digitalLeft || Boolean(gamepad.buttons[14]?.pressed)
right = right || Boolean(gamepad.buttons[15]?.pressed) || axisX >= GAMEPAD_AXIS_THRESHOLD; digitalRight = digitalRight || Boolean(gamepad.buttons[15]?.pressed)
// Xbox mapping on standard gamepads: A=0, B=1 const upIntensity = normalizeAxisIntensity(-axisY)
select = select || Boolean(gamepad.buttons[0]?.pressed); const downIntensity = normalizeAxisIntensity(axisY)
back = back || Boolean(gamepad.buttons[1]?.pressed); const leftIntensity = normalizeAxisIntensity(-axisX)
const rightIntensity = normalizeAxisIntensity(axisX)
if (upIntensity > analogUpIntensity) analogUpIntensity = upIntensity
if (downIntensity > analogDownIntensity) analogDownIntensity = downIntensity
if (leftIntensity > analogLeftIntensity) analogLeftIntensity = leftIntensity
if (rightIntensity > analogRightIntensity) analogRightIntensity = rightIntensity
// Xbox mapping on standard gamepads: A=0, B=1, X=2, Y=3
select = select || Boolean(gamepad.buttons[0]?.pressed)
back = back || Boolean(gamepad.buttons[1]?.pressed)
menu = menu || Boolean(gamepad.buttons[3]?.pressed)
raw2 = raw2 || Boolean(gamepad.buttons[2]?.pressed)
raw3 = raw3 || Boolean(gamepad.buttons[3]?.pressed)
raw4 = raw4 || Boolean(gamepad.buttons[4]?.pressed)
raw5 = raw5 || Boolean(gamepad.buttons[5]?.pressed)
} }
applyGamepadAction('up', up, now); triggerSinglePressAction("up", digitalUp)
applyGamepadAction('down', down, now); triggerSinglePressAction("down", digitalDown)
applyGamepadAction('left', left, now); triggerSinglePressAction("left", digitalLeft)
applyGamepadAction('right', right, now); triggerSinglePressAction("right", digitalRight)
applyGamepadAction('select', select, now); triggerSinglePressAction("select", select)
applyGamepadAction('back', back, now); triggerSinglePressAction("back", back)
triggerSinglePressAction("menu", menu)
triggerSinglePressRawButton(2, raw2)
triggerSinglePressRawButton(3, raw3)
triggerSinglePressRawButton(4, raw4)
triggerSinglePressRawButton(5, raw5)
const hasDigitalRepeatable =
digitalUp || digitalDown || digitalLeft || digitalRight || raw2 || raw3 || raw4 || raw5
if (!hasDigitalRepeatable) {
digitalHoldStartedAt = 0
digitalLastRepeatAt = 0
digitalRepeatCount = 0
} else { } else {
resetPressedState(); if (digitalHoldStartedAt === 0) {
digitalHoldStartedAt = now
digitalLastRepeatAt = now
digitalRepeatCount = 0
} }
gamepadFrameId = window.requestAnimationFrame(pollGamepad); const holdMs = now - digitalHoldStartedAt
const repeatMs =
digitalRepeatCount === 0
? DIGITAL_INITIAL_REPEAT_DELAY_MS
: getDigitalRepeatIntervalMs(holdMs)
if (now - digitalLastRepeatAt >= repeatMs) {
if (digitalUp) dispatchGamepadAction("up")
if (digitalDown) dispatchGamepadAction("down")
if (digitalLeft) dispatchGamepadAction("left")
if (digitalRight) dispatchGamepadAction("right")
if (raw2) dispatchRawButton(2)
if (raw3) dispatchRawButton(3)
if (raw4) dispatchRawButton(4)
if (raw5) dispatchRawButton(5)
digitalLastRepeatAt = now
digitalRepeatCount += 1
}
}
// Analog repeat has no initial delay and speed increases with stick deflection.
// If digital d-pad direction is held, it owns that direction to avoid duplicate repeats.
applyAnalogDirection("up", digitalUp ? 0 : analogUpIntensity, now)
applyAnalogDirection("down", digitalDown ? 0 : analogDownIntensity, now)
applyAnalogDirection("left", digitalLeft ? 0 : analogLeftIntensity, now)
applyAnalogDirection("right", digitalRight ? 0 : analogRightIntensity, now)
// Keep using rAF while gamepads are active for responsive input
gamepadFrameId = window.requestAnimationFrame(pollGamepad)
} else {
resetPressedState()
// No gamepads connected — drop to slow polling to save CPU
scheduleIdlePoll()
}
}
function handleGamepadConnected() {
if (!gamepadInstalled) return
// A gamepad was plugged in; make sure we're polling
stopPolling()
gamepadFrameId = window.requestAnimationFrame(pollGamepad)
} }
export function installGamepadNavigation() { export function installGamepadNavigation() {
if (gamepadInstalled) return; if (gamepadInstalled) return
gamepadInstalled = true; gamepadInstalled = true
gamepadFrameId = window.requestAnimationFrame(pollGamepad); gamepadFrameId = window.requestAnimationFrame(pollGamepad)
window.addEventListener("gamepadconnected", handleGamepadConnected)
} }
export function uninstallGamepadNavigation() { export function uninstallGamepadNavigation() {
if (!gamepadInstalled) return; if (!gamepadInstalled) return
gamepadInstalled = false; gamepadInstalled = false
if (gamepadFrameId !== null) { stopPolling()
window.cancelAnimationFrame(gamepadFrameId); resetPressedState()
gamepadFrameId = null; window.removeEventListener("gamepadconnected", handleGamepadConnected)
}
resetPressedState();
} }
@@ -0,0 +1,74 @@
import { onUnmounted, ref, type Ref } from "vue"
export const PREVIEW_IDLE_MS = 30_000
const ACTIVITY_EVENTS = ["mousemove", "mousedown", "wheel", "keydown", "touchstart"] as const
const GAMEPAD_ACTIVITY_EVENT = "mediahive:gamepad-action"
interface IdlePreviewPlaybackOptions {
idleMs?: number
onStop: () => void
onRestart: () => void
}
// Stops preview videos after a period without user input and restarts them
// (via the component's staggered startup) when activity resumes. Also stops
// previews while the tab is hidden. Activity listeners run in the capture
// phase so the stopped flag clears before hover/focus handlers react.
export function useIdlePreviewPlayback(options: IdlePreviewPlaybackOptions): {
stopped: Ref<boolean>
} {
const idleMs = options.idleMs ?? PREVIEW_IDLE_MS
const stopped = ref(false)
let idleTimer: ReturnType<typeof setTimeout> | null = null
function clearIdleTimer() {
if (idleTimer !== null) {
clearTimeout(idleTimer)
idleTimer = null
}
}
function stop() {
clearIdleTimer()
if (stopped.value) return
stopped.value = true
options.onStop()
}
function handleActivity() {
if (document.hidden) return
if (stopped.value) {
stopped.value = false
options.onRestart()
}
clearIdleTimer()
idleTimer = setTimeout(stop, idleMs)
}
function handleVisibilityChange() {
if (document.hidden) {
stop()
} else {
handleActivity()
}
}
for (const eventName of ACTIVITY_EVENTS) {
window.addEventListener(eventName, handleActivity, { passive: true, capture: true })
}
window.addEventListener(GAMEPAD_ACTIVITY_EVENT, handleActivity, { passive: true, capture: true })
document.addEventListener("visibilitychange", handleVisibilityChange)
idleTimer = setTimeout(stop, idleMs)
onUnmounted(() => {
for (const eventName of ACTIVITY_EVENTS) {
window.removeEventListener(eventName, handleActivity, { capture: true })
}
window.removeEventListener(GAMEPAD_ACTIVITY_EVENT, handleActivity, { capture: true })
document.removeEventListener("visibilitychange", handleVisibilityChange)
clearIdleTimer()
})
return { stopped }
}
@@ -0,0 +1,161 @@
import { reportUserActivity } from "../api"
type InputModality = "mouse" | "keyboard" | "gamepad"
const MOUSE_IDLE_MS = 1400
const MOUSE_INTENT_DISTANCE_PX = 28
const MOUSE_INTENT_WINDOW_MS = 700
const MOUSE_OVER_INTENT_RECENCY_MS = 500
const MOUSE_INTENT_SELECTOR = [
"[data-nav-focusable]",
"button",
"a[href]",
"input",
"select",
"textarea",
'[role="button"]',
".media-card",
".collage-item",
".episode-tile",
".version-row",
".ctx-btn",
".header-nav-item",
].join(",")
let installed = false
let modality: InputModality = "mouse"
let mouseIdleTimer: number | null = null
let pointerVisible = false
let mouseTravelPx = 0
let lastMouseMoveAt = 0
let lastAnyMouseMoveAt = 0
function clearMouseIdleTimer() {
if (mouseIdleTimer !== null) {
window.clearTimeout(mouseIdleTimer)
mouseIdleTimer = null
}
}
function applyInputState(mouseActive: boolean) {
const root = document.documentElement
root.classList.toggle("mouse-active", mouseActive)
root.classList.toggle("pointer-visible", pointerVisible)
}
function scheduleMouseIdle() {
clearMouseIdleTimer()
mouseIdleTimer = window.setTimeout(() => {
pointerVisible = false
applyInputState(false)
}, MOUSE_IDLE_MS)
}
function activateMouseInput() {
modality = "mouse"
pointerVisible = true
applyInputState(true)
scheduleMouseIdle()
}
function activateNonMouseInput(next: InputModality) {
modality = next
pointerVisible = false
mouseTravelPx = 0
clearMouseIdleTimer()
applyInputState(false)
}
function showPointerFromMotion() {
pointerVisible = true
applyInputState(modality === "mouse")
scheduleMouseIdle()
}
function isMouseIntentTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false
return Boolean(target.closest(MOUSE_INTENT_SELECTOR))
}
function registerMouseIntentTravel(event: MouseEvent): boolean {
const now = performance.now()
if (now - lastMouseMoveAt > MOUSE_INTENT_WINDOW_MS) {
mouseTravelPx = 0
}
lastMouseMoveAt = now
const step = Math.hypot(event.movementX || 0, event.movementY || 0)
mouseTravelPx += step
if (mouseTravelPx >= MOUSE_INTENT_DISTANCE_PX) {
mouseTravelPx = 0
return true
}
return false
}
function handleMouseMove(event: MouseEvent) {
reportUserActivity()
showPointerFromMotion()
lastAnyMouseMoveAt = performance.now()
if (modality === "mouse") {
applyInputState(true)
return
}
if (!isMouseIntentTarget(event.target)) return
if (registerMouseIntentTravel(event)) {
activateMouseInput()
}
}
function handleMouseOver(event: MouseEvent) {
if (modality === "mouse") return
if (!isMouseIntentTarget(event.target)) return
// Browsers fire mouseover/mouseenter when scrolling or re-rendering moves
// content under a stationary cursor (e.g. sideways season browsing while
// the pointer happens to rest over the episode grid). Without recent real
// pointer motion this is not mouse intent — activating mouse input here
// would let hover handlers steal keyboard/gamepad focus.
if (performance.now() - lastAnyMouseMoveAt > MOUSE_OVER_INTENT_RECENCY_MS) return
// Entering an interactive target indicates likely mouse intent.
activateMouseInput()
}
function handleMouseIntentAction(event: MouseEvent | WheelEvent) {
reportUserActivity()
pointerVisible = true
if (isMouseIntentTarget(event.target)) {
activateMouseInput()
return
}
applyInputState(modality === "mouse")
scheduleMouseIdle()
}
function handleKeyboardActivity(event: KeyboardEvent) {
if (event.metaKey || event.ctrlKey || event.altKey) return
reportUserActivity()
activateNonMouseInput("keyboard")
}
function handleGamepadActivity() {
activateNonMouseInput("gamepad")
}
export function installInputModalityTracking() {
if (installed) return
installed = true
applyInputState(false)
window.addEventListener("mousemove", handleMouseMove, { passive: true })
window.addEventListener("mouseover", handleMouseOver, { passive: true })
window.addEventListener("mousedown", handleMouseIntentAction, { passive: true })
window.addEventListener("wheel", handleMouseIntentAction, { passive: true })
window.addEventListener("keydown", handleKeyboardActivity, { passive: true })
window.addEventListener("mediahive:gamepad-action", handleGamepadActivity as EventListener)
}
File diff suppressed because it is too large Load Diff
+612 -136
View File
@@ -1,179 +1,654 @@
import { ref, readonly, onUnmounted } from 'vue'; import { shallowRef, readonly, onUnmounted } from "vue"
import type { Movie, Series, MediaIndex, TaskInfo, WsMessage } from '../types'; import type {
CastGender,
CastMember,
Movie,
MovieUi,
Person,
Series,
SeriesUi,
Episode,
Season,
Torrent,
MediaIndex,
TaskInfo,
WsMessage,
WsRootStatus,
} from "../types"
interface RootState {
movieMap: Map<string, MovieUi>
seriesMap: Map<string, SeriesUi>
peopleMap: Map<number, Person>
initialized: boolean
pendingMessages: WsMessage[]
}
export interface RootStatusEntry extends WsRootStatus {}
const MERGED_KEY_DELIMITER = "::"
/** /**
* Composable that connects to the MediaHive WebSocket and keeps * Composable that connects to one all-roots MediaHive WebSocket and keeps
* the media index updated in real time. * a merged media index updated in real time.
*
* The server sends:
* - "init" → full index (movies + series) on connect
* - "upsert" → single item inserted or updated
* - "remove" → single item removed
* - "task" → background task progress
*
* Messages are msgspec-encoded binary JSON with a "type" tag field.
*/ */
export function useMediaWebSocket() { export function useMediaWebSocket() {
const mediaIndex = ref<MediaIndex | null>(null); type RootTaskInfo = TaskInfo & { root_id: string }
const loading = ref(true);
const error = ref<string | null>(null);
const connected = ref(false);
const tasks = ref<Map<string, TaskInfo>>(new Map());
let ws: WebSocket | null = null; const mediaIndex = shallowRef<MediaIndex | null>(null)
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; const loading = shallowRef(true)
let disposed = false; const error = shallowRef<string | null>(null)
const connected = shallowRef(false)
const tasks = shallowRef<Map<string, RootTaskInfo>>(new Map())
const roots = shallowRef<Map<string, RootStatusEntry>>(new Map())
// Lookup maps for fast upsert / remove const rootStates = shallowRef<Map<string, RootState>>(new Map())
const movieMap = new Map<string, Movie>(); const wsRef = shallowRef<WebSocket | null>(null)
const seriesMap = new Map<string, Series>(); let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let disposed = false
// Single periodic sweep for completed tasks instead of one timeout per task
const completedTaskIds = new Set<string>()
let taskSweepTimer: ReturnType<typeof setInterval> | null = null
function startTaskSweep() {
if (taskSweepTimer !== null) return
taskSweepTimer = setInterval(() => {
if (completedTaskIds.size === 0) return
const next = new Map(tasks.value)
let changed = false
for (const id of completedTaskIds) {
if (next.delete(id)) changed = true
}
completedTaskIds.clear()
if (changed) {
tasks.value = next
}
}, 3000)
}
function stopTaskSweep() {
if (taskSweepTimer !== null) {
clearInterval(taskSweepTimer)
taskSweepTimer = null
}
}
onUnmounted(stopTaskSweep)
function getContentHash(itemId: string): string {
return itemId.split(":").pop() || itemId
}
function torrentQualityScore(t: Torrent): number {
let score = 0
const res = (t.resolution || "").toLowerCase()
if (res.includes("2160") || res.includes("4k") || res.includes("uhd")) score += 100
else if (res.includes("1080") || res.includes("fhd")) score += 80
else if (res.includes("720") || res === "hd") score += 60
else if (res.includes("480") || res === "sd") score += 40
else if (res.includes("360")) score += 20
if (t.dovi) score += 15
if (t.hdr) score += 10
if (t.atmos) score += 5
return score
}
function expandPlayablePath(fileKey: string, playableFile: string | null): string | null {
if (!playableFile) return fileKey
if (playableFile.startsWith("concat:") || playableFile.includes("://")) return playableFile
if (playableFile.startsWith(`${fileKey}/`)) return playableFile
if (playableFile.startsWith("/")) return playableFile.replace(/^\/+/, "")
return `${fileKey}/${playableFile}`
}
function annotateFiles(
files: { [key: string]: Torrent },
rootId: string | null,
): { [key: string]: Torrent } {
return Object.fromEntries(
Object.entries(files || {}).map(([k, t]) => [
k,
{
...t,
playable_file: expandPlayablePath(resolveFilePathKey(k), t.playable_file || null),
root_id: t.root_id || rootId,
},
]),
)
}
function resolveFilePathKey(key: string): string {
const delimIndex = key.indexOf(MERGED_KEY_DELIMITER)
if (delimIndex >= 0) {
return key.slice(delimIndex + MERGED_KEY_DELIMITER.length)
}
return key
}
function mergeTorrentDicts(
a: { [key: string]: Torrent },
b: { [key: string]: Torrent },
): { [key: string]: Torrent } {
const merged: { [key: string]: Torrent } = { ...a }
for (const [k, t] of Object.entries(b)) {
const uniqueKey = merged[k] ? `${t.root_id || "unknown"}${MERGED_KEY_DELIMITER}${k}` : k
merged[uniqueKey] = t
}
const sorted = Object.entries(merged).sort(([, t1], [, t2]) => {
const s1 = torrentQualityScore(t1)
const s2 = torrentQualityScore(t2)
if (s2 !== s1) return s2 - s1
return (t2.size || 0) - (t1.size || 0)
})
return Object.fromEntries(sorted)
}
function withMovieIdentity(
id: string,
movie: Movie,
rootId: string,
people: Map<number, Person>,
): MovieUi {
return { ...normalizeMovie(movie, rootId, people), id, root_id: rootId }
}
function withSeriesIdentity(
id: string,
series: Series,
rootId: string,
people: Map<number, Person>,
): SeriesUi {
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
}
function normalizeCastMember(member: unknown, people: Map<number, Person>): CastMember {
if (!Array.isArray(member)) {
return {
name: "",
character: null,
profile_path: null,
gender: null,
id: null,
}
}
// Current wire format: CastCredit(array_like=True) => [character, id]
const character = typeof member[0] === "string" ? member[0] : null
const id = typeof member[1] === "number" ? member[1] : null
const person = id !== null ? people.get(id) || null : null
return {
name: person?.name || "",
character,
profile_path: person?.profile_path || null,
gender: person?.gender ?? null,
id,
}
}
function normalizePerson(member: unknown): Person | null {
if (!Array.isArray(member)) return null
const gender = normalizeCastGender(member[2])
return {
name: typeof member[0] === "string" ? member[0] : "",
profile_path: typeof member[1] === "string" ? member[1] : null,
gender,
}
}
function normalizeCastGender(value: unknown): CastGender | null {
if (typeof value !== "string") return null
switch (value) {
case "female":
case "male":
case "non_binary":
case "unknown":
return value
default:
return null
}
}
function normalizeInfo<T extends { cast?: unknown }>(
info: T | null,
people: Map<number, Person>,
): T | null {
if (!info) return info
let next: T = info
if (Array.isArray((info as { cast?: unknown }).cast)) {
const cast = ((info as { cast?: unknown[] }).cast || [])
.map((member) => normalizeCastMember(member, people))
.filter((member) => member.name.length > 0)
next = { ...next, cast } as T
}
return next
}
function normalizeMovie(movie: Movie, rootId: string | null, people: Map<number, Person>): Movie {
return {
...movie,
files: annotateFiles(movie.files, rootId),
info: normalizeInfo(movie.info, people),
}
}
function normalizeSeries(
series: Series,
rootId: string | null,
people: Map<number, Person>,
): Series {
return {
...series,
seasons: (series.seasons || []).map((season) => ({
...season,
episodes: (season.episodes || []).map((episode) => ({
...episode,
files: annotateFiles(episode.files, rootId),
})),
})),
info: normalizeInfo(series.info, people),
}
}
function mergeMovies(a: MovieUi, b: MovieUi): MovieUi {
const filesA = annotateFiles(a.files, a.root_id)
const filesB = annotateFiles(b.files, b.root_id)
return {
...a,
id: getContentHash(a.id),
files: mergeTorrentDicts(filesA, filesB),
info: a.info || b.info,
cover_path: a.cover_path || b.cover_path,
backdrop_path: a.backdrop_path || b.backdrop_path,
showreel_images: a.showreel_images?.length ? a.showreel_images : b.showreel_images,
showreel_source_sets: a.showreel_source_sets?.length
? a.showreel_source_sets
: b.showreel_source_sets,
}
}
function mergeEpisodes(
a: Episode,
b: Episode,
rootIdA: string | null,
rootIdB: string | null,
): Episode {
const filesA = annotateFiles(a.files, rootIdA)
const filesB = annotateFiles(b.files, rootIdB)
return {
...a,
files: mergeTorrentDicts(filesA, filesB),
reel_image: a.reel_image || b.reel_image,
reel_sources: a.reel_sources?.length ? a.reel_sources : b.reel_sources,
}
}
function mergeSeasons(
a: Season,
b: Season,
rootIdA: string | null,
rootIdB: string | null,
): Season {
const episodeMap = new Map<number, Episode>()
for (const ep of a.episodes) {
episodeMap.set(ep.episode_number, ep)
}
for (const ep of b.episodes) {
const existing = episodeMap.get(ep.episode_number)
if (existing) {
episodeMap.set(ep.episode_number, mergeEpisodes(existing, ep, rootIdA, rootIdB))
} else {
episodeMap.set(ep.episode_number, {
...ep,
files: annotateFiles(ep.files, rootIdB),
})
}
}
return {
...a,
episodes: Array.from(episodeMap.values()).sort((a, b) => a.episode_number - b.episode_number),
poster_path: a.poster_path || b.poster_path,
}
}
function mergeSeries(a: SeriesUi, b: SeriesUi): SeriesUi {
const seasonMap = new Map<number, Season>()
for (const season of a.seasons || []) {
seasonMap.set(season.season_number, {
...season,
episodes: season.episodes.map((ep) => ({
...ep,
files: annotateFiles(ep.files, a.root_id),
})),
})
}
for (const season of b.seasons || []) {
const existing = seasonMap.get(season.season_number)
if (existing) {
seasonMap.set(season.season_number, mergeSeasons(existing, season, a.root_id, b.root_id))
} else {
seasonMap.set(season.season_number, {
...season,
episodes: season.episodes.map((ep) => ({
...ep,
files: annotateFiles(ep.files, b.root_id),
})),
})
}
}
return {
...a,
id: getContentHash(a.id),
seasons: Array.from(seasonMap.values()).sort((a, b) => a.season_number - b.season_number),
info: a.info || b.info,
cover_path: a.cover_path || b.cover_path,
backdrop_path: a.backdrop_path || b.backdrop_path,
}
}
function mergeItemsByHash<T extends MovieUi | SeriesUi>(
items: T[],
mergeFn: (a: T, b: T) => T,
): T[] {
const map = new Map<string, T[]>()
for (const item of items) {
const hash = getContentHash(item.id)
const arr = map.get(hash) || []
arr.push(item)
map.set(hash, arr)
}
const merged: T[] = []
for (const [, group] of map) {
if (group.length === 1) {
merged.push(group[0])
} else {
let result = group[0]
for (let i = 1; i < group.length; i++) {
result = mergeFn(result, group[i])
}
merged.push(result)
}
}
return merged
}
function ensureRootState(rootId: string): RootState {
const existing = rootStates.value.get(rootId)
if (existing) {
return existing
}
const created: RootState = {
movieMap: new Map(),
seriesMap: new Map(),
peopleMap: new Map(),
initialized: false,
pendingMessages: [],
}
rootStates.value.set(rootId, created)
return created
}
function pruneMissingRoots(nextRoots: Map<string, RootStatusEntry>) {
for (const rootId of rootStates.value.keys()) {
if (!nextRoots.has(rootId)) {
rootStates.value.delete(rootId)
}
}
for (const [taskKey, task] of tasks.value.entries()) {
if (!nextRoots.has(task.root_id)) {
tasks.value.delete(taskKey)
}
}
tasks.value = new Map(tasks.value)
}
function buildIndex(): MediaIndex { function buildIndex(): MediaIndex {
const movies = Array.from(movieMap.values()); const movies: MovieUi[] = []
const series = Array.from(seriesMap.values()); const series: SeriesUi[] = []
for (const state of rootStates.value.values()) {
movies.push(...state.movieMap.values())
series.push(...state.seriesMap.values())
}
const mergedMovies = mergeItemsByHash(movies, mergeMovies)
const mergedSeries = mergeItemsByHash(series, mergeSeries)
return { return {
version: 0, v: 1,
generated_at: new Date().toISOString(), generated_at: new Date().toISOString(),
stats: { movies: mergedMovies,
total_movies: movies.length, series: mergedSeries,
total_series: series.length, }
}
function updateMergedState() {
mediaIndex.value = buildIndex()
let anyInitialized = false
for (const state of rootStates.value.values()) {
if (state.initialized) {
anyInitialized = true
break
}
}
if (anyInitialized || roots.value.size === 0) {
loading.value = false
error.value = null
}
connected.value = wsRef.value?.readyState === WebSocket.OPEN
}
function applyRootInit(
rootId: string,
rootData: {
movies: Record<string, Movie>
series: Record<string, Series>
people?: Record<string, unknown>
}, },
movies, ) {
series, const state = ensureRootState(rootId)
};
}
function handleMessage(event: MessageEvent) { state.peopleMap.clear()
try { for (const [id, person] of Object.entries(rootData.people || {})) {
// Server sends binary frames (msgspec json bytes) const parsed = Number(id)
let text: string; const normalized = normalizePerson(person)
if (event.data instanceof Blob) { if (Number.isFinite(parsed)) {
// Will be handled by the blob reader below state.peopleMap.set(parsed, normalized || { name: "", profile_path: null, gender: null })
event.data.text().then((t) => processJson(t));
return;
} else if (event.data instanceof ArrayBuffer) {
text = new TextDecoder().decode(event.data);
} else {
text = event.data as string;
}
processJson(text);
} catch (e) {
console.error('[WS] Failed to handle message:', e);
} }
} }
function processJson(text: string) { state.movieMap.clear()
const msg = JSON.parse(text) as WsMessage; state.seriesMap.clear()
for (const [id, m] of Object.entries(rootData.movies || {})) {
state.movieMap.set(id, withMovieIdentity(id, m, rootId, state.peopleMap))
}
for (const [id, s] of Object.entries(rootData.series || {})) {
state.seriesMap.set(id, withSeriesIdentity(id, s, rootId, state.peopleMap))
}
state.initialized = true
if (state.pendingMessages.length > 0) {
const queued = state.pendingMessages
state.pendingMessages = []
for (const queuedMsg of queued) {
processMessage(queuedMsg)
}
}
}
function processMessage(msg: WsMessage) {
switch (msg.type) { switch (msg.type) {
case 'init': { case "roots": {
movieMap.clear(); const next = new Map<string, RootStatusEntry>()
seriesMap.clear(); for (const root of msg.roots || []) {
for (const m of msg.data.movies) movieMap.set(m.id, m); next.set(root.root_id, { ...root })
for (const s of msg.data.series) seriesMap.set(s.id, s); ensureRootState(root.root_id)
mediaIndex.value = buildIndex();
loading.value = false;
error.value = null;
console.log(`[WS] init: ${movieMap.size} movies, ${seriesMap.size} series`);
break;
} }
case 'upsert': { roots.value = next
if (msg.kind === 'movie') { pruneMissingRoots(next)
movieMap.set(msg.item.id, msg.item as Movie); updateMergedState()
return
}
case "init": {
for (const [rootId, rootData] of Object.entries(msg.roots || {})) {
applyRootInit(rootId, rootData)
}
updateMergedState()
return
}
case "upsert": {
const state = ensureRootState(msg.root_id)
if (!state.initialized) {
state.pendingMessages.push(msg)
return
}
if (msg.people) {
for (const [id, person] of Object.entries(msg.people)) {
const parsed = Number(id)
const normalized = normalizePerson(person)
if (Number.isFinite(parsed)) {
state.peopleMap.set(
parsed,
normalized || { name: "", profile_path: null, gender: null },
)
}
}
}
if (msg.kind === "movie") {
state.movieMap.set(
msg.id,
withMovieIdentity(msg.id, msg.item as Movie, msg.root_id, state.peopleMap),
)
} else { } else {
seriesMap.set(msg.item.id, msg.item as Series); state.seriesMap.set(
msg.id,
withSeriesIdentity(msg.id, msg.item as Series, msg.root_id, state.peopleMap),
)
} }
// Rebuild the index ref so Vue detects the change updateMergedState()
mediaIndex.value = buildIndex(); return
break;
} }
case 'remove': {
if (msg.kind === 'movie') { case "remove": {
movieMap.delete(msg.id); const state = ensureRootState(msg.root_id)
if (!state.initialized) {
state.pendingMessages.push(msg)
return
}
if (msg.kind === "movie") {
state.movieMap.delete(msg.id)
} else { } else {
seriesMap.delete(msg.id); state.seriesMap.delete(msg.id)
} }
mediaIndex.value = buildIndex(); updateMergedState()
break; return
} }
case 'task': {
const info = msg.data; case "task": {
if (info.status === 'completed' || info.status === 'cancelled' || info.status === 'error') { const info = msg.data
// Keep finished tasks briefly so the UI can show completion const taskKey = `${msg.root_id}:${info.id}`
tasks.value.set(info.id, info); tasks.value.set(taskKey, { ...info, root_id: msg.root_id })
setTimeout(() => { tasks.value = new Map(tasks.value)
tasks.value.delete(info.id); if (info.status === "completed" || info.status === "cancelled" || info.status === "error") {
tasks.value = new Map(tasks.value); completedTaskIds.add(taskKey)
}, 3000); startTaskSweep()
} else {
tasks.value.set(info.id, info);
} }
// Trigger reactivity return
tasks.value = new Map(tasks.value);
break;
} }
} }
} }
function connect() { function handleRawMessage(event: MessageEvent) {
if (disposed) return; const processText = (text: string) => {
try {
// Build WS URL relative to current page processMessage(JSON.parse(text) as WsMessage)
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; } catch (e) {
const url = `${proto}//${location.host}/api/ws`; console.error("[WS] Failed to handle message:", e)
console.log(`[WS] Connecting to ${url}...`);
ws = new WebSocket(url);
ws.onopen = () => {
connected.value = true;
error.value = null;
console.log('[WS] Connected');
};
ws.onmessage = handleMessage;
ws.onclose = (ev) => {
connected.value = false;
console.log(`[WS] Closed (code=${ev.code})`);
scheduleReconnect();
};
ws.onerror = (ev) => {
console.error('[WS] Error:', ev);
if (!mediaIndex.value) {
error.value = 'WebSocket connection failed';
} }
}; }
if (event.data instanceof Blob) {
void event.data.text().then(processText)
return
}
if (event.data instanceof ArrayBuffer) {
processText(new TextDecoder().decode(event.data))
return
}
processText(event.data as string)
} }
function scheduleReconnect() { function scheduleReconnect() {
if (disposed) return; if (disposed) return
if (reconnectTimer) clearTimeout(reconnectTimer); if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => { reconnectTimer = setTimeout(() => {
console.log('[WS] Reconnecting...'); reconnectTimer = null
connect(); connect()
}, 2000); }, 2000)
}
function connect() {
if (disposed) return
if (wsRef.value && wsRef.value.readyState <= WebSocket.OPEN) return
const proto = location.protocol === "https:" ? "wss:" : "ws:"
const url = `${proto}//${location.host}/api/ws`
console.log(`[WS] Connecting to ${url}...`)
const ws = new WebSocket(url)
wsRef.value = ws
ws.onopen = () => {
connected.value = true
error.value = null
console.log("[WS] Connected")
}
ws.onmessage = (ev) => handleRawMessage(ev)
ws.onclose = (ev) => {
if (wsRef.value === ws) {
wsRef.value = null
}
connected.value = false
console.log(`[WS] Closed (code=${ev.code})`)
scheduleReconnect()
}
ws.onerror = (ev) => {
console.error("[WS] Error:", ev)
if (!mediaIndex.value) {
error.value = "WebSocket connection failed"
}
}
} }
function disconnect() { function disconnect() {
disposed = true; disposed = true
stopTaskSweep()
if (reconnectTimer) { if (reconnectTimer) {
clearTimeout(reconnectTimer); clearTimeout(reconnectTimer)
reconnectTimer = null; reconnectTimer = null
}
if (ws) {
ws.onclose = null; // prevent reconnect
ws.close();
ws = null;
}
} }
// Start the connection if (wsRef.value) {
connect(); wsRef.value.onclose = null
wsRef.value.close()
wsRef.value = null
}
// Clean up on component unmount connected.value = false
onUnmounted(disconnect); roots.value.clear()
rootStates.value.clear()
}
connect()
onUnmounted(disconnect)
return { return {
mediaIndex, mediaIndex,
@@ -181,6 +656,7 @@ export function useMediaWebSocket() {
error: readonly(error), error: readonly(error),
connected: readonly(connected), connected: readonly(connected),
tasks: readonly(tasks), tasks: readonly(tasks),
roots: readonly(roots),
disconnect, disconnect,
}; }
} }
+168
View File
@@ -0,0 +1,168 @@
import { reactive, watch } from "vue"
import type { Torrent } from "../types"
export type ResolutionPreference = "r2" | "r3" | "r4" | "rmax"
export type HdrPreference = "none" | "hdr10plus" | "dovi"
export interface MediaHiveSettings {
preferredResolution: ResolutionPreference
preferredHdr: HdrPreference
playerId: string | null
playerCustomCmd: string | null
playerMpcPort: number | null
}
const STORAGE_KEY = "MediaHive"
const RESOLUTION_PRIORITY: Record<string, number> = {
"8K": 5,
"4K": 4,
FHD: 3,
HD: 2,
SD: 1,
}
// Max resolution priority allowed for each preference level
const RESOLUTION_CAP: Record<ResolutionPreference, number> = {
r2: 2,
r3: 3,
r4: 4,
rmax: 999,
}
const RESOLUTION_VALUES = ["r2", "r3", "r4", "rmax"] as const
const HDR_VALUES = ["none", "hdr10plus", "dovi"] as const
function isResolutionPreference(value: unknown): value is ResolutionPreference {
return typeof value === "string" && RESOLUTION_VALUES.includes(value as ResolutionPreference)
}
function isHdrPreference(value: unknown): value is HdrPreference {
return typeof value === "string" && HDR_VALUES.includes(value as HdrPreference)
}
function loadSettings(): MediaHiveSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<MediaHiveSettings>
return {
preferredResolution: isResolutionPreference(parsed.preferredResolution)
? parsed.preferredResolution
: "rmax",
preferredHdr: isHdrPreference(parsed.preferredHdr) ? parsed.preferredHdr : "none",
playerId: typeof parsed.playerId === "string" ? parsed.playerId : "default",
playerCustomCmd: typeof parsed.playerCustomCmd === "string" ? parsed.playerCustomCmd : null,
playerMpcPort: typeof parsed.playerMpcPort === "number" ? parsed.playerMpcPort : null,
}
}
} catch {
// ignore parse errors
}
return {
preferredResolution: "rmax",
preferredHdr: "none",
playerId: "default",
playerCustomCmd: null,
playerMpcPort: null,
}
}
const settings = reactive<MediaHiveSettings>(loadSettings())
watch(
() => ({ ...settings }),
(value) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
} catch {
// ignore
}
},
{ deep: true },
)
export function useSettings() {
return settings
}
/**
* Sort a list of Torrent objects according to the current format preferences.
*
* Algorithm:
* 1. Compute the "effective priority" = min(actual priority, cap).
* Releases exceeding the cap receive the capped priority.
* 2. Higher effective priority wins.
* 3. For equal effective priority, prefer HDR flavor per `preferredHdr`.
* 4. Equal in all above: fall back to raw resolution priority (larger is better)
* then size.
*/
export function sortTorrentsByPreference(torrents: Torrent[]): Torrent[] {
const cap = RESOLUTION_CAP[settings.preferredResolution]
function effectivePriority(t: Torrent): number {
const raw = RESOLUTION_PRIORITY[t.resolution ?? ""] ?? 0
return Math.min(raw, cap)
}
type HdrProfile = "sdr" | "hdr10" | "hdr10plus" | "dovi"
function detectHdrProfile(t: Torrent): HdrProfile {
const text = [t.title, t.quality, t.codec, t.audio].filter(Boolean).join(" ")
const hasDovi = t.dovi || /dolby\s*vision|dovi|\bdv\b/i.test(text)
const hasHdr10Plus = t.hdr10plus || /hdr10\+|hdr10plus/i.test(text)
const hasAnyHdr = t.hdr || hasHdr10Plus || hasDovi || /\bhdr\b/i.test(text)
if (hasDovi) return "dovi"
if (hasHdr10Plus) return "hdr10plus"
if (hasAnyHdr) return "hdr10"
return "sdr"
}
function scoreForPreference(profile: HdrProfile): number {
if (settings.preferredHdr === "none") {
// Prefer no HDR; HDR variants after SDR.
if (profile === "sdr") return 4
if (profile === "hdr10") return 3
if (profile === "hdr10plus") return 2
return 1
}
if (settings.preferredHdr === "hdr10plus") {
// Requested behavior: HDR10+ best, HDR10 next, SDR then, DoVi last.
if (profile === "hdr10plus") return 4
if (profile === "hdr10") return 3
if (profile === "sdr") return 2
return 1
}
if (profile === "dovi") return 4
if (profile === "hdr10plus") return 3
if (profile === "hdr10") return 2
return 1
}
function hdrPreferenceScore(t: Torrent): number {
return scoreForPreference(detectHdrProfile(t))
}
return [...torrents].sort((a, b) => {
const epA = effectivePriority(a)
const epB = effectivePriority(b)
if (epB !== epA) return epB - epA
// Same effective resolution bucket - apply HDR format preference
const hdrScoreA = hdrPreferenceScore(a)
const hdrScoreB = hdrPreferenceScore(b)
if (hdrScoreB !== hdrScoreA) {
return hdrScoreB - hdrScoreA
}
// Fall back to raw resolution priority then size
const rawA = RESOLUTION_PRIORITY[a.resolution ?? ""] ?? 0
const rawB = RESOLUTION_PRIORITY[b.resolution ?? ""] ?? 0
if (rawB !== rawA) return rawB - rawA
return (b.size ?? 0) - (a.size ?? 0)
})
}
+70 -15
View File
@@ -1,18 +1,73 @@
import { createApp } from 'vue' import { createApp } from "vue"
import App from './App.vue' import App from "./App.vue"
import router from './router' import router from "./router"
import './styles/main.css' import "./styles/main.css"
import { installKeyboardNavigation } from './composables/useKeyboardNavigation' import { installKeyboardNavigation } from "./composables/useKeyboardNavigation"
import { installGamepadNavigation } from './composables/useGamepadNavigation' import { installGamepadNavigation } from "./composables/useGamepadNavigation"
import { installInputModalityTracking } from "./composables/useInputModality"
// Install global keyboard navigation handlers immediately function postClientError(payload: {
installKeyboardNavigation() message: string
installGamepadNavigation() stack: string | null
source: string | null
if ('serviceWorker' in navigator && !navigator.serviceWorker.controller) { }) {
navigator.serviceWorker.addEventListener('controllerchange', () => { fetch("/api/client-log", {
window.location.reload() method: "POST",
}, { once: true }) headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => {})
} }
createApp(App).use(router).mount('#app') function installErrorCapture() {
window.addEventListener("error", (event) => {
const source =
event.filename != null ? `${event.filename}:${event.lineno ?? 0}:${event.colno ?? 0}` : null
postClientError({
message: event.message || String(event.error ?? "Unknown error"),
stack: event.error?.stack ?? null,
source,
})
})
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason
postClientError({
message: reason instanceof Error ? reason.message : `Unhandled rejection: ${String(reason)}`,
stack: reason instanceof Error ? (reason.stack ?? null) : null,
source: "unhandledrejection",
})
})
}
function installReloadShortcut() {
document.addEventListener(
"keydown",
(event) => {
if (
event.key === "F5" ||
((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "r")
) {
event.preventDefault()
window.location.reload()
}
},
{ capture: true },
)
}
// Install global keyboard navigation handlers immediately
installInputModalityTracking()
installKeyboardNavigation()
installGamepadNavigation()
installReloadShortcut()
installErrorCapture()
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
for (const registration of registrations) {
registration.unregister()
}
})
}
createApp(App).use(router).mount("#app")
-8
View File
@@ -1,8 +0,0 @@
import { registerSW } from 'virtual:pwa-register'
export function registerMediaHivePwa(): void {
const updateServiceWorker = registerSW({
immediate: true,
onNeedRefresh: () => void updateServiceWorker(true),
})
}
+33 -23
View File
@@ -1,49 +1,59 @@
import { createRouter, createWebHashHistory } from 'vue-router'; import { createRouter, createWebHistory } from "vue-router"
import { defineComponent, h } from 'vue'; import { defineComponent, h } from "vue"
// Empty component - App.vue handles all rendering based on route meta // Empty component - App.vue handles all rendering based on route meta
const EmptyRouteComponent = defineComponent({ const EmptyRouteComponent = defineComponent({
render() { render() {
return h('div'); return h("div")
} },
}); })
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHistory(),
scrollBehavior() { scrollBehavior() {
// Always scroll to top on navigation // Always scroll to top on navigation
return { top: 0 }; return { top: 0 }
}, },
routes: [ routes: [
{ {
path: '/', path: "/",
redirect: '/movies', redirect: "/movies",
}, },
{ {
path: '/movies', path: "/movies",
name: 'movies', name: "movies",
component: EmptyRouteComponent, component: EmptyRouteComponent,
meta: { view: 'movies' }, meta: { view: "movies" },
}, },
{ {
path: '/movies/:id', path: "/movies/:id",
name: 'movie-detail', name: "movie-detail",
component: EmptyRouteComponent, component: EmptyRouteComponent,
meta: { view: 'movies' }, meta: { view: "movies" },
}, },
{ {
path: '/series', path: "/search/:term",
name: 'series', name: "search",
component: EmptyRouteComponent, component: EmptyRouteComponent,
meta: { view: 'series' },
}, },
{ {
path: '/series/:id', path: "/settings",
name: 'series-detail', name: "settings",
component: EmptyRouteComponent, component: EmptyRouteComponent,
meta: { view: 'series' }, },
{
path: "/series",
name: "series",
component: EmptyRouteComponent,
meta: { view: "series" },
},
{
path: "/series/:id",
name: "series-detail",
component: EmptyRouteComponent,
meta: { view: "series" },
}, },
], ],
}); })
export default router; export default router
+797
View File
@@ -0,0 +1,797 @@
// Search Web Worker - runs search off the main thread
// This file is loaded as a Web Worker, not imported as a module.
import type { MovieUi, SeriesUi, MatchedPerson, MatchedEpisode, SearchMatchInfo } from "./types"
// ---------------------------------------------------------------------------
// Message types
// ---------------------------------------------------------------------------
export interface SearchIndexMessage {
type: "index"
movies: MovieUi[]
series: SeriesUi[]
}
export interface SearchQueryMessage {
type: "query"
id: number
query: string
}
export type SearchWorkerMessage = SearchIndexMessage | SearchQueryMessage
export interface SearchResultItem {
id: string
title: string | null
year?: number | null
cover_path: string | null
showreel_images?: string[] | null
showreel_source_sets?: string[][] | null
type: "movies" | "series"
resolution?: string | null
root_id: string | null
searchMatchInfo?: SearchMatchInfo
}
export interface SearchCategoryResult {
name: string
items: SearchResultItem[]
}
export interface SearchResponseMessage {
id: number
results: SearchResultItem[]
categories: SearchCategoryResult[]
}
// ---------------------------------------------------------------------------
// Worker state
// ---------------------------------------------------------------------------
let movies: MovieUi[] = []
let series: SeriesUi[] = []
// ---------------------------------------------------------------------------
// Normalization helpers (mirrored from App.vue)
// ---------------------------------------------------------------------------
function normalizeSearchText(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9-]+/g, " ")
.trim()
.replace(/\s+/g, " ")
}
function normalizePathSearchText(value: string): string {
return value
.toLowerCase()
.replace(/[\\/]+/g, "/")
.replace(/[^a-z0-9/]+/g, " ")
.trim()
.replace(/\s+/g, " ")
}
// ---------------------------------------------------------------------------
// Scoring helpers (mirrored from App.vue)
// ---------------------------------------------------------------------------
function getTermMatchScore(term: string, field: string): number {
const index = field.indexOf(term)
if (index < 0) return 0
if (index === 0) return 100
const charBefore = field[index - 1]
if (/\s/.test(charBefore)) return 80
if (index < field.length / 2) return 50
return 30
}
function getPathTermMatchScore(term: string, field: string): number {
const index = field.indexOf(term)
if (index < 0) return 0
if (index === 0) return 100
const charBefore = field[index - 1]
if (/\s|\//.test(charBefore)) return 80
if (index < field.length / 2) return 50
return 30
}
function getRelevanceScore(query: string, field: string): number {
const normalizedField = normalizeSearchText(field)
const normalizedQuery = normalizeSearchText(query)
if (!normalizedField || !normalizedQuery) return 0
let bestScore = 0
const exactIndex = normalizedField.indexOf(normalizedQuery)
if (exactIndex >= 0) {
if (exactIndex === 0) {
bestScore = 110
} else {
const charBefore = normalizedField[exactIndex - 1]
if (/\s/.test(charBefore)) {
bestScore = 95
} else if (exactIndex < normalizedField.length / 2) {
bestScore = 75
} else {
bestScore = 60
}
}
}
const terms = normalizedQuery.split(" ")
if (terms.length > 1) {
let matchedTerms = 0
let termScoreTotal = 0
for (const term of terms) {
const termScore = getTermMatchScore(term, normalizedField)
if (termScore > 0) {
matchedTerms += 1
termScoreTotal += termScore
}
}
if (matchedTerms > 0) {
const coverage = matchedTerms / terms.length
const averageScore = termScoreTotal / matchedTerms
const combinedScore = Math.round(averageScore * (0.6 + coverage * 0.4))
if (combinedScore > bestScore) bestScore = combinedScore
}
}
return bestScore
}
function getPathRelevanceScore(query: string, field: string): number {
const normalizedField = normalizePathSearchText(field)
const normalizedQuery = normalizePathSearchText(query)
if (!normalizedField || !normalizedQuery) return 0
let bestScore = 0
const exactIndex = normalizedField.indexOf(normalizedQuery)
if (exactIndex >= 0) {
if (exactIndex === 0) {
bestScore = 110
} else {
const charBefore = normalizedField[exactIndex - 1]
if (/\s|\//.test(charBefore)) {
bestScore = 95
} else if (exactIndex < normalizedField.length / 2) {
bestScore = 75
} else {
bestScore = 60
}
}
}
const terms = normalizedQuery.split(" ")
if (terms.length > 1) {
let matchedTerms = 0
let termScoreTotal = 0
for (const term of terms) {
const termScore = getPathTermMatchScore(term, normalizedField)
if (termScore > 0) {
matchedTerms += 1
termScoreTotal += termScore
}
}
if (matchedTerms > 0) {
const coverage = matchedTerms / terms.length
const averageScore = termScoreTotal / matchedTerms
const combinedScore = Math.round(averageScore * (0.6 + coverage * 0.4))
if (combinedScore > bestScore) bestScore = combinedScore
}
}
return bestScore
}
function getBestScore(query: string, ...fields: (string | null | undefined)[]): number {
let bestScore = 0
for (const field of fields) {
if (field) {
const score = getRelevanceScore(query, field)
if (score > bestScore) bestScore = score
}
}
return bestScore
}
function getMoviePathScore(movie: MovieUi, query: string): number {
const torrentFields: (string | null | undefined)[] = []
for (const torrent of Object.values(movie.files || {})) {
torrentFields.push(torrent.title, torrent.playable_file)
}
let bestScore = 0
for (const field of torrentFields) {
if (!field) continue
const score = getPathRelevanceScore(query, field)
if (score > bestScore) bestScore = score
}
return bestScore
}
function getSeriesPathScore(series: SeriesUi, query: string): number {
const torrentFields: (string | null | undefined)[] = []
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
for (const torrent of Object.values(episode.files || {})) {
torrentFields.push(torrent.title, torrent.playable_file)
}
}
}
let bestScore = 0
for (const field of torrentFields) {
if (!field) continue
const score = getPathRelevanceScore(query, field)
if (score > bestScore) bestScore = score
}
return bestScore
}
// ---------------------------------------------------------------------------
// People matching (mirrored from App.vue)
// ---------------------------------------------------------------------------
interface PersonMatch {
name: string
roles: string[]
highlightRoles: boolean
}
interface PersonCandidate {
name: string
role: string
highlightRoles: boolean
score: number
matchedWordIndexes: number[]
}
function getQueryWords(value: string): string[] {
const normalized = normalizeSearchText(value)
if (!normalized) return []
return normalized.split(" ")
}
function getMatchedWordIndexes(queryWords: string[], value: string): number[] {
const normalized = normalizeSearchText(value)
if (!normalized || queryWords.length === 0) return []
const targetWords = normalized.split(" ")
const matches: number[] = []
for (let i = 0; i < queryWords.length; i += 1) {
const queryWord = queryWords[i]
if (targetWords.some((targetWord) => targetWord.startsWith(queryWord))) {
matches.push(i)
}
}
return matches
}
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void {
const existing = target.find(
(person) => person.name.toLowerCase() === candidate.name.toLowerCase(),
)
if (existing) {
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
if (candidate.highlightRoles) existing.highlightRoles = true
return
}
target.push({
name: candidate.name,
roles: [candidate.role],
highlightRoles: candidate.highlightRoles,
})
}
function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
const sorted = indexes.filter((index) => availableIndexes.has(index)).sort((a, b) => a - b)
if (sorted.length === 0) return []
let bestStart = 0
let bestLength = 1
let runStart = 0
let runLength = 1
for (let i = 1; i < sorted.length; i += 1) {
if (sorted[i] === sorted[i - 1] + 1) {
runLength += 1
continue
}
if (runLength > bestLength) {
bestStart = runStart
bestLength = runLength
}
runStart = i
runLength = 1
}
if (runLength > bestLength) {
bestStart = runStart
bestLength = runLength
}
return sorted.slice(bestStart, bestStart + bestLength)
}
function nameMatchesQuery(query: string, name: string): boolean {
const nq = normalizeSearchText(query)
const nn = normalizeSearchText(name)
if (!nq || !nn) return false
// Exact substring match (e.g. "jackie chan" matches "jackie chan" and "jackie chans")
if (nn.includes(nq)) return true
// Each query word must appear as a prefix of a name word (word-boundary match)
const queryWords = nq.split(" ")
const nameWords = nn.split(" ")
return queryWords.every((qw) => nameWords.some((nw) => nw.startsWith(qw)))
}
function getNameMatchScore(query: string, name: string): number {
if (!nameMatchesQuery(query, name)) return 0
const nq = normalizeSearchText(query)
const nn = normalizeSearchText(name)
const idx = nn.indexOf(nq)
if (idx === 0) return 110
if (idx > 0) {
const before = nn[idx - 1]
if (/\s/.test(before)) return 95
return 75
}
// Prefix-based match: score lower than exact substring
return 70
}
function matchesPeople(
query: string,
cast: { name: string; character?: string | null }[] | null | undefined,
director?: string | null,
creators?: string[] | null,
): { matches: PersonMatch[]; score: number } {
const matchedPeople: PersonMatch[] = []
const candidates: PersonCandidate[] = []
const queryWords = getQueryWords(query)
const addCandidate = (
name: string,
role: string,
highlightRoles: boolean,
score: number,
matchedWordIndexes: number[],
) => {
candidates.push({ name, role, highlightRoles, score, matchedWordIndexes })
}
let bestScore = 0
if (director) {
const score = getNameMatchScore(query, director)
if (score > 0) {
addCandidate(director, "Director", false, score, getMatchedWordIndexes(queryWords, director))
} else {
addCandidate(director, "Director", false, 0, getMatchedWordIndexes(queryWords, director))
}
}
if (creators) {
for (const creator of creators) {
const score = getNameMatchScore(query, creator)
if (score > 0) {
addCandidate(creator, "Creator", false, score, getMatchedWordIndexes(queryWords, creator))
} else {
addCandidate(creator, "Creator", false, 0, getMatchedWordIndexes(queryWords, creator))
}
}
}
if (cast) {
for (const person of cast) {
const nameScore = getNameMatchScore(query, person.name)
const characterScore = person.character ? getNameMatchScore(query, person.character) : 0
const bestPersonScore = Math.max(nameScore, characterScore)
const nameWordIndexes = getMatchedWordIndexes(queryWords, person.name)
const characterWordIndexes = person.character
? getMatchedWordIndexes(queryWords, person.character)
: []
const useCharacterWords = characterWordIndexes.length > nameWordIndexes.length
const matchedWordIndexes = useCharacterWords ? characterWordIndexes : nameWordIndexes
if (bestPersonScore > 0) {
const role = person.character || "Cast"
const highlightRoles = characterScore > nameScore
addCandidate(person.name, role, highlightRoles, bestPersonScore, matchedWordIndexes)
} else {
addCandidate(person.name, person.character || "Cast", false, 0, matchedWordIndexes)
}
}
}
for (const candidate of candidates) {
if (candidate.score <= 0) continue
mergePersonMatch(matchedPeople, candidate)
if (candidate.score > bestScore) bestScore = candidate.score
}
if (matchedPeople.length > 0) {
return { matches: matchedPeople, score: bestScore }
}
if (queryWords.some((word) => word.length < 2)) {
return { matches: [], score: 0 }
}
const explicitMultiPerson = /[,&+]/.test(query)
const uncoveredWordIndexes = new Set(queryWords.map((_, index) => index))
const selected: Array<{ candidate: PersonCandidate; matchedIndexes: number[] }> = []
const usableCandidates = candidates.filter((candidate) => candidate.matchedWordIndexes.length > 0)
let hasMultiWordChunk = false
while (uncoveredWordIndexes.size > 0) {
let bestCandidate: PersonCandidate | null = null
let bestChunk: number[] = []
let bestCoverage = 0
for (const candidate of usableCandidates) {
if (selected.some((entry) => entry.candidate === candidate)) continue
const chunk = getBestContiguousWordRun(candidate.matchedWordIndexes, uncoveredWordIndexes)
if (chunk.length <= 0) continue
const coverage = candidate.matchedWordIndexes.length
if (
chunk.length > bestChunk.length ||
(chunk.length === bestChunk.length && coverage > bestCoverage)
) {
bestCandidate = candidate
bestChunk = chunk
bestCoverage = coverage
}
}
if (!bestCandidate || bestChunk.length <= 0) break
selected.push({ candidate: bestCandidate, matchedIndexes: bestChunk })
if (bestChunk.length > 1) hasMultiWordChunk = true
for (const index of bestChunk) {
uncoveredWordIndexes.delete(index)
}
}
if (uncoveredWordIndexes.size > 0 || selected.length === 0) {
return { matches: [], score: 0 }
}
// Without explicit separators, require at least one multi-word person chunk.
// This avoids accidental matches like "jackie chan" => "Jackie" + "Chan".
if (!explicitMultiPerson && selected.length > 1 && !hasMultiWordChunk) {
return { matches: [], score: 0 }
}
let multiPersonScore = 0
for (const entry of selected) {
mergePersonMatch(matchedPeople, entry.candidate)
const candidateScore = entry.candidate.score > 0 ? entry.candidate.score : 65
if (candidateScore > multiPersonScore) multiPersonScore = candidateScore
}
return { matches: matchedPeople, score: multiPersonScore }
}
function formatMatchedPeople(people: PersonMatch[]): MatchedPerson[] {
return people.map((p) => ({
name: p.name,
roles: p.roles.join(", "),
highlightRoles: p.highlightRoles,
}))
}
// ---------------------------------------------------------------------------
// MediaItem conversion (lightweight, without data payload)
// ---------------------------------------------------------------------------
function movieToSearchResult(movie: MovieUi): SearchResultItem {
const files = Object.values(movie.files || {})
const resolution = files.length > 0 ? files[0].resolution : null
return {
id: movie.id,
title: movie.title || "Unknown",
year: movie.year,
cover_path: movie.cover_path,
showreel_images: movie.showreel_images,
showreel_source_sets: movie.showreel_source_sets,
type: "movies",
resolution,
root_id: movie.root_id,
}
}
function seriesToSearchResult(series: SeriesUi): SearchResultItem {
const reelImages: string[] = []
const reelSourceSets: string[][] = []
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
if (episode.reel_sources && episode.reel_sources.length > 0) {
reelImages.push(episode.reel_sources[0])
reelSourceSets.push(episode.reel_sources)
} else if (episode.reel_image) {
reelImages.push(episode.reel_image)
reelSourceSets.push([episode.reel_image])
}
}
}
return {
id: series.id,
title: series.title || "Unknown",
year: null,
cover_path: series.cover_path,
showreel_images: reelImages.length > 0 ? reelImages : null,
showreel_source_sets: reelSourceSets.length > 0 ? reelSourceSets : null,
type: "series",
root_id: series.root_id,
}
}
// ---------------------------------------------------------------------------
// Core search with cancellation token
// ---------------------------------------------------------------------------
const MAX_RESULTS = 100
interface ScoredResult {
item: SearchResultItem
score: number
matchType: "movies" | "series" | "people" | "other"
}
interface CancelToken {
id: number
}
let currentSearchId = 0
function isCancelled(token: CancelToken): boolean {
return token.id !== currentSearchId
}
/** Yield control briefly so the worker can receive a new message. */
function yieldControl(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
async function performSearch(
query: string,
token: CancelToken,
): Promise<SearchResponseMessage | null> {
const allScored: ScoredResult[] = []
const processedIds = new Set<string>()
const yearMatch = query.match(/^(\d{4})$/)
const searchYear = yearMatch ? parseInt(yearMatch[1], 10) : null
const isYearQuery = searchYear !== null && searchYear >= 1900 && searchYear <= 2100
const yearBonus = 25
// -------------------------------------------------------------------------
// Search movies
// -------------------------------------------------------------------------
for (let i = 0; i < movies.length; i++) {
if (i % 50 === 0) {
if (isCancelled(token)) return null
await yieldControl()
}
const movie = movies[i]
const titleScore = getBestScore(query, movie.title, movie.info?.original_title)
const yearScore = isYearQuery && movie.year === searchYear ? yearBonus : 0
if (titleScore > 0 || yearScore > 0) {
allScored.push({
item: movieToSearchResult(movie),
score: titleScore + yearScore + (movie.info?.rating ?? 0) / 10,
matchType: "movies",
})
processedIds.add(movie.id)
continue
}
const peopleMatch = matchesPeople(query, movie.info?.cast, movie.info?.director)
if (peopleMatch.matches.length > 0) {
const item = movieToSearchResult(movie)
item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) }
allScored.push({
item,
score: peopleMatch.score + (movie.info?.rating ?? 0) / 10,
matchType: "people",
})
processedIds.add(movie.id)
continue
}
const otherScore = Math.max(
getBestScore(
query,
movie.info?.genres?.join(" "),
movie.info?.keywords?.join(" "),
movie.info?.overview,
movie.info?.tagline,
movie.info?.collection,
),
getMoviePathScore(movie, query),
)
if (otherScore > 0) {
allScored.push({
item: movieToSearchResult(movie),
score: otherScore + (movie.info?.rating ?? 0) / 10,
matchType: "other",
})
processedIds.add(movie.id)
}
}
// -------------------------------------------------------------------------
// Search series
// -------------------------------------------------------------------------
for (let i = 0; i < series.length; i++) {
if (i % 50 === 0) {
if (isCancelled(token)) return null
await yieldControl()
}
const seriesItem = series[i]
const titleScore = getBestScore(query, seriesItem.title, seriesItem.info?.original_title)
const seriesYear = seriesItem.info?.release_date
? parseInt(seriesItem.info.release_date.substring(0, 4), 10)
: null
const yearScore = isYearQuery && seriesYear === searchYear ? yearBonus : 0
if (titleScore > 0 || yearScore > 0) {
allScored.push({
item: seriesToSearchResult(seriesItem),
score: titleScore + yearScore + (seriesItem.info?.rating ?? 0) / 10,
matchType: "series",
})
processedIds.add(seriesItem.id)
continue
}
const matchedEpisodes: MatchedEpisode[] = []
let episodeScore = 0
const isEndedSingleSeason =
(seriesItem.info?.number_of_seasons === 1 || seriesItem.seasons?.length === 1) &&
["Ended", "Canceled", "Cancelled"].includes(seriesItem.info?.status || "")
for (const season of seriesItem.seasons || []) {
for (const episode of season.episodes || []) {
if (episode.name) {
const epScore = getRelevanceScore(query, episode.name)
if (epScore > 0) {
const hideSeason = isEndedSingleSeason || season.season_number === 0
const location = hideSeason
? `Episode ${episode.episode_number}`
: `S${season.season_number} Episode ${episode.episode_number}`
matchedEpisodes.push({
name: episode.name,
location,
seasonNumber: season.season_number,
episodeNumber: episode.episode_number,
})
if (epScore > episodeScore) episodeScore = epScore
}
}
}
}
if (matchedEpisodes.length > 0 && !processedIds.has(seriesItem.id)) {
const item = seriesToSearchResult(seriesItem)
item.searchMatchInfo = { matchedEpisodes }
allScored.push({
item,
score: episodeScore + (seriesItem.info?.rating ?? 0) / 10,
matchType: "series",
})
processedIds.add(seriesItem.id)
continue
}
const peopleMatch = matchesPeople(query, seriesItem.info?.cast, null, seriesItem.info?.creators)
if (peopleMatch.matches.length > 0 && !processedIds.has(seriesItem.id)) {
const item = seriesToSearchResult(seriesItem)
item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) }
allScored.push({
item,
score: peopleMatch.score + (seriesItem.info?.rating ?? 0) / 10,
matchType: "people",
})
processedIds.add(seriesItem.id)
continue
}
if (!processedIds.has(seriesItem.id)) {
const otherScore = Math.max(
getBestScore(
query,
seriesItem.info?.genres?.join(" "),
seriesItem.info?.keywords?.join(" "),
seriesItem.info?.overview,
seriesItem.info?.tagline,
seriesItem.info?.networks?.join(" "),
),
getSeriesPathScore(seriesItem, query),
)
if (otherScore > 0) {
allScored.push({
item: seriesToSearchResult(seriesItem),
score: otherScore + (seriesItem.info?.rating ?? 0) / 10,
matchType: "other",
})
processedIds.add(seriesItem.id)
}
}
}
if (isCancelled(token)) return null
allScored.sort((a, b) => b.score - a.score)
const topResults = allScored.slice(0, MAX_RESULTS)
const moviesCat: SearchResultItem[] = []
const seriesCat: SearchResultItem[] = []
const peopleCat: SearchResultItem[] = []
const otherCat: SearchResultItem[] = []
for (const scored of topResults) {
switch (scored.matchType) {
case "movies":
moviesCat.push(scored.item)
break
case "series":
seriesCat.push(scored.item)
break
case "people":
peopleCat.push(scored.item)
break
case "other":
otherCat.push(scored.item)
break
}
}
const categories: SearchCategoryResult[] = []
if (moviesCat.length > 0) categories.push({ name: "Movies", items: moviesCat })
if (seriesCat.length > 0) categories.push({ name: "Series", items: seriesCat })
if (peopleCat.length > 0) categories.push({ name: "People", items: peopleCat })
if (otherCat.length > 0) categories.push({ name: "Other", items: otherCat })
return {
id: token.id,
results: topResults.map((s) => s.item),
categories,
}
}
// ---------------------------------------------------------------------------
// Worker message handler — single persistent runner
// ---------------------------------------------------------------------------
self.onmessage = (event: MessageEvent<SearchWorkerMessage>) => {
const msg = event.data
if (msg.type === "index") {
movies = msg.movies
series = msg.series
return
}
if (msg.type === "query") {
const { id, query } = msg
currentSearchId = id
const token: CancelToken = { id }
void (async () => {
const response = await performSearch(query, token)
if (!response) {
return
}
self.postMessage(response)
})()
}
}
+123 -124
View File
@@ -27,12 +27,15 @@
box-sizing: border-box; box-sizing: border-box;
} }
html, body { html,
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; body {
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg-primary); background-color: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
height: 100%;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
overflow-y: hidden;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-align: justify; text-align: justify;
hyphens: auto; hyphens: auto;
@@ -40,9 +43,33 @@ html, body {
-ms-hyphens: auto; -ms-hyphens: auto;
} }
html::-webkit-scrollbar,
body::-webkit-scrollbar {
width: 0;
height: 0;
}
html:not(.pointer-visible),
html:not(.pointer-visible) * {
cursor: none !important;
}
#app { #app {
height: 100vh;
min-height: 100vh; min-height: 100vh;
position: relative; position: relative;
overflow: hidden;
}
.scrollbar-hidden {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hidden::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
} }
/* Scrollbar styling */ /* Scrollbar styling */
@@ -60,7 +87,7 @@ html, body {
border-radius: 4px; border-radius: 4px;
} }
::-webkit-scrollbar-thumb:hover { html.mouse-active ::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary); background: var(--text-secondary);
} }
@@ -75,9 +102,28 @@ html, body {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 12px; padding: 0 12px;
isolation: isolate;
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1); transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
} }
.header::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 0;
border-radius: 0;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-fast);
}
.header > * {
position: relative;
z-index: 1;
}
.header-top { .header-top {
top: 40px; top: 40px;
} }
@@ -88,8 +134,8 @@ html, body {
} }
.header-after-movie-header { .header-after-movie-header {
/* Position after movie detail collage-header (300px) */ /* Place header below movie detail collage-header (300px). */
top: 320px; top: 300px;
} }
.header-after-series-hero { .header-after-series-hero {
@@ -97,6 +143,22 @@ html, body {
top: clamp(450px, 70vh, 600px); top: clamp(450px, 70vh, 600px);
} }
.header-after-series-hero::before {
width: 30em;
max-width: calc(100vw - 24px);
opacity: 1;
background: linear-gradient(to bottom, rgba(5, 7, 10, 0.72) 0%, rgba(5, 7, 10, 0.5) 100%);
backdrop-filter: blur(10px) saturate(115%);
-webkit-backdrop-filter: blur(10px) saturate(115%);
}
@media (max-width: 900px) {
.header-after-hero {
/* Match the reduced browse hero height at <=900px. */
top: clamp(350px, 50vh, 600px);
}
}
/* Spacer to reserve space for header in layout */ /* Spacer to reserve space for header in layout */
.header-spacer { .header-spacer {
height: calc(var(--header-height) + 20px); height: calc(var(--header-height) + 20px);
@@ -108,6 +170,11 @@ html, body {
gap: 12px; gap: 12px;
} }
.header-logo-link {
display: inline-flex;
align-items: center;
}
.header-logo { .header-logo {
height: 40px; height: 40px;
width: 40px; width: 40px;
@@ -133,13 +200,18 @@ html, body {
padding: 0; padding: 0;
} }
.header-nav-item:hover, .header-after-movie-header .header-nav-item,
.header-after-series-hero .header-nav-item {
color: rgba(255, 255, 255, 0.78);
}
html.mouse-active .header-nav-item:hover,
.header-nav-item.active { .header-nav-item.active {
color: var(--text-primary); color: var(--text-primary);
} }
.header-nav-item:focus, .header-nav-item:focus,
.header-nav-item.nav-focused { html:not(.mouse-active) .header-nav-item.nav-focused {
color: var(--text-primary); color: var(--text-primary);
outline: none; outline: none;
text-decoration: underline; text-decoration: underline;
@@ -170,7 +242,7 @@ html, body {
transition: color var(--transition-fast); transition: color var(--transition-fast);
} }
.header-settings-btn:hover { html.mouse-active .header-settings-btn:hover {
color: var(--text-primary); color: var(--text-primary);
} }
@@ -205,84 +277,10 @@ html, body {
padding-top: 40px; padding-top: 40px;
} }
/* Hero section - kept for backwards compatibility but not used */
.hero {
position: relative;
height: 70vh;
max-height: 600px;
min-height: 400px;
display: flex;
align-items: flex-end;
padding: 0 4% 8%;
background-color: var(--bg-primary);
overflow: hidden;
}
.hero-background {
position: absolute;
top: 0;
right: 0;
width: 60%;
height: 100%;
background-size: contain;
background-position: right top;
background-repeat: no-repeat;
mask-image: linear-gradient(to left, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.3) 60%, transparent 100%);
-webkit-mask-image: linear-gradient(to left, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.3) 60%, transparent 100%);
}
.hero::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 200px;
background: var(--gradient-fade);
}
.hero-content {
position: relative;
z-index: 1;
max-width: 600px;
}
.hero-title {
font-size: 3rem;
font-weight: 700;
margin-bottom: 16px;
text-shadow: 2px 2px 4px var(--shadow-color);
}
.hero-meta {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
font-size: 1rem;
}
.hero-year {
color: var(--text-secondary);
}
.hero-quality {
background: var(--bg-secondary);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.hero-buttons {
display: flex;
gap: 12px;
margin-top: 24px;
}
/* Blinking animation for button focus */ /* Blinking animation for button focus */
@keyframes btn-outline-blink { @keyframes btn-outline-blink {
0%, 100% { 0%,
100% {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.9); box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.9);
} }
50% { 50% {
@@ -318,8 +316,8 @@ html, body {
color: var(--bg-primary); color: var(--bg-primary);
} }
.btn-primary:hover, html.mouse-active .btn-primary:hover,
.btn-primary.nav-focused { html:not(.mouse-active) .btn-primary.nav-focused {
background: rgba(255, 255, 255, 0.85); background: rgba(255, 255, 255, 0.85);
animation: btn-outline-blink 1s ease-in-out infinite; animation: btn-outline-blink 1s ease-in-out infinite;
} }
@@ -329,8 +327,8 @@ html, body {
color: var(--text-primary); color: var(--text-primary);
} }
.btn-secondary:hover, html.mouse-active .btn-secondary:hover,
.btn-secondary.nav-focused { html:not(.mouse-active) .btn-secondary.nav-focused {
background: rgba(109, 109, 110, 0.5); background: rgba(109, 109, 110, 0.5);
animation: btn-outline-blink 1s ease-in-out infinite; animation: btn-outline-blink 1s ease-in-out infinite;
} }
@@ -346,11 +344,15 @@ html, body {
} }
.view-zoom-enter-active { .view-zoom-enter-active {
transition: opacity 0.4s ease-out, transform 0.4s ease-out; transition:
opacity 0.4s ease-out,
transform 0.4s ease-out;
} }
.view-zoom-leave-active { .view-zoom-leave-active {
transition: opacity 0.3s ease-in, transform 0.3s ease-in; transition:
opacity 0.3s ease-in,
transform 0.3s ease-in;
} }
.view-zoom-enter-from { .view-zoom-enter-from {
@@ -365,7 +367,7 @@ html, body {
/* Media rows */ /* Media rows */
.media-section { .media-section {
padding: 0 var(--section-padding); padding: 0;
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -373,17 +375,22 @@ html, body {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
margin-bottom: 10px; margin-bottom: 10px;
padding: 0 calc(2px + var(--section-padding));
color: var(--text-primary); color: var(--text-primary);
} }
.media-row { .media-row {
--sync-row-tail: 0px;
display: flex; display: flex;
gap: 6px; gap: 6px;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
padding-bottom: 8px; padding-bottom: 8px;
margin: 0 -2px; margin: 0 -2px;
padding: 4px 2px 8px; /* Tail padding is added only on the right so that increasing it does not
shift items to the right (which would break safe-zone calculations). */
padding: 4px calc(2px + var(--section-padding) + var(--sync-row-tail)) 8px
calc(2px + var(--section-padding));
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
} }
@@ -406,13 +413,21 @@ html, body {
flex-shrink: 0; flex-shrink: 0;
width: var(--card-width); width: var(--card-width);
cursor: pointer; cursor: pointer;
transition: z-index 0s, box-shadow var(--transition-medium); transition:
z-index 0s,
box-shadow var(--transition-medium);
position: relative; position: relative;
outline: none; outline: none;
text-decoration: none;
color: inherit;
/* Skip rendering work for cards scrolled out of view (long rows). Width is
fixed; the intrinsic height is only a pre-first-render estimate. */
content-visibility: auto;
contain-intrinsic-height: auto 330px;
} }
.media-card:hover, html.mouse-active .media-card:hover,
.media-card.nav-focused { html:not(.mouse-active) .media-card.nav-focused {
z-index: 10; z-index: 10;
} }
@@ -434,7 +449,7 @@ html, body {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
} }
.media-card:hover .media-card-poster { html.mouse-active .media-card:hover .media-card-poster {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
} }
@@ -480,7 +495,7 @@ html, body {
transition: opacity var(--transition-fast); transition: opacity var(--transition-fast);
} }
.media-card:hover .media-card-info { html.mouse-active .media-card:hover .media-card-info {
opacity: 1; opacity: 1;
} }
@@ -525,6 +540,14 @@ html, body {
justify-content: center; justify-content: center;
padding: 40px 20px; padding: 40px 20px;
overflow-y: auto; overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.modal-overlay::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
} }
.modal-content { .modal-content {
@@ -556,7 +579,7 @@ html, body {
} }
.modal-header::after { .modal-header::after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
@@ -584,7 +607,7 @@ html, body {
transition: background var(--transition-fast); transition: background var(--transition-fast);
} }
.modal-close:hover { html.mouse-active .modal-close:hover {
background: var(--bg-card); background: var(--bg-card);
} }
@@ -680,7 +703,7 @@ html, body {
transition: background var(--transition-fast); transition: background var(--transition-fast);
} }
.release-item:hover { html.mouse-active .release-item:hover {
background: var(--bg-card-hover); background: var(--bg-card-hover);
} }
@@ -787,23 +810,6 @@ html, body {
--section-padding: 3%; --section-padding: 3%;
} }
.hero {
height: 50vh;
min-height: 300px;
}
.hero-title {
font-size: 1.5rem;
}
.header-nav {
gap: 10px;
}
.search-input {
width: 140px;
}
.modal-title { .modal-title {
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -816,13 +822,6 @@ html, body {
@media (max-width: 480px) { @media (max-width: 480px) {
:root { :root {
--card-width: 100px; --card-width: 100px;
--header-height: 48px;
}
.header-logo {
height: 32px;
width: 32px;
margin-right: 12px;
} }
.media-card-info { .media-card-info {
+182 -118
View File
@@ -1,189 +1,253 @@
// Type definitions for the media browser // Type definitions for the media browser
export type CastGender = "female" | "male" | "non_binary" | "unknown"
export interface CastMember { export interface CastMember {
name: string; name: string
character?: string | null; character?: string | null
profile_path: string | null; profile_path: string | null
gender?: CastGender | null
id?: number | null
} }
export interface SimilarMedia { export type CastCreditWire = [character: string | null, id: number | null]
id: number; export type PersonWire = [name: string, profile_path: string | null, gender: CastGender | null]
title: string;
poster_path: string | null; export interface Person {
name: string
profile_path: string | null
gender?: CastGender | null
} }
export interface Info { export interface Info {
tmdb_id: number; tmdb_id: number
title: string | null; title: string | null
original_title: string | null; original_title: string | null
alternative_titles: string[] | null; original_language: string | null
rating: number | null; alternative_titles: string[] | null
vote_count: number | null; rating: number | null
overview: string | null; vote_count: number | null
genres: string[] | null; overview: string | null
release_date: string | null; genres: string[] | null
runtime: number | null; release_date: string | null
status: string | null; runtime: number | null
tagline: string | null; collection: string | null
poster_path: string | null; status: string | null
backdrop_path: string | null; tagline: string | null
similar: SimilarMedia[] | null; keywords: string[] | null
keywords: string[] | null; cast: CastMember[] | null
cast: CastMember[] | null; director: string | null
director: string | null; creators: string[] | null
creators: string[] | null; number_of_seasons: number | null
number_of_seasons: number | null; number_of_episodes: number | null
number_of_episodes: number | null; networks: string[] | null
networks: string[] | null;
} }
export interface Torrent { export interface Torrent {
title: string | null; title: string | null
playable_file: string | null; playable_file: string | null
resolution: string | null; resolution: string | null
quality: string | null; quality: string | null
codec: string | null; network: string | null
audio: string | null; codec: string | null
encoder: string | null; audio: string | null
size: number | null; audio_languages: string[] | null
added_at: number | null; subtitle_languages: string[] | null
external_subtitle_languages?: string[] | null
hdr?: boolean
dovi?: boolean
atmos?: boolean
hdr10plus?: boolean
encoder: string | null
size: number | null
added_at: number | null
root_id?: string | null
} }
export interface Movie { export interface Movie {
id: string; title: string | null
title: string | null; info: Info | null
info: Info | null; year: number | null
year: number | null; newest: number | null
newest: number | null; cover_path: string | null
cover_path: string | null; backdrop_path: string | null
backdrop_path: string | null; showreel_images: string[] | null
showreel_images: string[] | null; showreel_source_sets: string[][] | null
torrents: { [key: string]: Torrent }; files: { [key: string]: Torrent }
} }
export interface Episode { export interface Episode {
episode_number: number; episode_number: number
name: string | null; name: string | null
overview: string | null; overview: string | null
air_date: string | null; air_date: string | null
runtime: number | null; runtime: number | null
still_path: string | null; still_path: string | null
rating: number | null; rating: number | null
director: string | null; director: string | null
reel_image: string | null; reel_image: string | null
torrents: { [key: string]: Torrent }; reel_sources: string[] | null
files: { [key: string]: Torrent }
} }
export interface Season { export interface Season {
season_number: number; season_number: number
name: string | null; name: string | null
overview: string | null; overview: string | null
air_date: string | null; air_date: string | null
poster_path: string | null; poster_path: string | null
episode_count: number | null; episode_count: number | null
episodes: Episode[]; episodes: Episode[]
} }
export interface Series { export interface Series {
id: string; title: string | null
title: string | null; info: Info | null
info: Info | null; alternative_titles: string[] | null
alternative_titles: string[] | null; newest: number | null
newest: number | null; cover_path: string | null
cover_path: string | null; backdrop_path: string | null
backdrop_path: string | null; seasons: Season[]
seasons: Season[]; }
/** A series' single continue point (last watched position). */
export interface SeriesResumePoint {
seasonNumber: number
episodeNumber: number
positionSeconds: number
}
export interface MovieUi extends Movie {
id: string
root_id: string | null
}
export interface SeriesUi extends Series {
id: string
root_id: string | null
} }
export interface MediaStats { export interface MediaStats {
total_movies: number; total_movies: number
total_movie_versions?: number; total_movie_versions?: number
total_series: number; total_series: number
total_series_episodes?: number; total_series_episodes?: number
} }
export interface MediaIndex { export interface MediaIndex {
version: number; v: number
generated_at: string; generated_at: string
stats: MediaStats; movies: MovieUi[]
movies: Movie[]; series: SeriesUi[]
series: Series[];
} }
export type MediaType = 'movies' | 'series' | 'episode'; export type MediaType = "movies" | "series" | "episode"
// Matched person info for search results // Matched person info for search results
export interface MatchedPerson { export interface MatchedPerson {
name: string; name: string
roles: string; // e.g., "Director", "Tony Stark", "Creator" roles: string // e.g., "Director", "Tony Stark", "Creator"
highlightRoles: boolean; // true if the roles/character matched (vs the name) highlightRoles: boolean // true if the roles/character matched (vs the name)
} }
// Matched episode info for search results // Matched episode info for search results
export interface MatchedEpisode { export interface MatchedEpisode {
name: string; // Episode name (highlighted) name: string // Episode name (highlighted)
location: string; // "SN Episode M" (dimmed) location: string // "SN Episode M" (dimmed)
seasonNumber: number; // For navigation to episode seasonNumber: number // For navigation to episode
episodeNumber: number; // For navigation to episode episodeNumber: number // For navigation to episode
} }
// Info about why a search matched this item // Info about why a search matched this item
export interface SearchMatchInfo { export interface SearchMatchInfo {
// Matched people with their roles/characters // Matched people with their roles/characters
matchedPeople?: MatchedPerson[]; matchedPeople?: MatchedPerson[]
// Matched episodes for series // Matched episodes for series
matchedEpisodes?: MatchedEpisode[]; matchedEpisodes?: MatchedEpisode[]
} }
export interface MediaItem { export interface MediaItem {
id: string; id: string
title: string | null; title: string | null
year?: number | null; year?: number | null
cover_path: string | null; cover_path: string | null
showreel_images?: string[] | null; showreel_images?: string[] | null
type: MediaType; showreel_source_sets?: string[][] | null
resolution?: string | null; type: MediaType
data: Movie | Series | EpisodeWithSeries; resolution?: string | null
data: Movie | Series | EpisodeWithSeries
root_id: string | null
// Optional search match info - only present in search results // Optional search match info - only present in search results
searchMatchInfo?: SearchMatchInfo; searchMatchInfo?: SearchMatchInfo
} }
// Episode with parent series info for standalone display // Episode with parent series info for standalone display
export interface EpisodeWithSeries { export interface EpisodeWithSeries {
episode: Episode; episode: Episode
series: Series; series: SeriesUi
seasonNumber: number; seasonNumber: number
} }
// Task progress info from background scanning // Task progress info from background scanning
export interface TaskInfo { export interface TaskInfo {
id: string; id: string
status: string; status: string
progress: number; progress: number
detail: string; detail: string
} }
// WebSocket message types (matching server msgspec tagged structs) // WebSocket message types (matching server msgspec tagged structs)
export interface WsRootStatus {
root_id: string
path: string
status: string
error: string | null
snapshot_loaded: boolean
movies: number
series: number
}
export interface WsRootInitData {
movies: Record<string, Movie>
series: Record<string, Series>
people?: Record<string, PersonWire>
}
export interface WsRootsMessage {
type: "roots"
roots: WsRootStatus[]
}
export interface WsInitMessage { export interface WsInitMessage {
type: 'init'; type: "init"
data: { movies: Movie[]; series: Series[] }; roots: Record<string, WsRootInitData>
} }
export interface WsUpsertMessage { export interface WsUpsertMessage {
type: 'upsert'; type: "upsert"
kind: 'movie' | 'series'; root_id: string
item: Movie | Series; kind: "movie" | "series"
id: string
item: Movie | Series
people?: Record<string, PersonWire>
} }
export interface WsRemoveMessage { export interface WsRemoveMessage {
type: 'remove'; type: "remove"
kind: 'movie' | 'series'; root_id: string
id: string; kind: "movie" | "series"
id: string
} }
export interface WsTaskMessage { export interface WsTaskMessage {
type: 'task'; type: "task"
data: TaskInfo; root_id: string
data: TaskInfo
} }
export type WsMessage = WsInitMessage | WsUpsertMessage | WsRemoveMessage | WsTaskMessage; export type WsMessage =
| WsRootsMessage
| WsInitMessage
| WsUpsertMessage
| WsRemoveMessage
| WsTaskMessage
+605
View File
@@ -0,0 +1,605 @@
import * as flagSvgs from "country-flag-icons/string/3x2"
export interface LanguageFlagEntry {
countryCode: string
svg: string
sourceCodes: string[]
}
const FLAGS = flagSvgs as Record<string, string>
const LANGUAGE_TO_COUNTRY: Record<string, string> = {
// English
en: "GB",
eng: "GB",
// Spanish (including LATAM variants collapsed to Spain flag)
es: "ES",
spa: "ES",
esp: "ES",
esl: "ES",
spl: "ES",
"es-es": "ES",
"es-419": "ES",
"spa-la": "ES",
// Portuguese (Brazilian variant collapses to Portugal flag)
pt: "PT",
por: "PT",
"pt-pt": "PT",
"pt-br": "PT",
// Major European languages
fr: "FR",
fra: "FR",
fre: "FR",
de: "DE",
deu: "DE",
ger: "DE",
it: "IT",
ita: "IT",
nl: "NL",
nld: "NL",
dut: "NL",
sv: "SE",
swe: "SE",
no: "NO",
nor: "NO",
da: "DK",
dan: "DK",
fi: "FI",
fin: "FI",
pl: "PL",
pol: "PL",
cs: "CZ",
ces: "CZ",
cze: "CZ",
hu: "HU",
hun: "HU",
ro: "RO",
ron: "RO",
rum: "RO",
el: "GR",
gre: "GR",
ell: "GR",
tr: "TR",
tur: "TR",
// Slavic / Eurasian
ru: "RU",
rus: "RU",
uk: "UA",
ukr: "UA",
bg: "BG",
bul: "BG",
sr: "RS",
srp: "RS",
hr: "HR",
hrv: "HR",
sl: "SI",
slv: "SI",
sk: "SK",
slk: "SK",
slo: "SK",
// East / South / SE Asia
ja: "JP",
jpn: "JP",
ko: "KR",
kor: "KR",
zh: "CN",
zho: "CN",
chi: "CN",
yue: "HK",
th: "TH",
tha: "TH",
vi: "VN",
vie: "VN",
id: "ID",
ind: "ID",
ms: "MY",
msa: "MY",
may: "MY",
hi: "IN",
hin: "IN",
// Middle East / Africa
ar: "SA",
ara: "SA",
he: "IL",
heb: "IL",
fa: "IR",
fas: "IR",
per: "IR",
ur: "PK",
urd: "PK",
sw: "TZ",
swa: "TZ",
// Other common
ca: "ES",
cat: "ES",
eu: "ES",
baq: "ES",
eus: "ES",
gl: "ES",
glg: "ES",
// Additional ISO 639-2 codes (bibliographic + terminology)
mk: "MK",
mkd: "MK",
mac: "MK",
et: "EE",
est: "EE",
lv: "LV",
lav: "LV",
lt: "LT",
lit: "LT",
is: "IS",
isl: "IS",
ice: "IS",
ga: "IE",
gle: "IE",
cy: "GB",
cym: "GB",
wel: "GB",
gd: "GB",
gla: "GB",
mt: "MT",
mlt: "MT",
sq: "AL",
sqi: "AL",
alb: "AL",
be: "BY",
bel: "BY",
bs: "BA",
bos: "BA",
scc: "RS",
scr: "HR",
nb: "NO",
nob: "NO",
nn: "NO",
nno: "NO",
kk: "KZ",
kaz: "KZ",
az: "AZ",
aze: "AZ",
hy: "AM",
hye: "AM",
arm: "AM",
ka: "GE",
kat: "GE",
geo: "GE",
uz: "UZ",
uzb: "UZ",
tk: "TM",
tuk: "TM",
tg: "TJ",
tgk: "TJ",
ky: "KG",
kir: "KG",
mn: "MN",
mon: "MN",
bo: "CN",
bod: "CN",
tib: "CN",
my: "MM",
mya: "MM",
bur: "MM",
km: "KH",
khm: "KH",
lo: "LA",
lao: "LA",
si: "LK",
sin: "LK",
ne: "NP",
nep: "NP",
bn: "BD",
ben: "BD",
ta: "IN",
tam: "IN",
te: "IN",
tel: "IN",
kn: "IN",
kan: "IN",
ml: "IN",
mal: "IN",
mr: "IN",
mar: "IN",
gu: "IN",
guj: "IN",
pa: "IN",
pan: "IN",
tl: "PH",
tgl: "PH",
fil: "PH",
af: "ZA",
afr: "ZA",
am: "ET",
amh: "ET",
so: "SO",
som: "SO",
ha: "NG",
hau: "NG",
yo: "NG",
yor: "NG",
ig: "NG",
ibo: "NG",
ku: "TR",
kur: "TR",
ps: "AF",
pus: "AF",
}
function normalizeLanguageCode(code: string): string {
return code.trim().toLowerCase().replace("_", "-")
}
function normalizeLanguageName(name: string): string {
return name.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ")
}
let _browserLanguagePreferences: string[] | null = null
let _browserPreferenceRanks: Map<string, number> | null = null
const LANGUAGE_NAME_TO_CODE: Record<string, string> = {
english: "en",
spanish: "es",
portuguese: "pt",
french: "fr",
german: "de",
italian: "it",
dutch: "nl",
swedish: "sv",
norwegian: "no",
danish: "da",
finnish: "fi",
polish: "pl",
czech: "cs",
hungarian: "hu",
romanian: "ro",
greek: "el",
turkish: "tr",
russian: "ru",
ukrainian: "uk",
bulgarian: "bg",
serbian: "sr",
croatian: "hr",
slovenian: "sl",
slovak: "sk",
japanese: "ja",
korean: "ko",
chinese: "zh",
cantonese: "yue",
thai: "th",
vietnamese: "vi",
indonesian: "id",
malay: "ms",
hindi: "hi",
arabic: "ar",
hebrew: "he",
persian: "fa",
urdu: "ur",
swahili: "sw",
catalan: "ca",
basque: "eu",
}
function getBrowserLanguagePreferences(): string[] {
if (_browserLanguagePreferences) return _browserLanguagePreferences
const preferences: string[] = []
if (typeof navigator !== "undefined") {
if (Array.isArray(navigator.languages)) {
for (const lang of navigator.languages) {
if (typeof lang === "string" && lang.trim()) {
preferences.push(normalizeLanguageCode(lang))
}
}
}
if (typeof navigator.language === "string" && navigator.language.trim()) {
preferences.push(normalizeLanguageCode(navigator.language))
}
}
const deduped = Array.from(new Set(preferences))
_browserLanguagePreferences = deduped
return deduped
}
function getBrowserPreferenceRanks(): Map<string, number> {
if (_browserPreferenceRanks) return _browserPreferenceRanks
const preferences = getBrowserLanguagePreferences()
const ranks = new Map<string, number>()
const display = new Intl.DisplayNames(["en"], { type: "language" })
for (const [index, pref] of preferences.entries()) {
const base = pref.split("-", 1)[0]
if (!ranks.has(pref)) ranks.set(pref, index)
if (!ranks.has(base)) ranks.set(base, index)
const prefName = display.of(pref)
if (prefName) {
const key = normalizeLanguageName(prefName)
if (!ranks.has(key)) ranks.set(key, index)
}
const baseName = display.of(base)
if (baseName) {
const key = normalizeLanguageName(baseName)
if (!ranks.has(key)) ranks.set(key, index)
}
}
_browserPreferenceRanks = ranks
return ranks
}
function resolveLanguageIdentifier(value: string): string {
const normalized = normalizeLanguageCode(value)
if (/^[a-z]{2,3}(?:-[a-z0-9]{2,})?$/i.test(normalized)) {
return normalized
}
const nameKey = normalizeLanguageName(value)
const byName = LANGUAGE_NAME_TO_CODE[nameKey]
if (byName) return byName
return normalized
}
function getPreferenceRank(value: string, preferenceRanks: Map<string, number>): number {
const normalized = resolveLanguageIdentifier(value)
const exact = preferenceRanks.get(normalized)
if (exact !== undefined) return exact
const base = normalized.split("-", 1)[0]
const baseRank = preferenceRanks.get(base)
if (baseRank !== undefined) return baseRank
const nameKey = normalizeLanguageName(toLanguageName(normalized))
const nameRank = preferenceRanks.get(nameKey)
if (nameRank !== undefined) return nameRank
const rawNameRank = preferenceRanks.get(normalizeLanguageName(value))
if (rawNameRank !== undefined) return rawNameRank
return Number.MAX_SAFE_INTEGER
}
export function mapLanguageToCountry(code: string): string | null {
const normalized = resolveLanguageIdentifier(code)
const direct = LANGUAGE_TO_COUNTRY[normalized]
if (direct) return direct
// region-tag style code like en-us / pt-br / es-mx: variants collapse to
// the base language's host-country flag; only fall back to the region
// itself when the base language is unmapped.
const hyphenParts = normalized.split("-")
if (hyphenParts.length >= 2) {
const base = LANGUAGE_TO_COUNTRY[hyphenParts[0]]
if (base) return base
const region = hyphenParts[hyphenParts.length - 1]
if (/^[a-z]{2}$/i.test(region)) {
return region.toUpperCase()
}
}
return null
}
export function buildLanguageFlags(codes: string[] | null | undefined): {
flags: LanguageFlagEntry[]
unmappedCodes: string[]
} {
if (!codes || codes.length === 0) {
return { flags: [], unmappedCodes: [] }
}
const byCountry = new Map<string, string[]>()
const unmapped: string[] = []
const seenUnmapped = new Set<string>()
const preferenceRanks = getBrowserPreferenceRanks()
const ordered = codes
.map((raw, index) => ({ raw, index }))
.filter((v) => Boolean(v.raw && String(v.raw).trim()))
.sort((a, b) => {
const aRank = getPreferenceRank(String(a.raw), preferenceRanks)
const bRank = getPreferenceRank(String(b.raw), preferenceRanks)
if (aRank !== bRank) return aRank - bRank
return a.index - b.index
})
for (const { raw } of ordered) {
if (!raw) continue
const countryCode = mapLanguageToCountry(raw)
if (!countryCode) {
const upper = raw.toUpperCase()
if (!seenUnmapped.has(upper)) {
seenUnmapped.add(upper)
unmapped.push(upper)
}
continue
}
if (!FLAGS[countryCode]) {
const upper = raw.toUpperCase()
if (!seenUnmapped.has(upper)) {
seenUnmapped.add(upper)
unmapped.push(upper)
}
continue
}
const existing = byCountry.get(countryCode) || []
existing.push(raw)
byCountry.set(countryCode, existing)
}
const flags: LanguageFlagEntry[] = Array.from(byCountry.entries()).map(
([countryCode, sourceCodes]) => ({
countryCode,
svg: FLAGS[countryCode],
sourceCodes,
}),
)
return {
flags,
unmappedCodes: unmapped,
}
}
const LANGUAGE_NAME_OVERRIDES: Record<string, string> = {
eng: "English",
spa: "Spanish",
esp: "Spanish",
"spa-la": "Spanish",
"es-419": "Spanish",
esl: "Spanish",
spl: "Spanish",
por: "Portuguese",
"pt-br": "Portuguese",
nob: "Norwegian",
nno: "Norwegian",
fre: "French",
fra: "French",
ger: "German",
deu: "German",
ita: "Italian",
nld: "Dutch",
dut: "Dutch",
swe: "Swedish",
nor: "Norwegian",
dan: "Danish",
fin: "Finnish",
pol: "Polish",
ces: "Czech",
cze: "Czech",
hun: "Hungarian",
ron: "Romanian",
rum: "Romanian",
ell: "Greek",
gre: "Greek",
tur: "Turkish",
rus: "Russian",
ukr: "Ukrainian",
bul: "Bulgarian",
srp: "Serbian",
hrv: "Croatian",
slv: "Slovenian",
slk: "Slovak",
slo: "Slovak",
jpn: "Japanese",
kor: "Korean",
zho: "Chinese",
chi: "Chinese",
yue: "Cantonese",
tha: "Thai",
vie: "Vietnamese",
ind: "Indonesian",
msa: "Malay",
may: "Malay",
hin: "Hindi",
ara: "Arabic",
heb: "Hebrew",
fas: "Persian",
per: "Persian",
urd: "Urdu",
swa: "Swahili",
cat: "Catalan",
eus: "Basque",
baq: "Basque",
}
function toLanguageName(code: string): string {
const normalized = normalizeLanguageCode(code)
const override = LANGUAGE_NAME_OVERRIDES[normalized]
if (override) return override
const display = new Intl.DisplayNames(["en"], { type: "language" })
const candidate = display.of(normalized)
if (candidate) return candidate
const base = normalized.split("-", 1)[0]
const baseOverride = LANGUAGE_NAME_OVERRIDES[base]
if (baseOverride) return baseOverride
const baseCandidate = display.of(base)
if (baseCandidate) return baseCandidate
return code.toUpperCase()
}
function summarizeLanguageCodes(codes: string[] | null | undefined): string {
if (!codes || codes.length === 0) return ""
const names: string[] = []
const seen = new Set<string>()
for (const raw of codes) {
if (!raw) continue
const name = toLanguageName(raw).trim()
if (!name) continue
const key = name.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
names.push(name)
}
return names.join(", ")
}
const REGION_NAME_OVERRIDES: Record<string, string> = {
GB: "UK",
US: "US",
}
function toRegionName(countryCode: string): string {
const override = REGION_NAME_OVERRIDES[countryCode]
if (override) return override
const display = new Intl.DisplayNames(["en"], { type: "region" })
return display.of(countryCode) ?? countryCode
}
export function formatLanguageFlagTitle(
entry: LanguageFlagEntry,
externalCodes?: string[] | null,
): string {
const names: string[] = []
const variants: string[] = []
const external = new Set((externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)))
let hasExternal = false
for (const code of entry.sourceCodes) {
const normalized = resolveLanguageIdentifier(code)
const base = normalized.split("-", 1)[0]
const name = toLanguageName(base)
if (!names.includes(name)) names.push(name)
// Explicit region tags (en-us, es-419) become parenthesized variants;
// plain codes contribute their host country.
const suffix = normalized.split("-").pop() ?? ""
const region =
/^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
? suffix.toUpperCase()
: mapLanguageToCountry(code)
const regionName = region ? toRegionName(region) : null
const variant = external.has(normalized)
? regionName
? `${regionName} srt`
: "srt"
: regionName
if (variant && !variants.includes(variant)) variants.push(variant)
if (external.has(normalized)) hasExternal = true
}
const title = names.join(" / ")
if (variants.length > 1 || hasExternal) return `${title} (${variants.join(", ")})`
return title
}
export function formatAudioSubtitleSummary(
audioCodes: string[] | null | undefined,
subtitleCodes: string[] | null | undefined,
): string {
const audio = summarizeLanguageCodes(audioCodes)
const subs = summarizeLanguageCodes(subtitleCodes)
if (audio && subs) return `${audio} / ${subs}`
return audio || subs
}
+2 -3
View File
@@ -1,8 +1,7 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
declare module '*.vue' { declare module "*.vue" {
import type { DefineComponent } from 'vue' import type { DefineComponent } from "vue"
const component: DefineComponent<{}, {}, any> const component: DefineComponent<{}, {}, any>
export default component export default component
} }
+7 -5
View File
@@ -5,13 +5,14 @@
* Configures Vite for FastAPI backend integration: * Configures Vite for FastAPI backend integration:
* - Proxies /api/* requests to the FastAPI backend * - Proxies /api/* requests to the FastAPI backend
* - Builds to the Python module's frontend-build directory * - Builds to the Python module's frontend-build directory
* - Disables Vite's screen clearing on startup
* *
* Options: * Options:
* paths - Array of paths to proxy (default: ["/api"]) * paths - Array of paths to proxy (default: ['/api'])
*/ */
export default function fastapiVue({ paths = ["/api"] } = {}) { export default function fastapiVue({ paths = ['/api'] } = {}) {
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8420" const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || 'http://localhost:8421'
// Build proxy configuration for each path // Build proxy configuration for each path
const proxy = {} const proxy = {}
@@ -24,11 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
} }
return { return {
name: "vite-plugin-fastapi-mediahive", name: 'vite-plugin-fastapi-mediahive',
config: () => ({ config: () => ({
clearScreen: false,
server: { proxy }, server: { proxy },
build: { build: {
outDir: "../mediahive/frontend-build", outDir: '../mediahive/frontend-build',
emptyOutDir: true, emptyOutDir: true,
}, },
}), }),
+4 -47
View File
@@ -1,6 +1,5 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import { VitePWA } from "vite-plugin-pwa";
import fastapiVue from './vite-plugin-fastapi.js' import fastapiVue from './vite-plugin-fastapi.js'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
@@ -8,52 +7,6 @@ export default defineConfig(async () => ({
plugins: [ plugins: [
fastapiVue(), fastapiVue(),
vue(), vue(),
VitePWA({
registerType: "autoUpdate",
injectRegister: "script",
manifest: {
id: "/",
name: "MediaHive",
short_name: "MediaHive",
description: "Movies and Series",
start_url: "/",
scope: "/",
display_override: ["window-controls-overlay", "fullscreen", "standalone"],
display: "fullscreen",
background_color: "#0a0a0a",
theme_color: "#0a0a0a",
icons: [
{
src: "/mediahive-32.webp",
sizes: "32x32",
type: "image/webp"
},
{
src: "/mediahive.webp",
sizes: "192x192",
type: "image/webp"
}
]
},
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
manifestTransforms: [
async (entries) => {
const manifest = entries.map((entry) =>
entry.url === "index.html" ? { ...entry, url: "/" } : entry
);
return { manifest, warnings: [] };
},
],
navigateFallback: null,
navigateFallbackDenylist: [/^\/api\//],
},
devOptions: {
enabled: false,
},
}),
], ],
// Vite dev server options // Vite dev server options
@@ -62,4 +15,8 @@ export default defineConfig(async () => ({
port: 8420, port: 8420,
strictPort: true, strictPort: true,
}, },
worker: {
format: "es",
},
})); }));
+19
View File
@@ -0,0 +1,19 @@
pre-commit:
parallel: true
commands:
ruff-check:
glob: "*.py"
run: .venv/bin/ruff check --fix {staged_files}
stage_fixed: true
ruff-format:
glob: "*.py"
run: .venv/bin/ruff format {staged_files}
stage_fixed: true
oxlint:
glob: "frontend/src/**"
run: npm --prefix frontend run lint
stage_fixed: true
oxfmt:
glob: "frontend/src/**"
run: npm --prefix frontend run format
stage_fixed: true
+1 -1
View File
@@ -1 +1 @@
"""MediaHive - Media Browser Server""" """MediaHive - Media Browser Server."""
+140 -25
View File
@@ -1,38 +1,104 @@
import argparse """MediaHive CLI entrypoint."""
import os import os
# Must be set before fastapi_vue env bindings are created (mediahive.config).
os.environ["FASTAPI_VUE"] = "MEDIAHIVE"
import argparse
import asyncio
import sys import sys
from pathlib import Path from pathlib import Path
from fastapi_vue import server from fastapi_vue import env, server
from mediahive.config import config
DEFAULT_PORT = 8420 DEFAULT_PORT = 8420
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
def resolve_media_root(path: str | None = None) -> Path: def _configure_windows_event_loop_policy() -> None:
"""Resolve the media root folder from a path, MEDIAHIVE_PATH env, or cwd.""" """Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
match Path(path or os.environ.get("MEDIAHIVE_PATH") or Path.cwd()).parts: if sys.platform != "win32":
case (*rest, ".mediahive", "index.json"): return
... import warnings
case (*rest, ".mediahive"):
... with warnings.catch_warnings():
case rest: warnings.simplefilter("ignore", DeprecationWarning)
... policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None)
mediaroot = Path(*rest).resolve() if policy_cls is None:
if not mediaroot.exists() or not mediaroot.is_dir(): return
sys.stderr.write(f"Error: Folder does not exist: {mediaroot}\n") asyncio.set_event_loop_policy(policy_cls())
sys.exit(1)
return mediaroot
def main(): def _derive_name(path: str) -> str:
"""Derive a root name from a path."""
p = Path(path)
return p.name or p.anchor.strip("/\\").lower() or "media"
def _dev_reload_supervisor() -> None:
"""Windows dev-mode reloader: restart the server process on changes.
uvicorn's own reload cannot work here: it restarts the child with
CTRL_C_EVENT, which is never delivered to a plain spawn child (no own
console process group), so the reloader blocks in join() after the
first reload and the old server — scanner included — keeps running.
And even when the child does restart, uvicorn passes it sockets bound
by the parent; ProactorEventLoop cannot register inherited sockets
with IOCP (WinError 87 on accept), while the selector loop would lose
asyncio subprocess support (ffmpeg/ffprobe showreel generation).
So: watch the package directory ourselves and respawn a fresh child
process that binds its own sockets. The child runs with
MEDIAHIVE_DEV_CHILD=1 and reload disabled. Scanner state is persisted
after every scan, so a non-graceful child exit on reload loses nothing.
"""
import subprocess
import watchfiles
watch_dir = Path(__file__).parent
argv = [sys.executable, "-m", "mediahive", *sys.argv[1:]]
child_env = dict(os.environ, MEDIAHIVE_DEV_CHILD="1")
print(f"Dev reloader: watching {watch_dir}", file=sys.stderr)
proc = subprocess.Popen(argv, env=child_env)
try:
for changes in watchfiles.watch(watch_dir):
changed = sorted({str(Path(p).name) for _, p in changes})
print(
f"Dev reloader: change in {', '.join(changed[:5])} — restarting",
file=sys.stderr,
)
proc.terminate()
proc.wait()
proc = subprocess.Popen(argv, env=child_env)
except KeyboardInterrupt:
pass
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
def main() -> None:
_configure_windows_event_loop_policy()
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="MediaHive - Media scanning, indexing, and streaming" description="MediaHive - Media scanning, indexing, and streaming"
) )
parser.add_argument( parser.add_argument(
"media_folder", "media_folders",
nargs="?", nargs="*",
help="Path to the media folder (default: MEDIAHIVE_PATH or current directory)", metavar="MEDIA_FOLDER",
help=(
"One or more media folders to index "
"(default: none — configure via UI or API)"
),
) )
parser.add_argument( parser.add_argument(
"-l", "-l",
@@ -40,18 +106,67 @@ def main():
action="append", action="append",
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."), help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
) )
parser.add_argument(
"--gui",
action="store_true",
help="Run with GUI (fails if GUI dependencies are not installed)",
)
args = parser.parse_args() args = parser.parse_args()
mediaroot = resolve_media_root(args.media_folder) # --listen implies server-only mode; use --gui to force GUI even with --listen.
os.environ["MEDIAHIVE_PATH"] = mediaroot.as_posix() use_gui = args.gui or not args.listen
if use_gui:
try:
from mediahive.winmain import gui_main
except ImportError as exc:
if args.gui:
raise RuntimeError(
"GUI dependencies are not installed. "
"Install with: uv pip install mediahive[gui]"
) from exc
else:
gui_main()
return
if args.media_folders:
roots: dict[str, str] = {}
for path in args.media_folders:
# Defer filesystem validation to the server so startup is never
# blocked by macOS permission dialogs or missing paths.
p = Path(path).expanduser()
name = _derive_name(p.as_posix())
# Resolve collisions
base_name = name
suffix = 2
while name in roots:
name = f"{base_name}{suffix}"
suffix += 1
roots[name] = p.as_posix()
# Teleported to the server process by fastapi-vue's server.run().
config.roots = roots
if (
env.dev
and sys.platform == "win32"
and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1"
):
_dev_reload_supervisor()
return
dev = {"reload": True, "reload_dirs": ["mediahive"]}
server.run( server.run(
"mediahive.server:app", "mediahive.server:app",
listen=args.listen, listen=args.listen,
default_port=DEFAULT_PORT, default_port=DEFAULT_PORT,
**(dev if DEVMODE else {}), server_header=False,
loop="none" if sys.platform == "win32" else "auto",
reload=Path(__file__).parent if env.dev and sys.platform != "win32" else False,
# fastapi-vue routes the root logger at INFO in dev / WARNING in prod;
# keep our own loggers visible in production too.
log_config={
"loggers": {"mediahive": {"level": "DEBUG" if env.dev else "INFO"}}
},
) )
@@ -0,0 +1,7 @@
Welcome to the MediaHive installer.
During installation and on first launch, macOS may ask you to allow
permissions (for example, access to your media folders or the local
network). Please allow these so MediaHive can find and play your media.
Click Continue to begin.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+27 -16
View File
@@ -1,31 +1,42 @@
"""Platform-appropriate config persistence for MediaHive. r"""Platform-appropriate config persistence for MediaHive.
Config file location: Locations (via platformdirs):
Windows: %APPDATA%\\mediahive\\config.toml Config — Windows: %LOCALAPPDATA%\mediahive\config.toml
macOS: ~/Library/Application Support/mediahive/config.toml macOS: ~/Library/Application Support/mediahive/config.toml
Linux: $XDG_CONFIG_HOME/mediahive/config.toml (~/.config/mediahive/config.toml) Linux: $XDG_CONFIG_HOME/mediahive/config.toml
Logs — Windows: %LOCALAPPDATA%\mediahive\mediahive.log
macOS: ~/Library/Logs/mediahive/mediahive.log
Linux: $XDG_STATE_HOME/mediahive/mediahive.log
""" """
import os
import sys
from pathlib import Path from pathlib import Path
import msgspec import msgspec
import msgspec.toml import msgspec.toml
from fastapi_vue import env
from platformdirs import user_config_path, user_log_path
class Config(msgspec.Struct): class Config(msgspec.Struct, omit_defaults=True):
media_folder: str | None = None roots: dict[str, str] | None = None
# Runtime config shared between the CLI entrypoint and the server process via
# fastapi-vue's env teleport (MEDIAHIVE_CONFIG). Values set here take
# precedence over the persisted config file.
config = env(Config)
def config_dir() -> Path: def config_dir() -> Path:
if sys.platform == "win32": # appauthor=False: avoid the doubled %LOCALAPPDATA%\mediahive\mediahive.
base = Path(os.environ.get("APPDATA") or Path.home()) # roaming=False: config is machine-specific state, not something to sync
elif sys.platform == "darwin": # across a domain profile.
base = Path.home() / "Library" / "Application Support" return user_config_path("mediahive", appauthor=False, roaming=False)
else:
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
return base / "mediahive" def log_dir() -> Path:
# opinion=False: no extra Logs/ subdir; mediahive.log sits beside config.
return user_log_path("mediahive", appauthor=False, opinion=False)
def config_path() -> Path: def config_path() -> Path:
@@ -37,7 +48,7 @@ def load_config() -> Config:
if path.exists(): if path.exists():
try: try:
return msgspec.toml.decode(path.read_bytes(), type=Config) return msgspec.toml.decode(path.read_bytes(), type=Config)
except Exception: except OSError, msgspec.DecodeError, msgspec.ValidationError:
return Config() return Config()
return Config() return Config()
+5 -6
View File
@@ -1,5 +1,4 @@
""" """Hivescan - Continuous media scanning with live WebSocket updates.
Hivescan - Continuous media scanning with live WebSocket updates.
Import from submodules directly: Import from submodules directly:
from mediahive.hivescan.scanner import start, stop from mediahive.hivescan.scanner import start, stop
@@ -9,13 +8,13 @@ Import from submodules directly:
""" """
# Minimal public API - prefer importing from submodules directly # Minimal public API - prefer importing from submodules directly
from mediahive.hivescan.models import ContentType, ContentHash, ParsedContent from mediahive.hivescan.models import ContentHash, ContentType, ParsedContent
from mediahive.hivescan.utils import DEFAULT_OUTPUT_FOLDER, find_common_root from mediahive.hivescan.utils import DEFAULT_OUTPUT_FOLDER, find_common_root
__all__ = [ __all__ = [
"ContentType",
"ContentHash",
"ParsedContent",
"DEFAULT_OUTPUT_FOLDER", "DEFAULT_OUTPUT_FOLDER",
"ContentHash",
"ContentType",
"ParsedContent",
"find_common_root", "find_common_root",
] ]
+30 -14
View File
@@ -1,10 +1,32 @@
import argparse """Hivescan CLI entrypoint."""
import logging
import os import os
# Must be set before fastapi_vue env bindings are created (mediahive.config).
os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE")
import argparse
import asyncio
import logging
import sys
from pathlib import Path from pathlib import Path
from mediahive.config import config
def _configure_windows_event_loop_policy() -> None:
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
if sys.platform != "win32":
return
policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None)
if policy_cls is None:
return
asyncio.set_event_loop_policy(policy_cls())
def main() -> None:
_configure_windows_event_loop_policy()
def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Hivescan server — continuous media scanning with live WS updates.", description="Hivescan server — continuous media scanning with live WS updates.",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -16,11 +38,8 @@ Examples:
Exclude paths by creating .mediahive/scanignore (gitignore syntax). Exclude paths by creating .mediahive/scanignore (gitignore syntax).
The server exposes: The server exposes a unified endpoint:
WS /ws Live index updates & task progress WS /api/ws Live index updates, task progress, and root status changes
POST /api/scan Trigger a new scan
GET /api/status Current server status
GET /api/index Full index as JSON (HTTP fallback)
""", """,
) )
parser.add_argument( parser.add_argument(
@@ -41,12 +60,9 @@ The server exposes:
args = parser.parse_args() args = parser.parse_args()
media_root = Path(args.media_folder).resolve() # Defer filesystem validation to the server; pass raw path via env config.
if not media_root.exists() or not media_root.is_dir(): media_root = Path(args.media_folder).expanduser()
print(f"Error: Folder does not exist: {media_root}") config.roots = {media_root.name or "media": media_root.as_posix()}
exit(1)
os.environ["MEDIAHIVE_PATH"] = str(media_root)
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
+55 -18
View File
@@ -1,21 +1,21 @@
"""TMDb image downloading functions.""" """TMDb image downloading functions."""
import httpx import re
from pathlib import Path from pathlib import Path
from typing import Optional
import httpx
from aiopathlib import AsyncPath from aiopathlib import AsyncPath
from mediahive.hivescan.utils import get_media_folder_path from mediahive.hivescan.utils import get_media_folder_path
# TMDb image configuration # TMDb image configuration
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p" TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
DEFAULT_POSTER_SIZE = "w500" DEFAULT_POSTER_SIZE = "w500"
DEFAULT_BACKDROP_SIZE = "w1280" DEFAULT_BACKDROP_SIZE = "w1280"
DEFAULT_PROFILE_SIZE = "w185"
# Shared async HTTP client (created lazily) # Shared async HTTP client (created lazily)
_image_client: Optional[httpx.AsyncClient] = None _image_client: httpx.AsyncClient | None = None
def _get_image_client() -> httpx.AsyncClient: def _get_image_client() -> httpx.AsyncClient:
@@ -30,13 +30,19 @@ def _get_image_client() -> httpx.AsyncClient:
return _image_client return _image_client
async def _download_image( async def close_image_client() -> None:
url: str, output_path: Path, description: str """Close the persistent image HTTP client if it was created."""
) -> Optional[str]: global _image_client
if _image_client is not None:
await _image_client.aclose()
_image_client = None
async def _download_image(url: str, output_path: Path, description: str) -> str | None:
"""Download an image from URL to output path.""" """Download an image from URL to output path."""
ap = AsyncPath(output_path) ap = AsyncPath(output_path)
if await ap.exists(): if await ap.exists():
return str(output_path) return output_path.as_posix()
try: try:
client = _get_image_client() client = _get_image_client()
@@ -44,8 +50,8 @@ async def _download_image(
response.raise_for_status() response.raise_for_status()
await AsyncPath(output_path.parent).mkdir(parents=True, exist_ok=True) await AsyncPath(output_path.parent).mkdir(parents=True, exist_ok=True)
await ap.write_bytes(response.content) await ap.write_bytes(response.content)
return str(output_path) return output_path.as_posix()
except Exception as e: except (httpx.HTTPError, OSError) as e:
print(f" Failed to download {description}: {e}") print(f" Failed to download {description}: {e}")
return None return None
@@ -53,11 +59,11 @@ async def _download_image(
async def download_cover_image( async def download_cover_image(
poster_path: str, poster_path: str,
title: str, title: str,
year: Optional[int], year: int | None,
media_type: str, media_type: str,
cover_dir: Path, cover_dir: Path,
size: str = DEFAULT_POSTER_SIZE, size: str = DEFAULT_POSTER_SIZE,
) -> Optional[str]: ) -> str | None:
"""Download a cover image from TMDb.""" """Download a cover image from TMDb."""
if not poster_path: if not poster_path:
return None return None
@@ -66,7 +72,7 @@ async def download_cover_image(
cover_path = media_folder / "cover.jpg" cover_path = media_folder / "cover.jpg"
if await AsyncPath(cover_path).exists(): if await AsyncPath(cover_path).exists():
return str(cover_path) return cover_path.as_posix()
url = f"{TMDB_IMAGE_BASE}/{size}{poster_path}" url = f"{TMDB_IMAGE_BASE}/{size}{poster_path}"
print(f" Downloading cover: {title}") print(f" Downloading cover: {title}")
@@ -76,11 +82,11 @@ async def download_cover_image(
async def download_backdrop_image( async def download_backdrop_image(
backdrop_path: str, backdrop_path: str,
title: str, title: str,
year: Optional[int], year: int | None,
media_type: str, media_type: str,
cover_dir: Path, cover_dir: Path,
size: str = DEFAULT_BACKDROP_SIZE, size: str = DEFAULT_BACKDROP_SIZE,
) -> Optional[str]: ) -> str | None:
"""Download a backdrop image from TMDb.""" """Download a backdrop image from TMDb."""
if not backdrop_path: if not backdrop_path:
return None return None
@@ -89,7 +95,7 @@ async def download_backdrop_image(
local_path = media_folder / "backdrop.jpg" local_path = media_folder / "backdrop.jpg"
if await AsyncPath(local_path).exists(): if await AsyncPath(local_path).exists():
return str(local_path) return local_path.as_posix()
url = f"{TMDB_IMAGE_BASE}/{size}{backdrop_path}" url = f"{TMDB_IMAGE_BASE}/{size}{backdrop_path}"
print(f" Downloading backdrop: {title}") print(f" Downloading backdrop: {title}")
@@ -100,7 +106,7 @@ async def download_season_poster(
poster_path: str, poster_path: str,
media_folder: Path, media_folder: Path,
season_num: int, season_num: int,
) -> Optional[str]: ) -> str | None:
"""Download a season poster image from TMDb.""" """Download a season poster image from TMDb."""
if not poster_path: if not poster_path:
return None return None
@@ -108,8 +114,39 @@ async def download_season_poster(
output_path = media_folder / f"season{season_num:02d}.jpg" output_path = media_folder / f"season{season_num:02d}.jpg"
if await AsyncPath(output_path).exists(): if await AsyncPath(output_path).exists():
return str(output_path) return output_path.as_posix()
await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True) await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True)
url = f"{TMDB_IMAGE_BASE}/{DEFAULT_POSTER_SIZE}{poster_path}" url = f"{TMDB_IMAGE_BASE}/{DEFAULT_POSTER_SIZE}{poster_path}"
return await _download_image(url, output_path, f"season {season_num} poster") return await _download_image(url, output_path, f"season {season_num} poster")
async def download_cast_profile(
profile_path: str,
media_folder: Path,
cast_name: str,
person_id: int | None,
size: str = DEFAULT_PROFILE_SIZE,
) -> str | None:
"""Download a cached cast profile image from TMDb."""
if not profile_path:
return None
# Shared people cache avoids duplicating identical actor images per title.
people_dir = media_folder.parent.parent / "people"
safe_name = _slugify_person_name(cast_name) or "Unknown"
person_suffix = str(person_id) if person_id is not None else "unknown"
output_path = people_dir / f"{safe_name}-{person_suffix}.jpg"
if await AsyncPath(output_path).exists():
return output_path.as_posix()
await AsyncPath(people_dir).mkdir(parents=True, exist_ok=True)
url = f"{TMDB_IMAGE_BASE}/{size}{profile_path}"
return await _download_image(url, output_path, f"cast profile for {cast_name}")
def _slugify_person_name(name: str) -> str:
"""Slugify a person name preserving capitals and hyphens; use dots as separators."""
slug = re.sub(r"[^0-9A-Za-z-]+", ".", name)
return re.sub(r"\.+", ".", slug).strip(".")
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -4,7 +4,6 @@ import hashlib
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Optional
class ContentType(Enum): class ContentType(Enum):
@@ -24,7 +23,7 @@ class ContentHash:
size: int = 0 size: int = 0
@classmethod @classmethod
def from_path(cls, path: Path) -> "ContentHash": def from_path(cls, path: Path) -> ContentHash:
"""Generate a content hash based on torrent name.""" """Generate a content hash based on torrent name."""
hash_val = hashlib.md5(path.name.encode()).hexdigest()[:16] hash_val = hashlib.md5(path.name.encode()).hexdigest()[:16]
return cls(path=path, hash=hash_val) return cls(path=path, hash=hash_val)
@@ -38,16 +37,17 @@ class ParsedContent:
name: str name: str
content_type: ContentType content_type: ContentType
title: str title: str
year: Optional[int] = None year: int | None = None
resolution: Optional[str] = None resolution: str | None = None
quality: Optional[str] = None quality: str | None = None
codec: Optional[str] = None network: str | None = None
audio: Optional[str] = None codec: str | None = None
season: Optional[int] = None audio: str | None = None
episode: Optional[int] = None season: int | None = None
episode_name: Optional[str] = None episode: int | None = None
encoder: Optional[str] = None episode_name: str | None = None
language: Optional[str] = None encoder: str | None = None
language: str | None = None
is_directory: bool = False is_directory: bool = False
raw_parsed: dict = field(default_factory=dict) raw_parsed: dict = field(default_factory=dict)
content_hash: Optional[ContentHash] = None content_hash: ContentHash | None = None
+16 -5
View File
@@ -2,12 +2,21 @@
import re import re
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple
import PTN import PTN
from aiopathlib import AsyncPath from aiopathlib import AsyncPath
from mediahive.hivescan.models import ContentHash, ContentType, ParsedContent from mediahive.hivescan.models import ContentHash, ContentType, ParsedContent
from mediahive.hivescan.utils import normalize_resolution_label
_EDGE_NON_ALPHANUMERICS_RE = re.compile(r"^[^0-9A-Za-z]+|[^0-9A-Za-z]+$")
def strip_edge_non_alphanumerics(value: str | None) -> str | None:
"""Remove punctuation from the start and end of PTN scene tags."""
if not value:
return None
return _EDGE_NON_ALPHANUMERICS_RE.sub("", value)
def determine_content_type(parsed: dict) -> ContentType: def determine_content_type(parsed: dict) -> ContentType:
@@ -27,6 +36,7 @@ async def parse_download(path: Path) -> ParsedContent:
"""Parse a downloaded torrent directory/file name.""" """Parse a downloaded torrent directory/file name."""
name = path.name name = path.name
parsed = PTN.parse(name) parsed = PTN.parse(name)
parsed["encoder"] = strip_edge_non_alphanumerics(parsed.get("encoder"))
content_type = determine_content_type(parsed) content_type = determine_content_type(parsed)
content_hash = ContentHash.from_path(path) content_hash = ContentHash.from_path(path)
@@ -36,8 +46,9 @@ async def parse_download(path: Path) -> ParsedContent:
content_type=content_type, content_type=content_type,
title=parsed.get("title", name), title=parsed.get("title", name),
year=parsed.get("year"), year=parsed.get("year"),
resolution=parsed.get("resolution"), resolution=normalize_resolution_label(parsed.get("resolution")),
quality=parsed.get("quality"), quality=parsed.get("quality"),
network=parsed.get("network"),
codec=parsed.get("codec"), codec=parsed.get("codec"),
audio=parsed.get("audio"), audio=parsed.get("audio"),
season=parsed.get("season"), season=parsed.get("season"),
@@ -51,14 +62,14 @@ async def parse_download(path: Path) -> ParsedContent:
) )
def parse_episode_from_filename(filename: str) -> Optional[Tuple[int, int]]: def parse_episode_from_filename(filename: str) -> tuple[int, int] | None:
""" """Parse season and episode numbers from a filename.
Parse season and episode numbers from a filename.
Handles formats: S01E05, 1x05, Season 1 Episode 5 Handles formats: S01E05, 1x05, Season 1 Episode 5
Returns: Returns:
Tuple of (season_number, episode_number) or None if not found Tuple of (season_number, episode_number) or None if not found
""" """
name = filename.lower() name = filename.lower()
+3 -13
View File
@@ -1,5 +1,4 @@
""" """Gitignore-style path matcher for controlling which directories the scanner visits.
Gitignore-style path matcher for controlling which directories the scanner visits.
Reads patterns from ``<media_root>/.mediahive/scanignore``. The file uses the Reads patterns from ``<media_root>/.mediahive/scanignore``. The file uses the
same syntax as ``.gitignore``: same syntax as ``.gitignore``:
@@ -30,8 +29,6 @@ from __future__ import annotations
import re import re
from pathlib import Path from pathlib import Path
from typing import List, Tuple
# Built-in patterns that are always excluded (before user file) # Built-in patterns that are always excluded (before user file)
_BUILTIN_EXCLUDES: list[str] = [ _BUILTIN_EXCLUDES: list[str] = [
@@ -75,11 +72,9 @@ def _pattern_to_regex(pattern: str) -> re.Pattern[str]:
parts.append("(?:.+/)?") parts.append("(?:.+/)?")
i += 3 i += 3
continue continue
else:
parts.append(".*") parts.append(".*")
i += 2 i += 2
continue continue
else:
parts.append("[^/]*") parts.append("[^/]*")
i += 1 i += 1
elif c == "?": elif c == "?":
@@ -94,12 +89,7 @@ def _pattern_to_regex(pattern: str) -> re.Pattern[str]:
regex_str = "".join(parts) regex_str = "".join(parts)
if anchored or has_slash: regex_str = "^" + regex_str if anchored or has_slash else "(?:^|/)" + regex_str
# Match from the start of the relative path
regex_str = "^" + regex_str
else:
# Match against any path component (basename or as suffix after /)
regex_str = "(?:^|/)" + regex_str
# Must match the whole remaining path or be a prefix (directory match) # Must match the whole remaining path or be a prefix (directory match)
regex_str += "(?:/.*)?$" regex_str += "(?:/.*)?$"
@@ -112,7 +102,7 @@ class ScanIgnore:
def __init__(self, media_root: Path) -> None: def __init__(self, media_root: Path) -> None:
self.media_root = media_root.resolve() self.media_root = media_root.resolve()
self._rules: List[Tuple[bool, re.Pattern[str]]] = [] # (negated, regex) self._rules: list[tuple[bool, re.Pattern[str]]] = [] # (negated, regex)
self._load_builtins() self._load_builtins()
self._load_file() self._load_file()
File diff suppressed because it is too large Load Diff
+399 -50
View File
@@ -2,8 +2,11 @@
import asyncio import asyncio
import glob import glob
import operator
import os
import threading
from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple
from aiopathlib import AsyncPath from aiopathlib import AsyncPath
@@ -11,7 +14,6 @@ from mediahive.hivescan.models import ContentType, ParsedContent
from mediahive.hivescan.parsing import parse_download, parse_episode_from_filename from mediahive.hivescan.parsing import parse_download, parse_episode_from_filename
from mediahive.hivescan.utils import get_media_folder_path, sanitize_filename from mediahive.hivescan.utils import get_media_folder_path, sanitize_filename
# Video file extensions # Video file extensions
VIDEO_EXTENSIONS = { VIDEO_EXTENSIONS = {
".mkv", ".mkv",
@@ -26,23 +28,118 @@ VIDEO_EXTENSIONS = {
".m2ts", ".m2ts",
} }
# Caches for expensive operations # External subtitle file extensions
_episode_files_cache: Dict[str, Dict[Tuple[int, int], List[Tuple[str, int]]]] = {} SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub"}
_playable_file_cache: Dict[str, Optional[str]] = {}
# Non-language tokens that may follow the language in a sidecar filename
_SUBTITLE_FLAG_TOKENS = {"forced", "sdh", "cc", "hi", "dhi", "commentary", "signs"}
# ISO 639-1 -> ISO 639-2/B for common sidecar language tags, so they merge
# with the codes ffmpeg reports for embedded tracks.
_ISO_639_1_TO_639_2 = {
"ar": "ara",
"cs": "ces",
"da": "dan",
"de": "deu",
"el": "ell",
"en": "eng",
"es": "esp",
"fi": "fin",
"fr": "fra",
"he": "heb",
"hi": "hin",
"hu": "hun",
"id": "ind",
"it": "ita",
"ja": "jpn",
"ko": "kor",
"nl": "nld",
"no": "nor",
"pl": "pol",
"pt": "por",
"ru": "rus",
"sv": "swe",
"th": "tha",
"tr": "tur",
"uk": "ukr",
"vi": "vie",
"zh": "zho",
}
# Caches for expensive operations. These are per-scan only: the scanner
# clears them at the start of every scan. Caching across scans is wrong —
# an empty result recorded before a download finished (or during a transient
# network-mount error) would stick for the process lifetime and report
# "no episodes found" for series that do have episodes.
_episode_files_cache: dict[str, dict[tuple[int, int], list[tuple[str, int]]]] = {}
_playable_file_cache: dict[str, str | None] = {}
_bluray_probe_file_cache: dict[str, str | None] = {}
async def scan_downloads(base_pattern: str) -> List[ParsedContent]: def clear_scan_caches() -> None:
""" """Drop all per-scan filesystem caches; called at the start of each scan."""
Scan download directories matching the pattern. _episode_files_cache.clear()
_playable_file_cache.clear()
_bluray_probe_file_cache.clear()
def _scandir_split(
directory: Path,
stop_event: threading.Event,
) -> tuple[list[Path], list[Path]]:
"""Return child directories and files, checking stop_event each iteration."""
child_dirs: list[Path] = []
child_files: list[Path] = []
with os.scandir(directory) as entries:
for entry in entries:
if stop_event.is_set():
return child_dirs, child_files
try:
is_dir = entry.is_dir(follow_symlinks=False)
except OSError:
continue
p = Path(entry.path)
if is_dir:
child_dirs.append(p)
else:
child_files.append(p)
return child_dirs, child_files
def _scandir_files_with_suffix(
directory: Path,
suffixes: set[str],
stop_event: threading.Event,
) -> list[Path]:
"""Return files in directory with a matching suffix, cancellable via stop_event."""
files: list[Path] = []
with os.scandir(directory) as entries:
for entry in entries:
if stop_event.is_set():
return files
try:
if entry.is_dir(follow_symlinks=False):
continue
except OSError:
continue
p = Path(entry.path)
if p.suffix.lower() in suffixes:
files.append(p)
return files
async def scan_downloads(base_pattern: str) -> list[ParsedContent]:
"""Scan download directories matching the pattern.
Args: Args:
base_pattern: Glob pattern for finding download directories base_pattern: Glob pattern for finding download directories
Returns: Returns:
List of ParsedContent objects for each found download List of ParsedContent objects for each found download
""" """
exclude_patterns = [".torrents", "incomplete", ".incomplete"] exclude_patterns = [".torrents", "incomplete", ".incomplete"]
results: List[ParsedContent] = [] results: list[ParsedContent] = []
paths = await asyncio.to_thread(glob.glob, base_pattern) paths = await asyncio.to_thread(glob.glob, base_pattern)
for path_str in paths: for path_str in paths:
@@ -81,57 +178,74 @@ def categorize_downloads(
async def find_episode_files( async def find_episode_files(
path: Path, path: Path,
) -> Dict[Tuple[int, int], List[Tuple[str, int]]]: ) -> dict[tuple[int, int], list[tuple[str, int]]]:
""" """Find all episode video files in a directory.
Find all episode video files in a directory.
Args: Args:
path: Path to search (can be a season pack directory or single file) path: Path to search (can be a season pack directory or single file)
Returns: Returns:
Dict mapping (season_num, episode_num) to list of (file_path, file_size) tuples Dict mapping (season_num, episode_num) to list of (file_path, file_size) tuples
""" """
cache_key = str(path) cache_key = path.as_posix()
if cache_key in _episode_files_cache: if cache_key in _episode_files_cache:
return _episode_files_cache[cache_key] return _episode_files_cache[cache_key]
episodes: Dict[Tuple[int, int], List[Tuple[str, int]]] = {} episodes: dict[tuple[int, int], list[tuple[str, int]]] = {}
ap = AsyncPath(path) ap = AsyncPath(path)
if await ap.is_file(): if await ap.is_file():
if path.suffix.lower() in VIDEO_EXTENSIONS: if path.suffix.lower() in VIDEO_EXTENSIONS:
ep_info = parse_episode_from_filename(path.name) ep_info = parse_episode_from_filename(path.name)
if ep_info: if ep_info:
episodes[ep_info] = [(str(path), (await ap.stat()).st_size)] episodes[ep_info] = [(path.as_posix(), (await ap.stat()).st_size)]
_episode_files_cache[cache_key] = episodes _episode_files_cache[cache_key] = episodes
return episodes return episodes
stack = [path]
while stack:
current = stack.pop()
stop_event = threading.Event()
try: try:
for f in ap.rglob("*"): child_dirs, child_files = await asyncio.to_thread(
af = AsyncPath(f) _scandir_split,
if await af.is_file() and Path(f).suffix.lower() in VIDEO_EXTENSIONS: current,
if "sample" in Path(f).name.lower(): stop_event,
)
except asyncio.CancelledError:
stop_event.set()
raise
except OSError, PermissionError:
continue continue
ep_info = parse_episode_from_filename(Path(f).name)
stack.extend(child_dirs)
for f in child_files:
if f.suffix.lower() not in VIDEO_EXTENSIONS:
continue
if "sample" in f.name.lower():
continue
af = AsyncPath(f)
try:
ep_info = parse_episode_from_filename(f.name)
if ep_info: if ep_info:
if ep_info not in episodes: if ep_info not in episodes:
episodes[ep_info] = [] episodes[ep_info] = []
episodes[ep_info].append((str(f), (await af.stat()).st_size)) episodes[ep_info].append((f.as_posix(), (await af.stat()).st_size))
except OSError, PermissionError: except OSError, PermissionError:
pass continue
_episode_files_cache[cache_key] = episodes _episode_files_cache[cache_key] = episodes
return episodes return episodes
async def find_playable_file(path: Path) -> Optional[str]: async def find_playable_file(path: Path) -> str | None:
""" """Find the main playable media file in a directory.
Find the main playable media file in a directory.
For Blu-ray discs: Returns BDMV/index.bdmv For Blu-ray discs: Returns BDMV/MovieObject.bdmv (fallback: BDMV/index.bdmv)
For other content: Returns the largest video file For other content: Returns the largest video file
""" """
cache_key = str(path) cache_key = path.as_posix()
if cache_key in _playable_file_cache: if cache_key in _playable_file_cache:
return _playable_file_cache[cache_key] return _playable_file_cache[cache_key]
@@ -139,71 +253,306 @@ async def find_playable_file(path: Path) -> Optional[str]:
if await ap.is_file(): if await ap.is_file():
if path.suffix.lower() in VIDEO_EXTENSIONS: if path.suffix.lower() in VIDEO_EXTENSIONS:
result = str(path) result = path.as_posix()
_playable_file_cache[cache_key] = result _playable_file_cache[cache_key] = result
return result return result
_playable_file_cache[cache_key] = None _playable_file_cache[cache_key] = None
return None return None
# Check for Blu-ray disc structure # Check for Blu-ray disc structure
bdmv_index = path / "BDMV" / "index.bdmv" bdmv_dir = path / "BDMV"
bdmv_movieobject = bdmv_dir / "MovieObject.bdmv"
bdmv_index = bdmv_dir / "index.bdmv"
if await AsyncPath(bdmv_movieobject).exists():
result = bdmv_movieobject.as_posix()
_playable_file_cache[cache_key] = result
return result
if await AsyncPath(bdmv_index).exists(): if await AsyncPath(bdmv_index).exists():
result = str(bdmv_index) result = bdmv_index.as_posix()
_playable_file_cache[cache_key] = result
return result
# Check for DVD disc structure
video_ts_dir = path / "VIDEO_TS"
video_ts_ifo = video_ts_dir / "VIDEO_TS.IFO"
if await AsyncPath(video_ts_ifo).exists():
result = video_ts_ifo.as_posix()
_playable_file_cache[cache_key] = result _playable_file_cache[cache_key] = result
return result return result
# Check nested Blu-ray structure (e.g., MovieName/DISC1/BDMV/) # Check nested Blu-ray structure (e.g., MovieName/DISC1/BDMV/)
try: try:
for subdir in ap.iterdir(): stop_event = threading.Event()
if await AsyncPath(subdir).is_dir(): child_dirs, _ = await asyncio.to_thread(
nested_bdmv = Path(subdir) / "BDMV" / "index.bdmv" _scandir_split,
if await AsyncPath(nested_bdmv).exists(): path,
result = str(nested_bdmv) stop_event,
)
except asyncio.CancelledError:
stop_event.set()
raise
except OSError, PermissionError:
child_dirs = []
for subdir in child_dirs:
nested_bdmv_dir = subdir / "BDMV"
nested_movieobject = nested_bdmv_dir / "MovieObject.bdmv"
nested_index = nested_bdmv_dir / "index.bdmv"
if await AsyncPath(nested_movieobject).exists():
result = nested_movieobject.as_posix()
_playable_file_cache[cache_key] = result
return result
if await AsyncPath(nested_index).exists():
result = nested_index.as_posix()
_playable_file_cache[cache_key] = result
return result
nested_video_ts_dir = subdir / "VIDEO_TS"
nested_video_ts_ifo = nested_video_ts_dir / "VIDEO_TS.IFO"
if await AsyncPath(nested_video_ts_ifo).exists():
result = nested_video_ts_ifo.as_posix()
_playable_file_cache[cache_key] = result _playable_file_cache[cache_key] = result
return result return result
except OSError, PermissionError:
pass
# Find largest video file # Find largest video file
video_files = [] video_files = []
stack = [path]
while stack:
current = stack.pop()
stop_event = threading.Event()
try: try:
for f in ap.rglob("*"): child_dirs, child_files = await asyncio.to_thread(
af = AsyncPath(f) _scandir_split,
if await af.is_file() and Path(f).suffix.lower() in VIDEO_EXTENSIONS: current,
if "sample" in Path(f).name.lower(): stop_event,
continue )
video_files.append((str(f), (await af.stat()).st_size)) except asyncio.CancelledError:
stop_event.set()
raise
except OSError, PermissionError: except OSError, PermissionError:
pass continue
stack.extend(child_dirs)
for f in child_files:
if f.suffix.lower() not in VIDEO_EXTENSIONS:
continue
if "sample" in f.name.lower():
continue
af = AsyncPath(f)
try:
video_files.append((f.as_posix(), (await af.stat()).st_size))
except OSError, PermissionError:
continue
if not video_files: if not video_files:
_playable_file_cache[cache_key] = None _playable_file_cache[cache_key] = None
return None return None
video_files.sort(key=lambda x: x[1], reverse=True) video_files.sort(key=operator.itemgetter(1), reverse=True)
result = video_files[0][0] result = video_files[0][0]
_playable_file_cache[cache_key] = result _playable_file_cache[cache_key] = result
return result return result
def _sidecar_subtitle_language(video_stem: str, filename: str) -> str | None:
"""Language tag from a sidecar subtitle name like `<stem>.esp.srt`, if any."""
if not filename.startswith(video_stem + "."):
return None
suffix = Path(filename).suffix.lower()
if suffix not in SUBTITLE_EXTENSIONS:
return None
middle = filename[len(video_stem) + 1 : -len(suffix)]
tokens = [t for t in middle.split(".") if t]
while tokens and tokens[-1].lower() in _SUBTITLE_FLAG_TOKENS:
tokens.pop()
if not tokens:
return None
code = tokens[-1].lower()
if not code.isalpha() or not 2 <= len(code) <= 3:
return None
code = _ISO_639_1_TO_639_2.get(code, code)
return None if code == "und" else code
def _scan_external_subtitle_languages(video_path: Path) -> list[str]:
languages: list[str] = []
with os.scandir(video_path.parent) as entries:
for entry in entries:
if not entry.is_file(follow_symlinks=False):
continue
lang = _sidecar_subtitle_language(video_path.stem, entry.name)
if lang and lang not in languages:
languages.append(lang)
return languages
async def find_external_subtitle_languages(video_path: str | None) -> list[str]:
"""Languages of external subtitle files sitting next to a video file.
Matches sidecars named `<stem>.<lang>.<ext>` (e.g. `Movie.esp.srt` ->
``esp``), optionally with flags like ``forced``/``sdh`` after the language.
Bare `<stem>.<ext>` files carry no language tag and are ignored.
"""
if not video_path or "://" in video_path or video_path.startswith("concat:"):
return []
path = Path(video_path)
if path.suffix.lower() not in VIDEO_EXTENSIONS:
return []
try:
return await asyncio.to_thread(_scan_external_subtitle_languages, path)
except OSError, PermissionError:
return []
async def find_metadata_probe_file(playable_path: str | None) -> str | None:
"""Resolve a path suitable for ffmpeg stream metadata probing.
For regular files, returns ``playable_path`` unchanged.
For Blu-ray control files (``*.bdmv``), returns the largest
``BDMV/STREAM/*.m2ts`` file, which ffmpeg can usually inspect even
when direct BDMV probing is unsupported.
For DVD control files (``*.ifo``), returns a ``concat:`` URI that
covers all VOBs of the largest title set, giving ffmpeg the full
main feature to probe.
"""
if not playable_path:
return None
if not playable_path.lower().endswith(".bdmv"):
# For DVD control files, build a concat URI covering the main title set
if playable_path.lower().endswith(".ifo"):
cache_key = playable_path
if cache_key in _bluray_probe_file_cache:
return _bluray_probe_file_cache[cache_key]
playable = Path(playable_path)
video_ts_dir = (
playable.parent
if playable.parent.name.upper() == "VIDEO_TS"
else playable.parent
)
# Group VOBs by title set (VTS_XX_Y.VOB)
title_sets: dict[str, list[tuple[str, int]]] = defaultdict(list)
try:
stop_event = threading.Event()
vob_files = await asyncio.to_thread(
_scandir_files_with_suffix,
video_ts_dir,
{".vob"},
stop_event,
)
except asyncio.CancelledError:
stop_event.set()
raise
except OSError, PermissionError:
_bluray_probe_file_cache[cache_key] = None
return None
for f in vob_files:
af = AsyncPath(f)
name = f.name.upper()
if not name.startswith("VTS_") or len(name) < 10:
continue
try:
size = (await af.stat()).st_size
except OSError, PermissionError:
continue
ts_num = name[4:6]
title_sets[ts_num].append((f.as_posix(), size))
if not title_sets:
_bluray_probe_file_cache[cache_key] = None
return None
# Pick the title set with the largest total size (main feature)
best_ts = max(
title_sets.keys(),
key=lambda ts: sum(size for _, size in title_sets[ts]),
)
best_vobs = sorted(title_sets[best_ts], key=lambda x: x[0].upper())
concat_uri = "concat:" + "|".join(path for path, _ in best_vobs)
_bluray_probe_file_cache[cache_key] = concat_uri
return concat_uri
return playable_path
cache_key = playable_path
if cache_key in _bluray_probe_file_cache:
return _bluray_probe_file_cache[cache_key]
playable = Path(playable_path)
bdmv_dir = (
playable.parent if playable.parent.name.upper() == "BDMV" else playable.parent
)
stream_dir = bdmv_dir / "STREAM"
ap_stream = AsyncPath(stream_dir)
if not await ap_stream.exists() or not await ap_stream.is_dir():
_bluray_probe_file_cache[cache_key] = None
return None
candidates: list[tuple[str, int]] = []
stack = [stream_dir]
while stack:
current = stack.pop()
stop_event = threading.Event()
try:
child_dirs, child_files = await asyncio.to_thread(
_scandir_split,
current,
stop_event,
)
except asyncio.CancelledError:
stop_event.set()
raise
except OSError, PermissionError:
continue
stack.extend(child_dirs)
for f in child_files:
if f.suffix.lower() != ".m2ts":
continue
af = AsyncPath(f)
try:
candidates.append((f.as_posix(), (await af.stat()).st_size))
except OSError, PermissionError:
continue
if not candidates:
_bluray_probe_file_cache[cache_key] = None
return None
candidates.sort(key=operator.itemgetter(1), reverse=True)
result = candidates[0][0]
_bluray_probe_file_cache[cache_key] = result
return result
async def find_cover_image( async def find_cover_image(
title: str, year: Optional[int], media_type: str, cover_dir: Path title: str, year: int | None, media_type: str, cover_dir: Path
) -> Optional[str]: ) -> str | None:
"""Find a cover image for the given media item.""" """Find a cover image for the given media item."""
media_folder = get_media_folder_path(title, year, media_type, cover_dir) media_folder = get_media_folder_path(title, year, media_type, cover_dir)
cover_path = media_folder / "cover.jpg" cover_path = media_folder / "cover.jpg"
if await AsyncPath(cover_path).exists(): if await AsyncPath(cover_path).exists():
return str(cover_path) return cover_path.as_posix()
# Legacy structure fallback # Legacy structure fallback
subdir = "movies" if media_type == "movie" else "series" subdir = "movies" if media_type == "movie" else "series"
if media_type == "movie" and year: if media_type == "movie" and year:
legacy_path = cover_dir / subdir / f"{sanitize_filename(title)} ({year}).jpg" legacy_path = cover_dir / subdir / f"{sanitize_filename(title)} ({year}).jpg"
if await AsyncPath(legacy_path).exists(): if await AsyncPath(legacy_path).exists():
return str(legacy_path) return legacy_path.as_posix()
legacy_path = cover_dir / subdir / f"{sanitize_filename(title)}.jpg" legacy_path = cover_dir / subdir / f"{sanitize_filename(title)}.jpg"
if await AsyncPath(legacy_path).exists(): if await AsyncPath(legacy_path).exists():
return str(legacy_path) return legacy_path.as_posix()
return None return None
File diff suppressed because it is too large Load Diff
+145 -109
View File
@@ -1,7 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """TMDb Client - Fetch movie and TV series metadata from The Movie Database (TMDb)."""
TMDb Client - Fetch movie and TV series metadata from The Movie Database (TMDb).
"""
import asyncio import asyncio
import hashlib import hashlib
@@ -10,17 +8,16 @@ import os
import sys import sys
import urllib.parse import urllib.parse
from pathlib import Path from pathlib import Path
from typing import Dict, Optional
import httpx import httpx
from aiopathlib import AsyncPath from aiopathlib import AsyncPath
from mediahive.models.tmdb import ( from mediahive.models.tmdb import (
CastMember, CastCredit,
EpisodeInfo, EpisodeInfo,
Info, Info,
Person,
SeasonInfo, SeasonInfo,
SimilarMedia,
) )
# TMDb API configuration # TMDb API configuration
@@ -28,10 +25,10 @@ TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "6bd914e6a5df1c6d1ddf622cf2dbc232"
TMDB_API_BASE = "https://api.themoviedb.org/3" TMDB_API_BASE = "https://api.themoviedb.org/3"
# API response cache directory (can be overridden via set_cache_dir) # API response cache directory (can be overridden via set_cache_dir)
_tmdb_cache_dir: Optional[Path] = None _tmdb_cache_dir: Path | None = None
# Persistent async HTTP client for connection reuse # Persistent async HTTP client for connection reuse
_http_client: Optional[httpx.AsyncClient] = None _http_client: httpx.AsyncClient | None = None
def set_cache_dir(cache_dir: Path) -> None: def set_cache_dir(cache_dir: Path) -> None:
@@ -61,11 +58,19 @@ def _get_http_client() -> httpx.AsyncClient:
return _http_client return _http_client
async def close_http_client() -> None:
"""Close the persistent TMDb HTTP client if it was created."""
global _http_client
if _http_client is not None:
await _http_client.aclose()
_http_client = None
# Sentinel value to distinguish "cached None" from "not in cache" # Sentinel value to distinguish "cached None" from "not in cache"
_NOT_FOUND = object() _NOT_FOUND = object()
def _get_cache_path(endpoint: str, params: Dict[str, str]) -> Path: def _get_cache_path(endpoint: str, params: dict[str, str]) -> Path:
"""Generate a cache file path for an API request.""" """Generate a cache file path for an API request."""
# Create a stable cache key from endpoint and sorted params # Create a stable cache key from endpoint and sorted params
cache_key = endpoint + "?" + urllib.parse.urlencode(sorted(params.items())) cache_key = endpoint + "?" + urllib.parse.urlencode(sorted(params.items()))
@@ -84,20 +89,17 @@ async def _load_from_cache(cache_path: Path):
if data.get("_cached_none"): if data.get("_cached_none"):
return None return None
return data return data
except Exception: except OSError, TypeError, json.JSONDecodeError:
return _NOT_FOUND return _NOT_FOUND
async def _save_to_cache(cache_path: Path, data: Optional[Dict]): async def _save_to_cache(cache_path: Path, data: dict | None) -> None:
"""Save response to cache.""" """Save response to cache."""
try: try:
await AsyncPath(_get_cache_dir()).mkdir(parents=True, exist_ok=True) await AsyncPath(_get_cache_dir()).mkdir(parents=True, exist_ok=True)
if data is None: text = json.dumps({"_cached_none": True}) if data is None else json.dumps(data)
text = json.dumps({"_cached_none": True})
else:
text = json.dumps(data)
await AsyncPath(cache_path).write_text(text, encoding="utf-8") await AsyncPath(cache_path).write_text(text, encoding="utf-8")
except Exception: except OSError, TypeError, ValueError:
pass # Cache write failures are not critical pass # Cache write failures are not critical
@@ -105,8 +107,8 @@ async def _save_to_cache(cache_path: Path, data: Optional[Dict]):
async def tmdb_api_request( async def tmdb_api_request(
endpoint: str, params: Optional[Dict[str, str]] = None endpoint: str, params: dict[str, str] | None = None
) -> Optional[Dict[str, str]]: ) -> dict[str, str] | None:
"""Make a request to the TMDb API with disk caching and connection reuse.""" """Make a request to the TMDb API with disk caching and connection reuse."""
params = params or {} params = params or {}
@@ -139,35 +141,30 @@ async def tmdb_api_request(
# Cache the failure (None) to avoid retrying # Cache the failure (None) to avoid retrying
await _save_to_cache(cache_path, None) await _save_to_cache(cache_path, None)
return None return None
except Exception: except httpx.HTTPError:
# Don't cache network errors - they may be transient # Don't cache network errors - they may be transient
return None return None
async def fetch_movie_details(movie_id: int) -> Optional[Dict]: async def fetch_movie_details(movie_id: int) -> dict | None:
"""Fetch detailed movie info including credits, similar, keywords, and alternative titles.""" """Fetch movie info including credits, keywords, alt titles, and collection."""
# Use append_to_response to get multiple data in one request # Use append_to_response to get multiple data in one request
data = await tmdb_api_request( return await tmdb_api_request(
f"/movie/{movie_id}", f"/movie/{movie_id}",
{"append_to_response": "credits,similar,keywords,alternative_titles"}, {"append_to_response": "credits,keywords,alternative_titles"},
) )
return data
async def fetch_series_details(series_id: int) -> Optional[Dict]: async def fetch_series_details(series_id: int) -> dict | None:
"""Fetch detailed TV series info including credits, similar, and keywords.""" """Fetch detailed TV series info including credits and keywords."""
# Use append_to_response to get multiple data in one request # Use append_to_response to get multiple data in one request
data = await tmdb_api_request( return await tmdb_api_request(
f"/tv/{series_id}", {"append_to_response": "credits,similar,keywords"} f"/tv/{series_id}", {"append_to_response": "credits,keywords"}
) )
return data
async def fetch_season_details( async def fetch_season_details(series_id: int, season_number: int) -> SeasonInfo | None:
series_id: int, season_number: int """Fetch detailed season info including all episodes.
) -> Optional[SeasonInfo]:
"""
Fetch detailed season info including all episodes.
Returns season metadata with episode list including: Returns season metadata with episode list including:
- Episode names, overviews, air dates - Episode names, overviews, air dates
@@ -217,9 +214,21 @@ async def fetch_season_details(
) )
def _map_person_gender(value: object) -> str | None:
"""Map TMDb person gender codes to stable string values."""
if value == 1:
return "female"
if value == 2:
return "male"
if value == 3:
return "non_binary"
if value == 0:
return "unknown"
return None
def _generate_title_variants(words: list[str], min_words: int = 2) -> list[str]: def _generate_title_variants(words: list[str], min_words: int = 2) -> list[str]:
""" """Generate title variants by progressively removing words from both ends.
Generate title variants by progressively removing words from both ends.
Order: full title, then shorter from end, then shorter from start. Order: full title, then shorter from end, then shorter from start.
""" """
@@ -232,12 +241,15 @@ def _generate_title_variants(words: list[str], min_words: int = 2) -> list[str]:
variants.append(" ".join(words)) variants.append(" ".join(words))
# Then try removing from end (most common: edition names at end) # Then try removing from end (most common: edition names at end)
for num_words in range(len(words) - 1, min_words - 1, -1): variants.extend(
variants.append(" ".join(words[:num_words])) " ".join(words[:num_words])
for num_words in range(len(words) - 1, min_words - 1, -1)
)
# Then try removing from start (garbage at beginning) # Then try removing from start (garbage at beginning)
for start in range(1, len(words) - min_words + 1): variants.extend(
variants.append(" ".join(words[start:])) " ".join(words[start:]) for start in range(1, len(words) - min_words + 1)
)
# Finally try middle portions (remove from both ends) # Finally try middle portions (remove from both ends)
for start in range(1, len(words) - min_words): for start in range(1, len(words) - min_words):
@@ -259,8 +271,7 @@ def _normalize_for_match(text: str) -> set[str]:
def _titles_match(original_title: str, tmdb_title: str, search_query: str) -> bool: def _titles_match(original_title: str, tmdb_title: str, search_query: str) -> bool:
""" """Check if TMDb result title reasonably matches our original title.
Check if TMDb result title reasonably matches our original title.
Uses word overlap to verify the result is relevant, preventing Uses word overlap to verify the result is relevant, preventing
false matches from short queries like "The" or just a year. false matches from short queries like "The" or just a year.
@@ -312,11 +323,8 @@ def _titles_match(original_title: str, tmdb_title: str, search_query: str) -> bo
) )
async def _search_movie_with_fallbacks( async def _search_movie_with_fallbacks(title: str, year: int | None) -> dict | None:
title: str, year: Optional[int] """Search for a movie with progressive title shortening fallbacks.
) -> Optional[Dict]:
"""
Search for a movie with progressive title shortening fallbacks.
PTN often includes edition names (THEATRICAL CUT, DIRECTOR'S CUT, etc.) PTN often includes edition names (THEATRICAL CUT, DIRECTOR'S CUT, etc.)
or garbage at the beginning/end of the title. or garbage at the beginning/end of the title.
@@ -327,7 +335,7 @@ async def _search_movie_with_fallbacks(
variants = _generate_title_variants(words, min_words=2) variants = _generate_title_variants(words, min_words=2)
def _result_matches( def _result_matches(
top_result: Dict, original_title: str, search_query: str top_result: dict, original_title: str, search_query: str
) -> bool: ) -> bool:
"""Check if result matches against either title or original_title.""" """Check if result matches against either title or original_title."""
tmdb_title = top_result.get("title", "") tmdb_title = top_result.get("title", "")
@@ -363,7 +371,10 @@ async def _search_movie_with_fallbacks(
return None return None
async def fetch_movie_info(title: str, year: Optional[int] = None) -> Optional[Info]: async def fetch_movie_info(
title: str,
year: int | None = None,
) -> tuple[Info, str | None, str | None, dict[int, Person]] | None:
"""Fetch comprehensive movie info from TMDb.""" """Fetch comprehensive movie info from TMDb."""
data = await _search_movie_with_fallbacks(title, year) data = await _search_movie_with_fallbacks(title, year)
@@ -373,20 +384,24 @@ async def fetch_movie_info(title: str, year: Optional[int] = None) -> Optional[I
result = data["results"][0] result = data["results"][0]
movie_id = result["id"] movie_id = result["id"]
# Fetch full details with credits, similar movies, and keywords # Fetch full details with credits, keywords, alt titles, and collection
details = await fetch_movie_details(movie_id) details = await fetch_movie_details(movie_id)
if not details: if not details:
# Fall back to basic info from search # Fall back to basic info from search
return Info( return (
Info(
tmdb_id=movie_id, tmdb_id=movie_id,
title=result.get("title"), title=result.get("title"),
original_title=result.get("original_title"), original_title=result.get("original_title"),
original_language=result.get("original_language"),
rating=result.get("vote_average"), rating=result.get("vote_average"),
vote_count=result.get("vote_count"), vote_count=result.get("vote_count"),
overview=result.get("overview"), overview=result.get("overview"),
poster_path=result.get("poster_path"),
backdrop_path=result.get("backdrop_path"),
release_date=result.get("release_date"), release_date=result.get("release_date"),
),
result.get("poster_path"),
result.get("backdrop_path"),
{},
) )
# Extract genres # Extract genres
@@ -410,55 +425,66 @@ async def fetch_movie_info(title: str, year: Optional[int] = None) -> Optional[I
alt_titles_set.discard(orig_title) alt_titles_set.discard(orig_title)
alternative_titles = sorted(alt_titles_set) if alt_titles_set else None alternative_titles = sorted(alt_titles_set) if alt_titles_set else None
# Extract top cast (limit to 10) # Extract full cast
credits = details.get("credits", {}) credits_data = details.get("credits", {})
cast_data = credits.get("cast", [])[:10] cast_data = credits_data.get("cast", [])
cast = [ cast: list[CastCredit] = []
CastMember( people: dict[int, Person] = {}
name=c["name"], for c in cast_data:
character=c.get("character", ""), person_id = c.get("id")
cast.append(
CastCredit(
character=c.get("character", "") or None,
id=person_id if isinstance(person_id, int) else None,
)
)
if isinstance(person_id, int):
people[person_id] = Person(
name=c.get("name") or "",
profile_path=c.get("profile_path"), profile_path=c.get("profile_path"),
gender=_map_person_gender(c.get("gender")),
) )
for c in cast_data
]
# Extract director from crew # Extract director from crew
crew = credits.get("crew", []) crew = credits_data.get("crew", [])
directors = [c["name"] for c in crew if c.get("job") == "Director"] directors = [c["name"] for c in crew if c.get("job") == "Director"]
director = directors[0] if directors else None director = directors[0] if directors else None
# Extract similar movies (limit to 10) collection_data = details.get("belongs_to_collection")
similar_data = details.get("similar", {}).get("results", [])[:10] collection = None
similar = [ if isinstance(collection_data, dict):
SimilarMedia(id=s["id"], title=s["title"], poster_path=s.get("poster_path")) collection_name = collection_data.get("name")
for s in similar_data if isinstance(collection_name, str):
] collection = collection_name or None
return Info( return (
Info(
tmdb_id=movie_id, tmdb_id=movie_id,
title=details.get("title"), title=details.get("title"),
original_title=details.get("original_title"), original_title=details.get("original_title"),
original_language=details.get("original_language"),
alternative_titles=alternative_titles, alternative_titles=alternative_titles,
rating=details.get("vote_average"), rating=details.get("vote_average"),
vote_count=details.get("vote_count"), vote_count=details.get("vote_count"),
overview=details.get("overview"), overview=details.get("overview"),
genres=genres if genres else None, genres=genres or None,
release_date=details.get("release_date"), release_date=details.get("release_date"),
runtime=details.get("runtime"), runtime=details.get("runtime"),
collection=collection,
status=details.get("status"), status=details.get("status"),
tagline=details.get("tagline"), tagline=details.get("tagline"),
poster_path=details.get("poster_path"), keywords=keywords or None,
backdrop_path=details.get("backdrop_path"), cast=cast or None,
similar=similar if similar else None,
keywords=keywords if keywords else None,
cast=cast if cast else None,
director=director, director=director,
),
details.get("poster_path"),
details.get("backdrop_path"),
people,
) )
async def _search_series_with_fallbacks(title: str) -> Optional[Dict]: async def _search_series_with_fallbacks(title: str) -> dict | None:
""" """Search for a TV series with progressive title shortening fallbacks.
Search for a TV series with progressive title shortening fallbacks.
PTN often includes extra text in the title at beginning or end. PTN often includes extra text in the title at beginning or end.
Results are validated with fuzzy matching to prevent false positives. Results are validated with fuzzy matching to prevent false positives.
@@ -479,7 +505,9 @@ async def _search_series_with_fallbacks(title: str) -> Optional[Dict]:
return None return None
async def fetch_series_info(title: str) -> Optional[Info]: async def fetch_series_info(
title: str,
) -> tuple[Info, str | None, str | None, dict[int, Person]] | None:
"""Fetch comprehensive TV series info from TMDb.""" """Fetch comprehensive TV series info from TMDb."""
data = await _search_series_with_fallbacks(title) data = await _search_series_with_fallbacks(title)
@@ -489,19 +517,23 @@ async def fetch_series_info(title: str) -> Optional[Info]:
result = data["results"][0] result = data["results"][0]
series_id = result["id"] series_id = result["id"]
# Fetch full details with credits, similar shows, and keywords # Fetch full details with credits and keywords
details = await fetch_series_details(series_id) details = await fetch_series_details(series_id)
if not details: if not details:
# Fall back to basic info from search # Fall back to basic info from search
return Info( return (
Info(
tmdb_id=series_id, tmdb_id=series_id,
title=result.get("name"), title=result.get("name"),
original_title=result.get("original_name"), original_title=result.get("original_name"),
original_language=result.get("original_language"),
rating=result.get("vote_average"), rating=result.get("vote_average"),
vote_count=result.get("vote_count"), vote_count=result.get("vote_count"),
overview=result.get("overview"), overview=result.get("overview"),
poster_path=result.get("poster_path"), ),
backdrop_path=result.get("backdrop_path"), result.get("poster_path"),
result.get("backdrop_path"),
{},
) )
# Extract genres # Extract genres
@@ -511,17 +543,25 @@ async def fetch_series_info(title: str) -> Optional[Info]:
keywords_data = details.get("keywords", {}).get("results", []) keywords_data = details.get("keywords", {}).get("results", [])
keywords = [k["name"] for k in keywords_data] keywords = [k["name"] for k in keywords_data]
# Extract top cast (limit to 10) # Extract full cast
credits = details.get("credits", {}) credits_data = details.get("credits", {})
cast_data = credits.get("cast", [])[:10] cast_data = credits_data.get("cast", [])
cast = [ cast: list[CastCredit] = []
CastMember( people: dict[int, Person] = {}
name=c["name"], for c in cast_data:
character=c.get("character", ""), person_id = c.get("id")
cast.append(
CastCredit(
character=c.get("character", "") or None,
id=person_id if isinstance(person_id, int) else None,
)
)
if isinstance(person_id, int):
people[person_id] = Person(
name=c.get("name") or "",
profile_path=c.get("profile_path"), profile_path=c.get("profile_path"),
gender=_map_person_gender(c.get("gender")),
) )
for c in cast_data
]
# Extract creators # Extract creators
creators = [c["name"] for c in details.get("created_by", [])] creators = [c["name"] for c in details.get("created_by", [])]
@@ -529,34 +569,30 @@ async def fetch_series_info(title: str) -> Optional[Info]:
# Extract networks # Extract networks
networks = [n["name"] for n in details.get("networks", [])] networks = [n["name"] for n in details.get("networks", [])]
# Extract similar series (limit to 10)
similar_data = details.get("similar", {}).get("results", [])[:10]
similar = [
SimilarMedia(id=s["id"], title=s["name"], poster_path=s.get("poster_path"))
for s in similar_data
]
# Get first air date # Get first air date
first_air_date = details.get("first_air_date") first_air_date = details.get("first_air_date")
return Info( return (
Info(
tmdb_id=series_id, tmdb_id=series_id,
title=details.get("name"), title=details.get("name"),
original_title=details.get("original_name"), original_title=details.get("original_name"),
original_language=details.get("original_language"),
rating=details.get("vote_average"), rating=details.get("vote_average"),
vote_count=details.get("vote_count"), vote_count=details.get("vote_count"),
overview=details.get("overview"), overview=details.get("overview"),
genres=genres if genres else None, genres=genres or None,
release_date=first_air_date, release_date=first_air_date,
status=details.get("status"), status=details.get("status"),
tagline=details.get("tagline"), tagline=details.get("tagline"),
poster_path=details.get("poster_path"), keywords=keywords or None,
backdrop_path=details.get("backdrop_path"), cast=cast or None,
similar=similar if similar else None, creators=creators or None,
keywords=keywords if keywords else None,
cast=cast if cast else None,
creators=creators if creators else None,
number_of_seasons=details.get("number_of_seasons"), number_of_seasons=details.get("number_of_seasons"),
number_of_episodes=details.get("number_of_episodes"), number_of_episodes=details.get("number_of_episodes"),
networks=networks if networks else None, networks=networks or None,
),
details.get("poster_path"),
details.get("backdrop_path"),
people,
) )
+134 -55
View File
@@ -1,86 +1,170 @@
"""Utility functions for paths, sizes, and timestamps.""" """Utility functions for paths, sizes, and timestamps."""
import time import os
import re
import unicodedata
from pathlib import Path from pathlib import Path
from typing import Optional, List
from aiopathlib import AsyncPath from aiopathlib import AsyncPath
# Default output folder name (created at common root of scanned paths) # Default output folder name (created at common root of scanned paths)
DEFAULT_OUTPUT_FOLDER = ".mediahive" DEFAULT_OUTPUT_FOLDER = ".mediahive"
# Threshold for considering atime "too close" to current time (1 hour)
_ATIME_FRESHNESS_THRESHOLD = 3600
# Resolution priority for quality sorting (higher = better) # Resolution priority for quality sorting (higher = better)
RESOLUTION_PRIORITY = { RESOLUTION_PRIORITY = {
"2160p": 4, "8K": 5,
"4K": 4, "4K": 4,
"FHD": 3,
"HD": 2,
"SD": 1,
# Backward compatibility for existing snapshot data
"4320p": 5,
"2160p": 4,
"UHD": 4,
"1080p": 3, "1080p": 3,
"1080i": 3, "1080i": 3,
"720p": 2, "720p": 2,
"576p": 1,
"480p": 1, "480p": 1,
} }
async def get_added_timestamp(path: Path) -> Optional[int]: def build_movie_id(title: str | None, year: int | None) -> str:
""" """Build a readable movie ID slug from the title and year."""
Get the timestamp when a torrent was added to the collection. normalized_title = unicodedata.normalize("NFKD", title or "")
ascii_title = normalized_title.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "-", ascii_title.lower()).strip("-")
slug = slug or "movie"
if year:
return f"{slug}-{year}"
return slug
Heuristic:
- For directories: use ctime (most accurate for torrent folder creation) def build_series_id(title: str | None) -> str:
- For files: use atime unless it's too close to current time (suggesting """Build a readable series ID slug from the title."""
the filesystem updates atime on reads), otherwise use max(mtime, ctime) normalized_title = unicodedata.normalize("NFKD", title or "")
ascii_title = normalized_title.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "-", ascii_title.lower()).strip("-")
return slug or "series"
def classify_resolution_from_dimensions(
width: int | None, height: int | None
) -> str | None:
"""Map raw frame dimensions to SD/HD/FHD/4K/8K buckets.
Uses the smallest standard frame bucket that can contain the source frame,
which keeps cropped cinematic encodes in their expected class.
"""
if not width or not height or width <= 0 or height <= 0:
return None
long_edge = max(width, height)
short_edge = min(width, height)
buckets = [
(1024, 576, "SD"),
(1280, 720, "HD"),
(1920, 1080, "FHD"),
(4096, 2160, "4K"),
(8192, 4320, "8K"),
]
for max_w, max_h, label in buckets:
if long_edge <= max_w and short_edge <= max_h:
return label
return "8K"
def normalize_resolution_label(value: str | None) -> str | None:
"""Normalize PTN/legacy resolution text into SD/HD/FHD/4K/8K labels."""
if not value:
return None
normalized = str(value).strip().upper()
mapping = {
"SD": "SD",
"HD": "HD",
"FHD": "FHD",
"4K": "4K",
"8K": "8K",
"4320P": "8K",
"2160P": "4K",
"UHD": "4K",
"1080P": "FHD",
"1080I": "FHD",
"720P": "HD",
"576P": "SD",
"480P": "SD",
}
return mapping.get(normalized)
async def get_added_timestamp(path: Path) -> int | None:
"""Get the timestamp when a torrent was added to the collection.
Best-effort rule:
- On Windows: use ctime (creation-time semantics)
- On other OSes: use mtime (ctime is metadata-change time on Unix)
Returns: Returns:
Unix timestamp as int, or None if path doesn't exist Unix timestamp as int, or None if path doesn't exist
""" """
ap = AsyncPath(path) ap = AsyncPath(path)
try: try:
stat_info = await ap.stat() stat_info = await ap.stat()
except OSError, PermissionError: except OSError, PermissionError:
return None return None
if os.name == "nt":
if await ap.is_dir():
return int(stat_info.st_ctime) return int(stat_info.st_ctime)
return int(stat_info.st_mtime)
now = time.time()
atime = stat_info.st_atime
if now - atime < _ATIME_FRESHNESS_THRESHOLD:
return int(max(stat_info.st_mtime, stat_info.st_ctime))
return int(atime)
async def get_directory_size(path: Path) -> int: def get_directory_size(path: Path) -> int:
"""Calculate total size of a directory recursively.""" """Calculate total size using scandir recursion in a sync worker."""
ap = AsyncPath(path)
total = 0
try: try:
if await ap.is_file(): if path.is_file():
return (await ap.stat()).st_size return path.stat().st_size
for item in ap.rglob("*"):
if await AsyncPath(item).is_file():
total += (await AsyncPath(item).stat()).st_size
except OSError, PermissionError: except OSError, PermissionError:
pass return 0
total = 0
stack = [path]
while stack:
current = stack.pop()
try:
with os.scandir(current) as entries:
for entry in entries:
try:
if entry.is_dir(follow_symlinks=False):
stack.append(Path(entry.path))
continue
except OSError:
continue
try:
total += entry.stat(follow_symlinks=False).st_size
except OSError:
continue
except OSError, PermissionError:
continue
return total return total
def format_size(size_bytes: int) -> str: def format_size(size_bytes: int) -> str:
"""Format size in human-readable format.""" """Format size in human-readable format."""
size = float(size_bytes)
for unit in ["B", "KB", "MB", "GB", "TB"]: for unit in ["B", "KB", "MB", "GB", "TB"]:
if size_bytes < 1024: if size < 1024:
return f"{size_bytes:.2f} {unit}" return f"{size:.2f} {unit}"
size_bytes /= 1024 size /= 1024
return f"{size_bytes:.2f} PB" return f"{size:.2f} PB"
async def find_common_root(paths: List[Path]) -> Optional[Path]: async def find_common_root(paths: list[Path]) -> Path | None:
""" """Find the common root directory for a list of paths.
Find the common root directory for a list of paths.
Returns None if paths are on different drives/mounts or have no common ancestor. Returns None if paths are on different drives/mounts or have no common ancestor.
""" """
@@ -126,7 +210,7 @@ async def find_common_root(paths: List[Path]) -> Optional[Path]:
# Find common prefix # Find common prefix
common_parts = [] common_parts = []
for parts in zip(*all_parts): for parts in zip(*all_parts, strict=False):
if len(set(parts)) == 1: if len(set(parts)) == 1:
common_parts.append(parts[0]) common_parts.append(parts[0])
else: else:
@@ -138,11 +222,8 @@ async def find_common_root(paths: List[Path]) -> Optional[Path]:
return Path(*common_parts) return Path(*common_parts)
def make_relative_path( def make_relative_path(path: str | None, root: str | None = None) -> str | None:
path: Optional[str], root: Optional[str] = None """Convert an absolute path to a posix-style path relative to the given root.
) -> Optional[str]:
"""
Convert an absolute path to a posix-style path relative to the given root.
If root is None, returns the path as a posix string unchanged. If root is None, returns the path as a posix string unchanged.
""" """
@@ -161,20 +242,18 @@ def sanitize_filename(name: str) -> str:
"""Sanitize a string for use as a filename.""" """Sanitize a string for use as a filename."""
for char in ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]: for char in ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]:
name = name.replace(char, "_") name = name.replace(char, "_")
name = name.strip(". ") return name.strip(". ")
return name
def get_media_folder_name(title: str, year: Optional[int], media_type: str) -> str: def get_media_folder_name(title: str, year: int | None, media_type: str) -> str:
"""Get the folder name for a media item.""" """Get the folder name for a media item."""
sanitized_title = sanitize_filename(title) if media_type == "movie":
if media_type == "movie" and year: return build_movie_id(title, year)
return f"{sanitized_title} ({year})" return build_series_id(title)
return sanitized_title
def get_media_folder_path( def get_media_folder_path(
title: str, year: Optional[int], media_type: str, cover_dir: Path title: str, year: int | None, media_type: str, cover_dir: Path
) -> Path: ) -> Path:
"""Get the full path to a media item's folder.""" """Get the full path to a media item's folder."""
subdir = "movies" if media_type == "movie" else "series" subdir = "movies" if media_type == "movie" else "series"
+584 -77
View File
@@ -1,5 +1,4 @@
""" """In-memory index store with disk snapshot and WebSocket broadcast.
In-memory index store with disk snapshot and WebSocket broadcast.
The IndexStore is the single source of truth for the media index. The IndexStore is the single source of truth for the media index.
All mutations happen synchronously in the asyncio event loop — no locks needed. All mutations happen synchronously in the asyncio event loop — no locks needed.
@@ -8,33 +7,34 @@ debounced background task.
""" """
import asyncio import asyncio
import contextlib
import logging import logging
import os from collections.abc import Callable
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional
import msgspec import msgspec
from aiopathlib import AsyncPath from aiopathlib import AsyncPath
from fastapi import WebSocket from fastapi import WebSocket
from mediahive.models.data import ( from mediahive.models.data import (
Episode,
IndexSnapshot, IndexSnapshot,
MediaStats,
Movie, Movie,
Season,
Series, Series,
TaskInfo, TaskInfo,
Torrent,
) )
from mediahive.models.events import Remove, Task, Upsert from mediahive.models.events import Remove, Task, Upsert
from mediahive.models.protocol import ( from mediahive.models.tmdb import Person
WsInit,
WsInitData,
)
logger = logging.getLogger("mediahive.index_store") logger = logging.getLogger("mediahive.index_store")
# Debounce interval for writing snapshots to disk (seconds) # Debounce interval for writing snapshots to disk (seconds)
SNAPSHOT_DEBOUNCE = 5.0 SNAPSHOT_DEBOUNCE = 5.0
# Debounce interval for rebuilding the in-memory API snapshot (seconds)
SNAPSHOT_CACHE_DEBOUNCE = 0.25
class IndexStore: class IndexStore:
@@ -48,20 +48,38 @@ class IndexStore:
# This would make IndexStore testable without FastAPI's WebSocket. # This would make IndexStore testable without FastAPI's WebSocket.
""" """
def __init__(self, snapshot_path: Path, media_root: Optional[str] = None): def __init__(
self,
snapshot_path: Path,
) -> None:
self.snapshot_path = snapshot_path self.snapshot_path = snapshot_path
self.media_root = media_root self.snapshot_loaded = False
# The index: keyed by item id # The index: keyed by item id
self.movies: dict[str, Movie] = {} self.movies: dict[str, Movie] = {}
self.series: dict[str, Series] = {} self.series: dict[str, Series] = {}
self.people: dict[int, Person] = {}
self._movie_tmdb_ids: dict[int, str] = {}
self._series_tmdb_ids: dict[int, str] = {}
# Connected WebSocket clients # Connected WebSocket clients
self._clients: set[WebSocket] = set() self._clients: set[WebSocket] = set()
# Passive listeners for broadcast events (used by server-level WS fan-in)
self._listeners: set[Callable[[object], None]] = set()
# Snapshot debounce state # Snapshot debounce state
self._snapshot_dirty = False self._snapshot_dirty = False
self._snapshot_task: Optional[asyncio.Task] = None self._snapshot_task: asyncio.Task | None = None
# In-memory API snapshot cache (served by get_full_index)
self._snapshot_cache_dirty = True
self._snapshot_cache_task: asyncio.Task | None = None
self._cached_snapshot = IndexSnapshot(
generated_at=datetime.now().isoformat(),
movies={},
series={},
people={},
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Persistence # Persistence
@@ -70,41 +88,313 @@ class IndexStore:
async def load_snapshot(self) -> None: async def load_snapshot(self) -> None:
"""Load index from disk snapshot (recovery on startup).""" """Load index from disk snapshot (recovery on startup)."""
ap = AsyncPath(self.snapshot_path) ap = AsyncPath(self.snapshot_path)
self.snapshot_loaded = False
if not await ap.exists(): if not await ap.exists():
logger.info("No snapshot found at %s, starting fresh", self.snapshot_path) logger.info("No snapshot found at %s, starting fresh", self.snapshot_path)
self._schedule_snapshot_cache_refresh()
return return
try: try:
data = msgspec.json.decode(await ap.read_bytes(), type=IndexSnapshot) raw = await ap.read_bytes()
for m in data.movies: loaded_movies, loaded_series, loaded_people = await asyncio.to_thread(
self.movies[m.id] = m self._load_snapshot_sync,
for s in data.series: raw,
self.series[s.id] = s
logger.info(
"Loaded snapshot: %d movies, %d series",
len(self.movies),
len(self.series),
) )
self._merge_loaded_snapshot(
loaded_movies,
loaded_series,
loaded_people,
)
self._rebuild_tmdb_indexes()
self.snapshot_loaded = True
self._schedule_snapshot_cache_refresh()
except Exception: except Exception:
logger.exception("Failed to load snapshot from %s", self.snapshot_path) logger.exception("Failed to load snapshot from %s", self.snapshot_path)
def _load_snapshot_sync(
self,
raw: bytes,
) -> tuple[dict[str, Movie], dict[str, Series], dict[int, Person]]:
"""Parse snapshot bytes in a thread-pool context."""
data = msgspec.json.decode(raw, type=IndexSnapshot)
loaded_movies = dict(data.movies)
loaded_series = dict(data.series)
loaded_people = dict(data.people)
logger.info(
"Loaded snapshot: %d movies, %d series, %d people",
len(loaded_movies),
len(loaded_series),
len(loaded_people),
)
return loaded_movies, loaded_series, loaded_people
def _merge_loaded_snapshot(
self,
movies: dict[str, Movie],
series: dict[str, Series],
people: dict[int, Person],
) -> None:
"""Merge loaded snapshot items without overriding newer in-memory updates."""
for item_id, movie in movies.items():
if item_id not in self.movies:
self.movies[item_id] = movie
for item_id, show in series.items():
if item_id not in self.series:
self.series[item_id] = show
for person_id, person in people.items():
self.people[person_id] = person
@staticmethod
def _get_tmdb_id(item: Movie | Series) -> int | None:
if item.info is None:
return None
return item.info.tmdb_id
def torrent_paths(self) -> set[str]:
"""All media-root-relative torrent paths currently in the index.
The scanner uses this to reprocess items that are missing from the
index even though their mtime is unchanged (e.g. after the snapshot
was wiped or an upsert never landed).
"""
paths: set[str] = set()
for movie in self.movies.values():
paths.update(movie.files)
for show in self.series.values():
for season in show.seasons:
for episode in season.episodes:
paths.update(episode.files)
return paths
@staticmethod
def _newest_from_files(files: dict[str, Torrent]) -> int | None:
timestamps = [t.added_at for t in files.values() if t.added_at]
return max(timestamps) if timestamps else None
# ------------------------------------------------------------------
# Merge helpers (partial rescan support)
# ------------------------------------------------------------------
def _merge_movie(self, existing: Movie, new: Movie, scanned: set[str]) -> Movie:
"""Merge a partially rebuilt movie into the existing entry.
File entries belonging to torrents in ``scanned`` are replaced by the
new data; everything else is preserved.
"""
files = {k: v for k, v in existing.files.items() if k not in scanned}
files.update(new.files)
return Movie(
title=new.title or existing.title,
info=new.info or existing.info,
year=new.year if new.year is not None else existing.year,
newest=(
self._newest_from_files(files)
or max(filter(None, [existing.newest, new.newest]), default=None)
),
cover_path=new.cover_path or existing.cover_path,
backdrop_path=new.backdrop_path or existing.backdrop_path,
showreel_images=new.showreel_images or existing.showreel_images,
showreel_source_sets=new.showreel_source_sets
or existing.showreel_source_sets,
files=files,
)
def _merge_series(self, existing: Series, new: Series, scanned: set[str]) -> Series:
"""Merge a partially rebuilt series into the existing entry.
File entries belonging to torrents in ``scanned`` are replaced by the
new data; seasons/episodes/files from torrents that were not rescanned
are preserved. Episodes and seasons left without files are dropped.
"""
seasons: dict[int, Season] = {}
for season in existing.seasons:
episodes: dict[int, Episode] = {}
for ep in season.episodes:
files = {k: v for k, v in ep.files.items() if k not in scanned}
if files:
episodes[ep.episode_number] = msgspec.structs.replace(
ep, files=files
)
if episodes:
seasons[season.season_number] = msgspec.structs.replace(
season,
episodes=list(episodes.values()),
episode_count=len(episodes),
)
for season in new.seasons:
current = seasons.get(season.season_number)
if current is None:
seasons[season.season_number] = season
continue
episodes = {ep.episode_number: ep for ep in current.episodes}
for ep in season.episodes:
old = episodes.get(ep.episode_number)
if old is None:
episodes[ep.episode_number] = ep
continue
# Same episode from an unscanned torrent too: union the files,
# prefer fresh metadata/reel info from the new scan.
files = dict(old.files)
files.update(ep.files)
episodes[ep.episode_number] = msgspec.structs.replace(
ep,
files=files,
reel_image=ep.reel_image or old.reel_image,
reel_sources=ep.reel_sources or old.reel_sources,
)
ordered = [episodes[k] for k in sorted(episodes)]
seasons[season.season_number] = msgspec.structs.replace(
season,
episodes=ordered,
episode_count=len(ordered),
poster_path=season.poster_path or current.poster_path,
)
alt_titles = sorted(
set(existing.alternative_titles or []) | set(new.alternative_titles or [])
)
return Series(
title=new.title or existing.title,
info=new.info or existing.info,
alternative_titles=alt_titles or None,
newest=max(filter(None, [existing.newest, new.newest]), default=None),
cover_path=new.cover_path or existing.cover_path,
backdrop_path=new.backdrop_path or existing.backdrop_path,
seasons=[seasons[k] for k in sorted(seasons)],
)
def _rebuild_tmdb_indexes(self) -> None:
"""Rebuild TMDb id lookup maps from the current in-memory items."""
self._movie_tmdb_ids.clear()
self._series_tmdb_ids.clear()
for item_id, movie in self.movies.items():
tmdb_id = self._get_tmdb_id(movie)
if tmdb_id is not None:
self._movie_tmdb_ids[tmdb_id] = item_id
for item_id, series in self.series.items():
tmdb_id = self._get_tmdb_id(series)
if tmdb_id is not None:
self._series_tmdb_ids[tmdb_id] = item_id
def _dedupe_tmdb_duplicates(self) -> None:
"""Remove duplicate entries that point at the same TMDb item."""
seen_movies: dict[int, str] = {}
for item_id, movie in list(self.movies.items()):
tmdb_id = self._get_tmdb_id(movie)
if tmdb_id is None:
continue
existing_id = seen_movies.get(tmdb_id)
if existing_id is None:
seen_movies[tmdb_id] = item_id
continue
if existing_id != item_id:
self.movies.pop(item_id, None)
seen_series: dict[int, str] = {}
for item_id, series in list(self.series.items()):
tmdb_id = self._get_tmdb_id(series)
if tmdb_id is None:
continue
existing_id = seen_series.get(tmdb_id)
if existing_id is None:
seen_series[tmdb_id] = item_id
continue
if existing_id != item_id:
self.series.pop(item_id, None)
self._rebuild_tmdb_indexes()
def _collapse_movie_tmdb_duplicates(self, tmdb_id: int, keep_id: str) -> None:
"""Fold other entries that share a TMDb id into the kept one."""
for item_id, movie in list(self.movies.items()):
if item_id == keep_id:
continue
if self._get_tmdb_id(movie) != tmdb_id:
continue
kept = self.movies.get(keep_id)
if kept is not None:
# Preserve any file versions the duplicate alone carried.
self.movies[keep_id] = self._merge_movie(movie, kept, set())
self.movies.pop(item_id, None)
if self._movie_tmdb_ids.get(tmdb_id) == item_id:
self._movie_tmdb_ids[tmdb_id] = keep_id
def _collapse_series_tmdb_duplicates(self, tmdb_id: int, keep_id: str) -> None:
"""Fold other entries that share a TMDb id into the kept one."""
for item_id, series in list(self.series.items()):
if item_id == keep_id:
continue
if self._get_tmdb_id(series) != tmdb_id:
continue
kept = self.series.get(keep_id)
if kept is not None:
# Preserve any seasons/episodes the duplicate alone carried.
self.series[keep_id] = self._merge_series(series, kept, set())
self.series.pop(item_id, None)
if self._series_tmdb_ids.get(tmdb_id) == item_id:
self._series_tmdb_ids[tmdb_id] = keep_id
async def _write_snapshot(self) -> None: async def _write_snapshot(self) -> None:
"""Write current index to disk (called from debounce task).""" """Write current index to disk (called from debounce task)."""
snapshot = self._build_snapshot() # Copy values on the event loop thread, then do full snapshot build + disk I/O
# in a worker thread to keep the loop responsive.
await AsyncPath(self.snapshot_path.parent).mkdir(parents=True, exist_ok=True) movies = dict(self.movies)
tmp = self.snapshot_path.with_suffix(".tmp") series = dict(self.series)
await AsyncPath(tmp).write_bytes( people = dict(self.people)
msgspec.json.format(msgspec.json.encode(snapshot), indent=2) await asyncio.to_thread(self._write_snapshot_sync, movies, series, people)
)
# os.replace is atomic and overwrites on all platforms (unlike rename on Windows)
await asyncio.to_thread(os.replace, tmp, self.snapshot_path)
logger.debug("Snapshot written to %s", self.snapshot_path) logger.debug("Snapshot written to %s", self.snapshot_path)
def _write_snapshot_sync(
self,
movies: dict[str, Movie],
series: dict[str, Series],
people: dict[int, Person],
) -> None:
"""Build and write snapshot synchronously in a worker thread."""
snapshot = self._build_snapshot_from_maps(movies, series, people)
self.snapshot_path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.snapshot_path.with_suffix(".tmp")
tmp.write_bytes(msgspec.json.encode(snapshot))
# Path.replace is atomic and overwrites on all platforms.
tmp.replace(self.snapshot_path)
def _schedule_snapshot(self) -> None: def _schedule_snapshot(self) -> None:
"""Schedule a debounced snapshot write.""" """Schedule a debounced snapshot write."""
self._snapshot_dirty = True self._snapshot_dirty = True
if self._snapshot_task is None or self._snapshot_task.done(): if self._snapshot_task is None or self._snapshot_task.done():
self._snapshot_task = asyncio.create_task(self._snapshot_writer()) self._snapshot_task = asyncio.create_task(self._snapshot_writer())
self._schedule_snapshot_cache_refresh()
def _schedule_snapshot_cache_refresh(self) -> None:
"""Schedule a debounced rebuild of the in-memory API snapshot cache."""
self._snapshot_cache_dirty = True
if self._snapshot_cache_task is None or self._snapshot_cache_task.done():
self._snapshot_cache_task = asyncio.create_task(
self._snapshot_cache_writer()
)
async def _refresh_snapshot_cache_once(self) -> None:
"""Rebuild cached snapshot once using copied store values."""
movies = dict(self.movies)
series = dict(self.series)
people = dict(self.people)
self._cached_snapshot = await asyncio.to_thread(
self._build_snapshot_from_maps,
movies,
series,
people,
)
async def _snapshot_cache_writer(self) -> None:
"""Refresh the in-memory snapshot cache while mutations are pending."""
while True:
await asyncio.sleep(SNAPSHOT_CACHE_DEBOUNCE)
if self._snapshot_cache_dirty:
self._snapshot_cache_dirty = False
await self._refresh_snapshot_cache_once()
else:
break
async def _snapshot_writer(self) -> None: async def _snapshot_writer(self) -> None:
"""Flush to disk every SNAPSHOT_DEBOUNCE seconds while dirty.""" """Flush to disk every SNAPSHOT_DEBOUNCE seconds while dirty."""
@@ -118,68 +408,282 @@ class IndexStore:
async def flush_snapshot(self) -> None: async def flush_snapshot(self) -> None:
"""Force-write a snapshot immediately (e.g. on shutdown).""" """Force-write a snapshot immediately (e.g. on shutdown)."""
if self._snapshot_cache_task and not self._snapshot_cache_task.done():
self._snapshot_cache_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._snapshot_cache_task
await self._refresh_snapshot_cache_once()
if self._snapshot_task and not self._snapshot_task.done(): if self._snapshot_task and not self._snapshot_task.done():
self._snapshot_task.cancel() self._snapshot_task.cancel()
try: with contextlib.suppress(asyncio.CancelledError):
await self._snapshot_task await self._snapshot_task
except asyncio.CancelledError:
pass
await self._write_snapshot() await self._write_snapshot()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Mutations # Mutations
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def upsert_movie(self, item: Movie) -> bool: def upsert_movie(
"""Insert or update a movie. Returns True if it was a real change.""" self,
existing = self.movies.get(item.id) item_id: str,
item: Movie,
people: dict[int, Person] | None = None,
scanned: list[str] | None = None,
) -> bool:
"""Insert or update a movie. Returns True if it was a real change.
When ``scanned`` is given, the item is a partial rebuild covering only
those torrent paths; it is merged into the existing entry instead of
replacing it.
"""
tmdb_id = self._get_tmdb_id(item)
existing_id = self._movie_tmdb_ids.get(tmdb_id) if tmdb_id is not None else None
if existing_id is not None and existing_id != item_id:
item_id = existing_id
existing = self.movies.get(item_id)
if tmdb_id is not None:
self._movie_tmdb_ids[tmdb_id] = item_id
self._collapse_movie_tmdb_duplicates(tmdb_id, item_id)
existing = self.movies.get(item_id)
if existing is not None and scanned is not None:
item = self._merge_movie(existing, item, set(scanned))
if existing is not None: if existing is not None:
if msgspec.json.encode(existing) == msgspec.json.encode(item): if msgspec.json.encode(existing) == msgspec.json.encode(item):
return False if people:
self.movies[item.id] = item self.people.update(people)
self._schedule_snapshot() self._schedule_snapshot()
self._broadcast(Upsert(kind="movie", item=item)) self._broadcast(
Upsert(kind="movie", id=item_id, item=item, people=people)
)
return True
return False
self.movies[item_id] = item
if people:
self.people.update(people)
self._schedule_snapshot()
self._broadcast(Upsert(kind="movie", id=item_id, item=item, people=people))
return True return True
def upsert_series(self, item: Series) -> bool: def upsert_series(
"""Insert or update a series. Returns True if it was a real change.""" self,
existing = self.series.get(item.id) item_id: str,
item: Series,
people: dict[int, Person] | None = None,
scanned: list[str] | None = None,
) -> bool:
"""Insert or update a series. Returns True if it was a real change.
When ``scanned`` is given, the item is a partial rebuild covering only
those torrent paths; it is merged into the existing entry instead of
replacing it.
"""
tmdb_id = self._get_tmdb_id(item)
existing_id = (
self._series_tmdb_ids.get(tmdb_id) if tmdb_id is not None else None
)
if existing_id is not None and existing_id != item_id:
item_id = existing_id
existing = self.series.get(item_id)
if tmdb_id is not None:
self._series_tmdb_ids[tmdb_id] = item_id
self._collapse_series_tmdb_duplicates(tmdb_id, item_id)
existing = self.series.get(item_id)
if existing is not None and scanned is not None:
item = self._merge_series(existing, item, set(scanned))
if existing is not None: if existing is not None:
if msgspec.json.encode(existing) == msgspec.json.encode(item): if msgspec.json.encode(existing) == msgspec.json.encode(item):
return False if people:
self.series[item.id] = item self.people.update(people)
self._schedule_snapshot() self._schedule_snapshot()
self._broadcast(Upsert(kind="series", item=item)) self._broadcast(
Upsert(kind="series", id=item_id, item=item, people=people)
)
return True
return False
self.series[item_id] = item
if people:
self.people.update(people)
self._schedule_snapshot()
self._broadcast(Upsert(kind="series", id=item_id, item=item, people=people))
return True return True
def remove_movie(self, item_id: str) -> None: def remove_movie(self, item_id: str) -> None:
"""Remove a movie from the index and broadcast.""" """Remove a movie from the index and broadcast."""
self.movies.pop(item_id, None) movie = self.movies.pop(item_id, None)
if movie is None:
return
tmdb_id = self._get_tmdb_id(movie)
if tmdb_id is not None and self._movie_tmdb_ids.get(tmdb_id) == item_id:
self._movie_tmdb_ids.pop(tmdb_id, None)
self._schedule_snapshot() self._schedule_snapshot()
self._broadcast(Remove(kind="movie", id=item_id)) self._broadcast(Remove(kind="movie", id=item_id))
def remove_series(self, item_id: str) -> None: def remove_series(self, item_id: str) -> None:
"""Remove a series from the index and broadcast.""" """Remove a series from the index and broadcast."""
self.series.pop(item_id, None) series = self.series.pop(item_id, None)
if series is None:
return
tmdb_id = self._get_tmdb_id(series)
if tmdb_id is not None and self._series_tmdb_ids.get(tmdb_id) == item_id:
self._series_tmdb_ids.pop(tmdb_id, None)
self._schedule_snapshot() self._schedule_snapshot()
self._broadcast(Remove(kind="series", id=item_id)) self._broadcast(Remove(kind="series", id=item_id))
# ------------------------------------------------------------------
# Scanner-driven maintenance
# ------------------------------------------------------------------
def sync_torrent_paths(self, paths: set[str]) -> None:
"""Drop file entries whose torrent path no longer exists on disk.
``paths`` is the complete set of media-root-relative torrent paths the
scanner found during a fully completed discovery pass. Episodes and
seasons left without files are dropped; items left without any files
are removed entirely.
"""
for item_id, movie in list(self.movies.items()):
kept = {k: v for k, v in movie.files.items() if k in paths}
if len(kept) == len(movie.files):
continue
if not kept:
self.remove_movie(item_id)
continue
updated = msgspec.structs.replace(
movie, files=kept, newest=self._newest_from_files(kept)
)
self.movies[item_id] = updated
self._schedule_snapshot()
self._broadcast(Upsert(kind="movie", id=item_id, item=updated))
for item_id, series in list(self.series.items()):
removed_any = False
new_seasons: list[Season] = []
for season in series.seasons:
new_episodes: list[Episode] = []
for ep in season.episodes:
files = {k: v for k, v in ep.files.items() if k in paths}
if len(files) < len(ep.files):
removed_any = True
if files:
new_episodes.append(msgspec.structs.replace(ep, files=files))
else:
removed_any = True
if not new_episodes:
removed_any = True
continue
if len(new_episodes) < len(season.episodes):
season = msgspec.structs.replace(
season,
episodes=new_episodes,
episode_count=len(new_episodes),
)
new_seasons.append(season)
if not removed_any:
continue
if not new_seasons:
self.remove_series(item_id)
continue
updated_series = msgspec.structs.replace(series, seasons=new_seasons)
self.series[item_id] = updated_series
self._schedule_snapshot()
self._broadcast(Upsert(kind="series", id=item_id, item=updated_series))
def set_movie_showreel(
self,
item_id: str,
showreel_images: list[str] | None,
showreel_source_sets: list[list[str]] | None,
) -> None:
"""Update only the showreel fields of a movie (reel worker callback)."""
movie = self.movies.get(item_id)
if movie is None:
return
updated = msgspec.structs.replace(
movie,
showreel_images=showreel_images,
showreel_source_sets=showreel_source_sets,
)
if msgspec.json.encode(updated) == msgspec.json.encode(movie):
return
self.movies[item_id] = updated
self._schedule_snapshot()
self._broadcast(Upsert(kind="movie", id=item_id, item=updated))
def set_episode_reel(
self,
item_id: str,
season_num: int,
episode_num: int,
reel_image: str | None,
reel_sources: list[str] | None,
) -> None:
"""Update only the reel fields of one episode (reel worker callback)."""
series = self.series.get(item_id)
if series is None:
return
for season in series.seasons:
if season.season_number != season_num:
continue
for ep in season.episodes:
if ep.episode_number != episode_num:
continue
if ep.reel_image == reel_image and ep.reel_sources == reel_sources:
return
new_episodes = [
msgspec.structs.replace(
e, reel_image=reel_image, reel_sources=reel_sources
)
if e is ep
else e
for e in season.episodes
]
new_seasons = [
msgspec.structs.replace(s, episodes=new_episodes)
if s is season
else s
for s in series.seasons
]
updated = msgspec.structs.replace(series, seasons=new_seasons)
self.series[item_id] = updated
self._schedule_snapshot()
self._broadcast(Upsert(kind="series", id=item_id, item=updated))
return
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# WebSocket management # WebSocket management
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def add_listener(self, listener: Callable[[object], None]) -> None:
"""Register a listener called for each broadcast message."""
self._listeners.add(listener)
def remove_listener(self, listener: Callable[[object], None]) -> None:
"""Unregister a previously registered broadcast listener."""
self._listeners.discard(listener)
async def connect(self, ws: WebSocket) -> None: async def connect(self, ws: WebSocket) -> None:
"""Accept a WS client and send the full index as init.""" """Accept a WS client and send the full index as init."""
await ws.accept() await ws.accept()
self._clients.add(ws) self._clients.add(ws)
logger.info("WS client connected (%d total)", len(self._clients)) logger.info("WS client connected (%d total)", len(self._clients))
# Send full current state # Send full current state
msg = WsInit( msg = {
data=WsInitData( "type": "init",
movies=list(self.movies.values()), "roots": {
series=list(self.series.values()), "": {
) "movies": dict(self.movies),
) "series": dict(self.series),
"people": dict(self.people),
}
},
}
await ws.send_bytes(msgspec.json.encode(msg)) await ws.send_bytes(msgspec.json.encode(msg))
def disconnect(self, ws: WebSocket) -> None: def disconnect(self, ws: WebSocket) -> None:
@@ -189,6 +693,12 @@ class IndexStore:
def _broadcast(self, msg: object) -> None: def _broadcast(self, msg: object) -> None:
"""Broadcast a message to all connected WS clients (non-blocking).""" """Broadcast a message to all connected WS clients (non-blocking)."""
for listener in tuple(self._listeners):
try:
listener(msg)
except Exception:
logger.exception("IndexStore listener failed")
data = msgspec.json.encode(msg) data = msgspec.json.encode(msg)
dead: list[WebSocket] = [] dead: list[WebSocket] = []
for ws in self._clients: for ws in self._clients:
@@ -202,7 +712,7 @@ class IndexStore:
"""Send data to a WS client; mark as dead on failure.""" """Send data to a WS client; mark as dead on failure."""
try: try:
await ws.send_bytes(data) await ws.send_bytes(data)
except Exception: except OSError, RuntimeError:
dead.append(ws) dead.append(ws)
def broadcast_task(self, task_info: TaskInfo) -> None: def broadcast_task(self, task_info: TaskInfo) -> None:
@@ -213,31 +723,28 @@ class IndexStore:
# Read helpers # Read helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _build_snapshot(self) -> IndexSnapshot: def _build_snapshot_from_maps(
"""Build a sorted IndexSnapshot with computed stats.""" self,
movies_list = sorted( movies: dict[str, Movie],
self.movies.values(), key=lambda x: (x.title.lower(), x.year or 0) series: dict[str, Series],
) people: dict[int, Person],
series_list = sorted(self.series.values(), key=lambda x: x.title.lower()) ) -> IndexSnapshot:
"""Build a keyed IndexSnapshot from map copies."""
total_movie_versions = sum(len(m.torrents) for m in movies_list)
total_series_episodes = sum(
sum(len(season.episodes) for season in s.seasons) for s in series_list
)
return IndexSnapshot( return IndexSnapshot(
generated_at=datetime.now().isoformat(), generated_at=datetime.now().isoformat(),
media_root=self.media_root, movies=movies,
stats=MediaStats( series=series,
total_movies=len(movies_list), people=people,
total_movie_versions=total_movie_versions, )
total_series=len(series_list),
total_series_episodes=total_series_episodes, def _build_snapshot(self) -> IndexSnapshot:
), """Build a sorted IndexSnapshot with computed stats."""
movies=movies_list, return self._build_snapshot_from_maps(
series=series_list, dict(self.movies),
dict(self.series),
dict(self.people),
) )
def get_full_index(self) -> IndexSnapshot: def get_full_index(self) -> IndexSnapshot:
"""Return the full index as an IndexSnapshot.""" """Return the latest in-memory IndexSnapshot cache."""
return self._build_snapshot() return self._cached_snapshot
+1
View File
@@ -0,0 +1 @@
"""Shared data/protocol models for MediaHive."""
+20 -26
View File
@@ -1,5 +1,4 @@
""" """Data structures for mediahive and hivescan.
Data structures for mediahive and hivescan.
All types are msgspec.Structs for fast serialization. All types are msgspec.Structs for fast serialization.
""" """
@@ -8,23 +7,30 @@ from __future__ import annotations
import msgspec import msgspec
from .tmdb import Info from .tmdb import Info, Person
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Index item types (the state stored in IndexStore, sent over WS/API) # Index item types (the state stored in IndexStore, sent over WS/API)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class Torrent(msgspec.Struct): class Torrent(msgspec.Struct, omit_defaults=True):
"""A torrent file, either for a movie or an episode.""" """A torrent file, either for a movie or an episode."""
title: str | None = None title: str | None = None
playable_file: str | None = None playable_file: str | None = None
resolution: str | None = None resolution: str | None = None
quality: str | None = None quality: str | None = None
network: str | None = None
codec: str | None = None codec: str | None = None
audio: str | None = None audio: str | None = None
audio_languages: list[str] | None = None
subtitle_languages: list[str] | None = None
external_subtitle_languages: list[str] | None = None
hdr: bool = False
dovi: bool = False
atmos: bool = False
hdr10plus: bool = False
encoder: str | None = None encoder: str | None = None
size: int | None = None size: int | None = None
added_at: int | None = None added_at: int | None = None
@@ -42,7 +48,8 @@ class Episode(msgspec.Struct):
rating: float | None = None rating: float | None = None
director: str | None = None director: str | None = None
reel_image: str | None = None reel_image: str | None = None
torrents: dict[str, Torrent] = {} reel_sources: list[str] | None = None
files: dict[str, Torrent] = {}
class Season(msgspec.Struct): class Season(msgspec.Struct):
@@ -60,7 +67,6 @@ class Season(msgspec.Struct):
class Movie(msgspec.Struct): class Movie(msgspec.Struct):
"""A movie in the index (one or more versions/releases).""" """A movie in the index (one or more versions/releases)."""
id: str
title: str | None = None title: str | None = None
info: Info | None = None info: Info | None = None
year: int | None = None year: int | None = None
@@ -68,13 +74,13 @@ class Movie(msgspec.Struct):
cover_path: str | None = None cover_path: str | None = None
backdrop_path: str | None = None backdrop_path: str | None = None
showreel_images: list[str] | None = None showreel_images: list[str] | None = None
torrents: dict[str, Torrent] = {} showreel_source_sets: list[list[str]] | None = None
files: dict[str, Torrent] = {}
class Series(msgspec.Struct): class Series(msgspec.Struct):
"""A TV series in the index.""" """A TV series in the index."""
id: str
title: str | None = None title: str | None = None
info: Info | None = None info: Info | None = None
alternative_titles: list[str] | None = None alternative_titles: list[str] | None = None
@@ -88,29 +94,17 @@ class Series(msgspec.Struct):
# Snapshot (disk format for index.json) # Snapshot (disk format for index.json)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
INDEX_SNAPSHOT_VERSION = 1
class MediaStats(msgspec.Struct):
"""Aggregate counts for the index snapshot."""
total_movies: int = 0
total_movie_versions: int = 0
total_series: int = 0
total_series_episodes: int = 0
class IndexSnapshot(msgspec.Struct): class IndexSnapshot(msgspec.Struct):
"""On-disk recovery snapshot of the full index.""" """On-disk recovery snapshot of the full index."""
version: int = 6 v: int = INDEX_SNAPSHOT_VERSION
generated_at: str = "" generated_at: str = ""
media_root: str | None = None movies: dict[str, Movie] = {}
stats: MediaStats = msgspec.UNSET # type: ignore[assignment] series: dict[str, Series] = {}
movies: list[Movie] = [] people: dict[int, Person] = {}
series: list[Series] = []
def __post_init__(self):
if self.stats is msgspec.UNSET:
self.stats = MediaStats()
class TaskInfo(msgspec.Struct): class TaskInfo(msgspec.Struct):
+43 -4
View File
@@ -1,5 +1,4 @@
""" """Event types shared between scanner and WebSocket.
Event types shared between scanner and WebSocket.
These types are used as: These types are used as:
- Internal scan events (scanner → server queue) - Internal scan events (scanner → server queue)
@@ -11,13 +10,24 @@ from __future__ import annotations
import msgspec import msgspec
from .data import Movie, Series, TaskInfo from .data import Movie, Series, TaskInfo
from .tmdb import Person
class Upsert(msgspec.Struct, tag="upsert"): class Upsert(msgspec.Struct, tag="upsert"):
"""Single item inserted or updated.""" """Single item inserted or updated.
``scanned`` lists the media-root-relative torrent paths whose content was
(re)scanned to build this item. When present, the store merges the item
into the existing entry instead of replacing it wholesale: only data
belonging to the scanned torrents is replaced. ``None`` means full
replacement (legacy behaviour).
"""
kind: str # "movie" or "series" kind: str # "movie" or "series"
id: str
item: Movie | Series item: Movie | Series
people: dict[int, Person] | None = None
scanned: list[str] | None = None
class Remove(msgspec.Struct, tag="remove"): class Remove(msgspec.Struct, tag="remove"):
@@ -27,6 +37,35 @@ class Remove(msgspec.Struct, tag="remove"):
id: str id: str
class Sync(msgspec.Struct, tag="sync"):
"""Full set of media-root-relative torrent paths currently on disk.
Sent by the scanner after a successfully completed discovery pass so the
store can drop entries whose files no longer exist. Internal only —
never forwarded to WebSocket clients.
"""
paths: list[str]
class MovieShowreel(msgspec.Struct, tag="movie-showreel"):
"""Reel worker result for a movie (internal, scanner → store)."""
id: str
showreel_images: list[str] | None = None
showreel_source_sets: list[list[str]] | None = None
class EpisodeReel(msgspec.Struct, tag="episode-reel"):
"""Reel worker result for one episode (internal, scanner → store)."""
id: str
season: int
episode: int
reel_image: str | None = None
reel_sources: list[str] | None = None
class Task(msgspec.Struct, tag="task"): class Task(msgspec.Struct, tag="task"):
"""Task progress broadcast.""" """Task progress broadcast."""
@@ -34,4 +73,4 @@ class Task(msgspec.Struct, tag="task"):
# Union of scan events (scanner → server) and WS broadcast messages # Union of scan events (scanner → server) and WS broadcast messages
ScanEvent = Upsert | Task ScanEvent = Upsert | Sync | MovieShowreel | EpisodeReel | Task
+94 -36
View File
@@ -1,5 +1,4 @@
""" """Protocol structures for API and WebSocket communication.
Protocol structures for API and WebSocket communication.
All types are msgspec.Structs for fast serialization. All types are msgspec.Structs for fast serialization.
""" """
@@ -9,41 +8,87 @@ from __future__ import annotations
import msgspec import msgspec
from fastapi.responses import Response from fastapi.responses import Response
from .data import Movie, Series from .data import Movie, Series, TaskInfo
from .events import Remove, ScanEvent, Task, Upsert from .events import ScanEvent
from .tmdb import Person
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# WebSocket message types # WebSocket message types
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class WsInitData(msgspec.Struct): class WsRootStatus(msgspec.Struct):
"""Payload of the init message.""" """Current status for one configured root."""
movies: list[Movie] root_id: str
series: list[Series] path: str
status: str
error: str | None = None
snapshot_loaded: bool = False
movies: int = 0
series: int = 0
class WsRootInitData(msgspec.Struct):
"""Initial full index payload for one root."""
movies: dict[str, Movie]
series: dict[str, Series]
people: dict[int, Person]
class WsRoots(msgspec.Struct, tag="roots"):
"""Root list and status update."""
roots: list[WsRootStatus]
class WsInit(msgspec.Struct, tag="init"): class WsInit(msgspec.Struct, tag="init"):
"""Full index sent on WS connect.""" """Full index payload keyed by root_id."""
data: WsInitData roots: dict[str, WsRootInitData]
class WsUpsert(msgspec.Struct, tag="upsert"):
"""Single item inserted or updated for one root."""
root_id: str
kind: str # "movie" or "series"
id: str
item: Movie | Series
people: dict[int, Person] | None = None
class WsRemove(msgspec.Struct, tag="remove"):
"""Single item removed for one root."""
root_id: str
kind: str
id: str
class WsTask(msgspec.Struct, tag="task"):
"""Task progress update for one root."""
root_id: str
data: TaskInfo
# Union of all outbound WS messages (for documentation / future decoding) # Union of all outbound WS messages (for documentation / future decoding)
WsMessage = WsInit | Upsert | Remove | Task WsMessage = WsRoots | WsInit | WsUpsert | WsRemove | WsTask
# Re-export unified types for backward compatibility # Re-export unified types for backward compatibility
__all__ = [ __all__ = [
"Remove",
"ScanEvent", "ScanEvent",
"Task",
"Upsert",
"WsInit", "WsInit",
"WsInitData",
"WsMessage", "WsMessage",
"WsRemove",
"WsRootInitData",
"WsRootStatus",
"WsRoots",
"WsTask",
"WsUpsert",
] ]
@@ -52,37 +97,50 @@ __all__ = [
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ScanRequest(msgspec.Struct):
"""POST /api/scan body."""
paths: list[str] | None = None
class StatusResponse(msgspec.Struct):
"""GET /api/status response."""
scanning: bool = False
movies: int = 0
series: int = 0
showreel_queue: int = 0
class PlayMediaRequest(msgspec.Struct): class PlayMediaRequest(msgspec.Struct):
"""POST /api/play body (mediahive server).""" """POST /api/play/{root_id} body."""
file_path: str = "" file_path: str = ""
player_id: str | None = None
player_custom_cmd: str | None = None
class OpenFolderRequest(msgspec.Struct): class OpenFolderRequest(msgspec.Struct):
"""POST /api/open-folder body (mediahive server).""" """POST /api/open-folder/{root_id} body."""
folder_path: str = "" folder_path: str = ""
class ChangeFolderRequest(msgspec.Struct): class RootsRequest(msgspec.Struct):
"""POST /api/change-folder body.""" """PUT /api/config/roots body."""
folder: str roots: dict[str, str]
class PlaybackStateUpdateRequest(msgspec.Struct):
"""POST /api/meta/playback-state body."""
root_id: str
file_path: str
pos: int | None = None
class RootEntryResponse(msgspec.Struct):
"""Single root entry in responses."""
path: str
root_id: str
class RootStatusResponse(msgspec.Struct):
"""Legacy per-root status shape kept for non-WS callers."""
root_id: str
path: str
status: str
error: str | None = None
movies: int = 0
series: int = 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+14 -18
View File
@@ -1,5 +1,4 @@
""" """TMDb data structures.
TMDb data structures.
All types are msgspec.Structs for fast serialization. All types are msgspec.Structs for fast serialization.
""" """
@@ -8,26 +7,24 @@ from __future__ import annotations
import msgspec import msgspec
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sub-types (shared by TMDb results and index items) # Sub-types (shared by TMDb results and index items)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class CastMember(msgspec.Struct): class CastCredit(msgspec.Struct, array_like=True):
"""Actor/crew member.""" """Cast reference embedded in media info (character + person id)."""
character: str | None = None
id: int | None = None
class Person(msgspec.Struct, array_like=True):
"""Deduplicated person payload stored in top-level people map."""
name: str name: str
character: str | None = None
profile_path: str | None = None profile_path: str | None = None
gender: str | None = None
class SimilarMedia(msgspec.Struct):
"""Pointer to a similar movie/series on TMDb."""
id: int
title: str
poster_path: str | None = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -68,6 +65,7 @@ class Info(msgspec.Struct):
tmdb_id: int tmdb_id: int
title: str | None = None title: str | None = None
original_title: str | None = None original_title: str | None = None
original_language: str | None = None
alternative_titles: list[str] | None = None alternative_titles: list[str] | None = None
rating: float | None = None rating: float | None = None
vote_count: int | None = None vote_count: int | None = None
@@ -75,13 +73,11 @@ class Info(msgspec.Struct):
genres: list[str] | None = None genres: list[str] | None = None
release_date: str | None = None release_date: str | None = None
runtime: int | None = None runtime: int | None = None
collection: str | None = None
status: str | None = None status: str | None = None
tagline: str | None = None tagline: str | None = None
poster_path: str | None = None
backdrop_path: str | None = None
similar: list[SimilarMedia] | None = None
keywords: list[str] | None = None keywords: list[str] | None = None
cast: list[CastMember] | None = None cast: list[CastCredit] | None = None
director: str | None = None director: str | None = None
creators: list[str] | None = None creators: list[str] | None = None
number_of_seasons: int | None = None number_of_seasons: int | None = None
+363
View File
@@ -0,0 +1,363 @@
"""Media player detection and launching.
Windows: registry + known install paths.
macOS: /Applications + PATH.
Linux: PATH (which) only.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
import msgspec
class PlayerInfo(msgspec.Struct):
"""Descriptor for a detected media player."""
id: str
name: str
family: str
path: str | None = None
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _which(cmd: str) -> str | None:
"""Find a command in PATH."""
return shutil.which(cmd)
# ---------------------------------------------------------------------------
# Windows detection
# ---------------------------------------------------------------------------
def _winreg_lookup(key_path: str, value_name: str = "") -> str | None:
"""Read a string value from the Windows registry."""
try:
import winreg
# key_path like r"HKLM\Software\MPC-BE Team\MPC-BE"
parts = key_path.split("\\", 1)
hive_name = parts[0].upper()
subpath = parts[1] if len(parts) > 1 else ""
hive = {
"HKLM": winreg.HKEY_LOCAL_MACHINE,
"HKCU": winreg.HKEY_CURRENT_USER,
"HKCR": winreg.HKEY_CLASSES_ROOT,
}.get(hive_name)
if hive is None:
return None
with winreg.OpenKey(hive, subpath) as key:
val, _ = winreg.QueryValueEx(key, value_name or None)
if isinstance(val, str):
return val
except OSError:
pass
return None
def _expand_command_path(cmd: str) -> Path | None:
r"""Extract the executable path from a shell\open\command string.
Handles quoted paths like ``"C:\Program Files\Player\player.exe" "%1"``
and unquoted like ``C:\Program Files\Player\player.exe "%1"``.
"""
cmd = cmd.strip()
if cmd.startswith('"'):
end = cmd.find('"', 1)
if end != -1:
exe = cmd[1:end]
return Path(exe) if Path(exe).exists() else None
# Space-separated, take first token
parts = cmd.split()
if parts:
candidate = Path(parts[0])
if candidate.exists():
return candidate
return None
def _detect_default_player() -> PlayerInfo | None:
"""Detect which program is associated with .mkv files."""
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r".mkv") as key:
progid, _ = winreg.QueryValueEx(key, None)
if not progid:
return None
with winreg.OpenKey(
winreg.HKEY_CLASSES_ROOT, f"{progid}\\shell\\open\\command"
) as key:
cmd, _ = winreg.QueryValueEx(key, None)
exe_path = _expand_command_path(cmd) if cmd else None
name = progid.replace(".", " ").title()
return PlayerInfo(
id="default-associated",
name=f"Default ({name})",
family="default",
path=str(exe_path) if exe_path else None,
)
except OSError:
return None
def _detect_mpc_be() -> PlayerInfo | None:
candidates = [
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
/ "MPC-BE"
/ "mpc-be64.exe",
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
/ "MPC-BE"
/ "mpc-be.exe",
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
/ "MPC-BE"
/ "mpc-be.exe",
]
# Registry path used by MPC-BE installer
reg = _winreg_lookup(r"HKLM\Software\MPC-BE Team\MPC-BE", "ExePath")
if reg:
candidates.insert(0, Path(reg))
for path in candidates:
if path.exists():
return PlayerInfo(id="mpc-be", name="MPC-BE", family="mpc", path=str(path))
return None
def _detect_mpc_hc() -> PlayerInfo | None:
candidates = [
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
/ "MPC-HC"
/ "mpc-hc64.exe",
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
/ "MPC-HC"
/ "mpc-hc.exe",
]
for path in candidates:
if path.exists():
return PlayerInfo(id="mpc-hc", name="MPC-HC", family="mpc", path=str(path))
return None
def _detect_vlc() -> PlayerInfo | None:
candidates = [
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
/ "VideoLAN"
/ "VLC"
/ "vlc.exe",
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
/ "VideoLAN"
/ "VLC"
/ "vlc.exe",
]
for path in candidates:
if path.exists():
return PlayerInfo(
id="vlc", name="VLC media player", family="vlc", path=str(path)
)
return None
def _detect_potplayer() -> PlayerInfo | None:
candidates = [
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
/ "DAUM"
/ "PotPlayer"
/ "PotPlayer64.exe",
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
/ "DAUM"
/ "PotPlayer"
/ "PotPlayer.exe",
]
for path in candidates:
if path.exists():
return PlayerInfo(
id="potplayer", name="PotPlayer", family="potplayer", path=str(path)
)
return None
def _detect_mpv() -> PlayerInfo | None:
# Check PATH first
mpv_in_path = shutil.which("mpv")
if mpv_in_path:
return PlayerInfo(id="mpv", name="mpv", family="mpv", path=mpv_in_path)
candidates = [
Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local"))
/ "mpv"
/ "mpv.exe",
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "mpv" / "mpv.exe",
]
for path in candidates:
if path.exists():
return PlayerInfo(id="mpv", name="mpv", family="mpv", path=str(path))
return None
# ---------------------------------------------------------------------------
# macOS detection
# ---------------------------------------------------------------------------
def _detect_macos_app(
bundle_name: str, display_name: str, family: str
) -> PlayerInfo | None:
"""Detect an app in /Applications or ~/Applications."""
for apps_dir in (Path("/Applications"), Path.home() / "Applications"):
app_path = apps_dir / f"{bundle_name}.app"
if app_path.exists():
# Find the actual executable inside the bundle
macos_dir = app_path / "Contents" / "MacOS"
if macos_dir.exists():
# Often the executable name matches the bundle name
exe = macos_dir / bundle_name
if exe.exists():
return PlayerInfo(
id=family, name=display_name, family=family, path=str(exe)
)
# Fallback: any executable in MacOS dir
for child in macos_dir.iterdir():
if child.is_file() and os.access(child, os.X_OK):
return PlayerInfo(
id=family, name=display_name, family=family, path=str(child)
)
return None
def _detect_iina() -> PlayerInfo | None:
return _detect_macos_app("IINA", "IINA", "mpv")
def _detect_vlc_macos() -> PlayerInfo | None:
return _detect_macos_app("VLC", "VLC media player", "vlc")
# ---------------------------------------------------------------------------
# Linux detection
# ---------------------------------------------------------------------------
def _detect_vlc_linux() -> PlayerInfo | None:
path = _which("vlc")
if path:
return PlayerInfo(id="vlc", name="VLC media player", family="vlc", path=path)
return None
def _detect_smplayer() -> PlayerInfo | None:
path = _which("smplayer")
if path:
return PlayerInfo(id="smplayer", name="SMPlayer", family="mpv", path=path)
return None
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def detect_players() -> list[PlayerInfo]:
"""Return a list of detected players plus default and custom entries."""
detected: list[PlayerInfo] = []
if sys.platform == "win32":
# Specific players
for detector in (
_detect_mpc_be,
_detect_mpc_hc,
_detect_vlc,
_detect_potplayer,
_detect_mpv,
):
player = detector()
if player:
detected.append(player)
# Default associated player (optional, for info)
default_assoc = _detect_default_player()
if default_assoc and default_assoc.path:
# Only add if it's a different executable than one we already found
existing_paths = {p.path.lower() for p in detected if p.path}
if default_assoc.path.lower() not in existing_paths:
detected.append(default_assoc)
elif sys.platform == "darwin":
for detector in (_detect_iina, _detect_vlc_macos):
player = detector()
if player:
detected.append(player)
else:
# Linux / other Unix
for detector in (_detect_vlc_linux, _detect_smplayer):
player = detector()
if player:
detected.append(player)
# Always include the abstract "default" and "custom" options
result: list[PlayerInfo] = [
PlayerInfo(id="default", name="System Default", family="default"),
]
result.extend(detected)
result.append(PlayerInfo(id="custom", name="Custom…", family="custom"))
return result
def launch_player(
player_id: str,
file_path: Path,
player_path: str | None = None,
custom_cmd: str | None = None,
) -> None:
"""Launch a media file with the specified player.
Args:
player_id: One of "default", "custom", or a detected player id.
file_path: Absolute path to the media file.
player_path: Executable path for detected players (from detection).
custom_cmd: Raw command string for "custom" player (with %s).
"""
if player_id == "default" or not player_id:
if sys.platform == "win32":
os.startfile(str(file_path))
return
opener = "open" if sys.platform == "darwin" else "xdg-open"
subprocess.Popen(
[opener, str(file_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return
if player_id == "custom":
if not custom_cmd:
raise ValueError("Custom player command is empty")
cmd_str = custom_cmd.replace("%s", str(file_path))
if "%s" not in custom_cmd:
cmd_str = f'{custom_cmd} "{file_path}"'
# Use shell=True for custom commands so arguments are parsed naturally
subprocess.Popen(
cmd_str, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
return
if not player_path:
raise ValueError(f"Player path not provided for {player_id}")
# Detected player: pass file path as the single argument
subprocess.Popen(
[player_path, str(file_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
+362
View File
@@ -0,0 +1,362 @@
"""Root registry, per-root context, and supervisor for multi-root media support."""
from __future__ import annotations
import asyncio
import contextlib
import logging
from pathlib import Path
import msgspec
from mediahive.config import load_config, save_config
from mediahive.index_store import IndexStore
from mediahive.models.events import (
EpisodeReel,
MovieShowreel,
ScanEvent,
Sync,
Task,
Upsert,
)
logger = logging.getLogger("mediahive.root_registry")
# ---------------------------------------------------------------------------
# Root path normalization and friendly name derivation
# ---------------------------------------------------------------------------
def _normalize_path(path: str) -> str:
"""Canonicalize a path for stable ID generation.
- expanduser() only (do not resolve symlinks/mapped drives)
- lower-case drive letter on Windows
- strip trailing separators
- use forward slashes
"""
p = Path(path).expanduser()
posix = p.as_posix()
# Canonicalize drive-only roots ("Z:") to drive root ("Z:/") so paths are absolute.
if len(posix) == 2 and posix[1] == ":" and posix[0].isalpha():
posix = f"{posix}/"
# Windows drive letter normalization
if len(posix) >= 2 and posix[1] == ":":
posix = posix[0].lower() + posix[1:]
# Strip trailing slash (except root "/")
while (
len(posix) > 1
and posix.endswith("/")
and not (len(posix) == 3 and posix[1] == ":" and posix[2] == "/")
):
posix = posix[:-1]
return posix
def _derive_root_name(path: str) -> str:
"""Derive a friendly root name from a path basename/anchor."""
normalized = (path or "").replace("\\", "/").rstrip("/")
if not normalized:
return "media"
parts = [segment for segment in normalized.split("/") if segment]
if parts:
leaf = parts[-1]
if len(leaf) == 2 and leaf[1] == ":" and leaf[0].isalpha():
return leaf[0]
return leaf
return "media"
# ---------------------------------------------------------------------------
# Root entry
# ---------------------------------------------------------------------------
class RootEntry(msgspec.Struct):
path: str
root_id: str
# ---------------------------------------------------------------------------
# Root context
# ---------------------------------------------------------------------------
class RootContext:
"""Runtime container for a single media root."""
def __init__(self, root_id: str, root_path: Path) -> None:
self.root_id = root_id
self.root_path = root_path
self.status = "loading"
self.error: str | None = None
snapshot_path = root_path / ".mediahive" / "index.json"
self.store = IndexStore(snapshot_path)
# Scanner is injected later by the supervisor
self.scanner: object | None = None
# Event queue and consumer
self._events: asyncio.Queue[ScanEvent] = asyncio.Queue()
self._consumer_task: asyncio.Task | None = None
self._startup_task: asyncio.Task | None = None
async def start(self) -> None:
"""Start consumer immediately and load snapshot in the background."""
self.status = "loading"
self.error = None
if self._consumer_task is None or self._consumer_task.done():
self._consumer_task = asyncio.create_task(self._consume_events())
if self._startup_task is None or self._startup_task.done():
self._startup_task = asyncio.create_task(self._load_snapshot_background())
async def _load_snapshot_background(self) -> None:
"""Load snapshot without blocking root activation paths."""
try:
await self.store.load_snapshot()
self.status = "ready"
logger.info(
"Root %s ready: %d movies, %d series",
self.root_id,
len(self.store.movies),
len(self.store.series),
)
except asyncio.CancelledError:
raise
except Exception as exc:
self.status = "error"
self.error = str(exc)
logger.exception("Root %s failed to load snapshot", self.root_id)
async def stop(self) -> None:
"""Stop consumer, flush snapshot, stop scanner."""
if self._startup_task and not self._startup_task.done():
self._startup_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._startup_task
if self.scanner is not None:
try:
await self.scanner.stop()
except Exception:
logger.exception("Error stopping scanner for root %s", self.root_id)
self.scanner = None
if self._consumer_task and not self._consumer_task.done():
self._consumer_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._consumer_task
try:
await self.store.flush_snapshot()
except Exception:
logger.exception("Error flushing snapshot for root %s", self.root_id)
async def send_event(self, event: ScanEvent) -> None:
"""Push a scanner event into this root's queue."""
await self._events.put(event)
async def _consume_events(self) -> None:
while True:
try:
event = await self._events.get()
if isinstance(event, Upsert):
if event.kind == "movie":
self.store.upsert_movie(
event.id, event.item, event.people, event.scanned
)
else:
self.store.upsert_series(
event.id, event.item, event.people, event.scanned
)
elif isinstance(event, Sync):
self.store.sync_torrent_paths(set(event.paths))
elif isinstance(event, MovieShowreel):
self.store.set_movie_showreel(
event.id, event.showreel_images, event.showreel_source_sets
)
elif isinstance(event, EpisodeReel):
self.store.set_episode_reel(
event.id,
event.season,
event.episode,
event.reel_image,
event.reel_sources,
)
elif isinstance(event, Task):
self.store.broadcast_task(event.data)
except asyncio.CancelledError:
return
except Exception:
logger.exception(
"Error processing scan event for root %s", self.root_id
)
# ---------------------------------------------------------------------------
# Supervisor
# ---------------------------------------------------------------------------
class Supervisor:
"""Manages the active set of RootContexts and handles atomic replacement."""
def __init__(self) -> None:
# Active contexts keyed by root_id
self._contexts: dict[str, RootContext] = {}
self._lock = asyncio.Lock()
# ------------------------------------------------------------------
# Read helpers
# ------------------------------------------------------------------
def get(self, root_id: str) -> RootContext | None:
return self._contexts.get(root_id)
def all_contexts(self) -> dict[str, RootContext]:
return self._contexts.copy()
def all_statuses(self) -> list[dict]:
return [
{
"root_id": ctx.root_id,
"path": ctx.root_path.as_posix(),
"status": ctx.status,
"error": ctx.error,
"snapshot_loaded": ctx.store.snapshot_loaded,
"movies": len(ctx.store.movies),
"series": len(ctx.store.series),
}
for ctx in self._contexts.values()
]
def merged_index(self) -> dict:
"""Return a merged index snapshot from all ready roots."""
movies = []
series = []
total_movie_versions = 0
total_series_episodes = 0
for ctx in self._contexts.values():
if ctx.status not in {"ready", "scanning"}:
continue
movies.extend(ctx.store.movies.values())
series.extend(ctx.store.series.values())
total_movie_versions += sum(len(m.files) for m in ctx.store.movies.values())
total_series_episodes += sum(
sum(len(season.episodes) for season in s.seasons)
for s in ctx.store.series.values()
)
from datetime import datetime
return {
"v": 1,
"generated_at": datetime.now().isoformat(),
"stats": {
"total_movies": len(movies),
"total_movie_versions": total_movie_versions,
"total_series": len(series),
"total_series_episodes": total_series_episodes,
},
"movies": movies,
"series": series,
}
# ------------------------------------------------------------------
# Atomic replacement
# ------------------------------------------------------------------
async def replace_roots(
self, roots: dict[str, str]
) -> tuple[list[RootEntry], list[dict]]:
"""Atomically replace the active root set.
Returns (accepted_entries, failed_entries_with_reason).
"""
async with self._lock:
# Validate and canonicalize
candidates: list[RootEntry] = []
seen_paths: set[str] = set()
failed: list[dict] = []
for path_str in roots.values():
p = Path(path_str).expanduser()
if not p.exists() or not p.is_dir():
failed.append({
"path": path_str,
"reason": "not a directory",
})
continue
norm = _normalize_path(p.as_posix())
if norm in seen_paths:
failed.append({
"path": path_str,
"reason": "duplicate path",
})
continue
seen_paths.add(norm)
# Friendly names should reflect the configured root path (e.g. "Z:" -> "Z"),
# not the resolved physical target (which may be a UNC path).
configured_path = Path(path_str).expanduser().as_posix()
base_name = _derive_root_name(configured_path)
unique_name = base_name
suffix = 2
existing_names = {e.root_id for e in candidates}
while unique_name in existing_names:
unique_name = f"{base_name}{suffix}"
suffix += 1
# root_id now uses the same friendly identifier as the display name.
candidates.append(RootEntry(path=norm, root_id=unique_name))
# Build desired root_id set
desired_ids = {e.root_id for e in candidates}
# Stop scanners for roots that are being removed or changed
old_contexts = list(self._contexts.values())
for ctx in old_contexts:
if ctx.root_id not in desired_ids:
asyncio.create_task(ctx.stop())
# Prepare new contexts
new_contexts: dict[str, RootContext] = {}
for entry in candidates:
existing = self._contexts.get(entry.root_id)
if existing and existing.root_path.as_posix() == entry.path:
# Reuse existing context
new_contexts[entry.root_id] = existing
else:
# If existing path changed, stop old one
if existing:
asyncio.create_task(existing.stop())
ctx = RootContext(entry.root_id, Path(entry.path))
await ctx.start()
new_contexts[entry.root_id] = ctx
# Atomic swap
self._contexts = new_contexts
# Persist to config
cfg = load_config()
save_config(
msgspec.structs.replace(
cfg,
roots={e.root_id: e.path for e in candidates},
)
)
return candidates, failed
async def shutdown(self) -> None:
async with self._lock:
# Stop roots concurrently — each may wait on task cancellation and
# network-mount snapshot flushes, and those delays must not add up.
await asyncio.gather(
*(ctx.stop() for ctx in list(self._contexts.values())),
return_exceptions=True,
)
self._contexts.clear()
+1569 -292
View File
File diff suppressed because it is too large Load Diff
+475
View File
@@ -0,0 +1,475 @@
"""Cross-platform master volume control with linear amplification mapping.
The UI slider produces a value ``x`` in the range ``[0, 1.5]``. This module
converts ``x`` to a linear amplitude coefficient ``amp`` using:
amp = 0.02 * x if x < 0.1
amp = (exp(6 * x) - 1) / 402.42879349 otherwise
``amp`` is then applied to the system mixer in a platform-specific way:
* **Linux / PipeWire** ``wpctl`` accepts linear values > 1.0 natively, so
amplification up to ~150 % (and beyond) works without clipping.
* **Windows** ``IAudioEndpointVolume`` uses an audio-tapered scalar and does
not support > 100 %. ``amp`` is clamped to 1.0 and mapped to the scalar.
* **macOS** ``osascript`` sets a 0-100 slider and does not support > 100 %.
``amp`` is clamped to 1.0 and mapped to that range.
"""
from __future__ import annotations
import logging
import math
import subprocess
import sys
import threading
from collections.abc import Callable
logger = logging.getLogger("mediahive.volume")
# ---------------------------------------------------------------------------
# Linear amplitude formula
# ---------------------------------------------------------------------------
_AMP_DENOMINATOR = 402.42879349
def slider_to_amp(x: float) -> float:
"""Convert UI slider position ``x`` (0.0 .. 1.5) to linear amplitude.
Returns a value in the range ``[0, ~20]`` where ``1.0`` means unity gain.
"""
if x <= 0.0:
return 0.0
if x < 0.1:
return 0.02 * x
return (math.exp(6.0 * x) - 1.0) / _AMP_DENOMINATOR
def amp_to_slider(amp: float) -> float:
"""Inverse of :func:`slider_to_amp` for the ``x >= 0.1`` branch.
Returns a slider position in ``[0.1, 1.5]`` (or slightly above when the
platform reports amplification > 1.0).
"""
if amp <= 0.0:
return 0.0
# For very small values we fall back to the linear branch
if amp < 0.002042892590415348: # value at x == 0.1
return amp / 0.02
return math.log(amp * _AMP_DENOMINATOR + 1.0) / 6.0
# ---------------------------------------------------------------------------
# Platform backends
# ---------------------------------------------------------------------------
def _set_volume_windows(amp: float) -> None:
"""Set master volume on Windows via ``IAudioEndpointVolume`` (ctypes)."""
import ctypes
import uuid
from ctypes import (
POINTER,
Structure,
byref,
c_float,
c_uint32,
c_void_p,
cast,
wintypes,
)
class GUID(Structure):
_fields_ = [
("Data1", wintypes.DWORD),
("Data2", wintypes.WORD),
("Data3", wintypes.WORD),
("Data4", wintypes.BYTE * 8),
]
def _uuid_to_guid(u: uuid.UUID) -> GUID:
g = GUID()
g.Data1 = u.time_low
g.Data2 = u.time_mid
g.Data3 = u.time_hi_version
g.Data4[0] = u.clock_seq_hi_variant & 0xFF
g.Data4[1] = u.clock_seq_low & 0xFF
for i in range(6):
g.Data4[2 + i] = (u.node >> (40 - i * 8)) & 0xFF
return g
CLSID_MMDeviceEnumerator = _uuid_to_guid(
uuid.UUID("{BCDE0395-E52F-467C-8E3D-C4579291692E}")
)
IID_IMMDeviceEnumerator = _uuid_to_guid(
uuid.UUID("{A95664D2-9614-4F35-A746-DE8DB63617E6}")
)
IID_IAudioEndpointVolume = _uuid_to_guid(
uuid.UUID("{5CDF2C82-841E-4546-9722-0CF74078229A}")
)
IID_IMMDevice = _uuid_to_guid(uuid.UUID("{D666063F-1587-4E43-81F1-B948E807363F}"))
CLSCTX_ALL = 23
ole32 = ctypes.windll.ole32
ole32.CoInitializeEx(None, 0)
CoCreateInstance = ole32.CoCreateInstance
CoCreateInstance.argtypes = [
POINTER(GUID),
c_void_p,
wintypes.DWORD,
POINTER(GUID),
POINTER(c_void_p),
]
CoCreateInstance.restype = wintypes.HRESULT
enumerator_ptr = c_void_p()
hr = CoCreateInstance(
byref(CLSID_MMDeviceEnumerator),
None,
CLSCTX_ALL,
byref(IID_IMMDeviceEnumerator),
byref(enumerator_ptr),
)
if hr != 0:
raise OSError(f"CoCreateInstance failed: {hr:#x}")
try:
vtable_ptr = cast(enumerator_ptr, POINTER(c_void_p)).contents
vtable = cast(vtable_ptr, POINTER(c_void_p * 6))
GetDefaultAudioEndpoint = ctypes.WINFUNCTYPE(
wintypes.HRESULT, c_void_p, c_uint32, c_uint32, POINTER(c_void_p)
)(vtable[0][4])
device_ptr = c_void_p()
hr = GetDefaultAudioEndpoint(enumerator_ptr, 0, 0, byref(device_ptr))
if hr != 0:
raise OSError(f"GetDefaultAudioEndpoint failed: {hr:#x}")
try:
device_vtable_ptr = cast(device_ptr, POINTER(c_void_p)).contents
device_vtable = cast(device_vtable_ptr, POINTER(c_void_p * 7))
Activate = ctypes.WINFUNCTYPE(
wintypes.HRESULT,
c_void_p,
POINTER(GUID),
wintypes.DWORD,
c_void_p,
POINTER(c_void_p),
)(device_vtable[0][3])
volume_ptr = c_void_p()
hr = Activate(
device_ptr,
byref(IID_IAudioEndpointVolume),
CLSCTX_ALL,
None,
byref(volume_ptr),
)
if hr != 0:
raise OSError(f"Activate(IAudioEndpointVolume) failed: {hr:#x}")
try:
vol_vtable_ptr = cast(volume_ptr, POINTER(c_void_p)).contents
vol_vtable = cast(vol_vtable_ptr, POINTER(c_void_p * 22))
GetVolumeRange = ctypes.WINFUNCTYPE(
wintypes.HRESULT,
c_void_p,
POINTER(c_float),
POINTER(c_float),
POINTER(c_float),
)(vol_vtable[0][20])
min_db = c_float()
max_db = c_float()
step_db = c_float()
hr = GetVolumeRange(
volume_ptr, byref(min_db), byref(max_db), byref(step_db)
)
if hr != 0:
raise OSError(f"GetVolumeRange failed: {hr:#x}")
SetMasterVolumeLevel = ctypes.WINFUNCTYPE(
wintypes.HRESULT, c_void_p, c_float, POINTER(GUID)
)(vol_vtable[0][6])
SetMasterVolumeLevelScalar = ctypes.WINFUNCTYPE(
wintypes.HRESULT, c_void_p, c_float, POINTER(GUID)
)(vol_vtable[0][7])
# Windows does not natively support >100% system volume.
# Clamp to unity gain and map to the audio-tapered scalar.
# The exponent 0.573 was empirically derived from measurements
# on a reference Windows system.
clamped = min(amp, 1.0)
scalar = clamped**0.573
SetMasterVolumeLevelScalar(volume_ptr, scalar, None)
finally:
Release = ctypes.WINFUNCTYPE(wintypes.ULONG, c_void_p)(vol_vtable[0][2])
Release(volume_ptr)
finally:
Release = ctypes.WINFUNCTYPE(wintypes.ULONG, c_void_p)(device_vtable[0][2])
Release(device_ptr)
finally:
Release = ctypes.WINFUNCTYPE(wintypes.ULONG, c_void_p)(vtable[0][2])
Release(enumerator_ptr)
def _get_volume_windows() -> float:
"""Return current linear amplitude on Windows."""
import ctypes
import uuid
from ctypes import (
POINTER,
Structure,
byref,
c_float,
c_uint32,
c_void_p,
cast,
wintypes,
)
class GUID(Structure):
_fields_ = [
("Data1", wintypes.DWORD),
("Data2", wintypes.WORD),
("Data3", wintypes.WORD),
("Data4", wintypes.BYTE * 8),
]
def _uuid_to_guid(u: uuid.UUID) -> GUID:
g = GUID()
g.Data1 = u.time_low
g.Data2 = u.time_mid
g.Data3 = u.time_hi_version
g.Data4[0] = u.clock_seq_hi_variant & 0xFF
g.Data4[1] = u.clock_seq_low & 0xFF
for i in range(6):
g.Data4[2 + i] = (u.node >> (40 - i * 8)) & 0xFF
return g
CLSID_MMDeviceEnumerator = _uuid_to_guid(
uuid.UUID("{BCDE0395-E52F-467C-8E3D-C4579291692E}")
)
IID_IMMDeviceEnumerator = _uuid_to_guid(
uuid.UUID("{A95664D2-9614-4F35-A746-DE8DB63617E6}")
)
IID_IAudioEndpointVolume = _uuid_to_guid(
uuid.UUID("{5CDF2C82-841E-4546-9722-0CF74078229A}")
)
IID_IMMDevice = _uuid_to_guid(uuid.UUID("{D666063F-1587-4E43-81F1-B948E807363F}"))
CLSCTX_ALL = 23
ole32 = ctypes.windll.ole32
ole32.CoInitializeEx(None, 0)
CoCreateInstance = ole32.CoCreateInstance
CoCreateInstance.argtypes = [
POINTER(GUID),
c_void_p,
wintypes.DWORD,
POINTER(GUID),
POINTER(c_void_p),
]
CoCreateInstance.restype = wintypes.HRESULT
enumerator_ptr = c_void_p()
hr = CoCreateInstance(
byref(CLSID_MMDeviceEnumerator),
None,
CLSCTX_ALL,
byref(IID_IMMDeviceEnumerator),
byref(enumerator_ptr),
)
if hr != 0:
raise OSError(f"CoCreateInstance failed: {hr:#x}")
try:
vtable_ptr = cast(enumerator_ptr, POINTER(c_void_p)).contents
vtable = cast(vtable_ptr, POINTER(c_void_p * 6))
GetDefaultAudioEndpoint = ctypes.WINFUNCTYPE(
wintypes.HRESULT, c_void_p, c_uint32, c_uint32, POINTER(c_void_p)
)(vtable[0][4])
device_ptr = c_void_p()
hr = GetDefaultAudioEndpoint(enumerator_ptr, 0, 0, byref(device_ptr))
if hr != 0:
raise OSError(f"GetDefaultAudioEndpoint failed: {hr:#x}")
try:
device_vtable_ptr = cast(device_ptr, POINTER(c_void_p)).contents
device_vtable = cast(device_vtable_ptr, POINTER(c_void_p * 7))
Activate = ctypes.WINFUNCTYPE(
wintypes.HRESULT,
c_void_p,
POINTER(GUID),
wintypes.DWORD,
c_void_p,
POINTER(c_void_p),
)(device_vtable[0][3])
volume_ptr = c_void_p()
hr = Activate(
device_ptr,
byref(IID_IAudioEndpointVolume),
CLSCTX_ALL,
None,
byref(volume_ptr),
)
if hr != 0:
raise OSError(f"Activate failed: {hr:#x}")
try:
vol_vtable_ptr = cast(volume_ptr, POINTER(c_void_p)).contents
vol_vtable = cast(vol_vtable_ptr, POINTER(c_void_p * 22))
GetMasterVolumeLevel = ctypes.WINFUNCTYPE(
wintypes.HRESULT, c_void_p, POINTER(c_float)
)(vol_vtable[0][8])
GetMasterVolumeLevelScalar = ctypes.WINFUNCTYPE(
wintypes.HRESULT, c_void_p, POINTER(c_float)
)(vol_vtable[0][9])
db = c_float()
hr = GetMasterVolumeLevel(volume_ptr, byref(db))
if hr == 0 and db.value > -90.0:
return 10.0 ** (db.value / 20.0)
scalar = c_float()
hr = GetMasterVolumeLevelScalar(volume_ptr, byref(scalar))
if hr != 0:
raise OSError(f"GetMasterVolumeLevelScalar failed: {hr:#x}")
# Invert the audio-tapered mapping
return scalar.value ** (1.0 / 0.573)
finally:
Release = ctypes.WINFUNCTYPE(wintypes.ULONG, c_void_p)(vol_vtable[0][2])
Release(volume_ptr)
finally:
Release = ctypes.WINFUNCTYPE(wintypes.ULONG, c_void_p)(device_vtable[0][2])
Release(device_ptr)
finally:
Release = ctypes.WINFUNCTYPE(wintypes.ULONG, c_void_p)(vtable[0][2])
Release(enumerator_ptr)
def _set_volume_macos(amp: float) -> None:
"""Set master volume on macOS via AppleScript."""
# macOS does not natively support >100% system volume.
clamped = min(amp, 1.0)
level = int(clamped * 100)
subprocess.run(
["osascript", "-e", f"set volume output volume {level}"],
check=False,
capture_output=True,
)
def _get_volume_macos() -> float:
"""Return current linear amplitude on macOS."""
result = subprocess.run(
["osascript", "-e", "output volume of (get volume settings)"],
capture_output=True,
text=True,
check=False,
)
try:
return int(result.stdout.strip()) / 100.0
except ValueError, AttributeError:
return 1.0
def _set_volume_linux(amp: float) -> None:
"""Set master volume on Linux via PipeWire (``wpctl``)."""
# wpctl is the native PipeWire CLI and accepts linear values > 1.0
# directly. We do not fall back to PulseAudio tools.
subprocess.run(
["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", str(amp)],
check=True,
capture_output=True,
)
def _get_volume_linux() -> float:
"""Return current linear amplitude on Linux via PipeWire (``wpctl``)."""
result = subprocess.run(
["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"],
capture_output=True,
text=True,
check=True,
)
# Output: "Volume: 0.40" or "Volume: 0.50 [MUTED]"
line = result.stdout.strip()
parts = line.split()
return float(parts[1])
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
_setter: Callable[[float], None] | None = None
_getter: Callable[[], float] | None = None
_lock = threading.Lock()
def _init_backend() -> None:
"""Lazily select the platform backend."""
global _setter, _getter
with _lock:
if _setter is not None:
return
if sys.platform == "win32":
_setter = _set_volume_windows
_getter = _get_volume_windows
elif sys.platform == "darwin":
_setter = _set_volume_macos
_getter = _get_volume_macos
else:
_setter = _set_volume_linux
_getter = _get_volume_linux
def set_volume(x: float) -> None:
"""Set master volume from slider position ``x`` (0.0 .. 1.5).
The value is converted to a linear amplitude coefficient and applied to
the system mixer using the platform-native API.
"""
_init_backend()
amp = slider_to_amp(x)
assert _setter is not None
try:
_setter(amp)
except Exception:
logger.exception("Failed to set volume (x=%.3f, amp=%.6f)", x, amp)
def get_volume() -> float:
"""Return the current slider position (0.0 .. 1.5) by reading the OS mixer.
On platforms that do not support amplification above 100 % the returned
value will never exceed ``1.0``.
"""
_init_backend()
assert _getter is not None
try:
amp = _getter()
except Exception:
logger.exception("Failed to get volume")
return 1.0
return amp_to_slider(amp)
def volume_max() -> float:
"""Return the maximum slider position supported on this platform.
* Linux / PipeWire ``1.5`` (amplification above 100 % is supported).
* Windows / macOS ``1.0`` (the OS mixer does not amplify above unity).
"""
if sys.platform in ("win32", "darwin"):
return 1.0
return 1.5
+761 -143
View File
File diff suppressed because it is too large Load Diff
+70 -3
View File
@@ -8,11 +8,12 @@ dependencies = [
"aiofiles>=25.1.0", "aiofiles>=25.1.0",
"aiopathlib>=0.6.0", "aiopathlib>=0.6.0",
"bencodepy>=0.9.5", "bencodepy>=0.9.5",
"fastapi-vue>=0.5.2", "fastapi-vue~=1.7.2",
"fastapi[standard]>=0.128.0", "fastapi[standard]>=0.128.0",
"httpx[http2]>=0.28.1", "httpx[http2]>=0.28.1",
"msgspec>=0.19", "msgspec>=0.19",
"parse-torrent-title>=2.8.1", "parse-torrent-title>=2.8.1",
"platformdirs>=4.0",
"tomli-w>=1.2.0", "tomli-w>=1.2.0",
"uvicorn[standard]>=0.40.0", "uvicorn[standard]>=0.40.0",
] ]
@@ -36,7 +37,11 @@ artifacts = ["mediahive/frontend-build"]
only-packages = true only-packages = true
[tool.hatch.build.targets.sdist.hooks.custom] [tool.hatch.build.targets.sdist.hooks.custom]
path = "scripts/fastapi-vue/build-frontend.py" path = "scripts/fastapi-vue/buildhook.py"
[tool.hatch.build.targets.sdist.force-include]
"scripts/fastapi-vue/buildhook.py" = "scripts/fastapi-vue/buildhook.py"
"scripts/fastapi-vue/buildutil.py" = "scripts/fastapi-vue/buildutil.py"
[tool.uv] [tool.uv]
package = true package = true
@@ -46,7 +51,11 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
[project.optional-dependencies] [project.optional-dependencies]
gui = [ gui = [
"pywebview>=6.2.1", # pywebview's qt extra is Qt6-only (QtPy + PyQt6 + PyQt6-WebEngine);
# Qt5 would come from its separate qt5 extra, which we do not use.
"pywebview[qt]>=6.2.1; platform_system != 'Windows'",
"pywebview>=6.2.1; platform_system == 'Windows'",
"velopack>=1.2",
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'", "pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
"pyinstaller>=6.0", "pyinstaller>=6.0",
] ]
@@ -54,5 +63,63 @@ gui = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"httpx>=0.28.1", "httpx>=0.28.1",
"lefthook>=2.1.14",
"ruff>=0.15.14",
"setuptools-scm>=8", "setuptools-scm>=8",
] ]
[tool.ruff]
preview = true
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"incorrect-blank-line-before-class",
"multi-line-summary-second-line",
"docstring-missing-returns",
"missing-trailing-comma",
"print",
"EM",
"TC",
"raise-vanilla-args",
"S",
"missing-copyright-notice",
"PLR",
"PLW",
# TEMP suppressions - revisit and remove after focused cleanup passes.
"complex-structure",
"docstring-missing-exception",
"missing-return-type-undocumented-public-function",
"undocumented-public-function",
"boolean-type-hint-positional-argument",
"missing-type-function-argument",
"undocumented-public-method",
"try-consider-else",
"undocumented-public-init",
"missing-return-type-private-function",
"raise-without-from-inside-except",
"create-subprocess-in-async-function",
"line-too-long",
"implicit-namespace-package",
"boolean-default-value-positional-argument",
"import-outside-top-level",
"undocumented-public-class",
"asyncio-dangling-task",
"private-member-access",
"blocking-path-method-in-async-function",
"invalid-class-name",
"collapsible-if",
"call-datetime-now-without-tzinfo",
"useless-if-else",
"missing-terminal-punctuation",
"missing-trailing-period",
"any-type",
# Allow en-dash in docstrings (used for list formatting)
"ambiguous-unicode-character-docstring",
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
"non-lowercase-variable-in-function",
# Allow inline comments that describe output formats
"commented-out-code",
# Allow unused local variables in ctypes COM boilerplate
"unused-variable",
]
-162
View File
@@ -1,162 +0,0 @@
#!/usr/bin/env python3
"""
RTorrent Client - Communicate with rtorrent via XMLRPC over SCGI socket.
"""
import socket
import xmlrpc.client
from pathlib import Path
from typing import Dict, List, Optional
class SCGITransport(xmlrpc.client.Transport):
"""SCGI transport for communicating with rtorrent via Unix socket."""
def __init__(self, socket_path: str):
super().__init__()
self.socket_path = socket_path
def single_request(self, host, handler, request_body, verbose=False):
# Create SCGI request
headers = f"CONTENT_LENGTH\x00{len(request_body)}\x00SCGI\x001\x00"
request = f"{len(headers)}:{headers},{request_body.decode('utf-8')}"
# Connect to socket
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(self.socket_path)
sock.send(request.encode("utf-8"))
# Read response
response = b""
while True:
data = sock.recv(4096)
if not data:
break
response += data
sock.close()
# Parse response - skip HTTP headers
if b"\r\n\r\n" in response:
response = response.split(b"\r\n\r\n", 1)[1]
return self.parse_response(response)
def parse_response(self, response_body):
p, u = xmlrpc.client.getparser()
p.feed(response_body)
p.close()
return u.close()
class RTorrentClient:
"""Client for communicating with rtorrent via XMLRPC over SCGI socket."""
def __init__(self, socket_path: str = "/home/user/rtorrent/.session/rpc.socket"):
self.socket_path = socket_path
transport = SCGITransport(socket_path)
self.proxy = xmlrpc.client.ServerProxy(
"http://localhost/RPC2", transport=transport
)
def get_loaded_hashes(self) -> set[str]:
"""Get set of info hashes for all currently loaded torrents."""
try:
downloads = self.proxy.download_list("")
return set(h.upper() for h in downloads)
except Exception as e:
print(f"Error getting loaded torrents: {e}")
return set()
def load_torrent(self, torrent_path: Path, download_dir: Path) -> bool:
"""
Load a torrent file and set its download directory.
Uses load.start_verbose to load and immediately start/hash-check.
Args:
torrent_path: Path to the .torrent file
download_dir: Directory where the data already exists
Returns:
True if successful, False otherwise
"""
try:
# load.start_verbose with d.directory.set to specify download location
# This will hash-check existing files instead of re-downloading
self.proxy.load.start_verbose(
"", str(torrent_path), f'd.directory.set="{download_dir}"'
)
return True
except Exception as e:
print(f"Error loading torrent {torrent_path}: {e}")
return False
def get_torrent_info(self, info_hash: str) -> Optional[Dict]:
"""Get info about a loaded torrent."""
try:
name = self.proxy.d.name(info_hash)
message = self.proxy.d.message(info_hash)
tied_file = self.proxy.d.tied_to_file(info_hash)
directory = self.proxy.d.directory(info_hash)
base_path = self.proxy.d.base_path(info_hash) # Actual data path
is_multi_file = self.proxy.d.is_multi_file(info_hash)
return {
"hash": info_hash,
"name": name,
"message": message,
"tied_file": tied_file,
"directory": directory,
"base_path": base_path, # Full path to data (file or folder)
"is_multi_file": is_multi_file,
}
except Exception as e:
print(f"Error getting torrent info for {info_hash}: {e}")
return None
def get_unregistered_torrents(self) -> List[Dict]:
"""
Find all torrents with 'unregistered' or 'not registered' tracker errors.
Returns:
List of torrent info dicts for torrents with registration errors
"""
unregistered = []
try:
hashes = self.proxy.download_list("")
for info_hash in hashes:
try:
message = self.proxy.d.message(info_hash)
if message and (
"unregistered" in message.lower()
or "not registered" in message.lower()
):
info = self.get_torrent_info(info_hash)
if info:
unregistered.append(info)
except Exception:
continue
except Exception as e:
print(f"Error scanning for unregistered torrents: {e}")
return unregistered
def remove_torrent(self, info_hash: str, delete_files: bool = False) -> bool:
"""
Remove a torrent from rtorrent.
Args:
info_hash: The info hash of the torrent to remove
delete_files: If True, also delete downloaded files (default: False)
Returns:
True if successful, False otherwise
"""
try:
if delete_files:
# This would delete the data - NOT what we want
self.proxy.d.erase(info_hash)
else:
# Just remove from rtorrent, keep files
self.proxy.d.erase(info_hash)
return True
except Exception as e:
print(f"Error removing torrent {info_hash}: {e}")
return False
+79 -20
View File
@@ -1,37 +1,57 @@
# MediaHive.spec — PyInstaller build for the Windows GUI application # MediaHive.spec — PyInstaller build for the MediaHive desktop GUI app
# #
# Build manually (from repo root): # Build manually (from repo root):
# uv run --no-project --python 3.14 --with ".[gui]" --with pyinstaller ^ # uv run --no-project --python 3.14 --with ".[gui]" --with pyinstaller \
# pyinstaller --noconfirm --clean scripts/MediaHive.spec # pyinstaller --noconfirm --clean scripts/MediaHive.spec
# #
# Or use the build script (recommended—handles versioning and packaging): # Or use the build script (recommended—handles versioning and packaging):
# uv run scripts/build_windows_gui.py # uv run scripts/guibuild.py
import sys
import mediahive.winmain import mediahive.winmain
import mediahive.server import mediahive.server
from pathlib import Path from pathlib import Path
from PyInstaller.utils.hooks import collect_data_files
block_cipher = None block_cipher = None
_pkg = Path(mediahive.server.__file__).parent _pkg = Path(mediahive.server.__file__).parent
_frontend_build = _pkg / "frontend-build" _frontend_build = _pkg / "frontend-build"
_icon = _pkg / "assets" / "mediahive.ico" _logo_webp = _pkg / "assets" / "mediahive.webp"
_ffmpeg = Path(SPECPATH).parent / "build" / "ffmpeg" / "ffmpeg.exe" _icon_win = _pkg / "assets" / "mediahive.ico"
_icon_mac = _pkg / "assets" / "mediahive.icns"
# ffmpeg staging lives in the persistent build cache (same logic as
# scripts/guibuild.py); fall back to the legacy build/ffmpeg location.
from platformdirs import user_cache_path
a = Analysis( _tools_dir = (
[mediahive.winmain.__file__], user_cache_path("mediahive-build", appauthor=False, opinion=False) / "ffmpeg"
pathex=[], )
binaries=[ if not _tools_dir.exists():
# Bundle ffmpeg so showreel generation works without a system install. _tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
# Populated by build_windows_gui.py before PyInstaller runs. _tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg"]
(str(_ffmpeg), "."),
], _binaries = []
datas=[ for _tool_name in _tool_names:
_tool_path = _tools_dir / _tool_name
if _tool_path.exists():
_binaries.append((str(_tool_path), "."))
_datas = [
# Bundled Vue frontend served by the FastAPI backend # Bundled Vue frontend served by the FastAPI backend
(str(_frontend_build), "mediahive/frontend-build"), (str(_frontend_build), "mediahive/frontend-build"),
(str(_icon), "mediahive/assets"), ]
], # tracerite (indirect dep) loads style.css / script.js at runtime; PyInstaller
hiddenimports=[ # has no hook for it, so collect its package data explicitly
_datas += collect_data_files("tracerite")
if _icon_win.exists():
_datas.append((str(_icon_win), "mediahive/assets"))
if _icon_mac.exists():
_datas.append((str(_icon_mac), "mediahive/assets"))
if _logo_webp.exists():
_datas.append((str(_logo_webp), "mediahive/assets"))
_hiddenimports = [
# uvicorn dynamic imports # uvicorn dynamic imports
"uvicorn.logging", "uvicorn.logging",
"uvicorn.loops", "uvicorn.loops",
@@ -60,7 +80,29 @@ a = Analysis(
"starlette.routing", "starlette.routing",
# msgspec TOML write backend # msgspec TOML write backend
"tomli_w", "tomli_w",
], ]
if sys.platform == "darwin":
_hiddenimports.extend(
[
# pywebview Qt backend selected dynamically via webview.start(gui="qt")
"webview.platforms.qt",
"qtpy",
"PyQt6",
"PyQt6.QtCore",
"PyQt6.QtGui",
"PyQt6.QtWidgets",
"PyQt6.QtWebEngineCore",
"PyQt6.QtWebEngineWidgets",
]
)
a = Analysis(
[mediahive.winmain.__file__],
pathex=[],
binaries=_binaries,
datas=_datas,
hiddenimports=_hiddenimports,
hookspath=[], hookspath=[],
runtime_hooks=[], runtime_hooks=[],
excludes=[], excludes=[],
@@ -80,12 +122,21 @@ exe = EXE(
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
upx=True, upx=True,
icon=str(_icon), icon=(
str(_icon_mac)
if sys.platform == "darwin" and _icon_mac.exists()
else str(_icon_win) if _icon_win.exists() else None
),
# windowed=True hides the console; the backend subprocess inherits this # windowed=True hides the console; the backend subprocess inherits this
console=False, console=False,
windowed=True, windowed=True,
) )
# UPX breaks .NET assemblies: packing Python.Runtime.dll strips/corrupts its
# CLR metadata and pythonnet then fails with "Failed to resolve
# Python.Runtime.Loader.Initialize from .../Python.Runtime.dll".
_upx_exclude = ["Python.Runtime.dll"] if sys.platform == "win32" else []
coll = COLLECT( coll = COLLECT(
exe, exe,
a.binaries, a.binaries,
@@ -93,6 +144,14 @@ coll = COLLECT(
a.datas, a.datas,
strip=False, strip=False,
upx=True, upx=True,
upx_exclude=[], upx_exclude=_upx_exclude,
name="MediaHive", name="MediaHive",
) )
if sys.platform == "darwin":
app = BUNDLE(
coll,
name="MediaHive.app",
icon=str(_icon_mac) if _icon_mac.exists() else None,
bundle_identifier="fi.zi.mediahive",
)
Regular → Executable
+25 -13
View File
@@ -5,13 +5,15 @@
import argparse import argparse
import asyncio import asyncio
import os import os
import subprocess
import sys import sys
from contextlib import suppress
from pathlib import Path from pathlib import Path
import tracerite
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path) # Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue"))) sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from devutil import ( # type: ignore from devutil import (
ProcessGroup, ProcessGroup,
check_ports_free, check_ports_free,
logger, logger,
@@ -22,11 +24,15 @@ from devutil import ( # type: ignore
DEFAULT_VITE_PORT = 8420 DEFAULT_VITE_PORT = 8420
DEFAULT_DEV_PORT = 8421 DEFAULT_DEV_PORT = 8421
HEALTH = "/api/health?from=devserver.py"
async def run_devserver( async def run_devserver(
listen: str, backend: str, extra_args: list[str] | None = None listen: str,
backend: str,
extra_args: list[str] | None = None,
) -> None: ) -> None:
"""Start Vite and FastAPI dev servers with hot reload."""
reporoot = Path(__file__).parent.parent reporoot = Path(__file__).parent.parent
front = reporoot / "frontend" front = reporoot / "frontend"
if not (front / "package.json").exists(): if not (front / "package.json").exists():
@@ -36,20 +42,22 @@ async def run_devserver(
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT) viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
backurl, mediahive = setup_cli("mediahive", backend, DEFAULT_DEV_PORT) backurl, mediahive = setup_cli("mediahive", backend, DEFAULT_DEV_PORT)
# Tell the everyone by environment (vite proxy and backend devmode use these) # Tell everyone via environment (vite proxy and backend devmode use these)
os.environ["MEDIAHIVE_VITE_URL"] = viteurl os.environ["MEDIAHIVE_VITE_URL"] = viteurl
os.environ["MEDIAHIVE_BACKEND_URL"] = backurl os.environ["MEDIAHIVE_BACKEND_URL"] = backurl
os.environ["MEDIAHIVE_DEV"] = "1" os.environ["MEDIAHIVE_DEV"] = "1"
async with ProcessGroup() as pg: async with ProcessGroup() as pg:
pg.create_task(check_ports_free(viteurl, backurl))
npm_i = await pg.spawn(*npm_install, cwd=front) npm_i = await pg.spawn(*npm_install, cwd=front)
await check_ports_free(viteurl, backurl) await pg.spawn(*mediahive, *(extra_args or []), vital=True)
await pg.spawn(*mediahive, *(extra_args or [])) await pg.wait(npm_i, ready(backurl, path=HEALTH))
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py")) await pg.spawn(*vite, cwd=front, vital=True)
await pg.spawn(*vite, cwd=front)
def main(): def main() -> None:
"""Parse CLI arguments and run the devserver."""
tracerite.load()
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Run Vite and FastAPI development servers", description="Run Vite and FastAPI development servers",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -58,21 +66,25 @@ def main():
parser.add_argument( parser.add_argument(
"-l", "-l",
"--listen", "--listen",
metavar="host:port", metavar="addr",
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})", help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
) )
parser.add_argument( parser.add_argument(
"--backend", "--backend",
metavar="host:port", metavar="addr",
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})", help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
) )
args, extra_args = parser.parse_known_args() args, extra_args = parser.parse_known_args()
with suppress(KeyboardInterrupt): try:
asyncio.run(run_devserver(args.listen, args.backend, extra_args)) asyncio.run(run_devserver(args.listen, args.backend, extra_args))
except* KeyboardInterrupt:
pass # user stopped the devserver: normal exit
except* subprocess.SubprocessError, RuntimeError:
raise SystemExit(1) from None # logged in devutil already; exit 1
HELP_EPILOG = """ HELP_EPILOG = """
scripts/devserver.py [args to mediahive] Other options are forwarded to mediahive [args]
JS_RUNTIME environment variable can be used to select the JS runtime: JS_RUNTIME environment variable can be used to select the JS runtime:
npm, deno, bun, or full path to the runtime executable (node maps to npm). npm, deno, bun, or full path to the runtime executable (node maps to npm).
@@ -3,13 +3,16 @@
import sys import sys
from pathlib import Path from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore from hatchling.builders.hooks.plugin.interface import BuildHookInterface
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from buildutil import build from buildutil import build
class CustomBuildHook(BuildHookInterface): class CustomBuildHook(BuildHookInterface): # type: ignore[misc]
def initialize(self, version, build_data): """Hatch build hook that builds Vue frontend during package build."""
def initialize(self, version: str, build_data: dict) -> None: # type: ignore[override]
"""Build frontend before package is built."""
super().initialize(version, build_data) super().initialize(version, build_data)
build("frontend") build("frontend")
+106 -60
View File
@@ -7,21 +7,30 @@ import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
MIN_NODE_VERSION = 20
class _PrefixFormatter(logging.Formatter):
"""Formatter that adds prefix based on log level.""" class _Formatter(logging.Formatter):
"""Prefix formatter, intentionally different from fastapi_vue.logging.
INFO and below pass through unprefixed so messages can use their own
markings (>>>, ###); WARNING and above get an emoji prefix.
"""
def format(self, record: logging.LogRecord) -> str: def format(self, record: logging.LogRecord) -> str:
if record.levelno >= logging.ERROR:
return f"🛑 {record.getMessage()}"
if record.levelno >= logging.WARNING: if record.levelno >= logging.WARNING:
return f"⚠️ {record.getMessage()}" return f"💣 {record.getMessage()}"
return record.getMessage() return record.getMessage()
_handler = logging.StreamHandler() _handler = logging.StreamHandler()
_handler.setFormatter(_PrefixFormatter()) _handler.setFormatter(_Formatter())
logger = logging.getLogger("fastapi-vue") logger = logging.getLogger("fastapi-vue")
logger.addHandler(_handler) logger.addHandler(_handler)
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
logger.propagate = False # own handler; do not double-print via a configured root
def _check_node_version(node_path: str) -> None: def _check_node_version(node_path: str) -> None:
@@ -31,81 +40,118 @@ def _check_node_version(node_path: str) -> None:
""" """
try: try:
result = subprocess.run( result = subprocess.run(
[node_path, "--version"], capture_output=True, text=True, check=True [node_path, "--version"],
capture_output=True,
text=True,
check=True,
) )
version_str = result.stdout.strip() version_str = result.stdout.strip()
# Parse version like "v20.10.0" or "v18.17.1" # Parse version like "v20.10.0" or "v18.17.1"
match = re.match(r"v(\d+)", version_str) match = re.match(r"v(\d+)", version_str)
if match: if match:
major_version = int(match.group(1)) major_version = int(match.group(1))
if major_version >= 20: if major_version >= MIN_NODE_VERSION:
return return
raise RuntimeError( msg = f"Node.js {version_str} found, but v{MIN_NODE_VERSION}+ required"
f"Node.js {version_str} found, but v20+ required (install with nvm)" raise RuntimeError(msg)
)
except subprocess.CalledProcessError, FileNotFoundError, ValueError: except subprocess.CalledProcessError, FileNotFoundError, ValueError:
pass pass
raise RuntimeError("Could not determine Node.js version") msg = "Could not determine Node.js version"
raise RuntimeError(msg)
def _validate_npm_runtime(tool: str) -> bool:
"""Validate npm runtime by checking Node.js version. Returns True if valid."""
node_path = shutil.which("node", path=str(Path(tool).parent))
if node_path is None:
return False
try:
_check_node_version(node_path)
except RuntimeError:
return False
return True
def _find_runtime_from_env(options: list[str]) -> tuple[str, str] | None:
"""Find runtime specified by JS_RUNTIME environment variable."""
js_runtime_env = os.environ.get("JS_RUNTIME")
if not js_runtime_env:
return None
js_runtime = js_runtime_env
js_path = Path(js_runtime)
runtime_name = js_path.name
# Map node to npm
if runtime_name == "node":
runtime_name = "npm"
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
for option in options:
if option != runtime_name and not runtime_name.startswith(option):
continue
tool = shutil.which(js_runtime)
if tool is None:
msg = f"JS_RUNTIME={js_runtime_env}: {option} not found"
raise RuntimeError(msg)
if option == "npm":
node_path = shutil.which("node", path=str(Path(tool).parent))
if node_path is None:
msg = f"JS_RUNTIME={js_runtime_env}: node not found"
raise RuntimeError(msg)
_check_node_version(node_path)
return tool, option
msg = f"JS_RUNTIME={js_runtime_env} not recognized"
raise RuntimeError(msg)
def _auto_detect_runtime(options: list[str]) -> tuple[str, str]:
"""Auto-detect JavaScript runtime from available options."""
node_version_error: RuntimeError | None = None
for option in options:
tool = shutil.which(option)
if not tool:
continue
if option == "npm" and not _validate_npm_runtime(tool):
try:
node_path = shutil.which("node", path=str(Path(tool).parent))
if node_path:
_check_node_version(node_path)
except RuntimeError as e:
node_version_error = e
continue
return tool, option
if node_version_error:
raise node_version_error
msg = "Node.js (v20+), Deno or Bun is required but none was found"
raise RuntimeError(msg)
def find_js_runtime() -> tuple[str, str]: def find_js_runtime() -> tuple[str, str]:
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect. """Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun". Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
Raises JSRuntimeError if no suitable runtime is found. Raises RuntimeError if no suitable runtime is found.
""" """
options = ["npm", "deno", "bun"] options = ["npm", "deno", "bun"]
node_version_error: RuntimeError | None = None
# Check for JS_RUNTIME environment variable # Check for JS_RUNTIME environment variable
if js_runtime_env := os.environ.get("JS_RUNTIME"): if result := _find_runtime_from_env(options):
js_runtime = js_runtime_env return result
js_path = Path(js_runtime)
runtime_name = js_path.name
# Map node to npm
if runtime_name == "node":
runtime_name = "npm"
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
for option in options:
if option == runtime_name or runtime_name.startswith(option):
tool = shutil.which(js_runtime)
if tool is None:
raise RuntimeError(
f"JS_RUNTIME={js_runtime_env}: {option} not found"
)
# Check Node.js version if using npm
if option == "npm":
node_path = shutil.which("node", path=str(Path(tool).parent))
if node_path is None:
raise RuntimeError(
f"JS_RUNTIME={js_runtime_env}: node not found"
)
_check_node_version(node_path) # Raises on failure
return tool, option
raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized")
# Auto-detect # Auto-detect
for option in options: return _auto_detect_runtime(options)
if tool := shutil.which(option):
# Check Node.js version if using npm
if option == "npm":
node_path = shutil.which("node", path=str(Path(tool).parent))
if node_path is None:
continue
try:
_check_node_version(node_path)
except RuntimeError as e:
node_version_error = e
continue # Try next runtime
return tool, option
# No runtime found - provide helpful error
if node_version_error:
raise node_version_error
raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found")
def find_build_tool(): def find_build_tool() -> tuple[list[str], list[str]]:
"""Find JavaScript runtime and construct install/build commands. """Find JavaScript runtime and construct install/build commands.
Returns (install_cmd, build_cmd) tuples of command lists. Returns (install_cmd, build_cmd) tuples of command lists.
@@ -143,7 +189,7 @@ def find_dev_tool() -> list[str]:
if name == "bun": if name == "bun":
logger.warning( logger.warning(
"Bun has a bug in WS proxying (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]] return [tool, *dev_args[name]]
@@ -176,9 +222,9 @@ def build(folder: str = "frontend") -> None:
install_cmd, build_cmd = find_build_tool() install_cmd, build_cmd = find_build_tool()
except RuntimeError as e: except RuntimeError as e:
logger.warning(e) logger.warning(e)
raise SystemExit(1) raise SystemExit(1) from None
def run(cmd): def run(cmd: list[str]) -> None:
display_cmd = [Path(cmd[0]).stem, *cmd[1:]] display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
logger.info("### %s", " ".join(display_cmd)) logger.info("### %s", " ".join(display_cmd))
subprocess.run(cmd, check=True, cwd=folder) subprocess.run(cmd, check=True, cwd=folder)
@@ -188,4 +234,4 @@ def build(folder: str = "frontend") -> None:
logger.info("") logger.info("")
run(build_cmd) run(build_cmd)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
raise SystemExit(1) raise SystemExit(1) from None
+125 -105
View File
@@ -1,140 +1,154 @@
"""Utilities meant for devserver script, used only in source repository with dev deps.""" """Utilities meant for devserver script, used only in source repository with dev deps."""
from __future__ import annotations
import asyncio import asyncio
import subprocess
import sys import sys
from collections.abc import Coroutine from asyncio.subprocess import Process
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import Any from subprocess import CalledProcessError
from typing import TYPE_CHECKING, Any
import httpx from urllib.parse import urlsplit
from fastapi_vue.hostutil import parse_endpoint
from buildutil import find_dev_tool, find_install_tool, logger from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint
if TYPE_CHECKING:
from collections.abc import Awaitable
class ProcessGroup: class ProcessGroup(asyncio.TaskGroup):
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes.""" """TaskGroup with structured ownership of async subprocesses."""
def __init__(self): def __init__(self, *, terminate_timeout: float = 10) -> None:
self._procs: list[asyncio.subprocess.Process] = [] """Set the grace period before terminate() escalates to kill()."""
self._cmds: dict[int, str] = {} # pid -> command name super().__init__()
self._terminate_timeout = terminate_timeout
self._cmds: dict[Process, tuple[str, ...]] = {}
async def spawn( async def spawn(
self, *cmd: str, cwd: str | None = None self, *cmd: str, cwd: str | None = None, vital: bool = False
) -> asyncio.subprocess.Process: ) -> Process:
"""Spawn a subprocess and track it.""" """Spawn and own a subprocess. If a vital process exits, the group cancels."""
cmd_name = Path(cmd[0]).stem
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._procs.append(proc)
self._cmds[proc.pid] = cmd_name
return proc
async def wait( async def run() -> None:
self, *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]" name = Path(cmd[0]).stem
) -> None: logger.info(">>> %s", " ".join([name, *cmd[1:]]))
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
returncode = await proc.wait()
if returncode != 0:
cmd_name = self._cmds.get(proc.pid, "unknown")
raise subprocess.CalledProcessError(returncode, cmd_name)
tasks = [
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
for w in waitables
]
try: try:
await asyncio.gather(*tasks) proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
except subprocess.CalledProcessError as e: self._cmds[proc] = cmd
logger.warning("%s failed with exit status %d", e.cmd, e.returncode) started.set_result(proc)
raise SystemExit(1) from None except Exception as e: # ruff: ignore[blind-except]
started.set_exception(e)
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, *_):
"""Wait for one process to exit, terminate others, then wait for all."""
await self._cleanup(immediate=exc_type is not None)
async def _cleanup(self, immediate: bool = False):
running = [p for p in self._procs if p.returncode is None]
if not running:
return return
if not immediate:
# Wait for any one process to exit
with suppress(asyncio.CancelledError):
await asyncio.wait(
[asyncio.create_task(p.wait()) for p in running],
return_when=asyncio.FIRST_COMPLETED,
)
# Terminate remaining processes
for p in self._procs:
if p.returncode is None:
with suppress(ProcessLookupError):
p.terminate()
# Wait for all to finish (with overall timeout), shielded from cancellation
still_running = [p for p in self._procs if p.returncode is None]
if still_running:
with suppress(asyncio.CancelledError):
try: try:
await asyncio.shield( returncode = await proc.wait()
asyncio.wait_for( finally:
asyncio.gather(*[p.wait() for p in still_running]),
timeout=10,
)
)
except TimeoutError:
for p in self._procs:
if p.returncode is None:
with suppress(ProcessLookupError): with suppress(ProcessLookupError):
p.kill() proc.terminate()
await p.wait() try:
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
except TimeoutError:
with suppress(ProcessLookupError):
proc.kill()
await proc.wait()
if vital:
logger.warning("Vital process %s exited", name)
raise CalledProcessError(returncode, cmd)
started = asyncio.get_running_loop().create_future()
self.create_task(run())
return await asyncio.shield(started)
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
"""Wait concurrently and return results in argument order."""
async def task(w: Process | Awaitable) -> Any:
if not isinstance(w, Process):
return await w
if retcode := await w.wait():
cmd = self._cmds[w]
logger.warning(
"Process %s exited with status %d", Path(cmd[0]).stem, retcode
)
raise CalledProcessError(retcode, cmd)
return retcode
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(task(w)) for w in waitables]
return tuple(task.result() for task in tasks)
async def http_get_server(url: str, timeout: float) -> str | None: # ruff: ignore[async-function-with-timeout]
"""GET url with plain asyncio streams, return the response Server header.
Returns an empty string when the server responds without a Server header,
and None when the server is unreachable or doesn't answer in time.
"""
parts = urlsplit(url)
host = parts.hostname or "localhost"
port = parts.port or (443 if parts.scheme == "https" else 80)
path = parts.path or "/"
if parts.query:
path += f"?{parts.query}"
try:
async with asyncio.timeout(timeout):
reader, writer = await asyncio.open_connection(host, port)
try:
writer.write(f"GET {path} HTTP/1.0\r\nHost: {host}\r\n\r\n".encode())
await writer.drain()
data = await reader.readuntil(b"\r\n\r\n")
finally:
writer.close()
except OSError, EOFError, ValueError, TimeoutError:
return None
for line in data.decode(errors="replace").split("\r\n"):
if line.lower().startswith("server:"):
return line[7:].strip()
return ""
async def check_ports_free(*urls: str) -> None: async def check_ports_free(*urls: str) -> None:
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond.""" """Verify URLs are not responding (ports are free).
async def check(client: httpx.AsyncClient, url: str) -> None: Meant to run as a task inside a TaskGroup. Logs the conflict and raises
with suppress(httpx.RequestError): RuntimeError (handled like a failed process) if any URL responds.
res = await client.get(url, timeout=0.1) """
server = res.headers.get("server", "server") servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
logger.warning("Conflicting %s already running at %s", server, url) for url, server in zip(urls, servers, strict=True):
raise SystemExit(1) if server is not None:
logger.error(
async with httpx.AsyncClient() as client: "Conflicting %s already running at %s", server or "server", url
await asyncio.gather(*[check(client, url) for url in urls]) )
raise RuntimeError(url)
async def ready(url: str, path: str = "") -> None: async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
"""Wait for the server to be ready by polling an endpoint. """Wait for the server to be ready by polling an endpoint.
Raises SystemExit(1) if server doesn't start in time. Use empty path to disable the check and make this return immediately.
Logs, then raises RuntimeError if the server doesn't start in time.
""" """
max_attempts = 50 if not path:
full_url = f"{url}{path}" return
async with httpx.AsyncClient() as client:
for attempt in range(max_attempts): for attempt in range(max_attempts):
try: if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
await client.get(full_url, timeout=1.0) logger.info("🟢 Backend ready!")
logger.info("✓ Backend ready!")
return return
except httpx.RequestError:
if attempt == max_attempts - 1: if attempt == max_attempts - 1:
logger.warning("Backend didn't start in time") logger.error("Backend at %s didn't start in time", url)
raise SystemExit(1) raise RuntimeError(url)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
def setup_vite( def setup_vite(
endpoint: str, default_port: int = 5173 endpoint: str,
default_port: int = 5173,
) -> tuple[str, list[str], list[str]]: ) -> tuple[str, list[str], list[str]]:
"""Parse frontend endpoint and build commands. """Parse frontend endpoint and build commands.
@@ -160,7 +174,9 @@ def setup_vite(
def setup_fastapi( def setup_fastapi(
endpoint: str, module: str, default_port: int = 8000 endpoint: str,
module: str,
default_port: int = 8000,
) -> tuple[str, list[str]]: ) -> tuple[str, list[str]]:
"""Parse backend endpoint and build uvicorn command. """Parse backend endpoint and build uvicorn command.
@@ -175,7 +191,7 @@ def setup_fastapi(
host = endpoints[0]["host"] host = endpoints[0]["host"]
port = endpoints[0]["port"] port = endpoints[0]["port"]
reload_dir = module.split(".")[0] # Don't reload on frontend changes reload_dir = module.split(".", maxsplit=1)[0] # Don't reload on frontend changes
cmd = [ cmd = [
sys.executable, sys.executable,
@@ -192,7 +208,9 @@ def setup_fastapi(
def setup_cli( def setup_cli(
cli: str, endpoint: str, default_port: int = 8000 cli: str,
endpoint: str,
default_port: int = 8000,
) -> tuple[str, list[str]]: ) -> tuple[str, list[str]]:
"""Parse backend endpoint and build CLI command. """Parse backend endpoint and build CLI command.
@@ -208,5 +226,7 @@ def setup_cli(
host = endpoints[0]["host"] host = endpoints[0]["host"]
port = endpoints[0]["port"] port = endpoints[0]["port"]
cmd = [cli, f"--listen={host}:{port}"] # Run the package as a module with the current interpreter, instead of
# relying on a PATH-installed CLI entry point.
cmd = [sys.executable, "-m", cli, f"--listen={host}:{port}"]
return f"http://{host}:{port}", cmd return f"http://{host}:{port}", cmd
+600
View File
@@ -0,0 +1,600 @@
#!/usr/bin/env -S uv run
"""Build the desktop GUI application and package it with Velopack.
Usage:
uv run scripts/guibuild.py
This runs in the project environment where dependencies
are available via pyproject.toml.
This script:
1. Reads the version from pyproject.toml
2. Runs `uv build` to produce the wheel/sdist
3. On Windows/macOS, downloads the ffmpeg binary for bundling
4. Builds MediaHive using PyInstaller
5. Packages with Velopack: Setup.exe (Windows), .pkg (macOS),
.AppImage (Linux), plus the update feed in build/velopack/
that release.py uploads for in-app auto-updates
6. On Windows, also creates a portable ZIP (no auto-updates)
"""
import io
import os
import platform
import re
import shutil
import stat
import subprocess
import sys
import urllib.request
import zipfile
from pathlib import Path
from typing import NamedTuple
import setuptools_scm
from platformdirs import user_cache_path
# BtbN automated builds always publish a 'latest' tag with this asset.
_FFMPEG_URL = (
"https://github.com/BtbN/ffmpeg-builds/releases/download/latest"
"/ffmpeg-master-latest-win64-gpl.zip"
)
_MACOS_ARM64_TOOL_URLS = {
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
}
_REPO_ROOT = Path(__file__).parent.parent
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
def _build_cache_dir() -> Path:
"""Return the persistent cross-build cache dir for downloaded tools (CI wipes build/)."""
return user_cache_path("mediahive-build", appauthor=False, opinion=False)
_FFMPEG_STAGING = _build_cache_dir() / "ffmpeg"
# Velopack CLI (dotnet tool package). Runs on the machine's .NET runtime; the
# produced Setup.exe/Update.exe are native and need no runtime on end-user
# machines. Pin a version whose tools target an installed .NET major.
_VPK_VERSION = "1.2.158"
_VPK_URL = (
f"https://api.nuget.org/v3-flatcontainer/vpk/{_VPK_VERSION}"
f"/vpk.{_VPK_VERSION}.nupkg"
)
_VPK_STAGING = _build_cache_dir() / f"vpk-{_VPK_VERSION}"
class _Platform(NamedTuple):
"""Per-platform naming/packaging constants.
tag is the release artifact suffix. Only Windows keeps an arch marker
(win64); macOS builds are arm64-only and we ship one Linux flavor.
"""
tag: str # win64 / macos / linux
channel: str # Velopack update channel: win / osx / linux
rid: str # Velopack runtime id
dist_dir: str # PyInstaller output dir under build/
icon: str # file in mediahive/assets
main_exe: str
setup_ext: str
def _platform() -> _Platform:
if sys.platform == "win32":
return _Platform(
"win64",
"win",
"win-x64",
"MediaHive",
"mediahive.ico",
"MediaHive.exe",
".exe",
)
if sys.platform == "darwin":
return _Platform(
"macos",
"osx",
"osx-arm64",
"MediaHive.app",
"mediahive.icns",
"MediaHive",
".pkg",
)
return _Platform(
"linux",
"linux",
"linux-x64",
"MediaHive",
"mediahive.png",
"MediaHive",
".AppImage",
)
def setup_artifact_name() -> str:
"""Versionless name so releases/download/latest/<name> links stay valid."""
p = _platform()
# Windows keeps the -setup suffix: a bare .exe isn't self-explanatory.
suffix = "-setup" if sys.platform == "win32" else ""
return f"MediaHive-{p.tag}{suffix}{p.setup_ext}"
def fetch_ffmpeg() -> Path:
"""Download latest ffmpeg.exe from BtbN builds into the persistent build cache."""
dest = _FFMPEG_STAGING / "ffmpeg.exe"
if dest.exists():
print(f"ffmpeg already staged at {dest}, skipping download.")
return dest
_FFMPEG_STAGING.mkdir(parents=True, exist_ok=True)
print(f"Downloading ffmpeg from {_FFMPEG_URL} ...")
with urllib.request.urlopen(_FFMPEG_URL) as resp:
data = resp.read()
print("Extracting ffmpeg.exe ...")
with zipfile.ZipFile(io.BytesIO(data)) as zf:
# The zip contains a top-level folder; ffmpeg.exe is under .../bin/
ffmpeg_entry = next(
name for name in zf.namelist() if name.endswith("/bin/ffmpeg.exe")
)
with zf.open(ffmpeg_entry) as src:
Path(dest).write_bytes(src.read())
print(f"ffmpeg staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)")
return dest
def fetch_macos_arm64_binaries() -> dict[str, Path]:
"""Download prebuilt macOS arm64 ffmpeg binary into the persistent build cache."""
if sys.platform != "darwin" or platform.machine().lower() not in {
"arm64",
"aarch64",
}:
raise RuntimeError("macOS bundling is only supported for arm64 builds")
_FFMPEG_STAGING.mkdir(parents=True, exist_ok=True)
staged: dict[str, Path] = {}
for tool_name, url in _MACOS_ARM64_TOOL_URLS.items():
dest = _FFMPEG_STAGING / tool_name
if dest.exists():
print(f"{tool_name} already staged at {dest}, skipping download.")
staged[tool_name] = dest
continue
print(f"Downloading {tool_name} from {url} ...")
with urllib.request.urlopen(url) as resp:
data = resp.read()
with zipfile.ZipFile(io.BytesIO(data)) as zf:
entry_name = next(
name
for name in zf.namelist()
if Path(name).name == tool_name and not name.endswith("/")
)
with zf.open(entry_name) as src:
Path(dest).write_bytes(src.read())
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
# Make the bundled binary runnable when copied out of the zip/app on macOS.
subprocess.run(["xattr", "-cr", str(dest)], check=False)
subprocess.run(["codesign", "-f", "-s", "-", str(dest)], check=True)
staged[tool_name] = dest
print(
f"{tool_name} staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)"
)
return staged
def ensure_macos_icon() -> Path:
"""Create mediahive.icns from mediahive.ico when building on macOS."""
icon_icns = _ASSETS_DIR / "mediahive.icns"
if icon_icns.exists():
return icon_icns
icon_ico = _ASSETS_DIR / "mediahive.ico"
if not icon_ico.exists():
raise FileNotFoundError(f"Missing source icon: {icon_ico}")
iconset_dir = _REPO_ROOT / "build" / "mediahive.iconset"
iconset_dir.mkdir(parents=True, exist_ok=True)
base_png = _REPO_ROOT / "build" / "mediahive-icon-1024.png"
subprocess.run(
["sips", "-s", "format", "png", str(icon_ico), "--out", str(base_png)],
check=True,
)
size_entries = [
(16, "icon_16x16.png"),
(32, "icon_16x16@2x.png"),
(32, "icon_32x32.png"),
(64, "icon_32x32@2x.png"),
(128, "icon_128x128.png"),
(256, "icon_128x128@2x.png"),
(256, "icon_256x256.png"),
(512, "icon_256x256@2x.png"),
(512, "icon_512x512.png"),
(1024, "icon_512x512@2x.png"),
]
for pixels, name in size_entries:
subprocess.run(
[
"sips",
"-z",
str(pixels),
str(pixels),
str(base_png),
"--out",
str(iconset_dir / name),
],
check=True,
)
subprocess.run(
["iconutil", "-c", "icns", str(iconset_dir), "-o", str(icon_icns)],
check=True,
)
print(f"macOS app icon generated: {icon_icns}")
return icon_icns
_VPK_TFM = "net10.0"
_VPK_REQUIRED_DOTNET_MAJOR = int(re.fullmatch(r"net(\d+)\.0", _VPK_TFM).group(1))
def fetch_vpk() -> Path:
"""Download the Velopack CLI package into the persistent build cache.
Returns the path to vpk.dll, runnable with `dotnet vpk.dll ...`.
"""
vpk_dll = _VPK_STAGING / "tools" / _VPK_TFM / "any" / "vpk.dll"
if vpk_dll.exists():
print(f"vpk already staged at {_VPK_STAGING}, skipping download.")
return vpk_dll
_VPK_STAGING.mkdir(parents=True, exist_ok=True)
print(f"Downloading vpk from {_VPK_URL} ...")
with urllib.request.urlopen(_VPK_URL) as resp:
data = resp.read()
with zipfile.ZipFile(io.BytesIO(data)) as zf:
zf.extractall(_VPK_STAGING)
if not vpk_dll.exists():
raise RuntimeError(f"vpk.dll not found in package at {vpk_dll}")
print(f"vpk staged at {_VPK_STAGING}")
return vpk_dll
def _dotnet_runtime_major(exe: Path) -> int | None:
"""Return the highest installed Microsoft.NETCore.App major version, or None."""
try:
result = subprocess.run(
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
)
except OSError, subprocess.TimeoutExpired:
return None
if result.returncode != 0:
return None
majors = []
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[0] == "Microsoft.NETCore.App":
try:
majors.append(int(parts[1].split(".")[0]))
except ValueError:
continue
return max(majors, default=None)
def fetch_dotnet() -> str:
"""Resolve a system dotnet host able to run vpk (needs .NET >= 10).
The .NET SDK is a build prerequisite installed on the build machine —
downloading a runtime per build is slow and flaky. Several dotnet
installations may coexist (PATH may resolve to a runtime-only .NET 8
while scoop holds the SDK 10), so probe known locations and pick the
newest runtime rather than the first that runs.
"""
exe_name = "dotnet.exe" if sys.platform == "win32" else "dotnet"
candidates: list[Path] = []
root = os.environ.get("DOTNET_ROOT")
if root:
candidates.append(Path(root) / exe_name)
which = shutil.which("dotnet")
if which:
candidates.append(Path(which))
if sys.platform == "win32":
candidates += [
Path(r"C:\ProgramData\scoop\apps\dotnet-sdk\current") / exe_name,
Path(r"C:\Program Files\dotnet") / exe_name,
]
elif sys.platform == "darwin":
candidates += [
Path("/opt/homebrew/bin") / exe_name,
Path("/usr/local/share/dotnet") / exe_name,
]
else:
candidates += [
Path("/usr/share/dotnet") / exe_name,
Path("/usr/lib/dotnet") / exe_name,
Path.home() / ".dotnet" / exe_name,
]
best: tuple[int, Path] | None = None
for exe in candidates:
if not exe.exists():
continue
major = _dotnet_runtime_major(exe)
if major is not None and (best is None or major > best[0]):
best = (major, exe)
if best is not None and best[0] >= _VPK_REQUIRED_DOTNET_MAJOR:
print(f"Using dotnet at {best[1]} (.NET {best[0]})")
return str(best[1])
found = f"newest found is .NET {best[0]} at {best[1]}" if best else "none found"
raise RuntimeError(
f"vpk requires Microsoft.NETCore.App >= {_VPK_REQUIRED_DOTNET_MAJOR} ({found}). "
"Install the current .NET SDK on this build machine "
"(Windows: `scoop install dotnet-sdk`; macOS: `brew install dotnet-sdk`; "
"Linux: distro `dotnet-sdk` package or the dotnet-install script)."
)
def build_velopack(version: str) -> Path:
"""Build the Velopack installer/bundle for this platform.
Windows: per-user Setup.exe. macOS: .pkg installer. Linux: .AppImage.
Also produces the update feed (releases.<channel>.json, *.nupkg) in
build/velopack/ for release.py to upload — in-app auto-updates read it
from the Gitea release. Velopack installs carry no Mark-of-the-Web, so
the .NET CLR loads pythonnet/pywebview assemblies that it refuses from
a downloaded ZIP.
"""
plat = _platform()
dist_folder = _REPO_ROOT / "build" / plat.dist_dir
if not dist_folder.exists():
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
vpk_dll = fetch_vpk()
releases_dir = _REPO_ROOT / "build" / "velopack"
cmd = [
fetch_dotnet(),
str(vpk_dll),
"pack",
"--packId",
"MediaHive",
"--packVersion",
version,
"--packDir",
str(dist_folder),
"--mainExe",
plat.main_exe,
"--packAuthors",
"MediaHive",
"--packTitle",
"MediaHive",
"--icon",
str(_ASSETS_DIR / plat.icon),
"--runtime",
plat.rid,
"--outputDir",
str(releases_dir),
]
if sys.platform == "darwin":
cmd += ["--instWelcome", str(_ASSETS_DIR / "macos-installer-welcome.txt")]
print(f"Running: {' '.join(cmd)}")
try:
result = subprocess.run(cmd, cwd=_REPO_ROOT, capture_output=True, text=True)
except OSError as exc:
raise RuntimeError(f"vpk failed to start: {exc}") from exc
if result.returncode != 0:
raise RuntimeError(
f"vpk pack failed with exit code {result.returncode}\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
setup = next(iter(sorted(releases_dir.glob(f"*Setup*{plat.setup_ext}"))), None)
if setup is None:
setup = next(iter(sorted(releases_dir.glob(f"*{plat.setup_ext}"))), None)
if setup is None:
raise RuntimeError(f"vpk produced no *{plat.setup_ext} in {releases_dir}")
if sys.platform == "darwin":
force_macos_user_install(setup)
artifact = _REPO_ROOT / "build" / setup_artifact_name()
artifact.unlink(missing_ok=True)
setup.rename(artifact)
rename_feed_package(releases_dir, version, plat.channel)
return artifact
def rename_feed_package(releases_dir: Path, version: str, channel: str) -> None:
"""Rename this platform's update-feed nupkg in place.
vpk hardcodes MediaHive-{ver}[-{channel}]-full.nupkg (Windows, the legacy
default channel, gets no marker). Rename all to the uniform
mediahive-{ver}-{channel}-full.nupkg: lowercase groups them with the
wheel/sdist below the capitalized user downloads on the release page,
and every platform carries its channel. releases.<channel>.json
references the filename, so patch it too.
"""
old_name = f"MediaHive-{version}-full.nupkg"
if not (releases_dir / old_name).exists():
old_name = f"MediaHive-{version}-{channel}-full.nupkg"
nupkg = releases_dir / old_name
if not nupkg.exists():
raise RuntimeError(f"vpk produced no {old_name} in {releases_dir}")
new_name = f"mediahive-{version}-{channel}-full.nupkg"
manifest = releases_dir / f"releases.{channel}.json"
text = manifest.read_text()
if old_name not in text:
raise RuntimeError(f"{manifest.name} does not reference {old_name}")
manifest.write_text(text.replace(old_name, new_name))
nupkg.rename(nupkg.with_name(new_name))
def force_macos_user_install(pkg: Path) -> None:
"""Restrict the Velopack-generated pkg to per-user installs (~/Applications).
Velopack hardcodes two install domains (currentUserHome + localSystem) in
the distribution XML. System installs land in /Applications, which the
user may not own — Velopack's UpdateMac then cannot replace the .app on
auto-update. With a single domain, macOS Installer skips the Destination
Select page and installs to ~/Applications without admin rights.
Also drops the `sudo -u "$USER"` prefix from Velopack's postinstall
script: under a per-user install the script already runs as the
installing user, and sudo would fail for lack of a tty.
NB: only ever use `pkgutil --expand` (which keeps component Payloads
archived) — `--expand-full` flattens payloads to loose files that
`--flatten` cannot repack, producing a pkg that "installs" nothing.
"""
expanded = pkg.with_name(pkg.stem + "-expanded")
shutil.rmtree(expanded, ignore_errors=True)
subprocess.run(["pkgutil", "--expand", str(pkg), str(expanded)], check=True)
dist_xml = expanded / "Distribution"
xml = dist_xml.read_text()
new_xml, count = re.subn(
r"<domains [^>]*/>",
'<domains enable_anywhere="false" enable_currentUserHome="true" enable_localSystem="false" />',
xml,
)
if count != 1:
raise RuntimeError("Unexpected distribution.xml: <domains> not found")
dist_xml.write_text(new_xml)
# Edit postinstall inside the component pkg. Depending on the macOS
# version, --expand leaves the component as an archived file (needs a
# nested expand/flatten round) or as an already-expanded directory.
components = list(expanded.glob("*.pkg"))
if len(components) != 1:
contents = sorted(p.name for p in expanded.iterdir())
raise RuntimeError(
f"Unexpected pkg layout: components={components} in {contents}"
)
component = components[0]
if component.is_dir():
comp_dir = component
else:
comp_dir = expanded / (component.stem + "-component")
subprocess.run(
["pkgutil", "--expand", str(component), str(comp_dir)], check=True
)
postinstall = comp_dir / "Scripts" / "postinstall"
script = postinstall.read_text()
if 'sudo -u "$USER" ' not in script:
raise RuntimeError("Unexpected postinstall script: sudo prefix not found")
postinstall.write_text(script.replace('sudo -u "$USER" ', ""))
if comp_dir is not component:
subprocess.run(
["pkgutil", "--flatten", str(comp_dir), str(component)], check=True
)
shutil.rmtree(comp_dir)
subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True)
shutil.rmtree(expanded)
def read_version() -> str:
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
return setuptools_scm.get_version(root=str(_REPO_ROOT))
def build_wheel() -> None:
"""Run uv build to produce the wheel and sdist."""
repo_root = _REPO_ROOT
cmd = ["uv", "build"]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=repo_root)
if result.returncode != 0:
raise RuntimeError(f"uv build failed with exit code {result.returncode}")
def build_executable() -> None:
"""Run PyInstaller to build the desktop GUI app."""
repo_root = _REPO_ROOT
spec_file = Path(__file__).parent / "MediaHive.spec"
cmd = [
sys.executable,
"-m",
"PyInstaller",
"--noconfirm",
"--clean",
"--distpath",
str(repo_root / "build"),
"--workpath",
str(repo_root / "build" / ".pyinstaller-work"),
str(spec_file),
]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=repo_root)
if result.returncode != 0:
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
def create_portable_zip() -> Path:
"""Create the Windows portable ZIP of the build/MediaHive folder.
Velopack-less plain-folder distribution for users who cannot or do not
want to run Setup.exe. No auto-updates; the app strips Mark-of-the-Web
from bundled DLLs at first run instead.
"""
dist_folder = _REPO_ROOT / "build" / "MediaHive"
if not dist_folder.exists():
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
zip_path = _REPO_ROOT / "build" / "MediaHive-win64-portable.zip"
print(f"Creating {zip_path}...")
shutil.make_archive(
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
"zip",
root_dir=str(dist_folder), # zip contents of MediaHive/, not the folder itself
)
return zip_path
def main() -> None:
# Windows consoles default to cp1252, which can't encode ✓/✗
sys.stdout.reconfigure(errors="replace")
sys.stderr.reconfigure(errors="replace")
try:
version = read_version()
print(f"MediaHive version: {version}")
if sys.platform == "win32":
fetch_ffmpeg()
elif sys.platform == "darwin":
fetch_macos_arm64_binaries()
ensure_macos_icon()
else:
print(
"Skipping ffmpeg bundling on this platform "
"(uses system ffmpeg if available)."
)
build_wheel()
build_executable()
artifacts = [build_velopack(version)]
if sys.platform == "win32":
artifacts.append(create_portable_zip())
for artifact_path in artifacts:
print(f"✓ Built successfully: {artifact_path}")
print(f" Size: {artifact_path.stat().st_size / (1024 * 1024):.1f} MB")
except (FileNotFoundError, OSError, RuntimeError, ValueError) as e:
print(f"✗ Build failed: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Regular → Executable
+137 -48
View File
@@ -1,3 +1,4 @@
#!/usr/bin/env -S uv run
"""Publish a MediaHive release to Gitea. """Publish a MediaHive release to Gitea.
Usage: Usage:
@@ -8,10 +9,15 @@ Reads from [project.urls] Repository in pyproject.toml.
Token: GITEA_TOKEN environment variable Token: GITEA_TOKEN environment variable
Steps: Steps:
1. Find clean-versioned ZIPs in build/ and matching dist/ wheels/sdists 1. Read the clean tag version via setuptools_scm, find platform artifacts
2. Abort if any dist files are missing for a found ZIP version in build/ and matching dist/ wheels/sdists
3. Create a Gitea release for each version and upload all assets 2. Abort if any dist files are missing
3. Create a Gitea release for each version (or reuse the existing one
for the tag, skipping already-uploaded assets) and upload all assets
4. Remind the user to run: uv publish 4. Remind the user to run: uv publish
Parallel CI platform builds converge on one release per tag; pass --no-dist
on all but one platform so only it uploads the wheel/sdist.
""" """
import argparse import argparse
@@ -23,6 +29,7 @@ from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
import setuptools_scm
REPO_ROOT = Path(__file__).parent.parent REPO_ROOT = Path(__file__).parent.parent
@@ -31,9 +38,10 @@ REPO_ROOT = Path(__file__).parent.parent
# Config / token helpers # Config / token helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def load_gitea_config() -> dict: def load_gitea_config() -> dict:
pyproject = REPO_ROOT / "pyproject.toml" pyproject = REPO_ROOT / "pyproject.toml"
with open(pyproject, "rb") as f: with Path(pyproject).open("rb") as f:
data = tomllib.load(f) data = tomllib.load(f)
repo_url = data.get("project", {}).get("urls", {}).get("Repository") repo_url = data.get("project", {}).get("urls", {}).get("Repository")
if not repo_url: if not repo_url:
@@ -41,7 +49,9 @@ def load_gitea_config() -> dict:
parsed = urlparse(repo_url.rstrip("/")) parsed = urlparse(repo_url.rstrip("/"))
parts = parsed.path.lstrip("/").split("/", 1) parts = parsed.path.lstrip("/").split("/", 1)
if len(parts) != 2: if len(parts) != 2:
raise RuntimeError("[project.urls] Repository must include owner and repo, e.g. https://git.example.com/owner/repo") raise RuntimeError(
"[project.urls] Repository must include owner and repo, e.g. https://git.example.com/owner/repo"
)
return { return {
"url": f"{parsed.scheme}://{parsed.netloc}", "url": f"{parsed.scheme}://{parsed.netloc}",
"repo": f"{parts[0]}/{parts[1]}", "repo": f"{parts[0]}/{parts[1]}",
@@ -59,20 +69,31 @@ def load_token() -> str:
# ZIP + dist helpers # ZIP + dist helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Matches MediaHive-1.2.3-win64.zip or MediaHive-1.2.3.4-win64.zip # Installer artifacts are versionless (MediaHive-win64-setup.exe,
# Rejects dev/dirty names like MediaHive-1.2.3.dev0+gabcd-win64.zip # MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage,
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-win64\.zip$") # MediaHive-win64-portable.zip) so /releases/download/latest/<name> links
# stay valid. The version comes from setuptools_scm instead.
_ARTIFACT_RE = re.compile(
r"^MediaHive-(?!\d)[A-Za-z0-9._-]+\.(?:zip|dmg|exe|pkg|AppImage)$"
)
def find_releasable_zips() -> list[tuple[Path, str]]: def read_version() -> str:
"""Return (path, version) pairs for clean-versioned ZIPs in build/.""" """Read version via setuptools_scm, refusing dev/dirty versions."""
version = setuptools_scm.get_version(root=str(REPO_ROOT))
if not re.fullmatch(r"\d+(?:\.\d+)*", version):
raise RuntimeError(
f"Refusing to release non-clean version {version!r}. Tag a release first."
)
return version
def find_releasable_artifacts() -> list[Path]:
"""Return platform artifact paths in build/."""
build_dir = REPO_ROOT / "build" build_dir = REPO_ROOT / "build"
results = [] return [
for p in sorted(build_dir.glob("MediaHive-*-win64.zip")): p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)
m = _CLEAN_ZIP_RE.match(p.name) ]
if m:
results.append((p, m.group(1)))
return results
def find_dist_files(version: str) -> list[Path]: def find_dist_files(version: str) -> list[Path]:
@@ -81,13 +102,13 @@ def find_dist_files(version: str) -> list[Path]:
Raises FileNotFoundError listing every missing file if any are absent. Raises FileNotFoundError listing every missing file if any are absent.
""" """
dist_dir = REPO_ROOT / "dist" dist_dir = REPO_ROOT / "dist"
ver = re.escape(version) wheel = next((p for p in dist_dir.glob(f"mediahive-{version}-*.whl")), None)
wheel = next(
(p for p in dist_dir.glob(f"mediahive-{version}-*.whl")), None
)
sdist = next( sdist = next(
(p for p in dist_dir.glob(f"mediahive-{version}.*") (
if p.suffix in (".gz", ".zip") and p.name != f"mediahive-{version}.zip"), p
for p in dist_dir.glob(f"mediahive-{version}.*")
if p.suffix in {".gz", ".zip"} and p.name != f"mediahive-{version}.zip"
),
None, None,
) )
missing = [] missing = []
@@ -104,14 +125,44 @@ def find_dist_files(version: str) -> list[Path]:
return [wheel, sdist] return [wheel, sdist]
def find_velopack_feed_files() -> list[Path]:
"""Velopack update feed files produced by vpk pack in build/velopack/.
Only what the in-app updater (GiteaSource) reads from the latest
release: this channel's releases.<channel>.json index and the nupkg
payload it points to. The legacy RELEASES and assets.*.json manifests
(Squirrel compat / setup bootstrap) are not uploaded.
"""
releases_dir = REPO_ROOT / "build" / "velopack"
if not releases_dir.exists():
return []
files: list[Path] = []
for pattern in ("releases.*.json", "*.nupkg"):
files.extend(sorted(releases_dir.glob(pattern)))
return files
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Gitea API helpers # Gitea API helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def gitea_headers(token: str) -> dict: def gitea_headers(token: str) -> dict:
return {"Authorization": f"token {token}", "Accept": "application/json"} return {"Authorization": f"token {token}", "Accept": "application/json"}
def get_release_by_tag(
client: httpx.Client, base_url: str, repo: str, tag: str
) -> dict | None:
"""Return the existing release for a tag, or None."""
url = f"{base_url}/api/v1/repos/{repo}/releases/tags/{tag}"
resp = client.get(url)
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
def create_release( def create_release(
client: httpx.Client, client: httpx.Client,
base_url: str, base_url: str,
@@ -120,8 +171,12 @@ def create_release(
version: str, version: str,
notes: str, notes: str,
draft: bool, draft: bool,
) -> int: ) -> tuple[int, set[str]]:
"""Create a Gitea release and return its id.""" """Create a Gitea release, or reuse the existing one for the tag.
Returns (release_id, names of assets already attached), so parallel
platform builds can converge on one release without conflicts.
"""
url = f"{base_url}/api/v1/repos/{repo}/releases" url = f"{base_url}/api/v1/repos/{repo}/releases"
payload = { payload = {
"tag_name": tag, "tag_name": tag,
@@ -132,13 +187,17 @@ def create_release(
} }
resp = client.post(url, json=payload) resp = client.post(url, json=payload)
if resp.status_code == 409: if resp.status_code == 409:
raise RuntimeError( existing = get_release_by_tag(client, base_url, repo, tag)
f"A release for tag '{tag}' already exists on Gitea." if existing is None:
) raise RuntimeError(f"Release for tag '{tag}' conflicts but cannot be read.")
release_id = existing["id"]
assets = {a["name"] for a in existing.get("assets", [])}
print(f"Release for tag '{tag}' already exists (id={release_id}), reusing it.")
return release_id, assets
resp.raise_for_status() resp.raise_for_status()
release_id = resp.json()["id"] release_id = resp.json()["id"]
print(f"Created release id={release_id} (draft={draft})") print(f"Created release id={release_id} (draft={draft})")
return release_id return release_id, set()
def upload_asset( def upload_asset(
@@ -151,9 +210,12 @@ def upload_asset(
"""Upload a file to the release and return the download URL.""" """Upload a file to the release and return the download URL."""
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets" url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
size_mb = path.stat().st_size / (1024 * 1024) size_mb = path.stat().st_size / (1024 * 1024)
mime = "application/zip" if path.suffix == ".zip" else "application/octet-stream" mime = {
".zip": "application/zip",
".dmg": "application/x-apple-diskimage",
}.get(path.suffix, "application/octet-stream")
print(f"Uploading {path.name} ({size_mb:.1f} MB) ...") print(f"Uploading {path.name} ({size_mb:.1f} MB) ...")
with open(path, "rb") as fh: with Path(path).open("rb") as fh:
resp = client.post( resp = client.post(
url, url,
files={"attachment": (path.name, fh, mime)}, files={"attachment": (path.name, fh, mime)},
@@ -169,50 +231,77 @@ def upload_asset(
# Entrypoint # Entrypoint
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def main() -> None: def main() -> None:
# Windows consoles default to cp1252, which can't encode ✓/✗
sys.stdout.reconfigure(errors="replace")
sys.stderr.reconfigure(errors="replace")
parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea") parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea")
parser.add_argument("--draft", action="store_true", help="Create as a draft release") parser.add_argument(
parser.add_argument("--notes", default="", metavar="TEXT", help="Release notes body") "--draft", action="store_true", help="Create as a draft release"
)
parser.add_argument(
"--notes", default="", metavar="TEXT", help="Release notes body"
)
parser.add_argument(
"--no-dist",
action="store_true",
help="Skip wheel/sdist upload (for parallel platform builds; one job uploads them)",
)
args = parser.parse_args() args = parser.parse_args()
try: try:
cfg = load_gitea_config() cfg = load_gitea_config()
token = load_token() token = load_token()
version = read_version()
zips = find_releasable_zips() artifacts = find_releasable_artifacts()
if not zips: if not artifacts:
raise FileNotFoundError( print(
"No clean-versioned ZIPs found in build/.\n" "No platform artifacts found in build/.\n"
"Run scripts/winbuild.py first." "Run scripts/guibuild.py first.",
file=sys.stderr,
) )
sys.exit(1)
# Validate all dist files exist before touching Gitea # Validate all dist files exist before touching Gitea
dist_files: dict[str, list[Path]] = {} dist_files: list[Path] = [] if args.no_dist else find_dist_files(version)
for _, version in zips:
dist_files[version] = find_dist_files(version)
base_url = cfg["url"].rstrip("/") base_url = cfg["url"].rstrip("/")
repo = cfg["repo"] repo = cfg["repo"]
with httpx.Client(headers=gitea_headers(token)) as client: with httpx.Client(headers=gitea_headers(token)) as client:
for zip_path, version in zips:
print(f"\nReleasing {version} ...") print(f"\nReleasing {version} ...")
tag = f"v{version}" tag = f"v{version}"
release_id = create_release( release_id, uploaded = create_release(
client, base_url, repo, tag, version, args.notes, args.draft client, base_url, repo, tag, version, args.notes, args.draft
) )
for path in [zip_path, *dist_files[version]]: for path in dist_files:
if path.name in uploaded:
print(f"Skipping {path.name}, already on the release.")
continue
upload_asset(client, base_url, repo, release_id, path) upload_asset(client, base_url, repo, release_id, path)
for artifact_path in artifacts:
if artifact_path.name in uploaded:
print(f"Skipping {artifact_path.name}, already on the release.")
continue
print(f"Uploading platform artifact: {artifact_path.name}")
upload_asset(client, base_url, repo, release_id, artifact_path)
uploaded.add(artifact_path.name)
for feed_file in find_velopack_feed_files():
if feed_file.name in uploaded:
print(f"Skipping {feed_file.name}, already on the release.")
continue
upload_asset(client, base_url, repo, release_id, feed_file)
uploaded.add(feed_file.name)
print(f"{tag} published") print(f"{tag} published")
print("\nDone. To publish to PyPI, run:") print("\nDone. To publish to PyPI, run:")
print(" uv publish") print(" uv publish")
except Exception as e: except (FileNotFoundError, OSError, RuntimeError, ValueError, httpx.HTTPError) as e:
print(f"✗ Release failed: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"✗ Release failed: {e}", file=sys.stderr) print(f"✗ Release failed: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)

Some files were not shown because too many files have changed in this diff Show More