Merge mediahive.

This commit is contained in:
2026-02-07 00:21:32 +00:00
parent 470adbbbb7
commit daf4eb5e0c
35 changed files with 9025 additions and 43 deletions
+11
View File
@@ -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
+58 -39
View File
@@ -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)
+15
View File
@@ -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"
}
+15
View File
@@ -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>
+1405
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"
}
}
+1106
View File
File diff suppressed because it is too large Load Diff
+89
View File
@@ -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
+157
View File
@@ -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>
+149
View File
@@ -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>
+297
View File
@@ -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
+27
View File
@@ -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 };
+10
View File
@@ -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')
+49
View File
@@ -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;
+810
View File
@@ -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;
}
}
+179
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+21
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+36
View File
@@ -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,
},
}),
}
}
+15
View File
@@ -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,
},
}));
+1
View File
@@ -0,0 +1 @@
"""MediaHive - Media Browser Server"""
+51
View File
@@ -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()
+273
View File
@@ -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
View File
@@ -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",
]
+82
View File
@@ -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()
+15
View File
@@ -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")
+191
View File
@@ -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)
+216
View File
@@ -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