From daf4eb5e0c7fc89e4e8baed60327461c6858ae77 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 7 Feb 2026 00:21:32 +0000 Subject: [PATCH] Merge mediahive. --- .gitignore | 11 + README.md | 97 +- frontend/deno.json | 15 + frontend/index.html | 15 + frontend/package-lock.json | 1405 +++++++++++++++++ frontend/package.json | 21 + frontend/src/App.vue | 1106 +++++++++++++ frontend/src/api.ts | 89 ++ frontend/src/assets/mediahive-32.webp | Bin 0 -> 874 bytes frontend/src/assets/mediahive.webp | Bin 0 -> 5638 bytes frontend/src/components/CollageHero.vue | 1048 ++++++++++++ frontend/src/components/Header.vue | 157 ++ frontend/src/components/HeroSection.vue | 149 ++ frontend/src/components/MediaCard.vue | 297 ++++ frontend/src/components/MediaDetail.vue | 1257 +++++++++++++++ frontend/src/components/MediaRow.vue | 27 + frontend/src/components/SeriesFullView.vue | 1001 ++++++++++++ .../src/composables/useKeyboardNavigation.ts | 381 +++++ frontend/src/main.ts | 10 + frontend/src/router.ts | 49 + frontend/src/styles/main.css | 810 ++++++++++ frontend/src/types.ts | 179 +++ frontend/src/vite-env.d.ts | 7 + frontend/tsconfig.json | 21 + frontend/tsconfig.node.json | 10 + frontend/vite-plugin-fastapi.js | 36 + frontend/vite.config.ts | 15 + mediahive/__init__.py | 1 + mediahive/__main__.py | 51 + mediahive/server.py | 273 ++++ pyproject.toml | 26 +- scripts/devserver.py | 82 + scripts/fastapi-vue/build-frontend.py | 15 + scripts/fastapi-vue/buildutil.py | 191 +++ scripts/fastapi-vue/devutil.py | 216 +++ 35 files changed, 9025 insertions(+), 43 deletions(-) create mode 100644 frontend/deno.json create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/assets/mediahive-32.webp create mode 100644 frontend/src/assets/mediahive.webp create mode 100644 frontend/src/components/CollageHero.vue create mode 100644 frontend/src/components/Header.vue create mode 100644 frontend/src/components/HeroSection.vue create mode 100644 frontend/src/components/MediaCard.vue create mode 100644 frontend/src/components/MediaDetail.vue create mode 100644 frontend/src/components/MediaRow.vue create mode 100644 frontend/src/components/SeriesFullView.vue create mode 100644 frontend/src/composables/useKeyboardNavigation.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router.ts create mode 100644 frontend/src/styles/main.css create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite-plugin-fastapi.js create mode 100644 frontend/vite.config.ts create mode 100644 mediahive/__init__.py create mode 100644 mediahive/__main__.py create mode 100644 mediahive/server.py create mode 100644 scripts/devserver.py create mode 100644 scripts/fastapi-vue/build-frontend.py create mode 100644 scripts/fastapi-vue/buildutil.py create mode 100644 scripts/fastapi-vue/devutil.py diff --git a/.gitignore b/.gitignore index fae0205..a0b7001 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,20 @@ +# Python __pycache__/ *.py[oc] build/ dist/ wheels/ *.egg-info + +# Node / Frontend +node_modules/ +frontend/dist/ +mediahive/frontend-build/ + +# Lockfiles (uv.lock, package-lock.json, deno.lock, bun.lockb, etc.) *.lock +*.lockb + +# Dotfiles .* !.gitignore diff --git a/README.md b/README.md index 8f3f617..e849cf6 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,42 @@ -# Torrent Manager +# MediaHive -Tools for managing a media torrent library: scanning, indexing, metadata fetching, and preview generation. +Media scanning, indexing, and Netflix-style web streaming for your torrent collection. ## Project Structure ``` -hivescan/ Indexing & previews (installable package) - 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 +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/ - rtorrent-manager.py Torrent scanning & rtorrent management -rtorrent_client.py RTorrent XMLRPC/SCGI client + 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 ``` -## Hivescan +## Quick Start -Scans downloaded content, categorizes it (Movies, Series, Other), fetches metadata from TMDb, generates preview clips, and produces a JSON index. +```bash +pip install -e . +``` + +### 1. Scan & Index Media ```bash # Scan downloads, auto-detect common root, create .mediahive folder @@ -37,39 +52,43 @@ hivescan /media/torrents/* -o /srv/media/.mediahive hivescan /media/torrents/* --no-covers --no-showreels ``` -## RTorrent Manager - -Scans `.torrent` files, filters by tracker, verifies downloads exist on disk, loads verified torrents into rtorrent, and cleans up unregistered torrents. +### 2. Serve & Browse ```bash -# Scan .torrents directories and manage rtorrent -python scripts/rtorrent-manager.py /media/torrents*/.torrents/ +# Start the web server +mediahive /path/to/your/media/folder -# Multiple paths -python scripts/rtorrent-manager.py /mnt/disk1/torrents/.torrents/ /mnt/disk2/torrents/.torrents/ - -# Filter by tracker, dry run -python scripts/rtorrent-manager.py /media/torrents*/.torrents/ --tracker example.org --dry +# Server starts at http://localhost:8420 ``` -## Path Mapping +The app expects `/.mediahive/index.json` generated by hivescan. -Hivescan auto-detects the common root of scanned paths and creates a `.mediahive` folder there. All paths in the index are stored relative to that root. +### 3. Development -| Location | Example | -|----------|---------| -| Torrents | `/media/torrents*/` | -| Index | `/media/.mediahive/index.json` | -| Covers | `/media/.mediahive/movies/` | -| Showreels | `/media/.mediahive/movies//reel1.webm` | +```bash +cd frontend && npm install && cd .. +python scripts/devserver.py +``` -Use `-o` to override the output directory if the auto-detected root isn't suitable. +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 -- ffmpeg (for showreel generation) - -```bash -pip install -e . -``` +- Node.js 18+ (frontend development) +- ffmpeg (showreel generation) diff --git a/frontend/deno.json b/frontend/deno.json new file mode 100644 index 0000000..0d46e67 --- /dev/null +++ b/frontend/deno.json @@ -0,0 +1,15 @@ +{ + "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" + }, + "imports": { + "vue": "npm:vue@^3.4.0" + }, + "compilerOptions": { + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "preserve" + }, + "nodeModulesDir": "auto" +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..67901dc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/webp" sizes="32x32" href="/src/assets/mediahive-32.webp" /> + <link rel="icon" type="image/webp" sizes="192x192" href="/src/assets/mediahive.webp" /> + <link rel="apple-touch-icon" href="/src/assets/mediahive.webp" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>MediaHive + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..3b53203 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1405 @@ +{ + "name": "mediahive-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mediahive-frontend", + "version": "1.0.0", + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vue-tsc": "^2.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", + "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.27", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", + "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", + "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.27", + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", + "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz", + "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", + "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/runtime-core": "3.5.27", + "@vue/shared": "3.5.27", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz", + "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "vue": "3.5.27" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", + "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-sfc": "3.5.27", + "@vue/runtime-dom": "3.5.27", + "@vue/server-renderer": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2a36843 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "mediahive-frontend", + "version": "1.0.0", + "description": "Netflix-style media streaming for your collection", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vue-tsc": "^2.0.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..c12fcff --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,1106 @@ + + + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..452837e --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,89 @@ +import type { MediaIndex } from './types'; + +/** + * Load the media index from the server + */ +export async function loadMediaIndex(): Promise { + 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 { + try { + const response = await fetch('/api/play', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: filePath }), + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || response.statusText); + } + } catch (e) { + console.error('Play media error:', e); + alert(`Failed to play: ${e}`); + } +} + +/** + * Open a folder in Windows Explorer + */ +export async function openFolder(folderPath: string): Promise { + try { + const response = await fetch('/api/open-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder_path: folderPath }), + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || response.statusText); + } + } catch (e) { + console.error('Open folder error:', e); + alert(`Failed to open folder: ${e}`); + } +} + +/** + * Convert a cover path to a displayable URL. + * Uses FastAPI server for async file serving. + * + * 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 { + if (!coverPath) { + return ''; + } + // Ignore TMDB relative paths (start with /) - these are bugs in the index + 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); + } + 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}`; +} diff --git a/frontend/src/assets/mediahive-32.webp b/frontend/src/assets/mediahive-32.webp new file mode 100644 index 0000000000000000000000000000000000000000..15df1148f145dfe9a4a2ca7a461e8167044cd3ea GIT binary patch literal 874 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/CollageHero.vue b/frontend/src/components/CollageHero.vue new file mode 100644 index 0000000..a7a7eb8 --- /dev/null +++ b/frontend/src/components/CollageHero.vue @@ -0,0 +1,1048 @@ + + + + + diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue new file mode 100644 index 0000000..4c10ed4 --- /dev/null +++ b/frontend/src/components/Header.vue @@ -0,0 +1,157 @@ + + + diff --git a/frontend/src/components/HeroSection.vue b/frontend/src/components/HeroSection.vue new file mode 100644 index 0000000..8321ac9 --- /dev/null +++ b/frontend/src/components/HeroSection.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/frontend/src/components/MediaCard.vue b/frontend/src/components/MediaCard.vue new file mode 100644 index 0000000..52f3213 --- /dev/null +++ b/frontend/src/components/MediaCard.vue @@ -0,0 +1,297 @@ + + + + + diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue new file mode 100644 index 0000000..a13afbe --- /dev/null +++ b/frontend/src/components/MediaDetail.vue @@ -0,0 +1,1257 @@ + + + + + diff --git a/frontend/src/components/MediaRow.vue b/frontend/src/components/MediaRow.vue new file mode 100644 index 0000000..4429c66 --- /dev/null +++ b/frontend/src/components/MediaRow.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/SeriesFullView.vue b/frontend/src/components/SeriesFullView.vue new file mode 100644 index 0000000..8542826 --- /dev/null +++ b/frontend/src/components/SeriesFullView.vue @@ -0,0 +1,1001 @@ + + + + + diff --git a/frontend/src/composables/useKeyboardNavigation.ts b/frontend/src/composables/useKeyboardNavigation.ts new file mode 100644 index 0000000..64927ca --- /dev/null +++ b/frontend/src/composables/useKeyboardNavigation.ts @@ -0,0 +1,381 @@ +import { ref } from 'vue'; + +export interface FocusableElement { + element: HTMLElement; + row: number; + col: number; +} + +// Global focus state +const focusedElement = ref(null); +const isNavigating = ref(false); +// Track the "desired" column when moving vertically (to maintain column position across rows of different lengths) +const desiredCol = ref(null); +// Track if global handlers are installed +let handlersInstalled = false; + +// Data attribute names +const FOCUSABLE_ATTR = 'data-nav-focusable'; +const ROW_ATTR = 'data-nav-row'; +const COL_ATTR = 'data-nav-col'; +const ENTRY_COL_ATTR = 'data-nav-entry-col'; + +/** + * Get all focusable elements in the DOM, grouped by row + */ +function getFocusableElements(): FocusableElement[] { + const elements = document.querySelectorAll(`[${FOCUSABLE_ATTR}]`); + const result: FocusableElement[] = []; + + elements.forEach((el) => { + const htmlEl = el as HTMLElement; + // Skip hidden elements + if (htmlEl.offsetParent === null) return; + + const rect = htmlEl.getBoundingClientRect(); + // Skip elements not in viewport or zero-sized + if (rect.width === 0 || rect.height === 0) return; + + const row = parseInt(htmlEl.getAttribute(ROW_ATTR) || '0', 10); + const col = parseInt(htmlEl.getAttribute(COL_ATTR) || '0', 10); + + result.push({ + element: htmlEl, + row, + col, + }); + }); + + return result; +} + +/** + * Get elements grouped by row + */ +function getElementsByRow(): Map { + const elements = getFocusableElements(); + const byRow = new Map(); + + for (const el of elements) { + if (!byRow.has(el.row)) { + byRow.set(el.row, []); + } + byRow.get(el.row)!.push(el); + } + + // Sort each row by column + for (const [, rowElements] of byRow) { + rowElements.sort((a, b) => a.col - b.col); + } + + return byRow; +} + +/** + * Find element by row and col indices + * @param useEntryCol - if true, check for entry-col override on elements + */ +function findElementAt(row: number, col: number, useEntryCol: boolean = false): FocusableElement | null { + const byRow = getElementsByRow(); + const rowElements = byRow.get(row); + if (!rowElements || rowElements.length === 0) return null; + + // Check if any element in this row has an entry-col override + if (useEntryCol) { + for (const el of rowElements) { + const entryCol = el.element.getAttribute(ENTRY_COL_ATTR); + if (entryCol !== null) { + const overrideCol = parseInt(entryCol, 10); + const entryTarget = rowElements.find(e => e.col === overrideCol); + if (entryTarget) return entryTarget; + } + } + } + + // Find exact match or nearest col + const exact = rowElements.find(e => e.col === col); + if (exact) return exact; + + // Find nearest col in this row + let nearest = rowElements[0]; + let nearestDist = Math.abs(nearest.col - col); + + for (const el of rowElements) { + const dist = Math.abs(el.col - col); + if (dist < nearestDist) { + nearest = el; + nearestDist = dist; + } + } + + return nearest; +} + +/** + * Find next element in direction using row/col indices + */ +function findNextElement( + current: HTMLElement, + direction: 'up' | 'down' | 'left' | 'right' +): HTMLElement | null { + const currentRow = parseInt(current.getAttribute(ROW_ATTR) || '0', 10); + const currentCol = parseInt(current.getAttribute(COL_ATTR) || '0', 10); + const byRow = getElementsByRow(); + + if (direction === 'left' || direction === 'right') { + // Horizontal: move within same row by col index + desiredCol.value = null; // Reset desired col on horizontal movement + + const rowElements = byRow.get(currentRow); + if (!rowElements) return null; + + const delta = direction === 'right' ? 1 : -1; + const targetCol = currentCol + delta; + + // Find element with target col in this row + const target = rowElements.find(e => e.col === targetCol); + return target?.element || null; + } else { + // Vertical: move to adjacent row, try to maintain column + const sortedRows = Array.from(byRow.keys()).sort((a, b) => a - b); + const currentRowIdx = sortedRows.indexOf(currentRow); + + if (currentRowIdx === -1) return null; + + const delta = direction === 'down' ? 1 : -1; + const targetRowIdx = currentRowIdx + delta; + + if (targetRowIdx < 0 || targetRowIdx >= sortedRows.length) return null; + + const targetRow = sortedRows[targetRowIdx]; + + // Use desired col if set, otherwise use current col + const targetCol = desiredCol.value ?? currentCol; + + // Set desired col if not already set (first vertical move in a sequence) + if (desiredCol.value === null) { + desiredCol.value = currentCol; + } + + // Use entry column hook for vertical navigation + const target = findElementAt(targetRow, targetCol, true); + return target?.element || null; + } +} + +/** + * Focus an element and scroll it into view + */ +function focusElement(element: HTMLElement | null) { + if (!element) return; + + // Remove focus from previous element + if (focusedElement.value && focusedElement.value !== element) { + focusedElement.value.classList.remove('nav-focused'); + focusedElement.value.blur(); + } + + // Add focus to new element + element.classList.add('nav-focused'); + element.focus({ preventScroll: true }); + + // Smooth scroll for vertical (block), instant for horizontal (inline) + element.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }); + + focusedElement.value = element; +} + +/** + * Get current focus state (row, col) for saving + */ +function getFocusState(): { row: number; col: number } | null { + if (!focusedElement.value) return null; + const row = parseInt(focusedElement.value.getAttribute(ROW_ATTR) || '0', 10); + const col = parseInt(focusedElement.value.getAttribute(COL_ATTR) || '0', 10); + return { row, col }; +} + +/** + * Restore focus to element with given row/col + */ +function restoreFocusState(state: { row: number; col: number } | null) { + if (!state) return; + + const target = findElementAt(state.row, state.col); + if (target) { + setTimeout(() => { + focusElement(target.element); + }, 50); + } +} + +/** + * Focus element at specific row/col after a delay (for page transitions) + */ +function focusAt(row: number, col: number, delay: number = 100) { + setTimeout(() => { + const target = findElementAt(row, col); + if (target) { + focusElement(target.element); + } + }, delay); +} + +/** + * Check if we should allow navigation from an input element + */ +function shouldAllowNavigationFromInput(target: HTMLElement, direction: string): boolean { + if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA' && !target.isContentEditable) { + return true; // Not an input, allow navigation + } + + // Always allow up/down navigation from inputs + if (direction === 'up' || direction === 'down') { + return true; + } + + // For left/right, only capture if input is empty + const inputEl = target as HTMLInputElement | HTMLTextAreaElement; + const value = inputEl.value || ''; + return value.length === 0; +} + +/** + * Handle keyboard navigation + */ +function handleKeyDown(event: KeyboardEvent) { + const target = event.target as HTMLElement; + + const direction = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', + }[event.key] as 'up' | 'down' | 'left' | 'right' | undefined; + + if (!direction) return; + + // Check if we should allow navigation from this element + if (!shouldAllowNavigationFromInput(target, direction)) { + return; + } + + event.preventDefault(); + isNavigating.value = true; + + // Get current focused element or find the first one + let current = focusedElement.value; + + // If no element is focused, try to get the currently focused element from DOM + if (!current) { + const activeElement = document.activeElement as HTMLElement; + if (activeElement && activeElement.hasAttribute(FOCUSABLE_ATTR)) { + current = activeElement; + } + } + + // If still no current, focus the first available element + if (!current) { + const elements = getFocusableElements(); + if (elements.length > 0) { + focusElement(elements[0].element); + } + return; + } + + // Find and focus the next element using index-based navigation + const next = findNextElement(current, direction); + if (next) { + focusElement(next); + } +} + +/** + * Handle Enter key to activate focused element + */ +function handleEnterKey(event: KeyboardEvent) { + if (event.key !== 'Enter') return; + + const target = event.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { + return; + } + + if (focusedElement.value) { + event.preventDefault(); + focusedElement.value.click(); + } +} + +/** + * Install global keyboard navigation handlers + * Should be called once at app initialization + */ +export function installKeyboardNavigation() { + if (handlersInstalled) return; + handlersInstalled = true; + + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('keydown', handleEnterKey); + + // Handle mouse clicks to update focus state + document.addEventListener('click', (event) => { + const target = event.target as HTMLElement; + const focusable = target.closest(`[${FOCUSABLE_ATTR}]`) as HTMLElement | null; + if (focusable) { + desiredCol.value = null; // Reset desired col on mouse click + focusElement(focusable); + } + }); + + // Handle focus events from tab navigation + document.addEventListener('focusin', (event) => { + const target = event.target as HTMLElement; + if (target.hasAttribute(FOCUSABLE_ATTR)) { + if (focusedElement.value && focusedElement.value !== target) { + focusedElement.value.classList.remove('nav-focused'); + } + focusedElement.value = target; + target.classList.add('nav-focused'); + desiredCol.value = null; // Reset desired col on focus change + } + }); +} + +/** + * Composable to access keyboard navigation state + * @deprecated Use installKeyboardNavigation() at app init instead + */ +export function useKeyboardNavigation() { + return { + focusedElement, + isNavigating, + focusElement, + focusAt, + getFocusState, + restoreFocusState, + }; +} + +/** + * Helper to generate navigation attributes for a focusable element + * @param entryCol - optional column to focus when entering this row vertically + */ +export function navAttrs(row: number, col: number, entryCol?: number) { + const attrs: Record = { + [FOCUSABLE_ATTR]: 'true', + [ROW_ATTR]: String(row), + [COL_ATTR]: String(col), + tabindex: 0, + }; + if (entryCol !== undefined) { + attrs[ENTRY_COL_ATTR] = String(entryCol); + } + return attrs; +} + +export { FOCUSABLE_ATTR, ROW_ATTR, COL_ATTR, ENTRY_COL_ATTR }; diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..4b3d0a4 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' +import './styles/main.css' +import { installKeyboardNavigation } from './composables/useKeyboardNavigation' + +// Install global keyboard navigation handlers immediately +installKeyboardNavigation() + +createApp(App).use(router).mount('#app') diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..0449042 --- /dev/null +++ b/frontend/src/router.ts @@ -0,0 +1,49 @@ +import { createRouter, createWebHashHistory } 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'); + } +}); + +const router = createRouter({ + history: createWebHashHistory(), + scrollBehavior() { + // Always scroll to top on navigation + return { top: 0 }; + }, + routes: [ + { + path: '/', + redirect: '/movies', + }, + { + path: '/movies', + name: 'movies', + component: EmptyRouteComponent, + meta: { view: 'movies' }, + }, + { + path: '/movies/:id', + name: 'movie-detail', + component: EmptyRouteComponent, + meta: { view: 'movies' }, + }, + { + path: '/series', + name: 'series', + component: EmptyRouteComponent, + meta: { view: 'series' }, + }, + { + path: '/series/:id', + name: 'series-detail', + component: EmptyRouteComponent, + meta: { view: 'series' }, + }, + ], +}); + +export default router; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 0000000..bf8fcea --- /dev/null +++ b/frontend/src/styles/main.css @@ -0,0 +1,810 @@ +/* Netflix-style dark theme */ +:root { + --bg-primary: #0a0a0a; + --bg-secondary: #141414; + --bg-card: #1f1f1f; + --bg-card-hover: #2a2a2a; + --text-primary: #ffffff; + --text-secondary: #b3b3b3; + --text-muted: #808080; + --accent-red: #e50914; + --accent-red-hover: #f40612; + --border-color: #333333; + --shadow-color: rgba(0, 0, 0, 0.75); + --gradient-fade: linear-gradient(to top, var(--bg-primary) 0%, transparent 100%); + + --header-height: 56px; + --card-width: 160px; + --card-aspect-ratio: 2/3; + --section-padding: 2.5%; + --transition-fast: 150ms ease; + --transition-medium: 300ms ease; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + text-align: justify; + hyphens: auto; + -webkit-hyphens: auto; + -ms-hyphens: auto; +} + +#app { + min-height: 100vh; + position: relative; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--text-muted); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Header - persistent overlay that scrolls with content */ +.header { + position: absolute; + left: 0; + right: 0; + height: var(--header-height); + background: transparent; + z-index: 50; + display: flex; + align-items: center; + padding: 0 12px; + transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} + +.header-top { + top: 40px; +} + +.header-after-hero { + /* Position after browse collage-hero (70vh, max 600px) */ + top: clamp(450px, 70vh, 600px); +} + +.header-after-movie-header { + /* Position after movie detail collage-header (300px) */ + top: 320px; +} + +.header-after-series-hero { + /* Position after series detail hero (70vh, 450-600px) */ + top: clamp(450px, 70vh, 600px); +} + +/* Spacer to reserve space for header in layout */ +.header-spacer { + height: calc(var(--header-height) + 20px); +} + +.header-left { + display: flex; + align-items: center; + gap: 12px; +} + +.header-logo { + height: 40px; + width: 40px; + margin-right: 20px; + object-fit: contain; +} + +.header-nav { + display: flex; + gap: 20px; + min-width: 200px; +} + +.header-nav-item { + color: var(--text-secondary); + text-decoration: none; + font-size: 1.6rem; + font-weight: 500; + cursor: pointer; + transition: color var(--transition-fast); + background: none; + border: none; + padding: 0; +} + +.header-nav-item:hover, +.header-nav-item.active { + color: var(--text-primary); +} + +.header-nav-item:focus, +.header-nav-item.nav-focused { + color: var(--text-primary); + outline: none; + text-decoration: underline; + text-underline-offset: 4px; +} + +.header-search { + display: flex; + align-items: center; + gap: 12px; +} + +.search-input { + background: rgba(20, 20, 20, 0.9); + border: 2px solid var(--border-color); + border-radius: 4px; + padding: 4px 10px; + color: var(--text-primary); + font-size: 1.6rem; + width: 6em; + transition: all var(--transition-fast); +} + +.search-input:focus { + outline: none; + border-color: var(--text-secondary); + border-width: 3px; +} + +.search-input::placeholder { + color: var(--text-muted); +} + +/* Main content */ +.main-content { + min-height: 100vh; + background: var(--bg-primary); +} + +.main-content-no-hero { + 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% { + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.9); + } + 50% { + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.4); + } +} + +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 28px; + border-radius: 4px; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + border: none; + transition: all var(--transition-fast); + outline: none; +} + +.btn:focus { + outline: none; +} + +.btn.nav-focused, +.btn:focus-visible { + outline: none; +} + +.btn-primary { + background: var(--text-primary); + color: var(--bg-primary); +} + +.btn-primary:hover, +.btn-primary.nav-focused { + background: rgba(255, 255, 255, 0.85); + animation: btn-outline-blink 1s ease-in-out infinite; +} + +.btn-secondary { + background: rgba(109, 109, 110, 0.7); + color: var(--text-primary); +} + +.btn-secondary:hover, +.btn-secondary.nav-focused { + background: rgba(109, 109, 110, 0.5); + animation: btn-outline-blink 1s ease-in-out infinite; +} + +/* View transitions - cinematic zoom effect */ +.view-container { + position: relative; + overflow: hidden; +} + +.view-content { + width: 100%; +} + +.view-zoom-enter-active { + 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; +} + +.view-zoom-enter-from { + opacity: 0; + transform: scale(1.02) translateY(-10px); +} + +.view-zoom-leave-to { + opacity: 0; + transform: scale(0.98) translateY(10px); +} + +/* Media rows */ +.media-section { + padding: 0 var(--section-padding); + margin-bottom: 20px; +} + +.section-title { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 10px; + color: var(--text-primary); +} + +.media-row { + display: flex; + gap: 6px; + overflow-x: auto; + overflow-y: hidden; + scroll-behavior: smooth; + padding-bottom: 8px; + margin: 0 -2px; + padding: 4px 2px 8px; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.media-row::-webkit-scrollbar { + height: 0; + display: none; +} + +.media-row-wrap { + flex-wrap: wrap; + overflow-x: visible; + overflow-y: visible; + gap: 12px; + padding-bottom: 20px; +} + +/* Media cards */ +.media-card { + flex-shrink: 0; + width: var(--card-width); + cursor: pointer; + transition: z-index 0s, box-shadow var(--transition-medium); + position: relative; + outline: none; +} + +.media-card:hover, +.media-card.nav-focused { + z-index: 10; +} + +.media-card:focus { + outline: none; +} + +.media-card:focus-visible { + outline: none; +} + +.media-card-poster { + width: 100%; + aspect-ratio: var(--card-aspect-ratio); + background: var(--bg-card); + border-radius: 3px; + overflow: hidden; + position: relative; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); +} + +.media-card:hover .media-card-poster { + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); +} + +.media-card-poster img { + width: 100%; + height: 100%; + object-fit: cover; + transition: opacity var(--transition-fast); +} + +.media-card-placeholder { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: + repeating-linear-gradient( + 45deg, + rgba(100, 100, 100, 0.4), + rgba(100, 100, 100, 0.4) 10px, + rgba(80, 80, 80, 0.4) 10px, + rgba(80, 80, 80, 0.4) 20px + ), + linear-gradient(135deg, #3a3a3a 0%, #2a2a2a 100%); + color: #888; + font-size: 3rem; + border: 2px dashed #555; + gap: 8px; +} + +.media-card-placeholder::after { + content: "No Cover"; + font-size: 0.7rem; + color: #666; + letter-spacing: 0.5px; +} + +.media-card-info { + padding: 4px 2px 2px; + opacity: 1; + transition: opacity var(--transition-fast); +} + +.media-card:hover .media-card-info { + opacity: 1; +} + +.media-card-title-row { + display: flex; + align-items: baseline; + gap: 4px; + margin-bottom: 2px; +} + +.media-card-title { + font-size: 0.75rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +.media-card-year { + font-size: 0.7rem; + color: var(--text-muted); + flex-shrink: 0; +} + +.media-card-meta { + font-size: 0.7rem; + color: var(--text-muted); + display: flex; + gap: 6px; +} + +/* Modal / Detail view */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + z-index: 1000; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 40px 20px; + overflow-y: auto; +} + +.modal-content { + background: var(--bg-secondary); + border-radius: 8px; + width: 100%; + max-width: 900px; + overflow: hidden; + box-shadow: 0 20px 60px var(--shadow-color); + animation: modalIn 0.3s ease; +} + +@keyframes modalIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.modal-header { + position: relative; + height: 400px; + background-size: cover; + background-position: center; +} + +.modal-header::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 200px; + background: linear-gradient(to top, var(--bg-secondary) 0%, transparent 100%); +} + +.modal-close { + position: absolute; + top: 16px; + right: 16px; + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--bg-primary); + border: none; + color: var(--text-primary); + font-size: 1.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + transition: background var(--transition-fast); +} + +.modal-close:hover { + background: var(--bg-card); +} + +.modal-header-content { + position: absolute; + bottom: 24px; + left: 32px; + right: 32px; + z-index: 5; +} + +.modal-title { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 16px; +} + +.modal-body { + padding: 24px 32px 32px; +} + +.modal-meta { + display: flex; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.modal-meta-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.modal-meta-badge { + background: var(--bg-card); + padding: 4px 10px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 600; +} + +.modal-actions { + display: flex; + gap: 12px; + margin-bottom: 32px; +} + +/* Season selector for series */ +.season-selector { + margin-bottom: 24px; +} + +.season-selector label { + display: block; + font-size: 0.9rem; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.season-selector select { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 10px 16px; + color: var(--text-primary); + font-size: 1rem; + cursor: pointer; + min-width: 200px; +} + +.season-selector select:focus { + outline: none; + border-color: var(--text-secondary); +} + +/* Release list */ +.release-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.release-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background: var(--bg-card); + border-radius: 4px; + transition: background var(--transition-fast); +} + +.release-item:hover { + background: var(--bg-card-hover); +} + +.release-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.release-name { + font-weight: 500; +} + +.release-meta { + display: flex; + gap: 12px; + font-size: 0.85rem; + color: var(--text-muted); +} + +.release-actions { + display: flex; + gap: 8px; +} + +.btn-small { + padding: 8px 16px; + font-size: 0.85rem; +} + +/* Loading state */ +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + gap: 16px; +} + +.loading-spinner { + width: 48px; + height: 48px; + border: 3px solid var(--bg-card); + border-top-color: var(--accent-red); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loading-text { + color: var(--text-secondary); + font-size: 1rem; +} + +/* Error state */ +.error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + gap: 16px; + padding: 40px; + text-align: center; +} + +.error-icon { + font-size: 4rem; + color: var(--accent-red); +} + +.error-title { + font-size: 1.5rem; + font-weight: 600; +} + +.error-message { + color: var(--text-secondary); + max-width: 500px; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} + +.empty-state-icon { + font-size: 4rem; + margin-bottom: 16px; +} + +/* Responsive */ +@media (max-width: 768px) { + :root { + --card-width: 120px; + --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; + } + + .section-title { + font-size: 1rem; + } +} + +@media (max-width: 480px) { + :root { + --card-width: 100px; + --header-height: 48px; + } + + .header-logo { + height: 32px; + width: 32px; + margin-right: 12px; + } + + .media-card-info { + display: none; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..eae0329 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,179 @@ +// Type definitions for the media browser + +export interface MovieVersion { + path: string; + playable_file: string | null; + resolution: string | null; + quality: string | null; + codec: string | null; + audio: string | null; + encoder: string | null; + size: number | null; + in_rtorrent: boolean | null; + torrent_path: string | null; +} + +export interface TmdbPerson { + name: string; + character?: string | null; + profile_path: string | null; +} + +export interface SimilarMedia { + id: number; + title: string; + poster_path: string | null; +} + +export interface Movie { + id: string; + title: string; + original_title: string | null; + alternative_titles: string[] | null; + torrent_title: string | null; + year: number | null; + cover_path: string | null; + showreel_images: string[] | null; + versions: MovieVersion[]; + tmdb_id: number | null; + tmdb_title: 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: TmdbPerson[] | null; + director: string | null; + newest: number | null; +} + +export interface EpisodeRelease { + path: string; + playable_file: string | null; + resolution: string | null; + quality: string | null; + codec: string | null; + audio: string | null; + encoder: string | null; + size: number | null; + in_rtorrent: boolean | null; +} + +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; + releases: EpisodeRelease[]; +} + +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[]; +} + +export interface Series { + id: string; + title: string; + original_title: string | null; + alternative_titles: string[] | null; + torrent_title: string | null; + cover_path: string | null; + seasons: Season[]; + tmdb_id: number | null; + tmdb_title: string | null; + rating: number | null; + vote_count: number | null; + overview: string | null; + genres: string[] | null; + release_date: string | null; + status: string | null; + tagline: string | null; + poster_path: string | null; + backdrop_path: string | null; + similar: SimilarMedia[] | null; + keywords: string[] | null; + cast: TmdbPerson[] | null; + creators: string[] | null; + number_of_seasons: number | null; + number_of_episodes: number | null; + networks: string[] | null; + newest: number | null; +} + +export interface MediaStats { + 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[]; +} + +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) +} + +// 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 +} + +// Info about why a search matched this item +export interface SearchMatchInfo { + // Matched people with their roles/characters + matchedPeople?: MatchedPerson[]; + // Matched episodes for series + matchedEpisodes?: MatchedEpisode[]; +} + +export interface MediaItem { + id: string; + title: string; + year?: number | null; + cover_path: string | null; + showreel_images?: string[] | null; + type: MediaType; + resolution?: string | null; + data: Movie | Series | EpisodeWithSeries; + // Optional search match info - only present in search results + searchMatchInfo?: SearchMatchInfo; +} + +// Episode with parent series info for standalone display +export interface EpisodeWithSeries { + episode: Episode; + series: Series; + seasonNumber: number; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..74eb4d8 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite-plugin-fastapi.js b/frontend/vite-plugin-fastapi.js new file mode 100644 index 0000000..4bdeb79 --- /dev/null +++ b/frontend/vite-plugin-fastapi.js @@ -0,0 +1,36 @@ +/** + * FastAPI-Vue Vite Plugin + * auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin + * + * Configures Vite for FastAPI backend integration: + * - Proxies /api/* requests to the FastAPI backend + * - Builds to the Python module's frontend-build directory + * + * Options: + * paths - Array of paths to proxy (default: ["/api"]) + */ + +export default function fastapiVue({ paths = ["/api"] } = {}) { + const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8420" + + // Build proxy configuration for each path + const proxy = {} + for (const path of paths) { + proxy[path] = { + target: backendUrl, + changeOrigin: false, + ws: true, + } + } + + return { + name: "vite-plugin-fastapi-mediahive", + config: () => ({ + server: { proxy }, + build: { + outDir: "../mediahive/frontend-build", + emptyOutDir: true, + }, + }), + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..df2a826 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import fastapiVue from './vite-plugin-fastapi.js' + +// https://vitejs.dev/config/ +export default defineConfig(async () => ({ + plugins: [fastapiVue(), vue()], + + // Vite dev server options + clearScreen: false, + server: { + port: 8420, + strictPort: true, + }, +})); diff --git a/mediahive/__init__.py b/mediahive/__init__.py new file mode 100644 index 0000000..6ea254b --- /dev/null +++ b/mediahive/__init__.py @@ -0,0 +1 @@ +"""MediaHive - Media Browser Server""" diff --git a/mediahive/__main__.py b/mediahive/__main__.py new file mode 100644 index 0000000..39c277c --- /dev/null +++ b/mediahive/__main__.py @@ -0,0 +1,51 @@ +import argparse +import os +from pathlib import Path + +from fastapi_vue import server + +DEFAULT_PORT = 8420 +DEVMODE = bool(os.getenv("MEDIAHIVE_FRONTEND_URL")) + + +def main(): + parser = argparse.ArgumentParser(description="Run the mediahive server.") + parser.add_argument( + "media_folder", + nargs="?", + help="Path to the media folder (default: MEDIAHIVE_PATH or current directory)", + ) + parser.add_argument( + "-l", + "--listen", + action="append", + help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."), + ) + args = parser.parse_args() + + # Determine media folder + match Path( + args.media_folder 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(): + print(f"Error: Folder does not exist: {mediaroot}") + exit(1) + os.environ["MEDIAHIVE_PATH"] = mediaroot.as_posix() + dev = {"reload": True, "reload_dirs": ["mediahive"]} + server.run( + "mediahive.server:app", + listen=args.listen, + default_port=DEFAULT_PORT, + **(dev if DEVMODE else {}), + ) + + +if __name__ == "__main__": + main() diff --git a/mediahive/server.py b/mediahive/server.py new file mode 100644 index 0000000..c6ba11e --- /dev/null +++ b/mediahive/server.py @@ -0,0 +1,273 @@ +""" +FastAPI server for MediaHive. +Replaces Tauri backend with async HTTP server. +""" + +import json +import mimetypes +import os +import subprocess +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +import aiofiles +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, StreamingResponse +from fastapi_vue import Frontend +from pydantic import BaseModel + +from mediahive.__main__ import DEVMODE + +# Vue Frontend static files +frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"]) + + +# Media root path (initialized in lifespan) +MEDIAROOT = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global MEDIAROOT + if not os.environ.get("MEDIAHIVE_PATH"): + raise RuntimeError("MEDIAHIVE_PATH environment variable must be set") + MEDIAROOT = Path(os.environ["MEDIAHIVE_PATH"]) + await frontend.load() + yield + + +app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE) + +# Allow CORS for development +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def linux_to_windows_path(path: str) -> str: + """Normalize media path (now relative paths are kept as is).""" + return path + + +def normalize_path(url_path: str) -> Path: + """ + Convert URL path to filesystem path. + URL: /media/.mediahive/Movies/... + Returns: MEDIAROOT/.mediahive/Movies/... + """ + clean_path = url_path.lstrip("/") + return MEDIAROOT / clean_path + + +# === API Models === + + +class PlayMediaRequest(BaseModel): + file_path: str + + +class OpenFolderRequest(BaseModel): + folder_path: str + + +# === API Endpoints === + + +@app.get("/api/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "ok"} + + +@app.get("/api/index") +async def load_media_index(): + """ + Load and return the media index from disk. + Converts Linux paths to Windows paths. + """ + index_path = MEDIAROOT / ".mediahive" / "index.json" + if not index_path.exists(): + raise HTTPException( + status_code=404, detail=f"Index file not found: {index_path}" + ) + + try: + async with aiofiles.open(index_path, "r", encoding="utf-8") as f: + content = await f.read() + + index = json.loads(content) + + # Convert all Linux paths to Windows paths + for movie in index.get("movies", []): + if movie.get("cover_path"): + movie["cover_path"] = linux_to_windows_path(movie["cover_path"]) + if movie.get("backdrop_path"): + movie["backdrop_path"] = linux_to_windows_path(movie["backdrop_path"]) + if movie.get("showreel_images"): + movie["showreel_images"] = [ + linux_to_windows_path(p) for p in movie["showreel_images"] + ] + for version in movie.get("versions", []): + version["path"] = linux_to_windows_path(version["path"]) + if version.get("playable_file"): + version["playable_file"] = linux_to_windows_path( + version["playable_file"] + ) + if version.get("torrent_path"): + version["torrent_path"] = linux_to_windows_path( + version["torrent_path"] + ) + + for series in index.get("series", []): + if series.get("cover_path"): + series["cover_path"] = linux_to_windows_path(series["cover_path"]) + if series.get("backdrop_path"): + series["backdrop_path"] = linux_to_windows_path(series["backdrop_path"]) + for season in series.get("seasons", []): + if season.get("poster_path"): + season["poster_path"] = linux_to_windows_path(season["poster_path"]) + for episode in season.get("episodes", []): + if episode.get("reel_image"): + episode["reel_image"] = linux_to_windows_path( + episode["reel_image"] + ) + for release in episode.get("releases", []): + release["path"] = linux_to_windows_path(release["path"]) + if release.get("playable_file"): + release["playable_file"] = linux_to_windows_path( + release["playable_file"] + ) + + return index + + except json.JSONDecodeError as e: + raise HTTPException(status_code=500, detail=f"Failed to parse index file: {e}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to read index file: {e}") + + +@app.post("/api/play") +async def play_media(request: PlayMediaRequest): + """ + Open a media file with the system's default player. + """ + print(f"[play] Received path: {request.file_path}") + file_path = MEDIAROOT / request.file_path + + if not file_path.exists(): + print(f"[play] File not found: {file_path}") + raise HTTPException( + status_code=404, detail=f"File not found: {request.file_path}" + ) + + try: + # Use os.startfile on Windows (non-blocking) + if sys.platform == "win32": + os.startfile(str(file_path)) + else: + # For other platforms, use xdg-open or open + opener = "open" if sys.platform == "darwin" else "xdg-open" + subprocess.Popen([opener, str(file_path)]) + + return {"status": "ok"} + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to play media: {e}") + + +@app.post("/api/open-folder") +async def open_folder(request: OpenFolderRequest): + """ + Open a folder in the system file explorer. + If the path is a file, opens the parent folder and selects the file. + """ + print(f"[open-folder] Received path: {request.folder_path}") + target_path = MEDIAROOT / request.folder_path + + if not target_path.exists(): + print(f"[open-folder] Path not found: {target_path}") + raise HTTPException( + status_code=404, detail=f"Path not found: {request.folder_path}" + ) + + try: + if sys.platform == "win32": + if target_path.is_file(): + # Open parent folder and select the file + subprocess.Popen(["explorer", "/select,", str(target_path)]) + else: + # Open the folder directly + subprocess.Popen(["explorer", str(target_path)]) + elif sys.platform == "darwin": + if target_path.is_file(): + subprocess.Popen(["open", "-R", str(target_path)]) + else: + subprocess.Popen(["open", str(target_path)]) + else: + # Linux - just open the folder (no standard way to select) + folder = target_path.parent if target_path.is_file() else target_path + subprocess.Popen(["xdg-open", str(folder)]) + + return {"status": "ok"} + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to open folder: {e}") + + +@app.get("/api/media/{file_path:path}") +async def serve_media_file(file_path: str): + """ + Serve a media file asynchronously. + """ + full_path = normalize_path(file_path) + + # Security: ensure path doesn't escape base + try: + full_path.resolve().relative_to(MEDIAROOT.resolve()) + except ValueError: + raise HTTPException(status_code=403, detail="Access denied") + + if not full_path.exists(): + raise HTTPException(status_code=404, detail=f"File not found: {file_path}") + + if not full_path.is_file(): + raise HTTPException(status_code=400, detail="Not a file") + + # Guess content type + content_type, _ = mimetypes.guess_type(str(full_path)) + if content_type is None: + content_type = "application/octet-stream" + + # For images, use FileResponse which handles caching headers + if content_type.startswith("image/"): + return FileResponse( + full_path, + media_type=content_type, + headers={ + "Cache-Control": "public, max-age=86400", + }, + ) + + # For larger files, stream them + async def stream_file(): + async with aiofiles.open(full_path, "rb") as f: + while chunk := await f.read(64 * 1024): + yield chunk + + return StreamingResponse( + stream_file(), + media_type=content_type, + headers={ + "Cache-Control": "public, max-age=86400", + }, + ) + + +# Serve the Vue frontend (needs to be last if SPA catch-all is used) +frontend.route(app, "/") diff --git a/pyproject.toml b/pyproject.toml index 31cfec3..a68acb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,25 +1,43 @@ [project] -name = "torrentmanager" +name = "mediahive" version = "0.1.0" -description = "Media torrent manager with download scanning and indexing" +description = "MediaHive - Media scanning, indexing, and Netflix-style streaming" readme = "README.md" requires-python = ">=3.14" dependencies = [ + "aiofiles>=25.1.0", "bencodepy>=0.9.5", + "fastapi-vue>=0.5.2", + "fastapi[standard]>=0.128.0", "httpx[http2]>=0.28.1", "parse-torrent-title>=2.8.1", "tqdm>=4.67.3", + "uvicorn[standard]>=0.40.0", ] [project.scripts] +mediahive = "mediahive.__main__:main" hivescan = "hivescan.__main__:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" -[tool.hatch.build.targets.wheel] -packages = ["hivescan"] +[tool.hatch.build] +packages = ["mediahive", "hivescan"] +artifacts = ["mediahive/frontend-build"] +only-packages = true + +[tool.hatch.build.targets.sdist.hooks.custom] +path = "scripts/fastapi-vue/build-frontend.py" + +[tool.uv] +package = true [tool.uv.sources] parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-title.git" } + +[dependency-groups] +dev = [ + "httpx>=0.28.1", +] diff --git a/scripts/devserver.py b/scripts/devserver.py new file mode 100644 index 0000000..d11bfdd --- /dev/null +++ b/scripts/devserver.py @@ -0,0 +1,82 @@ +#!/usr/bin/env -S uv run +# auto-upgrade@fastapi-vue-setup - remove this if you modify this file +"""Run Vite development server for frontend and FastAPI backend with auto-reload.""" + +import argparse +import asyncio +import os +import sys +from contextlib import suppress +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 + ProcessGroup, + check_ports_free, + logger, + ready, + setup_cli, + setup_vite, +) + +DEFAULT_VITE_PORT = 8420 +DEFAULT_DEV_PORT = 8421 + + +async def run_devserver( + frontend: str, backend: str, extra_args: list[str] | None = None +) -> None: + reporoot = Path(__file__).parent.parent + front = reporoot / "frontend" + if not (front / "package.json").exists(): + logger.warning("Frontend source not found at %s", front) + raise SystemExit(1) + + viteurl, npm_install, vite = setup_vite(frontend, DEFAULT_VITE_PORT) + backurl, mediahive = setup_cli("mediahive", backend, DEFAULT_DEV_PORT) + + # Tell the everyone where the frontend and backend are (vite proxy, etc) + os.environ["MEDIAHIVE_FRONTEND_URL"] = viteurl + os.environ["MEDIAHIVE_BACKEND_URL"] = backurl + + async with ProcessGroup() as pg: + npm_i = await pg.spawn(*npm_install, cwd=front) + await check_ports_free(viteurl, backurl) + await pg.spawn(*mediahive, *(extra_args or [])) + await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py")) + await pg.spawn(*vite, cwd=front) + + +def main(): + parser = argparse.ArgumentParser( + description="Run Vite and FastAPI development servers", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=HELP_EPILOG, + ) + parser.add_argument( + "frontend", + nargs="?", + metavar="host:port", + help=f"Vite frontend endpoint (default: localhost:{DEFAULT_VITE_PORT})", + ) + parser.add_argument( + "--backend", + metavar="host:port", + help=f"FastAPI backend endpoint (default: localhost:{DEFAULT_DEV_PORT})", + ) + args, extra_args = parser.parse_known_args() + with suppress(KeyboardInterrupt): + asyncio.run(run_devserver(args.frontend, args.backend, extra_args)) + + +HELP_EPILOG = """ + scripts/devserver.py [args to mediahive] + + JS_RUNTIME environment variable can be used to select the JS runtime: + npm, deno, bun, or full path to the runtime executable (node maps to npm). +""" + + +if __name__ == "__main__": + main() diff --git a/scripts/fastapi-vue/build-frontend.py b/scripts/fastapi-vue/build-frontend.py new file mode 100644 index 0000000..152c48c --- /dev/null +++ b/scripts/fastapi-vue/build-frontend.py @@ -0,0 +1,15 @@ +"""Hatch build hook for building Vue frontend during package build.""" + +import sys +from pathlib import Path + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore + +sys.path.insert(0, str(Path(__file__).parent)) +from buildutil import build + + +class CustomBuildHook(BuildHookInterface): + def initialize(self, version, build_data): + super().initialize(version, build_data) + build("frontend") diff --git a/scripts/fastapi-vue/buildutil.py b/scripts/fastapi-vue/buildutil.py new file mode 100644 index 0000000..533c46e --- /dev/null +++ b/scripts/fastapi-vue/buildutil.py @@ -0,0 +1,191 @@ +"""Utilities used at build time and in devserver script. No dependencies.""" + +import logging +import os +import re +import shutil +import subprocess +from pathlib import Path + + +class _PrefixFormatter(logging.Formatter): + """Formatter that adds prefix based on log level.""" + + def format(self, record: logging.LogRecord) -> str: + if record.levelno >= logging.WARNING: + return f"⚠️ {record.getMessage()}" + return record.getMessage() + + +_handler = logging.StreamHandler() +_handler.setFormatter(_PrefixFormatter()) +logger = logging.getLogger("fastapi-vue") +logger.addHandler(_handler) +logger.setLevel(logging.INFO) + + +def _check_node_version(node_path: str) -> None: + """Check if Node.js version is >= 20. + + Raises RuntimeError if version is too old or cannot be determined. + """ + try: + result = subprocess.run( + [node_path, "--version"], capture_output=True, text=True, check=True + ) + version_str = result.stdout.strip() + # Parse version like "v20.10.0" or "v18.17.1" + match = re.match(r"v(\d+)", version_str) + if match: + major_version = int(match.group(1)) + if major_version >= 20: + return + raise RuntimeError( + f"Node.js {version_str} found, but v20+ required (install with nvm)" + ) + except (subprocess.CalledProcessError, FileNotFoundError, ValueError): + pass + raise RuntimeError("Could not determine Node.js version") + + +def find_js_runtime() -> tuple[str, str]: + """Find a JavaScript runtime from JS_RUNTIME env or auto-detect. + + Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun". + Raises JSRuntimeError if no suitable runtime is found. + """ + options = ["npm", "deno", "bun"] + node_version_error: RuntimeError | None = None + + # Check for JS_RUNTIME environment variable + if js_runtime_env := os.environ.get("JS_RUNTIME"): + js_runtime = js_runtime_env + js_path = Path(js_runtime) + runtime_name = js_path.name + # Map node to npm + if runtime_name == "node": + runtime_name = "npm" + js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm" + for option in options: + if option == runtime_name or runtime_name.startswith(option): + tool = shutil.which(js_runtime) + if tool is None: + raise RuntimeError( + f"JS_RUNTIME={js_runtime_env}: {option} not found" + ) + # Check Node.js version if using npm + if option == "npm": + node_path = shutil.which("node", path=str(Path(tool).parent)) + if node_path is None: + raise RuntimeError( + f"JS_RUNTIME={js_runtime_env}: node not found" + ) + _check_node_version(node_path) # Raises on failure + return tool, option + raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized") + + # Auto-detect + for option in options: + if tool := shutil.which(option): + # Check Node.js version if using npm + if option == "npm": + node_path = shutil.which("node", path=str(Path(tool).parent)) + if node_path is None: + continue + try: + _check_node_version(node_path) + except RuntimeError as e: + node_version_error = e + continue # Try next runtime + return tool, option + + # No runtime found - provide helpful error + if node_version_error: + raise node_version_error + raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found") + + +def find_build_tool(): + """Find JavaScript runtime and construct install/build commands. + + Returns (install_cmd, build_cmd) tuples of command lists. + Raises RuntimeError if no runtime is found. + """ + install = { + "deno": ("install", "--allow-scripts=npm:vue-demi"), + "npm": ("install",), + "bun": ("--bun", "install"), + } + # Run vite directly for deno to avoid npm-run-all2/run-p issues + build = { + "deno": ("run", "-A", "npm:vite", "build"), + "npm": ("run", "build"), + "bun": ("--bun", "run", "build"), + } + + tool, name = find_js_runtime() + return [tool, *install[name]], [tool, *build[name]] + + +def find_dev_tool() -> list[str]: + """Find JavaScript runtime and construct dev command. + + Returns dev_cmd (without vite-specific args). + Raises RuntimeError if no runtime is found. + """ + dev_args = { + "deno": ("run", "-A", "npm:vite"), + "npm": ("--silent", "run", "dev", "--"), + "bun": ("run", "dev", "--"), + } + + tool, name = find_js_runtime() + + if name == "bun": + logger.warning( + "Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead." + ) + + return [tool, *dev_args[name]] + + +def find_install_tool() -> list[str]: + """Find JavaScript runtime and construct install command. + + Returns install_cmd. + Raises RuntimeError if no runtime is found. + """ + install_args = { + "deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"), + "npm": ("install", "--silent"), + "bun": ("install", "--silent"), + } + + tool, name = find_js_runtime() + return [tool, *install_args[name]] + + +def build(folder: str = "frontend") -> None: + """Build the frontend in the specified folder. + + Raises SystemExit(1) on failure. + """ + logger.info(">>> Building %s", folder) + + try: + install_cmd, build_cmd = find_build_tool() + except RuntimeError as e: + logger.warning(e) + raise SystemExit(1) + + def run(cmd): + display_cmd = [Path(cmd[0]).stem, *cmd[1:]] + logger.info("### %s", " ".join(display_cmd)) + subprocess.run(cmd, check=True, cwd=folder) + + try: + run(install_cmd) + logger.info("") + run(build_cmd) + except subprocess.CalledProcessError: + raise SystemExit(1) diff --git a/scripts/fastapi-vue/devutil.py b/scripts/fastapi-vue/devutil.py new file mode 100644 index 0000000..811781c --- /dev/null +++ b/scripts/fastapi-vue/devutil.py @@ -0,0 +1,216 @@ +"""Utilities meant for devserver script, used only in source repository with dev deps.""" + +import asyncio +import subprocess +import sys +from collections.abc import Coroutine +from contextlib import suppress +from pathlib import Path +from typing import Any + +import httpx +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.""" + + def __init__(self): + self._procs: list[asyncio.subprocess.Process] = [] + self._cmds: dict[int, str] = {} # pid -> command name + + async def spawn( + self, *cmd: str, cwd: str | None = None + ) -> asyncio.subprocess.Process: + """Spawn a subprocess and track it.""" + cmd_name = Path(cmd[0]).stem + logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]])) + proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd) + self._procs.append(proc) + self._cmds[proc.pid] = cmd_name + return proc + + async def wait( + self, *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]" + ) -> None: + """Wait for processes/coroutines to complete, raise SystemExit on failure.""" + + async def wait_proc(proc: asyncio.subprocess.Process) -> None: + returncode = await proc.wait() + if returncode != 0: + cmd_name = self._cmds.get(proc.pid, "unknown") + raise subprocess.CalledProcessError(returncode, cmd_name) + + tasks = [ + wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w + for w in waitables + ] + try: + await asyncio.gather(*tasks) + except subprocess.CalledProcessError as e: + logger.warning("%s failed with exit status %d", e.cmd, e.returncode) + raise SystemExit(1) from None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, *_): + """Wait for one process to exit, terminate others, then wait for all.""" + await self._cleanup(immediate=exc_type is not None) + + async def _cleanup(self, immediate: bool = False): + running = [p for p in self._procs if p.returncode is None] + if not running: + return + + if not immediate: + # Wait for any one process to exit + with suppress(asyncio.CancelledError): + await asyncio.wait( + [asyncio.create_task(p.wait()) for p in running], + return_when=asyncio.FIRST_COMPLETED, + ) + + # Terminate remaining processes + for p in self._procs: + if p.returncode is None: + with suppress(ProcessLookupError): + p.terminate() + + # Wait for all to finish (with overall timeout), shielded from cancellation + still_running = [p for p in self._procs if p.returncode is None] + if still_running: + with suppress(asyncio.CancelledError): + try: + await asyncio.shield( + asyncio.wait_for( + asyncio.gather(*[p.wait() for p in still_running]), + timeout=10, + ) + ) + except TimeoutError: + for p in self._procs: + if p.returncode is None: + with suppress(ProcessLookupError): + p.kill() + await p.wait() + + +async def check_ports_free(*urls: str) -> None: + """Verify URLs are not responding (ports are free). Raise SystemExit if any respond.""" + + async def check(client: httpx.AsyncClient, url: str) -> None: + with suppress(httpx.RequestError): + res = await client.get(url, timeout=0.1) + server = res.headers.get("server", "server") + logger.warning("Conflicting %s already running at %s", server, url) + raise SystemExit(1) + + async with httpx.AsyncClient() as client: + await asyncio.gather(*[check(client, url) for url in urls]) + + +async def ready(url: str, path: str = "") -> None: + """Wait for the server to be ready by polling an endpoint. + + Raises SystemExit(1) if server doesn't start in time. + """ + max_attempts = 50 + full_url = f"{url}{path}" + + async with httpx.AsyncClient() as client: + for attempt in range(max_attempts): + try: + await client.get(full_url, timeout=1.0) + logger.info("✓ Backend ready!") + return + except httpx.RequestError: + if attempt == max_attempts - 1: + logger.warning("Backend didn't start in time") + raise SystemExit(1) + await asyncio.sleep(0.1) + + +def setup_vite( + endpoint: str, default_port: int = 5173 +) -> tuple[str, list[str], list[str]]: + """Parse frontend endpoint and build commands. + + Returns (url, install_cmd, dev_cmd). + Raises SystemExit(1) on invalid config. + """ + endpoints = parse_endpoint(endpoint, default_port) + + if "uds" in endpoints[0]: + logger.warning("Unix sockets not supported with vite devserver") + raise SystemExit(1) + + port = endpoints[0]["port"] + host = endpoints[0]["host"] + + install_cmd = find_install_tool() + dev_cmd = find_dev_tool() + if host != "localhost": + dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}") + dev_cmd.append(f"--port={port}") + + return f"http://{host}:{port}", install_cmd, dev_cmd + + +def setup_fastapi( + endpoint: str, module: str, default_port: int = 8000 +) -> tuple[str, list[str]]: + """Parse backend endpoint and build uvicorn command. + + Returns (url, uvicorn_cmd). + Raises SystemExit(1) on invalid config. + """ + endpoints = parse_endpoint(endpoint, default_port) + + if "uds" in endpoints[0]: + logger.warning("Unix sockets not supported with vite devserver") + raise SystemExit(1) + + host = endpoints[0]["host"] + port = endpoints[0]["port"] + reload_dir = module.split(".")[0] # Don't reload on frontend changes + + cmd = [ + sys.executable, + "-m", + "uvicorn", + module, + f"--host={host}", + f"--port={port}", + "--reload", + f"--reload-dir={reload_dir}", + "--forwarded-allow-ips=*", + ] + return f"http://{host}:{port}", cmd + + +def setup_cli( + cli: str, endpoint: str, default_port: int = 8000 +) -> tuple[str, list[str]]: + """Parse backend endpoint and build CLI command. + + Returns (url, cli_cmd). + Raises SystemExit(1) on invalid config. + """ + endpoints = parse_endpoint(endpoint, default_port) + + if "uds" in endpoints[0]: + logger.warning("Unix sockets not supported with vite devserver") + raise SystemExit(1) + + host = endpoints[0]["host"] + port = endpoints[0]["port"] + + cmd = [ + sys.executable, + "-m", + cli, + f"--listen={host}:{port}", + ] + return f"http://{host}:{port}", cmd