Improve scanning. Add Crime category taking it out of Action.

This commit is contained in:
2026-02-10 21:15:00 +00:00
parent 4347a8d11d
commit a9e6c2a145
10 changed files with 896 additions and 229 deletions
+109 -20
View File
@@ -5,6 +5,29 @@
<component :is="Component" v-show="false" />
</router-view>
<!-- Scanning progress debug overlay -->
<div v-if="activeTasks.length > 0 || !wsConnected" class="scan-debug-overlay">
<div v-if="!wsConnected" class="scan-debug-item scan-debug-disconnected">
Reconnecting...
</div>
<div
v-for="task in activeTasks"
:key="task.id"
class="scan-debug-item"
:class="{
'scan-debug-done': task.status === 'completed',
'scan-debug-error': task.status === 'error',
}"
>
<span class="scan-debug-label">{{ task.id }}</span>
<span v-if="task.progress > 0" class="scan-debug-progress">
{{ Math.round(task.progress * 100) }}%
</span>
<span v-if="task.detail" class="scan-debug-detail">{{ task.detail }}</span>
<span class="scan-debug-status">{{ task.status }}</span>
</div>
</div>
<!-- Persistent Header overlay - single instance -->
<Header
:current-view="currentView"
@@ -32,7 +55,7 @@
<div class="error-icon"></div>
<h2 class="error-title">Failed to load media index</h2>
<p class="error-message">{{ error }}</p>
<button class="btn btn-primary" @click="loadIndex">
<button class="btn btn-primary" @click="reloadPage">
Try Again
</button>
</div>
@@ -57,6 +80,7 @@
<!-- Hero for movies -->
<CollageHero
v-if="movieCollageItems.length > 0"
:key="`movie-hero-${movieCollageItems.length}-${movieFeaturedItem?.id || 'none'}`"
:items="movieCollageItems"
:featured-item="movieFeaturedItem"
@play="handlePlay"
@@ -83,6 +107,7 @@
<!-- Hero for series -->
<CollageHero
v-if="seriesCollageItems.length > 0"
:key="`series-hero-${seriesCollageItems.length}-${seriesFeaturedItem?.id || 'none'}`"
:items="seriesCollageItems"
:featured-item="seriesFeaturedItem"
@play="handlePlay"
@@ -113,6 +138,7 @@
<template v-if="searchResults.length > 0">
<!-- Hero with all results ranked by relevance -->
<CollageHero
:key="`search-hero-${searchCollageItems.length}-${searchFeaturedItem?.id || 'none'}`"
:items="searchCollageItems"
:featured-item="searchFeaturedItem"
@play="handlePlay"
@@ -152,9 +178,10 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import type { MediaIndex, Movie, Series, MediaItem, EpisodeWithSeries, MatchedPerson, MatchedEpisode } from './types';
import { loadMediaIndex, playMedia, openFolder } from './api';
import type { Movie, Series, MediaItem, EpisodeWithSeries, MatchedPerson, MatchedEpisode, TaskInfo } from './types';
import { playMedia, openFolder } from './api';
import { useKeyboardNavigation } from './composables/useKeyboardNavigation';
import { useMediaWebSocket } from './composables/useMediaWebSocket';
import Header from './components/Header.vue';
import CollageHero from './components/CollageHero.vue';
import MediaRow from './components/MediaRow.vue';
@@ -166,9 +193,12 @@ const { getFocusState, restoreFocusState, focusAt } = useKeyboardNavigation();
const router = useRouter();
const route = useRoute();
const loading = ref(true);
const error = ref<string | null>(null);
const mediaIndex = ref<MediaIndex | null>(null);
// WebSocket-driven media index
const { mediaIndex, loading, error, connected: wsConnected, tasks } = useMediaWebSocket();
// Active tasks for the debug overlay
const activeTasks = computed<TaskInfo[]>(() => Array.from(tasks.value.values()));
const searchResults = ref<MediaItem[]>([]);
const isSearching = ref(false);
@@ -438,10 +468,11 @@ function seriesToMediaItem(series: Series): MediaItem {
// priority: lower number = higher matching priority (movies assigned to highest priority match)
// exclude: if item has any of these genres, it won't match this category (negative match)
const GENRE_CATEGORIES = [
{ name: 'Action', keywords: ['Action', 'Adventure', 'Crime'], priority: 40, exclude: [] },
{ name: 'Action', keywords: ['Action', 'Adventure'], priority: 40, exclude: [] },
{ name: 'Comedy', keywords: ['Comedy'], priority: 30, exclude: ['Drama'] },
{ name: 'Romance', keywords: ['Romance'], priority: 20, exclude: [] },
{ name: 'Drama', keywords: ['Drama'], priority: 50, exclude: [] },
{ name: 'Crime', keywords: ['Crime'], priority: 45, exclude: [] },
{ name: 'Thriller', keywords: ['Thriller', 'Mystery'], priority: 20, exclude: [] },
{ name: 'Horror', keywords: ['Horror'], priority: 10, exclude: [] },
{ name: 'Science Fiction', keywords: ['Science Fiction', 'Sci-Fi'], priority: 15, exclude: [] },
@@ -1049,16 +1080,8 @@ const searchFeaturedItem = computed(() => {
return withCovers[0] || searchResults.value[0] || null;
});
async function loadIndex() {
loading.value = true;
error.value = null;
try {
mediaIndex.value = await loadMediaIndex();
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
} finally {
loading.value = false;
}
function reloadPage() {
window.location.reload();
}
async function handlePlay(filePath: string) {
@@ -1077,9 +1100,7 @@ async function handleOpenFolder(folderPath: string) {
}
}
onMounted(() => {
loadIndex();
});
</script>
<style scoped>
@@ -1087,6 +1108,74 @@ onMounted(() => {
min-height: 100vh;
}
/* Scanning progress debug overlay */
.scan-debug-overlay {
position: fixed;
bottom: 12px;
right: 12px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 4px;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 11px;
max-width: 380px;
pointer-events: none;
}
.scan-debug-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
background: rgba(0, 0, 0, 0.82);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(8px);
white-space: nowrap;
overflow: hidden;
}
.scan-debug-disconnected {
color: #f59e0b;
border-color: rgba(245, 158, 11, 0.3);
}
.scan-debug-done {
color: #34d399;
border-color: rgba(52, 211, 153, 0.3);
}
.scan-debug-error {
color: #f87171;
border-color: rgba(248, 113, 113, 0.3);
}
.scan-debug-label {
color: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.scan-debug-progress {
color: #60a5fa;
font-weight: 600;
flex-shrink: 0;
}
.scan-debug-detail {
color: rgba(255, 255, 255, 0.55);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.scan-debug-status {
color: rgba(255, 255, 255, 0.35);
flex-shrink: 0;
margin-left: auto;
}
.empty-hero {
height: 70vh;
min-height: 450px;
@@ -0,0 +1,186 @@
import { ref, readonly, onUnmounted } from 'vue';
import type { Movie, Series, MediaIndex, TaskInfo, WsMessage } from '../types';
/**
* Composable that connects to the MediaHive WebSocket and keeps
* the media index updated in real time.
*
* The server sends:
* - "init" → full index (movies + series) on connect
* - "upsert" → single item inserted or updated
* - "remove" → single item removed
* - "task" → background task progress
*
* Messages are msgspec-encoded binary JSON with a "type" tag field.
*/
export function useMediaWebSocket() {
const mediaIndex = ref<MediaIndex | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const connected = ref(false);
const tasks = ref<Map<string, TaskInfo>>(new Map());
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let disposed = false;
// Lookup maps for fast upsert / remove
const movieMap = new Map<string, Movie>();
const seriesMap = new Map<string, Series>();
function buildIndex(): MediaIndex {
const movies = Array.from(movieMap.values());
const series = Array.from(seriesMap.values());
return {
version: 0,
generated_at: new Date().toISOString(),
stats: {
total_movies: movies.length,
total_series: series.length,
},
movies,
series,
};
}
function handleMessage(event: MessageEvent) {
try {
// Server sends binary frames (msgspec json bytes)
let text: string;
if (event.data instanceof Blob) {
// Will be handled by the blob reader below
event.data.text().then((t) => processJson(t));
return;
} else if (event.data instanceof ArrayBuffer) {
text = new TextDecoder().decode(event.data);
} else {
text = event.data as string;
}
processJson(text);
} catch (e) {
console.error('[WS] Failed to handle message:', e);
}
}
function processJson(text: string) {
const msg = JSON.parse(text) as WsMessage;
switch (msg.type) {
case 'init': {
movieMap.clear();
seriesMap.clear();
for (const m of msg.data.movies) movieMap.set(m.id, m);
for (const s of msg.data.series) seriesMap.set(s.id, s);
mediaIndex.value = buildIndex();
loading.value = false;
error.value = null;
console.log(`[WS] init: ${movieMap.size} movies, ${seriesMap.size} series`);
break;
}
case 'upsert': {
if (msg.kind === 'movie') {
movieMap.set(msg.item.id, msg.item as Movie);
} else {
seriesMap.set(msg.item.id, msg.item as Series);
}
// Rebuild the index ref so Vue detects the change
mediaIndex.value = buildIndex();
break;
}
case 'remove': {
if (msg.kind === 'movie') {
movieMap.delete(msg.id);
} else {
seriesMap.delete(msg.id);
}
mediaIndex.value = buildIndex();
break;
}
case 'task': {
const info = msg.data;
if (info.status === 'completed' || info.status === 'cancelled' || info.status === 'error') {
// Keep finished tasks briefly so the UI can show completion
tasks.value.set(info.id, info);
setTimeout(() => {
tasks.value.delete(info.id);
tasks.value = new Map(tasks.value);
}, 3000);
} else {
tasks.value.set(info.id, info);
}
// Trigger reactivity
tasks.value = new Map(tasks.value);
break;
}
}
}
function connect() {
if (disposed) return;
// Build WS URL relative to current page
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/ws`;
console.log(`[WS] Connecting to ${url}...`);
ws = new WebSocket(url);
ws.onopen = () => {
connected.value = true;
error.value = null;
console.log('[WS] Connected');
};
ws.onmessage = handleMessage;
ws.onclose = (ev) => {
connected.value = false;
console.log(`[WS] Closed (code=${ev.code})`);
scheduleReconnect();
};
ws.onerror = (ev) => {
console.error('[WS] Error:', ev);
if (!mediaIndex.value) {
error.value = 'WebSocket connection failed';
}
};
}
function scheduleReconnect() {
if (disposed) return;
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
console.log('[WS] Reconnecting...');
connect();
}, 2000);
}
function disconnect() {
disposed = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.onclose = null; // prevent reconnect
ws.close();
ws = null;
}
}
// Start the connection
connect();
// Clean up on component unmount
onUnmounted(disconnect);
return {
mediaIndex,
loading: readonly(loading),
error: readonly(error),
connected: readonly(connected),
tasks: readonly(tasks),
disconnect,
};
}
+34
View File
@@ -88,6 +88,7 @@ export interface Series {
id: string;
title: string | null;
info: Info | null;
alternative_titles: string[] | null;
newest: number | null;
cover_path: string | null;
backdrop_path: string | null;
@@ -153,3 +154,36 @@ export interface EpisodeWithSeries {
series: Series;
seasonNumber: number;
}
// Task progress info from background scanning
export interface TaskInfo {
id: string;
status: string;
progress: number;
detail: string;
}
// WebSocket message types (matching server msgspec tagged structs)
export interface WsInitMessage {
type: 'init';
data: { movies: Movie[]; series: Series[] };
}
export interface WsUpsertMessage {
type: 'upsert';
kind: 'movie' | 'series';
item: Movie | Series;
}
export interface WsRemoveMessage {
type: 'remove';
kind: 'movie' | 'series';
id: string;
}
export interface WsTaskMessage {
type: 'task';
data: TaskInfo;
}
export type WsMessage = WsInitMessage | WsUpsertMessage | WsRemoveMessage | WsTaskMessage;
+1 -2
View File
@@ -12,8 +12,6 @@ def main():
parser = argparse.ArgumentParser(
description="MediaHive - Media scanning, indexing, and streaming"
)
# TODO: Accept .mediahive root folder directly from CLI.
# Future: use gitignore-style system (file in .mediahive folder) for path determination.
parser.add_argument(
"media_folder",
nargs="?",
@@ -43,6 +41,7 @@ def main():
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",
+11 -42
View File
@@ -1,12 +1,8 @@
import argparse
import asyncio
import glob
import logging
import os
from pathlib import Path
from mediahive.hivescan.utils import find_common_root
def main():
parser = argparse.ArgumentParser(
@@ -14,10 +10,11 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python -m mediahive.hivescan /path/to/torrents/* # Scan paths, auto-detect common root
python -m mediahive.hivescan /mnt/disk1/* /mnt/disk2/* # Scan multiple locations
python -m mediahive.hivescan /torrents/* -o /srv/media # Override output directory
python -m mediahive.hivescan /torrents/* --port 9000 # Custom port
python -m mediahive.hivescan /srv/media # Scan a media root
python -m mediahive.hivescan Z:\\ # Windows drive
python -m mediahive.hivescan /srv/media --port 9000 # Custom port
Exclude paths by creating .mediahive/scanignore (gitignore syntax).
The server exposes:
WS /ws Live index updates & task progress
@@ -27,15 +24,8 @@ The server exposes:
""",
)
parser.add_argument(
"paths",
nargs="+",
help="Folders or glob patterns to scan for downloads",
)
parser.add_argument(
"-o",
"--output-dir",
metavar="DIR",
help="Output directory for index and covers (default: .mediahive at common root)",
"media_folder",
help="Root folder to scan recursively",
)
parser.add_argument(
"--host",
@@ -51,33 +41,12 @@ The server exposes:
args = parser.parse_args()
# TODO: Take .mediahive root folder from CLI directly.
# Future: use gitignore-style system (file in .mediahive folder) for path determination.
media_root = Path(args.media_folder).resolve()
if not media_root.exists() or not media_root.is_dir():
print(f"Error: Folder does not exist: {media_root}")
exit(1)
# Derive media_root only if no explicit output-dir is given
if args.output_dir:
media_root = Path(args.output_dir).parent.resolve()
else:
# Expand globs once to find common root
all_paths: list[Path] = []
for pattern in args.paths:
expanded = glob.glob(pattern)
if expanded:
all_paths.extend(Path(p) for p in expanded)
else:
all_paths.append(Path(pattern))
media_root = asyncio.run(find_common_root(all_paths))
if media_root is None:
print("Error: Cannot determine common root; use -o to set output directory")
exit(1)
media_root = media_root.resolve()
# Configure environment - scanner will re-expand patterns from HIVESCAN_PATHS
os.environ["MEDIAHIVE_PATH"] = str(media_root)
os.environ["HIVESCAN_PATHS"] = os.pathsep.join(args.paths)
if args.output_dir:
os.environ["HIVESCAN_OUTPUT"] = args.output_dir
logging.basicConfig(
level=logging.INFO,
+157
View File
@@ -0,0 +1,157 @@
"""
Gitignore-style path matcher for controlling which directories the scanner visits.
Reads patterns from ``<media_root>/.mediahive/scanignore``. The file uses the
same syntax as ``.gitignore``:
- Blank lines and lines starting with ``#`` are ignored.
- A pattern without a slash is matched against the **name** of every directory
entry (e.g. ``incomplete`` matches any directory called *incomplete*).
- A pattern with a slash is matched against the **path relative to media root**
(e.g. ``Downloads/ISOs/`` skips that specific subtree).
- A leading ``!`` negates the pattern (re-includes a previously excluded path).
- A leading ``/`` anchors the pattern to the media root.
- ``*`` matches anything except ``/``; ``**`` matches zero or more directories.
- Trailing ``/`` restricts the match to directories (always the case for the
scanner, since it only walks directories).
Default built-in excludes (always active, before the user file is read)::
.mediahive
.torrents
incomplete
.incomplete
.Trash*
$RECYCLE.BIN
System Volume Information
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import List, Tuple
# Built-in patterns that are always excluded (before user file)
_BUILTIN_EXCLUDES: list[str] = [
".mediahive",
".torrents",
"incomplete",
".incomplete",
".Trash*",
"$RECYCLE.BIN",
"System Volume Information",
]
def _pattern_to_regex(pattern: str) -> re.Pattern[str]:
"""Convert a single gitignore-style pattern to a compiled regex.
The regex is applied to forward-slash normalised relative paths.
"""
# Trailing slash just means "directories only" — always true for us
pattern = pattern.rstrip("/")
anchored = pattern.startswith("/")
if anchored:
pattern = pattern.lstrip("/")
has_slash = "/" in pattern
# Translate glob-like syntax to regex:
# 1. Escape regex-special chars (except our glob chars)
# 2. Handle ** (match zero or more path segments)
# 3. Handle * (match anything except /)
# 4. Handle ? (match single char except /)
parts: list[str] = []
i = 0
while i < len(pattern):
c = pattern[i]
if c == "*":
if i + 1 < len(pattern) and pattern[i + 1] == "*":
# **
if i + 2 < len(pattern) and pattern[i + 2] == "/":
parts.append("(?:.+/)?")
i += 3
continue
else:
parts.append(".*")
i += 2
continue
else:
parts.append("[^/]*")
i += 1
elif c == "?":
parts.append("[^/]")
i += 1
elif c in r"\.+^${}()|[]":
parts.append("\\" + c)
i += 1
else:
parts.append(c)
i += 1
regex_str = "".join(parts)
if anchored or has_slash:
# Match from the start of the relative path
regex_str = "^" + regex_str
else:
# Match against any path component (basename or as suffix after /)
regex_str = "(?:^|/)" + regex_str
# Must match the whole remaining path or be a prefix (directory match)
regex_str += "(?:/.*)?$"
return re.compile(regex_str, re.IGNORECASE)
class ScanIgnore:
"""Matcher that decides whether a path should be scanned or excluded."""
def __init__(self, media_root: Path) -> None:
self.media_root = media_root.resolve()
self._rules: List[Tuple[bool, re.Pattern[str]]] = [] # (negated, regex)
self._load_builtins()
self._load_file()
def _load_builtins(self) -> None:
for pat in _BUILTIN_EXCLUDES:
self._rules.append((False, _pattern_to_regex(pat)))
def _load_file(self) -> None:
scanignore = self.media_root / ".mediahive" / "scanignore"
if not scanignore.exists():
return
for line in scanignore.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
negated = line.startswith("!")
if negated:
line = line[1:]
self._rules.append((negated, _pattern_to_regex(line)))
def is_excluded(self, path: Path) -> bool:
"""Return True if *path* should be skipped by the scanner.
*path* must be an absolute path under ``media_root``.
"""
try:
rel = path.resolve().relative_to(self.media_root)
except ValueError:
return False # outside media root — not our business
# Normalise to forward slashes for matching
rel_str = rel.as_posix()
excluded = False
for negated, regex in self._rules:
if regex.search(rel_str):
excluded = not negated
return excluded
@property
def file_path(self) -> Path:
return self.media_root / ".mediahive" / "scanignore"
+327 -133
View File
@@ -5,10 +5,12 @@ All scanning logic lives here in hivescan. Communication with the mediahive
server happens exclusively through an async ``send`` callable that pushes
:class:`~mediahive.models.events.ScanEvent` messages (``Upsert`` /
``Task``) onto an :class:`asyncio.Queue` owned by the caller.
The scanner recursively walks the media root, respecting ignore patterns
defined in ``.mediahive/scanignore`` (gitignore-style syntax).
"""
import asyncio
import glob
import logging
import os
import uuid
@@ -20,6 +22,7 @@ from aiopathlib import AsyncPath
from mediahive.hivescan.indexer import _process_movies, _process_series
from mediahive.hivescan.models import ContentType, ParsedContent
from mediahive.hivescan.parsing import parse_download
from mediahive.hivescan.scanignore import ScanIgnore
from mediahive.hivescan.scanning import categorize_downloads
from mediahive.hivescan.showreel import (
episode_reel_exists,
@@ -31,7 +34,6 @@ from mediahive.hivescan.showreel import (
from mediahive.hivescan.tmdb_client import set_cache_dir
from mediahive.hivescan.utils import (
DEFAULT_OUTPUT_FOLDER,
find_common_root,
make_relative_path,
)
from mediahive.models.data import Movie, Series, TaskInfo
@@ -47,9 +49,9 @@ Send = Callable[[ScanEvent], Awaitable[None]]
# Configuration (populated by ``start``)
# ---------------------------------------------------------------------------
_scan_paths: List[str] = []
_output_dir: Optional[Path] = None
_media_root: Optional[Path] = None
_scanignore: Optional[ScanIgnore] = None
# Runtime state
_send: Optional[Send] = None
@@ -69,49 +71,31 @@ async def start(send: Send) -> None:
"""
Initialise and start the scanner.
Reads ``HIVESCAN_PATHS`` / ``HIVESCAN_OUTPUT`` from the environment,
expands globs, sets up the TMDb cache, and starts background workers.
Reads ``MEDIAHIVE_PATH`` from the environment, loads the scanignore
rules from ``.mediahive/scanignore``, and starts background workers
that recursively walk the media root.
"""
global _send, _output_dir, _media_root, _scan_paths
global _send, _output_dir, _media_root, _scanignore
global _showreel_worker_task, _rescan_worker_task
_send = send
raw_paths = os.environ.get("HIVESCAN_PATHS", "")
if not raw_paths:
logger.error("HIVESCAN_PATHS environment variable must be set")
media_path = os.environ.get("MEDIAHIVE_PATH", "")
if not media_path:
logger.error("MEDIAHIVE_PATH environment variable must be set")
return
all_paths: List[Path] = []
for pattern in raw_paths.split(os.pathsep):
pattern = pattern.strip()
if not pattern:
continue
expanded = await asyncio.to_thread(glob.glob, pattern)
if expanded:
all_paths.extend(Path(p) for p in expanded)
else:
all_paths.append(Path(pattern))
_scan_paths = [str(p) for p in all_paths]
if os.environ.get("HIVESCAN_OUTPUT"):
_output_dir = Path(os.environ["HIVESCAN_OUTPUT"])
_media_root = _output_dir.parent
else:
_media_root = await find_common_root(all_paths)
if _media_root is None:
logger.error("Cannot determine common root; set HIVESCAN_OUTPUT")
return
_output_dir = _media_root / DEFAULT_OUTPUT_FOLDER
_media_root = Path(media_path).resolve()
_output_dir = _media_root / DEFAULT_OUTPUT_FOLDER
_scanignore = ScanIgnore(_media_root)
await AsyncPath(_output_dir).mkdir(parents=True, exist_ok=True)
set_cache_dir(_output_dir / ".tmdb-cache")
logger.info(
"Scanner started — %d scan paths, output=%s",
len(_scan_paths),
_output_dir,
"Scanner started — root=%s, scanignore=%s",
_media_root,
"loaded" if _scanignore.file_path.exists() else "defaults only",
)
_showreel_worker_task = asyncio.create_task(_showreel_worker())
@@ -133,11 +117,11 @@ def showreel_queue_size() -> int:
return _showreel_queue.qsize()
def trigger_scan(paths: Optional[List[str]] = None) -> bool:
def trigger_scan() -> bool:
"""Start a scan. Returns False if one is already running."""
if is_scanning():
return False
_start_scan(paths)
_start_scan()
return True
@@ -146,9 +130,9 @@ def trigger_scan(paths: Optional[List[str]] = None) -> bool:
# ---------------------------------------------------------------------------
def _start_scan(paths: Optional[List[str]] = None):
def _start_scan():
global _scan_task
_scan_task = asyncio.create_task(_run_scan(paths))
_scan_task = asyncio.create_task(_run_scan())
async def _rescan_loop():
@@ -157,85 +141,203 @@ async def _rescan_loop():
_start_scan()
if _scan_task:
await _scan_task
await asyncio.sleep(1)
await asyncio.sleep(30)
except asyncio.CancelledError:
return
except Exception:
logger.exception("Rescan loop error")
async def _discover_downloads(paths_to_scan: List[str]) -> List[ParsedContent]:
"""Walk the filesystem and parse all downloads, skipping unchanged torrents."""
async def _discover_downloads(task_id: str) -> List[ParsedContent]:
"""Recursively walk the media root, respecting scanignore rules.
Each non-ignored **leaf directory** (a directory whose children are only
files, i.e. a single download/torrent folder) and each non-ignored
top-level file is treated as a download to parse.
Sends live task progress so the user can see which directories are being
explored and how many items have been found so far.
"""
downloads: List[ParsedContent] = []
media_root_str = str(_media_root) if _media_root else None
for pattern in paths_to_scan:
p = Path(pattern)
ap = AsyncPath(p)
if await ap.is_dir():
for item in ap.iterdir():
if not Path(item).name.startswith("."):
relpath = make_relative_path(str(item), media_root_str)
stat_info = await AsyncPath(item).stat()
mtime = int(stat_info.st_mtime)
if relpath in _seen_mtimes and _seen_mtimes[relpath] == mtime:
continue
_seen_mtimes[relpath] = mtime
downloads.append(await parse_download(Path(item)))
elif await ap.exists():
relpath = make_relative_path(str(p), media_root_str)
stat_info = await ap.stat()
mtime = int(stat_info.st_mtime)
if relpath in _seen_mtimes and _seen_mtimes[relpath] == mtime:
continue
_seen_mtimes[relpath] = mtime
downloads.append(await parse_download(p))
return downloads
dirs_visited = 0
async def _run_scan(override_paths: Optional[List[str]] = None):
"""
Full scan pipeline:
1. Walk filesystem, parse torrents
2. Categorise → movies / series
3. Iterate async generators, send each item as Upsert
4. Queue showreel tasks
"""
task_id = f"scan-{uuid.uuid4().hex[:8]}"
paths_to_scan = override_paths or _scan_paths
media_root_str = str(_media_root) if _media_root else None
try:
downloads = await _discover_downloads(paths_to_scan)
if downloads:
logger.info("Scan started (%s)", task_id)
await _send(
Task(
data=TaskInfo(
id=task_id,
status="running",
progress=0,
detail="Scanning filesystem...",
)
)
)
logger.info("Found %d items to process", len(downloads))
categories = categorize_downloads(downloads)
total = len(categories[ContentType.MOVIE]) + len(categories[ContentType.SERIES])
processed = 0
# Process movies
async def _report(detail: str) -> None:
await _send(
Task(
data=TaskInfo(
id=task_id,
status="running",
progress=0,
detail="Processing movies...",
detail=detail,
)
)
)
async def _walk(directory: Path) -> None:
nonlocal dirs_visited
ap = AsyncPath(directory)
if not await ap.is_dir():
return
has_child_dirs = False
child_dirs: list[Path] = []
child_files: list[Path] = []
try:
for item_async in ap.iterdir():
item = Path(item_async)
if item.name.startswith("."):
continue
if _scanignore and _scanignore.is_excluded(item):
continue
if await AsyncPath(item).is_dir():
has_child_dirs = True
child_dirs.append(item)
else:
child_files.append(item)
except OSError, PermissionError:
logger.debug("Cannot list directory: %s", directory)
return
if has_child_dirs:
# Branch directory — log it and recurse into subdirectories
dirs_visited += 1
rel = make_relative_path(str(directory), media_root_str) or str(directory)
if dirs_visited % 5 == 1: # throttle progress updates
await _report(f"Scanning: {rel} ({len(downloads)} found)")
logger.info("Scanning: %s (%d found so far)", rel, len(downloads))
for child in child_dirs:
await _walk(child)
# Yield control periodically so WS messages flush
await asyncio.sleep(0)
else:
# Leaf directory — treat the directory itself as a download
relpath = make_relative_path(str(directory), media_root_str)
try:
stat_info = await ap.stat()
mtime = int(stat_info.st_mtime)
except OSError:
return
if relpath in _seen_mtimes and _seen_mtimes[relpath] == mtime:
return
_seen_mtimes[relpath] = mtime
downloads.append(await parse_download(directory))
# Walk immediate children of the media root (skip root itself)
logger.info("Starting filesystem discovery at %s", _media_root)
await _report(f"Scanning: {_media_root}")
root_ap = AsyncPath(_media_root)
try:
root_children = list(root_ap.iterdir())
except OSError, PermissionError:
logger.error("Cannot list media root: %s", _media_root)
return downloads
for item_async in root_children:
item = Path(item_async)
if item.name.startswith("."):
continue
if _scanignore and _scanignore.is_excluded(item):
logger.debug("Excluded: %s", item.name)
continue
if await AsyncPath(item).is_dir():
await _walk(item)
else:
# Top-level file — parse directly
relpath = make_relative_path(str(item), media_root_str)
try:
stat_info = await AsyncPath(item).stat()
mtime = int(stat_info.st_mtime)
except OSError:
continue
if relpath in _seen_mtimes and _seen_mtimes[relpath] == mtime:
continue
_seen_mtimes[relpath] = mtime
downloads.append(await parse_download(item))
logger.info(
"Discovery complete: %d downloads found, %d directories visited",
len(downloads),
dirs_visited,
)
return downloads
async def _run_scan():
"""
Full scan pipeline:
1. Recursively walk media root (respecting scanignore) — with live progress
2. Categorise → movies / series
3. Iterate async generators, send each item as Upsert
4. Queue showreel tasks
"""
task_id = f"scan-{uuid.uuid4().hex[:8]}"
media_root_str = str(_media_root) if _media_root else None
try:
logger.info("Scan started (%s)", task_id)
await _send(
Task(
data=TaskInfo(
id=task_id,
status="running",
progress=0,
detail="Starting scan...",
)
)
)
downloads = await _discover_downloads(task_id)
if not downloads:
logger.info("No new downloads found (%s)", task_id)
await _send(
Task(
data=TaskInfo(
id=task_id,
status="completed",
progress=1,
detail="No new items",
)
)
)
return
logger.info("Found %d items to process", len(downloads))
await _send(
Task(
data=TaskInfo(
id=task_id,
status="running",
progress=0,
detail=f"Processing {len(downloads)} items...",
)
)
)
categories = categorize_downloads(downloads)
n_movies = len(categories[ContentType.MOVIE])
n_series = len(categories[ContentType.SERIES])
total = n_movies + n_series
processed = 0
logger.info("Categorised: %d movies, %d series", n_movies, n_series)
# Process movies
if n_movies:
await _send(
Task(
data=TaskInfo(
id=task_id,
status="running",
progress=0,
detail=f"Processing {n_movies} movies...",
)
)
)
async for movie, showreel_task in _process_movies(
categories,
_output_dir,
@@ -246,6 +348,20 @@ async def _run_scan(override_paths: Optional[List[str]] = None):
await _send(Upsert(kind="movie", item=movie))
if showreel_task:
await _showreel_queue.put(("movie", showreel_task, movie))
logger.info(
"[%d/%d] Movie: %s (showreel queued, queue=%d)",
processed + 1,
total,
movie.title,
_showreel_queue.qsize(),
)
else:
logger.info(
"[%d/%d] Movie: %s (no showreel task)",
processed + 1,
total,
movie.title,
)
processed += 1
progress = round(processed / total, 3) if total else 1
await _send(
@@ -260,16 +376,17 @@ async def _run_scan(override_paths: Optional[List[str]] = None):
)
# Process series
await _send(
Task(
data=TaskInfo(
id=task_id,
status="running",
progress=processed / total if total else 0.5,
detail="Processing series...",
if n_series:
await _send(
Task(
data=TaskInfo(
id=task_id,
status="running",
progress=processed / total if total else 0.5,
detail=f"Processing {n_series} series...",
)
)
)
)
async for series, ep_reel_tasks in _process_series(
categories,
_output_dir,
@@ -280,6 +397,22 @@ async def _run_scan(override_paths: Optional[List[str]] = None):
await _send(Upsert(kind="series", item=series))
for task in ep_reel_tasks:
await _showreel_queue.put(("episode", task, series))
if ep_reel_tasks:
logger.info(
"[%d/%d] Series: %s (%d episode reels queued, queue=%d)",
processed + 1,
total,
series.title,
len(ep_reel_tasks),
_showreel_queue.qsize(),
)
else:
logger.info(
"[%d/%d] Series: %s (no reel tasks)",
processed + 1,
total,
series.title,
)
processed += 1
progress = round(processed / total, 3) if total else 1
await _send(
@@ -299,12 +432,17 @@ async def _run_scan(override_paths: Optional[List[str]] = None):
id=task_id,
status="completed",
progress=1,
detail="Scan complete",
detail=f"Done — {n_movies} movies, {n_series} series",
)
)
)
if downloads:
logger.info("Scan complete (%s)", task_id)
logger.info(
"Scan complete (%s): %d movies, %d series, showreel queue=%d",
task_id,
n_movies,
n_series,
_showreel_queue.qsize(),
)
except asyncio.CancelledError:
await _send(
@@ -347,11 +485,20 @@ async def _showreel_worker():
try:
kind, task_data, item = await _showreel_queue.get()
task_id = f"showreel-{uuid.uuid4().hex[:8]}"
remaining = _showreel_queue.qsize()
if kind == "movie":
movie: Movie = item
video_path, media_folder, title = task_data
logger.info(
"Showreel dequeued: %s (video=%s, folder=%s, %d remaining)",
title,
video_path,
media_folder,
remaining,
)
if await movie_showreels_exist(media_folder):
logger.info("Showreel skipped (already exists): %s", title)
_showreel_queue.task_done()
continue
await _send(
@@ -364,32 +511,61 @@ async def _showreel_worker():
)
)
)
await generate_showreel_images(video_path, media_folder, title=title)
paths = get_expected_showreel_paths(
media_folder, media_root=media_root_path
generated = await generate_showreel_images(
video_path,
media_folder,
title=title,
)
movie.showreel_images = paths if paths else None
await _send(Upsert(kind="movie", item=movie))
await _send(
Task(
data=TaskInfo(
id=task_id,
status="completed",
progress=1,
detail=f"Showreel: {title}",
if generated:
paths = get_expected_showreel_paths(
media_folder, media_root=media_root_path
)
movie.showreel_images = paths if paths else None
await _send(Upsert(kind="movie", item=movie))
await _send(
Task(
data=TaskInfo(
id=task_id,
status="completed",
progress=1,
detail=f"Showreel: {title} ({len(generated)} reels)",
)
)
)
else:
logger.warning("Showreel generation returned nothing: %s", title)
await _send(
Task(
data=TaskInfo(
id=task_id,
status="error",
progress=0,
detail=f"Showreel failed: {title}",
)
)
)
)
elif kind == "episode":
series: Series = item
video_path, media_folder, season_num, episode_num, series_title = (
task_data
)
ep_code = f"S{season_num:02d}E{episode_num:02d}"
logger.info(
"Episode reel dequeued: %s %s (video=%s, %d remaining)",
series_title,
ep_code,
video_path,
remaining,
)
if await episode_reel_exists(media_folder, season_num, episode_num):
logger.info(
"Episode reel skipped (already exists): %s %s",
series_title,
ep_code,
)
_showreel_queue.task_done()
continue
ep_code = f"S{season_num:02d}E{episode_num:02d}"
await _send(
Task(
data=TaskInfo(
@@ -416,16 +592,32 @@ async def _showreel_worker():
media_root_str,
)
await _send(Upsert(kind="series", item=series))
await _send(
Task(
data=TaskInfo(
id=task_id,
status="completed",
progress=1,
detail=f"Reel: {series_title} {ep_code}",
await _send(
Task(
data=TaskInfo(
id=task_id,
status="completed",
progress=1,
detail=f"Reel: {series_title} {ep_code}",
)
)
)
else:
logger.warning(
"Episode reel generation failed: %s %s",
series_title,
ep_code,
)
await _send(
Task(
data=TaskInfo(
id=task_id,
status="error",
progress=0,
detail=f"Reel failed: {series_title} {ep_code}",
)
)
)
)
_showreel_queue.task_done()
@@ -433,7 +625,9 @@ async def _showreel_worker():
logger.info("Showreel worker shutting down")
return
except Exception:
logger.exception("Showreel worker error")
logger.exception(
"Showreel worker error (queue size=%d)", _showreel_queue.qsize()
)
try:
_showreel_queue.task_done()
except ValueError:
+53 -5
View File
@@ -541,6 +541,9 @@ async def generate_showreel_images(
List of relative paths to generated showreel video clips
"""
if not video_path:
logger.warning(
" Showreel: no video path provided for %s", title or "unknown"
)
return []
# Handle Blu-ray disc structures using bluray: protocol
@@ -549,6 +552,7 @@ async def generate_showreel_images(
ffmpeg_input = bluray_uri
else:
if not await AsyncPath(video_path).exists():
logger.warning(" Showreel: video file does not exist: %s", video_path)
return []
ffmpeg_input = video_path
@@ -582,6 +586,12 @@ async def generate_showreel_images(
if duration > 60:
valid_timestamps = [int(duration / 2) - 5] # Center the 10s clip
else:
logger.warning(
" Showreel: video too short (%.0fs) for %s: %s",
duration,
title or "unknown",
video_path,
)
return []
# Get the best available AV1 encoder
@@ -659,13 +669,26 @@ async def generate_showreel_images(
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.wait_for(proc.communicate(), timeout=120)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
if proc.returncode == 0 and await AsyncPath(output_path).exists():
generated_paths.append(str(output_path))
logger.info(
" Showreel reel%d generated for %s",
reel_num,
title or "unknown",
)
if on_progress:
on_progress(reel_num)
else:
stderr_text = stderr.decode(errors="replace").strip() if stderr else ""
logger.error(
" Showreel reel%d failed (rc=%s) for %s: %s",
reel_num,
proc.returncode,
title or "unknown",
stderr_text[:500] if stderr_text else "(no output)",
)
await AsyncPath(output_path).unlink(missing_ok=True)
# Abort remaining reels - if first one fails, others likely will too
break
@@ -682,6 +705,16 @@ async def generate_showreel_images(
# Abort remaining reels
break
if generated_paths:
logger.info(
" Showreel complete for %s: %d/%d reels",
title or "unknown",
len(generated_paths),
len(valid_timestamps),
)
else:
logger.warning(" Showreel: no reels generated for %s", title or "unknown")
return generated_paths
@@ -707,7 +740,9 @@ async def generate_episode_reel(
Returns:
Relative path to generated image, or None if failed
"""
ep_code = f"S{season_num:02d}E{episode_num:02d}"
if not video_path:
logger.warning(" Episode reel: no video path for %s", ep_code)
return None
# Handle Blu-ray disc structures using bluray: protocol
@@ -716,13 +751,16 @@ async def generate_episode_reel(
ffmpeg_input = bluray_uri
else:
if not await AsyncPath(video_path).exists():
logger.warning(
" Episode reel: file not found for %s: %s", ep_code, video_path
)
return None
ffmpeg_input = video_path
await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True)
# Normalize episode code to SxxExx format
output_filename = f"S{season_num:02d}E{episode_num:02d}.webm"
output_filename = f"{ep_code}.webm"
output_path = media_folder / output_filename
# Skip if already exists
@@ -732,6 +770,9 @@ async def generate_episode_reel(
# Check video duration
duration = await get_video_duration(ffmpeg_input)
if duration is None:
logger.warning(
" Episode reel: could not get duration for %s: %s", ep_code, video_path
)
return None
# Use 40% of total length for the clip start
@@ -801,17 +842,24 @@ async def generate_episode_reel(
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.wait_for(proc.communicate(), timeout=120)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
if proc.returncode == 0 and await AsyncPath(output_path).exists():
logger.info(" Episode reel generated: %s", ep_code)
return str(output_path)
else:
stderr_text = stderr.decode(errors="replace").strip() if stderr else ""
logger.error(
" Episode reel %s failed (rc=%s): %s",
ep_code,
proc.returncode,
stderr_text[:500] if stderr_text else "(no output)",
)
await AsyncPath(output_path).unlink(missing_ok=True)
return None
except BaseException as e:
await AsyncPath(output_path).unlink(missing_ok=True)
if isinstance(e, (KeyboardInterrupt, SystemExit, asyncio.CancelledError)):
raise
episode_code = f"S{season_num:02d}E{episode_num:02d}"
logger.error("Error generating episode reel for %s: %s", episode_code, e)
logger.error("Error generating episode reel for %s: %s", ep_code, e)
return None
+1
View File
@@ -77,6 +77,7 @@ class Series(msgspec.Struct):
id: str
title: str | None = None
info: Info | None = None
alternative_titles: list[str] | None = None
newest: int | None = None
cover_path: str | None = None
backdrop_path: str | None = None
+17 -27
View File
@@ -1,8 +1,9 @@
"""
FastAPI server for MediaHive.
Serves media files, the Vue frontend, and — when ``HIVESCAN_PATHS`` is set —
also runs the continuous scanning pipeline with live WebSocket updates.
Serves media files, the Vue frontend, and runs the continuous scanning
pipeline with live WebSocket updates. Excluded paths are controlled by
``.mediahive/scanignore`` (gitignore-style syntax).
"""
import asyncio
@@ -27,7 +28,6 @@ from mediahive.models.protocol import (
MsgspecResponse,
PlayMediaRequest,
OpenFolderRequest,
ScanRequest,
StatusResponse,
)
@@ -95,26 +95,22 @@ async def lifespan(app: FastAPI):
len(store.series),
)
# If scan paths are configured, start the scanner subsystem
if os.environ.get("HIVESCAN_PATHS"):
from mediahive.hivescan.scanner import (
start as start_scanner,
stop as stop_scanner,
)
# Start the scanner subsystem
from mediahive.hivescan.scanner import (
start as start_scanner,
stop as stop_scanner,
)
_consumer_task = asyncio.create_task(_consume_scan_events())
await start_scanner(_send_event)
_scanner_active = True
_consumer_task = asyncio.create_task(_consume_scan_events())
await start_scanner(_send_event)
_scanner_active = True
yield
# Shutdown
if _scanner_active:
from mediahive.hivescan.scanner import stop as stop_scanner
await stop_scanner()
if _consumer_task:
_consumer_task.cancel()
await stop_scanner()
if _consumer_task:
_consumer_task.cancel()
await store.flush_snapshot()
@@ -174,19 +170,13 @@ async def ws_endpoint(ws: WebSocket):
@app.post("/api/scan")
async def trigger_scan(request: Request):
async def trigger_scan():
"""Trigger a new scan. Returns 409 if a scan is already running."""
if not _scanner_active:
raise HTTPException(status_code=503, detail="Scanner not configured")
raise HTTPException(status_code=503, detail="Scanner not active yet")
from mediahive.hivescan.scanner import trigger_scan as _trigger
body_bytes = await request.body()
req = (
msgspec.json.decode(body_bytes, type=ScanRequest)
if body_bytes
else ScanRequest()
)
started = _trigger(req.paths if req.paths else None)
started = _trigger()
return {"status": "started" if started else "already_running"}