Merge mediahive.
This commit is contained in:
+11
@@ -1,9 +1,20 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Node / Frontend
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
mediahive/frontend-build/
|
||||
|
||||
# Lockfiles (uv.lock, package-lock.json, deno.lock, bun.lockb, etc.)
|
||||
*.lock
|
||||
*.lockb
|
||||
|
||||
# Dotfiles
|
||||
.*
|
||||
!.gitignore
|
||||
|
||||
@@ -1,27 +1,42 @@
|
||||
# Torrent Manager
|
||||
# MediaHive
|
||||
|
||||
Tools for managing a media torrent library: scanning, indexing, metadata fetching, and preview generation.
|
||||
Media scanning, indexing, and Netflix-style web streaming for your torrent collection.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
hivescan/ Indexing & previews (installable package)
|
||||
indexer.py Media index generation
|
||||
scanning.py File system scanning
|
||||
parsing.py Torrent name parsing (PTN)
|
||||
models.py Data models
|
||||
images.py TMDb cover/backdrop downloading
|
||||
showreel.py Video preview clip generation (ffmpeg)
|
||||
tmdb_client.py TMDb API client with caching
|
||||
utils.py Path, size, and timestamp helpers
|
||||
hivescan/ Indexing & previews (library + CLI)
|
||||
indexer.py Media index generation
|
||||
scanning.py File system scanning
|
||||
parsing.py Torrent name parsing (PTN)
|
||||
models.py Data models
|
||||
images.py TMDb cover/backdrop downloading
|
||||
showreel.py Video preview clip generation (ffmpeg)
|
||||
tmdb_client.py TMDb API client with caching
|
||||
utils.py Path, size, and timestamp helpers
|
||||
mediahive/ FastAPI web server + Vue frontend
|
||||
server.py FastAPI app (API + media serving)
|
||||
__main__.py CLI entry point
|
||||
frontend/ Vue 3 frontend source
|
||||
src/
|
||||
components/ Vue components (Netflix-style UI)
|
||||
styles/ CSS styles
|
||||
api.ts API calls to FastAPI backend
|
||||
types.ts TypeScript interfaces
|
||||
scripts/
|
||||
rtorrent-manager.py Torrent scanning & rtorrent management
|
||||
rtorrent_client.py RTorrent XMLRPC/SCGI client
|
||||
devserver.py Development server (Vite + FastAPI)
|
||||
rtorrent-manager.py Torrent scanning & rtorrent management
|
||||
fastapi-vue/ Build utilities for frontend
|
||||
rtorrent_client.py RTorrent XMLRPC/SCGI client
|
||||
```
|
||||
|
||||
## Hivescan
|
||||
## Quick Start
|
||||
|
||||
Scans downloaded content, categorizes it (Movies, Series, Other), fetches metadata from TMDb, generates preview clips, and produces a JSON index.
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 1. Scan & Index Media
|
||||
|
||||
```bash
|
||||
# Scan downloads, auto-detect common root, create .mediahive folder
|
||||
@@ -37,39 +52,43 @@ hivescan /media/torrents/* -o /srv/media/.mediahive
|
||||
hivescan /media/torrents/* --no-covers --no-showreels
|
||||
```
|
||||
|
||||
## RTorrent Manager
|
||||
|
||||
Scans `.torrent` files, filters by tracker, verifies downloads exist on disk, loads verified torrents into rtorrent, and cleans up unregistered torrents.
|
||||
### 2. Serve & Browse
|
||||
|
||||
```bash
|
||||
# Scan .torrents directories and manage rtorrent
|
||||
python scripts/rtorrent-manager.py /media/torrents*/.torrents/
|
||||
# Start the web server
|
||||
mediahive /path/to/your/media/folder
|
||||
|
||||
# Multiple paths
|
||||
python scripts/rtorrent-manager.py /mnt/disk1/torrents/.torrents/ /mnt/disk2/torrents/.torrents/
|
||||
|
||||
# Filter by tracker, dry run
|
||||
python scripts/rtorrent-manager.py /media/torrents*/.torrents/ --tracker example.org --dry
|
||||
# Server starts at http://localhost:8420
|
||||
```
|
||||
|
||||
## Path Mapping
|
||||
The app expects `<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 |
|
||||
|----------|---------|
|
||||
| Torrents | `/media/torrents*/` |
|
||||
| Index | `/media/.mediahive/index.json` |
|
||||
| Covers | `/media/.mediahive/movies/` |
|
||||
| Showreels | `/media/.mediahive/movies/<title>/reel1.webm` |
|
||||
```bash
|
||||
cd frontend && npm install && cd ..
|
||||
python scripts/devserver.py
|
||||
```
|
||||
|
||||
Use `-o` to override the output directory if the auto-detected root isn't suitable.
|
||||
Starts Vite dev server + FastAPI backend with auto-reload.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/index` — Load media index
|
||||
- `POST /api/play` — Open media file with system player
|
||||
- `POST /api/open-folder` — Open folder in file explorer
|
||||
- `GET /api/media/{path}` — Serve media files (images, video)
|
||||
|
||||
## RTorrent Manager
|
||||
|
||||
```bash
|
||||
python scripts/rtorrent-manager.py /media/torrents*/.torrents/
|
||||
```
|
||||
|
||||
Scans `.torrent` files, verifies downloads exist, loads into rtorrent.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python ≥ 3.14
|
||||
- ffmpeg (for showreel generation)
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
- Node.js 18+ (frontend development)
|
||||
- ffmpeg (showreel generation)
|
||||
|
||||
@@ -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]
|
||||
name = "torrentmanager"
|
||||
name = "mediahive"
|
||||
version = "0.1.0"
|
||||
description = "Media torrent manager with download scanning and indexing"
|
||||
description = "MediaHive - Media scanning, indexing, and Netflix-style streaming"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"aiofiles>=25.1.0",
|
||||
"bencodepy>=0.9.5",
|
||||
"fastapi-vue>=0.5.2",
|
||||
"fastapi[standard]>=0.128.0",
|
||||
"httpx[http2]>=0.28.1",
|
||||
"parse-torrent-title>=2.8.1",
|
||||
"tqdm>=4.67.3",
|
||||
"uvicorn[standard]>=0.40.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mediahive = "mediahive.__main__:main"
|
||||
hivescan = "hivescan.__main__:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["hivescan"]
|
||||
[tool.hatch.build]
|
||||
packages = ["mediahive", "hivescan"]
|
||||
artifacts = ["mediahive/frontend-build"]
|
||||
only-packages = true
|
||||
|
||||
[tool.hatch.build.targets.sdist.hooks.custom]
|
||||
path = "scripts/fastapi-vue/build-frontend.py"
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
||||
[tool.uv.sources]
|
||||
parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-title.git" }
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"httpx>=0.28.1",
|
||||
]
|
||||
|
||||
@@ -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