Improve scanning. Add Crime category taking it out of Action.
This commit is contained in:
+109
-20
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user