135 Commits
Author SHA1 Message Date
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
92 changed files with 18115 additions and 5866 deletions
+31 -80
View File
@@ -1,96 +1,47 @@
![MediaHive](docs/mediahive.avif)
# 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)**
**[Windows and Mac portable ZIP downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
## Project Structure
## What It Does
```
hivescan/ Indexing & previews (library + CLI)
indexer.py Media index generation
scanning.py File system scanning
parsing.py Torrent name parsing (PTN)
models.py Data models
images.py TMDb cover/backdrop downloading
showreel.py Video preview clip generation (ffmpeg)
tmdb_client.py TMDb API client with caching
utils.py Path, size, and timestamp helpers
mediahive/ FastAPI web server + Vue frontend
server.py FastAPI app (API + media serving)
__main__.py CLI entry point
frontend/ Vue 3 frontend source
src/
components/ Vue components (Netflix-style UI)
styles/ CSS styles
api.ts API calls to FastAPI backend
types.ts TypeScript interfaces
scripts/
devserver.py Development server (Vite + FastAPI)
rtorrent-manager.py Torrent scanning & rtorrent management
fastapi-vue/ Build utilities for frontend
rtorrent_client.py RTorrent XMLRPC/SCGI client
```
- Scans your chosen media folder for all movies and series that can be found
- Produces preview video clips and downloads metadata
- Search on cast and character names, not just titles
- Hand off playback to your preferred system player
- Implement gamepad controls for MPC-BE on Windows (where needed)
## Quick Start
Extract the ZIP in some place and run MediaHive.exe to start the app. Currently we have no installer, but you can pin to start/taskbar for easier access. On the first startup the app asks for your media folder, that can later be changed by clicking in-app folder icon.
```bash
pip install -e .
```
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.
### 1. Scan & Index Media
## Controls
```bash
# Scan downloads, auto-detect common root, create .mediahive folder
hivescan /media/torrents/*
MediaHive is designed to work with a mouse, keyboard, or gamepad.
# Scan multiple locations
hivescan /mnt/disk1/* /mnt/disk2/*
| Input | Controls |
| --- | --- |
| Mouse | Click posters, rows, search, play, and folder actions directly. |
| Keyboard | Arrow keys move focus, `Enter` activates the focused item, `Escape` goes back, and `/` jumps to search. |
| 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. |
# Override output directory
hivescan /media/torrents/* -o /srv/media/.mediahive
## Recommended Players
# Skip cover/showreel generation
hivescan /media/torrents/* --no-covers --no-showreels
```
- Windows: [MPC-BE](https://github.com/Aleksoid1978/MPC-BE/releases)
- macOS: [IINA](https://iina.io/)
- Linux: SMPlayer
### 2. Serve & Browse
MediaHive opens files with the OS default player, but one specific player may be configured via settings. You are of course free to use any player instead.
```bash
# Start the web server
mediahive /path/to/your/media/folder
- `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.
# Server starts at http://localhost:8420
```
## Background
The app expects `<media-folder>/.mediahive/index.json` generated by hivescan.
### 3. Development
```bash
cd frontend && npm install && cd ..
python scripts/devserver.py
```
Starts Vite dev server + FastAPI backend with auto-reload.
## API Endpoints
- `GET /api/index` — Load media index
- `POST /api/play` — Open media file with system player
- `POST /api/open-folder` — Open folder in file explorer
- `GET /api/media/{path}` — Serve media files (images, video)
## RTorrent Manager
```bash
python scripts/rtorrent-manager.py /media/torrents*/.torrents/
```
Scans `.torrent` files, verifies downloads exist, loads into rtorrent.
## Requirements
- Python ≥ 3.14
- Node.js 18+ (frontend development)
- ffmpeg (showreel generation)
This project started as a personal project that I have used for browsing my warez for some time now. It is still in early development, but I have just now made it public for a wider audience.
+32
View File
@@ -0,0 +1,32 @@
# 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. |
| `POST` | `/api/play/{root_id}` | Opens a media file with the system player. |
| `POST` | `/api/open-folder/{root_id}` | Opens a folder in the system file explorer. |
| `GET` | `/api/meta/{root_id}/{meta_key}` | Returns allowed metadata from `<root>/.mediahive`. |
| `GET` | `/api/meta/playback-state` | Returns merged resume positions across all roots. |
| `GET` | `/api/player/status` | Returns whether remote player control is currently available. |
| `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable. |
| `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. |
## 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.
- `GET /api/media/{root_id}/{file_path:path}` is constrained to the specified root; path traversal outside the root is rejected.
- `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` is constrained to `<root>/.mediahive/{asset_type}` where `asset_type` is one of `movies`, `series`, `people`.
- `GET /api/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`.
- `GET /api/player/status` returns `{ "remote": true|false }`.
- `GET /api/mpcbe/status` returns `false` on non-Windows platforms.
+56
View File
@@ -0,0 +1,56 @@
# Development
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) is aimed at Windows end users.
## 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.
## Migrate Existing Index Snapshots
```bash
uv run python scripts/indexmigr.py /path/to/media/root --write
```
This applies versioned snapshot migrations to `.mediahive/index.json` outside the main application. Use it before starting a newer build against an older index.
## 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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

+5 -1
View File
@@ -2,7 +2,11 @@
"tasks": {
"dev": "deno run -A npm:vite",
"build": "deno run -A npm:vue-tsc --noEmit && deno run -A npm:vite build",
"preview": "deno run -A npm:vite preview"
"preview": "deno run -A npm:vite preview",
"lint": "deno run -A npm:oxlint --vue-plugin --import-plugin src",
"lint:fix": "deno task lint --fix",
"format": "deno run -A npm:oxfmt --config oxfmt.json src",
"format:check": "deno run -A npm:oxfmt --config oxfmt.json --check src"
},
"imports": {
"vue": "npm:vue@^3.4.0"
+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": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
"preview": "vite preview",
"lint": "oxlint --vue-plugin --import-plugin src",
"lint:fix": "npm run lint -- --fix",
"format": "oxfmt --config oxfmt.json src",
"format:check": "oxfmt --config oxfmt.json --check src"
},
"dependencies": {
"country-flag-icons": "^1.6.17",
"vue": "^3.4.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"oxfmt": "^0.51.0",
"oxlint": "^1.66.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-pwa": "^1.3.0",
"vue-tsc": "^2.0.0"
}
}
+1319 -779
View File
File diff suppressed because it is too large Load Diff
+326 -93
View File
@@ -1,89 +1,343 @@
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 {
return input
.replace(/\\/g, '/')
.replace(/^[A-Za-z]:\//, '')
.replace(/^\/+/, '');
.replace(/\\/g, "/")
.replace(/^[A-Za-z]:\//, "")
.replace(/^\/+/, "")
}
export function isVideoPath(path: string | null | undefined): boolean {
return Boolean(path && /\.(webm|mp4|mkv|avi|mov)$/i.test(path))
}
export interface VideoSourceAttributes {
type: string
codecs: string
}
export function isSafariBrowser(): boolean {
if (typeof navigator === "undefined") {
return false
}
const ua = navigator.userAgent
return /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|FxiOS/i.test(ua)
}
export function getVideoPreviewUrl(url: string): string {
if (!url || !isSafariBrowser()) {
return url
}
if (url.includes("#")) {
return url
}
// Safari often needs a tiny time offset to paint the first frame before playback.
return `${url}#t=0.001`
}
export function getVideoSourceAttributes(path: string | null | undefined): VideoSourceAttributes {
if (!path) {
return { type: "video/mp4", codecs: "hvc1" }
}
if (/\.webm$/i.test(path)) {
return { type: "video/webm", codecs: "av1" }
}
if (/\.mp4$/i.test(path) || /\.m4v$/i.test(path)) {
return { type: "video/mp4", codecs: "hvc1" }
}
if (/\.mov$/i.test(path)) {
return { type: "video/quicktime", codecs: "hvc1" }
}
if (/\.avi$/i.test(path)) {
return { type: "video/x-msvideo", codecs: "" }
}
if (/\.mkv$/i.test(path)) {
return { type: "video/x-matroska", codecs: "" }
}
return { type: "video/mp4", codecs: "hvc1" }
}
function encodePathSegments(path: string): string {
return path
.split("/")
.filter((segment) => segment.length > 0)
.map((segment) => encodeURIComponent(segment))
.join("/")
}
function normalizeCoverPath(path: string): string {
return path
.replace(/\\/g, "/")
.replace(/^[A-Za-z]:\//, "")
.replace(/^\/+/, "")
}
function toRootAssetPath(path: string): string | null {
const normalized = normalizeCoverPath(path)
const marker = ".mediahive/"
const idx = normalized.toLowerCase().indexOf(marker)
if (idx < 0) return null
const logical = normalized.slice(idx + marker.length)
return logical.length > 0 ? logical : null
}
function splitAssetTypePath(assetPath: string): { assetType: string; relativePath: string } | null {
const parts = assetPath.split("/").filter((segment) => segment.length > 0)
if (parts.length < 2) return null
const [assetType, ...rest] = parts
if (!assetType || !["movies", "series", "people"].includes(assetType.toLowerCase())) {
return null
}
return { assetType: assetType.toLowerCase(), relativePath: rest.join("/") }
}
/**
* Load the media index from the server
* Fetch merged resume positions from all roots.
*/
export async function loadMediaIndex(): Promise<MediaIndex> {
const response = await fetch('/api/index');
if (!response.ok) {
throw new Error(`Failed to load media index: ${response.statusText}`);
}
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);
export async function fetchResumePositions(): Promise<Record<string, number>> {
try {
const response = await fetch('/api/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: normalizedPath }),
});
const response = await fetch("/api/meta/playback-state")
if (!response.ok) return {}
const data = await response.json().catch(() => ({}))
const positions = data?.data?.resume_positions
if (!positions || typeof positions !== "object") {
return {}
}
const normalized: Record<string, number> = {}
for (const [slug, value] of Object.entries(positions as Record<string, unknown>)) {
if (!value || typeof value !== "object") continue
const pos = (value as { pos?: unknown }).pos
if (typeof pos === "number" && Number.isFinite(pos) && pos > 0) {
normalized[slug] = pos
}
}
return normalized
} catch {
return {}
}
}
/**
* Replace the full root set atomically
*/
export async function replaceRoots(
roots: Record<string, string>,
): Promise<{ accepted: RootEntry[]; failed: unknown[] }> {
const response = await fetch("/api/config/roots", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roots }),
})
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || response.statusText);
const err = await response.json().catch(() => ({ detail: response.statusText }))
throw new Error(err.detail || response.statusText)
}
return response.json()
}
/**
* Fetch detected media players from the backend.
*/
export async function fetchPlayers(): Promise<PlayerInfo[]> {
const response = await fetch("/api/players")
if (!response.ok) {
throw new Error(`Failed to load players: ${response.statusText}`)
}
const data = await response.json()
return data.players || []
}
/**
* Play a media file with the selected player.
*/
export async function playMedia(
rootId: string,
filePath: string,
playerId?: string | null,
playerCustomCmd?: string | null,
timing?: ActionTimingContext,
): Promise<void> {
const normalizedPath = normalizeMediaPath(filePath)
const body: Record<string, unknown> = { file_path: normalizedPath }
if (playerId) body.player_id = playerId
if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd
const actionStart = timing?.actionStartedAt ?? nowMs()
const traceId = makeTraceId("play")
try {
const fetchStart = nowMs()
const actionToFetchMs = Math.max(0, fetchStart - actionStart)
const clientSentMs = Date.now()
const actionStartEpochMs = clientSentMs - actionToFetchMs
const response = await fetch(`/api/play/${encodeURIComponent(rootId)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-MediaHive-Trace-Id": traceId,
"X-MediaHive-Client-Sent-Ms": clientSentMs.toFixed(3),
"X-MediaHive-Client-Action-Start-Ms": actionStartEpochMs.toFixed(3),
},
body: JSON.stringify(body),
})
const fetchMs = Math.max(0, nowMs() - fetchStart)
const totalMs = Math.max(0, nowMs() - actionStart)
const serverTiming = response.headers.get("server-timing")
const responseTraceId = response.headers.get("x-mediahive-trace-id") || traceId
logActionTiming(
"play",
responseTraceId,
response.status,
actionToFetchMs,
fetchMs,
totalMs,
serverTiming,
timing?.source,
)
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || response.statusText)
}
} catch (e) {
console.error('Play media error:', e);
alert(`Failed to play media.\n\n${e}`);
const totalMs = Math.max(0, nowMs() - actionStart)
console.error(`Play media error after ${totalMs.toFixed(1)}ms (trace=${traceId}):`, e)
alert(`Failed to play media.\n\n${e}`)
}
}
/**
* Open a folder in Windows Explorer
* Open a folder in the system file manager
*/
export async function openFolder(folderPath: string): Promise<void> {
const normalizedPath = normalizeMediaPath(folderPath);
export async function openFolder(
rootId: string,
folderPath: string,
timing?: ActionTimingContext,
): Promise<void> {
const normalizedPath = normalizeMediaPath(folderPath)
const actionStart = timing?.actionStartedAt ?? nowMs()
const traceId = makeTraceId("open-folder")
try {
const response = await fetch('/api/open-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
const fetchStart = nowMs()
const actionToFetchMs = Math.max(0, fetchStart - actionStart)
const clientSentMs = Date.now()
const actionStartEpochMs = clientSentMs - actionToFetchMs
const response = await fetch(`/api/open-folder/${encodeURIComponent(rootId)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-MediaHive-Trace-Id": traceId,
"X-MediaHive-Client-Sent-Ms": clientSentMs.toFixed(3),
"X-MediaHive-Client-Action-Start-Ms": actionStartEpochMs.toFixed(3),
},
body: JSON.stringify({ folder_path: normalizedPath }),
});
})
const fetchMs = Math.max(0, nowMs() - fetchStart)
const totalMs = Math.max(0, nowMs() - actionStart)
const serverTiming = response.headers.get("server-timing")
const responseTraceId = response.headers.get("x-mediahive-trace-id") || traceId
logActionTiming(
"open-folder",
responseTraceId,
response.status,
actionToFetchMs,
fetchMs,
totalMs,
serverTiming,
timing?.source,
)
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || response.statusText);
const error = await response.json()
throw new Error(error.detail || response.statusText)
}
} catch (e) {
console.error('Open folder error:', e);
alert(`Failed to open folder.\n\n${e}`);
const totalMs = Math.max(0, nowMs() - actionStart)
console.error(`Open folder error after ${totalMs.toFixed(1)}ms (trace=${traceId}):`, e)
alert(`Failed to open folder.\n\n${e}`)
}
}
/**
* Return whether MPC-BE local web control is currently reachable.
* @param port - Optional custom port (default 13579)
*/
export async function isMpcBeReachable(): Promise<boolean> {
export async function isMpcBeReachable(port?: number | null): Promise<boolean> {
try {
const response = await fetch('/api/mpcbe/status');
if (!response.ok) return false;
const data = await response.json().catch(() => ({}));
return Boolean(data.reachable);
const url = port ? `/api/mpcbe/status?port=${port}` : "/api/mpcbe/status"
const response = await fetch(url)
if (!response.ok) return false
const data = await response.json().catch(() => ({}))
return Boolean(data.reachable)
} catch {
return false;
return false
}
}
export async function fetchResumePositions(): Promise<Record<string, number>> {
try {
const response = await fetch('/api/playback/resume-positions');
if (!response.ok) return {};
const data = await response.json().catch(() => ({}));
const resumePositions = data?.resume_positions;
return resumePositions && typeof resumePositions === 'object' ? resumePositions : {};
} catch {
return {};
/**
* Return player integration capabilities for the current OS.
* @param port - Optional custom MPC-BE port (default 13579)
*/
export async function getPlayerStatus(port?: number | null): Promise<PlayerStatus> {
const url = port ? `/api/player/status?port=${port}` : "/api/player/status"
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to load player status: ${response.statusText}`)
}
return response.json()
}
/**
@@ -93,57 +347,36 @@ export async function fetchResumePositions(): Promise<Record<string, number>> {
* The path comes from the server already converted to Windows format (Z:\...)
* Paths starting with '/' are TMDB relative paths that weren't fetched - ignore them
*/
export function getCoverUrl(coverPath: string | null): string {
export function getCoverUrl(coverPath: string | null, rootId?: string | null): string {
if (!coverPath) {
return '';
return ""
}
// Ignore TMDB relative paths (start with /) - these are bugs in the index
if (coverPath.startsWith('/')) {
return '';
if (coverPath.startsWith("/")) {
return ""
}
// Convert relative path to URL path for FastAPI server
// .mediahive/covers/Movies/... -> /media/.mediahive/covers/Movies/...
let urlPath = coverPath;
// Remove drive letter (Z:) and convert backslashes to forward slashes
if (urlPath.match(/^[A-Za-z]:/)) {
urlPath = urlPath.substring(2);
const rid = rootId || "unknown"
const assetPath = toRootAssetPath(coverPath)
if (assetPath) {
const split = splitAssetTypePath(assetPath)
if (split) {
return `/api/assets/${encodeURIComponent(rid)}/${encodeURIComponent(split.assetType)}/${encodePathSegments(split.relativePath)}`
}
urlPath = urlPath.replace(/\\/g, '/');
// Ensure path starts with /
if (!urlPath.startsWith('/')) {
urlPath = '/' + urlPath;
}
// Encode URI components but preserve slashes
const encodedPath = urlPath.split('/').map(segment => encodeURIComponent(segment)).join('/');
return `/api/media${encodedPath}`;
const mediaPath = normalizeCoverPath(coverPath)
return `/api/media/${encodeURIComponent(rid)}/${encodePathSegments(mediaPath)}`
}
/**
* Invoke the native OS folder picker via pywebview, then switch the server's
* media folder in-place and reload the page. Only works inside the packaged
* desktop app.
* Invoke the native OS folder picker via pywebview, then add the selected
* folder to the server's root list. Only works inside the packaged desktop app.
*/
export async function pickFolderAndRestart(): Promise<void> {
export async function pickFolderAndAddRoot(): Promise<string | null> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const api = (window as any).pywebview?.api;
if (!api) return;
const folder: string | null = await api.pick_folder();
if (!folder) return;
const res = await fetch('/api/change-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder }),
});
if (res.ok) {
// Give the server a moment to complete the background folder switch before reloading
setTimeout(() => window.location.reload(), 500);
} else {
const err = await res.json().catch(() => ({ detail: res.statusText }));
alert(`Failed to change folder: ${err.detail || res.statusText}`);
}
const api = (window as any).pywebview?.api
if (!api) return null
const folder: string | null = await api.pick_folder()
return folder
}
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,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
"
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,198 @@
<template>
<div
v-if="visible"
ref="menuRef"
class="episode-release-menu"
:style="menuStyle"
tabindex="-1"
@keydown="handleKeydown"
>
<div class="episode-release-header">{{ episodeName }}</div>
<div v-if="releases.length > 0" class="episode-release-list">
<ReleaseVersionCard
v-for="(release, index) in releases"
:key="index"
:torrent="release"
:best="index === 0"
:selectable="!!release.playable_file"
:disabled="!release.playable_file"
compact-flags
variant="menu"
inert-card
show-actions
:play-label="getPlayLabel(release.playable_file)"
@play="emit('play', release.playable_file || '')"
@open-folder="emit('openFolder', release.playable_file || '')"
/>
</div>
<div v-else class="episode-release-empty">No versions available</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
import type { Torrent } from "../types"
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
const props = defineProps<{
visible: boolean
x: number
y: number
episodeName: string
releases: Torrent[]
hasResumePosition: (filePath: string | null) => boolean
}>()
const emit = defineEmits<{
play: [string]
openFolder: [string]
close: []
}>()
const menuRef = ref<HTMLElement | null>(null)
const menuLeft = ref(0)
const menuTop = ref(0)
const VIEWPORT_MARGIN = 12
const menuStyle = computed(() => ({
left: `${menuLeft.value}px`,
top: `${menuTop.value}px`,
}))
function getPlayLabel(filePath: string | null | undefined): string {
return props.hasResumePosition(filePath || null) ? "Continue" : "Play"
}
function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return []
return Array.from(
menuRef.value.querySelectorAll<HTMLElement>(
'.ctx-btn:not(:disabled)'
)
)
}
function focusNext(delta: number) {
const elements = getFocusableElements()
if (elements.length === 0) return
const currentIndex = elements.findIndex((el) => el === document.activeElement)
const nextIndex =
currentIndex < 0 ? 0 : (currentIndex + delta + elements.length) % elements.length
elements[nextIndex].focus()
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Tab") {
event.preventDefault()
focusNext(event.shiftKey ? -1 : 1)
return
}
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
event.preventDefault()
focusNext(1)
return
}
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
event.preventDefault()
focusNext(-1)
return
}
if (event.key === "Escape") {
event.preventDefault()
emit("close")
return
}
}
function clampToViewport() {
const menu = menuRef.value
if (!menu) return
const width = menu.offsetWidth
const height = menu.offsetHeight
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN)
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft)
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop)
}
function handleViewportChange() {
if (!props.visible) return
clampToViewport()
}
watch(
() => [props.visible, props.x, props.y, props.episodeName, props.releases.length],
async ([visible]) => {
if (!visible) return
await nextTick()
clampToViewport()
// Focus first action button for keyboard navigation
const firstBtn = menuRef.value?.querySelector(
".ctx-btn:not(:disabled)",
) as HTMLElement | null
firstBtn?.focus()
},
{ immediate: true },
)
watch(
() => props.visible,
(visible) => {
if (visible) {
window.addEventListener("resize", handleViewportChange)
return
}
window.removeEventListener("resize", handleViewportChange)
},
{ immediate: true },
)
onBeforeUnmount(() => {
window.removeEventListener("resize", handleViewportChange)
})
</script>
<style scoped>
.episode-release-menu {
position: fixed;
z-index: 1000;
background: rgba(20, 20, 30, 0.98);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
min-width: 420px;
max-width: min(820px, calc(100vw - 24px));
max-height: calc(100vh - 24px);
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
padding: 8px;
}
.episode-release-header {
padding: 10px 12px;
font-weight: 600;
font-size: 0.9rem;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.episode-release-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.episode-release-empty {
padding: 16px;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 0.85rem;
}
</style>
+741 -77
View File
@@ -1,7 +1,9 @@
<template>
<header class="header" :class="[`header-${position}`]">
<div class="header-left">
<RouterLink to="/" class="header-logo-link" aria-label="Go to front page">
<img :src="logoUrl" alt="MediaHive" class="header-logo" />
</RouterLink>
<nav class="header-nav">
<!-- Browse mode: show both Movies and Series -->
<template v-if="!isDetailPage">
@@ -26,19 +28,10 @@
</template>
<!-- Detail mode: show current category + Details -->
<template v-else>
<button
class="header-nav-item"
v-bind="navAttrs(navRow, 0)"
@focus="goToCategory"
>
{{ currentView === 'movies' ? 'Movies' : 'Series' }}
</button>
<button
class="header-nav-item active"
v-bind="navAttrs(navRow, 1, 1)"
>
Details
<button class="header-nav-item" v-bind="navAttrs(navRow, 0)" @focus="goToCategory">
{{ currentView === "search" ? "Search" : currentView === "movies" ? "Movies" : "Series" }}
</button>
<button class="header-nav-item active" v-bind="navAttrs(navRow, 1, 1)">Details</button>
</template>
</nav>
</div>
@@ -49,12 +42,23 @@
type="search"
class="search-input"
placeholder="Search..."
:spellcheck="false"
autocorrect="off"
autocapitalize="off"
autocomplete="off"
v-model="localSearch"
v-bind="navAttrs(navRow, 2)"
:data-nav-entry-col="localSearch ? 2 : undefined"
@focus="handleSearchFocus"
@keydown.escape="handleEscape"
/>
<HexKeyboard
v-model="localSearch"
:visible="hexKeyboardVisible"
:search-ref="searchInputRef"
@close="hexKeyboardVisible = false"
@submit="hexKeyboardVisible = false"
/>
</div>
<div v-if="mpcBeConnected" class="player-indicator" title="MPC-BE is connected">
@@ -62,129 +66,492 @@
<span>Player Open</span>
</div>
<div v-if="isDesktopApp" class="header-settings">
<button
class="header-settings-btn"
title="Change media folder"
@click="changeFolder"
<div class="header-settings">
<button class="header-settings-btn" title="Settings" @click="openSettings">
<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"
>
<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">
<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"/>
<circle cx="12" cy="12" r="3" />
<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>
</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>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, watch, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { navAttrs } from '../composables/useKeyboardNavigation';
import logoUrl from '../assets/mediahive.webp';
import { pickFolderAndRestart } from '../api';
import { ref, watch, computed, onMounted, onUnmounted } from "vue"
import { useRouter, useRoute } from "vue-router"
import { navAttrs } from "../composables/useKeyboardNavigation"
import logoUrl from "../assets/mediahive.webp"
import { 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<{
currentView: 'movies' | 'series';
searchQuery: string;
mpcBeConnected: boolean;
navRow: number;
position: 'top' | 'after-hero' | 'after-movie-header' | 'after-series-hero';
}>();
currentView: "movies" | "series" | "search"
searchQuery: string
roots: RootEntry[]
mpcBeConnected: boolean
navRow: number
position: "top" | "after-hero" | "after-movie-header" | "after-series-hero"
}>()
const emit = defineEmits<{
search: [string];
clearSearch: [];
goBack: [];
}>();
search: [string]
goBack: []
}>()
const router = useRouter();
const searchInputRef = ref<HTMLInputElement | null>(null);
const localSearch = ref(props.searchQuery);
const router = useRouter()
const route = useRoute()
const searchInputRef = ref<HTMLInputElement | null>(null)
const localSearch = ref(props.searchQuery)
// True only when running inside the packaged pywebview desktop app.
// pywebview injects window.pywebview asynchronously, so we listen for the
// 'pywebviewready' event rather than checking at component creation time.
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();
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))
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)
}
}
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()
}
})
// Check if we're on a detail page
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)
const isSearchActive = computed(() => {
return !isDetailPage.value && !!localSearch.value;
});
return !isDetailPage.value && !!localSearch.value
})
// Switch views on focus (no Enter required) - only in browse mode
function switchToMovies() {
if (!isDetailPage.value && props.currentView !== 'movies') {
emit('clearSearch');
router.push('/movies');
if (!isDetailPage.value && props.currentView !== "movies") {
router.push("/movies")
}
}
function switchToSeries() {
if (!isDetailPage.value && props.currentView !== 'series') {
emit('clearSearch');
router.push('/series');
if (!isDetailPage.value && props.currentView !== "series") {
router.push("/series")
}
}
// Go back to category list from detail page
function goToCategory() {
// 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
function handleSearchFocus() {
// If on detail page, go back to browse first
if (isDetailPage.value) {
goToCategory();
}
// Intentionally no-op: focusing search should not navigate away from detail.
}
// Sync local search to parent
watch(localSearch, (val) => {
emit('search', val);
});
emit("search", val)
})
// Sync parent search to local (for external clears)
watch(() => props.searchQuery, (val) => {
watch(
() => props.searchQuery,
(val) => {
if (val !== localSearch.value) {
localSearch.value = val;
localSearch.value = val
}
});
},
)
function handleEscape() {
if (hexKeyboardVisible.value) {
hexKeyboardVisible.value = false
return
}
// Clear search and blur
localSearch.value = '';
searchInputRef.value?.blur();
localSearch.value = ""
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) {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
searchInputRef.value?.focus();
searchInputRef.value?.select();
const target = e.target as HTMLElement | null
const isTypingTarget = Boolean(
target &&
(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(() => {
window.addEventListener('keydown', handleKeydown);
});
window.addEventListener("keydown", handleKeydown)
window.addEventListener("mediahive:gamepad-action", onGamepadAction)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown);
});
window.removeEventListener("keydown", handleKeydown)
window.removeEventListener("mediahive:gamepad-action", onGamepadAction)
})
</script>
<style scoped>
@@ -204,4 +571,301 @@ onUnmounted(() => {
background: #22c55e;
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;
}
</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>
+617
View File
@@ -0,0 +1,617 @@
<template>
<Teleport to="body">
<Transition name="hex-keyboard-fade">
<div v-if="visible" ref="keyboardRef" class="hex-keyboard" @click.stop @keydown="handleKeyDown">
<div ref="gridRef" class="hex-keyboard-grid">
<div
v-for="(row, rowIndex) in rows"
:key="rowIndex"
class="hex-keyboard-row"
:class="`hex-keyboard-row-${rowIndex}`"
>
<button
v-for="key in row"
:key="key.id"
:ref="(el: unknown) => setKeyRef(el as HTMLElement | null, key.globalIndex)"
class="hex-key"
:class="[
`hex-key-row-${getCoord(key.globalIndex).row}`,
{
'hex-key-blue': key.bg === 'blue',
'hex-key-yellow': key.bg === 'yellow',
'hex-key-red': key.bg === 'red',
'hex-key-pressed': isPressed(key.globalIndex),
},
]"
tabindex="-1"
@click="handleKeyClick(key)"
>
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{ key.label }}</span>
</button>
</div>
<!-- Green focus outline rendered separately on top -->
<svg
v-if="focusedIndex !== null"
class="hex-key-focus"
viewBox="-5 -5 96.6 110"
preserveAspectRatio="none"
:style="focusSvgStyle"
>
<polygon points="43.3,0 86.6,25 86.6,75 43.3,100 0,75 0,25" />
</svg>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted, onUnmounted, computed } from "vue"
interface KeyDef {
id: string
label: string
value?: string
action?: "backspace" | "space" | "close"
bg?: "blue" | "yellow" | "red"
}
interface RowKeyDef extends KeyDef {
globalIndex: number
}
const props = defineProps<{
visible: boolean
modelValue: string
searchRef?: HTMLInputElement | null
}>()
const emit = defineEmits<{
"update:modelValue": [string]
close: []
submit: []
}>()
const focusedIndex = ref<number | null>(null)
const pressedTimestamps = ref<Map<number, number>>(new Map())
const keyRefs = ref<(HTMLElement | null)[]>([])
const keyboardRef = ref<HTMLElement | null>(null)
const gridRef = ref<HTMLElement | null>(null)
const pressedTimeouts = new Map<number, ReturnType<typeof setTimeout>>()
function isPressed(index: number): boolean {
const ts = pressedTimestamps.value.get(index)
if (!ts) return false
return Date.now() - ts < 1000
}
const rows = computed<RowKeyDef[][]>(() => {
const result: RowKeyDef[][] = [[], [], [], []]
for (let i = 0; i < layoutKeys.length; i++) {
const key = layoutKeys[i]
const coord = getCoord(i)
result[coord.row].push({ ...key, globalIndex: i })
}
return result
})
// Four-row layout with honeycomb staggering
// Each row shifted 0.5 cell left relative to the one below:
// Row 0 (numbers): shift 0
// Row 1 (qwerty): shift 0.5
// Row 2 (asdf): shift 1.0
// Row 3 (zxcv): shift 1.5
const layoutKeys: KeyDef[] = [
// Row 0 (number row) — shift 0
{ id: "1", label: "1", value: "1" },
{ id: "2", label: "2", value: "2" },
{ id: "3", label: "3", value: "3" },
{ id: "4", label: "4", value: "4" },
{ id: "5", label: "5", value: "5" },
{ id: "6", label: "6", value: "6" },
{ id: "7", label: "7", value: "7" },
{ id: "8", label: "8", value: "8" },
{ id: "9", label: "9", value: "9" },
{ id: "0", label: "0", value: "0" },
{ id: "bs", label: "\u2190", action: "backspace", bg: "blue" },
// Row 1 (QWERTY) — shift 0.5
{ id: "q", label: "Q", value: "q" },
{ id: "w", label: "W", value: "w" },
{ id: "e", label: "E", value: "e" },
{ id: "r", label: "R", value: "r" },
{ id: "t", label: "T", value: "t" },
{ id: "y", label: "Y", value: "y" },
{ id: "u", label: "U", value: "u" },
{ id: "i", label: "I", value: "i" },
{ id: "o", label: "O", value: "o" },
{ id: "p", label: "P", value: "p" },
// Row 2 (ASDF) — shift 1.0
{ id: "a", label: "A", value: "a" },
{ id: "s", label: "S", value: "s" },
{ id: "d", label: "D", value: "d" },
{ id: "f", label: "F", value: "f" },
{ id: "g", label: "G", value: "g" },
{ id: "h", label: "H", value: "h" },
{ id: "j", label: "J", value: "j" },
{ id: "k", label: "K", value: "k" },
{ id: "l", label: "L", value: "l" },
// Row 3 (ZXCV + Space + Close) — shift 1.5
{ id: "z", label: "Z", value: "z" },
{ id: "x", label: "X", value: "x" },
{ id: "c", label: "C", value: "c" },
{ id: "v", label: "V", value: "v" },
{ id: "b", label: "B", value: "b" },
{ id: "n", label: "N", value: "n" },
{ id: "m", label: "M", value: "m" },
{ id: "sp", label: "\u2423", value: " ", bg: "yellow" },
{ id: "cls", label: "Close", action: "close", bg: "red" },
]
const ROW_0_START = 0
const ROW_0_COUNT = 11
const ROW_1_START = 11
const ROW_1_COUNT = 10
const ROW_2_START = 21
const ROW_2_COUNT = 9
const ROW_3_START = 30
const ROW_3_COUNT = 9
function getCoord(index: number): { row: number; col: number } {
if (index >= ROW_0_START && index < ROW_0_START + ROW_0_COUNT) {
return { row: 0, col: index - ROW_0_START }
}
if (index >= ROW_1_START && index < ROW_1_START + ROW_1_COUNT) {
return { row: 1, col: index - ROW_1_START }
}
if (index >= ROW_2_START && index < ROW_2_START + ROW_2_COUNT) {
return { row: 2, col: index - ROW_2_START }
}
if (index >= ROW_3_START && index < ROW_3_START + ROW_3_COUNT) {
return { row: 3, col: index - ROW_3_START }
}
return { row: 0, col: 0 }
}
function getRowRange(row: number): { start: number; count: number } {
switch (row) {
case 0: return { start: ROW_0_START, count: ROW_0_COUNT }
case 1: return { start: ROW_1_START, count: ROW_1_COUNT }
case 2: return { start: ROW_2_START, count: ROW_2_COUNT }
case 3: return { start: ROW_3_START, count: ROW_3_COUNT }
default: return { start: 0, count: 0 }
}
}
function findIndexAt(row: number, col: number): number | null {
for (let i = 0; i < layoutKeys.length; i++) {
const c = getCoord(i)
if (c.row === row && c.col === col) return i
}
return null
}
function setKeyRef(el: HTMLElement | null, index: number) {
keyRefs.value[index] = el
}
function triggerPress(index: number) {
pressedTimestamps.value.set(index, Date.now())
const existing = pressedTimeouts.get(index)
if (existing) clearTimeout(existing)
const timeout = setTimeout(() => {
pressedTimestamps.value.delete(index)
pressedTimeouts.delete(index)
}, 1000)
pressedTimeouts.set(index, timeout)
// Restart CSS animation immediately by touching the DOM directly
const el = keyRefs.value[index]
const label = el?.querySelector(".hex-key-label") as HTMLElement | null
if (label) {
label.style.animation = "none"
// Force reflow
void label.offsetWidth
label.style.animation = ""
}
}
function handleKeyClick(key: KeyDef) {
const index = layoutKeys.findIndex((k) => k.id === key.id)
if (index !== -1) triggerPress(index)
if (key.action === "backspace") {
emit("update:modelValue", props.modelValue.slice(0, -1))
return
}
if (key.action === "space") {
emit("update:modelValue", props.modelValue + " ")
return
}
if (key.action === "close") {
close()
return
}
if (key.value) {
emit("update:modelValue", props.modelValue + key.value)
}
}
function activateKeyById(keyId: string): boolean {
const key = layoutKeys.find((candidate) => candidate.id === keyId)
if (!key) return false
handleKeyClick(key)
return true
}
function close() {
emit("close")
}
function findNext(
currentIdx: number,
direction: "up" | "down" | "left" | "right",
): number | null {
const current = getCoord(currentIdx)
if (direction === "left") {
const targetCol = current.col - 1
if (targetCol < 0) {
const range = getRowRange(current.row)
return range.start + range.count - 1
}
let next = findIndexAt(current.row, targetCol)
if (next !== null) return next
if (current.row > 0) {
next = findIndexAt(current.row - 1, targetCol)
if (next !== null) return next
}
if (current.row < 3) {
next = findIndexAt(current.row + 1, targetCol)
if (next !== null) return next
}
return null
}
if (direction === "right") {
const targetCol = current.col + 1
const range = getRowRange(current.row)
if (targetCol >= range.count) {
return range.start
}
let next = findIndexAt(current.row, targetCol)
if (next !== null) return next
if (current.row > 0) {
next = findIndexAt(current.row - 1, targetCol)
if (next !== null) return next
}
if (current.row < 3) {
next = findIndexAt(current.row + 1, targetCol)
if (next !== null) return next
}
return null
}
if (direction === "up") {
const targetRow = current.row - 1
if (targetRow < 0) return null
let next = findIndexAt(targetRow, current.col)
if (next !== null) return next
next = findIndexAt(targetRow, current.col - 1)
if (next !== null) return next
next = findIndexAt(targetRow, current.col + 1)
if (next !== null) return next
return null
}
if (direction === "down") {
const targetRow = current.row + 1
if (targetRow > 3) return null
let next = findIndexAt(targetRow, current.col)
if (next !== null) return next
next = findIndexAt(targetRow, current.col - 1)
if (next !== null) return next
next = findIndexAt(targetRow, current.col + 1)
if (next !== null) return next
return null
}
return null
}
function handleKeyDown(e: KeyboardEvent) {
const direction = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
}[e.key] as "up" | "down" | "left" | "right" | undefined
if (!direction) {
if (e.key === "Enter" && focusedIndex.value !== null) {
e.preventDefault()
e.stopPropagation()
handleKeyClick(layoutKeys[focusedIndex.value])
}
return
}
const current = focusedIndex.value ?? 0
const next = findNext(current, direction)
if (next !== null && keyRefs.value[next]) {
e.preventDefault()
e.stopPropagation()
focusedIndex.value = next
}
}
// Compute focus outline position from focused key element
const focusSvgStyle = computed(() => {
const idx = focusedIndex.value
if (idx === null || !keyRefs.value[idx] || !gridRef.value) return {}
const el = keyRefs.value[idx]!
const grid = gridRef.value
const gridRect = grid.getBoundingClientRect()
const elRect = el.getBoundingClientRect()
return {
left: `${(elRect.left - gridRect.left) / 0.8}px`,
top: `${(elRect.top - gridRect.top) / 0.8}px`,
width: `${elRect.width / 0.8}px`,
height: `${elRect.height / 0.8}px`,
}
})
// Position keyboard under search input
function updatePosition() {
if (!keyboardRef.value || !props.searchRef) return
const searchRect = props.searchRef.getBoundingClientRect()
const keyboardEl = keyboardRef.value
keyboardEl.style.left = `${searchRect.left + searchRect.width / 2}px`
keyboardEl.style.top = `${searchRect.bottom + 8}px`
keyboardEl.style.bottom = "auto"
keyboardEl.style.transform = "translateX(-50%) scale(0.8)"
}
// Track focus loss to close keyboard
function onFocusIn(event: FocusEvent) {
if (!props.visible) return
const target = event.target as HTMLElement | null
if (keyboardRef.value && !keyboardRef.value.contains(target)) {
close()
}
}
// Initial focus on 'A' (index 21)
watch(
() => props.visible,
(visible) => {
if (visible) {
nextTick(() => {
focusedIndex.value = 21
updatePosition()
})
} else {
focusedIndex.value = null
}
},
)
function onGamepadAction(event: Event) {
if (!props.visible) return
const customEvent = event as CustomEvent<{ action?: string }>
const action = customEvent.detail?.action
if (!action) return
if (action === "select") {
event.preventDefault()
if (focusedIndex.value !== null) {
triggerPress(focusedIndex.value)
handleKeyClick(layoutKeys[focusedIndex.value])
}
return
}
if (action === "back") {
event.preventDefault()
close()
return
}
const direction = {
up: "up",
down: "down",
left: "left",
right: "right",
}[action] as "up" | "down" | "left" | "right" | undefined
if (direction && focusedIndex.value !== null) {
event.preventDefault()
const next = findNext(focusedIndex.value, direction)
if (next !== null && keyRefs.value[next]) {
focusedIndex.value = next
}
}
}
// Custom handler for X (backspace), Y (space) and shoulder buttons (LB/RB)
function onRawGamepad(event: Event) {
if (!props.visible) return
const customEvent = event as CustomEvent<{ action?: string; button?: number }>
const button = customEvent.detail?.button
if (button === undefined) return
// X button = backspace (button 2)
if (button === 2) {
event.preventDefault()
activateKeyById("bs")
return
}
// Y button = space (button 3)
if (button === 3) {
event.preventDefault()
activateKeyById("sp")
return
}
// LB = move cursor left in search input (button 4)
if (button === 4) {
event.preventDefault()
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }))
return
}
// RB = move cursor right in search input (button 5)
if (button === 5) {
event.preventDefault()
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))
return
}
}
onMounted(() => {
window.addEventListener("mediahive:gamepad-action", onGamepadAction)
window.addEventListener("mediahive:gamepad-button", onRawGamepad)
document.addEventListener("focusin", onFocusIn)
window.addEventListener("resize", updatePosition)
})
onUnmounted(() => {
window.removeEventListener("mediahive:gamepad-action", onGamepadAction)
window.removeEventListener("mediahive:gamepad-button", onRawGamepad)
document.removeEventListener("focusin", onFocusIn)
window.removeEventListener("resize", updatePosition)
})
</script>
<style scoped>
.hex-keyboard {
position: fixed;
z-index: 3000;
transform-origin: top center;
}
.hex-keyboard-grid {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
--key-h: 56px;
--key-w: calc(0.866 * var(--key-h));
}
.hex-keyboard-row {
display: flex;
}
/* Overlap: each row overlaps the previous by 0.25 * key-h */
.hex-keyboard-row:not(:first-child) {
margin-top: calc(var(--key-h) * -0.25);
}
/* Row horizontal offsets for honeycomb staggering */
.hex-keyboard-row-0 { margin-left: 0; }
.hex-keyboard-row-1 { margin-left: calc(var(--key-w) * 0.5); }
.hex-keyboard-row-2 { margin-left: calc(var(--key-w) * 1.0); }
.hex-keyboard-row-3 { margin-left: calc(var(--key-w) * 1.5); }
.hex-key {
position: relative;
height: var(--key-h);
width: var(--key-w);
border: none;
color: #ffffff;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
transition: transform 0.15s ease, color 0.3s ease;
outline: none;
transform: scale(0.97);
}
/* Row-based greyscale gradients — lighter top, darker home row */
.hex-key-row-0 {
background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%);
}
.hex-key-row-1, .hex-key-row-3 {
background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%);
}
.hex-key-row-2 {
background: linear-gradient(180deg, #1f2937d0 0%, #111827d0 100%);
}
/* No hover/active scale — keyboard is gamepad-operated */
/* Keypress feedback: instant green, then exponential fade to white */
.hex-key-pressed .hex-key-label {
animation: hex-label-fade 1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes hex-label-fade {
0% { color: #22c55e; }
100% { color: #ffffff; }
}
/* Special key backgrounds override row gradients */
.hex-key-blue {
background: linear-gradient(180deg, #60a5fad0 0%, #3b82f6d0 100%);
}
.hex-key-yellow {
background: linear-gradient(180deg, #facc15d0 0%, #eab308d0 100%);
}
.hex-key-red {
background: linear-gradient(180deg, #f87171d0 0%, #ef4444d0 100%);
}
.hex-key-label {
position: relative;
z-index: 2;
pointer-events: none;
user-select: none;
}
.hex-key-label-large {
font-size: 2em;
line-height: 1;
margin-top: -0.15em;
}
/* Green focus outline rendered separately on top of the grid */
.hex-key-focus {
position: absolute;
pointer-events: none;
z-index: 20;
overflow: visible;
}
.hex-key-focus polygon {
fill: none;
stroke: #22c55e;
stroke-width: 3;
vector-effect: non-scaling-stroke;
animation: hex-outline-blink 1s ease-in-out infinite;
}
@keyframes hex-outline-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Transition */
.hex-keyboard-fade-enter-active,
.hex-keyboard-fade-leave-active {
transition: opacity 0.25s ease;
}
.hex-keyboard-fade-enter-from,
.hex-keyboard-fade-leave-to {
opacity: 0;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<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="`${entry.countryCode}: ${entry.sourceCodes.join(', ')}`"
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 } from "../utils/languageFlags"
const props = defineProps<{
label?: string
codes: string[] | null | undefined
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>
+180 -100
View File
@@ -1,25 +1,27 @@
<template>
<div
<component
:is="href ? 'a' : 'div'"
class="media-card"
v-bind="navAttributes"
:data-item-id="item.id"
@click="$emit('click')"
:data-item-type="item.type"
:href="href || undefined"
@click="handleClick"
@keydown.enter.prevent="$emit('click')"
>
<div class="media-card-poster">
<!-- SVG focus outline -->
<svg class="card-focus-outline" viewBox="0 0 100 150" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="150" />
</svg>
<img
v-if="coverUrl && !imageError"
:src="coverUrl"
v-if="posterImageUrl && !imageError"
:src="posterImageUrl"
:alt="item.title || 'Unknown'"
loading="lazy"
@error="imageError = true"
/>
<div v-else class="media-card-placeholder">
{{ item.type === 'movies' ? '🎬' : item.type === 'episode' ? '📺' : '📺' }}
{{ item.type === "movies" ? "🎬" : item.type === "episode" ? "📺" : "📺" }}
</div>
<div v-if="rating" class="media-card-rating" :class="ratingClass">
{{ rating.toFixed(1) }}
@@ -30,145 +32,206 @@
<span class="media-card-title">{{ displayTitle }}</span>
<span v-if="item.year" class="media-card-year">{{ item.year }}</span>
</div>
<!-- Search match info (when searching) -->
<template v-if="item.searchMatchInfo">
<div v-if="matchedPeople && matchedPeople.length > 0" class="media-card-detail match-reason">
<div
v-if="matchedPeople && matchedPeople.length > 0"
class="media-card-detail match-reason"
>
<template v-for="(person, idx) in matchedPeople" :key="person.name">
<span :class="person.highlightRoles ? 'match-dim' : 'match-name'">{{ person.name }}</span>
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'">({{ person.roles }})</span><span v-if="idx < matchedPeople.length - 1">, </span>
<span :class="person.highlightRoles ? 'match-dim' : 'match-name'">{{
person.name
}}</span>
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'"
>({{ person.roles }})</span
><span v-if="idx < matchedPeople.length - 1">, </span>
</template>
</div>
<div v-if="item.searchMatchInfo.matchedEpisodes && item.searchMatchInfo.matchedEpisodes.length > 0" class="media-card-episodes">
<div v-for="ep in item.searchMatchInfo.matchedEpisodes.slice(0, 3)" :key="ep.name" class="matched-episode">
<div
v-if="
item.searchMatchInfo.matchedEpisodes && item.searchMatchInfo.matchedEpisodes.length > 0
"
class="media-card-episodes"
>
<div
v-for="ep in item.searchMatchInfo.matchedEpisodes.slice(0, 3)"
:key="ep.name"
class="matched-episode"
>
<span class="match-name">{{ ep.name }}</span>
<span class="match-roles"> ({{ ep.location }})</span>
</div>
<div v-if="item.searchMatchInfo.matchedEpisodes.length > 3" class="matched-episode-more">+{{ item.searchMatchInfo.matchedEpisodes.length - 3 }} more</div>
<div v-if="item.searchMatchInfo.matchedEpisodes.length > 3" class="matched-episode-more">
+{{ item.searchMatchInfo.matchedEpisodes.length - 3 }} more
</div>
</div>
</template>
<!-- Default display (browsing) -->
<template v-else>
<div v-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
<div v-if="directorAndCast" class="media-card-detail">
<span v-if="director" class="director-name">{{ director }}</span><span v-if="director && filteredCastNames">, </span>{{ filteredCastNames }}
<div
v-if="item.type === 'series' && formattedSeriesCreators"
class="media-card-detail person-list"
>
<span
v-for="(creatorName, creatorIndex) in formattedSeriesCreators"
:key="`${creatorName}-${creatorIndex}`"
class="person-token"
>{{ creatorName }}</span
>
</div>
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
<div v-if="directorAndCast" class="media-card-detail person-list">
<span v-if="director" class="director-name person-token">{{ formatPersonLabel(director) }}</span>
<span
v-for="(castName, castIndex) in formattedCastNames"
:key="`${castName}-${castIndex}`"
class="person-token"
>{{ castName }}</span
>
</div>
</template>
</div>
</div>
</component>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { MediaItem, Movie, Series, EpisodeWithSeries } from '../types';
import { getCoverUrl } from '../api';
import { navAttrs } from '../composables/useKeyboardNavigation';
import { computed, ref } from "vue"
import type { MediaItem, Movie, Series, EpisodeWithSeries } from "../types"
import { getCoverUrl, isVideoPath } from "../api"
import { navAttrs } from "../composables/useKeyboardNavigation"
const props = defineProps<{
item: MediaItem;
navRow?: number;
navCol?: number;
}>();
item: MediaItem
navRow?: number
navCol?: number
href?: string
}>()
defineEmits<{
click: [];
}>();
const emit = defineEmits<{
click: []
}>()
function handleClick(event: MouseEvent) {
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return
}
// Prevent default navigation for plain left-clicks and synthetic clicks
// so that parent handlers can manage side-effects and routing
event.preventDefault()
emit("click")
}
// Navigation attributes for keyboard navigation
const navAttributes = computed(() => {
if (props.navRow !== undefined && props.navCol !== undefined) {
return navAttrs(props.navRow, props.navCol);
return navAttrs(props.navRow, props.navCol)
}
return {};
});
return {}
})
const imageError = ref(false);
const imageError = ref(false)
const coverUrl = computed(() => {
if (imageError.value) return null;
return getCoverUrl(props.item.cover_path);
});
const posterImageUrl = computed(() => {
if (imageError.value) return null
if (!props.item.cover_path || isVideoPath(props.item.cover_path)) {
return null
}
return getCoverUrl(props.item.cover_path, props.item.root_id)
})
const rating = computed(() => {
if (props.item.type === 'movies') {
return (props.item.data as Movie).info?.rating;
if (props.item.type === "movies") {
return (props.item.data as Movie).info?.rating
}
if (props.item.type === 'episode') {
const epData = props.item.data as EpisodeWithSeries;
return epData.episode.rating ?? epData.series.info?.rating;
if (props.item.type === "episode") {
const epData = props.item.data as EpisodeWithSeries
return epData.episode.rating ?? epData.series.info?.rating
}
return (props.item.data as Series).info?.rating;
});
return (props.item.data as Series).info?.rating
})
const ratingClass = computed(() => {
if (!rating.value) return '';
if (rating.value >= 7.5) return 'rating-high';
if (rating.value >= 6) return 'rating-medium';
return 'rating-low';
});
if (!rating.value) return ""
if (rating.value >= 7.5) return "rating-high"
if (rating.value >= 6) return "rating-medium"
return "rating-low"
})
const displayTitle = computed(() => {
if (props.item.type === 'episode') {
const epData = props.item.data as EpisodeWithSeries;
return epData.episode.name || `Episode ${epData.episode.episode_number}`;
if (props.item.type === "episode") {
const epData = props.item.data as EpisodeWithSeries
return epData.episode.name || `Episode ${epData.episode.episode_number}`
}
return props.item.title;
});
return props.item.title
})
const subtitle = computed(() => {
if (props.item.type === 'episode') {
const epData = props.item.data as EpisodeWithSeries;
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`;
if (props.item.type === "episode") {
const epData = props.item.data as EpisodeWithSeries
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`
}
// For series, show creators
if (props.item.type === 'series') {
const creators = (props.item.data as Series).info?.creators;
return creators && creators.length > 0 ? creators.join(', ') : null;
return null
})
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(() => {
if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).info?.director;
});
if (props.item.type !== "movies") return null
return (props.item.data as Movie).info?.director
})
// Check if we have director and/or cast to display
const directorAndCast = computed(() => {
if (props.item.type !== 'movies') return false;
return director.value || filteredCastNames.value;
});
if (props.item.type !== "movies") return false
return !!director.value || filteredCastNames.value.length > 0
})
// Cast names, excluding director if they appear in cast
const filteredCastNames = computed(() => {
if (props.item.type !== 'movies') return null;
const cast = (props.item.data as Movie).info?.cast;
if (!cast || cast.length === 0) return null;
if (props.item.type !== "movies") return []
const cast = (props.item.data as Movie).info?.cast
if (!cast || cast.length === 0) return []
const directorName = director.value?.toLowerCase();
const directorName = director.value?.toLowerCase()
const filteredCast = directorName
? cast.filter(c => c.name.toLowerCase() !== directorName)
: cast;
? cast.filter((c) => c.name.toLowerCase() !== directorName)
: cast
if (filteredCast.length === 0) return null;
if (filteredCast.length === 0) return []
// Show first 3 cast members
const names = filteredCast.slice(0, 3).map(c => c.name);
return names.join(', ');
});
return filteredCast.slice(0, 3).map((c) => c.name)
})
const formattedCastNames = computed(() => {
return filteredCastNames.value.map((name) => formatPersonLabel(name))
})
// Matched people from search (from searchMatchInfo)
const matchedPeople = computed(() => {
const info = props.item.searchMatchInfo;
if (!info || !info.matchedPeople) return null;
return info.matchedPeople;
});
const info = props.item.searchMatchInfo
if (!info || !info.matchedPeople) return null
return info.matchedPeople
})
</script>
<style scoped>
/* Blinking animation for focus outline */
@keyframes card-outline-blink {
0%, 100% {
0%,
100% {
opacity: 1;
}
50% {
@@ -176,7 +239,6 @@ const matchedPeople = computed(() => {
}
}
/* SVG focus outline styles */
.card-focus-outline {
position: absolute;
inset: 0;
@@ -195,15 +257,21 @@ const matchedPeople = computed(() => {
vector-effect: non-scaling-stroke;
}
/* Show outline on hover and focus */
.media-card:hover .card-focus-outline,
.media-card.nav-focused .card-focus-outline {
.media-card-poster img,
.media-card-poster video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
html.mouse-active .media-card:hover .card-focus-outline,
html:not(.mouse-active) .media-card.nav-focused .card-focus-outline {
opacity: 1;
animation: card-outline-blink 1s ease-in-out infinite;
}
/* Brighter outline for keyboard focus */
.media-card.nav-focused .card-focus-outline rect {
html:not(.mouse-active) .media-card.nav-focused .card-focus-outline rect {
stroke: #ffffff;
stroke-width: 5;
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
@@ -236,7 +304,6 @@ const matchedPeople = computed(() => {
font-size: 0.65rem;
color: var(--text-muted);
margin-top: 1px;
/* Allow up to 2 lines with ellipsis */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -244,12 +311,26 @@ const matchedPeople = computed(() => {
line-height: 1.3;
}
.media-card-detail.person-list {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
row-gap: 1px;
column-gap: 0.5ch;
-webkit-line-clamp: unset;
-webkit-box-orient: unset;
max-height: calc(1.3em * 2);
}
.person-token {
white-space: nowrap;
}
.director-name {
font-weight: 600;
color: var(--text-secondary);
}
/* Search match styles */
.match-reason {
color: var(--text-secondary);
}
@@ -264,7 +345,6 @@ const matchedPeople = computed(() => {
font-weight: 400;
}
/* When character name matched - highlight the role, dim the name */
.match-dim {
color: var(--text-muted);
font-weight: 400;
File diff suppressed because it is too large Load Diff
+18 -8
View File
@@ -3,6 +3,7 @@
class="media-row"
:class="{ 'media-row-wrap': wrap }"
:data-sync-scroll-row="!wrap && rowIndex !== undefined ? 'true' : undefined"
:data-sync-scroll-group="!wrap && rowIndex !== undefined ? 'browse' : undefined"
>
<MediaCard
v-for="(item, index) in items"
@@ -10,22 +11,31 @@
:item="item"
:nav-row="rowIndex"
:nav-col="index"
:href="getItemHref(item)"
@click="$emit('select', item)"
/>
</div>
</template>
<script setup lang="ts">
import type { MediaItem } from '../types';
import MediaCard from './MediaCard.vue';
import type { MediaItem, EpisodeWithSeries } from "../types"
import MediaCard from "./MediaCard.vue"
defineProps<{
items: MediaItem[];
wrap?: boolean;
rowIndex?: number;
}>();
items: MediaItem[]
wrap?: boolean
rowIndex?: number
}>()
defineEmits<{
select: [MediaItem];
}>();
select: [MediaItem]
}>()
function getItemHref(item: MediaItem): string | undefined {
if (item.type === "episode") {
const epData = item.data as EpisodeWithSeries
return `/series/${epData.series.id}`
}
return `/${item.type}/${item.id}`
}
</script>
@@ -0,0 +1,232 @@
<template>
<div
v-if="visible"
ref="menuRef"
class="version-action-menu"
:style="menuStyle"
tabindex="-1"
@keydown="handleKeydown"
>
<div class="version-action-path" :title="resolvedPath">
{{ resolvedPath }}
</div>
<button class="version-action-item" :disabled="disabled" @click="emit('play')">
<span class="version-action-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" focusable="false">
<path
d="M4 3.2c0-.54.6-.86 1.05-.56l6.2 4.14a.67.67 0 0 1 0 1.12l-6.2 4.14A.67.67 0 0 1 4 11.44V3.2Z"
/>
</svg>
</span>
{{ playLabel }}
</button>
<button class="version-action-item" :disabled="disabled" @click="emit('openFolder')">
<span class="version-action-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" focusable="false">
<path
d="M1.4 4.3c0-.72.58-1.3 1.3-1.3h3.55c.3 0 .58.13.77.35l.72.85h5.56c.72 0 1.3.58 1.3 1.3v.92H1.4V4.3Zm0 3.22h13.2v4.2c0 .72-.58 1.3-1.3 1.3H2.7c-.72 0-1.3-.58-1.3-1.3v-4.2Z"
/>
</svg>
</span>
Open Folder
</button>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
const props = withDefaults(
defineProps<{
visible: boolean
x: number
y: number
filePath: string | null
rootName?: string | null
playLabel?: string
}>(),
{
rootName: null,
playLabel: "Play",
},
)
const emit = defineEmits<{
play: []
openFolder: []
close: []
}>()
const menuRef = ref<HTMLElement | null>(null)
const menuLeft = ref(0)
const menuTop = ref(0)
const VIEWPORT_MARGIN = 12
function toPosixPath(value: string | null | undefined): string {
return (value || "").replace(/\\/g, "/")
}
const resolvedPath = computed(() => {
if (!props.filePath) return "No playable file"
const normalizedFilePath = toPosixPath(props.filePath)
const rootName = toPosixPath((props.rootName || "").trim())
if (!rootName) return normalizedFilePath
return `${rootName}/${normalizedFilePath}`
})
const menuStyle = computed(() => ({
left: `${menuLeft.value}px`,
top: `${menuTop.value}px`,
}))
const disabled = computed(() => !props.filePath)
function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return []
return Array.from(
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)")
)
}
function focusNext(delta: number) {
const elements = getFocusableElements()
if (elements.length === 0) return
const currentIndex = elements.findIndex((el) => el === document.activeElement)
const nextIndex =
currentIndex < 0 ? 0 : (currentIndex + delta + elements.length) % elements.length
elements[nextIndex].focus()
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Tab") {
event.preventDefault()
focusNext(event.shiftKey ? -1 : 1)
return
}
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
event.preventDefault()
focusNext(1)
return
}
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
event.preventDefault()
focusNext(-1)
return
}
if (event.key === "Escape") {
event.preventDefault()
emit("close")
}
}
function clampToViewport() {
const menu = menuRef.value
if (!menu) return
const width = menu.offsetWidth
const height = menu.offsetHeight
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN)
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft)
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop)
}
function handleViewportChange() {
if (!props.visible) return
clampToViewport()
}
watch(
() => [props.visible, props.x, props.y, resolvedPath.value],
async ([visible]) => {
if (!visible) return
await nextTick()
clampToViewport()
},
{ immediate: true },
)
watch(
() => props.visible,
(visible) => {
if (visible) {
window.addEventListener("resize", handleViewportChange)
return
}
window.removeEventListener("resize", handleViewportChange)
},
{ immediate: true },
)
onBeforeUnmount(() => {
window.removeEventListener("resize", handleViewportChange)
})
</script>
<style scoped>
.version-action-menu {
position: fixed;
z-index: 1001;
min-width: 260px;
max-width: min(680px, calc(100vw - 24px));
background: rgba(18, 20, 28, 0.98);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
overflow: hidden;
}
.version-action-path {
padding: 8px 12px;
font-size: 0.74rem;
line-height: 1.35;
color: rgba(255, 255, 255, 0.78);
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
word-break: break-all;
white-space: normal;
}
.version-action-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #fff;
text-align: left;
padding: 10px 12px;
font-size: 0.82rem;
cursor: pointer;
}
html.mouse-active .version-action-item:hover:not(:disabled),
.version-action-item:focus-visible:not(:disabled) {
background: rgba(255, 255, 255, 0.12);
outline: none;
}
.version-action-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.version-action-icon {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.85);
flex: 0 0 16px;
}
.version-action-icon svg {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>
@@ -0,0 +1,654 @@
<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"
: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
+262 -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 GAMEPAD_REPEAT_MS = 180;
const ANALOG_DEADZONE = 0.25
const DIGITAL_INITIAL_REPEAT_DELAY_MS = 150
const DIGITAL_REPEAT_START_MS = 90
const DIGITAL_REPEAT_MIN_MS = 16
const DIGITAL_ACCEL_RAMP_MS = 3500
const ANALOG_REPEAT_MAX_MS = 180
const ANALOG_REPEAT_MIN_MS = 16
const IDLE_POLL_MS = 500
const KEY_BY_ACTION: Record<GamepadAction, string> = {
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
right: 'ArrowRight',
select: 'Enter',
back: 'Escape',
};
const KEY_BY_ACTION: Partial<Record<GamepadAction, string>> = {
up: "ArrowUp",
down: "ArrowDown",
left: "ArrowLeft",
right: "ArrowRight",
select: "Enter",
back: "Escape",
}
const gamepadPressedState: Record<GamepadAction, boolean> = {
up: false,
@@ -19,112 +26,287 @@ const gamepadPressedState: Record<GamepadAction, boolean> = {
right: false,
select: false,
back: false,
};
menu: false,
}
const gamepadLastTriggerAt: Record<GamepadAction, number> = {
const rawButtonPressedState: Record<number, boolean> = {
2: false,
3: false,
4: false,
5: false,
}
const analogLastTriggerAt: Record<DirectionAction, number> = {
up: 0,
down: 0,
left: 0,
right: 0,
select: 0,
back: 0,
};
}
let gamepadFrameId: number | null = null;
let gamepadInstalled = false;
let digitalHoldStartedAt = 0
let digitalLastRepeatAt = 0
let digitalRepeatCount = 0
let gamepadFrameId: number | null = null
let idleTimerId: number | null = null
let gamepadInstalled = false
function dispatchKey(key: string) {
const active = document.activeElement;
const target = active instanceof HTMLElement ? active : document;
target.dispatchEvent(new KeyboardEvent('keydown', {
const active = document.activeElement
const target = active instanceof HTMLElement ? active : document
target.dispatchEvent(
new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
}));
}),
)
}
function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: number) {
const wasPressed = gamepadPressedState[action];
gamepadPressedState[action] = isPressed;
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', {
function dispatchGamepadAction(action: GamepadAction): void {
const actionEvent = new CustomEvent("mediahive:gamepad-action", {
detail: { action },
cancelable: true,
});
const shouldContinueWithKeyboard = window.dispatchEvent(actionEvent);
if (!shouldContinueWithKeyboard) return;
})
const shouldContinueWithKeyboard = window.dispatchEvent(actionEvent)
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() {
gamepadPressedState.up = false;
gamepadPressedState.down = false;
gamepadPressedState.left = false;
gamepadPressedState.right = false;
gamepadPressedState.select = false;
gamepadPressedState.back = false;
gamepadPressedState.up = false
gamepadPressedState.down = false
gamepadPressedState.left = false
gamepadPressedState.right = false
gamepadPressedState.select = false
gamepadPressedState.back = false
gamepadPressedState.menu = false
rawButtonPressedState[2] = false
rawButtonPressedState[3] = false
rawButtonPressedState[4] = false
rawButtonPressedState[5] = false
analogLastTriggerAt.up = 0
analogLastTriggerAt.down = 0
analogLastTriggerAt.left = 0
analogLastTriggerAt.right = 0
digitalHoldStartedAt = 0
digitalLastRepeatAt = 0
digitalRepeatCount = 0
}
function stopPolling() {
if (gamepadFrameId !== null) {
window.cancelAnimationFrame(gamepadFrameId)
gamepadFrameId = null
}
if (idleTimerId !== null) {
window.clearTimeout(idleTimerId)
idleTimerId = null
}
}
function scheduleIdlePoll() {
if (idleTimerId !== null) return
idleTimerId = window.setTimeout(() => {
idleTimerId = null
if (gamepadInstalled) {
pollGamepad()
}
}, IDLE_POLL_MS)
}
function pollGamepad() {
const gamepads = navigator.getGamepads?.() ?? [];
const now = performance.now();
const connectedGamepads = gamepads.filter((gp): gp is Gamepad => Boolean(gp && gp.connected));
const gamepads = navigator.getGamepads?.() ?? []
const now = performance.now()
const connectedGamepads = gamepads.filter((gp): gp is Gamepad => Boolean(gp && gp.connected))
if (connectedGamepads.length > 0) {
let up = false;
let down = false;
let left = false;
let right = false;
let select = false;
let back = false;
let digitalUp = false
let digitalDown = false
let digitalLeft = false
let digitalRight = false
let analogUpIntensity = 0
let analogDownIntensity = 0
let analogLeftIntensity = 0
let analogRightIntensity = 0
let select = false
let back = false
let menu = false
let raw2 = false
let raw3 = false
let raw4 = false
let raw5 = false
for (const gamepad of connectedGamepads) {
const axisX = gamepad.axes[0] ?? 0;
const axisY = gamepad.axes[1] ?? 0;
const axisX = gamepad.axes[0] ?? 0
const axisY = gamepad.axes[1] ?? 0
up = up || Boolean(gamepad.buttons[12]?.pressed) || axisY <= -GAMEPAD_AXIS_THRESHOLD;
down = down || Boolean(gamepad.buttons[13]?.pressed) || axisY >= GAMEPAD_AXIS_THRESHOLD;
left = left || Boolean(gamepad.buttons[14]?.pressed) || axisX <= -GAMEPAD_AXIS_THRESHOLD;
right = right || Boolean(gamepad.buttons[15]?.pressed) || axisX >= GAMEPAD_AXIS_THRESHOLD;
digitalUp = digitalUp || Boolean(gamepad.buttons[12]?.pressed)
digitalDown = digitalDown || Boolean(gamepad.buttons[13]?.pressed)
digitalLeft = digitalLeft || Boolean(gamepad.buttons[14]?.pressed)
digitalRight = digitalRight || Boolean(gamepad.buttons[15]?.pressed)
// Xbox mapping on standard gamepads: A=0, B=1
select = select || Boolean(gamepad.buttons[0]?.pressed);
back = back || Boolean(gamepad.buttons[1]?.pressed);
const upIntensity = normalizeAxisIntensity(-axisY)
const downIntensity = normalizeAxisIntensity(axisY)
const leftIntensity = normalizeAxisIntensity(-axisX)
const rightIntensity = normalizeAxisIntensity(axisX)
if (upIntensity > analogUpIntensity) analogUpIntensity = upIntensity
if (downIntensity > analogDownIntensity) analogDownIntensity = downIntensity
if (leftIntensity > analogLeftIntensity) analogLeftIntensity = leftIntensity
if (rightIntensity > analogRightIntensity) analogRightIntensity = rightIntensity
// Xbox mapping on standard gamepads: A=0, B=1, X=2, Y=3
select = select || Boolean(gamepad.buttons[0]?.pressed)
back = back || Boolean(gamepad.buttons[1]?.pressed)
menu = menu || Boolean(gamepad.buttons[3]?.pressed)
raw2 = raw2 || Boolean(gamepad.buttons[2]?.pressed)
raw3 = raw3 || Boolean(gamepad.buttons[3]?.pressed)
raw4 = raw4 || Boolean(gamepad.buttons[4]?.pressed)
raw5 = raw5 || Boolean(gamepad.buttons[5]?.pressed)
}
applyGamepadAction('up', up, now);
applyGamepadAction('down', down, now);
applyGamepadAction('left', left, now);
applyGamepadAction('right', right, now);
applyGamepadAction('select', select, now);
applyGamepadAction('back', back, now);
triggerSinglePressAction("up", digitalUp)
triggerSinglePressAction("down", digitalDown)
triggerSinglePressAction("left", digitalLeft)
triggerSinglePressAction("right", digitalRight)
triggerSinglePressAction("select", select)
triggerSinglePressAction("back", back)
triggerSinglePressAction("menu", menu)
triggerSinglePressRawButton(2, raw2)
triggerSinglePressRawButton(3, raw3)
triggerSinglePressRawButton(4, raw4)
triggerSinglePressRawButton(5, raw5)
const hasDigitalRepeatable =
digitalUp || digitalDown || digitalLeft || digitalRight || raw2 || raw3 || raw4 || raw5
if (!hasDigitalRepeatable) {
digitalHoldStartedAt = 0
digitalLastRepeatAt = 0
digitalRepeatCount = 0
} else {
resetPressedState();
if (digitalHoldStartedAt === 0) {
digitalHoldStartedAt = now
digitalLastRepeatAt = now
digitalRepeatCount = 0
}
gamepadFrameId = window.requestAnimationFrame(pollGamepad);
const holdMs = now - digitalHoldStartedAt
const repeatMs =
digitalRepeatCount === 0
? DIGITAL_INITIAL_REPEAT_DELAY_MS
: getDigitalRepeatIntervalMs(holdMs)
if (now - digitalLastRepeatAt >= repeatMs) {
if (digitalUp) dispatchGamepadAction("up")
if (digitalDown) dispatchGamepadAction("down")
if (digitalLeft) dispatchGamepadAction("left")
if (digitalRight) dispatchGamepadAction("right")
if (raw2) dispatchRawButton(2)
if (raw3) dispatchRawButton(3)
if (raw4) dispatchRawButton(4)
if (raw5) dispatchRawButton(5)
digitalLastRepeatAt = now
digitalRepeatCount += 1
}
}
// Analog repeat has no initial delay and speed increases with stick deflection.
// If digital d-pad direction is held, it owns that direction to avoid duplicate repeats.
applyAnalogDirection("up", digitalUp ? 0 : analogUpIntensity, now)
applyAnalogDirection("down", digitalDown ? 0 : analogDownIntensity, now)
applyAnalogDirection("left", digitalLeft ? 0 : analogLeftIntensity, now)
applyAnalogDirection("right", digitalRight ? 0 : analogRightIntensity, now)
// Keep using rAF while gamepads are active for responsive input
gamepadFrameId = window.requestAnimationFrame(pollGamepad)
} else {
resetPressedState()
// No gamepads connected — drop to slow polling to save CPU
scheduleIdlePoll()
}
}
function handleGamepadConnected() {
if (!gamepadInstalled) return
// A gamepad was plugged in; make sure we're polling
stopPolling()
gamepadFrameId = window.requestAnimationFrame(pollGamepad)
}
export function installGamepadNavigation() {
if (gamepadInstalled) return;
gamepadInstalled = true;
gamepadFrameId = window.requestAnimationFrame(pollGamepad);
if (gamepadInstalled) return
gamepadInstalled = true
gamepadFrameId = window.requestAnimationFrame(pollGamepad)
window.addEventListener("gamepadconnected", handleGamepadConnected)
}
export function uninstallGamepadNavigation() {
if (!gamepadInstalled) return;
gamepadInstalled = false;
if (gamepadFrameId !== null) {
window.cancelAnimationFrame(gamepadFrameId);
gamepadFrameId = null;
}
resetPressedState();
if (!gamepadInstalled) return
gamepadInstalled = false
stopPolling()
resetPressedState()
window.removeEventListener("gamepadconnected", handleGamepadConnected)
}
@@ -0,0 +1,146 @@
type InputModality = "mouse" | "keyboard" | "gamepad"
const MOUSE_IDLE_MS = 1400
const MOUSE_INTENT_DISTANCE_PX = 28
const MOUSE_INTENT_WINDOW_MS = 700
const MOUSE_INTENT_SELECTOR = [
"[data-nav-focusable]",
"button",
"a[href]",
"input",
"select",
"textarea",
'[role="button"]',
".media-card",
".collage-item",
".episode-tile",
".version-row",
".ctx-btn",
".header-nav-item",
].join(",")
let installed = false
let modality: InputModality = "mouse"
let mouseIdleTimer: number | null = null
let pointerVisible = false
let mouseTravelPx = 0
let lastMouseMoveAt = 0
function clearMouseIdleTimer() {
if (mouseIdleTimer !== null) {
window.clearTimeout(mouseIdleTimer)
mouseIdleTimer = null
}
}
function applyInputState(mouseActive: boolean) {
const root = document.documentElement
root.classList.toggle("mouse-active", mouseActive)
root.classList.toggle("pointer-visible", pointerVisible)
}
function scheduleMouseIdle() {
clearMouseIdleTimer()
mouseIdleTimer = window.setTimeout(() => {
pointerVisible = false
applyInputState(false)
}, MOUSE_IDLE_MS)
}
function activateMouseInput() {
modality = "mouse"
pointerVisible = true
applyInputState(true)
scheduleMouseIdle()
}
function activateNonMouseInput(next: InputModality) {
modality = next
pointerVisible = false
mouseTravelPx = 0
clearMouseIdleTimer()
applyInputState(false)
}
function showPointerFromMotion() {
pointerVisible = true
applyInputState(modality === "mouse")
scheduleMouseIdle()
}
function isMouseIntentTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false
return Boolean(target.closest(MOUSE_INTENT_SELECTOR))
}
function registerMouseIntentTravel(event: MouseEvent): boolean {
const now = performance.now()
if (now - lastMouseMoveAt > MOUSE_INTENT_WINDOW_MS) {
mouseTravelPx = 0
}
lastMouseMoveAt = now
const step = Math.hypot(event.movementX || 0, event.movementY || 0)
mouseTravelPx += step
if (mouseTravelPx >= MOUSE_INTENT_DISTANCE_PX) {
mouseTravelPx = 0
return true
}
return false
}
function handleMouseMove(event: MouseEvent) {
showPointerFromMotion()
if (modality === "mouse") {
applyInputState(true)
return
}
if (!isMouseIntentTarget(event.target)) return
if (registerMouseIntentTravel(event)) {
activateMouseInput()
}
}
function handleMouseOver(event: MouseEvent) {
if (modality === "mouse") return
if (!isMouseIntentTarget(event.target)) return
// Entering an interactive target indicates likely mouse intent.
activateMouseInput()
}
function handleMouseIntentAction(event: MouseEvent | WheelEvent) {
pointerVisible = true
if (isMouseIntentTarget(event.target)) {
activateMouseInput()
return
}
applyInputState(modality === "mouse")
scheduleMouseIdle()
}
function handleKeyboardActivity(event: KeyboardEvent) {
if (event.metaKey || event.ctrlKey || event.altKey) return
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
+604 -138
View File
@@ -1,179 +1,644 @@
import { ref, readonly, onUnmounted } from 'vue';
import type { Movie, Series, MediaIndex, TaskInfo, WsMessage } from '../types';
import { shallowRef, readonly, onUnmounted } from "vue"
import type {
CastGender,
CastMember,
Movie,
MovieUi,
Person,
Series,
SeriesUi,
Episode,
Season,
Torrent,
MediaIndex,
TaskInfo,
WsMessage,
WsRootStatus,
} from "../types"
interface RootState {
movieMap: Map<string, MovieUi>
seriesMap: Map<string, SeriesUi>
peopleMap: Map<number, Person>
initialized: boolean
pendingMessages: WsMessage[]
}
export interface RootStatusEntry extends WsRootStatus {}
const MERGED_KEY_DELIMITER = "::"
/**
* Composable that connects to the MediaHive WebSocket and keeps
* the media index updated in real time.
*
* The server sends:
* - "init" → full index (movies + series) on connect
* - "upsert" → single item inserted or updated
* - "remove" → single item removed
* - "task" → background task progress
*
* Messages are msgspec-encoded binary JSON with a "type" tag field.
* Composable that connects to one all-roots MediaHive WebSocket and keeps
* a merged media index updated in real time.
*/
export function useMediaWebSocket() {
const mediaIndex = ref<MediaIndex | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const connected = ref(false);
const tasks = ref<Map<string, TaskInfo>>(new Map());
type RootTaskInfo = TaskInfo & { root_id: string }
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let disposed = false;
const mediaIndex = shallowRef<MediaIndex | null>(null)
const loading = shallowRef(true)
const error = shallowRef<string | null>(null)
const connected = shallowRef(false)
const tasks = shallowRef<Map<string, RootTaskInfo>>(new Map())
const roots = shallowRef<Map<string, RootStatusEntry>>(new Map())
// Lookup maps for fast upsert / remove
const movieMap = new Map<string, Movie>();
const seriesMap = new Map<string, Series>();
const rootStates = shallowRef<Map<string, RootState>>(new Map())
const wsRef = shallowRef<WebSocket | null>(null)
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let disposed = false
// Single periodic sweep for completed tasks instead of one timeout per task
const completedTaskIds = new Set<string>()
let taskSweepTimer: ReturnType<typeof setInterval> | null = null
function startTaskSweep() {
if (taskSweepTimer !== null) return
taskSweepTimer = setInterval(() => {
if (completedTaskIds.size === 0) return
const next = new Map(tasks.value)
let changed = false
for (const id of completedTaskIds) {
if (next.delete(id)) changed = true
}
completedTaskIds.clear()
if (changed) {
tasks.value = next
}
}, 3000)
}
function stopTaskSweep() {
if (taskSweepTimer !== null) {
clearInterval(taskSweepTimer)
taskSweepTimer = null
}
}
onUnmounted(stopTaskSweep)
function getContentHash(itemId: string): string {
return itemId.split(":").pop() || itemId
}
function torrentQualityScore(t: Torrent): number {
let score = 0
const res = (t.resolution || "").toLowerCase()
if (res.includes("2160") || res.includes("4k") || res.includes("uhd")) score += 100
else if (res.includes("1080") || res.includes("fhd")) score += 80
else if (res.includes("720") || res === "hd") score += 60
else if (res.includes("480") || res === "sd") score += 40
else if (res.includes("360")) score += 20
if (t.dovi) score += 15
if (t.hdr) score += 10
if (t.atmos) score += 5
return score
}
function expandPlayablePath(fileKey: string, playableFile: string | null): string | null {
if (!playableFile) return fileKey
if (playableFile.startsWith("concat:") || playableFile.includes("://")) return playableFile
if (playableFile.startsWith(`${fileKey}/`)) return playableFile
if (playableFile.startsWith("/")) return playableFile.replace(/^\/+/, "")
return `${fileKey}/${playableFile}`
}
function annotateFiles(
files: { [key: string]: Torrent },
rootId: string | null,
): { [key: string]: Torrent } {
return Object.fromEntries(
Object.entries(files || {}).map(([k, t]) => [
k,
{
...t,
playable_file: expandPlayablePath(resolveFilePathKey(k), t.playable_file || null),
root_id: t.root_id || rootId,
},
]),
)
}
function resolveFilePathKey(key: string): string {
const delimIndex = key.indexOf(MERGED_KEY_DELIMITER)
if (delimIndex >= 0) {
return key.slice(delimIndex + MERGED_KEY_DELIMITER.length)
}
return key
}
function mergeTorrentDicts(
a: { [key: string]: Torrent },
b: { [key: string]: Torrent },
): { [key: string]: Torrent } {
const merged: { [key: string]: Torrent } = { ...a }
for (const [k, t] of Object.entries(b)) {
const uniqueKey = merged[k] ? `${t.root_id || "unknown"}${MERGED_KEY_DELIMITER}${k}` : k
merged[uniqueKey] = t
}
const sorted = Object.entries(merged).sort(([, t1], [, t2]) => {
const s1 = torrentQualityScore(t1)
const s2 = torrentQualityScore(t2)
if (s2 !== s1) return s2 - s1
return (t2.size || 0) - (t1.size || 0)
})
return Object.fromEntries(sorted)
}
function withMovieIdentity(
id: string,
movie: Movie,
rootId: string,
people: Map<number, Person>,
): MovieUi {
return { ...normalizeMovie(movie, rootId, people), id, root_id: rootId }
}
function withSeriesIdentity(
id: string,
series: Series,
rootId: string,
people: Map<number, Person>,
): SeriesUi {
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
}
function normalizeCastMember(
member: unknown,
people: Map<number, Person>,
): CastMember {
if (!Array.isArray(member)) {
return {
name: "",
character: null,
profile_path: null,
gender: null,
id: null,
}
}
// Current wire format: CastCredit(array_like=True) => [character, id]
const character = typeof member[0] === "string" ? member[0] : null
const id = typeof member[1] === "number" ? member[1] : null
const person = id !== null ? people.get(id) || null : null
return {
name: person?.name || "",
character,
profile_path: person?.profile_path || null,
gender: person?.gender ?? null,
id,
}
}
function normalizePerson(member: unknown): Person | null {
if (!Array.isArray(member)) return null
const gender = normalizeCastGender(member[2])
return {
name: typeof member[0] === "string" ? member[0] : "",
profile_path: typeof member[1] === "string" ? member[1] : null,
gender,
}
}
function normalizeCastGender(value: unknown): CastGender | null {
if (typeof value !== "string") return null
switch (value) {
case "female":
case "male":
case "non_binary":
case "unknown":
return value
default:
return null
}
}
function normalizeInfo<T extends { cast?: unknown }>(
info: T | null,
people: Map<number, Person>,
): T | null {
if (!info) return info
let next: T = info
if (Array.isArray((info as { cast?: unknown }).cast)) {
const cast = ((info as { cast?: unknown[] }).cast || [])
.map((member) => normalizeCastMember(member, people))
.filter((member) => member.name.length > 0)
next = { ...next, cast } as T
}
return next
}
function normalizeMovie(movie: Movie, rootId: string | null, people: Map<number, Person>): Movie {
return {
...movie,
files: annotateFiles(movie.files, rootId),
info: normalizeInfo(movie.info, people),
}
}
function normalizeSeries(
series: Series,
rootId: string | null,
people: Map<number, Person>,
): Series {
return {
...series,
seasons: (series.seasons || []).map((season) => ({
...season,
episodes: (season.episodes || []).map((episode) => ({
...episode,
files: annotateFiles(episode.files, rootId),
})),
})),
info: normalizeInfo(series.info, people),
}
}
function mergeMovies(a: MovieUi, b: MovieUi): MovieUi {
const filesA = annotateFiles(a.files, a.root_id)
const filesB = annotateFiles(b.files, b.root_id)
return {
...a,
id: getContentHash(a.id),
files: mergeTorrentDicts(filesA, filesB),
info: a.info || b.info,
cover_path: a.cover_path || b.cover_path,
backdrop_path: a.backdrop_path || b.backdrop_path,
showreel_images: a.showreel_images?.length ? a.showreel_images : b.showreel_images,
showreel_source_sets: a.showreel_source_sets?.length
? a.showreel_source_sets
: b.showreel_source_sets,
}
}
function mergeEpisodes(
a: Episode,
b: Episode,
rootIdA: string | null,
rootIdB: string | null,
): Episode {
const filesA = annotateFiles(a.files, rootIdA)
const filesB = annotateFiles(b.files, rootIdB)
return {
...a,
files: mergeTorrentDicts(filesA, filesB),
reel_image: a.reel_image || b.reel_image,
reel_sources: a.reel_sources?.length ? a.reel_sources : b.reel_sources,
}
}
function mergeSeasons(
a: Season,
b: Season,
rootIdA: string | null,
rootIdB: string | null,
): Season {
const episodeMap = new Map<number, Episode>()
for (const ep of a.episodes) {
episodeMap.set(ep.episode_number, ep)
}
for (const ep of b.episodes) {
const existing = episodeMap.get(ep.episode_number)
if (existing) {
episodeMap.set(ep.episode_number, mergeEpisodes(existing, ep, rootIdA, rootIdB))
} else {
episodeMap.set(ep.episode_number, {
...ep,
files: annotateFiles(ep.files, rootIdB),
})
}
}
return {
...a,
episodes: Array.from(episodeMap.values()).sort((a, b) => a.episode_number - b.episode_number),
poster_path: a.poster_path || b.poster_path,
}
}
function mergeSeries(a: SeriesUi, b: SeriesUi): SeriesUi {
const seasonMap = new Map<number, Season>()
for (const season of a.seasons || []) {
seasonMap.set(season.season_number, {
...season,
episodes: season.episodes.map((ep) => ({
...ep,
files: annotateFiles(ep.files, a.root_id),
})),
})
}
for (const season of b.seasons || []) {
const existing = seasonMap.get(season.season_number)
if (existing) {
seasonMap.set(season.season_number, mergeSeasons(existing, season, a.root_id, b.root_id))
} else {
seasonMap.set(season.season_number, {
...season,
episodes: season.episodes.map((ep) => ({
...ep,
files: annotateFiles(ep.files, b.root_id),
})),
})
}
}
return {
...a,
id: getContentHash(a.id),
seasons: Array.from(seasonMap.values()).sort((a, b) => a.season_number - b.season_number),
info: a.info || b.info,
cover_path: a.cover_path || b.cover_path,
backdrop_path: a.backdrop_path || b.backdrop_path,
}
}
function mergeItemsByHash<T extends MovieUi | SeriesUi>(items: T[], mergeFn: (a: T, b: T) => T): T[] {
const map = new Map<string, T[]>()
for (const item of items) {
const hash = getContentHash(item.id)
const arr = map.get(hash) || []
arr.push(item)
map.set(hash, arr)
}
const merged: T[] = []
for (const [, group] of map) {
if (group.length === 1) {
merged.push(group[0])
} else {
let result = group[0]
for (let i = 1; i < group.length; i++) {
result = mergeFn(result, group[i])
}
merged.push(result)
}
}
return merged
}
function ensureRootState(rootId: string): RootState {
const existing = rootStates.value.get(rootId)
if (existing) {
return existing
}
const created: RootState = {
movieMap: new Map(),
seriesMap: new Map(),
peopleMap: new Map(),
initialized: false,
pendingMessages: [],
}
rootStates.value.set(rootId, created)
return created
}
function pruneMissingRoots(nextRoots: Map<string, RootStatusEntry>) {
for (const rootId of rootStates.value.keys()) {
if (!nextRoots.has(rootId)) {
rootStates.value.delete(rootId)
}
}
for (const [taskKey, task] of tasks.value.entries()) {
if (!nextRoots.has(task.root_id)) {
tasks.value.delete(taskKey)
}
}
tasks.value = new Map(tasks.value)
}
function buildIndex(): MediaIndex {
const movies = Array.from(movieMap.values());
const series = Array.from(seriesMap.values());
const movies: MovieUi[] = []
const series: SeriesUi[] = []
for (const state of rootStates.value.values()) {
movies.push(...state.movieMap.values())
series.push(...state.seriesMap.values())
}
const mergedMovies = mergeItemsByHash(movies, mergeMovies)
const mergedSeries = mergeItemsByHash(series, mergeSeries)
return {
version: 0,
v: 1,
generated_at: new Date().toISOString(),
stats: {
total_movies: movies.length,
total_series: series.length,
},
movies,
series,
};
}
function handleMessage(event: MessageEvent) {
try {
// Server sends binary frames (msgspec json bytes)
let text: string;
if (event.data instanceof Blob) {
// Will be handled by the blob reader below
event.data.text().then((t) => processJson(t));
return;
} else if (event.data instanceof ArrayBuffer) {
text = new TextDecoder().decode(event.data);
} else {
text = event.data as string;
}
processJson(text);
} catch (e) {
console.error('[WS] Failed to handle message:', e);
movies: mergedMovies,
series: mergedSeries,
}
}
function processJson(text: string) {
const msg = JSON.parse(text) as WsMessage;
function updateMergedState() {
mediaIndex.value = buildIndex()
let anyInitialized = false
for (const state of rootStates.value.values()) {
if (state.initialized) {
anyInitialized = true
break
}
}
if (anyInitialized || roots.value.size === 0) {
loading.value = false
error.value = null
}
connected.value = wsRef.value?.readyState === WebSocket.OPEN
}
function applyRootInit(rootId: string, rootData: { movies: Record<string, Movie>; series: Record<string, Series>; people?: Record<string, unknown> }) {
const state = ensureRootState(rootId)
state.peopleMap.clear()
for (const [id, person] of Object.entries(rootData.people || {})) {
const parsed = Number(id)
const normalized = normalizePerson(person)
if (Number.isFinite(parsed)) {
state.peopleMap.set(parsed, normalized || { name: "", profile_path: null, gender: null })
}
}
state.movieMap.clear()
state.seriesMap.clear()
for (const [id, m] of Object.entries(rootData.movies || {})) {
state.movieMap.set(id, withMovieIdentity(id, m, rootId, state.peopleMap))
}
for (const [id, s] of Object.entries(rootData.series || {})) {
state.seriesMap.set(id, withSeriesIdentity(id, s, rootId, state.peopleMap))
}
state.initialized = true
if (state.pendingMessages.length > 0) {
const queued = state.pendingMessages
state.pendingMessages = []
for (const queuedMsg of queued) {
processMessage(queuedMsg)
}
}
}
function processMessage(msg: WsMessage) {
switch (msg.type) {
case 'init': {
movieMap.clear();
seriesMap.clear();
for (const m of msg.data.movies) movieMap.set(m.id, m);
for (const s of msg.data.series) seriesMap.set(s.id, s);
mediaIndex.value = buildIndex();
loading.value = false;
error.value = null;
console.log(`[WS] init: ${movieMap.size} movies, ${seriesMap.size} series`);
break;
case "roots": {
const next = new Map<string, RootStatusEntry>()
for (const root of msg.roots || []) {
next.set(root.root_id, { ...root })
ensureRootState(root.root_id)
}
case 'upsert': {
if (msg.kind === 'movie') {
movieMap.set(msg.item.id, msg.item as Movie);
roots.value = next
pruneMissingRoots(next)
updateMergedState()
return
}
case "init": {
for (const [rootId, rootData] of Object.entries(msg.roots || {})) {
applyRootInit(rootId, rootData)
}
updateMergedState()
return
}
case "upsert": {
const state = ensureRootState(msg.root_id)
if (!state.initialized) {
state.pendingMessages.push(msg)
return
}
if (msg.people) {
for (const [id, person] of Object.entries(msg.people)) {
const parsed = Number(id)
const normalized = normalizePerson(person)
if (Number.isFinite(parsed)) {
state.peopleMap.set(parsed, normalized || { name: "", profile_path: null, gender: null })
}
}
}
if (msg.kind === "movie") {
state.movieMap.set(
msg.id,
withMovieIdentity(msg.id, msg.item as Movie, msg.root_id, state.peopleMap),
)
} else {
seriesMap.set(msg.item.id, msg.item as Series);
state.seriesMap.set(
msg.id,
withSeriesIdentity(msg.id, msg.item as Series, msg.root_id, state.peopleMap),
)
}
// Rebuild the index ref so Vue detects the change
mediaIndex.value = buildIndex();
break;
updateMergedState()
return
}
case 'remove': {
if (msg.kind === 'movie') {
movieMap.delete(msg.id);
case "remove": {
const state = ensureRootState(msg.root_id)
if (!state.initialized) {
state.pendingMessages.push(msg)
return
}
if (msg.kind === "movie") {
state.movieMap.delete(msg.id)
} else {
seriesMap.delete(msg.id);
state.seriesMap.delete(msg.id)
}
mediaIndex.value = buildIndex();
break;
updateMergedState()
return
}
case 'task': {
const info = msg.data;
if (info.status === 'completed' || info.status === 'cancelled' || info.status === 'error') {
// Keep finished tasks briefly so the UI can show completion
tasks.value.set(info.id, info);
setTimeout(() => {
tasks.value.delete(info.id);
tasks.value = new Map(tasks.value);
}, 3000);
} else {
tasks.value.set(info.id, info);
case "task": {
const info = msg.data
const taskKey = `${msg.root_id}:${info.id}`
tasks.value.set(taskKey, { ...info, root_id: msg.root_id })
tasks.value = new Map(tasks.value)
if (info.status === "completed" || info.status === "cancelled" || info.status === "error") {
completedTaskIds.add(taskKey)
startTaskSweep()
}
// Trigger reactivity
tasks.value = new Map(tasks.value);
break;
return
}
}
}
function connect() {
if (disposed) return;
// Build WS URL relative to current page
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/ws`;
console.log(`[WS] Connecting to ${url}...`);
ws = new WebSocket(url);
ws.onopen = () => {
connected.value = true;
error.value = null;
console.log('[WS] Connected');
};
ws.onmessage = handleMessage;
ws.onclose = (ev) => {
connected.value = false;
console.log(`[WS] Closed (code=${ev.code})`);
scheduleReconnect();
};
ws.onerror = (ev) => {
console.error('[WS] Error:', ev);
if (!mediaIndex.value) {
error.value = 'WebSocket connection failed';
function handleRawMessage(event: MessageEvent) {
const processText = (text: string) => {
try {
processMessage(JSON.parse(text) as WsMessage)
} catch (e) {
console.error("[WS] Failed to handle message:", e)
}
};
}
if (event.data instanceof Blob) {
void event.data.text().then(processText)
return
}
if (event.data instanceof ArrayBuffer) {
processText(new TextDecoder().decode(event.data))
return
}
processText(event.data as string)
}
function scheduleReconnect() {
if (disposed) return;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (disposed) return
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
console.log('[WS] Reconnecting...');
connect();
}, 2000);
reconnectTimer = null
connect()
}, 2000)
}
function connect() {
if (disposed) return
if (wsRef.value && wsRef.value.readyState <= WebSocket.OPEN) return
const proto = location.protocol === "https:" ? "wss:" : "ws:"
const url = `${proto}//${location.host}/api/ws`
console.log(`[WS] Connecting to ${url}...`)
const ws = new WebSocket(url)
wsRef.value = ws
ws.onopen = () => {
connected.value = true
error.value = null
console.log("[WS] Connected")
}
ws.onmessage = (ev) => handleRawMessage(ev)
ws.onclose = (ev) => {
if (wsRef.value === ws) {
wsRef.value = null
}
connected.value = false
console.log(`[WS] Closed (code=${ev.code})`)
scheduleReconnect()
}
ws.onerror = (ev) => {
console.error("[WS] Error:", ev)
if (!mediaIndex.value) {
error.value = "WebSocket connection failed"
}
}
}
function disconnect() {
disposed = true;
disposed = true
stopTaskSweep()
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.onclose = null; // prevent reconnect
ws.close();
ws = null;
}
clearTimeout(reconnectTimer)
reconnectTimer = null
}
// Start the connection
connect();
if (wsRef.value) {
wsRef.value.onclose = null
wsRef.value.close()
wsRef.value = null
}
// Clean up on component unmount
onUnmounted(disconnect);
connected.value = false
roots.value.clear()
rootStates.value.clear()
}
connect()
onUnmounted(disconnect)
return {
mediaIndex,
@@ -181,6 +646,7 @@ export function useMediaWebSocket() {
error: readonly(error),
connected: readonly(connected),
tasks: readonly(tasks),
roots: readonly(roots),
disconnect,
};
}
}
+162
View File
@@ -0,0 +1,162 @@
import { reactive, watch } from "vue"
import type { Torrent } from "../types"
export type ResolutionPreference = "r2" | "r3" | "r4" | "rmax"
export type HdrPreference = "none" | "hdr10plus" | "dovi"
export interface MediaHiveSettings {
preferredResolution: ResolutionPreference
preferredHdr: HdrPreference
playerId: string | null
playerCustomCmd: string | null
playerMpcPort: number | null
}
const STORAGE_KEY = "MediaHive"
const RESOLUTION_PRIORITY: Record<string, number> = {
"8K": 5,
"4K": 4,
"FHD": 3,
"HD": 2,
"SD": 1,
}
// Max resolution priority allowed for each preference level
const RESOLUTION_CAP: Record<ResolutionPreference, number> = {
r2: 2,
r3: 3,
r4: 4,
rmax: 999,
}
const RESOLUTION_VALUES = ["r2", "r3", "r4", "rmax"] as const
const HDR_VALUES = ["none", "hdr10plus", "dovi"] as const
function isResolutionPreference(value: unknown): value is ResolutionPreference {
return typeof value === "string" && RESOLUTION_VALUES.includes(value as ResolutionPreference)
}
function isHdrPreference(value: unknown): value is HdrPreference {
return typeof value === "string" && HDR_VALUES.includes(value as HdrPreference)
}
function loadSettings(): MediaHiveSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<MediaHiveSettings>
return {
preferredResolution: isResolutionPreference(parsed.preferredResolution)
? parsed.preferredResolution
: "rmax",
preferredHdr: isHdrPreference(parsed.preferredHdr) ? parsed.preferredHdr : "none",
playerId: typeof parsed.playerId === "string" ? parsed.playerId : "default",
playerCustomCmd: typeof parsed.playerCustomCmd === "string" ? parsed.playerCustomCmd : null,
playerMpcPort: typeof parsed.playerMpcPort === "number" ? parsed.playerMpcPort : null,
}
}
} catch {
// ignore parse errors
}
return { preferredResolution: "rmax", preferredHdr: "none", playerId: "default", playerCustomCmd: null, playerMpcPort: null }
}
const settings = reactive<MediaHiveSettings>(loadSettings())
watch(
() => ({ ...settings }),
(value) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
} catch {
// ignore
}
},
{ deep: true },
)
export function useSettings() {
return settings
}
/**
* Sort a list of Torrent objects according to the current format preferences.
*
* Algorithm:
* 1. Compute the "effective priority" = min(actual priority, cap).
* Releases exceeding the cap receive the capped priority.
* 2. Higher effective priority wins.
* 3. For equal effective priority, prefer HDR flavor per `preferredHdr`.
* 4. Equal in all above: fall back to raw resolution priority (larger is better)
* then size.
*/
export function sortTorrentsByPreference(torrents: Torrent[]): Torrent[] {
const cap = RESOLUTION_CAP[settings.preferredResolution]
function effectivePriority(t: Torrent): number {
const raw = RESOLUTION_PRIORITY[t.resolution ?? ""] ?? 0
return Math.min(raw, cap)
}
type HdrProfile = "sdr" | "hdr10" | "hdr10plus" | "dovi"
function detectHdrProfile(t: Torrent): HdrProfile {
const text = [t.title, t.quality, t.codec, t.audio].filter(Boolean).join(" ")
const hasDovi = t.dovi || /dolby\s*vision|dovi|\bdv\b/i.test(text)
const hasHdr10Plus = t.hdr10plus || /hdr10\+|hdr10plus/i.test(text)
const hasAnyHdr = t.hdr || hasHdr10Plus || hasDovi || /\bhdr\b/i.test(text)
if (hasDovi) return "dovi"
if (hasHdr10Plus) return "hdr10plus"
if (hasAnyHdr) return "hdr10"
return "sdr"
}
function scoreForPreference(profile: HdrProfile): number {
if (settings.preferredHdr === "none") {
// Prefer no HDR; HDR variants after SDR.
if (profile === "sdr") return 4
if (profile === "hdr10") return 3
if (profile === "hdr10plus") return 2
return 1
}
if (settings.preferredHdr === "hdr10plus") {
// Requested behavior: HDR10+ best, HDR10 next, SDR then, DoVi last.
if (profile === "hdr10plus") return 4
if (profile === "hdr10") return 3
if (profile === "sdr") return 2
return 1
}
if (profile === "dovi") return 4
if (profile === "hdr10plus") return 3
if (profile === "hdr10") return 2
return 1
}
function hdrPreferenceScore(t: Torrent): number {
return scoreForPreference(detectHdrProfile(t))
}
return [...torrents].sort((a, b) => {
const epA = effectivePriority(a)
const epB = effectivePriority(b)
if (epB !== epA) return epB - epA
// Same effective resolution bucket - apply HDR format preference
const hdrScoreA = hdrPreferenceScore(a)
const hdrScoreB = hdrPreferenceScore(b)
if (hdrScoreB !== hdrScoreA) {
return hdrScoreB - hdrScoreA
}
// Fall back to raw resolution priority then size
const rawA = RESOLUTION_PRIORITY[a.resolution ?? ""] ?? 0
const rawB = RESOLUTION_PRIORITY[b.resolution ?? ""] ?? 0
if (rawB !== rawA) return rawB - rawA
return (b.size ?? 0) - (a.size ?? 0)
})
}
+16 -11
View File
@@ -1,18 +1,23 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './styles/main.css'
import { installKeyboardNavigation } from './composables/useKeyboardNavigation'
import { installGamepadNavigation } from './composables/useGamepadNavigation'
import { createApp } from "vue"
import App from "./App.vue"
import router from "./router"
import "./styles/main.css"
import { installKeyboardNavigation } from "./composables/useKeyboardNavigation"
import { installGamepadNavigation } from "./composables/useGamepadNavigation"
import { installInputModalityTracking } from "./composables/useInputModality"
// Install global keyboard navigation handlers immediately
installInputModalityTracking()
installKeyboardNavigation()
installGamepadNavigation()
if ('serviceWorker' in navigator && !navigator.serviceWorker.controller) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload()
}, { once: true })
// 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')
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 { defineComponent, h } from 'vue';
import { createRouter, createWebHistory } from "vue-router"
import { defineComponent, h } from "vue"
// Empty component - App.vue handles all rendering based on route meta
const EmptyRouteComponent = defineComponent({
render() {
return h('div');
}
});
return h("div")
},
})
const router = createRouter({
history: createWebHashHistory(),
history: createWebHistory(),
scrollBehavior() {
// Always scroll to top on navigation
return { top: 0 };
return { top: 0 }
},
routes: [
{
path: '/',
redirect: '/movies',
path: "/",
redirect: "/movies",
},
{
path: '/movies',
name: 'movies',
path: "/movies",
name: "movies",
component: EmptyRouteComponent,
meta: { view: 'movies' },
meta: { view: "movies" },
},
{
path: '/movies/:id',
name: 'movie-detail',
path: "/movies/:id",
name: "movie-detail",
component: EmptyRouteComponent,
meta: { view: 'movies' },
meta: { view: "movies" },
},
{
path: '/series',
name: 'series',
path: "/search/:term",
name: "search",
component: EmptyRouteComponent,
meta: { view: 'series' },
},
{
path: '/series/:id',
name: 'series-detail',
path: "/settings",
name: "settings",
component: EmptyRouteComponent,
meta: { view: 'series' },
},
{
path: "/series",
name: "series",
component: EmptyRouteComponent,
meta: { view: "series" },
},
{
path: "/series/:id",
name: "series-detail",
component: EmptyRouteComponent,
meta: { view: "series" },
},
],
});
})
export default router;
export default router
+803
View File
@@ -0,0 +1,803 @@
// Search Web Worker - runs search off the main thread
// This file is loaded as a Web Worker, not imported as a module.
import type {
MovieUi,
SeriesUi,
MatchedPerson,
MatchedEpisode,
SearchMatchInfo,
} from "./types"
// ---------------------------------------------------------------------------
// Message types
// ---------------------------------------------------------------------------
export interface SearchIndexMessage {
type: "index"
movies: MovieUi[]
series: SeriesUi[]
}
export interface SearchQueryMessage {
type: "query"
id: number
query: string
}
export type SearchWorkerMessage = SearchIndexMessage | SearchQueryMessage
export interface SearchResultItem {
id: string
title: string | null
year?: number | null
cover_path: string | null
showreel_images?: string[] | null
showreel_source_sets?: string[][] | null
type: "movies" | "series"
resolution?: string | null
root_id: string | null
searchMatchInfo?: SearchMatchInfo
}
export interface SearchCategoryResult {
name: string
items: SearchResultItem[]
}
export interface SearchResponseMessage {
id: number
results: SearchResultItem[]
categories: SearchCategoryResult[]
}
// ---------------------------------------------------------------------------
// Worker state
// ---------------------------------------------------------------------------
let movies: MovieUi[] = []
let series: SeriesUi[] = []
// ---------------------------------------------------------------------------
// Normalization helpers (mirrored from App.vue)
// ---------------------------------------------------------------------------
function normalizeSearchText(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9\-]+/g, " ")
.trim()
.replace(/\s+/g, " ")
}
function normalizePathSearchText(value: string): string {
return value
.toLowerCase()
.replace(/[\\/]+/g, "/")
.replace(/[^a-z0-9/]+/g, " ")
.trim()
.replace(/\s+/g, " ")
}
// ---------------------------------------------------------------------------
// Scoring helpers (mirrored from App.vue)
// ---------------------------------------------------------------------------
function getTermMatchScore(term: string, field: string): number {
const index = field.indexOf(term)
if (index < 0) return 0
if (index === 0) return 100
const charBefore = field[index - 1]
if (/\s/.test(charBefore)) return 80
if (index < field.length / 2) return 50
return 30
}
function getPathTermMatchScore(term: string, field: string): number {
const index = field.indexOf(term)
if (index < 0) return 0
if (index === 0) return 100
const charBefore = field[index - 1]
if (/\s|\//.test(charBefore)) return 80
if (index < field.length / 2) return 50
return 30
}
function getRelevanceScore(query: string, field: string): number {
const normalizedField = normalizeSearchText(field)
const normalizedQuery = normalizeSearchText(query)
if (!normalizedField || !normalizedQuery) return 0
let bestScore = 0
const exactIndex = normalizedField.indexOf(normalizedQuery)
if (exactIndex >= 0) {
if (exactIndex === 0) {
bestScore = 110
} else {
const charBefore = normalizedField[exactIndex - 1]
if (/\s/.test(charBefore)) {
bestScore = 95
} else if (exactIndex < normalizedField.length / 2) {
bestScore = 75
} else {
bestScore = 60
}
}
}
const terms = normalizedQuery.split(" ")
if (terms.length > 1) {
let matchedTerms = 0
let termScoreTotal = 0
for (const term of terms) {
const termScore = getTermMatchScore(term, normalizedField)
if (termScore > 0) {
matchedTerms += 1
termScoreTotal += termScore
}
}
if (matchedTerms > 0) {
const coverage = matchedTerms / terms.length
const averageScore = termScoreTotal / matchedTerms
const combinedScore = Math.round(averageScore * (0.6 + coverage * 0.4))
if (combinedScore > bestScore) bestScore = combinedScore
}
}
return bestScore
}
function getPathRelevanceScore(query: string, field: string): number {
const normalizedField = normalizePathSearchText(field)
const normalizedQuery = normalizePathSearchText(query)
if (!normalizedField || !normalizedQuery) return 0
let bestScore = 0
const exactIndex = normalizedField.indexOf(normalizedQuery)
if (exactIndex >= 0) {
if (exactIndex === 0) {
bestScore = 110
} else {
const charBefore = normalizedField[exactIndex - 1]
if (/\s|\//.test(charBefore)) {
bestScore = 95
} else if (exactIndex < normalizedField.length / 2) {
bestScore = 75
} else {
bestScore = 60
}
}
}
const terms = normalizedQuery.split(" ")
if (terms.length > 1) {
let matchedTerms = 0
let termScoreTotal = 0
for (const term of terms) {
const termScore = getPathTermMatchScore(term, normalizedField)
if (termScore > 0) {
matchedTerms += 1
termScoreTotal += termScore
}
}
if (matchedTerms > 0) {
const coverage = matchedTerms / terms.length
const averageScore = termScoreTotal / matchedTerms
const combinedScore = Math.round(averageScore * (0.6 + coverage * 0.4))
if (combinedScore > bestScore) bestScore = combinedScore
}
}
return bestScore
}
function getBestScore(query: string, ...fields: (string | null | undefined)[]): number {
let bestScore = 0
for (const field of fields) {
if (field) {
const score = getRelevanceScore(query, field)
if (score > bestScore) bestScore = score
}
}
return bestScore
}
function getMoviePathScore(movie: MovieUi, query: string): number {
const torrentFields: (string | null | undefined)[] = []
for (const torrent of Object.values(movie.files || {})) {
torrentFields.push(torrent.title, torrent.playable_file)
}
let bestScore = 0
for (const field of torrentFields) {
if (!field) continue
const score = getPathRelevanceScore(query, field)
if (score > bestScore) bestScore = score
}
return bestScore
}
function getSeriesPathScore(series: SeriesUi, query: string): number {
const torrentFields: (string | null | undefined)[] = []
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
for (const torrent of Object.values(episode.files || {})) {
torrentFields.push(torrent.title, torrent.playable_file)
}
}
}
let bestScore = 0
for (const field of torrentFields) {
if (!field) continue
const score = getPathRelevanceScore(query, field)
if (score > bestScore) bestScore = score
}
return bestScore
}
// ---------------------------------------------------------------------------
// People matching (mirrored from App.vue)
// ---------------------------------------------------------------------------
interface PersonMatch {
name: string
roles: string[]
highlightRoles: boolean
}
interface PersonCandidate {
name: string
role: string
highlightRoles: boolean
score: number
matchedWordIndexes: number[]
}
function getQueryWords(value: string): string[] {
const normalized = normalizeSearchText(value)
if (!normalized) return []
return normalized.split(" ")
}
function getMatchedWordIndexes(queryWords: string[], value: string): number[] {
const normalized = normalizeSearchText(value)
if (!normalized || queryWords.length === 0) return []
const targetWords = normalized.split(" ")
const matches: number[] = []
for (let i = 0; i < queryWords.length; i += 1) {
const queryWord = queryWords[i]
if (targetWords.some((targetWord) => targetWord.startsWith(queryWord))) {
matches.push(i)
}
}
return matches
}
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void {
const existing = target.find((person) => person.name.toLowerCase() === candidate.name.toLowerCase())
if (existing) {
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
if (candidate.highlightRoles) existing.highlightRoles = true
return
}
target.push({
name: candidate.name,
roles: [candidate.role],
highlightRoles: candidate.highlightRoles,
})
}
function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
const sorted = indexes
.filter((index) => availableIndexes.has(index))
.sort((a, b) => a - b)
if (sorted.length === 0) return []
let bestStart = 0
let bestLength = 1
let runStart = 0
let runLength = 1
for (let i = 1; i < sorted.length; i += 1) {
if (sorted[i] === sorted[i - 1] + 1) {
runLength += 1
continue
}
if (runLength > bestLength) {
bestStart = runStart
bestLength = runLength
}
runStart = i
runLength = 1
}
if (runLength > bestLength) {
bestStart = runStart
bestLength = runLength
}
return sorted.slice(bestStart, bestStart + bestLength)
}
function nameMatchesQuery(query: string, name: string): boolean {
const nq = normalizeSearchText(query)
const nn = normalizeSearchText(name)
if (!nq || !nn) return false
// Exact substring match (e.g. "jackie chan" matches "jackie chan" and "jackie chans")
if (nn.includes(nq)) return true
// Each query word must appear as a prefix of a name word (word-boundary match)
const queryWords = nq.split(" ")
const nameWords = nn.split(" ")
return queryWords.every((qw) => nameWords.some((nw) => nw.startsWith(qw)))
}
function getNameMatchScore(query: string, name: string): number {
if (!nameMatchesQuery(query, name)) return 0
const nq = normalizeSearchText(query)
const nn = normalizeSearchText(name)
const idx = nn.indexOf(nq)
if (idx === 0) return 110
if (idx > 0) {
const before = nn[idx - 1]
if (/\s/.test(before)) return 95
return 75
}
// Prefix-based match: score lower than exact substring
return 70
}
function matchesPeople(
query: string,
cast: { name: string; character?: string | null }[] | null | undefined,
director?: string | null,
creators?: string[] | null,
): { matches: PersonMatch[]; score: number } {
const matchedPeople: PersonMatch[] = []
const candidates: PersonCandidate[] = []
const queryWords = getQueryWords(query)
const addCandidate = (
name: string,
role: string,
highlightRoles: boolean,
score: number,
matchedWordIndexes: number[],
) => {
candidates.push({ name, role, highlightRoles, score, matchedWordIndexes })
}
let bestScore = 0
if (director) {
const score = getNameMatchScore(query, director)
if (score > 0) {
addCandidate(director, "Director", false, score, getMatchedWordIndexes(queryWords, director))
} else {
addCandidate(director, "Director", false, 0, getMatchedWordIndexes(queryWords, director))
}
}
if (creators) {
for (const creator of creators) {
const score = getNameMatchScore(query, creator)
if (score > 0) {
addCandidate(creator, "Creator", false, score, getMatchedWordIndexes(queryWords, creator))
} else {
addCandidate(creator, "Creator", false, 0, getMatchedWordIndexes(queryWords, creator))
}
}
}
if (cast) {
for (const person of cast) {
const nameScore = getNameMatchScore(query, person.name)
const characterScore = person.character ? getNameMatchScore(query, person.character) : 0
const bestPersonScore = Math.max(nameScore, characterScore)
const nameWordIndexes = getMatchedWordIndexes(queryWords, person.name)
const characterWordIndexes = person.character
? getMatchedWordIndexes(queryWords, person.character)
: []
const useCharacterWords = characterWordIndexes.length > nameWordIndexes.length
const matchedWordIndexes = useCharacterWords ? characterWordIndexes : nameWordIndexes
if (bestPersonScore > 0) {
const role = person.character || "Cast"
const highlightRoles = characterScore > nameScore
addCandidate(person.name, role, highlightRoles, bestPersonScore, matchedWordIndexes)
} else {
addCandidate(person.name, person.character || "Cast", false, 0, matchedWordIndexes)
}
}
}
for (const candidate of candidates) {
if (candidate.score <= 0) continue
mergePersonMatch(matchedPeople, candidate)
if (candidate.score > bestScore) bestScore = candidate.score
}
if (matchedPeople.length > 0) {
return { matches: matchedPeople, score: bestScore }
}
if (queryWords.some((word) => word.length < 2)) {
return { matches: [], score: 0 }
}
const explicitMultiPerson = /[,&+]/.test(query)
const uncoveredWordIndexes = new Set(queryWords.map((_, index) => index))
const selected: Array<{ candidate: PersonCandidate; matchedIndexes: number[] }> = []
const usableCandidates = candidates.filter((candidate) => candidate.matchedWordIndexes.length > 0)
let hasMultiWordChunk = false
while (uncoveredWordIndexes.size > 0) {
let bestCandidate: PersonCandidate | null = null
let bestChunk: number[] = []
let bestCoverage = 0
for (const candidate of usableCandidates) {
if (selected.some((entry) => entry.candidate === candidate)) continue
const chunk = getBestContiguousWordRun(candidate.matchedWordIndexes, uncoveredWordIndexes)
if (chunk.length <= 0) continue
const coverage = candidate.matchedWordIndexes.length
if (
chunk.length > bestChunk.length ||
(chunk.length === bestChunk.length && coverage > bestCoverage)
) {
bestCandidate = candidate
bestChunk = chunk
bestCoverage = coverage
}
}
if (!bestCandidate || bestChunk.length <= 0) break
selected.push({ candidate: bestCandidate, matchedIndexes: bestChunk })
if (bestChunk.length > 1) hasMultiWordChunk = true
for (const index of bestChunk) {
uncoveredWordIndexes.delete(index)
}
}
if (uncoveredWordIndexes.size > 0 || selected.length === 0) {
return { matches: [], score: 0 }
}
// Without explicit separators, require at least one multi-word person chunk.
// This avoids accidental matches like "jackie chan" => "Jackie" + "Chan".
if (!explicitMultiPerson && selected.length > 1 && !hasMultiWordChunk) {
return { matches: [], score: 0 }
}
let multiPersonScore = 0
for (const entry of selected) {
mergePersonMatch(matchedPeople, entry.candidate)
const candidateScore = entry.candidate.score > 0 ? entry.candidate.score : 65
if (candidateScore > multiPersonScore) multiPersonScore = candidateScore
}
return { matches: matchedPeople, score: multiPersonScore }
}
function formatMatchedPeople(people: PersonMatch[]): MatchedPerson[] {
return people.map((p) => ({
name: p.name,
roles: p.roles.join(", "),
highlightRoles: p.highlightRoles,
}))
}
// ---------------------------------------------------------------------------
// MediaItem conversion (lightweight, without data payload)
// ---------------------------------------------------------------------------
function movieToSearchResult(movie: MovieUi): SearchResultItem {
const files = Object.values(movie.files || {})
const resolution = files.length > 0 ? files[0].resolution : null
return {
id: movie.id,
title: movie.title || "Unknown",
year: movie.year,
cover_path: movie.cover_path,
showreel_images: movie.showreel_images,
showreel_source_sets: movie.showreel_source_sets,
type: "movies",
resolution,
root_id: movie.root_id,
}
}
function seriesToSearchResult(series: SeriesUi): SearchResultItem {
const reelImages: string[] = []
const reelSourceSets: string[][] = []
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
if (episode.reel_sources && episode.reel_sources.length > 0) {
reelImages.push(episode.reel_sources[0])
reelSourceSets.push(episode.reel_sources)
} else if (episode.reel_image) {
reelImages.push(episode.reel_image)
reelSourceSets.push([episode.reel_image])
}
}
}
return {
id: series.id,
title: series.title || "Unknown",
year: null,
cover_path: series.cover_path,
showreel_images: reelImages.length > 0 ? reelImages : null,
showreel_source_sets: reelSourceSets.length > 0 ? reelSourceSets : null,
type: "series",
root_id: series.root_id,
}
}
// ---------------------------------------------------------------------------
// Core search with cancellation token
// ---------------------------------------------------------------------------
const MAX_RESULTS = 100
interface ScoredResult {
item: SearchResultItem
score: number
matchType: "movies" | "series" | "people" | "other"
}
interface CancelToken {
id: number
}
let currentSearchId = 0
function isCancelled(token: CancelToken): boolean {
return token.id !== currentSearchId
}
/** Yield control briefly so the worker can receive a new message. */
function yieldControl(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
async function performSearch(
query: string,
token: CancelToken,
): Promise<SearchResponseMessage | null> {
const allScored: ScoredResult[] = []
const processedIds = new Set<string>()
const yearMatch = query.match(/^(\d{4})$/)
const searchYear = yearMatch ? parseInt(yearMatch[1], 10) : null
const isYearQuery = searchYear !== null && searchYear >= 1900 && searchYear <= 2100
const yearBonus = 25
// -------------------------------------------------------------------------
// Search movies
// -------------------------------------------------------------------------
for (let i = 0; i < movies.length; i++) {
if (i % 50 === 0) {
if (isCancelled(token)) return null
await yieldControl()
}
const movie = movies[i]
const titleScore = getBestScore(query, movie.title, movie.info?.original_title)
const yearScore = isYearQuery && movie.year === searchYear ? yearBonus : 0
if (titleScore > 0 || yearScore > 0) {
allScored.push({
item: movieToSearchResult(movie),
score: titleScore + yearScore + (movie.info?.rating ?? 0) / 10,
matchType: "movies",
})
processedIds.add(movie.id)
continue
}
const peopleMatch = matchesPeople(query, movie.info?.cast, movie.info?.director)
if (peopleMatch.matches.length > 0) {
const item = movieToSearchResult(movie)
item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) }
allScored.push({
item,
score: peopleMatch.score + (movie.info?.rating ?? 0) / 10,
matchType: "people",
})
processedIds.add(movie.id)
continue
}
const otherScore = Math.max(
getBestScore(
query,
movie.info?.genres?.join(" "),
movie.info?.keywords?.join(" "),
movie.info?.overview,
movie.info?.tagline,
movie.info?.collection,
),
getMoviePathScore(movie, query),
)
if (otherScore > 0) {
allScored.push({
item: movieToSearchResult(movie),
score: otherScore + (movie.info?.rating ?? 0) / 10,
matchType: "other",
})
processedIds.add(movie.id)
}
}
// -------------------------------------------------------------------------
// Search series
// -------------------------------------------------------------------------
for (let i = 0; i < series.length; i++) {
if (i % 50 === 0) {
if (isCancelled(token)) return null
await yieldControl()
}
const seriesItem = series[i]
const titleScore = getBestScore(query, seriesItem.title, seriesItem.info?.original_title)
const seriesYear = seriesItem.info?.release_date
? parseInt(seriesItem.info.release_date.substring(0, 4), 10)
: null
const yearScore = isYearQuery && seriesYear === searchYear ? yearBonus : 0
if (titleScore > 0 || yearScore > 0) {
allScored.push({
item: seriesToSearchResult(seriesItem),
score: titleScore + yearScore + (seriesItem.info?.rating ?? 0) / 10,
matchType: "series",
})
processedIds.add(seriesItem.id)
continue
}
const matchedEpisodes: MatchedEpisode[] = []
let episodeScore = 0
const isEndedSingleSeason =
(seriesItem.info?.number_of_seasons === 1 || seriesItem.seasons?.length === 1) &&
["Ended", "Canceled", "Cancelled"].includes(seriesItem.info?.status || "")
for (const season of seriesItem.seasons || []) {
for (const episode of season.episodes || []) {
if (episode.name) {
const epScore = getRelevanceScore(query, episode.name)
if (epScore > 0) {
const hideSeason = isEndedSingleSeason || season.season_number === 0
const location = hideSeason
? `Episode ${episode.episode_number}`
: `S${season.season_number} Episode ${episode.episode_number}`
matchedEpisodes.push({
name: episode.name,
location,
seasonNumber: season.season_number,
episodeNumber: episode.episode_number,
})
if (epScore > episodeScore) episodeScore = epScore
}
}
}
}
if (matchedEpisodes.length > 0 && !processedIds.has(seriesItem.id)) {
const item = seriesToSearchResult(seriesItem)
item.searchMatchInfo = { matchedEpisodes }
allScored.push({
item,
score: episodeScore + (seriesItem.info?.rating ?? 0) / 10,
matchType: "series",
})
processedIds.add(seriesItem.id)
continue
}
const peopleMatch = matchesPeople(query, seriesItem.info?.cast, null, seriesItem.info?.creators)
if (peopleMatch.matches.length > 0 && !processedIds.has(seriesItem.id)) {
const item = seriesToSearchResult(seriesItem)
item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) }
allScored.push({
item,
score: peopleMatch.score + (seriesItem.info?.rating ?? 0) / 10,
matchType: "people",
})
processedIds.add(seriesItem.id)
continue
}
if (!processedIds.has(seriesItem.id)) {
const otherScore = Math.max(
getBestScore(
query,
seriesItem.info?.genres?.join(" "),
seriesItem.info?.keywords?.join(" "),
seriesItem.info?.overview,
seriesItem.info?.tagline,
seriesItem.info?.networks?.join(" "),
),
getSeriesPathScore(seriesItem, query),
)
if (otherScore > 0) {
allScored.push({
item: seriesToSearchResult(seriesItem),
score: otherScore + (seriesItem.info?.rating ?? 0) / 10,
matchType: "other",
})
processedIds.add(seriesItem.id)
}
}
}
if (isCancelled(token)) return null
allScored.sort((a, b) => b.score - a.score)
const topResults = allScored.slice(0, MAX_RESULTS)
const moviesCat: SearchResultItem[] = []
const seriesCat: SearchResultItem[] = []
const peopleCat: SearchResultItem[] = []
const otherCat: SearchResultItem[] = []
for (const scored of topResults) {
switch (scored.matchType) {
case "movies":
moviesCat.push(scored.item)
break
case "series":
seriesCat.push(scored.item)
break
case "people":
peopleCat.push(scored.item)
break
case "other":
otherCat.push(scored.item)
break
}
}
const categories: SearchCategoryResult[] = []
if (moviesCat.length > 0) categories.push({ name: "Movies", items: moviesCat })
if (seriesCat.length > 0) categories.push({ name: "Series", items: seriesCat })
if (peopleCat.length > 0) categories.push({ name: "People", items: peopleCat })
if (otherCat.length > 0) categories.push({ name: "Other", items: otherCat })
return {
id: token.id,
results: topResults.map((s) => s.item),
categories,
}
}
// ---------------------------------------------------------------------------
// Worker message handler — single persistent runner
// ---------------------------------------------------------------------------
self.onmessage = (event: MessageEvent<SearchWorkerMessage>) => {
const msg = event.data
if (msg.type === "index") {
movies = msg.movies
series = msg.series
return
}
if (msg.type === "query") {
const { id, query } = msg
currentSearchId = id
const token: CancelToken = { id }
void (async () => {
const response = await performSearch(query, token)
if (!response) {
return
}
self.postMessage(response)
})()
}
}
+119 -124
View File
@@ -27,12 +27,15 @@
box-sizing: border-box;
}
html, body {
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
html,
body {
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
height: 100%;
min-height: 100vh;
overflow-x: hidden;
overflow-y: hidden;
-webkit-font-smoothing: antialiased;
text-align: justify;
hyphens: auto;
@@ -40,9 +43,33 @@ html, body {
-ms-hyphens: auto;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
width: 0;
height: 0;
}
html:not(.pointer-visible),
html:not(.pointer-visible) * {
cursor: none !important;
}
#app {
height: 100vh;
min-height: 100vh;
position: relative;
overflow: hidden;
}
.scrollbar-hidden {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hidden::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
/* Scrollbar styling */
@@ -60,7 +87,7 @@ html, body {
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
html.mouse-active ::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
@@ -75,9 +102,28 @@ html, body {
display: flex;
align-items: center;
padding: 0 12px;
isolation: isolate;
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.header::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 0;
border-radius: 0;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-fast);
}
.header > * {
position: relative;
z-index: 1;
}
.header-top {
top: 40px;
}
@@ -88,8 +134,8 @@ html, body {
}
.header-after-movie-header {
/* Position after movie detail collage-header (300px) */
top: 320px;
/* Place header below movie detail collage-header (300px). */
top: 300px;
}
.header-after-series-hero {
@@ -97,6 +143,22 @@ html, body {
top: clamp(450px, 70vh, 600px);
}
.header-after-series-hero::before {
width: 30em;
max-width: calc(100vw - 24px);
opacity: 1;
background: linear-gradient(to bottom, rgba(5, 7, 10, 0.72) 0%, rgba(5, 7, 10, 0.5) 100%);
backdrop-filter: blur(10px) saturate(115%);
-webkit-backdrop-filter: blur(10px) saturate(115%);
}
@media (max-width: 900px) {
.header-after-hero {
/* Match the reduced browse hero height at <=900px. */
top: clamp(350px, 50vh, 600px);
}
}
/* Spacer to reserve space for header in layout */
.header-spacer {
height: calc(var(--header-height) + 20px);
@@ -108,6 +170,11 @@ html, body {
gap: 12px;
}
.header-logo-link {
display: inline-flex;
align-items: center;
}
.header-logo {
height: 40px;
width: 40px;
@@ -133,13 +200,18 @@ html, body {
padding: 0;
}
.header-nav-item:hover,
.header-after-movie-header .header-nav-item,
.header-after-series-hero .header-nav-item {
color: rgba(255, 255, 255, 0.78);
}
html.mouse-active .header-nav-item:hover,
.header-nav-item.active {
color: var(--text-primary);
}
.header-nav-item:focus,
.header-nav-item.nav-focused {
html:not(.mouse-active) .header-nav-item.nav-focused {
color: var(--text-primary);
outline: none;
text-decoration: underline;
@@ -170,7 +242,7 @@ html, body {
transition: color var(--transition-fast);
}
.header-settings-btn:hover {
html.mouse-active .header-settings-btn:hover {
color: var(--text-primary);
}
@@ -205,84 +277,10 @@ html, body {
padding-top: 40px;
}
/* Hero section - kept for backwards compatibility but not used */
.hero {
position: relative;
height: 70vh;
max-height: 600px;
min-height: 400px;
display: flex;
align-items: flex-end;
padding: 0 4% 8%;
background-color: var(--bg-primary);
overflow: hidden;
}
.hero-background {
position: absolute;
top: 0;
right: 0;
width: 60%;
height: 100%;
background-size: contain;
background-position: right top;
background-repeat: no-repeat;
mask-image: linear-gradient(to left, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.3) 60%, transparent 100%);
-webkit-mask-image: linear-gradient(to left, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.3) 60%, transparent 100%);
}
.hero::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 200px;
background: var(--gradient-fade);
}
.hero-content {
position: relative;
z-index: 1;
max-width: 600px;
}
.hero-title {
font-size: 3rem;
font-weight: 700;
margin-bottom: 16px;
text-shadow: 2px 2px 4px var(--shadow-color);
}
.hero-meta {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
font-size: 1rem;
}
.hero-year {
color: var(--text-secondary);
}
.hero-quality {
background: var(--bg-secondary);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.hero-buttons {
display: flex;
gap: 12px;
margin-top: 24px;
}
/* Blinking animation for button focus */
@keyframes btn-outline-blink {
0%, 100% {
0%,
100% {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.9);
}
50% {
@@ -318,8 +316,8 @@ html, body {
color: var(--bg-primary);
}
.btn-primary:hover,
.btn-primary.nav-focused {
html.mouse-active .btn-primary:hover,
html:not(.mouse-active) .btn-primary.nav-focused {
background: rgba(255, 255, 255, 0.85);
animation: btn-outline-blink 1s ease-in-out infinite;
}
@@ -329,8 +327,8 @@ html, body {
color: var(--text-primary);
}
.btn-secondary:hover,
.btn-secondary.nav-focused {
html.mouse-active .btn-secondary:hover,
html:not(.mouse-active) .btn-secondary.nav-focused {
background: rgba(109, 109, 110, 0.5);
animation: btn-outline-blink 1s ease-in-out infinite;
}
@@ -346,11 +344,15 @@ html, body {
}
.view-zoom-enter-active {
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
transition:
opacity 0.4s ease-out,
transform 0.4s ease-out;
}
.view-zoom-leave-active {
transition: opacity 0.3s ease-in, transform 0.3s ease-in;
transition:
opacity 0.3s ease-in,
transform 0.3s ease-in;
}
.view-zoom-enter-from {
@@ -365,7 +367,7 @@ html, body {
/* Media rows */
.media-section {
padding: 0 var(--section-padding);
padding: 0;
margin-bottom: 20px;
}
@@ -373,17 +375,22 @@ html, body {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 10px;
padding: 0 calc(2px + var(--section-padding));
color: var(--text-primary);
}
.media-row {
--sync-row-tail: 0px;
display: flex;
gap: 6px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 8px;
margin: 0 -2px;
padding: 4px 2px 8px;
/* Tail padding is added only on the right so that increasing it does not
shift items to the right (which would break safe-zone calculations). */
padding: 4px calc(2px + var(--section-padding) + var(--sync-row-tail)) 8px
calc(2px + var(--section-padding));
scrollbar-width: none;
-ms-overflow-style: none;
}
@@ -406,13 +413,17 @@ html, body {
flex-shrink: 0;
width: var(--card-width);
cursor: pointer;
transition: z-index 0s, box-shadow var(--transition-medium);
transition:
z-index 0s,
box-shadow var(--transition-medium);
position: relative;
outline: none;
text-decoration: none;
color: inherit;
}
.media-card:hover,
.media-card.nav-focused {
html.mouse-active .media-card:hover,
html:not(.mouse-active) .media-card.nav-focused {
z-index: 10;
}
@@ -434,7 +445,7 @@ html, body {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
.media-card:hover .media-card-poster {
html.mouse-active .media-card:hover .media-card-poster {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
@@ -480,7 +491,7 @@ html, body {
transition: opacity var(--transition-fast);
}
.media-card:hover .media-card-info {
html.mouse-active .media-card:hover .media-card-info {
opacity: 1;
}
@@ -525,6 +536,14 @@ html, body {
justify-content: center;
padding: 40px 20px;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.modal-overlay::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.modal-content {
@@ -556,7 +575,7 @@ html, body {
}
.modal-header::after {
content: '';
content: "";
position: absolute;
bottom: 0;
left: 0;
@@ -584,7 +603,7 @@ html, body {
transition: background var(--transition-fast);
}
.modal-close:hover {
html.mouse-active .modal-close:hover {
background: var(--bg-card);
}
@@ -680,7 +699,7 @@ html, body {
transition: background var(--transition-fast);
}
.release-item:hover {
html.mouse-active .release-item:hover {
background: var(--bg-card-hover);
}
@@ -787,23 +806,6 @@ html, body {
--section-padding: 3%;
}
.hero {
height: 50vh;
min-height: 300px;
}
.hero-title {
font-size: 1.5rem;
}
.header-nav {
gap: 10px;
}
.search-input {
width: 140px;
}
.modal-title {
font-size: 1.5rem;
}
@@ -816,13 +818,6 @@ html, body {
@media (max-width: 480px) {
:root {
--card-width: 100px;
--header-height: 48px;
}
.header-logo {
height: 32px;
width: 32px;
margin-right: 12px;
}
.media-card-info {
+174 -118
View File
@@ -1,189 +1,245 @@
// Type definitions for the media browser
export type CastGender = "female" | "male" | "non_binary" | "unknown"
export interface CastMember {
name: string;
character?: string | null;
profile_path: string | null;
name: string
character?: string | null
profile_path: string | null
gender?: CastGender | null
id?: number | null
}
export interface SimilarMedia {
id: number;
title: string;
poster_path: string | null;
export type CastCreditWire = [character: string | null, id: number | null]
export type PersonWire = [name: string, profile_path: string | null, gender: CastGender | null]
export interface Person {
name: string
profile_path: string | null
gender?: CastGender | null
}
export interface Info {
tmdb_id: number;
title: string | null;
original_title: string | null;
alternative_titles: string[] | null;
rating: number | null;
vote_count: number | null;
overview: string | null;
genres: string[] | null;
release_date: string | null;
runtime: number | null;
status: string | null;
tagline: string | null;
poster_path: string | null;
backdrop_path: string | null;
similar: SimilarMedia[] | null;
keywords: string[] | null;
cast: CastMember[] | null;
director: string | null;
creators: string[] | null;
number_of_seasons: number | null;
number_of_episodes: number | null;
networks: string[] | null;
tmdb_id: number
title: string | null
original_title: string | null
original_language: string | null
alternative_titles: string[] | null
rating: number | null
vote_count: number | null
overview: string | null
genres: string[] | null
release_date: string | null
runtime: number | null
collection: string | null
status: string | null
tagline: string | null
keywords: string[] | null
cast: CastMember[] | null
director: string | null
creators: string[] | null
number_of_seasons: number | null
number_of_episodes: number | null
networks: string[] | null
}
export interface Torrent {
title: string | null;
playable_file: string | null;
resolution: string | null;
quality: string | null;
codec: string | null;
audio: string | null;
encoder: string | null;
size: number | null;
added_at: number | null;
title: string | null
playable_file: string | null
resolution: string | null
quality: string | null
network: string | null
codec: string | null
audio: string | null
audio_languages: string[] | null
subtitle_languages: string[] | null
hdr?: boolean
dovi?: boolean
atmos?: boolean
hdr10plus?: boolean
encoder: string | null
size: number | null
added_at: number | null
root_id?: string | null
}
export interface Movie {
id: string;
title: string | null;
info: Info | null;
year: number | null;
newest: number | null;
cover_path: string | null;
backdrop_path: string | null;
showreel_images: string[] | null;
torrents: { [key: string]: Torrent };
title: string | null
info: Info | null
year: number | null
newest: number | null
cover_path: string | null
backdrop_path: string | null
showreel_images: string[] | null
showreel_source_sets: string[][] | null
files: { [key: string]: Torrent }
}
export interface Episode {
episode_number: number;
name: string | null;
overview: string | null;
air_date: string | null;
runtime: number | null;
still_path: string | null;
rating: number | null;
director: string | null;
reel_image: string | null;
torrents: { [key: string]: Torrent };
episode_number: number
name: string | null
overview: string | null
air_date: string | null
runtime: number | null
still_path: string | null
rating: number | null
director: string | null
reel_image: string | null
reel_sources: string[] | null
files: { [key: string]: Torrent }
}
export interface Season {
season_number: number;
name: string | null;
overview: string | null;
air_date: string | null;
poster_path: string | null;
episode_count: number | null;
episodes: Episode[];
season_number: number
name: string | null
overview: string | null
air_date: string | null
poster_path: string | null
episode_count: number | null
episodes: Episode[]
}
export interface Series {
id: string;
title: string | null;
info: Info | null;
alternative_titles: string[] | null;
newest: number | null;
cover_path: string | null;
backdrop_path: string | null;
seasons: Season[];
title: string | null
info: Info | null
alternative_titles: string[] | null
newest: number | null
cover_path: string | null
backdrop_path: string | null
seasons: Season[]
}
export interface MovieUi extends Movie {
id: string
root_id: string | null
}
export interface SeriesUi extends Series {
id: string
root_id: string | null
}
export interface MediaStats {
total_movies: number;
total_movie_versions?: number;
total_series: number;
total_series_episodes?: number;
total_movies: number
total_movie_versions?: number
total_series: number
total_series_episodes?: number
}
export interface MediaIndex {
version: number;
generated_at: string;
stats: MediaStats;
movies: Movie[];
series: Series[];
v: number
generated_at: string
movies: MovieUi[]
series: SeriesUi[]
}
export type MediaType = 'movies' | 'series' | 'episode';
export type MediaType = "movies" | "series" | "episode"
// Matched person info for search results
export interface MatchedPerson {
name: string;
roles: string; // e.g., "Director", "Tony Stark", "Creator"
highlightRoles: boolean; // true if the roles/character matched (vs the name)
name: string
roles: string // e.g., "Director", "Tony Stark", "Creator"
highlightRoles: boolean // true if the roles/character matched (vs the name)
}
// Matched episode info for search results
export interface MatchedEpisode {
name: string; // Episode name (highlighted)
location: string; // "SN Episode M" (dimmed)
seasonNumber: number; // For navigation to episode
episodeNumber: number; // For navigation to episode
name: string // Episode name (highlighted)
location: string // "SN Episode M" (dimmed)
seasonNumber: number // For navigation to episode
episodeNumber: number // For navigation to episode
}
// Info about why a search matched this item
export interface SearchMatchInfo {
// Matched people with their roles/characters
matchedPeople?: MatchedPerson[];
matchedPeople?: MatchedPerson[]
// Matched episodes for series
matchedEpisodes?: MatchedEpisode[];
matchedEpisodes?: MatchedEpisode[]
}
export interface MediaItem {
id: string;
title: string | null;
year?: number | null;
cover_path: string | null;
showreel_images?: string[] | null;
type: MediaType;
resolution?: string | null;
data: Movie | Series | EpisodeWithSeries;
id: string
title: string | null
year?: number | null
cover_path: string | null
showreel_images?: string[] | null
showreel_source_sets?: string[][] | null
type: MediaType
resolution?: string | null
data: Movie | Series | EpisodeWithSeries
root_id: string | null
// Optional search match info - only present in search results
searchMatchInfo?: SearchMatchInfo;
searchMatchInfo?: SearchMatchInfo
}
// Episode with parent series info for standalone display
export interface EpisodeWithSeries {
episode: Episode;
series: Series;
seasonNumber: number;
episode: Episode
series: SeriesUi
seasonNumber: number
}
// Task progress info from background scanning
export interface TaskInfo {
id: string;
status: string;
progress: number;
detail: string;
id: string
status: string
progress: number
detail: string
}
// WebSocket message types (matching server msgspec tagged structs)
export interface WsRootStatus {
root_id: string
path: string
status: string
error: string | null
snapshot_loaded: boolean
movies: number
series: number
}
export interface WsRootInitData {
movies: Record<string, Movie>
series: Record<string, Series>
people?: Record<string, PersonWire>
}
export interface WsRootsMessage {
type: "roots"
roots: WsRootStatus[]
}
export interface WsInitMessage {
type: 'init';
data: { movies: Movie[]; series: Series[] };
type: "init"
roots: Record<string, WsRootInitData>
}
export interface WsUpsertMessage {
type: 'upsert';
kind: 'movie' | 'series';
item: Movie | Series;
type: "upsert"
root_id: string
kind: "movie" | "series"
id: string
item: Movie | Series
people?: Record<string, PersonWire>
}
export interface WsRemoveMessage {
type: 'remove';
kind: 'movie' | 'series';
id: string;
type: "remove"
root_id: string
kind: "movie" | "series"
id: string
}
export interface WsTaskMessage {
type: 'task';
data: TaskInfo;
type: "task"
root_id: string
data: TaskInfo
}
export type WsMessage = WsInitMessage | WsUpsertMessage | WsRemoveMessage | WsTaskMessage;
export type WsMessage =
| WsRootsMessage
| WsInitMessage
| WsUpsertMessage
| WsRemoveMessage
| WsTaskMessage
+442
View File
@@ -0,0 +1,442 @@
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",
esl: "ES",
spl: "ES",
"es-es": "ES",
"es-419": "ES",
"spa-la": "ES",
// Portuguese
pt: "PT",
por: "PT",
"pt-pt": "PT",
"pt-br": "BR",
// 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",
: "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",
}
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
const hyphenParts = normalized.split("-")
if (hyphenParts.length >= 2) {
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",
"spa-la": "Spanish",
esl: "Spanish",
spl: "Spanish",
por: "Portuguese",
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(", ")
}
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-plugin-pwa/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
declare module "*.vue" {
import type { DefineComponent } from "vue"
const component: DefineComponent<{}, {}, any>
export default component
}
+4 -47
View File
@@ -1,6 +1,5 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { VitePWA } from "vite-plugin-pwa";
import fastapiVue from './vite-plugin-fastapi.js'
// https://vitejs.dev/config/
@@ -8,52 +7,6 @@ export default defineConfig(async () => ({
plugins: [
fastapiVue(),
vue(),
VitePWA({
registerType: "autoUpdate",
injectRegister: "script",
manifest: {
id: "/",
name: "MediaHive",
short_name: "MediaHive",
description: "Movies and Series",
start_url: "/",
scope: "/",
display_override: ["window-controls-overlay", "fullscreen", "standalone"],
display: "fullscreen",
background_color: "#0a0a0a",
theme_color: "#0a0a0a",
icons: [
{
src: "/mediahive-32.webp",
sizes: "32x32",
type: "image/webp"
},
{
src: "/mediahive.webp",
sizes: "192x192",
type: "image/webp"
}
]
},
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
manifestTransforms: [
async (entries) => {
const manifest = entries.map((entry) =>
entry.url === "index.html" ? { ...entry, url: "/" } : entry
);
return { manifest, warnings: [] };
},
],
navigateFallback: null,
navigateFallbackDenylist: [/^\/api\//],
},
devOptions: {
enabled: false,
},
}),
],
// Vite dev server options
@@ -62,4 +15,8 @@ export default defineConfig(async () => ({
port: 8420,
strictPort: true,
},
worker: {
format: "es",
},
}));
+1 -1
View File
@@ -1 +1 @@
"""MediaHive - Media Browser Server"""
"""MediaHive - Media Browser Server."""
+49 -21
View File
@@ -1,4 +1,8 @@
"""MediaHive CLI entrypoint."""
import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
@@ -9,30 +13,40 @@ DEFAULT_PORT = 8420
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
def resolve_media_root(path: str | None = None) -> Path:
"""Resolve the media root folder from a path, MEDIAHIVE_PATH env, or cwd."""
match Path(path or os.environ.get("MEDIAHIVE_PATH") or Path.cwd()).parts:
case (*rest, ".mediahive", "index.json"):
...
case (*rest, ".mediahive"):
...
case rest:
...
mediaroot = Path(*rest).resolve()
if not mediaroot.exists() or not mediaroot.is_dir():
sys.stderr.write(f"Error: Folder does not exist: {mediaroot}\n")
sys.exit(1)
return mediaroot
def _configure_windows_event_loop_policy() -> None:
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
if sys.platform != "win32":
return
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None)
if policy_cls is None:
return
asyncio.set_event_loop_policy(policy_cls())
def main():
def _derive_name(path: str) -> str:
"""Derive a root name from a path."""
p = Path(path)
return p.name or p.anchor.strip("/\\").lower() or "media"
def main() -> None:
_configure_windows_event_loop_policy()
parser = argparse.ArgumentParser(
description="MediaHive - Media scanning, indexing, and streaming"
)
parser.add_argument(
"media_folder",
nargs="?",
help="Path to the media folder (default: MEDIAHIVE_PATH or current directory)",
"media_folders",
nargs="*",
metavar="MEDIA_FOLDER",
help=(
"One or more media folders to index "
"(default: none — configure via UI or API)"
),
)
parser.add_argument(
"-l",
@@ -43,15 +57,29 @@ def main():
args = parser.parse_args()
mediaroot = resolve_media_root(args.media_folder)
os.environ["MEDIAHIVE_PATH"] = mediaroot.as_posix()
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()
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots)
dev = {"reload": True, "reload_dirs": ["mediahive"]}
server.run(
"mediahive.server:app",
listen=args.listen,
default_port=DEFAULT_PORT,
**(dev if DEVMODE else {}),
loop="none" if sys.platform == "win32" else "auto",
**(dev if DEVMODE and sys.platform != "win32" else {}),
)
+253
View File
@@ -0,0 +1,253 @@
"""Custom access logging middleware for FastAPI/Uvicorn."""
import logging
import sys
import time
from ipaddress import IPv6Address
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger("mediahive.access")
_RESET = "\033[0m"
_STATUS_INFO = "\033[32m" # 1xx (green)
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
_HOST = "\033[38;5;242m" # hostname (dark grey)
_PATH = "\033[38;5;250m" # path (white)
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
_WS_OPEN = "\033[38;5;226m" # WebSocket connect (brightest yellow from 6x6x6 cube)
_WS_CLOSE = "\033[38;5;142m" # WebSocket disconnect (significantly dimmer yellow)
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
def format_ipv6_network(ip: str) -> str:
"""Format IPv6 address to show only network part (first 64 bits).
Special addresses are returned as-is for clarity:
- ::1 (loopback)
- :: (unspecified)
- ::ffff:x.x.x.x (IPv4-mapped, returns just the IPv4 part)
- fe80:: (link-local, returned as-is since interface-specific)
"""
try:
# Strip brackets that some proxies add around IPv6
ip = ip.strip("[]")
# Strip zone ID (e.g., fe80::1%eth0)
if "%" in ip:
ip = ip.split("%")[0]
addr = IPv6Address(ip)
# Special cases - return as-is or with minimal processing
if addr.is_loopback: # ::1
return "::1"
if addr.is_unspecified: # ::
return "::"
if addr.ipv4_mapped: # ::ffff:x.x.x.x
return str(addr.ipv4_mapped)
if addr.is_link_local: # fe80::/10 - interface-specific, keep full
return str(addr)
# Regular addresses: truncate to /64 network prefix
network_int = int(addr) >> 64
# Format as IPv6 with trailing ::
# Split into 4 groups of 16 bits
groups = []
for _ in range(4):
groups.insert(0, format(network_int & 0xFFFF, "x"))
network_int >>= 16
# Compress consecutive zero groups
result = ":".join(groups) + "::"
# Simplify leading zeros in groups and compress, then strip trailing ::
return str(IPv6Address(result + "0")).removesuffix("::")
except Exception:
return ip
def format_client_ip(ip: str) -> str:
"""Format client IP, compressing IPv6 to network part only."""
if not ip or ip == "-":
return "-"
# Strip brackets for detection (some proxies add them)
stripped = ip.strip("[]")
if ":" in stripped:
return format_ipv6_network(ip)
return ip
def status_color(status: int) -> str:
"""Return color code based on HTTP status."""
if status < 200:
return _STATUS_INFO
if status < 300:
return _STATUS_OK
if status < 400:
return _STATUS_REDIRECT
if status < 500:
return _STATUS_CLIENT_ERR
return _STATUS_SERVER_ERR
def method_color(method: str) -> str:
"""Return color code based on HTTP method."""
if method in ("GET", "HEAD", "OPTIONS"):
return _METHOD_READ
return _METHOD_WRITE
def format_access_log(
client: str,
status: int,
method: str,
host: str,
path: str,
duration_ms: float,
extra: str = "",
) -> str:
"""Format access log line with colors and aligned fields."""
# Format components with fixed widths for alignment
ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
timing = f"{duration_ms:.0f}ms"
method_padded = method.ljust(7) # Longest method is OPTIONS (7)
status_str = f"{status_color(status)}{status}{_RESET}"
timing_str = f"{_TIMING}{timing}{_RESET}"
method_str = f"{method_color(method)}{method_padded}{_RESET}"
host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}"
# Format: "IP STATUS METHOD host path [extra] TIMING"
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
return (
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
)
# WebSocket connection counter (mod 100)
_ws_counter = 0
def _next_ws_id() -> int:
"""Get next WebSocket connection ID (0-99)."""
global _ws_counter
ws_id = _ws_counter
_ws_counter = (_ws_counter + 1) % 100
return ws_id
def log_ws_open(ws) -> int:
"""Log WebSocket connection open. Returns connection ID for use in close."""
ws_id = _next_ws_id()
client = ws.client.host if ws.client else "-"
host = ws.headers.get("host", "-")
path = ws.url.path
origin = ws.headers.get("origin")
ip = format_client_ip(client).ljust(19)
# ID right-aligned like status codes (3 chars), emoji formatted like method
id_str = f"{_WS_OPEN}{str(ws_id).rjust(3)}{_RESET}"
# Emoji (2 display width) + 6 spaces = 8 display chars, but within color for alignment
emoji_str = f"{_METHOD_READ}🔌 {_RESET}"
# Determine if origin should be shown (omit when same as host)
# Origin header includes scheme (e.g., "https://example.com"), compare host part
origin_host = origin.split("://", 1)[-1] if origin else None
show_origin = origin_host and origin_host != host
host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}"
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
logger.info(f"{ip} {id_str} {emoji_str}{host_str}{path_str}{origin_str}")
return ws_id
# WebSocket close codes to human-readable status
WS_CLOSE_CODES = {
1000: "ok",
1001: "going away",
1002: "protocol error",
1003: "unsupported",
1005: "no status",
1006: "abnormal",
1007: "invalid data",
1008: "policy violation",
1009: "too large",
1010: "extension required",
1011: "server error",
1012: "restarting",
1013: "try again",
1014: "bad gateway",
1015: "tls error",
}
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
"""Log WebSocket connection close with duration and status."""
# ID right-aligned like status codes (3 chars), "closed" formatted like method
id_str = f"{_WS_CLOSE}{str(ws_id).rjust(3)}{_RESET}"
# Pad within the dim color to keep full width in color (8 display chars)
closed_str = f"{_TIMING}closed {_RESET}"
timing = f"{duration * 1000:.0f}ms"
# Convert close code to status text
if close_code is None:
code = "----"
status = "unknown"
else:
code = str(close_code)
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
# Status code and text in normal color, not dim
status_str = f"{code} {status}"
timing_str = f"{_TIMING}{timing}{_RESET}"
logger.info(f"{' ' * 19} {id_str} {closed_str}{status_str} {timing_str}")
class AccessLogMiddleware(BaseHTTPMiddleware):
"""Middleware that logs HTTP requests with custom format."""
async def dispatch(self, request: Request, call_next) -> Response:
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
client = request.client.host if request.client else "-"
host = request.headers.get("host", "-")
method = request.method
path = request.url.path
if request.url.query:
path = f"{path}?{request.url.query}"
status = response.status_code
extra = getattr(request.state, "log_extra", "")
line = format_access_log(
client, status, method, host, path, duration_ms, extra=extra
)
logger.info(line)
return response
def configure_access_logging():
"""Configure the access logger to output to stderr."""
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
# Suppress uvicorn access logs to avoid duplicate request lines.
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
# Suppress uvicorn websocket "connection open/closed" messages.
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
# Suppress watchfiles "X changes detected" INFO messages (keep WARNING for reload notification)
logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+19 -4
View File
@@ -1,7 +1,7 @@
"""Platform-appropriate config persistence for MediaHive.
r"""Platform-appropriate config persistence for MediaHive.
Config file location:
Windows: %APPDATA%\\mediahive\\config.toml
Windows: %APPDATA%\mediahive\config.toml
macOS: ~/Library/Application Support/mediahive/config.toml
Linux: $XDG_CONFIG_HOME/mediahive/config.toml (~/.config/mediahive/config.toml)
"""
@@ -16,6 +16,7 @@ import msgspec.toml
class Config(msgspec.Struct):
media_folder: str | None = None
roots: dict[str, str] | None = None
def config_dir() -> Path:
@@ -32,12 +33,26 @@ def config_path() -> Path:
return config_dir() / "config.toml"
def _migrate_legacy_media_folder(cfg: Config) -> Config:
"""If roots is empty but media_folder exists, seed roots with it."""
if cfg.roots:
return cfg
if not cfg.media_folder:
return cfg
path = Path(cfg.media_folder)
name = path.name or path.anchor.strip("/\\").lower() or "media"
# Resolve collisions simply by using the basename; if user had weird layout
# they can rename via the UI later.
return msgspec.structs.replace(cfg, roots={name: cfg.media_folder})
def load_config() -> Config:
path = config_path()
if path.exists():
try:
return msgspec.toml.decode(path.read_bytes(), type=Config)
except Exception:
cfg = msgspec.toml.decode(path.read_bytes(), type=Config)
return _migrate_legacy_media_folder(cfg)
except OSError, msgspec.DecodeError, msgspec.ValidationError:
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:
from mediahive.hivescan.scanner import start, stop
@@ -9,13 +8,13 @@ Import from submodules directly:
"""
# Minimal public API - prefer importing from submodules directly
from mediahive.hivescan.models import ContentType, ContentHash, ParsedContent
from mediahive.hivescan.models import ContentHash, ContentType, ParsedContent
from mediahive.hivescan.utils import DEFAULT_OUTPUT_FOLDER, find_common_root
__all__ = [
"ContentType",
"ContentHash",
"ParsedContent",
"DEFAULT_OUTPUT_FOLDER",
"ContentHash",
"ContentType",
"ParsedContent",
"find_common_root",
]
+25 -12
View File
@@ -1,10 +1,27 @@
"""Hivescan CLI entrypoint."""
import argparse
import asyncio
import json
import logging
import os
import sys
from pathlib import Path
def main():
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()
parser = argparse.ArgumentParser(
description="Hivescan server — continuous media scanning with live WS updates.",
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -16,11 +33,8 @@ Examples:
Exclude paths by creating .mediahive/scanignore (gitignore syntax).
The server exposes:
WS /ws Live index updates & task progress
POST /api/scan Trigger a new scan
GET /api/status Current server status
GET /api/index Full index as JSON (HTTP fallback)
The server exposes a unified endpoint:
WS /api/ws Live index updates, task progress, and root status changes
""",
)
parser.add_argument(
@@ -41,12 +55,11 @@ The server exposes:
args = parser.parse_args()
media_root = Path(args.media_folder).resolve()
if not media_root.exists() or not media_root.is_dir():
print(f"Error: Folder does not exist: {media_root}")
exit(1)
os.environ["MEDIAHIVE_PATH"] = str(media_root)
# Defer filesystem validation to the server; pass raw path via env.
media_root = Path(args.media_folder).expanduser()
os.environ["MEDIAHIVE_ROOTS"] = json.dumps({
media_root.name or "media": media_root.as_posix()
})
logging.basicConfig(
level=logging.INFO,
+55 -18
View File
@@ -1,21 +1,21 @@
"""TMDb image downloading functions."""
import httpx
import re
from pathlib import Path
from typing import Optional
import httpx
from aiopathlib import AsyncPath
from mediahive.hivescan.utils import get_media_folder_path
# TMDb image configuration
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
DEFAULT_POSTER_SIZE = "w500"
DEFAULT_BACKDROP_SIZE = "w1280"
DEFAULT_PROFILE_SIZE = "w185"
# Shared async HTTP client (created lazily)
_image_client: Optional[httpx.AsyncClient] = None
_image_client: httpx.AsyncClient | None = None
def _get_image_client() -> httpx.AsyncClient:
@@ -30,13 +30,19 @@ def _get_image_client() -> httpx.AsyncClient:
return _image_client
async def _download_image(
url: str, output_path: Path, description: str
) -> Optional[str]:
async def close_image_client() -> None:
"""Close the persistent image HTTP client if it was created."""
global _image_client
if _image_client is not None:
await _image_client.aclose()
_image_client = None
async def _download_image(url: str, output_path: Path, description: str) -> str | None:
"""Download an image from URL to output path."""
ap = AsyncPath(output_path)
if await ap.exists():
return str(output_path)
return output_path.as_posix()
try:
client = _get_image_client()
@@ -44,8 +50,8 @@ async def _download_image(
response.raise_for_status()
await AsyncPath(output_path.parent).mkdir(parents=True, exist_ok=True)
await ap.write_bytes(response.content)
return str(output_path)
except Exception as e:
return output_path.as_posix()
except (httpx.HTTPError, OSError) as e:
print(f" Failed to download {description}: {e}")
return None
@@ -53,11 +59,11 @@ async def _download_image(
async def download_cover_image(
poster_path: str,
title: str,
year: Optional[int],
year: int | None,
media_type: str,
cover_dir: Path,
size: str = DEFAULT_POSTER_SIZE,
) -> Optional[str]:
) -> str | None:
"""Download a cover image from TMDb."""
if not poster_path:
return None
@@ -66,7 +72,7 @@ async def download_cover_image(
cover_path = media_folder / "cover.jpg"
if await AsyncPath(cover_path).exists():
return str(cover_path)
return cover_path.as_posix()
url = f"{TMDB_IMAGE_BASE}/{size}{poster_path}"
print(f" Downloading cover: {title}")
@@ -76,11 +82,11 @@ async def download_cover_image(
async def download_backdrop_image(
backdrop_path: str,
title: str,
year: Optional[int],
year: int | None,
media_type: str,
cover_dir: Path,
size: str = DEFAULT_BACKDROP_SIZE,
) -> Optional[str]:
) -> str | None:
"""Download a backdrop image from TMDb."""
if not backdrop_path:
return None
@@ -89,7 +95,7 @@ async def download_backdrop_image(
local_path = media_folder / "backdrop.jpg"
if await AsyncPath(local_path).exists():
return str(local_path)
return local_path.as_posix()
url = f"{TMDB_IMAGE_BASE}/{size}{backdrop_path}"
print(f" Downloading backdrop: {title}")
@@ -100,7 +106,7 @@ async def download_season_poster(
poster_path: str,
media_folder: Path,
season_num: int,
) -> Optional[str]:
) -> str | None:
"""Download a season poster image from TMDb."""
if not poster_path:
return None
@@ -108,8 +114,39 @@ async def download_season_poster(
output_path = media_folder / f"season{season_num:02d}.jpg"
if await AsyncPath(output_path).exists():
return str(output_path)
return output_path.as_posix()
await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True)
url = f"{TMDB_IMAGE_BASE}/{DEFAULT_POSTER_SIZE}{poster_path}"
return await _download_image(url, output_path, f"season {season_num} poster")
async def download_cast_profile(
profile_path: str,
media_folder: Path,
cast_name: str,
person_id: int | None,
size: str = DEFAULT_PROFILE_SIZE,
) -> str | None:
"""Download a cached cast profile image from TMDb."""
if not profile_path:
return None
# Shared people cache avoids duplicating identical actor images per title.
people_dir = media_folder.parent.parent / "people"
safe_name = _slugify_person_name(cast_name) or "Unknown"
person_suffix = str(person_id) if person_id is not None else "unknown"
output_path = people_dir / f"{safe_name}-{person_suffix}.jpg"
if await AsyncPath(output_path).exists():
return output_path.as_posix()
await AsyncPath(people_dir).mkdir(parents=True, exist_ok=True)
url = f"{TMDB_IMAGE_BASE}/{size}{profile_path}"
return await _download_image(url, output_path, f"cast profile for {cast_name}")
def _slugify_person_name(name: str) -> str:
"""Slugify a person name preserving capitals and hyphens; use dots as separators."""
slug = re.sub(r"[^0-9A-Za-z-]+", ".", name)
return re.sub(r"\.+", ".", slug).strip(".")
+397 -178
View File
@@ -1,14 +1,45 @@
"""Media index generation — async generators for continuous scanning."""
import asyncio
import hashlib
import logging
import re
from collections.abc import AsyncIterator
from pathlib import Path
from typing import AsyncIterator, Dict, List, Optional, Tuple
from mediahive.hivescan.images import (
download_backdrop_image,
download_cast_profile,
download_cover_image,
download_season_poster,
)
from mediahive.hivescan.models import ContentType, ParsedContent
from mediahive.hivescan.scanning import (
find_cover_image,
find_episode_files,
find_metadata_probe_file,
find_playable_file,
)
from mediahive.hivescan.showreel import (
get_expected_episode_reel_path,
get_expected_showreel_paths,
get_existing_episode_reel_path,
get_existing_episode_reel_sources,
get_existing_showreel_paths,
get_existing_showreel_source_sets,
probe_media_info,
)
from mediahive.hivescan.tmdb_client import (
fetch_movie_info,
fetch_season_details,
fetch_series_info,
)
from mediahive.hivescan.utils import (
RESOLUTION_PRIORITY,
build_movie_id,
build_series_id,
get_added_timestamp,
get_directory_size,
get_media_folder_path,
make_relative_path,
sort_by_quality,
)
from mediahive.models.data import (
Episode,
@@ -17,68 +48,145 @@ from mediahive.models.data import (
Series,
Torrent,
)
from mediahive.models.tmdb import EpisodeInfo, Info, SeasonInfo
from mediahive.hivescan.tmdb_client import (
fetch_movie_info,
fetch_series_info,
fetch_season_details,
)
from mediahive.hivescan.models import ContentType, ParsedContent
from mediahive.hivescan.scanning import (
find_cover_image,
find_episode_files,
find_playable_file,
)
from mediahive.hivescan.images import (
download_cover_image,
download_backdrop_image,
download_season_poster,
)
from mediahive.hivescan.utils import (
get_added_timestamp,
get_directory_size,
get_media_folder_path,
make_relative_path,
sort_by_quality,
RESOLUTION_PRIORITY,
)
from mediahive.models.tmdb import EpisodeInfo, Info, Person, SeasonInfo
logger = logging.getLogger("hivescan.indexer")
_HDR10PLUS_RE = re.compile(r"hdr10\+|hdr10plus", re.IGNORECASE)
def _infer_hdr10plus(*values: str | None) -> bool:
"""Infer HDR10+ from parsed release strings when probe data is ambiguous."""
text = " ".join(v for v in values if v)
return bool(_HDR10PLUS_RE.search(text))
def _compact_playable_file(file_key: str, playable_file: str | None) -> str | None:
"""Store playable paths compactly relative to the file key when possible."""
if not playable_file:
return None
if playable_file == file_key:
return None
prefix = f"{file_key}/"
if playable_file.startswith(prefix):
rel = playable_file[len(prefix) :]
return rel or None
return playable_file
def _expand_playable_file(file_key: str, playable_file: str | None) -> str | None:
"""Expand compact playable paths back to media-root-relative paths."""
if not playable_file:
return file_key
if playable_file.startswith("concat:") or "://" in playable_file:
return playable_file
prefix = f"{file_key}/"
if playable_file.startswith(prefix):
return playable_file
if playable_file.startswith("/"):
return playable_file.lstrip("/")
return f"{file_key}/{playable_file}"
async def _build_torrent_info(
item: ParsedContent, media_root: Optional[str] = None
item: ParsedContent,
file_key: str,
media_root: str | None = None,
) -> Torrent:
"""Build torrent info for a single torrent."""
playable_file = await find_playable_file(item.path)
probe_info = None
probe_target = await find_metadata_probe_file(playable_file)
if probe_target:
probe_info = await probe_media_info(str(probe_target))
if item.content_hash and item.content_hash.size == 0:
item.content_hash.size = await get_directory_size(item.content_hash.path)
item.content_hash.size = await asyncio.to_thread(
get_directory_size,
item.content_hash.path,
)
size = item.content_hash.size if item.content_hash else None
added_at = await get_added_timestamp(item.path)
playable_rel = make_relative_path(playable_file, media_root)
hdr10plus_from_text = _infer_hdr10plus(
item.title,
item.quality,
item.codec,
item.audio,
playable_rel,
)
return Torrent(
title=item.title,
playable_file=make_relative_path(playable_file, media_root),
resolution=item.resolution,
playable_file=_compact_playable_file(file_key, playable_rel),
resolution=(probe_info.resolution if probe_info else None) or item.resolution,
quality=item.quality,
network=item.network,
codec=item.codec,
audio=item.audio,
audio_languages=probe_info.audio_languages if probe_info else None,
subtitle_languages=probe_info.subtitle_languages if probe_info else None,
hdr=probe_info.hdr if probe_info else False,
dovi=probe_info.dovi if probe_info else False,
atmos=probe_info.atmos if probe_info else False,
hdr10plus=(probe_info.hdr10plus if probe_info else False)
or hdr10plus_from_text,
encoder=item.encoder,
size=size,
added_at=added_at,
)
async def _cache_people_profiles(
info: Info | None,
people: dict[int, Person],
media_folder: Path,
media_root: str | None = None,
) -> tuple[Info | None, dict[int, Person]]:
"""Cache people profile images and keep people payload filename-only."""
_ = media_root
if not info or not info.cast:
return info, people
for cast_credit in info.cast:
if cast_credit.id is None:
continue
person = people.get(cast_credit.id)
if person is None or not person.profile_path:
continue
downloaded_path = await download_cast_profile(
person.profile_path,
media_folder,
person.name,
cast_credit.id,
)
if downloaded_path:
people[cast_credit.id] = Person(
name=person.name,
profile_path=Path(downloaded_path).name,
gender=person.gender,
)
return info, people
async def _collect_episode_files(
items: List[ParsedContent],
) -> Dict[Tuple[int, int], List[Dict]]:
"""
Collect all episode files from a list of torrent items.
items: list[ParsedContent],
) -> dict[tuple[int, int], list[dict]]:
"""Collect all episode files from a list of torrent items.
Returns dict mapping (season, episode) to list of file info dicts.
"""
all_episode_files: Dict[Tuple[int, int], List[Dict]] = {}
all_episode_files: dict[tuple[int, int], list[dict]] = {}
probe_cache: dict[str, object] = {}
async def get_probe(path: str):
cached = probe_cache.get(path)
if cached is not None:
return cached
probe = await probe_media_info(path)
probe_cache[path] = probe
return probe
for item in items:
episode_files = await find_episode_files(item.path)
@@ -88,19 +196,26 @@ async def _collect_episode_files(
if key not in all_episode_files:
all_episode_files[key] = []
for file_path, file_size in files:
all_episode_files[key].append(
{
probe = await get_probe(file_path)
all_episode_files[key].append({
"path": file_path,
"size": file_size,
"probed_resolution": probe.resolution,
"audio_languages": probe.audio_languages,
"subtitle_languages": probe.subtitle_languages,
"hdr": probe.hdr,
"dovi": probe.dovi,
"atmos": probe.atmos,
"hdr10plus": probe.hdr10plus,
"resolution": item.resolution,
"quality": item.quality,
"network": item.network,
"codec": item.codec,
"audio": item.audio,
"encoder": item.encoder,
"torrent_path": str(item.path),
"torrent_path": item.path.as_posix(),
"torrent_title": item.title,
}
)
})
# Handle individual episodes from PTN parsing
if item.episode is not None and item.season is not None:
@@ -113,6 +228,7 @@ async def _collect_episode_files(
playable = await find_playable_file(item.path)
if playable:
probe = await get_probe(playable)
for sn in season_nums:
for ep in episode_nums:
key = (sn, ep)
@@ -124,37 +240,44 @@ async def _collect_episode_files(
)
if not already_added:
if item.content_hash and item.content_hash.size == 0:
item.content_hash.size = await get_directory_size(
item.content_hash.path
item.content_hash.size = await asyncio.to_thread(
get_directory_size,
item.content_hash.path,
)
size = item.content_hash.size if item.content_hash else 0
all_episode_files[key].append(
{
all_episode_files[key].append({
"path": playable,
"size": size,
"probed_resolution": probe.resolution,
"audio_languages": probe.audio_languages,
"subtitle_languages": probe.subtitle_languages,
"hdr": probe.hdr,
"dovi": probe.dovi,
"atmos": probe.atmos,
"hdr10plus": probe.hdr10plus,
"resolution": item.resolution,
"quality": item.quality,
"network": item.network,
"codec": item.codec,
"audio": item.audio,
"encoder": item.encoder,
"torrent_path": str(item.path),
"torrent_path": item.path.as_posix(),
"torrent_title": item.title,
}
)
})
return all_episode_files
def _build_episodes_data(
episodes_in_season: Dict[int, List[Dict]],
tmdb_episodes: Dict[int, EpisodeInfo],
episodes_in_season: dict[int, list[dict]],
tmdb_episodes: dict[int, EpisodeInfo],
series_folder: Path,
season_num: int,
generate_showreels: bool,
episode_reel_tasks: List,
episode_reel_tasks: list,
series_title: str,
media_root: Optional[str] = None,
) -> List[Episode]:
media_root: str | None = None,
) -> list[Episode]:
"""Build episode data list for a season."""
episodes_data = []
@@ -165,29 +288,55 @@ def _build_episodes_data(
tmdb_ep = tmdb_episodes.get(episode_num)
reel_path = None
reel_sources = None
if generate_showreels and episode_files:
best_file = episode_files[0]["path"]
if best_file and not best_file.endswith(".bdmv"):
reel_path = get_expected_episode_reel_path(
if best_file and not best_file.endswith((".bdmv", ".ifo")):
reel_sources = get_existing_episode_reel_sources(
series_folder,
season_num,
episode_num,
media_root=Path(media_root) if media_root else None,
)
episode_reel_tasks.append(
(best_file, series_folder, season_num, episode_num, series_title)
reel_path = get_existing_episode_reel_path(
series_folder,
season_num,
episode_num,
media_root=Path(media_root) if media_root else None,
)
episode_reel_tasks.append((
best_file,
series_folder,
season_num,
episode_num,
series_title,
))
torrents = {}
files = {}
for f in episode_files:
relpath = make_relative_path(f["torrent_path"], media_root)
torrents[relpath] = Torrent(
playable_rel = make_relative_path(f["path"], media_root)
hdr10plus_from_text = _infer_hdr10plus(
f.get("torrent_title"),
f.get("quality"),
f.get("codec"),
f.get("audio"),
playable_rel,
)
files[relpath] = Torrent(
title=f["torrent_title"],
playable_file=make_relative_path(f["path"], media_root),
resolution=f.get("resolution"),
playable_file=_compact_playable_file(relpath, playable_rel),
resolution=f.get("probed_resolution") or f.get("resolution"),
quality=f.get("quality"),
network=f.get("network"),
codec=f.get("codec"),
audio=f.get("audio"),
audio_languages=f.get("audio_languages"),
subtitle_languages=f.get("subtitle_languages"),
hdr=bool(f.get("hdr")),
dovi=bool(f.get("dovi")),
atmos=bool(f.get("atmos")),
hdr10plus=bool(f.get("hdr10plus")) or hdr10plus_from_text,
encoder=f.get("encoder"),
size=f.get("size"),
)
@@ -202,7 +351,8 @@ def _build_episodes_data(
rating=tmdb_ep.vote_average if tmdb_ep else None,
director=tmdb_ep.director if tmdb_ep else None,
reel_image=reel_path,
torrents=torrents,
reel_sources=reel_sources or None,
files=files,
)
episodes_data.append(episode_data)
@@ -210,19 +360,19 @@ def _build_episodes_data(
async def _build_seasons_data(
all_episode_files: Dict[Tuple[int, int], List[Dict]],
tmdb_id: Optional[int],
all_episode_files: dict[tuple[int, int], list[dict]],
tmdb_id: int | None,
series_folder: Path,
display_title: str,
fetch_covers: bool,
generate_showreels: bool,
season_cache: Dict,
episode_reel_tasks: List,
media_root: Optional[str] = None,
) -> List[Season]:
season_cache: dict,
episode_reel_tasks: list,
media_root: str | None = None,
) -> list[Season]:
"""Build seasons data structure for a series."""
# Group episodes by season
seasons_map: Dict[int, Dict[int, List[Dict]]] = {}
seasons_map: dict[int, dict[int, list[dict]]] = {}
for (season_num, episode_num), files in all_episode_files.items():
if season_num not in seasons_map:
seasons_map[season_num] = {}
@@ -234,7 +384,7 @@ async def _build_seasons_data(
# Fetch TMDb season details if we have a TMDb ID
tmdb_season = None
tmdb_episodes: Dict[int, EpisodeInfo] = {}
tmdb_episodes: dict[int, EpisodeInfo] = {}
if tmdb_id:
cache_key = (tmdb_id, season_num)
@@ -290,17 +440,26 @@ async def _process_movies(
cover_dir: Path,
fetch_covers: bool,
generate_showreels: bool,
media_root: Optional[str] = None,
) -> AsyncIterator[Tuple[Movie, Optional[Tuple[str, Path, str]]]]:
"""
Async generator that processes all movies.
media_root: str | None = None,
root_id: str | None = None,
) -> AsyncIterator[tuple[str, Movie, tuple[str, Path, str] | None, dict[int, Person]]]:
"""Async generator that processes all movies.
Yields:
Tuples of ``(Movie, showreel_task_or_None)`` as each movie is processed.
Yields (Movie, showreel_task_or_None) for each movie as it is processed.
"""
_ = root_id
# In-memory cache for TMDb lookups
movie_tmdb_cache: Dict[str, Optional[Info]] = {}
movie_tmdb_cache: dict[
str,
tuple[Info, str | None, str | None, dict[int, Person]] | None,
] = {}
async def get_movie_tmdb(title: str, year: Optional[int]) -> Optional[Info]:
async def get_movie_tmdb(
title: str,
year: int | None,
) -> tuple[Info, str | None, str | None, dict[int, Person]] | None:
cache_key = f"{title.lower()}:{year}"
if cache_key in movie_tmdb_cache:
return movie_tmdb_cache[cache_key]
@@ -312,10 +471,9 @@ async def _process_movies(
return await find_playable_file(item.path) is not None
# Filter movies with playable files
valid_movies = []
for item in categories[ContentType.MOVIE]:
if await has_playable(item):
valid_movies.append(item)
valid_movies = [
item for item in categories[ContentType.MOVIE] if await has_playable(item)
]
skipped = len(categories[ContentType.MOVIE]) - len(valid_movies)
if skipped > 0:
logger.debug(
@@ -323,7 +481,7 @@ async def _process_movies(
)
# Group by title+year
movie_groups: Dict[str, List[ParsedContent]] = {}
movie_groups: dict[str, list[ParsedContent]] = {}
for item in valid_movies:
key = f"{item.title.lower()}:{item.year or 0}"
if key not in movie_groups:
@@ -331,8 +489,8 @@ async def _process_movies(
movie_groups[key].append(item)
# Re-group by TMDb ID
tmdb_movie_groups: Dict[int, Dict] = {}
no_tmdb_movie_groups: Dict[str, Dict] = {}
tmdb_movie_groups: dict[int, dict] = {}
no_tmdb_movie_groups: dict[str, dict] = {}
if movie_groups:
logger.info(
@@ -341,7 +499,7 @@ async def _process_movies(
len(categories[ContentType.MOVIE]),
) if movie_groups else None
for idx, (movie_key, items) in enumerate(movie_groups.items(), 1):
for idx, (_movie_key, items) in enumerate(movie_groups.items(), 1):
first_item = items[0]
logger.debug(
" [%d/%d] %s (%s)",
@@ -351,19 +509,22 @@ async def _process_movies(
first_item.year,
)
tmdb_info = await get_movie_tmdb(first_item.title, first_item.year)
# Yield to event loop so HTTP requests stay responsive
if idx % 20 == 0:
await asyncio.sleep(0)
tmdb_result = await get_movie_tmdb(first_item.title, first_item.year)
if tmdb_info and tmdb_info.tmdb_id:
if tmdb_result and tmdb_result[0].tmdb_id:
tmdb_info, poster_path_ref, backdrop_path_ref, people = tmdb_result
if tmdb_info.tmdb_id not in tmdb_movie_groups:
tmdb_movie_groups[tmdb_info.tmdb_id] = {
"tmdb_info": tmdb_info,
"poster_path_ref": poster_path_ref,
"backdrop_path_ref": backdrop_path_ref,
"people": people,
"items": [],
"torrent_titles": set(),
"year": first_item.year,
}
else:
tmdb_movie_groups[tmdb_info.tmdb_id]["people"].update(people)
tmdb_movie_groups[tmdb_info.tmdb_id]["items"].extend(items)
tmdb_movie_groups[tmdb_info.tmdb_id]["torrent_titles"].add(first_item.title)
else:
@@ -377,14 +538,18 @@ async def _process_movies(
no_tmdb_movie_groups[key]["items"].extend(items)
# Process movies with TMDb info — yield each as ready
for tmdb_id, group_data in tmdb_movie_groups.items():
for group_data in tmdb_movie_groups.values():
tmdb_info = group_data["tmdb_info"]
poster_path_ref = group_data["poster_path_ref"]
backdrop_path_ref = group_data["backdrop_path_ref"]
people = group_data["people"]
items = group_data["items"]
torrent_titles = group_data["torrent_titles"]
year = group_data["year"]
display_title = tmdb_info.title
item_id = hashlib.md5(f"movie:{tmdb_id}".encode()).hexdigest()[:12]
item_id = build_movie_id(display_title, year)
media_folder = get_media_folder_path(display_title, year, "movie", cover_dir)
# Find/download cover
cover_path = None
@@ -395,76 +560,94 @@ async def _process_movies(
cover_path = await find_cover_image(tt, year, "movie", cover_dir)
if cover_path:
break
if not cover_path and tmdb_info.poster_path:
if not cover_path and poster_path_ref:
cover_path = await download_cover_image(
tmdb_info.poster_path, display_title, year, "movie", cover_dir
poster_path_ref,
display_title,
year,
"movie",
cover_dir,
)
tmdb_info, people = await _cache_people_profiles(
tmdb_info,
people,
media_folder,
media_root,
)
torrents = {}
files = {}
for item in items:
relpath = make_relative_path(str(item.path), media_root)
torrent = await _build_torrent_info(item, media_root)
torrents[relpath] = torrent
relpath = make_relative_path(item.path.as_posix(), media_root)
torrent = await _build_torrent_info(item, relpath, media_root)
files[relpath] = torrent
sort_by_quality(list(torrents.values()))
sort_by_quality(list(files.values()))
# Queue showreel generation
showreel_paths = []
showreel_source_sets = []
showreel_task = None
if generate_showreels and torrents:
if generate_showreels and files:
# Find the best version for showreel (highest quality)
best_relpath = max(
torrents.keys(),
files.keys(),
key=lambda k: (
RESOLUTION_PRIORITY.get(torrents[k].resolution or "", 0),
torrents[k].size or 0,
RESOLUTION_PRIORITY.get(files[k].resolution or "", 0),
files[k].size or 0,
k,
),
)
best_version = torrents[best_relpath]
if best_version.playable_file:
best_version = files[best_relpath]
best_playable = _expand_playable_file(
best_relpath, best_version.playable_file
)
if best_playable and not best_playable.endswith(".ifo"):
abs_playable = (
str(Path(media_root) / best_version.playable_file)
(Path(media_root) / best_playable).as_posix()
if media_root
else best_version.playable_file
else best_playable
)
media_folder = get_media_folder_path(
display_title, year, "movie", cover_dir
showreel_source_sets = get_existing_showreel_source_sets(
media_folder, media_root=Path(media_root) if media_root else None
)
showreel_paths = get_expected_showreel_paths(
showreel_paths = get_existing_showreel_paths(
media_folder, media_root=Path(media_root) if media_root else None
)
showreel_task = (abs_playable, media_folder, display_title)
# Download backdrop
backdrop_path = None
if fetch_covers and tmdb_info.backdrop_path:
if fetch_covers and backdrop_path_ref:
backdrop_path = await download_backdrop_image(
tmdb_info.backdrop_path, display_title, year, "movie", cover_dir
backdrop_path_ref,
display_title,
year,
"movie",
cover_dir,
)
version_timestamps = [v.added_at for v in torrents.values() if v.added_at]
version_timestamps = [v.added_at for v in files.values() if v.added_at]
newest = max(version_timestamps) if version_timestamps else None
movie = Movie(
id=item_id,
title=display_title,
info=tmdb_info,
year=year,
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
backdrop_path=make_relative_path(backdrop_path, media_root),
showreel_images=showreel_paths if showreel_paths else None,
torrents=torrents,
showreel_images=showreel_paths or None,
showreel_source_sets=showreel_source_sets or None,
files=files,
)
yield movie, showreel_task
yield item_id, movie, showreel_task, people
# Process movies without TMDb info
for key, group_data in no_tmdb_movie_groups.items():
for group_data in no_tmdb_movie_groups.values():
items = group_data["items"]
title = group_data["title"]
year = group_data["year"]
item_id = hashlib.md5(f"movie:{title}:{year}".encode()).hexdigest()[:12]
item_id = build_movie_id(title, year)
cover_path = (
await find_cover_image(title, year, "movie", cover_dir)
@@ -472,55 +655,64 @@ async def _process_movies(
else None
)
torrents = {}
files = {}
for item in items:
relpath = make_relative_path(str(item.path), media_root)
torrent = await _build_torrent_info(item, media_root)
torrents[relpath] = torrent
relpath = make_relative_path(item.path.as_posix(), media_root)
torrent = await _build_torrent_info(item, relpath, media_root)
files[relpath] = torrent
sort_by_quality(list(torrents.values()))
sort_by_quality(list(files.values()))
showreel_paths = []
showreel_source_sets = []
showreel_task = None
if generate_showreels and torrents:
if generate_showreels and files:
# Find the best version for showreel (highest quality)
best_relpath = max(
torrents.keys(),
files.keys(),
key=lambda k: (
RESOLUTION_PRIORITY.get(torrents[k].resolution or "", 0),
torrents[k].size or 0,
RESOLUTION_PRIORITY.get(files[k].resolution or "", 0),
files[k].size or 0,
k,
),
)
best_version = torrents[best_relpath]
if best_version.playable_file and not best_version.playable_file.endswith(
".bdmv"
):
best_version = files[best_relpath]
best_playable = _expand_playable_file(
best_relpath, best_version.playable_file
)
if best_playable and not best_playable.endswith((
".bdmv",
".ifo",
)):
abs_playable = (
str(Path(media_root) / best_version.playable_file)
(Path(media_root) / best_playable).as_posix()
if media_root
else best_version.playable_file
else best_playable
)
media_folder = get_media_folder_path(title, year, "movie", cover_dir)
showreel_paths = get_expected_showreel_paths(
showreel_source_sets = get_existing_showreel_source_sets(
media_folder,
media_root=Path(media_root) if media_root else None,
)
showreel_paths = get_existing_showreel_paths(
media_folder,
media_root=Path(media_root) if media_root else None,
)
showreel_task = (abs_playable, media_folder, title)
version_timestamps = [v.added_at for v in torrents.values() if v.added_at]
version_timestamps = [v.added_at for v in files.values() if v.added_at]
newest = max(version_timestamps) if version_timestamps else None
movie = Movie(
id=item_id,
title=title,
year=year,
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
showreel_images=showreel_paths if showreel_paths else None,
torrents=torrents,
showreel_images=showreel_paths or None,
showreel_source_sets=showreel_source_sets or None,
files=files,
)
yield movie, showreel_task
yield item_id, movie, showreel_task, {}
async def _process_series(
@@ -528,18 +720,28 @@ async def _process_series(
cover_dir: Path,
fetch_covers: bool,
generate_showreels: bool,
media_root: Optional[str] = None,
) -> AsyncIterator[Tuple[Series, List[Tuple[str, Path, int, int, str]]]]:
"""
Async generator that processes all series.
media_root: str | None = None,
root_id: str | None = None,
) -> AsyncIterator[
tuple[str, Series, list[tuple[str, Path, int, int, str]], dict[int, Person]]
]:
"""Async generator that processes all series.
Yields:
Tuples of ``(Series, episode_reel_tasks)`` as each series is processed.
Yields (Series, episode_reel_tasks) for each series as it is processed.
"""
_ = root_id
# In-memory cache for TMDb lookups
series_tmdb_cache: Dict[str, Optional[Info]] = {}
season_cache: Dict[Tuple[int, int], Optional[SeasonInfo]] = {}
series_tmdb_cache: dict[
str,
tuple[Info, str | None, str | None, dict[int, Person]] | None,
] = {}
season_cache: dict[tuple[int, int], SeasonInfo | None] = {}
async def get_series_tmdb(title: str) -> Optional[Info]:
async def get_series_tmdb(
title: str,
) -> tuple[Info, str | None, str | None, dict[int, Person]] | None:
cache_key = title.lower()
if cache_key in series_tmdb_cache:
return series_tmdb_cache[cache_key]
@@ -553,10 +755,9 @@ async def _process_series(
return len(await find_episode_files(item.path)) > 0
# Filter series with video content
valid_series = []
for item in categories[ContentType.SERIES]:
if await has_video_content(item):
valid_series.append(item)
valid_series = [
item for item in categories[ContentType.SERIES] if await has_video_content(item)
]
skipped = len(categories[ContentType.SERIES]) - len(valid_series)
if skipped > 0:
logger.info(
@@ -564,7 +765,7 @@ async def _process_series(
)
# Group by title
series_groups: Dict[str, List[ParsedContent]] = {}
series_groups: dict[str, list[ParsedContent]] = {}
for item in valid_series:
key = item.title.lower()
if key not in series_groups:
@@ -572,8 +773,8 @@ async def _process_series(
series_groups[key].append(item)
# Re-group by TMDb ID
tmdb_groups: Dict[int, Dict] = {}
no_tmdb_groups: Dict[str, Dict] = {}
tmdb_groups: dict[int, dict] = {}
no_tmdb_groups: dict[str, dict] = {}
if series_groups:
logger.info(
@@ -582,22 +783,25 @@ async def _process_series(
len(categories[ContentType.SERIES]),
)
for idx, (series_key, items) in enumerate(series_groups.items(), 1):
for idx, (_series_key, items) in enumerate(series_groups.items(), 1):
first_item = items[0]
logger.debug(" [%d/%d] %s", idx, len(series_groups), first_item.title)
tmdb_info = await get_series_tmdb(first_item.title)
# Yield to event loop so HTTP requests stay responsive
if idx % 20 == 0:
await asyncio.sleep(0)
tmdb_result = await get_series_tmdb(first_item.title)
if tmdb_info and tmdb_info.tmdb_id:
if tmdb_result and tmdb_result[0].tmdb_id:
tmdb_info, poster_path_ref, backdrop_path_ref, people = tmdb_result
if tmdb_info.tmdb_id not in tmdb_groups:
tmdb_groups[tmdb_info.tmdb_id] = {
"tmdb_info": tmdb_info,
"poster_path_ref": poster_path_ref,
"backdrop_path_ref": backdrop_path_ref,
"people": people,
"items": [],
"torrent_titles": set(),
}
else:
tmdb_groups[tmdb_info.tmdb_id]["people"].update(people)
tmdb_groups[tmdb_info.tmdb_id]["items"].extend(items)
tmdb_groups[tmdb_info.tmdb_id]["torrent_titles"].add(first_item.title)
else:
@@ -609,11 +813,14 @@ async def _process_series(
# Process series with TMDb info — yield each as ready
for series_idx, (tmdb_id, group_data) in enumerate(tmdb_groups.items(), 1):
tmdb_info = group_data["tmdb_info"]
poster_path_ref = group_data["poster_path_ref"]
backdrop_path_ref = group_data["backdrop_path_ref"]
people = group_data["people"]
items = group_data["items"]
torrent_titles = group_data["torrent_titles"]
display_title = tmdb_info.title
series_id = hashlib.md5(f"series:{tmdb_id}".encode()).hexdigest()[:12]
series_id = build_series_id(display_title)
logger.debug(" [%d/%d] %s", series_idx, len(tmdb_groups), display_title)
@@ -630,21 +837,35 @@ async def _process_series(
cover_path = await find_cover_image(tt, None, "series", cover_dir)
if cover_path:
break
if not cover_path and tmdb_info.poster_path:
if not cover_path and poster_path_ref:
cover_path = await download_cover_image(
tmdb_info.poster_path, display_title, None, "series", cover_dir
poster_path_ref,
display_title,
None,
"series",
cover_dir,
)
tmdb_info, people = await _cache_people_profiles(
tmdb_info,
people,
series_folder,
media_root,
)
# Download backdrop
backdrop_path = None
if fetch_covers and tmdb_info.backdrop_path:
if fetch_covers and backdrop_path_ref:
backdrop_path = await download_backdrop_image(
tmdb_info.backdrop_path, display_title, None, "series", cover_dir
backdrop_path_ref,
display_title,
None,
"series",
cover_dir,
)
# Collect and build episode data
all_episode_files = await _collect_episode_files(items)
ep_reel_tasks: List[Tuple[str, Path, int, int, str]] = []
ep_reel_tasks: list[tuple[str, Path, int, int, str]] = []
seasons_data = await _build_seasons_data(
all_episode_files,
tmdb_id,
@@ -669,22 +890,21 @@ async def _process_series(
newest = max(item_timestamps) if item_timestamps else None
series = Series(
id=series_id,
title=display_title,
info=tmdb_info,
alternative_titles=different_titles if different_titles else None,
alternative_titles=different_titles or None,
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
backdrop_path=make_relative_path(backdrop_path, media_root),
seasons=seasons_data,
)
yield series, ep_reel_tasks
yield series_id, series, ep_reel_tasks, people
# Process series without TMDb info
for key, group_data in no_tmdb_groups.items():
for group_data in no_tmdb_groups.values():
items = group_data["items"]
title = group_data["title"]
series_id = hashlib.md5(f"series:{title}".encode()).hexdigest()[:12]
series_id = build_series_id(title)
cover_path = (
await find_cover_image(title, None, "series", cover_dir)
@@ -694,7 +914,7 @@ async def _process_series(
series_folder = get_media_folder_path(title, None, "series", cover_dir)
all_episode_files = await _collect_episode_files(items)
ep_reel_tasks: List[Tuple[str, Path, int, int, str]] = []
ep_reel_tasks: list[tuple[str, Path, int, int, str]] = []
seasons_data = await _build_seasons_data(
all_episode_files,
None,
@@ -716,10 +936,9 @@ async def _process_series(
newest = max(item_timestamps) if item_timestamps else None
series = Series(
id=series_id,
title=title,
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
seasons=seasons_data,
)
yield series, ep_reel_tasks
yield series_id, series, ep_reel_tasks, {}
+13 -13
View File
@@ -4,7 +4,6 @@ import hashlib
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
class ContentType(Enum):
@@ -24,7 +23,7 @@ class ContentHash:
size: int = 0
@classmethod
def from_path(cls, path: Path) -> "ContentHash":
def from_path(cls, path: Path) -> ContentHash:
"""Generate a content hash based on torrent name."""
hash_val = hashlib.md5(path.name.encode()).hexdigest()[:16]
return cls(path=path, hash=hash_val)
@@ -38,16 +37,17 @@ class ParsedContent:
name: str
content_type: ContentType
title: str
year: Optional[int] = None
resolution: Optional[str] = None
quality: Optional[str] = None
codec: Optional[str] = None
audio: Optional[str] = None
season: Optional[int] = None
episode: Optional[int] = None
episode_name: Optional[str] = None
encoder: Optional[str] = None
language: Optional[str] = None
year: int | None = None
resolution: str | None = None
quality: str | None = None
network: str | None = None
codec: str | None = None
audio: str | None = None
season: int | None = None
episode: int | None = None
episode_name: str | None = None
encoder: str | None = None
language: str | None = None
is_directory: bool = False
raw_parsed: dict = field(default_factory=dict)
content_hash: Optional[ContentHash] = None
content_hash: ContentHash | None = None
+16 -5
View File
@@ -2,12 +2,21 @@
import re
from pathlib import Path
from typing import Optional, Tuple
import PTN
from aiopathlib import AsyncPath
from mediahive.hivescan.models import ContentHash, ContentType, ParsedContent
from mediahive.hivescan.utils import normalize_resolution_label
_EDGE_NON_ALPHANUMERICS_RE = re.compile(r"^[^0-9A-Za-z]+|[^0-9A-Za-z]+$")
def strip_edge_non_alphanumerics(value: str | None) -> str | None:
"""Remove punctuation from the start and end of PTN scene tags."""
if not value:
return None
return _EDGE_NON_ALPHANUMERICS_RE.sub("", value)
def determine_content_type(parsed: dict) -> ContentType:
@@ -27,6 +36,7 @@ async def parse_download(path: Path) -> ParsedContent:
"""Parse a downloaded torrent directory/file name."""
name = path.name
parsed = PTN.parse(name)
parsed["encoder"] = strip_edge_non_alphanumerics(parsed.get("encoder"))
content_type = determine_content_type(parsed)
content_hash = ContentHash.from_path(path)
@@ -36,8 +46,9 @@ async def parse_download(path: Path) -> ParsedContent:
content_type=content_type,
title=parsed.get("title", name),
year=parsed.get("year"),
resolution=parsed.get("resolution"),
resolution=normalize_resolution_label(parsed.get("resolution")),
quality=parsed.get("quality"),
network=parsed.get("network"),
codec=parsed.get("codec"),
audio=parsed.get("audio"),
season=parsed.get("season"),
@@ -51,14 +62,14 @@ async def parse_download(path: Path) -> ParsedContent:
)
def parse_episode_from_filename(filename: str) -> Optional[Tuple[int, int]]:
"""
Parse season and episode numbers from a filename.
def parse_episode_from_filename(filename: str) -> tuple[int, int] | None:
"""Parse season and episode numbers from a filename.
Handles formats: S01E05, 1x05, Season 1 Episode 5
Returns:
Tuple of (season_number, episode_number) or None if not found
"""
name = filename.lower()
+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
same syntax as ``.gitignore``:
@@ -30,8 +29,6 @@ from __future__ import annotations
import re
from pathlib import Path
from typing import List, Tuple
# Built-in patterns that are always excluded (before user file)
_BUILTIN_EXCLUDES: list[str] = [
@@ -75,11 +72,9 @@ def _pattern_to_regex(pattern: str) -> re.Pattern[str]:
parts.append("(?:.+/)?")
i += 3
continue
else:
parts.append(".*")
i += 2
continue
else:
parts.append("[^/]*")
i += 1
elif c == "?":
@@ -94,12 +89,7 @@ def _pattern_to_regex(pattern: str) -> re.Pattern[str]:
regex_str = "".join(parts)
if anchored or has_slash:
# Match from the start of the relative path
regex_str = "^" + regex_str
else:
# Match against any path component (basename or as suffix after /)
regex_str = "(?:^|/)" + regex_str
regex_str = "^" + regex_str if anchored or has_slash else "(?:^|/)" + regex_str
# Must match the whole remaining path or be a prefix (directory match)
regex_str += "(?:/.*)?$"
@@ -112,7 +102,7 @@ class ScanIgnore:
def __init__(self, media_root: Path) -> None:
self.media_root = media_root.resolve()
self._rules: List[Tuple[bool, re.Pattern[str]]] = [] # (negated, regex)
self._rules: list[tuple[bool, re.Pattern[str]]] = [] # (negated, regex)
self._load_builtins()
self._load_file()
+270 -193
View File
@@ -1,5 +1,4 @@
"""
Scan orchestration — background tasks for continuous media scanning.
"""Scan orchestration — background tasks for continuous media scanning.
All scanning logic lives here in hivescan. Communication with the mediahive
server happens exclusively through an async ``send`` callable that pushes
@@ -10,12 +9,16 @@ The scanner recursively walks the media root, respecting ignore patterns
defined in ``.mediahive/scanignore`` (gitignore-style syntax).
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import threading
import uuid
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Awaitable, Callable, List, Optional
from aiopathlib import AsyncPath
@@ -28,7 +31,8 @@ from mediahive.hivescan.showreel import (
episode_reel_exists,
generate_episode_reel,
generate_showreel_images,
get_expected_showreel_paths,
get_existing_episode_reel_sources,
get_existing_showreel_source_sets,
movie_showreels_exist,
)
from mediahive.hivescan.tmdb_client import set_cache_dir
@@ -46,124 +50,148 @@ Send = Callable[[ScanEvent], Awaitable[None]]
# ---------------------------------------------------------------------------
# Configuration (populated by ``start``)
# RootScanner — per-root scanning runtime
# ---------------------------------------------------------------------------
_output_dir: Optional[Path] = None
_media_root: Optional[Path] = None
_scanignore: Optional[ScanIgnore] = None
class RootScanner:
"""Scanner instance for a single media root."""
def __init__(
self,
root_id: str,
media_root: Path,
send: Send,
) -> None:
self.root_id = root_id
self.media_root = media_root
self._send = send
self._output_dir = media_root / DEFAULT_OUTPUT_FOLDER
self._scanignore = ScanIgnore(media_root)
# Runtime state
_send: Optional[Send] = None
_scan_task: Optional[asyncio.Task] = None
_showreel_queue: asyncio.Queue = asyncio.Queue()
_showreel_worker_task: Optional[asyncio.Task] = None
_rescan_worker_task: Optional[asyncio.Task] = None
_seen_mtimes: dict[str, int] = {}
self._scan_task: asyncio.Task | None = None
self._showreel_queue: asyncio.Queue = asyncio.Queue()
self._showreel_worker_task: asyncio.Task | None = None
self._rescan_worker_task: asyncio.Task | None = None
self._seen_mtimes: dict[str, int] = {}
# ---------------------------------------------------------------------------
# ------------------------------------------------------------------
# Public lifecycle
# ---------------------------------------------------------------------------
# ------------------------------------------------------------------
async def start(send: Send) -> None:
"""
Initialise and start the scanner.
Reads ``MEDIAHIVE_PATH`` from the environment, loads the scanignore
rules from ``.mediahive/scanignore``, and starts background workers
that recursively walk the media root.
"""
global _send, _output_dir, _media_root, _scanignore
global _showreel_worker_task, _rescan_worker_task
_send = send
media_path = os.environ.get("MEDIAHIVE_PATH", "")
if not media_path:
logger.error("MEDIAHIVE_PATH environment variable must be set")
return
_media_root = Path(media_path).resolve()
_output_dir = _media_root / DEFAULT_OUTPUT_FOLDER
_scanignore = ScanIgnore(_media_root)
await AsyncPath(_output_dir).mkdir(parents=True, exist_ok=True)
set_cache_dir(_output_dir / ".tmdb-cache")
async def start(self) -> None:
"""Initialise and start background workers."""
await AsyncPath(self._output_dir).mkdir(parents=True, exist_ok=True)
set_cache_dir(self._output_dir / ".tmdb-cache")
logger.info(
"Scanner started root=%s, scanignore=%s",
_media_root,
"loaded" if _scanignore.file_path.exists() else "defaults only",
"Scanner started for root %s — path=%s, scanignore=%s",
self.root_id,
self.media_root,
"loaded" if self._scanignore.file_path.exists() else "defaults only",
)
_showreel_worker_task = asyncio.create_task(_showreel_worker())
_rescan_worker_task = asyncio.create_task(_rescan_loop())
self._showreel_worker_task = asyncio.create_task(self._showreel_worker())
self._rescan_worker_task = asyncio.create_task(self._rescan_loop())
async def stop() -> None:
async def stop(self) -> None:
"""Cancel all background tasks."""
for task in (_scan_task, _showreel_worker_task, _rescan_worker_task):
tasks = [
self._scan_task,
self._showreel_worker_task,
self._rescan_worker_task,
]
for task in tasks:
if task and not task.done():
task.cancel()
# Wait briefly for graceful shutdown to avoid lingering scanner tasks.
for task in tasks:
if task and not task.done():
with contextlib.suppress(TimeoutError, asyncio.CancelledError):
await asyncio.wait_for(task, timeout=2.0)
def is_scanning(self) -> bool:
return self._scan_task is not None and not self._scan_task.done()
def is_scanning() -> bool:
return _scan_task is not None and not _scan_task.done()
def showreel_queue_size(self) -> int:
return self._showreel_queue.qsize()
def showreel_queue_size() -> int:
return _showreel_queue.qsize()
def trigger_scan() -> bool:
def trigger_scan(self) -> bool:
"""Start a scan. Returns False if one is already running."""
if is_scanning():
if self.is_scanning():
return False
_start_scan()
self._start_scan()
return True
# ---------------------------------------------------------------------------
# ------------------------------------------------------------------
# Internal scan orchestration
# ---------------------------------------------------------------------------
# ------------------------------------------------------------------
def _start_scan(self) -> None:
self._scan_task = asyncio.create_task(self._run_scan())
def _start_scan():
global _scan_task
_scan_task = asyncio.create_task(_run_scan())
async def _rescan_loop():
async def _rescan_loop(self) -> None:
try:
while True:
_start_scan()
if _scan_task:
await _scan_task
self._start_scan()
if self._scan_task:
await self._scan_task
await asyncio.sleep(30)
except asyncio.CancelledError:
return
except Exception:
logger.exception("Rescan loop error")
async def _discover_downloads(task_id: str) -> List[ParsedContent]:
"""Recursively walk the media root, respecting scanignore rules.
Each non-ignored **leaf directory** (a directory whose children are only
files, i.e. a single download/torrent folder) and each non-ignored
top-level file is treated as a download to parse.
Sends live task progress so the user can see which directories are being
explored and how many items have been found so far.
"""
downloads: List[ParsedContent] = []
media_root_str = str(_media_root) if _media_root else None
async def _discover_downloads(self, task_id: str) -> list[ParsedContent]:
"""Recursively walk the media root, respecting scanignore rules."""
downloads: list[ParsedContent] = []
media_root_str = self.media_root.as_posix()
dirs_visited = 0
def _collect_children(
directory: Path,
stop_event: threading.Event,
) -> tuple[list[Path], list[Path], bool]:
child_dirs: list[Path] = []
child_files: list[Path] = []
is_media_container = False
with os.scandir(directory) as entries:
for entry in entries:
if stop_event.is_set():
return child_dirs, child_files, is_media_container
name = entry.name
if name.startswith("."):
continue
item = Path(entry.path)
try:
is_dir = entry.is_dir(follow_symlinks=False)
except OSError:
continue
if is_dir:
if name.upper() in media_container_dirs:
is_media_container = True
child_dirs.append(item)
else:
child_files.append(item)
return child_dirs, child_files, is_media_container
def _collect_root_children(
directory: Path,
stop_event: threading.Event,
) -> list[Path]:
items: list[Path] = []
with os.scandir(directory) as entries:
for entry in entries:
if stop_event.is_set():
return items
if entry.name.startswith("."):
continue
items.append(Path(entry.path))
return items
async def _report(detail: str) -> None:
await _send(
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -174,9 +202,8 @@ async def _discover_downloads(task_id: str) -> List[ParsedContent]:
)
)
# Media container folder names (indicates the parent is a single media item)
MEDIA_CONTAINER_DIRS = {"BDMV", "VIDEO_TS", "HVDVD_TS"}
VIDEO_EXTENSIONS = {
media_container_dirs = {"BDMV", "VIDEO_TS", "HVDVD_TS"}
video_extensions = {
".mkv",
".mp4",
".avi",
@@ -195,30 +222,33 @@ async def _discover_downloads(task_id: str) -> List[ParsedContent]:
if not await ap.is_dir():
return
child_dirs: list[Path] = []
child_files: list[Path] = []
is_media_container = False
try:
for item_async in ap.iterdir():
item = Path(item_async)
if item.name.startswith("."):
continue
if _scanignore and _scanignore.is_excluded(item):
continue
if await AsyncPath(item).is_dir():
# Check if this is a BluRay/DVD structure
if item.name.upper() in MEDIA_CONTAINER_DIRS:
is_media_container = True
child_dirs.append(item)
else:
child_files.append(item)
stop_event = threading.Event()
child_dirs, child_files, is_media_container = await asyncio.to_thread(
_collect_children,
directory,
stop_event,
)
except asyncio.CancelledError:
stop_event.set()
raise
except OSError, PermissionError:
logger.debug("Cannot list directory: %s", directory)
return
# If directory contains BDMV/VIDEO_TS, treat entire directory as a single download
# Apply ignore rules after fast scandir classification.
if self._scanignore:
child_dirs = [
item
for item in child_dirs
if not self._scanignore.is_excluded(item)
]
child_files = [
item
for item in child_files
if not self._scanignore.is_excluded(item)
]
if is_media_container:
relpath = make_relative_path(str(directory), media_root_str)
try:
@@ -226,79 +256,87 @@ async def _discover_downloads(task_id: str) -> List[ParsedContent]:
mtime = int(stat_info.st_mtime)
except OSError:
return
if relpath not in _seen_mtimes or _seen_mtimes[relpath] != mtime:
_seen_mtimes[relpath] = mtime
if (
relpath not in self._seen_mtimes
or self._seen_mtimes[relpath] != mtime
):
self._seen_mtimes[relpath] = mtime
downloads.append(await parse_download(directory))
return
if child_dirs:
# Branch directory — log it and recurse into subdirectories
dirs_visited += 1
rel = make_relative_path(str(directory), media_root_str) or str(directory)
if dirs_visited % 5 == 1: # throttle progress updates
rel = make_relative_path(str(directory), media_root_str) or str(
directory
)
if dirs_visited % 5 == 1:
await _report(f"Scanning: {rel} ({len(downloads)} found)")
logger.info("Scanning: %s (%d found so far)", rel, len(downloads))
for child in child_dirs:
await _walk(child)
# Yield control periodically so WS messages flush
await asyncio.sleep(0)
# Also process any video files directly in this directory
for child_file in child_files:
if child_file.suffix.lower() in VIDEO_EXTENSIONS:
if child_file.suffix.lower() in video_extensions:
relpath = make_relative_path(str(child_file), media_root_str)
try:
stat_info = await AsyncPath(child_file).stat()
mtime = int(stat_info.st_mtime)
except OSError:
continue
if relpath in _seen_mtimes and _seen_mtimes[relpath] == mtime:
if (
relpath in self._seen_mtimes
and self._seen_mtimes[relpath] == mtime
):
continue
_seen_mtimes[relpath] = mtime
self._seen_mtimes[relpath] = mtime
downloads.append(await parse_download(child_file))
else:
# Leaf directory — treat the directory itself as a download
relpath = make_relative_path(str(directory), media_root_str)
try:
stat_info = await ap.stat()
mtime = int(stat_info.st_mtime)
except OSError:
return
if relpath in _seen_mtimes and _seen_mtimes[relpath] == mtime:
if relpath in self._seen_mtimes and self._seen_mtimes[relpath] == mtime:
return
_seen_mtimes[relpath] = mtime
self._seen_mtimes[relpath] = mtime
downloads.append(await parse_download(directory))
# Walk immediate children of the media root (skip root itself)
logger.info("Starting filesystem discovery at %s", _media_root)
await _report(f"Scanning: {_media_root}")
logger.info("Starting filesystem discovery at %s", self.media_root)
await _report(f"Scanning: {self.media_root}")
root_ap = AsyncPath(_media_root)
try:
root_children = list(root_ap.iterdir())
stop_event = threading.Event()
root_children = await asyncio.to_thread(
_collect_root_children,
self.media_root,
stop_event,
)
except asyncio.CancelledError:
stop_event.set()
raise
except OSError, PermissionError:
logger.error("Cannot list media root: %s", _media_root)
logger.exception("Cannot list media root: %s", self.media_root)
return downloads
for item_async in root_children:
item = Path(item_async)
if item.name.startswith("."):
continue
if _scanignore and _scanignore.is_excluded(item):
if self._scanignore and self._scanignore.is_excluded(item):
logger.debug("Excluded: %s", item.name)
continue
if await AsyncPath(item).is_dir():
await _walk(item)
else:
# Top-level file — parse directly
relpath = make_relative_path(str(item), media_root_str)
try:
stat_info = await AsyncPath(item).stat()
mtime = int(stat_info.st_mtime)
except OSError:
continue
if relpath in _seen_mtimes and _seen_mtimes[relpath] == mtime:
if relpath in self._seen_mtimes and self._seen_mtimes[relpath] == mtime:
continue
_seen_mtimes[relpath] = mtime
self._seen_mtimes[relpath] = mtime
downloads.append(await parse_download(item))
logger.info(
@@ -308,21 +346,20 @@ async def _discover_downloads(task_id: str) -> List[ParsedContent]:
)
return downloads
async def _run_scan(self) -> None:
"""Full scan pipeline:
async def _run_scan():
"""
Full scan pipeline:
1. Recursively walk media root (respecting scanignore) — with live progress
1. Discover downloads
2. Categorise → movies / series
3. Iterate async generators, send each item as Upsert
4. Queue showreel tasks
4. Queue showreel tasks.
"""
task_id = f"scan-{uuid.uuid4().hex[:8]}"
media_root_str = str(_media_root) if _media_root else None
media_root_str = self.media_root.as_posix()
try:
logger.info("Scan started (%s)", task_id)
await _send(
logger.info("Scan started (%s) for root %s", task_id, self.root_id)
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -333,11 +370,11 @@ async def _run_scan():
)
)
downloads = await _discover_downloads(task_id)
downloads = await self._discover_downloads(task_id)
if not downloads:
logger.info("No new downloads found (%s)", task_id)
await _send(
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -350,7 +387,7 @@ async def _run_scan():
return
logger.info("Found %d items to process", len(downloads))
await _send(
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -371,7 +408,7 @@ async def _run_scan():
# Process movies
if n_movies:
await _send(
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -381,22 +418,35 @@ async def _run_scan():
)
)
)
async for movie, showreel_task in _process_movies(
async for movie_id, movie, showreel_task, people in _process_movies(
categories,
_output_dir,
self._output_dir,
fetch_covers=True,
generate_showreels=True,
media_root=media_root_str,
root_id=self.root_id,
):
await _send(Upsert(kind="movie", item=movie))
await self._send(
Upsert(
kind="movie",
id=movie_id,
item=movie,
people=people or None,
)
)
if showreel_task:
await _showreel_queue.put(("movie", showreel_task, movie))
await self._showreel_queue.put((
"movie",
movie_id,
showreel_task,
movie,
))
logger.info(
"[%d/%d] Movie: %s (showreel queued, queue=%d)",
processed + 1,
total,
movie.title,
_showreel_queue.qsize(),
self._showreel_queue.qsize(),
)
else:
logger.info(
@@ -407,7 +457,7 @@ async def _run_scan():
)
processed += 1
progress = round(processed / total, 3) if total else 1
await _send(
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -420,7 +470,7 @@ async def _run_scan():
# Process series
if n_series:
await _send(
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -430,16 +480,24 @@ async def _run_scan():
)
)
)
async for series, ep_reel_tasks in _process_series(
async for series_id, series, ep_reel_tasks, people in _process_series(
categories,
_output_dir,
self._output_dir,
fetch_covers=True,
generate_showreels=True,
media_root=media_root_str,
root_id=self.root_id,
):
await _send(Upsert(kind="series", item=series))
await self._send(
Upsert(
kind="series",
id=series_id,
item=series,
people=people or None,
)
)
for task in ep_reel_tasks:
await _showreel_queue.put(("episode", task, series))
await self._showreel_queue.put(("episode", series_id, task, series))
if ep_reel_tasks:
logger.info(
"[%d/%d] Series: %s (%d episode reels queued, queue=%d)",
@@ -447,7 +505,7 @@ async def _run_scan():
total,
series.title,
len(ep_reel_tasks),
_showreel_queue.qsize(),
self._showreel_queue.qsize(),
)
else:
logger.info(
@@ -458,7 +516,7 @@ async def _run_scan():
)
processed += 1
progress = round(processed / total, 3) if total else 1
await _send(
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -469,7 +527,7 @@ async def _run_scan():
)
)
await _send(
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -484,11 +542,11 @@ async def _run_scan():
task_id,
n_movies,
n_series,
_showreel_queue.qsize(),
self._showreel_queue.qsize(),
)
except asyncio.CancelledError:
await _send(
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -501,7 +559,7 @@ async def _run_scan():
logger.info("Scan cancelled (%s)", task_id)
except Exception:
logger.exception("Scan failed (%s)", task_id)
await _send(
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -512,23 +570,21 @@ async def _run_scan():
)
)
# ---------------------------------------------------------------------------
# ------------------------------------------------------------------
# Showreel worker
# ---------------------------------------------------------------------------
# ------------------------------------------------------------------
async def _showreel_worker():
async def _showreel_worker(self) -> None:
"""Background worker that generates showreels one at a time."""
logger.info("Showreel worker started")
media_root_path = Path(_media_root) if _media_root else None
media_root_str = str(_media_root) if _media_root else None
logger.info("Showreel worker started for root %s", self.root_id)
media_root_path = self.media_root
media_root_str = self.media_root.as_posix()
while True:
try:
kind, task_data, item = await _showreel_queue.get()
kind, item_id, task_data, item = await self._showreel_queue.get()
task_id = f"showreel-{uuid.uuid4().hex[:8]}"
remaining = _showreel_queue.qsize()
remaining = self._showreel_queue.qsize()
if kind == "movie":
movie: Movie = item
@@ -542,9 +598,9 @@ async def _showreel_worker():
)
if await movie_showreels_exist(media_folder):
logger.info("Showreel skipped (already exists): %s", title)
_showreel_queue.task_done()
self._showreel_queue.task_done()
continue
await _send(
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -560,24 +616,30 @@ async def _showreel_worker():
title=title,
)
if generated:
paths = get_expected_showreel_paths(
source_sets = get_existing_showreel_source_sets(
media_folder, media_root=media_root_path
)
movie.showreel_images = paths if paths else None
await _send(Upsert(kind="movie", item=movie))
await _send(
paths = [sources[0] for sources in source_sets if sources]
movie.showreel_images = paths or None
movie.showreel_source_sets = source_sets or None
await self._send(Upsert(kind="movie", id=item_id, item=movie))
await self._send(
Task(
data=TaskInfo(
id=task_id,
status="completed",
progress=1,
detail=f"Showreel: {title} ({len(generated)} reels)",
detail=(
f"Showreel: {title} ({len(generated)} reels)"
),
)
)
)
else:
logger.warning("Showreel generation returned nothing: %s", title)
await _send(
logger.warning(
"Showreel generation returned nothing: %s", title
)
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -607,9 +669,9 @@ async def _showreel_worker():
series_title,
ep_code,
)
_showreel_queue.task_done()
self._showreel_queue.task_done()
continue
await _send(
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -626,16 +688,27 @@ async def _showreel_worker():
episode_num,
)
if reel_path:
reel_sources = get_existing_episode_reel_sources(
media_folder,
season_num,
episode_num,
media_root=media_root_path,
)
for season in series.seasons:
if season.season_number == season_num:
for episode in season.episodes:
if episode.episode_number == episode_num:
episode.reel_image = make_relative_path(
episode.reel_image = (
reel_sources[0]
if reel_sources
else make_relative_path(
reel_path,
media_root_str,
)
await _send(Upsert(kind="series", item=series))
await _send(
)
episode.reel_sources = reel_sources or None
await self._send(Upsert(kind="series", id=item_id, item=series))
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -651,7 +724,7 @@ async def _showreel_worker():
series_title,
ep_code,
)
await _send(
await self._send(
Task(
data=TaskInfo(
id=task_id,
@@ -662,16 +735,20 @@ async def _showreel_worker():
)
)
_showreel_queue.task_done()
self._showreel_queue.task_done()
except asyncio.CancelledError:
logger.info("Showreel worker shutting down")
logger.info("Showreel worker shutting down for root %s", self.root_id)
return
except Exception:
logger.exception(
"Showreel worker error (queue size=%d)", _showreel_queue.qsize()
"Showreel worker error (queue size=%d)",
self._showreel_queue.qsize(),
)
try:
_showreel_queue.task_done()
except ValueError:
pass
with contextlib.suppress(ValueError):
self._showreel_queue.task_done()
# ---------------------------------------------------------------------------
# Legacy module-level API removed — use RootScanner per root instead.
# ---------------------------------------------------------------------------
+299 -49
View File
@@ -2,8 +2,11 @@
import asyncio
import glob
import operator
import os
import threading
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from aiopathlib import AsyncPath
@@ -11,7 +14,6 @@ from mediahive.hivescan.models import ContentType, ParsedContent
from mediahive.hivescan.parsing import parse_download, parse_episode_from_filename
from mediahive.hivescan.utils import get_media_folder_path, sanitize_filename
# Video file extensions
VIDEO_EXTENSIONS = {
".mkv",
@@ -27,22 +29,68 @@ VIDEO_EXTENSIONS = {
}
# Caches for expensive operations
_episode_files_cache: Dict[str, Dict[Tuple[int, int], List[Tuple[str, int]]]] = {}
_playable_file_cache: Dict[str, Optional[str]] = {}
_episode_files_cache: dict[str, dict[tuple[int, int], list[tuple[str, int]]]] = {}
_playable_file_cache: dict[str, str | None] = {}
_bluray_probe_file_cache: dict[str, str | None] = {}
async def scan_downloads(base_pattern: str) -> List[ParsedContent]:
"""
Scan download directories matching the pattern.
def _scandir_split(
directory: Path,
stop_event: threading.Event,
) -> tuple[list[Path], list[Path]]:
"""Return child directories and files, checking stop_event each iteration."""
child_dirs: list[Path] = []
child_files: list[Path] = []
with os.scandir(directory) as entries:
for entry in entries:
if stop_event.is_set():
return child_dirs, child_files
try:
is_dir = entry.is_dir(follow_symlinks=False)
except OSError:
continue
p = Path(entry.path)
if is_dir:
child_dirs.append(p)
else:
child_files.append(p)
return child_dirs, child_files
def _scandir_files_with_suffix(
directory: Path,
suffixes: set[str],
stop_event: threading.Event,
) -> list[Path]:
"""Return files in directory with a matching suffix, cancellable via stop_event."""
files: list[Path] = []
with os.scandir(directory) as entries:
for entry in entries:
if stop_event.is_set():
return files
try:
if entry.is_dir(follow_symlinks=False):
continue
except OSError:
continue
p = Path(entry.path)
if p.suffix.lower() in suffixes:
files.append(p)
return files
async def scan_downloads(base_pattern: str) -> list[ParsedContent]:
"""Scan download directories matching the pattern.
Args:
base_pattern: Glob pattern for finding download directories
Returns:
List of ParsedContent objects for each found download
"""
exclude_patterns = [".torrents", "incomplete", ".incomplete"]
results: List[ParsedContent] = []
results: list[ParsedContent] = []
paths = await asyncio.to_thread(glob.glob, base_pattern)
for path_str in paths:
@@ -81,57 +129,74 @@ def categorize_downloads(
async def find_episode_files(
path: Path,
) -> Dict[Tuple[int, int], List[Tuple[str, int]]]:
"""
Find all episode video files in a directory.
) -> dict[tuple[int, int], list[tuple[str, int]]]:
"""Find all episode video files in a directory.
Args:
path: Path to search (can be a season pack directory or single file)
Returns:
Dict mapping (season_num, episode_num) to list of (file_path, file_size) tuples
"""
cache_key = str(path)
cache_key = path.as_posix()
if cache_key in _episode_files_cache:
return _episode_files_cache[cache_key]
episodes: Dict[Tuple[int, int], List[Tuple[str, int]]] = {}
episodes: dict[tuple[int, int], list[tuple[str, int]]] = {}
ap = AsyncPath(path)
if await ap.is_file():
if path.suffix.lower() in VIDEO_EXTENSIONS:
ep_info = parse_episode_from_filename(path.name)
if ep_info:
episodes[ep_info] = [(str(path), (await ap.stat()).st_size)]
episodes[ep_info] = [(path.as_posix(), (await ap.stat()).st_size)]
_episode_files_cache[cache_key] = episodes
return episodes
stack = [path]
while stack:
current = stack.pop()
stop_event = threading.Event()
try:
for f in ap.rglob("*"):
af = AsyncPath(f)
if await af.is_file() and Path(f).suffix.lower() in VIDEO_EXTENSIONS:
if "sample" in Path(f).name.lower():
child_dirs, child_files = await asyncio.to_thread(
_scandir_split,
current,
stop_event,
)
except asyncio.CancelledError:
stop_event.set()
raise
except OSError, PermissionError:
continue
ep_info = parse_episode_from_filename(Path(f).name)
stack.extend(child_dirs)
for f in child_files:
if f.suffix.lower() not in VIDEO_EXTENSIONS:
continue
if "sample" in f.name.lower():
continue
af = AsyncPath(f)
try:
ep_info = parse_episode_from_filename(f.name)
if ep_info:
if ep_info not in episodes:
episodes[ep_info] = []
episodes[ep_info].append((str(f), (await af.stat()).st_size))
episodes[ep_info].append((f.as_posix(), (await af.stat()).st_size))
except OSError, PermissionError:
pass
continue
_episode_files_cache[cache_key] = episodes
return episodes
async def find_playable_file(path: Path) -> Optional[str]:
"""
Find the main playable media file in a directory.
async def find_playable_file(path: Path) -> str | None:
"""Find the main playable media file in a directory.
For Blu-ray discs: Returns BDMV/index.bdmv
For Blu-ray discs: Returns BDMV/MovieObject.bdmv (fallback: BDMV/index.bdmv)
For other content: Returns the largest video file
"""
cache_key = str(path)
cache_key = path.as_posix()
if cache_key in _playable_file_cache:
return _playable_file_cache[cache_key]
@@ -139,71 +204,256 @@ async def find_playable_file(path: Path) -> Optional[str]:
if await ap.is_file():
if path.suffix.lower() in VIDEO_EXTENSIONS:
result = str(path)
result = path.as_posix()
_playable_file_cache[cache_key] = result
return result
_playable_file_cache[cache_key] = None
return None
# Check for Blu-ray disc structure
bdmv_index = path / "BDMV" / "index.bdmv"
bdmv_dir = path / "BDMV"
bdmv_movieobject = bdmv_dir / "MovieObject.bdmv"
bdmv_index = bdmv_dir / "index.bdmv"
if await AsyncPath(bdmv_movieobject).exists():
result = bdmv_movieobject.as_posix()
_playable_file_cache[cache_key] = result
return result
if await AsyncPath(bdmv_index).exists():
result = str(bdmv_index)
result = bdmv_index.as_posix()
_playable_file_cache[cache_key] = result
return result
# Check for DVD disc structure
video_ts_dir = path / "VIDEO_TS"
video_ts_ifo = video_ts_dir / "VIDEO_TS.IFO"
if await AsyncPath(video_ts_ifo).exists():
result = video_ts_ifo.as_posix()
_playable_file_cache[cache_key] = result
return result
# Check nested Blu-ray structure (e.g., MovieName/DISC1/BDMV/)
try:
for subdir in ap.iterdir():
if await AsyncPath(subdir).is_dir():
nested_bdmv = Path(subdir) / "BDMV" / "index.bdmv"
if await AsyncPath(nested_bdmv).exists():
result = str(nested_bdmv)
stop_event = threading.Event()
child_dirs, _ = await asyncio.to_thread(
_scandir_split,
path,
stop_event,
)
except asyncio.CancelledError:
stop_event.set()
raise
except OSError, PermissionError:
child_dirs = []
for subdir in child_dirs:
nested_bdmv_dir = subdir / "BDMV"
nested_movieobject = nested_bdmv_dir / "MovieObject.bdmv"
nested_index = nested_bdmv_dir / "index.bdmv"
if await AsyncPath(nested_movieobject).exists():
result = nested_movieobject.as_posix()
_playable_file_cache[cache_key] = result
return result
if await AsyncPath(nested_index).exists():
result = nested_index.as_posix()
_playable_file_cache[cache_key] = result
return result
nested_video_ts_dir = subdir / "VIDEO_TS"
nested_video_ts_ifo = nested_video_ts_dir / "VIDEO_TS.IFO"
if await AsyncPath(nested_video_ts_ifo).exists():
result = nested_video_ts_ifo.as_posix()
_playable_file_cache[cache_key] = result
return result
except OSError, PermissionError:
pass
# Find largest video file
video_files = []
stack = [path]
while stack:
current = stack.pop()
stop_event = threading.Event()
try:
for f in ap.rglob("*"):
af = AsyncPath(f)
if await af.is_file() and Path(f).suffix.lower() in VIDEO_EXTENSIONS:
if "sample" in Path(f).name.lower():
continue
video_files.append((str(f), (await af.stat()).st_size))
child_dirs, child_files = await asyncio.to_thread(
_scandir_split,
current,
stop_event,
)
except asyncio.CancelledError:
stop_event.set()
raise
except OSError, PermissionError:
pass
continue
stack.extend(child_dirs)
for f in child_files:
if f.suffix.lower() not in VIDEO_EXTENSIONS:
continue
if "sample" in f.name.lower():
continue
af = AsyncPath(f)
try:
video_files.append((f.as_posix(), (await af.stat()).st_size))
except OSError, PermissionError:
continue
if not video_files:
_playable_file_cache[cache_key] = None
return None
video_files.sort(key=lambda x: x[1], reverse=True)
video_files.sort(key=operator.itemgetter(1), reverse=True)
result = video_files[0][0]
_playable_file_cache[cache_key] = result
return result
async def find_metadata_probe_file(playable_path: str | None) -> str | None:
"""Resolve a path suitable for ffmpeg stream metadata probing.
For regular files, returns ``playable_path`` unchanged.
For Blu-ray control files (``*.bdmv``), returns the largest
``BDMV/STREAM/*.m2ts`` file, which ffmpeg can usually inspect even
when direct BDMV probing is unsupported.
For DVD control files (``*.ifo``), returns a ``concat:`` URI that
covers all VOBs of the largest title set, giving ffmpeg the full
main feature to probe.
"""
if not playable_path:
return None
if not playable_path.lower().endswith(".bdmv"):
# For DVD control files, build a concat URI covering the main title set
if playable_path.lower().endswith(".ifo"):
cache_key = playable_path
if cache_key in _bluray_probe_file_cache:
return _bluray_probe_file_cache[cache_key]
playable = Path(playable_path)
video_ts_dir = (
playable.parent
if playable.parent.name.upper() == "VIDEO_TS"
else playable.parent
)
# Group VOBs by title set (VTS_XX_Y.VOB)
title_sets: dict[str, list[tuple[str, int]]] = defaultdict(list)
try:
stop_event = threading.Event()
vob_files = await asyncio.to_thread(
_scandir_files_with_suffix,
video_ts_dir,
{".vob"},
stop_event,
)
except asyncio.CancelledError:
stop_event.set()
raise
except OSError, PermissionError:
_bluray_probe_file_cache[cache_key] = None
return None
for f in vob_files:
af = AsyncPath(f)
name = f.name.upper()
if not name.startswith("VTS_") or len(name) < 10:
continue
try:
size = (await af.stat()).st_size
except OSError, PermissionError:
continue
ts_num = name[4:6]
title_sets[ts_num].append((f.as_posix(), size))
if not title_sets:
_bluray_probe_file_cache[cache_key] = None
return None
# Pick the title set with the largest total size (main feature)
best_ts = max(
title_sets.keys(),
key=lambda ts: sum(size for _, size in title_sets[ts]),
)
best_vobs = sorted(title_sets[best_ts], key=lambda x: x[0].upper())
concat_uri = "concat:" + "|".join(path for path, _ in best_vobs)
_bluray_probe_file_cache[cache_key] = concat_uri
return concat_uri
return playable_path
cache_key = playable_path
if cache_key in _bluray_probe_file_cache:
return _bluray_probe_file_cache[cache_key]
playable = Path(playable_path)
bdmv_dir = (
playable.parent if playable.parent.name.upper() == "BDMV" else playable.parent
)
stream_dir = bdmv_dir / "STREAM"
ap_stream = AsyncPath(stream_dir)
if not await ap_stream.exists() or not await ap_stream.is_dir():
_bluray_probe_file_cache[cache_key] = None
return None
candidates: list[tuple[str, int]] = []
stack = [stream_dir]
while stack:
current = stack.pop()
stop_event = threading.Event()
try:
child_dirs, child_files = await asyncio.to_thread(
_scandir_split,
current,
stop_event,
)
except asyncio.CancelledError:
stop_event.set()
raise
except OSError, PermissionError:
continue
stack.extend(child_dirs)
for f in child_files:
if f.suffix.lower() != ".m2ts":
continue
af = AsyncPath(f)
try:
candidates.append((f.as_posix(), (await af.stat()).st_size))
except OSError, PermissionError:
continue
if not candidates:
_bluray_probe_file_cache[cache_key] = None
return None
candidates.sort(key=operator.itemgetter(1), reverse=True)
result = candidates[0][0]
_bluray_probe_file_cache[cache_key] = result
return result
async def find_cover_image(
title: str, year: Optional[int], media_type: str, cover_dir: Path
) -> Optional[str]:
title: str, year: int | None, media_type: str, cover_dir: Path
) -> str | None:
"""Find a cover image for the given media item."""
media_folder = get_media_folder_path(title, year, media_type, cover_dir)
cover_path = media_folder / "cover.jpg"
if await AsyncPath(cover_path).exists():
return str(cover_path)
return cover_path.as_posix()
# Legacy structure fallback
subdir = "movies" if media_type == "movie" else "series"
if media_type == "movie" and year:
legacy_path = cover_dir / subdir / f"{sanitize_filename(title)} ({year}).jpg"
if await AsyncPath(legacy_path).exists():
return str(legacy_path)
return legacy_path.as_posix()
legacy_path = cover_dir / subdir / f"{sanitize_filename(title)}.jpg"
if await AsyncPath(legacy_path).exists():
return str(legacy_path)
return legacy_path.as_posix()
return None
File diff suppressed because it is too large Load Diff
+145 -109
View File
@@ -1,7 +1,5 @@
#!/usr/bin/env python3
"""
TMDb Client - Fetch movie and TV series metadata from The Movie Database (TMDb).
"""
"""TMDb Client - Fetch movie and TV series metadata from The Movie Database (TMDb)."""
import asyncio
import hashlib
@@ -10,17 +8,16 @@ import os
import sys
import urllib.parse
from pathlib import Path
from typing import Dict, Optional
import httpx
from aiopathlib import AsyncPath
from mediahive.models.tmdb import (
CastMember,
CastCredit,
EpisodeInfo,
Info,
Person,
SeasonInfo,
SimilarMedia,
)
# TMDb API configuration
@@ -28,10 +25,10 @@ TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "6bd914e6a5df1c6d1ddf622cf2dbc232"
TMDB_API_BASE = "https://api.themoviedb.org/3"
# API response cache directory (can be overridden via set_cache_dir)
_tmdb_cache_dir: Optional[Path] = None
_tmdb_cache_dir: Path | None = None
# Persistent async HTTP client for connection reuse
_http_client: Optional[httpx.AsyncClient] = None
_http_client: httpx.AsyncClient | None = None
def set_cache_dir(cache_dir: Path) -> None:
@@ -61,11 +58,19 @@ def _get_http_client() -> httpx.AsyncClient:
return _http_client
async def close_http_client() -> None:
"""Close the persistent TMDb HTTP client if it was created."""
global _http_client
if _http_client is not None:
await _http_client.aclose()
_http_client = None
# Sentinel value to distinguish "cached None" from "not in cache"
_NOT_FOUND = object()
def _get_cache_path(endpoint: str, params: Dict[str, str]) -> Path:
def _get_cache_path(endpoint: str, params: dict[str, str]) -> Path:
"""Generate a cache file path for an API request."""
# Create a stable cache key from endpoint and sorted params
cache_key = endpoint + "?" + urllib.parse.urlencode(sorted(params.items()))
@@ -84,20 +89,17 @@ async def _load_from_cache(cache_path: Path):
if data.get("_cached_none"):
return None
return data
except Exception:
except OSError, TypeError, json.JSONDecodeError:
return _NOT_FOUND
async def _save_to_cache(cache_path: Path, data: Optional[Dict]):
async def _save_to_cache(cache_path: Path, data: dict | None) -> None:
"""Save response to cache."""
try:
await AsyncPath(_get_cache_dir()).mkdir(parents=True, exist_ok=True)
if data is None:
text = json.dumps({"_cached_none": True})
else:
text = json.dumps(data)
text = json.dumps({"_cached_none": True}) if data is None else json.dumps(data)
await AsyncPath(cache_path).write_text(text, encoding="utf-8")
except Exception:
except OSError, TypeError, ValueError:
pass # Cache write failures are not critical
@@ -105,8 +107,8 @@ async def _save_to_cache(cache_path: Path, data: Optional[Dict]):
async def tmdb_api_request(
endpoint: str, params: Optional[Dict[str, str]] = None
) -> Optional[Dict[str, str]]:
endpoint: str, params: dict[str, str] | None = None
) -> dict[str, str] | None:
"""Make a request to the TMDb API with disk caching and connection reuse."""
params = params or {}
@@ -139,35 +141,30 @@ async def tmdb_api_request(
# Cache the failure (None) to avoid retrying
await _save_to_cache(cache_path, None)
return None
except Exception:
except httpx.HTTPError:
# Don't cache network errors - they may be transient
return None
async def fetch_movie_details(movie_id: int) -> Optional[Dict]:
"""Fetch detailed movie info including credits, similar, keywords, and alternative titles."""
async def fetch_movie_details(movie_id: int) -> dict | None:
"""Fetch movie info including credits, keywords, alt titles, and collection."""
# Use append_to_response to get multiple data in one request
data = await tmdb_api_request(
return await tmdb_api_request(
f"/movie/{movie_id}",
{"append_to_response": "credits,similar,keywords,alternative_titles"},
{"append_to_response": "credits,keywords,alternative_titles"},
)
return data
async def fetch_series_details(series_id: int) -> Optional[Dict]:
"""Fetch detailed TV series info including credits, similar, and keywords."""
async def fetch_series_details(series_id: int) -> dict | None:
"""Fetch detailed TV series info including credits and keywords."""
# Use append_to_response to get multiple data in one request
data = await tmdb_api_request(
f"/tv/{series_id}", {"append_to_response": "credits,similar,keywords"}
return await tmdb_api_request(
f"/tv/{series_id}", {"append_to_response": "credits,keywords"}
)
return data
async def fetch_season_details(
series_id: int, season_number: int
) -> Optional[SeasonInfo]:
"""
Fetch detailed season info including all episodes.
async def fetch_season_details(series_id: int, season_number: int) -> SeasonInfo | None:
"""Fetch detailed season info including all episodes.
Returns season metadata with episode list including:
- Episode names, overviews, air dates
@@ -217,9 +214,21 @@ async def fetch_season_details(
)
def _map_person_gender(value: object) -> str | None:
"""Map TMDb person gender codes to stable string values."""
if value == 1:
return "female"
if value == 2:
return "male"
if value == 3:
return "non_binary"
if value == 0:
return "unknown"
return None
def _generate_title_variants(words: list[str], min_words: int = 2) -> list[str]:
"""
Generate title variants by progressively removing words from both ends.
"""Generate title variants by progressively removing words from both ends.
Order: full title, then shorter from end, then shorter from start.
"""
@@ -232,12 +241,15 @@ def _generate_title_variants(words: list[str], min_words: int = 2) -> list[str]:
variants.append(" ".join(words))
# Then try removing from end (most common: edition names at end)
for num_words in range(len(words) - 1, min_words - 1, -1):
variants.append(" ".join(words[:num_words]))
variants.extend(
" ".join(words[:num_words])
for num_words in range(len(words) - 1, min_words - 1, -1)
)
# Then try removing from start (garbage at beginning)
for start in range(1, len(words) - min_words + 1):
variants.append(" ".join(words[start:]))
variants.extend(
" ".join(words[start:]) for start in range(1, len(words) - min_words + 1)
)
# Finally try middle portions (remove from both ends)
for start in range(1, len(words) - min_words):
@@ -259,8 +271,7 @@ def _normalize_for_match(text: str) -> set[str]:
def _titles_match(original_title: str, tmdb_title: str, search_query: str) -> bool:
"""
Check if TMDb result title reasonably matches our original title.
"""Check if TMDb result title reasonably matches our original title.
Uses word overlap to verify the result is relevant, preventing
false matches from short queries like "The" or just a year.
@@ -312,11 +323,8 @@ def _titles_match(original_title: str, tmdb_title: str, search_query: str) -> bo
)
async def _search_movie_with_fallbacks(
title: str, year: Optional[int]
) -> Optional[Dict]:
"""
Search for a movie with progressive title shortening fallbacks.
async def _search_movie_with_fallbacks(title: str, year: int | None) -> dict | None:
"""Search for a movie with progressive title shortening fallbacks.
PTN often includes edition names (THEATRICAL CUT, DIRECTOR'S CUT, etc.)
or garbage at the beginning/end of the title.
@@ -327,7 +335,7 @@ async def _search_movie_with_fallbacks(
variants = _generate_title_variants(words, min_words=2)
def _result_matches(
top_result: Dict, original_title: str, search_query: str
top_result: dict, original_title: str, search_query: str
) -> bool:
"""Check if result matches against either title or original_title."""
tmdb_title = top_result.get("title", "")
@@ -363,7 +371,10 @@ async def _search_movie_with_fallbacks(
return None
async def fetch_movie_info(title: str, year: Optional[int] = None) -> Optional[Info]:
async def fetch_movie_info(
title: str,
year: int | None = None,
) -> tuple[Info, str | None, str | None, dict[int, Person]] | None:
"""Fetch comprehensive movie info from TMDb."""
data = await _search_movie_with_fallbacks(title, year)
@@ -373,20 +384,24 @@ async def fetch_movie_info(title: str, year: Optional[int] = None) -> Optional[I
result = data["results"][0]
movie_id = result["id"]
# Fetch full details with credits, similar movies, and keywords
# Fetch full details with credits, keywords, alt titles, and collection
details = await fetch_movie_details(movie_id)
if not details:
# Fall back to basic info from search
return Info(
return (
Info(
tmdb_id=movie_id,
title=result.get("title"),
original_title=result.get("original_title"),
original_language=result.get("original_language"),
rating=result.get("vote_average"),
vote_count=result.get("vote_count"),
overview=result.get("overview"),
poster_path=result.get("poster_path"),
backdrop_path=result.get("backdrop_path"),
release_date=result.get("release_date"),
),
result.get("poster_path"),
result.get("backdrop_path"),
{},
)
# Extract genres
@@ -410,55 +425,66 @@ async def fetch_movie_info(title: str, year: Optional[int] = None) -> Optional[I
alt_titles_set.discard(orig_title)
alternative_titles = sorted(alt_titles_set) if alt_titles_set else None
# Extract top cast (limit to 10)
credits = details.get("credits", {})
cast_data = credits.get("cast", [])[:10]
cast = [
CastMember(
name=c["name"],
character=c.get("character", ""),
# Extract full cast
credits_data = details.get("credits", {})
cast_data = credits_data.get("cast", [])
cast: list[CastCredit] = []
people: dict[int, Person] = {}
for c in cast_data:
person_id = c.get("id")
cast.append(
CastCredit(
character=c.get("character", "") or None,
id=person_id if isinstance(person_id, int) else None,
)
)
if isinstance(person_id, int):
people[person_id] = Person(
name=c.get("name") or "",
profile_path=c.get("profile_path"),
gender=_map_person_gender(c.get("gender")),
)
for c in cast_data
]
# Extract director from crew
crew = credits.get("crew", [])
crew = credits_data.get("crew", [])
directors = [c["name"] for c in crew if c.get("job") == "Director"]
director = directors[0] if directors else None
# Extract similar movies (limit to 10)
similar_data = details.get("similar", {}).get("results", [])[:10]
similar = [
SimilarMedia(id=s["id"], title=s["title"], poster_path=s.get("poster_path"))
for s in similar_data
]
collection_data = details.get("belongs_to_collection")
collection = None
if isinstance(collection_data, dict):
collection_name = collection_data.get("name")
if isinstance(collection_name, str):
collection = collection_name or None
return Info(
return (
Info(
tmdb_id=movie_id,
title=details.get("title"),
original_title=details.get("original_title"),
original_language=details.get("original_language"),
alternative_titles=alternative_titles,
rating=details.get("vote_average"),
vote_count=details.get("vote_count"),
overview=details.get("overview"),
genres=genres if genres else None,
genres=genres or None,
release_date=details.get("release_date"),
runtime=details.get("runtime"),
collection=collection,
status=details.get("status"),
tagline=details.get("tagline"),
poster_path=details.get("poster_path"),
backdrop_path=details.get("backdrop_path"),
similar=similar if similar else None,
keywords=keywords if keywords else None,
cast=cast if cast else None,
keywords=keywords or None,
cast=cast or None,
director=director,
),
details.get("poster_path"),
details.get("backdrop_path"),
people,
)
async def _search_series_with_fallbacks(title: str) -> Optional[Dict]:
"""
Search for a TV series with progressive title shortening fallbacks.
async def _search_series_with_fallbacks(title: str) -> dict | None:
"""Search for a TV series with progressive title shortening fallbacks.
PTN often includes extra text in the title at beginning or end.
Results are validated with fuzzy matching to prevent false positives.
@@ -479,7 +505,9 @@ async def _search_series_with_fallbacks(title: str) -> Optional[Dict]:
return None
async def fetch_series_info(title: str) -> Optional[Info]:
async def fetch_series_info(
title: str,
) -> tuple[Info, str | None, str | None, dict[int, Person]] | None:
"""Fetch comprehensive TV series info from TMDb."""
data = await _search_series_with_fallbacks(title)
@@ -489,19 +517,23 @@ async def fetch_series_info(title: str) -> Optional[Info]:
result = data["results"][0]
series_id = result["id"]
# Fetch full details with credits, similar shows, and keywords
# Fetch full details with credits and keywords
details = await fetch_series_details(series_id)
if not details:
# Fall back to basic info from search
return Info(
return (
Info(
tmdb_id=series_id,
title=result.get("name"),
original_title=result.get("original_name"),
original_language=result.get("original_language"),
rating=result.get("vote_average"),
vote_count=result.get("vote_count"),
overview=result.get("overview"),
poster_path=result.get("poster_path"),
backdrop_path=result.get("backdrop_path"),
),
result.get("poster_path"),
result.get("backdrop_path"),
{},
)
# Extract genres
@@ -511,17 +543,25 @@ async def fetch_series_info(title: str) -> Optional[Info]:
keywords_data = details.get("keywords", {}).get("results", [])
keywords = [k["name"] for k in keywords_data]
# Extract top cast (limit to 10)
credits = details.get("credits", {})
cast_data = credits.get("cast", [])[:10]
cast = [
CastMember(
name=c["name"],
character=c.get("character", ""),
# Extract full cast
credits_data = details.get("credits", {})
cast_data = credits_data.get("cast", [])
cast: list[CastCredit] = []
people: dict[int, Person] = {}
for c in cast_data:
person_id = c.get("id")
cast.append(
CastCredit(
character=c.get("character", "") or None,
id=person_id if isinstance(person_id, int) else None,
)
)
if isinstance(person_id, int):
people[person_id] = Person(
name=c.get("name") or "",
profile_path=c.get("profile_path"),
gender=_map_person_gender(c.get("gender")),
)
for c in cast_data
]
# Extract creators
creators = [c["name"] for c in details.get("created_by", [])]
@@ -529,34 +569,30 @@ async def fetch_series_info(title: str) -> Optional[Info]:
# Extract networks
networks = [n["name"] for n in details.get("networks", [])]
# Extract similar series (limit to 10)
similar_data = details.get("similar", {}).get("results", [])[:10]
similar = [
SimilarMedia(id=s["id"], title=s["name"], poster_path=s.get("poster_path"))
for s in similar_data
]
# Get first air date
first_air_date = details.get("first_air_date")
return Info(
return (
Info(
tmdb_id=series_id,
title=details.get("name"),
original_title=details.get("original_name"),
original_language=details.get("original_language"),
rating=details.get("vote_average"),
vote_count=details.get("vote_count"),
overview=details.get("overview"),
genres=genres if genres else None,
genres=genres or None,
release_date=first_air_date,
status=details.get("status"),
tagline=details.get("tagline"),
poster_path=details.get("poster_path"),
backdrop_path=details.get("backdrop_path"),
similar=similar if similar else None,
keywords=keywords if keywords else None,
cast=cast if cast else None,
creators=creators if creators else None,
keywords=keywords or None,
cast=cast or None,
creators=creators or None,
number_of_seasons=details.get("number_of_seasons"),
number_of_episodes=details.get("number_of_episodes"),
networks=networks if networks else None,
networks=networks or None,
),
details.get("poster_path"),
details.get("backdrop_path"),
people,
)
+128 -36
View File
@@ -1,12 +1,13 @@
"""Utility functions for paths, sizes, and timestamps."""
import os
import re
import time
import unicodedata
from pathlib import Path
from typing import Optional, List
from aiopathlib import AsyncPath
# Default output folder name (created at common root of scanned paths)
DEFAULT_OUTPUT_FOLDER = ".mediahive"
@@ -15,18 +16,97 @@ _ATIME_FRESHNESS_THRESHOLD = 3600
# Resolution priority for quality sorting (higher = better)
RESOLUTION_PRIORITY = {
"2160p": 4,
"8K": 5,
"4K": 4,
"FHD": 3,
"HD": 2,
"SD": 1,
# Backward compatibility for existing snapshot data
"4320p": 5,
"2160p": 4,
"UHD": 4,
"1080p": 3,
"1080i": 3,
"720p": 2,
"576p": 1,
"480p": 1,
}
async def get_added_timestamp(path: Path) -> Optional[int]:
def build_movie_id(title: str | None, year: int | None) -> str:
"""Build a readable movie ID slug from the title and year."""
normalized_title = unicodedata.normalize("NFKD", title or "")
ascii_title = normalized_title.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "-", ascii_title.lower()).strip("-")
slug = slug or "movie"
if year:
return f"{slug}-{year}"
return slug
def build_series_id(title: str | None) -> str:
"""Build a readable series ID slug from the title."""
normalized_title = unicodedata.normalize("NFKD", title or "")
ascii_title = normalized_title.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "-", ascii_title.lower()).strip("-")
return slug or "series"
def classify_resolution_from_dimensions(
width: int | None, height: int | None
) -> str | None:
"""Map raw frame dimensions to SD/HD/FHD/4K/8K buckets.
Uses the smallest standard frame bucket that can contain the source frame,
which keeps cropped cinematic encodes in their expected class.
"""
Get the timestamp when a torrent was added to the collection.
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.
Heuristic:
- For directories: use ctime (most accurate for torrent folder creation)
@@ -35,6 +115,7 @@ async def get_added_timestamp(path: Path) -> Optional[int]:
Returns:
Unix timestamp as int, or None if path doesn't exist
"""
ap = AsyncPath(path)
try:
@@ -54,33 +135,49 @@ async def get_added_timestamp(path: Path) -> Optional[int]:
return int(atime)
async def get_directory_size(path: Path) -> int:
"""Calculate total size of a directory recursively."""
ap = AsyncPath(path)
total = 0
def get_directory_size(path: Path) -> int:
"""Calculate total size using scandir recursion in a sync worker."""
try:
if await ap.is_file():
return (await ap.stat()).st_size
for item in ap.rglob("*"):
if await AsyncPath(item).is_file():
total += (await AsyncPath(item).stat()).st_size
if path.is_file():
return path.stat().st_size
except OSError, PermissionError:
pass
return 0
total = 0
stack = [path]
while stack:
current = stack.pop()
try:
with os.scandir(current) as entries:
for entry in entries:
try:
if entry.is_dir(follow_symlinks=False):
stack.append(Path(entry.path))
continue
except OSError:
continue
try:
total += entry.stat(follow_symlinks=False).st_size
except OSError:
continue
except OSError, PermissionError:
continue
return total
def format_size(size_bytes: int) -> str:
"""Format size in human-readable format."""
size = float(size_bytes)
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size_bytes < 1024:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.2f} PB"
if size < 1024:
return f"{size:.2f} {unit}"
size /= 1024
return f"{size:.2f} PB"
async def find_common_root(paths: List[Path]) -> Optional[Path]:
"""
Find the common root directory for a list of paths.
async def find_common_root(paths: list[Path]) -> Path | None:
"""Find the common root directory for a list of paths.
Returns None if paths are on different drives/mounts or have no common ancestor.
"""
@@ -126,7 +223,7 @@ async def find_common_root(paths: List[Path]) -> Optional[Path]:
# Find common prefix
common_parts = []
for parts in zip(*all_parts):
for parts in zip(*all_parts, strict=False):
if len(set(parts)) == 1:
common_parts.append(parts[0])
else:
@@ -138,11 +235,8 @@ async def find_common_root(paths: List[Path]) -> Optional[Path]:
return Path(*common_parts)
def make_relative_path(
path: Optional[str], root: Optional[str] = None
) -> Optional[str]:
"""
Convert an absolute path to a posix-style path relative to the given root.
def make_relative_path(path: str | None, root: str | None = None) -> str | None:
"""Convert an absolute path to a posix-style path relative to the given root.
If root is None, returns the path as a posix string unchanged.
"""
@@ -161,20 +255,18 @@ def sanitize_filename(name: str) -> str:
"""Sanitize a string for use as a filename."""
for char in ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]:
name = name.replace(char, "_")
name = name.strip(". ")
return name
return name.strip(". ")
def get_media_folder_name(title: str, year: Optional[int], media_type: str) -> str:
def get_media_folder_name(title: str, year: int | None, media_type: str) -> str:
"""Get the folder name for a media item."""
sanitized_title = sanitize_filename(title)
if media_type == "movie" and year:
return f"{sanitized_title} ({year})"
return sanitized_title
if media_type == "movie":
return build_movie_id(title, year)
return build_series_id(title)
def get_media_folder_path(
title: str, year: Optional[int], media_type: str, cover_dir: Path
title: str, year: int | None, media_type: str, cover_dir: Path
) -> Path:
"""Get the full path to a media item's folder."""
subdir = "movies" if media_type == "movie" else "series"
+307 -73
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.
All mutations happen synchronously in the asyncio event loop no locks needed.
@@ -8,11 +7,11 @@ debounced background task.
"""
import asyncio
import contextlib
import logging
import os
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Optional
import msgspec
from aiopathlib import AsyncPath
@@ -20,21 +19,19 @@ from fastapi import WebSocket
from mediahive.models.data import (
IndexSnapshot,
MediaStats,
Movie,
Series,
TaskInfo,
)
from mediahive.models.events import Remove, Task, Upsert
from mediahive.models.protocol import (
WsInit,
WsInitData,
)
from mediahive.models.tmdb import Person
logger = logging.getLogger("mediahive.index_store")
# Debounce interval for writing snapshots to disk (seconds)
SNAPSHOT_DEBOUNCE = 5.0
# Debounce interval for rebuilding the in-memory API snapshot (seconds)
SNAPSHOT_CACHE_DEBOUNCE = 0.25
class IndexStore:
@@ -48,20 +45,38 @@ class IndexStore:
# This would make IndexStore testable without FastAPI's WebSocket.
"""
def __init__(self, snapshot_path: Path, media_root: Optional[str] = None):
def __init__(
self,
snapshot_path: Path,
) -> None:
self.snapshot_path = snapshot_path
self.media_root = media_root
self.snapshot_loaded = False
# The index: keyed by item id
self.movies: dict[str, Movie] = {}
self.series: dict[str, Series] = {}
self.people: dict[int, Person] = {}
self._movie_tmdb_ids: dict[int, str] = {}
self._series_tmdb_ids: dict[int, str] = {}
# Connected WebSocket clients
self._clients: set[WebSocket] = set()
# Passive listeners for broadcast events (used by server-level WS fan-in)
self._listeners: set[Callable[[object], None]] = set()
# Snapshot debounce state
self._snapshot_dirty = False
self._snapshot_task: Optional[asyncio.Task] = None
self._snapshot_task: asyncio.Task | None = None
# In-memory API snapshot cache (served by get_full_index)
self._snapshot_cache_dirty = True
self._snapshot_cache_task: asyncio.Task | None = None
self._cached_snapshot = IndexSnapshot(
generated_at=datetime.now().isoformat(),
movies={},
series={},
people={},
)
# ------------------------------------------------------------------
# Persistence
@@ -70,41 +85,187 @@ class IndexStore:
async def load_snapshot(self) -> None:
"""Load index from disk snapshot (recovery on startup)."""
ap = AsyncPath(self.snapshot_path)
self.snapshot_loaded = False
if not await ap.exists():
logger.info("No snapshot found at %s, starting fresh", self.snapshot_path)
self._schedule_snapshot_cache_refresh()
return
try:
data = msgspec.json.decode(await ap.read_bytes(), type=IndexSnapshot)
for m in data.movies:
self.movies[m.id] = m
for s in data.series:
self.series[s.id] = s
logger.info(
"Loaded snapshot: %d movies, %d series",
len(self.movies),
len(self.series),
raw = await ap.read_bytes()
loaded_movies, loaded_series, loaded_people = await asyncio.to_thread(
self._load_snapshot_sync,
raw,
)
self._merge_loaded_snapshot(
loaded_movies,
loaded_series,
loaded_people,
)
self._rebuild_tmdb_indexes()
self.snapshot_loaded = True
self._schedule_snapshot_cache_refresh()
except Exception:
logger.exception("Failed to load snapshot from %s", self.snapshot_path)
def _load_snapshot_sync(
self,
raw: bytes,
) -> tuple[dict[str, Movie], dict[str, Series], dict[int, Person]]:
"""Parse snapshot bytes in a thread-pool context."""
data = msgspec.json.decode(raw, type=IndexSnapshot)
loaded_movies = dict(data.movies)
loaded_series = dict(data.series)
loaded_people = dict(data.people)
logger.info(
"Loaded snapshot: %d movies, %d series, %d people",
len(loaded_movies),
len(loaded_series),
len(loaded_people),
)
return loaded_movies, loaded_series, loaded_people
def _merge_loaded_snapshot(
self,
movies: dict[str, Movie],
series: dict[str, Series],
people: dict[int, Person],
) -> None:
"""Merge loaded snapshot items without overriding newer in-memory updates."""
for item_id, movie in movies.items():
if item_id not in self.movies:
self.movies[item_id] = movie
for item_id, show in series.items():
if item_id not in self.series:
self.series[item_id] = show
for person_id, person in people.items():
self.people[person_id] = person
@staticmethod
def _get_tmdb_id(item: Movie | Series) -> int | None:
if item.info is None:
return None
return item.info.tmdb_id
def _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:
"""Remove other movie entries that share a TMDb id."""
for item_id, movie in list(self.movies.items()):
if item_id == keep_id:
continue
if self._get_tmdb_id(movie) == tmdb_id:
self.movies.pop(item_id, None)
self._rebuild_tmdb_indexes()
def _collapse_series_tmdb_duplicates(self, tmdb_id: int, keep_id: str) -> None:
"""Remove other series entries that share a TMDb id."""
for item_id, series in list(self.series.items()):
if item_id == keep_id:
continue
if self._get_tmdb_id(series) == tmdb_id:
self.series.pop(item_id, None)
self._rebuild_tmdb_indexes()
async def _write_snapshot(self) -> None:
"""Write current index to disk (called from debounce task)."""
snapshot = self._build_snapshot()
await AsyncPath(self.snapshot_path.parent).mkdir(parents=True, exist_ok=True)
tmp = self.snapshot_path.with_suffix(".tmp")
await AsyncPath(tmp).write_bytes(
msgspec.json.format(msgspec.json.encode(snapshot), indent=2)
)
# os.replace is atomic and overwrites on all platforms (unlike rename on Windows)
await asyncio.to_thread(os.replace, tmp, self.snapshot_path)
# Copy values on the event loop thread, then do full snapshot build + disk I/O
# in a worker thread to keep the loop responsive.
movies = dict(self.movies)
series = dict(self.series)
people = dict(self.people)
await asyncio.to_thread(self._write_snapshot_sync, movies, series, people)
logger.debug("Snapshot written to %s", self.snapshot_path)
def _write_snapshot_sync(
self,
movies: dict[str, Movie],
series: dict[str, Series],
people: dict[int, Person],
) -> None:
"""Build and write snapshot synchronously in a worker thread."""
snapshot = self._build_snapshot_from_maps(movies, series, people)
self.snapshot_path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.snapshot_path.with_suffix(".tmp")
tmp.write_bytes(msgspec.json.encode(snapshot))
# Path.replace is atomic and overwrites on all platforms.
tmp.replace(self.snapshot_path)
def _schedule_snapshot(self) -> None:
"""Schedule a debounced snapshot write."""
self._snapshot_dirty = True
if self._snapshot_task is None or self._snapshot_task.done():
self._snapshot_task = asyncio.create_task(self._snapshot_writer())
self._schedule_snapshot_cache_refresh()
def _schedule_snapshot_cache_refresh(self) -> None:
"""Schedule a debounced rebuild of the in-memory API snapshot cache."""
self._snapshot_cache_dirty = True
if self._snapshot_cache_task is None or self._snapshot_cache_task.done():
self._snapshot_cache_task = asyncio.create_task(
self._snapshot_cache_writer()
)
async def _refresh_snapshot_cache_once(self) -> None:
"""Rebuild cached snapshot once using copied store values."""
movies = dict(self.movies)
series = dict(self.series)
people = dict(self.people)
self._cached_snapshot = await asyncio.to_thread(
self._build_snapshot_from_maps,
movies,
series,
people,
)
async def _snapshot_cache_writer(self) -> None:
"""Refresh the in-memory snapshot cache while mutations are pending."""
while True:
await asyncio.sleep(SNAPSHOT_CACHE_DEBOUNCE)
if self._snapshot_cache_dirty:
self._snapshot_cache_dirty = False
await self._refresh_snapshot_cache_once()
else:
break
async def _snapshot_writer(self) -> None:
"""Flush to disk every SNAPSHOT_DEBOUNCE seconds while dirty."""
@@ -118,49 +279,107 @@ class IndexStore:
async def flush_snapshot(self) -> None:
"""Force-write a snapshot immediately (e.g. on shutdown)."""
if self._snapshot_cache_task and not self._snapshot_cache_task.done():
self._snapshot_cache_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._snapshot_cache_task
await self._refresh_snapshot_cache_once()
if self._snapshot_task and not self._snapshot_task.done():
self._snapshot_task.cancel()
try:
with contextlib.suppress(asyncio.CancelledError):
await self._snapshot_task
except asyncio.CancelledError:
pass
await self._write_snapshot()
# ------------------------------------------------------------------
# Mutations
# ------------------------------------------------------------------
def upsert_movie(self, item: Movie) -> bool:
def upsert_movie(
self,
item_id: str,
item: Movie,
people: dict[int, Person] | None = None,
) -> bool:
"""Insert or update a movie. Returns True if it was a real change."""
existing = self.movies.get(item.id)
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)
if existing is not None:
if msgspec.json.encode(existing) == msgspec.json.encode(item):
return False
self.movies[item.id] = item
if people:
self.people.update(people)
self._schedule_snapshot()
self._broadcast(Upsert(kind="movie", item=item))
self._broadcast(
Upsert(kind="movie", id=item_id, item=item, people=people)
)
return True
return False
self.movies[item_id] = item
if people:
self.people.update(people)
self._schedule_snapshot()
self._broadcast(Upsert(kind="movie", id=item_id, item=item, people=people))
return True
def upsert_series(self, item: Series) -> bool:
def upsert_series(
self,
item_id: str,
item: Series,
people: dict[int, Person] | None = None,
) -> bool:
"""Insert or update a series. Returns True if it was a real change."""
existing = self.series.get(item.id)
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)
if existing is not None:
if msgspec.json.encode(existing) == msgspec.json.encode(item):
return False
self.series[item.id] = item
if people:
self.people.update(people)
self._schedule_snapshot()
self._broadcast(Upsert(kind="series", item=item))
self._broadcast(
Upsert(kind="series", id=item_id, item=item, people=people)
)
return True
return False
self.series[item_id] = item
if people:
self.people.update(people)
self._schedule_snapshot()
self._broadcast(Upsert(kind="series", id=item_id, item=item, people=people))
return True
def remove_movie(self, item_id: str) -> None:
"""Remove a movie from the index and broadcast."""
self.movies.pop(item_id, None)
for tmdb_id, mapped_id in list(self._movie_tmdb_ids.items()):
if mapped_id == item_id:
self._movie_tmdb_ids.pop(tmdb_id, None)
self._schedule_snapshot()
self._broadcast(Remove(kind="movie", id=item_id))
def remove_series(self, item_id: str) -> None:
"""Remove a series from the index and broadcast."""
self.series.pop(item_id, None)
for tmdb_id, mapped_id in list(self._series_tmdb_ids.items()):
if mapped_id == item_id:
self._series_tmdb_ids.pop(tmdb_id, None)
self._schedule_snapshot()
self._broadcast(Remove(kind="series", id=item_id))
@@ -168,18 +387,30 @@ class IndexStore:
# WebSocket management
# ------------------------------------------------------------------
def add_listener(self, listener: Callable[[object], None]) -> None:
"""Register a listener called for each broadcast message."""
self._listeners.add(listener)
def remove_listener(self, listener: Callable[[object], None]) -> None:
"""Unregister a previously registered broadcast listener."""
self._listeners.discard(listener)
async def connect(self, ws: WebSocket) -> None:
"""Accept a WS client and send the full index as init."""
await ws.accept()
self._clients.add(ws)
logger.info("WS client connected (%d total)", len(self._clients))
# Send full current state
msg = WsInit(
data=WsInitData(
movies=list(self.movies.values()),
series=list(self.series.values()),
)
)
msg = {
"type": "init",
"roots": {
"": {
"movies": dict(self.movies),
"series": dict(self.series),
"people": dict(self.people),
}
},
}
await ws.send_bytes(msgspec.json.encode(msg))
def disconnect(self, ws: WebSocket) -> None:
@@ -189,6 +420,12 @@ class IndexStore:
def _broadcast(self, msg: object) -> None:
"""Broadcast a message to all connected WS clients (non-blocking)."""
for listener in tuple(self._listeners):
try:
listener(msg)
except Exception:
logger.exception("IndexStore listener failed")
data = msgspec.json.encode(msg)
dead: list[WebSocket] = []
for ws in self._clients:
@@ -202,7 +439,7 @@ class IndexStore:
"""Send data to a WS client; mark as dead on failure."""
try:
await ws.send_bytes(data)
except Exception:
except OSError, RuntimeError:
dead.append(ws)
def broadcast_task(self, task_info: TaskInfo) -> None:
@@ -213,31 +450,28 @@ class IndexStore:
# Read helpers
# ------------------------------------------------------------------
def _build_snapshot(self) -> IndexSnapshot:
"""Build a sorted IndexSnapshot with computed stats."""
movies_list = sorted(
self.movies.values(), key=lambda x: (x.title.lower(), x.year or 0)
)
series_list = sorted(self.series.values(), key=lambda x: x.title.lower())
total_movie_versions = sum(len(m.torrents) for m in movies_list)
total_series_episodes = sum(
sum(len(season.episodes) for season in s.seasons) for s in series_list
)
def _build_snapshot_from_maps(
self,
movies: dict[str, Movie],
series: dict[str, Series],
people: dict[int, Person],
) -> IndexSnapshot:
"""Build a keyed IndexSnapshot from map copies."""
return IndexSnapshot(
generated_at=datetime.now().isoformat(),
media_root=self.media_root,
stats=MediaStats(
total_movies=len(movies_list),
total_movie_versions=total_movie_versions,
total_series=len(series_list),
total_series_episodes=total_series_episodes,
),
movies=movies_list,
series=series_list,
movies=movies,
series=series,
people=people,
)
def _build_snapshot(self) -> IndexSnapshot:
"""Build a sorted IndexSnapshot with computed stats."""
return self._build_snapshot_from_maps(
dict(self.movies),
dict(self.series),
dict(self.people),
)
def get_full_index(self) -> IndexSnapshot:
"""Return the full index as an IndexSnapshot."""
return self._build_snapshot()
"""Return the latest in-memory IndexSnapshot cache."""
return self._cached_snapshot
+1
View File
@@ -0,0 +1 @@
"""Shared data/protocol models for MediaHive."""
+19 -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.
"""
@@ -8,23 +7,29 @@ from __future__ import annotations
import msgspec
from .tmdb import Info
from .tmdb import Info, Person
# ---------------------------------------------------------------------------
# Index item types (the state stored in IndexStore, sent over WS/API)
# ---------------------------------------------------------------------------
class Torrent(msgspec.Struct):
class Torrent(msgspec.Struct, omit_defaults=True):
"""A torrent file, either for a movie or an episode."""
title: str | None = None
playable_file: str | None = None
resolution: str | None = None
quality: str | None = None
network: str | None = None
codec: str | None = None
audio: str | None = None
audio_languages: list[str] | None = None
subtitle_languages: list[str] | None = None
hdr: bool = False
dovi: bool = False
atmos: bool = False
hdr10plus: bool = False
encoder: str | None = None
size: int | None = None
added_at: int | None = None
@@ -42,7 +47,8 @@ class Episode(msgspec.Struct):
rating: float | None = None
director: str | None = None
reel_image: str | None = None
torrents: dict[str, Torrent] = {}
reel_sources: list[str] | None = None
files: dict[str, Torrent] = {}
class Season(msgspec.Struct):
@@ -60,7 +66,6 @@ class Season(msgspec.Struct):
class Movie(msgspec.Struct):
"""A movie in the index (one or more versions/releases)."""
id: str
title: str | None = None
info: Info | None = None
year: int | None = None
@@ -68,13 +73,13 @@ class Movie(msgspec.Struct):
cover_path: str | None = None
backdrop_path: str | None = None
showreel_images: list[str] | None = None
torrents: dict[str, Torrent] = {}
showreel_source_sets: list[list[str]] | None = None
files: dict[str, Torrent] = {}
class Series(msgspec.Struct):
"""A TV series in the index."""
id: str
title: str | None = None
info: Info | None = None
alternative_titles: list[str] | None = None
@@ -88,29 +93,17 @@ class Series(msgspec.Struct):
# Snapshot (disk format for index.json)
# ---------------------------------------------------------------------------
class MediaStats(msgspec.Struct):
"""Aggregate counts for the index snapshot."""
total_movies: int = 0
total_movie_versions: int = 0
total_series: int = 0
total_series_episodes: int = 0
INDEX_SNAPSHOT_VERSION = 1
class IndexSnapshot(msgspec.Struct):
"""On-disk recovery snapshot of the full index."""
version: int = 6
v: int = INDEX_SNAPSHOT_VERSION
generated_at: str = ""
media_root: str | None = None
stats: MediaStats = msgspec.UNSET # type: ignore[assignment]
movies: list[Movie] = []
series: list[Series] = []
def __post_init__(self):
if self.stats is msgspec.UNSET:
self.stats = MediaStats()
movies: dict[str, Movie] = {}
series: dict[str, Series] = {}
people: dict[int, Person] = {}
class TaskInfo(msgspec.Struct):
+4 -2
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:
- Internal scan events (scanner server queue)
@@ -11,13 +10,16 @@ from __future__ import annotations
import msgspec
from .data import Movie, Series, TaskInfo
from .tmdb import Person
class Upsert(msgspec.Struct, tag="upsert"):
"""Single item inserted or updated."""
kind: str # "movie" or "series"
id: str
item: Movie | Series
people: dict[int, Person] | None = None
class Remove(msgspec.Struct, tag="remove"):
+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.
"""
@@ -9,41 +8,87 @@ from __future__ import annotations
import msgspec
from fastapi.responses import Response
from .data import Movie, Series
from .events import Remove, ScanEvent, Task, Upsert
from .data import Movie, Series, TaskInfo
from .events import ScanEvent
from .tmdb import Person
# ---------------------------------------------------------------------------
# WebSocket message types
# ---------------------------------------------------------------------------
class WsInitData(msgspec.Struct):
"""Payload of the init message."""
class WsRootStatus(msgspec.Struct):
"""Current status for one configured root."""
movies: list[Movie]
series: list[Series]
root_id: str
path: str
status: str
error: str | None = None
snapshot_loaded: bool = False
movies: int = 0
series: int = 0
class WsRootInitData(msgspec.Struct):
"""Initial full index payload for one root."""
movies: dict[str, Movie]
series: dict[str, Series]
people: dict[int, Person]
class WsRoots(msgspec.Struct, tag="roots"):
"""Root list and status update."""
roots: list[WsRootStatus]
class WsInit(msgspec.Struct, tag="init"):
"""Full index sent on WS connect."""
"""Full index payload keyed by root_id."""
data: WsInitData
roots: dict[str, WsRootInitData]
class WsUpsert(msgspec.Struct, tag="upsert"):
"""Single item inserted or updated for one root."""
root_id: str
kind: str # "movie" or "series"
id: str
item: Movie | Series
people: dict[int, Person] | None = None
class WsRemove(msgspec.Struct, tag="remove"):
"""Single item removed for one root."""
root_id: str
kind: str
id: str
class WsTask(msgspec.Struct, tag="task"):
"""Task progress update for one root."""
root_id: str
data: TaskInfo
# Union of all outbound WS messages (for documentation / future decoding)
WsMessage = WsInit | Upsert | Remove | Task
WsMessage = WsRoots | WsInit | WsUpsert | WsRemove | WsTask
# Re-export unified types for backward compatibility
__all__ = [
"Remove",
"ScanEvent",
"Task",
"Upsert",
"WsInit",
"WsInitData",
"WsMessage",
"WsRemove",
"WsRootInitData",
"WsRootStatus",
"WsRoots",
"WsTask",
"WsUpsert",
]
@@ -52,37 +97,50 @@ __all__ = [
# ---------------------------------------------------------------------------
class ScanRequest(msgspec.Struct):
"""POST /api/scan body."""
paths: list[str] | None = None
class StatusResponse(msgspec.Struct):
"""GET /api/status response."""
scanning: bool = False
movies: int = 0
series: int = 0
showreel_queue: int = 0
class PlayMediaRequest(msgspec.Struct):
"""POST /api/play body (mediahive server)."""
"""POST /api/play/{root_id} body."""
file_path: str = ""
player_id: str | None = None
player_custom_cmd: str | None = None
class OpenFolderRequest(msgspec.Struct):
"""POST /api/open-folder body (mediahive server)."""
"""POST /api/open-folder/{root_id} body."""
folder_path: str = ""
class ChangeFolderRequest(msgspec.Struct):
"""POST /api/change-folder body."""
class RootsRequest(msgspec.Struct):
"""PUT /api/config/roots body."""
folder: str
roots: dict[str, str]
class PlaybackStateUpdateRequest(msgspec.Struct):
"""POST /api/meta/playback-state body."""
root_id: str
file_path: str
pos: int | None = None
class RootEntryResponse(msgspec.Struct):
"""Single root entry in responses."""
path: str
root_id: str
class RootStatusResponse(msgspec.Struct):
"""Legacy per-root status shape kept for non-WS callers."""
root_id: str
path: str
status: str
error: str | None = None
movies: int = 0
series: int = 0
# ---------------------------------------------------------------------------
+14 -18
View File
@@ -1,5 +1,4 @@
"""
TMDb data structures.
"""TMDb data structures.
All types are msgspec.Structs for fast serialization.
"""
@@ -8,26 +7,24 @@ from __future__ import annotations
import msgspec
# ---------------------------------------------------------------------------
# Sub-types (shared by TMDb results and index items)
# ---------------------------------------------------------------------------
class CastMember(msgspec.Struct):
"""Actor/crew member."""
class CastCredit(msgspec.Struct, array_like=True):
"""Cast reference embedded in media info (character + person id)."""
character: str | None = None
id: int | None = None
class Person(msgspec.Struct, array_like=True):
"""Deduplicated person payload stored in top-level people map."""
name: str
character: str | None = None
profile_path: str | None = None
class SimilarMedia(msgspec.Struct):
"""Pointer to a similar movie/series on TMDb."""
id: int
title: str
poster_path: str | None = None
gender: str | None = None
# ---------------------------------------------------------------------------
@@ -68,6 +65,7 @@ class Info(msgspec.Struct):
tmdb_id: int
title: str | None = None
original_title: str | None = None
original_language: str | None = None
alternative_titles: list[str] | None = None
rating: float | None = None
vote_count: int | None = None
@@ -75,13 +73,11 @@ class Info(msgspec.Struct):
genres: list[str] | None = None
release_date: str | None = None
runtime: int | None = None
collection: str | None = None
status: str | None = None
tagline: str | None = None
poster_path: str | None = None
backdrop_path: str | None = None
similar: list[SimilarMedia] | None = None
keywords: list[str] | None = None
cast: list[CastMember] | None = None
cast: list[CastCredit] | None = None
director: str | None = None
creators: list[str] | None = None
number_of_seasons: int | None = None
+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,
)
+333
View File
@@ -0,0 +1,333 @@
"""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 ScanEvent, 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)
else:
self.store.upsert_series(event.id, event.item, event.people)
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:
for ctx in list(self._contexts.values()):
await ctx.stop()
self._contexts.clear()
+1125 -288
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
+425 -122
View File
@@ -5,32 +5,38 @@ Or from PyInstaller: MediaHive.exe [media_folder]
"""
import argparse
from concurrent.futures import Future, ThreadPoolExecutor
import asyncio
import contextlib
import ctypes
import html
import json
import logging
import os
import re
import socket
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import Future, ThreadPoolExecutor
from pathlib import Path
import msgspec.structs
import uvicorn
import webview
import msgspec.structs
from mediahive.__main__ import DEFAULT_PORT, resolve_media_root
from mediahive.config import Config, load_config, save_config
from mediahive.config import load_config, save_config
from mediahive.volume_control import get_volume, set_volume, volume_max
logger = logging.getLogger("mediahive.winmain")
BACKEND_HOST = "127.0.0.1"
BACKEND_PORT = 8420
BACKEND_URL = f"http://{BACKEND_HOST}:{BACKEND_PORT}"
HEALTH_TIMEOUT = 2 # seconds
BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds
BACKEND_HEALTH_POLL_SECONDS = 0.25
MPC_BE_URL = "http://127.0.0.1:13579"
GAMEPAD_REPEAT_SECONDS = 0.008
GAMEPAD_POLL_SECONDS = 0.008
@@ -48,6 +54,10 @@ MPC_BE_SEEK_BEGIN_COMMAND = 1085
MPC_BE_RESUME_APPLY_THRESHOLD_MS = 15000
MPC_BE_RESUME_CLEAR_MARGIN_MS = 15000
MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS = 1.0
VOLUME_MIN = 0.0
VOLUME_MAX = 1.5
VOLUME_STEP = 0.01
VOLUME_REPEAT_SECONDS = 0.02
class _XINPUT_GAMEPAD(ctypes.Structure):
@@ -87,8 +97,6 @@ _XINPUT_BUTTONS = {
}
_MPC_BE_COMMANDS = {
0x0001: 907,
0x0002: 908,
0x1000: 889,
0x2000: 816,
0x8000: 909,
@@ -100,8 +108,6 @@ _MPC_BE_SEEK_MASK_TO_COMMANDS = {
}
_MPC_BE_REPEATABLE_MASKS = {
0x0001,
0x0002,
*_MPC_BE_SEEK_MASK_TO_COMMANDS,
}
@@ -114,54 +120,127 @@ _DURATION_RE = re.compile(r'<p id="duration">(\d+)</p>')
def _default_playback_state() -> dict[str, object]:
return {
"current": None,
"resume_positions": {},
}
def _load_playback_state(path: Path) -> dict[str, object]:
def _normalize_media_path(path: str) -> str:
return path.replace("\\", "/").lstrip("/")
def _expand_playable_file(file_key: str, playable_file: str | None) -> str:
if not playable_file:
return file_key
if playable_file.startswith("concat:") or "://" in playable_file:
return playable_file
if playable_file.startswith(f"{file_key}/"):
return playable_file
if playable_file.startswith("/"):
return playable_file.lstrip("/")
return f"{file_key}/{playable_file}"
def _fetch_resume_positions(backend_url: str) -> dict[str, int]:
req = urllib.request.Request(
url=f"{backend_url}/api/meta/playback-state",
method="GET",
)
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return _default_playback_state()
with urllib.request.urlopen(req, timeout=2) as resp:
raw = json.loads(resp.read().decode("utf-8"))
except OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError:
return {}
if not isinstance(raw, dict):
return _default_playback_state()
data = raw.get("data") if isinstance(raw, dict) else None
positions = data.get("resume_positions") if isinstance(data, dict) else None
if not isinstance(positions, dict):
return {}
current = raw.get("current")
resume_positions = raw.get("resume_positions")
normalized: dict[str, object] = {
"current": current if isinstance(current, dict) else None,
"resume_positions": {},
}
if isinstance(resume_positions, dict):
cleaned_positions: dict[str, int] = {}
for key, value in resume_positions.items():
if isinstance(key, str) and isinstance(value, (int, float)):
cleaned_positions[key] = max(0, int(value))
normalized["resume_positions"] = cleaned_positions
return normalized
cleaned: dict[str, int] = {}
for slug, value in positions.items():
if not isinstance(slug, str) or not isinstance(value, dict):
continue
pos = value.get("pos")
if isinstance(pos, int) and pos > 0:
cleaned[slug] = pos * 1000
return cleaned
def _save_playback_state(path: Path, state: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
tmp_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
tmp_path.replace(path)
def _media_key_for_filepath(filepath: str, media_root: Path) -> str | None:
def _post_resume_position(
backend_url: str,
root_id: str,
file_path: str,
pos: int | None,
) -> bool:
body = json.dumps({
"root_id": root_id,
"file_path": file_path,
"pos": pos,
}).encode("utf-8")
req = urllib.request.Request(
url=f"{backend_url}/api/meta/playback-state",
data=body,
method="POST",
headers={"Content-Type": "application/json"},
)
try:
relative = Path(filepath).resolve().relative_to(media_root.resolve())
except Exception:
with urllib.request.urlopen(req, timeout=2):
return True
except OSError, TimeoutError, urllib.error.URLError:
logger.warning("Failed to post playback-state update for %s", file_path)
return False
def _load_movie_slug_map(index_path: Path) -> dict[str, str]:
try:
raw = json.loads(index_path.read_text(encoding="utf-8"))
except OSError, TypeError, json.JSONDecodeError:
return {}
movies = raw.get("movies") if isinstance(raw, dict) else None
if not isinstance(movies, dict):
return {}
mapping: dict[str, str] = {}
for movie_id, movie in movies.items():
if not isinstance(movie_id, str) or not isinstance(movie, dict):
continue
files = movie.get("files")
if not isinstance(files, dict):
continue
for file_key, torrent in files.items():
if not isinstance(file_key, str):
continue
normalized_key = _normalize_media_path(file_key)
mapping[normalized_key] = movie_id
playable_file = (
torrent.get("playable_file") if isinstance(torrent, dict) else None
)
expanded = _expand_playable_file(
file_key, playable_file if isinstance(playable_file, str) else None
)
mapping[_normalize_media_path(expanded)] = movie_id
return mapping
def _media_key_for_filepath(
filepath: str, roots: dict[str, Path]
) -> tuple[str | None, str, str] | None:
"""Resolve a filepath to a (movie_slug, root_id, relative_key) tuple."""
for root_id, root in roots.items():
try:
relative = Path(filepath).resolve().relative_to(root.resolve())
relative_key = relative.as_posix()
index_path = root / ".mediahive" / "index.json"
movie_slug = _load_movie_slug_map(index_path).get(
_normalize_media_path(relative_key)
)
return movie_slug, root_id, relative_key
except OSError, RuntimeError, ValueError:
continue
return None
return relative.as_posix()
def _should_clear_resume(position_ms: int, duration_ms: int) -> bool:
if position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS:
return True
if duration_ms <= 0:
return False
return duration_ms - position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS
@@ -177,7 +256,7 @@ def _load_xinput_get_state():
fn.argtypes = [ctypes.c_uint, ctypes.POINTER(_XINPUT_STATE)]
fn.restype = ctypes.c_ulong
return fn
except Exception:
except AttributeError, OSError:
continue
raise RuntimeError("XInput DLL not found")
@@ -188,7 +267,7 @@ def _mpcbe_request(path: str, timeout: float = MPC_BE_REQUEST_TIMEOUT) -> bool:
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return 200 <= resp.status < 300
except (urllib.error.URLError, TimeoutError, OSError):
except urllib.error.URLError, TimeoutError, OSError:
return False
@@ -204,12 +283,10 @@ def _format_mpcbe_position(position_ms: int) -> str:
def _seek_mpcbe_to_position(position_ms: int) -> bool:
query = urllib.parse.urlencode(
{
query = urllib.parse.urlencode({
"wm_command": -1,
"position": _format_mpcbe_position(position_ms),
}
)
})
return _mpcbe_request(f"/command.html?{query}")
@@ -219,7 +296,7 @@ def _mpcbe_fetch_status() -> tuple[str, int, int, int] | None:
try:
with urllib.request.urlopen(req, timeout=MPC_BE_REQUEST_TIMEOUT) as resp:
response_html = resp.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, TimeoutError, OSError):
except urllib.error.URLError, TimeoutError, OSError:
return None
state_match = _STATE_RE.search(response_html)
@@ -238,18 +315,21 @@ def _mpcbe_fetch_status() -> tuple[str, int, int, int] | None:
)
def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> threading.Thread:
def _start_gamepad_remote(
stop_event: threading.Event, roots: dict[str, Path], backend_url: str
) -> threading.Thread:
"""Start background XInput polling and send mapped commands to MPC-BE."""
get_state = _load_xinput_get_state()
last_connected = [False, False, False, False]
last_pressed_masks = [0, 0, 0, 0]
seek_begin_hold_started_at: list[float | None] = [None, None, None, None]
seek_begin_fired = [False, False, False, False]
last_volume_repeat_up_at = [0.0, 0.0, 0.0, 0.0]
last_volume_repeat_down_at = [0.0, 0.0, 0.0, 0.0]
last_repeat_at = [
{
mask: 0.0
for mask in (*_MPC_BE_COMMANDS.keys(), *_MPC_BE_SEEK_MASK_TO_COMMANDS.keys())
}
dict.fromkeys(
(*_MPC_BE_COMMANDS.keys(), *_MPC_BE_SEEK_MASK_TO_COMMANDS.keys()), 0.0
)
for _ in range(4)
]
request_pool = ThreadPoolExecutor(
@@ -265,16 +345,12 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
player_state: int | None = None
status_updated_at = 0.0
status_miss_count = 0
playback_state_path = media_root / ".mediahive" / "playback-state.json"
playback_state = _load_playback_state(playback_state_path)
resume_positions = playback_state["resume_positions"]
if not isinstance(resume_positions, dict):
resume_positions = {}
playback_state["resume_positions"] = resume_positions
if playback_state.get("current") is not None:
playback_state["current"] = None
_save_playback_state(playback_state_path, playback_state)
playback_state = _default_playback_state()
resume_positions = _fetch_resume_positions(backend_url)
tracked_media_key: str | None = None
tracked_root_id: str | None = None
tracked_relative_path = ""
tracked_filepath = ""
resume_applied_for_key: str | None = None
last_playback_state_flush_at = 0.0
@@ -283,52 +359,82 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
pending_requests.append(request_pool.submit(_send_mpcbe_command, command_id))
def queue_seek_to_position(position_ms: int) -> None:
pending_requests.append(request_pool.submit(_seek_mpcbe_to_position, position_ms))
def flush_playback_state() -> None:
_save_playback_state(playback_state_path, playback_state)
pending_requests.append(
request_pool.submit(_seek_mpcbe_to_position, position_ms)
)
def clear_tracked_current(*, clear_resume_applied: bool) -> None:
nonlocal tracked_media_key, tracked_filepath, last_playback_state_flush_at, resume_applied_for_key
nonlocal \
tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \
last_playback_state_flush_at, \
resume_applied_for_key
if tracked_media_key is None and playback_state.get("current") is None:
if clear_resume_applied:
resume_applied_for_key = None
return
tracked_media_key = None
tracked_root_id = None
tracked_relative_path = ""
tracked_filepath = ""
playback_state["current"] = None
last_playback_state_flush_at = 0.0
if clear_resume_applied:
resume_applied_for_key = None
flush_playback_state()
def finalize_tracked_current() -> None:
nonlocal tracked_media_key, tracked_filepath, resume_applied_for_key, last_playback_state_flush_at
nonlocal \
tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \
resume_applied_for_key, \
last_playback_state_flush_at
if tracked_media_key is None:
if playback_state.get("current") is not None:
playback_state["current"] = None
flush_playback_state()
return
position_ms = player_position_ms or 0
duration_ms = player_duration_ms or 0
if _should_clear_resume(position_ms, duration_ms):
resume_positions.pop(tracked_media_key, None)
if tracked_root_id and tracked_relative_path:
_post_resume_position(
backend_url, tracked_root_id, tracked_relative_path, None
)
elif position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS:
# Ignore brief starts; keep the previous saved resume position.
pass
else:
position_seconds = max(0, position_ms // 1000)
resume_positions[tracked_media_key] = position_ms
if tracked_root_id and tracked_relative_path:
_post_resume_position(
backend_url,
tracked_root_id,
tracked_relative_path,
position_seconds,
)
tracked_media_key = None
tracked_root_id = None
tracked_relative_path = ""
tracked_filepath = ""
playback_state["current"] = None
resume_applied_for_key = None
last_playback_state_flush_at = 0.0
flush_playback_state()
def persist_tracked_current(now: float, *, force: bool = False) -> None:
nonlocal last_playback_state_flush_at
if tracked_media_key is None:
return
if not force and now - last_playback_state_flush_at < MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS:
if (
not force
and now - last_playback_state_flush_at < MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS
):
return
playback_state["current"] = {
@@ -339,7 +445,6 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
"updated_at": int(time.time()),
}
last_playback_state_flush_at = now
flush_playback_state()
def maybe_apply_resume(now: float) -> None:
nonlocal player_position_ms, resume_applied_for_key
@@ -360,7 +465,6 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
if _should_clear_resume(saved_position, player_duration_ms):
resume_positions.pop(tracked_media_key, None)
resume_applied_for_key = tracked_media_key
flush_playback_state()
return
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
return
@@ -372,15 +476,25 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
persist_tracked_current(now, force=True)
def update_status_from_future() -> None:
nonlocal status_future, player_filepath, player_position_ms, player_duration_ms, player_state
nonlocal status_updated_at, status_miss_count, tracked_media_key, tracked_filepath
nonlocal resume_applied_for_key
nonlocal \
status_future, \
player_filepath, \
player_position_ms, \
player_duration_ms, \
player_state, \
status_updated_at, \
status_miss_count, \
tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \
resume_applied_for_key
if status_future is None or not status_future.done():
return
try:
status = status_future.result()
except Exception:
except OSError, RuntimeError, ValueError:
status = None
status_future = None
@@ -399,7 +513,10 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
status_miss_count = 0
filepath, position_ms, duration_ms, state = status
media_key = _media_key_for_filepath(filepath, media_root) if filepath else None
resolved = _media_key_for_filepath(filepath, roots) if filepath else None
media_key = resolved[0] if resolved else None
root_id = resolved[1] if resolved else None
relative_path = resolved[2] if resolved else ""
if tracked_media_key is not None and media_key != tracked_media_key:
finalize_tracked_current()
@@ -408,6 +525,8 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
clear_tracked_current(clear_resume_applied=True)
elif tracked_media_key != media_key:
tracked_media_key = media_key
tracked_root_id = root_id
tracked_relative_path = relative_path
tracked_filepath = filepath
resume_applied_for_key = None
@@ -442,14 +561,22 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
def repeat_seconds_for_seek(mask: int) -> float:
paused_command, _seek_command = _MPC_BE_SEEK_MASK_TO_COMMANDS[mask]
with status_lock:
active_command = paused_command if player_state == MPC_BE_STATE_PAUSED else None
return MPC_BE_FRAME_REPEAT_SECONDS if active_command == paused_command else GAMEPAD_REPEAT_SECONDS
active_command = (
paused_command if player_state == MPC_BE_STATE_PAUSED else None
)
return (
MPC_BE_FRAME_REPEAT_SECONDS
if active_command == paused_command
else GAMEPAD_REPEAT_SECONDS
)
def _run() -> None:
try:
while not stop_event.is_set():
now = time.monotonic()
pending_requests[:] = [future for future in pending_requests if not future.done()]
pending_requests[:] = [
future for future in pending_requests if not future.done()
]
update_status_from_future()
queue_status_refresh(now)
@@ -466,7 +593,8 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
seek_begin_fired[slot] = False
elif (
not seek_begin_fired[slot]
and now - seek_begin_hold_started_at[slot] >= MPC_BE_SEEK_BEGIN_HOLD_SECONDS
and now - seek_begin_hold_started_at[slot]
>= MPC_BE_SEEK_BEGIN_HOLD_SECONDS
and len(pending_requests) < MPC_BE_MAX_INFLIGHT_REQUESTS
):
queue_command(MPC_BE_SEEK_BEGIN_COMMAND)
@@ -478,6 +606,30 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
if is_connected != last_connected[slot]:
last_connected[slot] = is_connected
# Volume control via D-pad up/down with fixed repeat cadence
is_vol_up_pressed = bool(current_mask & 0x0001)
was_vol_up_pressed = bool(last_pressed_masks[slot] & 0x0001)
if is_vol_up_pressed and (
not was_vol_up_pressed
or now - last_volume_repeat_up_at[slot] >= VOLUME_REPEAT_SECONDS
):
set_volume(min(volume_max(), get_volume() + VOLUME_STEP))
last_volume_repeat_up_at[slot] = now
elif not is_vol_up_pressed:
last_volume_repeat_up_at[slot] = 0.0
is_vol_down_pressed = bool(current_mask & 0x0002)
was_vol_down_pressed = bool(last_pressed_masks[slot] & 0x0002)
if is_vol_down_pressed and (
not was_vol_down_pressed
or now - last_volume_repeat_down_at[slot]
>= VOLUME_REPEAT_SECONDS
):
set_volume(max(VOLUME_MIN, get_volume() - VOLUME_STEP))
last_volume_repeat_down_at[slot] = now
elif not is_vol_down_pressed:
last_volume_repeat_down_at[slot] = 0.0
for mask, command_id in _MPC_BE_COMMANDS.items():
is_pressed = bool(current_mask & mask)
was_pressed = bool(last_pressed_masks[slot] & mask)
@@ -487,7 +639,8 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
not should_fire
and is_pressed
and mask in _MPC_BE_REPEATABLE_MASKS
and now - last_repeat_at[slot][mask] >= GAMEPAD_REPEAT_SECONDS
and now - last_repeat_at[slot][mask]
>= GAMEPAD_REPEAT_SECONDS
):
should_fire = True
@@ -535,7 +688,7 @@ def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> thre
def _setup_logging() -> Path:
"""Redirect stdout/stderr and configure logging to a log file in %APPDATA%/mediahive/.
"""Redirect stdout/stderr and configure logging to a file in %APPDATA%/mediahive/.
In a PyInstaller --windowed build there is no console, so any print() or
unhandled exception traceback would be lost. This ensures everything ends
@@ -555,7 +708,8 @@ def _setup_logging() -> Path:
prev.unlink()
log_path.rename(prev)
log_file = open(log_path, "w", encoding="utf-8", buffering=1) # line-buffered
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
log_file = os.fdopen(fd, "w", encoding="utf-8", buffering=1) # line-buffered
# Redirect raw stdout/stderr so print() and tracebacks go to the file
sys.stdout = log_file
@@ -573,6 +727,7 @@ def _setup_logging() -> Path:
logging.getLogger("mediahive.winmain").info("MediaHive started")
return log_path
# Minimal branded setup page shown while the native folder dialog is open.
_SETUP_HTML = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
@@ -601,6 +756,21 @@ class JsApi:
result = self._window.create_file_dialog(webview.FOLDER_DIALOG)
return result[0] if result else None
def set_volume(self, x: float) -> None:
"""Set system master volume from slider position ``x`` (0.0 .. 1.5)."""
# Clamp to the platform's maximum so the slider never exceeds what
# the OS can actually apply (1.0 on Windows/macOS, 1.5 on Linux).
clamped = max(VOLUME_MIN, min(volume_max(), float(x)))
set_volume(clamped)
def get_volume(self) -> float:
"""Return current volume slider position (0.0 .. 1.5)."""
return get_volume()
def volume_max(self) -> float:
"""Return the maximum volume slider position for this platform."""
return volume_max()
def _prepend_meipass_to_path() -> None:
"""When frozen, ensure bundled binaries (ffmpeg) are found first on PATH."""
@@ -609,28 +779,42 @@ def _prepend_meipass_to_path() -> None:
os.environ["PATH"] = meipass + os.pathsep + os.environ.get("PATH", "")
def _wait_for_backend(timeout: int = HEALTH_TIMEOUT) -> bool:
url = BACKEND_URL + "/api/health"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=2):
return True
except Exception:
time.sleep(0.25)
def _wait_for_backend(timeout: int | None = None) -> bool:
url = os.environ["MEDIAHIVE_BACKEND_URL"] + "/api/health"
deadline = time.monotonic() + timeout if timeout is not None else None
while True:
if deadline is not None and time.monotonic() >= deadline:
return False
try:
with urllib.request.urlopen(url, timeout=BACKEND_HEALTH_REQUEST_TIMEOUT):
return True
except urllib.error.URLError, TimeoutError, OSError:
time.sleep(BACKEND_HEALTH_POLL_SECONDS)
def _icon_path() -> str | None:
"""Locate the application icon at runtime (frozen or development)."""
if getattr(sys, "frozen", False):
base = Path(sys._MEIPASS) # type: ignore[attr-defined]
meipass = Path(sys._MEIPASS) # type: ignore[attr-defined]
base = meipass / "mediahive" if (meipass / "mediahive").exists() else meipass
else:
base = Path(__file__).parent
ico = base / "assets" / "mediahive.ico"
return str(ico) if ico.exists() else None
def _webview_start_kwargs() -> dict[str, str]:
"""Return platform-specific pywebview startup kwargs."""
if sys.platform == "darwin":
return {"gui": "qt"}
return {}
def _selected_webview_backend() -> str:
"""Return the configured pywebview GUI backend name for logging."""
return _webview_start_kwargs().get("gui", "default")
def _run_initial_setup() -> str | None:
"""Show a setup window, prompt for a folder, then close and return the path.
@@ -653,16 +837,61 @@ def _run_initial_setup() -> str | None:
chosen.append(result[0])
window.destroy()
webview.start(func=on_shown, icon=_icon_path())
webview.start(func=on_shown, icon=_icon_path(), **_webview_start_kwargs())
return chosen[0] if chosen else None
def _normalize_media_root_input(path: str) -> Path:
"""Normalize configured media path without touching filesystem.
This intentionally avoids exists()/is_dir()/resolve() checks so startup can
continue even if macOS shows a permission dialog for the selected folder.
"""
parts = Path(path).expanduser().parts
match parts:
case (*rest, ".mediahive", "index.json"):
...
case (*rest, ".mediahive"):
...
case rest:
...
base = Path(*rest)
if not base.is_absolute():
base = Path.cwd() / base
return base
def _supports_gamepad_remote() -> bool:
return sys.platform == "win32"
def _reserve_backend_port() -> int:
"""Reserve an ephemeral localhost port for the embedded backend."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((BACKEND_HOST, 0))
sock.listen(1)
return int(sock.getsockname()[1])
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 winmain() -> None:
_configure_windows_event_loop_policy()
parser = argparse.ArgumentParser(description="MediaHive")
parser.add_argument(
"media_folder",
nargs="?",
help="Path to the media folder (default: saved config, MEDIAHIVE_PATH, or cwd)",
help="Path to the media folder (default: saved config or initial setup dialog)",
)
args = parser.parse_args()
@@ -672,30 +901,49 @@ def winmain() -> None:
if getattr(sys, "frozen", False):
_setup_logging()
# Resolution order: CLI arg → MEDIAHIVE_PATH env → saved config → ask user
folder = args.media_folder or os.environ.get("MEDIAHIVE_PATH") or load_config().media_folder
cfg = load_config()
if not folder:
# Build initial roots dict (filesystem is NOT touched here — validation is
# deferred to the server's background activation task).
initial_roots: dict[str, str] = {}
if args.media_folder:
p = _normalize_media_root_input(args.media_folder)
name = p.name or "media"
initial_roots[name] = p.as_posix()
elif cfg.roots:
initial_roots = cfg.roots
elif cfg.media_folder:
p = _normalize_media_root_input(cfg.media_folder)
name = p.name or "media"
initial_roots[name] = p.as_posix()
if not initial_roots:
folder = _run_initial_setup()
if not folder:
return # user cancelled the folder picker
return # user cancelled
p = _normalize_media_root_input(folder)
name = p.name or "media"
initial_roots[name] = p.as_posix()
mediaroot = resolve_media_root(folder)
os.environ["MEDIAHIVE_PATH"] = mediaroot.as_posix()
# Persist resolved roots
if cfg.roots != initial_roots:
save_config(msgspec.structs.replace(cfg, roots=initial_roots))
# Persist the resolved path so subsequent launches remember it.
cfg = load_config()
if cfg.media_folder != mediaroot.as_posix():
save_config(msgspec.structs.replace(cfg, media_folder=mediaroot.as_posix()))
# Pass roots to the server via env (validation deferred to server startup)
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(initial_roots)
# Run the FastAPI backend on a background thread so the main thread is
# free for pywebview (Edge WebView2 requires the GUI on the main thread).
backend_port = _reserve_backend_port()
backend_url = f"http://{BACKEND_HOST}:{backend_port}"
os.environ["MEDIAHIVE_BACKEND_URL"] = backend_url
# Run the FastAPI backend on a background thread
config = uvicorn.Config(
"mediahive.server:app",
host=BACKEND_HOST,
port=DEFAULT_PORT,
port=backend_port,
loop="asyncio",
log_level="warning",
timeout_graceful_shutdown=0,
)
server = uvicorn.Server(config)
backend_thread = threading.Thread(
@@ -703,14 +951,29 @@ def winmain() -> None:
)
backend_thread.start()
if not _wait_for_backend():
def _activate_initial_roots() -> None:
body = json.dumps({"roots": initial_roots}).encode("utf-8")
req = urllib.request.Request(
url=f"{backend_url}/api/config/roots",
data=body,
method="PUT",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10):
logger.info("Requested initial roots activation")
except (urllib.error.URLError, TimeoutError, OSError) as exc:
logger.warning("Initial roots activation request failed: %s", exc)
if not _wait_for_backend(timeout=HEALTH_TIMEOUT):
server.should_exit = True
raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s")
api = JsApi()
logger.info("Configured pywebview backend: %s", _selected_webview_backend())
window = webview.create_window(
title="MediaHive",
url=BACKEND_URL,
url=backend_url,
fullscreen=True,
js_api=api,
)
@@ -718,20 +981,60 @@ def winmain() -> None:
poll_stop = threading.Event()
poll_thread: threading.Thread | None = None
# Resolve all root paths for gamepad remote
gamepad_roots = {root_id: Path(p) for root_id, p in initial_roots.items()}
def on_shown() -> None:
api._window = window
nonlocal poll_thread
if poll_thread is None:
poll_thread = _start_gamepad_remote(poll_stop, mediaroot)
try:
user_agent = window.evaluate_js("navigator.userAgent")
if isinstance(user_agent, str):
logger.info("Embedded webview user agent: %s", user_agent)
except (OSError, RuntimeError, ValueError) as exc:
logger.warning("Could not read embedded user agent: %s", exc)
webview.start(func=on_shown, icon=_icon_path())
nonlocal poll_thread
if poll_thread is None and _supports_gamepad_remote():
poll_thread = _start_gamepad_remote(poll_stop, gamepad_roots, backend_url)
threading.Thread(
target=_activate_initial_roots,
daemon=True,
name="mediahive-initial-roots-activation",
).start()
def on_closing() -> None:
# Begin backend shutdown as soon as the window starts closing so that
# by the time webview.start() returns the backend is already done.
server.should_exit = True
window.events.closing += on_closing
webview.start(func=on_shown, icon=_icon_path(), **_webview_start_kwargs())
# Ensure backend shutdown has been requested (in case closing event
# was not fired or we are on a platform that does not support it).
server.should_exit = True
backend_thread.join(timeout=2)
poll_stop.set()
if poll_thread is not None:
poll_thread.join(timeout=1)
server.should_exit = True
backend_thread.join(timeout=10)
# Close log file handles so mediahive.log is not left locked.
if getattr(sys, "frozen", False):
logging.shutdown()
for handler in logging.root.handlers[:]:
handler.close()
logging.root.removeHandler(handler)
if sys.stdout is not sys.__stdout__:
with contextlib.suppress(Exception):
sys.stdout.close()
if sys.stderr is not sys.__stderr__:
with contextlib.suppress(Exception):
sys.stderr.close()
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
if __name__ == "__main__":
+64 -1
View File
@@ -46,7 +46,10 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
[project.optional-dependencies]
gui = [
"pywebview>=6.2.1",
"pywebview>=6.2.1; platform_system != 'Darwin'",
"pywebview[qt5]>=6.2.1; platform_system == 'Darwin'",
"qtpy>=2.4.1; platform_system == 'Darwin'",
"PyQt5>=5.15.11; platform_system == 'Darwin'",
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
"pyinstaller>=6.0",
]
@@ -54,5 +57,65 @@ gui = [
[dependency-groups]
dev = [
"httpx>=0.28.1",
"ruff>=0.15.14",
"setuptools-scm>=8",
]
[tool.ruff]
preview = true
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"D203",
"D213",
"DOC201",
"COM812",
"T201",
"EM",
"TC",
"TRY003",
"S",
"CPY001",
"PLR",
"PLW",
# TEMP suppressions - revisit and remove after focused cleanup passes.
"C901",
"DOC501",
"ANN201",
"D103",
"FBT001",
"ANN001",
"D102",
"TRY300",
"D107",
"ANN202",
"B904",
"ASYNC220",
"E501",
"INP001",
"FBT002",
"PLC0415",
"D101",
"RUF006",
"SLF001",
"ASYNC240",
"N801",
"SIM102",
"DTZ005",
"RUF034",
"D415",
"D400",
"ANN401",
# Allow en-dash in docstrings (used for list formatting)
"RUF002",
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
"N806",
# Allow inline comments that describe output formats
"ERA001",
# Allow unused local variables in ctypes COM boilerplate
"F841",
]
[tool.ruff.lint.per-file-ignores]
"mediahive/access_logging.py" = ["BLE001", "G004"]
+22 -22
View File
@@ -1,22 +1,19 @@
#!/usr/bin/env python3
"""
RTorrent Client - Communicate with rtorrent via XMLRPC over SCGI socket.
"""
"""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):
def __init__(self, socket_path: str) -> None:
super().__init__()
self.socket_path = socket_path
def single_request(self, host, handler, request_body, verbose=False):
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')}"
@@ -51,7 +48,9 @@ class SCGITransport(xmlrpc.client.Transport):
class RTorrentClient:
"""Client for communicating with rtorrent via XMLRPC over SCGI socket."""
def __init__(self, socket_path: str = "/home/user/rtorrent/.session/rpc.socket"):
def __init__(
self, socket_path: str = "/home/user/rtorrent/.session/rpc.socket"
) -> None:
self.socket_path = socket_path
transport = SCGITransport(socket_path)
self.proxy = xmlrpc.client.ServerProxy(
@@ -62,14 +61,14 @@ class RTorrentClient:
"""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:
return {h.upper() for h in downloads}
except (OSError, xmlrpc.client.Error) as e:
print(f"Error getting loaded torrents: {e}")
return set()
def load_torrent(self, torrent_path: Path, download_dir: Path) -> bool:
"""
Load a torrent file and set its download directory.
"""Load a torrent file and set its download directory.
Uses load.start_verbose to load and immediately start/hash-check.
Args:
@@ -78,6 +77,7 @@ class RTorrentClient:
Returns:
True if successful, False otherwise
"""
try:
# load.start_verbose with d.directory.set to specify download location
@@ -86,11 +86,11 @@ class RTorrentClient:
"", str(torrent_path), f'd.directory.set="{download_dir}"'
)
return True
except Exception as e:
except (OSError, xmlrpc.client.Error) as e:
print(f"Error loading torrent {torrent_path}: {e}")
return False
def get_torrent_info(self, info_hash: str) -> Optional[Dict]:
def get_torrent_info(self, info_hash: str) -> dict | None:
"""Get info about a loaded torrent."""
try:
name = self.proxy.d.name(info_hash)
@@ -108,16 +108,16 @@ class RTorrentClient:
"base_path": base_path, # Full path to data (file or folder)
"is_multi_file": is_multi_file,
}
except Exception as e:
except (OSError, xmlrpc.client.Error) as e:
print(f"Error getting torrent info for {info_hash}: {e}")
return None
def get_unregistered_torrents(self) -> List[Dict]:
"""
Find all torrents with 'unregistered' or 'not registered' tracker errors.
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:
@@ -132,15 +132,14 @@ class RTorrentClient:
info = self.get_torrent_info(info_hash)
if info:
unregistered.append(info)
except Exception:
except OSError, xmlrpc.client.Error:
continue
except Exception as e:
except (OSError, xmlrpc.client.Error) as e:
print(f"Error scanning for unregistered torrents: {e}")
return unregistered
def remove_torrent(self, info_hash: str, delete_files: bool = False) -> bool:
"""
Remove a torrent from rtorrent.
"""Remove a torrent from rtorrent.
Args:
info_hash: The info hash of the torrent to remove
@@ -148,6 +147,7 @@ class RTorrentClient:
Returns:
True if successful, False otherwise
"""
try:
if delete_files:
@@ -157,6 +157,6 @@ class RTorrentClient:
# Just remove from rtorrent, keep files
self.proxy.d.erase(info_hash)
return True
except Exception as e:
except (OSError, xmlrpc.client.Error) as e:
print(f"Error removing torrent {info_hash}: {e}")
return False
+60 -19
View File
@@ -1,12 +1,13 @@
# MediaHive.spec — PyInstaller build for the Windows GUI application
# MediaHive.spec — PyInstaller build for the MediaHive desktop GUI app
#
# Build manually (from repo root):
# uv run --no-project --python 3.14 --with ".[gui]" --with pyinstaller ^
# uv run --no-project --python 3.14 --with ".[gui]" --with pyinstaller \
# pyinstaller --noconfirm --clean scripts/MediaHive.spec
#
# Or use the build script (recommended—handles versioning and packaging):
# uv run scripts/build_windows_gui.py
# uv run scripts/winbuild.py
import sys
import mediahive.winmain
import mediahive.server
from pathlib import Path
@@ -15,23 +16,30 @@ block_cipher = None
_pkg = Path(mediahive.server.__file__).parent
_frontend_build = _pkg / "frontend-build"
_icon = _pkg / "assets" / "mediahive.ico"
_ffmpeg = Path(SPECPATH).parent / "build" / "ffmpeg" / "ffmpeg.exe"
_logo_webp = _pkg / "assets" / "mediahive.webp"
_icon_win = _pkg / "assets" / "mediahive.ico"
_icon_mac = _pkg / "assets" / "mediahive.icns"
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
_tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg"]
a = Analysis(
[mediahive.winmain.__file__],
pathex=[],
binaries=[
# Bundle ffmpeg so showreel generation works without a system install.
# Populated by build_windows_gui.py before PyInstaller runs.
(str(_ffmpeg), "."),
],
datas=[
_binaries = []
for _tool_name in _tool_names:
_tool_path = _tools_dir / _tool_name
if _tool_path.exists():
_binaries.append((str(_tool_path), "."))
_datas = [
# Bundled Vue frontend served by the FastAPI backend
(str(_frontend_build), "mediahive/frontend-build"),
(str(_icon), "mediahive/assets"),
],
hiddenimports=[
]
if _icon_win.exists():
_datas.append((str(_icon_win), "mediahive/assets"))
if _icon_mac.exists():
_datas.append((str(_icon_mac), "mediahive/assets"))
if _logo_webp.exists():
_datas.append((str(_logo_webp), "mediahive/assets"))
_hiddenimports = [
# uvicorn dynamic imports
"uvicorn.logging",
"uvicorn.loops",
@@ -60,7 +68,28 @@ a = Analysis(
"starlette.routing",
# msgspec TOML write backend
"tomli_w",
],
]
if sys.platform == "darwin":
_hiddenimports.extend(
[
# pywebview Qt backend selected dynamically via webview.start(gui="qt")
"webview.platforms.qt",
"qtpy",
"PyQt5",
"PyQt5.QtCore",
"PyQt5.QtGui",
"PyQt5.QtWidgets",
"PyQt5.QtWebEngineWidgets",
]
)
a = Analysis(
[mediahive.winmain.__file__],
pathex=[],
binaries=_binaries,
datas=_datas,
hiddenimports=_hiddenimports,
hookspath=[],
runtime_hooks=[],
excludes=[],
@@ -80,7 +109,11 @@ exe = EXE(
bootloader_ignore_signals=False,
strip=False,
upx=True,
icon=str(_icon),
icon=(
str(_icon_mac)
if sys.platform == "darwin" and _icon_mac.exists()
else str(_icon_win) if _icon_win.exists() else None
),
# windowed=True hides the console; the backend subprocess inherits this
console=False,
windowed=True,
@@ -96,3 +129,11 @@ coll = COLLECT(
upx_exclude=[],
name="MediaHive",
)
if sys.platform == "darwin":
app = BUNDLE(
coll,
name="MediaHive.app",
icon=str(_icon_mac) if _icon_mac.exists() else None,
bundle_identifier="fi.zi.mediahive",
)
+2 -2
View File
@@ -11,7 +11,7 @@ from pathlib import 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")))
from devutil import ( # type: ignore
from devutil import ( # type: ignore[import-not-found]
ProcessGroup,
check_ports_free,
logger,
@@ -49,7 +49,7 @@ async def run_devserver(
await pg.spawn(*vite, cwd=front)
def main():
def main() -> None:
parser = argparse.ArgumentParser(
description="Run Vite and FastAPI development servers",
formatter_class=argparse.RawDescriptionHelpFormatter,
+4 -2
View File
@@ -3,13 +3,15 @@
import sys
from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
from hatchling.builders.hooks.plugin.interface import ( # type: ignore[import-not-found]
BuildHookInterface,
)
sys.path.insert(0, str(Path(__file__).parent))
from buildutil import build
class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data):
def initialize(self, version, build_data) -> None:
super().initialize(version, build_data)
build("frontend")
+4 -2
View File
@@ -143,7 +143,9 @@ def find_dev_tool() -> list[str]:
if name == "bun":
logger.warning(
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
"Bun has a bug in WS proxying "
"(https://github.com/oven-sh/bun/issues/9882). "
"Consider using npm instead."
)
return [tool, *dev_args[name]]
@@ -178,7 +180,7 @@ def build(folder: str = "frontend") -> None:
logger.warning(e)
raise SystemExit(1)
def run(cmd):
def run(cmd) -> None:
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
logger.info("### %s", " ".join(display_cmd))
subprocess.run(cmd, check=True, cwd=folder)
+25 -12
View File
@@ -1,4 +1,7 @@
"""Utilities meant for devserver script, used only in source repository with dev deps."""
"""Utilities for the devserver script in the source repository.
Used only with development dependencies.
"""
import asyncio
import subprocess
@@ -6,18 +9,20 @@ import sys
from collections.abc import Coroutine
from contextlib import suppress
from pathlib import Path
from typing import Any
from typing import Any, Self
import httpx
from fastapi_vue.hostutil import parse_endpoint
from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint
class ProcessGroup:
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
"""Manage async subprocesses with automatic cleanup.
def __init__(self):
Acts like TaskGroup for processes.
"""
def __init__(self) -> None:
self._procs: list[asyncio.subprocess.Process] = []
self._cmds: dict[int, str] = {} # pid -> command name
@@ -33,7 +38,7 @@ class ProcessGroup:
return proc
async def wait(
self, *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]"
self, *waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any]
) -> None:
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
@@ -53,14 +58,19 @@ class ProcessGroup:
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
raise SystemExit(1) from None
async def __aenter__(self):
async def __aenter__(self) -> Self:
"""Return this process group context manager."""
return self
async def __aexit__(self, exc_type, *_):
async def __aexit__(
self,
exc_type: type[BaseException] | None,
*_: object,
) -> None:
"""Wait for one process to exit, terminate others, then wait for all."""
await self._cleanup(immediate=exc_type is not None)
async def _cleanup(self, immediate: bool = False):
async def _cleanup(self, immediate: bool = False) -> None:
running = [p for p in self._procs if p.returncode is None]
if not running:
return
@@ -99,7 +109,10 @@ class ProcessGroup:
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).
Raise SystemExit if any endpoint responds.
"""
async def check(client: httpx.AsyncClient, url: str) -> None:
with suppress(httpx.RequestError):
@@ -175,7 +188,7 @@ def setup_fastapi(
host = endpoints[0]["host"]
port = endpoints[0]["port"]
reload_dir = module.split(".")[0] # Don't reload on frontend changes
reload_dir = module.split(".", maxsplit=1)[0] # Don't reload on frontend changes
cmd = [
sys.executable,
+267
View File
@@ -0,0 +1,267 @@
"""Build the desktop GUI application and package it as a version-numbered ZIP.
Usage:
uv run scripts/winbuild.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, downloads the latest ffmpeg.exe for bundling
4. On macOS arm64, downloads a prebuilt ffmpeg binary for bundling
4. Builds MediaHive using PyInstaller
5. Creates a ZIP file with the version number
"""
import io
import platform
import shutil
import stat
import subprocess
import sys
import urllib.request
import zipfile
from pathlib import Path
import setuptools_scm
# BtbN automated builds always publish a 'latest' tag with this asset.
_FFMPEG_URL = (
"https://github.com/BtbN/ffmpeg-builds/releases/download/latest"
"/ffmpeg-master-latest-win64-gpl.zip"
)
_MACOS_ARM64_TOOL_URLS = {
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
}
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
_REPO_ROOT = Path(__file__).parent.parent
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
def _platform_zip_suffix() -> str:
machine = platform.machine().lower()
arch = {
"x86_64": "x64",
"amd64": "x64",
"arm64": "arm64",
"aarch64": "arm64",
}.get(machine, machine or "unknown")
if sys.platform == "win32":
return "win64"
if sys.platform == "darwin":
return f"macos-{arch}"
return f"linux-{arch}"
def fetch_ffmpeg() -> Path:
"""Download latest ffmpeg.exe from BtbN builds into build/ffmpeg/."""
dest = _FFMPEG_STAGING / "ffmpeg.exe"
if dest.exists():
print(f"ffmpeg already staged at {dest}, skipping download.")
return dest
_FFMPEG_STAGING.mkdir(parents=True, exist_ok=True)
print(f"Downloading ffmpeg from {_FFMPEG_URL} ...")
with urllib.request.urlopen(_FFMPEG_URL) as resp:
data = resp.read()
print("Extracting ffmpeg.exe ...")
with zipfile.ZipFile(io.BytesIO(data)) as zf:
# The zip contains a top-level folder; ffmpeg.exe is under .../bin/
ffmpeg_entry = next(
name for name in zf.namelist() if name.endswith("/bin/ffmpeg.exe")
)
with zf.open(ffmpeg_entry) as src:
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 build/ffmpeg/."""
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
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_zip(version: str) -> Path:
"""Create a version-numbered ZIP file of the build/MediaHive folder."""
repo_root = _REPO_ROOT
dist_folder = repo_root / "build" / "MediaHive"
if not dist_folder.exists():
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
zip_name = f"MediaHive-{version}-{_platform_zip_suffix()}.zip"
zip_path = repo_root / "build" / zip_name
zip_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Creating {zip_path}...")
shutil.make_archive(
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
"zip",
root_dir=str(dist_folder), # zip contents of MediaHive/, not the folder itself
)
return zip_path
def main() -> None:
try:
version = read_version()
print(f"MediaHive version: {version}")
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()
zip_path = create_zip(version)
print(f"✓ Built successfully: {zip_path}")
print(f" Size: {zip_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()
+43 -31
View File
@@ -31,9 +31,10 @@ REPO_ROOT = Path(__file__).parent.parent
# Config / token helpers
# ---------------------------------------------------------------------------
def load_gitea_config() -> dict:
pyproject = REPO_ROOT / "pyproject.toml"
with open(pyproject, "rb") as f:
with Path(pyproject).open("rb") as f:
data = tomllib.load(f)
repo_url = data.get("project", {}).get("urls", {}).get("Repository")
if not repo_url:
@@ -41,7 +42,9 @@ def load_gitea_config() -> dict:
parsed = urlparse(repo_url.rstrip("/"))
parts = parsed.path.lstrip("/").split("/", 1)
if len(parts) != 2:
raise RuntimeError("[project.urls] Repository must include owner and repo, e.g. https://git.example.com/owner/repo")
raise RuntimeError(
"[project.urls] Repository must include owner and repo, e.g. https://git.example.com/owner/repo"
)
return {
"url": f"{parsed.scheme}://{parsed.netloc}",
"repo": f"{parts[0]}/{parts[1]}",
@@ -59,19 +62,19 @@ def load_token() -> str:
# ZIP + dist helpers
# ---------------------------------------------------------------------------
# Matches MediaHive-1.2.3-win64.zip or MediaHive-1.2.3.4-win64.zip
# Rejects dev/dirty names like MediaHive-1.2.3.dev0+gabcd-win64.zip
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-win64\.zip$")
# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-macos-arm64.zip, etc.
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.zip$")
def find_releasable_zips() -> list[tuple[Path, str]]:
"""Return (path, version) pairs for clean-versioned ZIPs in build/."""
def find_releasable_zips() -> list[tuple[Path, str, str]]:
"""Return (path, version, platform_tag) for clean-versioned ZIPs in build/."""
build_dir = REPO_ROOT / "build"
results = []
for p in sorted(build_dir.glob("MediaHive-*-win64.zip")):
for p in sorted(build_dir.glob("MediaHive-*.zip")):
m = _CLEAN_ZIP_RE.match(p.name)
if m:
results.append((p, m.group(1)))
results.append((p, m.group(1), m.group(2)))
return results
@@ -81,13 +84,13 @@ def find_dist_files(version: str) -> list[Path]:
Raises FileNotFoundError listing every missing file if any are absent.
"""
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(
(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,
)
missing = []
@@ -108,6 +111,7 @@ def find_dist_files(version: str) -> list[Path]:
# Gitea API helpers
# ---------------------------------------------------------------------------
def gitea_headers(token: str) -> dict:
return {"Authorization": f"token {token}", "Accept": "application/json"}
@@ -132,9 +136,7 @@ def create_release(
}
resp = client.post(url, json=payload)
if resp.status_code == 409:
raise RuntimeError(
f"A release for tag '{tag}' already exists on Gitea."
)
raise RuntimeError(f"A release for tag '{tag}' already exists on Gitea.")
resp.raise_for_status()
release_id = resp.json()["id"]
print(f"Created release id={release_id} (draft={draft})")
@@ -153,7 +155,7 @@ def upload_asset(
size_mb = path.stat().st_size / (1024 * 1024)
mime = "application/zip" if path.suffix == ".zip" else "application/octet-stream"
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(
url,
files={"attachment": (path.name, fh, mime)},
@@ -169,10 +171,15 @@ def upload_asset(
# Entrypoint
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea")
parser.add_argument("--draft", action="store_true", help="Create as a draft release")
parser.add_argument("--notes", default="", metavar="TEXT", help="Release notes body")
parser.add_argument(
"--draft", action="store_true", help="Create as a draft release"
)
parser.add_argument(
"--notes", default="", metavar="TEXT", help="Release notes body"
)
args = parser.parse_args()
try:
@@ -181,38 +188,43 @@ def main() -> None:
zips = find_releasable_zips()
if not zips:
raise FileNotFoundError(
print(
"No clean-versioned ZIPs 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
dist_files: dict[str, list[Path]] = {}
for _, version in zips:
for _, version, _platform_tag in zips:
dist_files[version] = find_dist_files(version)
base_url = cfg["url"].rstrip("/")
repo = cfg["repo"]
with httpx.Client(headers=gitea_headers(token)) as client:
for zip_path, version in zips:
release_ids_by_version: dict[str, int] = {}
for zip_path, version, platform_tag in zips:
print(f"\nReleasing {version} ...")
tag = f"v{version}"
release_id = release_ids_by_version.get(version)
if release_id is None:
release_id = create_release(
client, base_url, repo, tag, version, args.notes, args.draft
)
for path in [zip_path, *dist_files[version]]:
release_ids_by_version[version] = release_id
for path in dist_files[version]:
upload_asset(client, base_url, repo, release_id, path)
print(f"Uploading platform artifact: {platform_tag}")
upload_asset(client, base_url, repo, release_id, zip_path)
print(f"{tag} published")
print("\nDone. To publish to PyPI, run:")
print(" uv publish")
except Exception as e:
print(f"✗ Release failed: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
except (FileNotFoundError, OSError, RuntimeError, ValueError, httpx.HTTPError) as e:
print(f"✗ Release failed: {e}", file=sys.stderr)
sys.exit(1)
+48 -38
View File
@@ -1,20 +1,35 @@
#!/usr/bin/env python3
"""
Torrent Scanner - Scans for .torrent files and analyzes their trackers.
"""
"""Torrent Scanner - Scans for .torrent files and analyzes their trackers."""
import argparse
import glob
import hashlib
import shutil
from pathlib import Path
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Iterator
from pathlib import Path
import bencodepy
from rtorrent_client import RTorrentClient
def _expand_path_pattern(pattern: str) -> list[Path]:
"""Expand a user-provided path or glob pattern with pathlib."""
expanded = Path(pattern).expanduser()
pattern_text = str(expanded)
has_glob = any(ch in pattern_text for ch in "*?[")
if not has_glob:
return [expanded] if expanded.exists() else []
normalized = pattern_text.replace("\\", "/")
if expanded.is_absolute():
root = Path(expanded.anchor)
remainder = normalized[len(expanded.anchor) :].lstrip("/")
return list(root.glob(remainder)) if remainder else []
return list(Path().glob(normalized))
@dataclass
class TorrentInfo:
"""Information extracted from a torrent file."""
@@ -40,8 +55,7 @@ class TorrentInfo:
return self.path.parent.parent
def get_expected_data_path(self) -> Path:
"""
Get the expected path where downloaded data should exist.
"""Get the expected path where downloaded data should exist.
For multi-file torrents: download_dir/torrent_name/ (directory)
For single-file torrents: download_dir/torrent_name (file)
@@ -49,11 +63,11 @@ class TorrentInfo:
return self.get_download_directory() / self.name
def verify_download_exists(self) -> tuple[bool, str]:
"""
Verify that the downloaded data exists on disk.
"""Verify that the downloaded data exists on disk.
Returns:
Tuple of (exists: bool, message: str)
"""
expected_path = self.get_expected_data_path()
@@ -69,7 +83,6 @@ class TorrentInfo:
if file_count == 0:
return False, f"Directory exists but is empty: {expected_path}"
return True, f"Directory exists with {file_count} files"
else:
# Single-file torrent: expect a file
if not expected_path.exists():
return False, f"File not found: {expected_path}"
@@ -79,19 +92,18 @@ class TorrentInfo:
def parse_torrent(filepath: Path) -> TorrentInfo | None:
"""
Parse a .torrent file and extract relevant information.
"""Parse a .torrent file and extract relevant information.
Args:
filepath: Path to the .torrent file
Returns:
TorrentInfo object or None if parsing fails
"""
try:
with open(filepath, "rb") as f:
data = bencodepy.decode(f.read())
except Exception as e:
data = bencodepy.decode(Path(filepath).read_bytes())
except (OSError, ValueError, TypeError) as e:
print(f"Error parsing {filepath}: {e}")
return None
@@ -154,28 +166,25 @@ def parse_torrent(filepath: Path) -> TorrentInfo | None:
def scan_torrent_directories(paths: list[str]) -> Iterator[Path]:
"""
Scan directories for .torrent files.
"""Scan directories for .torrent files.
Args:
paths: List of directory paths or glob patterns to scan
Yields:
Path objects for each .torrent file found
"""
for pattern in paths:
for dir_path in glob.glob(pattern):
torrent_dir = Path(dir_path)
for torrent_dir in _expand_path_pattern(pattern):
if torrent_dir.is_dir():
for torrent_file in torrent_dir.glob("*.torrent"):
yield torrent_file
yield from torrent_dir.glob("*.torrent")
def find_torrents_with_tracker(
tracker_domain: str, paths: list[str]
) -> list[TorrentInfo]:
"""
Find all torrents that have a specific tracker domain.
"""Find all torrents that have a specific tracker domain.
Args:
tracker_domain: Domain to search for in tracker URLs (e.g., "hdbits.org")
@@ -183,6 +192,7 @@ def find_torrents_with_tracker(
Returns:
List of TorrentInfo objects for matching torrents
"""
matching_torrents = []
@@ -206,8 +216,8 @@ def format_size(size_bytes: int | None) -> str:
return f"{size_bytes:.2f} PB"
def main():
"""Main entry point for the torrent scanner."""
def main() -> None:
"""Run the torrent scanner command-line workflow."""
parser = argparse.ArgumentParser(
description="Scan and manage torrent files",
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -242,7 +252,7 @@ Examples:
# Expand glob patterns
expanded_paths = []
for pattern in args.paths:
matches = glob.glob(pattern)
matches = [str(path) for path in _expand_path_pattern(pattern)]
if matches:
expanded_paths.extend(matches)
else:
@@ -343,7 +353,8 @@ Examples:
print(f"\nDry run: {added} would be added, {skipped} already loaded")
else:
print(
f"\nRtorrent results: {added} added, {skipped} skipped, {failed} failed"
f"\nRtorrent results: {added} added, "
f"{skipped} skipped, {failed} failed"
)
# Clean up unregistered torrents from rtorrent
@@ -373,9 +384,8 @@ Examples:
print(f" {status} {download_path}")
else:
print(f" {status} {torrent_info['name']} (no data path)")
else:
# Remove from rtorrent (keeps downloaded files)
if client.remove_torrent(torrent_info["hash"]):
elif client.remove_torrent(torrent_info["hash"]):
removed_from_rtorrent += 1
# Delete the .torrent file if it exists
@@ -386,7 +396,7 @@ Examples:
try:
torrent_file.unlink()
removed_torrent_files += 1
except Exception:
except OSError:
pass
# Delete the downloaded files
@@ -398,23 +408,23 @@ Examples:
download_path.unlink()
removed_downloads += 1
print(f" [DEL] {download_path}")
except Exception as e:
except OSError as e:
print(f" [ERR] {download_path}: {e}")
else:
print(f" [DEL] {torrent_info['name']} (no data)")
else:
print(
f" [ERR] {torrent_info['name']}: failed to remove from rtorrent"
)
print(f" [ERR] {torrent_info['name']}: failed to remove from rtorrent")
print()
if dry_run:
print(
f"Dry run: {len(unregistered)} would be removed (rtorrent + .torrent + downloads)"
f"Dry run: {len(unregistered)} would be removed "
"(rtorrent + .torrent + downloads)"
)
else:
print(
f"Cleanup: {removed_from_rtorrent} from rtorrent, {removed_torrent_files} .torrents, {removed_downloads} downloads"
f"Cleanup: {removed_from_rtorrent} from rtorrent, "
f"{removed_torrent_files} .torrents, {removed_downloads} downloads"
)
else:
print("No unregistered torrents found.")
@@ -449,7 +459,7 @@ Examples:
try:
torrent.path.unlink()
print(f"Removed: {torrent.path}")
except Exception as e:
except OSError as e:
print(f"Failed to remove {torrent.path}: {e}")
Binary file not shown.
-132
View File
@@ -1,132 +0,0 @@
"""Build the Windows GUI application and package it as a version-numbered ZIP.
Usage:
uv run scripts/build_windows_gui.py
This runs in the project environment where dependencies are available via pyproject.toml.
This script:
1. Reads the version from pyproject.toml
2. Runs `uv build` to produce the wheel/sdist
3. Downloads the latest ffmpeg.exe
4. Builds MediaHive.exe using PyInstaller
5. Creates a ZIP file with the version number
"""
import io
import shutil
import subprocess
import sys
import urllib.request
import zipfile
from pathlib import Path
import setuptools_scm
# BtbN automated builds always publish a 'latest' tag with this asset.
_FFMPEG_URL = (
"https://github.com/BtbN/ffmpeg-builds/releases/download/latest"
"/ffmpeg-master-latest-win64-gpl.zip"
)
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
def fetch_ffmpeg() -> Path:
"""Download latest ffmpeg.exe from BtbN builds into build/ffmpeg/."""
dest = _FFMPEG_STAGING / "ffmpeg.exe"
if dest.exists():
print(f"ffmpeg already staged at {dest}, skipping download.")
return dest
_FFMPEG_STAGING.mkdir(parents=True, exist_ok=True)
print(f"Downloading ffmpeg from {_FFMPEG_URL} ...")
with urllib.request.urlopen(_FFMPEG_URL) as resp:
data = resp.read()
print("Extracting ffmpeg.exe ...")
with zipfile.ZipFile(io.BytesIO(data)) as zf:
# The zip contains a top-level folder; ffmpeg.exe is under .../bin/
ffmpeg_entry = next(
name for name in zf.namelist()
if name.endswith("/bin/ffmpeg.exe")
)
with zf.open(ffmpeg_entry) as src, open(dest, "wb") as out:
out.write(src.read())
print(f"ffmpeg staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)")
return dest
def read_version() -> str:
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
repo_root = Path(__file__).parent.parent
return setuptools_scm.get_version(root=str(repo_root))
def build_wheel() -> None:
"""Run uv build to produce the wheel and sdist."""
repo_root = Path(__file__).parent.parent
cmd = ["uv", "build"]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=repo_root)
if result.returncode != 0:
raise RuntimeError(f"uv build failed with exit code {result.returncode}")
def build_exe() -> None:
"""Run PyInstaller to build the executable."""
repo_root = Path(__file__).parent.parent
spec_file = Path(__file__).parent / "MediaHive.spec"
cmd = [
sys.executable, "-m", "PyInstaller",
"--noconfirm", "--clean",
"--distpath", str(repo_root / "build"),
"--workpath", str(repo_root / "build" / ".pyinstaller-work"),
str(spec_file),
]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=repo_root)
if result.returncode != 0:
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
def create_zip(version: str) -> Path:
"""Create a version-numbered ZIP file of the dist/MediaHive folder."""
repo_root = Path(__file__).parent.parent
dist_folder = repo_root / "build" / "MediaHive"
if not dist_folder.exists():
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
zip_name = f"MediaHive-{version}-win64.zip"
zip_path = repo_root / "build" / zip_name
zip_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Creating {zip_path}...")
shutil.make_archive(
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
"zip",
root_dir=str(dist_folder), # zip contents of MediaHive/, not the folder itself
)
return zip_path
def main() -> None:
try:
version = read_version()
print(f"MediaHive version: {version}")
fetch_ffmpeg()
build_wheel()
build_exe()
zip_path = create_zip(version)
print(f"✓ Built successfully: {zip_path}")
print(f" Size: {zip_path.stat().st_size / (1024 * 1024):.1f} MB")
except Exception as e:
print(f"✗ Build failed: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()