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;