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;
|
||||
|
||||
Reference in New Issue
Block a user