Merge mediahive.
This commit is contained in:
+11
@@ -1,9 +1,20 @@
|
|||||||
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[oc]
|
*.py[oc]
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
wheels/
|
wheels/
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
|
# Node / Frontend
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
mediahive/frontend-build/
|
||||||
|
|
||||||
|
# Lockfiles (uv.lock, package-lock.json, deno.lock, bun.lockb, etc.)
|
||||||
*.lock
|
*.lock
|
||||||
|
*.lockb
|
||||||
|
|
||||||
|
# Dotfiles
|
||||||
.*
|
.*
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
|||||||
@@ -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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
hivescan/ Indexing & previews (installable package)
|
hivescan/ Indexing & previews (library + CLI)
|
||||||
indexer.py Media index generation
|
indexer.py Media index generation
|
||||||
scanning.py File system scanning
|
scanning.py File system scanning
|
||||||
parsing.py Torrent name parsing (PTN)
|
parsing.py Torrent name parsing (PTN)
|
||||||
models.py Data models
|
models.py Data models
|
||||||
images.py TMDb cover/backdrop downloading
|
images.py TMDb cover/backdrop downloading
|
||||||
showreel.py Video preview clip generation (ffmpeg)
|
showreel.py Video preview clip generation (ffmpeg)
|
||||||
tmdb_client.py TMDb API client with caching
|
tmdb_client.py TMDb API client with caching
|
||||||
utils.py Path, size, and timestamp helpers
|
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/
|
scripts/
|
||||||
rtorrent-manager.py Torrent scanning & rtorrent management
|
devserver.py Development server (Vite + FastAPI)
|
||||||
rtorrent_client.py RTorrent XMLRPC/SCGI client
|
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
|
```bash
|
||||||
# Scan downloads, auto-detect common root, create .mediahive folder
|
# 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
|
hivescan /media/torrents/* --no-covers --no-showreels
|
||||||
```
|
```
|
||||||
|
|
||||||
## RTorrent Manager
|
### 2. Serve & Browse
|
||||||
|
|
||||||
Scans `.torrent` files, filters by tracker, verifies downloads exist on disk, loads verified torrents into rtorrent, and cleans up unregistered torrents.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Scan .torrents directories and manage rtorrent
|
# Start the web server
|
||||||
python scripts/rtorrent-manager.py /media/torrents*/.torrents/
|
mediahive /path/to/your/media/folder
|
||||||
|
|
||||||
# Multiple paths
|
# Server starts at http://localhost:8420
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Path Mapping
|
The app expects `<media-folder>/.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 |
|
```bash
|
||||||
|----------|---------|
|
cd frontend && npm install && cd ..
|
||||||
| Torrents | `/media/torrents*/` |
|
python scripts/devserver.py
|
||||||
| Index | `/media/.mediahive/index.json` |
|
```
|
||||||
| Covers | `/media/.mediahive/movies/` |
|
|
||||||
| Showreels | `/media/.mediahive/movies/<title>/reel1.webm` |
|
|
||||||
|
|
||||||
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
|
## Requirements
|
||||||
|
|
||||||
- Python ≥ 3.14
|
- Python ≥ 3.14
|
||||||
- ffmpeg (for showreel generation)
|
- Node.js 18+ (frontend development)
|
||||||
|
- ffmpeg (showreel generation)
|
||||||
```bash
|
|
||||||
pip install -e .
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1405
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
|||||||
|
import type { MediaIndex } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the media index from the server
|
||||||
|
*/
|
||||||
|
export async function loadMediaIndex(): Promise<MediaIndex> {
|
||||||
|
const response = await fetch('/api/index');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load media index: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a media file with the system's default player
|
||||||
|
*/
|
||||||
|
export async function playMedia(filePath: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 874 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<header class="header" :class="[`header-${position}`]">
|
||||||
|
<div class="header-left">
|
||||||
|
<img :src="logoUrl" alt="MediaHive" class="header-logo" />
|
||||||
|
<nav class="header-nav">
|
||||||
|
<!-- Browse mode: show both Movies and Series -->
|
||||||
|
<template v-if="!isDetailPage">
|
||||||
|
<button
|
||||||
|
class="header-nav-item"
|
||||||
|
:class="{ active: !isSearchActive && currentView === 'movies' }"
|
||||||
|
v-bind="navAttrs(navRow, 0)"
|
||||||
|
:data-nav-entry-col="!isSearchActive && currentView === 'movies' ? 0 : undefined"
|
||||||
|
@focus="switchToMovies"
|
||||||
|
>
|
||||||
|
Movies
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="header-nav-item"
|
||||||
|
:class="{ active: !isSearchActive && currentView === 'series' }"
|
||||||
|
v-bind="navAttrs(navRow, 1)"
|
||||||
|
:data-nav-entry-col="!isSearchActive && currentView === 'series' ? 1 : undefined"
|
||||||
|
@focus="switchToSeries"
|
||||||
|
>
|
||||||
|
Series
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<!-- Detail mode: show current category + Details -->
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
class="header-nav-item"
|
||||||
|
v-bind="navAttrs(navRow, 0)"
|
||||||
|
@focus="goToCategory"
|
||||||
|
>
|
||||||
|
{{ currentView === 'movies' ? 'Movies' : 'Series' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="header-nav-item active"
|
||||||
|
v-bind="navAttrs(navRow, 1, 1)"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-search">
|
||||||
|
<input
|
||||||
|
ref="searchInputRef"
|
||||||
|
type="search"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search..."
|
||||||
|
v-model="localSearch"
|
||||||
|
v-bind="navAttrs(navRow, 2)"
|
||||||
|
:data-nav-entry-col="localSearch ? 2 : undefined"
|
||||||
|
@focus="handleSearchFocus"
|
||||||
|
@keydown.escape="handleEscape"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||||
|
import logoUrl from '../assets/mediahive.webp';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
currentView: 'movies' | 'series';
|
||||||
|
searchQuery: string;
|
||||||
|
navRow: number;
|
||||||
|
position: 'top' | 'after-hero' | 'after-movie-header' | 'after-series-hero';
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
search: [string];
|
||||||
|
clearSearch: [];
|
||||||
|
goBack: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const localSearch = ref(props.searchQuery);
|
||||||
|
|
||||||
|
// Check if we're on a detail page
|
||||||
|
const isDetailPage = computed(() => {
|
||||||
|
return props.position === 'after-movie-header' || props.position === 'after-series-hero';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if search is active (has query and not on detail page)
|
||||||
|
const isSearchActive = computed(() => {
|
||||||
|
return !isDetailPage.value && !!localSearch.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch views on focus (no Enter required) - only in browse mode
|
||||||
|
function switchToMovies() {
|
||||||
|
if (!isDetailPage.value && props.currentView !== 'movies') {
|
||||||
|
emit('clearSearch');
|
||||||
|
router.push('/movies');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToSeries() {
|
||||||
|
if (!isDetailPage.value && props.currentView !== 'series') {
|
||||||
|
emit('clearSearch');
|
||||||
|
router.push('/series');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go back to category list from detail page
|
||||||
|
function goToCategory() {
|
||||||
|
// Emit goBack to let App.vue handle navigation and focus restoration
|
||||||
|
emit('goBack');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle search input focus - navigate to search if we have a query
|
||||||
|
function handleSearchFocus() {
|
||||||
|
// If on detail page, go back to browse first
|
||||||
|
if (isDetailPage.value) {
|
||||||
|
goToCategory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync local search to parent
|
||||||
|
watch(localSearch, (val) => {
|
||||||
|
emit('search', val);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync parent search to local (for external clears)
|
||||||
|
watch(() => props.searchQuery, (val) => {
|
||||||
|
if (val !== localSearch.value) {
|
||||||
|
localSearch.value = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleEscape() {
|
||||||
|
// Clear search and blur
|
||||||
|
localSearch.value = '';
|
||||||
|
searchInputRef.value?.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||||
|
e.preventDefault();
|
||||||
|
searchInputRef.value?.focus();
|
||||||
|
searchInputRef.value?.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<section class="hero">
|
||||||
|
<div
|
||||||
|
v-if="coverUrl"
|
||||||
|
class="hero-background"
|
||||||
|
:style="{ backgroundImage: `url('${coverUrl}')` }"
|
||||||
|
></div>
|
||||||
|
<div class="hero-content">
|
||||||
|
<h1 class="hero-title">{{ item.title }}</h1>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<span v-if="item.year" class="hero-year">{{ item.year }}</span>
|
||||||
|
<span v-if="rating" class="hero-rating" :class="ratingClass">
|
||||||
|
★ {{ rating.toFixed(1) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="resolution" class="hero-quality">{{ resolution }}</span>
|
||||||
|
<span v-if="quality" class="hero-quality">{{ quality }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="overview" class="hero-overview">{{ overview }}</p>
|
||||||
|
<div class="hero-buttons">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="handlePlay"
|
||||||
|
:disabled="!playableFile"
|
||||||
|
>
|
||||||
|
▶ Play
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="$emit('info', item)">
|
||||||
|
ℹ More Info
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { MediaItem, Movie, Series } from '../types';
|
||||||
|
import { getCoverUrl } from '../api';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: MediaItem;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
play: [string];
|
||||||
|
info: [MediaItem];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const coverUrl = computed(() => {
|
||||||
|
return getCoverUrl(props.item.cover_path);
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolution = computed(() => {
|
||||||
|
if (props.item.type === 'movies') {
|
||||||
|
const movie = props.item.data as Movie;
|
||||||
|
return movie.versions && movie.versions.length > 0 ? movie.versions[0].resolution : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const quality = computed(() => {
|
||||||
|
if (props.item.type === 'movies') {
|
||||||
|
const movie = props.item.data as Movie;
|
||||||
|
return movie.versions && movie.versions.length > 0 ? movie.versions[0].quality : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rating = computed(() => {
|
||||||
|
if (props.item.type === 'movies') {
|
||||||
|
return (props.item.data as Movie).rating;
|
||||||
|
}
|
||||||
|
return (props.item.data as Series).rating;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ratingClass = computed(() => {
|
||||||
|
if (!rating.value) return '';
|
||||||
|
if (rating.value >= 7.5) return 'rating-high';
|
||||||
|
if (rating.value >= 6) return 'rating-medium';
|
||||||
|
return 'rating-low';
|
||||||
|
});
|
||||||
|
|
||||||
|
const overview = computed(() => {
|
||||||
|
if (props.item.type === 'movies') {
|
||||||
|
const o = (props.item.data as Movie).overview;
|
||||||
|
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null;
|
||||||
|
}
|
||||||
|
const o = (props.item.data as Series).overview;
|
||||||
|
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const playableFile = computed(() => {
|
||||||
|
if (props.item.type === 'movies') {
|
||||||
|
const movie = props.item.data as Movie;
|
||||||
|
// Get the first version's playable file
|
||||||
|
if (movie.versions && movie.versions.length > 0) {
|
||||||
|
return movie.versions[0].playable_file;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// For series, get first available file from episodes
|
||||||
|
const series = props.item.data as Series;
|
||||||
|
for (const season of series.seasons || []) {
|
||||||
|
for (const episode of season.episodes || []) {
|
||||||
|
for (const release of episode.releases || []) {
|
||||||
|
if (release.playable_file) {
|
||||||
|
return release.playable_file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handlePlay() {
|
||||||
|
if (playableFile.value) {
|
||||||
|
emit('play', playableFile.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hero-rating {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-high {
|
||||||
|
color: #46d369;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-medium {
|
||||||
|
color: #f9a825;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-low {
|
||||||
|
color: #e53935;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-overview {
|
||||||
|
max-width: 500px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="media-card"
|
||||||
|
v-bind="navAttributes"
|
||||||
|
:data-item-id="item.id"
|
||||||
|
@click="$emit('click')"
|
||||||
|
@keydown.enter.prevent="$emit('click')"
|
||||||
|
>
|
||||||
|
<div class="media-card-poster">
|
||||||
|
<!-- SVG focus outline -->
|
||||||
|
<svg class="card-focus-outline" viewBox="0 0 100 150" preserveAspectRatio="none">
|
||||||
|
<rect x="0" y="0" width="100" height="150" />
|
||||||
|
</svg>
|
||||||
|
<img
|
||||||
|
v-if="coverUrl && !imageError"
|
||||||
|
:src="coverUrl"
|
||||||
|
:alt="item.title"
|
||||||
|
loading="lazy"
|
||||||
|
@error="imageError = true"
|
||||||
|
/>
|
||||||
|
<div v-else class="media-card-placeholder">
|
||||||
|
{{ item.type === 'movies' ? '🎬' : item.type === 'episode' ? '📺' : '📺' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="rating" class="media-card-rating" :class="ratingClass">
|
||||||
|
★ {{ rating.toFixed(1) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="media-card-info">
|
||||||
|
<div class="media-card-title-row">
|
||||||
|
<span class="media-card-title">{{ displayTitle }}</span>
|
||||||
|
<span v-if="item.year" class="media-card-year">{{ item.year }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Search match info (when searching) -->
|
||||||
|
<template v-if="item.searchMatchInfo">
|
||||||
|
<div v-if="matchedPeople && matchedPeople.length > 0" class="media-card-detail match-reason">
|
||||||
|
<template v-for="(person, idx) in matchedPeople" :key="person.name">
|
||||||
|
<span :class="person.highlightRoles ? 'match-dim' : 'match-name'">{{ person.name }}</span>
|
||||||
|
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'">({{ person.roles }})</span><span v-if="idx < matchedPeople.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.searchMatchInfo.matchedEpisodes && item.searchMatchInfo.matchedEpisodes.length > 0" class="media-card-episodes">
|
||||||
|
<div v-for="ep in item.searchMatchInfo.matchedEpisodes.slice(0, 3)" :key="ep.name" class="matched-episode">
|
||||||
|
<span class="match-name">{{ ep.name }}</span>
|
||||||
|
<span class="match-roles"> ({{ ep.location }})</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.searchMatchInfo.matchedEpisodes.length > 3" class="matched-episode-more">+{{ item.searchMatchInfo.matchedEpisodes.length - 3 }} more</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- Default display (browsing) -->
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
||||||
|
<div v-if="directorAndCast" class="media-card-detail">
|
||||||
|
<span v-if="director" class="director-name">{{ director }}</span><span v-if="director && filteredCastNames">, </span>{{ filteredCastNames }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import type { MediaItem, Movie, Series, EpisodeWithSeries } from '../types';
|
||||||
|
import { getCoverUrl } from '../api';
|
||||||
|
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: MediaItem;
|
||||||
|
navRow?: number;
|
||||||
|
navCol?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
click: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Navigation attributes for keyboard navigation
|
||||||
|
const navAttributes = computed(() => {
|
||||||
|
if (props.navRow !== undefined && props.navCol !== undefined) {
|
||||||
|
return navAttrs(props.navRow, props.navCol);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageError = ref(false);
|
||||||
|
|
||||||
|
const coverUrl = computed(() => {
|
||||||
|
if (imageError.value) return null;
|
||||||
|
return getCoverUrl(props.item.cover_path);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rating = computed(() => {
|
||||||
|
if (props.item.type === 'movies') {
|
||||||
|
return (props.item.data as Movie).rating;
|
||||||
|
}
|
||||||
|
if (props.item.type === 'episode') {
|
||||||
|
const epData = props.item.data as EpisodeWithSeries;
|
||||||
|
return epData.episode.rating ?? epData.series.rating;
|
||||||
|
}
|
||||||
|
return (props.item.data as Series).rating;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ratingClass = computed(() => {
|
||||||
|
if (!rating.value) return '';
|
||||||
|
if (rating.value >= 7.5) return 'rating-high';
|
||||||
|
if (rating.value >= 6) return 'rating-medium';
|
||||||
|
return 'rating-low';
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayTitle = computed(() => {
|
||||||
|
if (props.item.type === 'episode') {
|
||||||
|
const epData = props.item.data as EpisodeWithSeries;
|
||||||
|
return epData.episode.name || `Episode ${epData.episode.episode_number}`;
|
||||||
|
}
|
||||||
|
return props.item.title;
|
||||||
|
});
|
||||||
|
|
||||||
|
const subtitle = computed(() => {
|
||||||
|
if (props.item.type === 'episode') {
|
||||||
|
const epData = props.item.data as EpisodeWithSeries;
|
||||||
|
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`;
|
||||||
|
}
|
||||||
|
// For series, show creators
|
||||||
|
if (props.item.type === 'series') {
|
||||||
|
const creators = (props.item.data as Series).creators;
|
||||||
|
return creators && creators.length > 0 ? creators.join(', ') : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Director for movies
|
||||||
|
const director = computed(() => {
|
||||||
|
if (props.item.type !== 'movies') return null;
|
||||||
|
return (props.item.data as Movie).director;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if we have director and/or cast to display
|
||||||
|
const directorAndCast = computed(() => {
|
||||||
|
if (props.item.type !== 'movies') return false;
|
||||||
|
return director.value || filteredCastNames.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cast names, excluding director if they appear in cast
|
||||||
|
const filteredCastNames = computed(() => {
|
||||||
|
if (props.item.type !== 'movies') return null;
|
||||||
|
const cast = (props.item.data as Movie).cast;
|
||||||
|
if (!cast || cast.length === 0) return null;
|
||||||
|
|
||||||
|
const directorName = director.value?.toLowerCase();
|
||||||
|
const filteredCast = directorName
|
||||||
|
? cast.filter(c => c.name.toLowerCase() !== directorName)
|
||||||
|
: cast;
|
||||||
|
|
||||||
|
if (filteredCast.length === 0) return null;
|
||||||
|
|
||||||
|
// Show first 3 cast members
|
||||||
|
const names = filteredCast.slice(0, 3).map(c => c.name);
|
||||||
|
return names.join(', ');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Matched people from search (from searchMatchInfo)
|
||||||
|
const matchedPeople = computed(() => {
|
||||||
|
const info = props.item.searchMatchInfo;
|
||||||
|
if (!info || !info.matchedPeople) return null;
|
||||||
|
return info.matchedPeople;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Blinking animation for focus outline */
|
||||||
|
@keyframes card-outline-blink {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SVG focus outline styles */
|
||||||
|
.card-focus-outline {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-focus-outline rect {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(255, 255, 255, 0.9);
|
||||||
|
stroke-width: 4;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show outline on hover and focus */
|
||||||
|
.media-card:hover .card-focus-outline,
|
||||||
|
.media-card.nav-focused .card-focus-outline {
|
||||||
|
opacity: 1;
|
||||||
|
animation: card-outline-blink 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brighter outline for keyboard focus */
|
||||||
|
.media-card.nav-focused .card-focus-outline rect {
|
||||||
|
stroke: #ffffff;
|
||||||
|
stroke-width: 5;
|
||||||
|
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-card-rating {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-high {
|
||||||
|
color: #46d369;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-medium {
|
||||||
|
color: #f9a825;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-low {
|
||||||
|
color: #e53935;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-card-detail {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 1px;
|
||||||
|
/* Allow up to 2 lines with ellipsis */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.director-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search match styles */
|
||||||
|
.match-reason {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-roles {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When character name matched - highlight the role, dim the name */
|
||||||
|
.match-dim {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-highlight {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-card-episodes {
|
||||||
|
margin-top: 2px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matched-episode {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matched-episode-more {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="media-row" :class="{ 'media-row-wrap': wrap }">
|
||||||
|
<MediaCard
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="item.id"
|
||||||
|
:item="item"
|
||||||
|
:nav-row="rowIndex"
|
||||||
|
:nav-col="index"
|
||||||
|
@click="$emit('select', item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MediaItem } from '../types';
|
||||||
|
import MediaCard from './MediaCard.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
items: MediaItem[];
|
||||||
|
wrap?: boolean;
|
||||||
|
rowIndex?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
select: [MediaItem];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export interface FocusableElement {
|
||||||
|
element: HTMLElement;
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global focus state
|
||||||
|
const focusedElement = ref<HTMLElement | null>(null);
|
||||||
|
const isNavigating = ref(false);
|
||||||
|
// Track the "desired" column when moving vertically (to maintain column position across rows of different lengths)
|
||||||
|
const desiredCol = ref<number | null>(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<number, FocusableElement[]> {
|
||||||
|
const elements = getFocusableElements();
|
||||||
|
const byRow = new Map<number, FocusableElement[]>();
|
||||||
|
|
||||||
|
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<string, string | number> = {
|
||||||
|
[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 };
|
||||||
@@ -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')
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""MediaHive - Media Browser Server"""
|
||||||
@@ -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()
|
||||||
@@ -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, "/")
|
||||||
+22
-4
@@ -1,25 +1,43 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "torrentmanager"
|
name = "mediahive"
|
||||||
version = "0.1.0"
|
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"
|
readme = "README.md"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aiofiles>=25.1.0",
|
||||||
"bencodepy>=0.9.5",
|
"bencodepy>=0.9.5",
|
||||||
|
"fastapi-vue>=0.5.2",
|
||||||
|
"fastapi[standard]>=0.128.0",
|
||||||
"httpx[http2]>=0.28.1",
|
"httpx[http2]>=0.28.1",
|
||||||
"parse-torrent-title>=2.8.1",
|
"parse-torrent-title>=2.8.1",
|
||||||
"tqdm>=4.67.3",
|
"tqdm>=4.67.3",
|
||||||
|
"uvicorn[standard]>=0.40.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
mediahive = "mediahive.__main__:main"
|
||||||
hivescan = "hivescan.__main__:main"
|
hivescan = "hivescan.__main__:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build]
|
||||||
packages = ["hivescan"]
|
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]
|
[tool.uv.sources]
|
||||||
parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-title.git" }
|
parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-title.git" }
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"httpx>=0.28.1",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user