major server manager overhaul

This commit is contained in:
2026-01-09 19:24:41 +00:00
parent 6a667d1019
commit c0ac650625
202 changed files with 325323 additions and 6929 deletions
+24
View File
@@ -8,6 +8,12 @@
<v-divider></v-divider>
<v-list density="compact" nav>
<v-list-item
prepend-icon="mdi-view-dashboard"
title="Dashboard"
to="/"
value="dashboard"
></v-list-item>
<v-list-item
prepend-icon="mdi-folder-network"
title="Samba Explorer"
@@ -32,6 +38,24 @@
to="/downloader"
value="downloader"
></v-list-item>
<v-list-item
prepend-icon="mdi-download-network"
title="qBittorrent"
to="/qbittorrent"
value="qbittorrent"
></v-list-item>
<v-list-item
prepend-icon="mdi-calendar-clock"
title="Scheduler"
to="/scheduler"
value="scheduler"
></v-list-item>
<v-list-item
prepend-icon="mdi-cog"
title="Settings"
to="/settings"
value="settings"
></v-list-item>
</v-list>
</v-navigation-drawer>
@@ -1,149 +1,536 @@
<template>
<v-container>
<v-row justify="center">
<v-col cols="12" md="8" lg="6">
<v-col cols="12" md="10" lg="8">
<v-card class="mb-4">
<v-toolbar color="primary" density="compact">
<v-toolbar-title>
<v-icon start>mdi-book-open-page-variant</v-icon>
Comics Management
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="fetchPending" :loading="isLoadingPending">
<v-icon>mdi-refresh</v-icon>
</v-btn>
</v-toolbar>
<v-card-text>
<p class="text-body-1 mb-4">
Manage your comic library. Organize files into folders and clean up empty directories.
Step 1: Process and group raw files in <b>isolation/comics/pendingorganization</b>.<br>
Step 2: Move organized folders to your library (default: <b>isolation/comics/manga</b>).
</p>
<v-alert
v-if="message"
type="info"
variant="tonal"
closable
class="mb-4"
@click:close="message = ''"
>
<v-alert v-if="message" type="info" variant="tonal" closable class="mb-4" @click:close="message = ''">
{{ message }}
</v-alert>
<v-alert
v-if="error"
type="error"
variant="tonal"
closable
class="mb-4"
@click:close="error = ''"
>
<v-alert v-if="error" type="error" variant="tonal" closable class="mb-4" @click:close="error = ''">
{{ error }}
</v-alert>
<div class="d-flex flex-column gap-3">
<div class="d-flex flex-column gap-3 mb-6">
<v-btn
color="primary"
size="large"
prepend-icon="mdi-folder-move"
prepend-icon="mdi-autorenew"
:loading="isOrganizing"
:disabled="isOrganizing || isCleaning"
:disabled="isOrganizing || isMoving || isUpdatingMetadata || isScanningDuplicates || isSorting"
@click="organizeComics"
block
>
{{ isOrganizing ? 'Organizing...' : 'Organize Comics' }}
</v-btn>
<v-btn
color="warning"
size="large"
prepend-icon="mdi-delete-empty"
:loading="isCleaning"
:disabled="isOrganizing || isCleaning"
@click="confirmCleanupDialog = true"
block
variant="outlined"
>
{{ isCleaning ? 'Cleaning...' : 'Cleanup Empty Folders' }}
1. Scan & Organize Staging Folder
</v-btn>
</div>
<v-divider class="mb-4"></v-divider>
<div v-if="pendingSeries.length > 0">
<h3 class="text-h6 mb-2">2. Review & Move Series</h3>
<v-row>
<v-col cols="4">
<v-select
v-model="destShare"
:items="['isolation', 'media']"
label="Target Share"
variant="outlined"
density="compact"
@update:model-value="updateDefaultPath"
></v-select>
</v-col>
<v-col cols="8">
<v-text-field
v-model="destPath"
label="Target Path"
hint="Manual edit allowed"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
</v-row>
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-subtitle-2">{{ selectedSeries.length }} selected</span>
<v-btn size="small" variant="text" @click="selectAll">Select All</v-btn>
</div>
<v-card variant="outlined" class="mb-4" style="max-height: 400px; overflow-y: auto;">
<v-list density="compact">
<v-list-item
v-for="series in pendingSeries"
:key="series.name"
@click="toggleSelection(series.name)"
>
<template v-slot:prepend>
<v-checkbox-btn :model-value="selectedSeries.includes(series.name)"></v-checkbox-btn>
</template>
<v-list-item-title>{{ series.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
<v-btn
color="success"
size="large"
prepend-icon="mdi-folder-move"
:loading="isMoving"
:disabled="selectedSeries.length === 0 || isMoving || isOrganizing || isUpdatingMetadata || isScanningDuplicates || isSorting"
@click="moveComics"
block
>
Move Selected to Library
</v-btn>
</div>
<div v-else-if="!isOrganizing && hasFetched && pendingSeries.length === 0" class="text-center py-6 text-grey">
<v-icon size="large" class="mb-2">mdi-folder-open-outline</v-icon>
<p>No organized series found in staging. Click "Organize" to start.</p>
</div>
<v-divider class="my-6"></v-divider>
<h3 class="text-h6 mb-2">3. Update Existing Metadata</h3>
<p class="text-body-2 mb-4">
Scan folders in <b>isolation/comics/manga</b> (or custom path) and add/update metadata files.
</p>
<v-row>
<v-col cols="12" sm="8">
<v-text-field
v-model="updatePath"
label="Library Path (on isolation share)"
hint="Default: /comics/manga"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
<v-col cols="12" sm="4">
<v-checkbox
v-model="forceUpdate"
label="Force Update (Overwrite)"
density="compact"
color="warning"
></v-checkbox>
</v-col>
</v-row>
<v-btn
color="secondary"
prepend-icon="mdi-tag-text-outline"
:loading="isUpdatingMetadata"
:disabled="isUpdatingMetadata || isMoving || isOrganizing || isScanningDuplicates || isSorting"
@click="updateMetadata"
block
>
Scan & Update Metadata
</v-btn>
<v-divider class="my-6"></v-divider>
<h3 class="text-h6 mb-2">4. Find Duplicate Folders</h3>
<p class="text-body-2 mb-4">
Find folders with similar names in your library.
</p>
<v-row>
<v-col cols="12" sm="8">
<v-text-field
v-model="dupScanPath"
label="Library Path (on isolation share)"
hint="Default: /comics/manga"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
<v-col cols="12" sm="4">
<v-slider
v-model="dupThreshold"
min="0.8"
max="1.0"
step="0.01"
label="Similarity"
thumb-label
></v-slider>
</v-col>
</v-row>
<v-btn
color="warning"
variant="outlined"
prepend-icon="mdi-content-duplicate"
:loading="isScanningDuplicates"
:disabled="isUpdatingMetadata || isMoving || isOrganizing || isScanningDuplicates || isSorting"
@click="scanDuplicates"
block
class="mb-4"
>
Scan for Duplicate Folders
</v-btn>
<div v-if="duplicateGroups.length > 0">
<v-alert type="warning" variant="tonal" class="mb-2">
Found {{ duplicateGroups.length }} groups of potential duplicates.
</v-alert>
<v-expansion-panels>
<v-expansion-panel v-for="(group, idx) in duplicateGroups" :key="idx">
<v-expansion-panel-title>
Group: {{ group.name }} ({{ group.folders.length }} items)
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list density="compact">
<v-list-item v-for="folder in group.folders" :key="folder.path">
<v-list-item-title>
{{ folder.name }}
<v-tooltip activator="parent" location="top">
{{ folder.name }}
</v-tooltip>
</v-list-item-title>
<v-list-item-subtitle>
{{ folder.path }}
<v-tooltip activator="parent" location="bottom">
{{ folder.path }}
</v-tooltip>
</v-list-item-subtitle>
<template v-slot:append>
<v-btn
icon="mdi-delete"
color="error"
variant="text"
size="small"
@click="deleteFolder(folder.path)"
></v-btn>
</template>
</v-list-item>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
<v-divider class="my-6"></v-divider>
<h3 class="text-h6 mb-2">5. Sort Library by Artist</h3>
<p class="text-body-2 mb-4">
Reorganize your library folders into subdirectories based on the Artist found in metadata.
</p>
<v-row>
<v-col cols="12">
<v-text-field
v-model="sortPath"
label="Library Path (on isolation share)"
hint="Default: /comics/manga"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
</v-row>
<v-btn
color="info"
prepend-icon="mdi-sort-variant"
:loading="isSorting"
:disabled="isUpdatingMetadata || isMoving || isOrganizing || isScanningDuplicates || isSorting"
@click="sortLibrary"
block
>
Sort by Artist
</v-btn>
<!-- LOG TERMINAL -->
<v-expand-transition>
<div v-if="showLogs" class="mt-6">
<div class="d-flex justify-space-between align-center mb-2">
<h3 class="text-h6">Live Activity Log</h3>
<v-btn size="small" variant="text" icon="mdi-close" @click="showLogs = false"></v-btn>
</div>
<v-card color="black" class="terminal-card">
<div class="terminal-content" ref="terminalOutput">
<div v-for="(line, index) in logs" :key="index" class="terminal-line">
{{ line }}
</div>
<div v-if="logs.length === 0" class="text-grey">Waiting for logs...</div>
</div>
</v-card>
</div>
</v-expand-transition>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Confirmation Dialog -->
<v-dialog v-model="confirmCleanupDialog" max-width="400">
<v-card>
<v-card-title class="text-h6">Confirm Cleanup</v-card-title>
<v-card-text>
This will delete empty folders in the 'toberead' path. Are you sure you want to continue?
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="confirmCleanupDialog = false">Cancel</v-btn>
<v-btn color="error" variant="text" @click="cleanupToberead">Confirm</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup>
import { ref } from 'vue';
import { ref, onMounted, nextTick } from 'vue';
const isOrganizing = ref(false);
const isMoving = ref(false);
const isLoadingPending = ref(false);
const isUpdatingMetadata = ref(false);
const isScanningDuplicates = ref(false);
const isSorting = ref(false);
const message = ref('');
const error = ref('');
const isCleaning = ref(false);
const confirmCleanupDialog = ref(false);
const pendingSeries = ref([]);
const selectedSeries = ref([]);
const destShare = ref('isolation');
const destPath = ref('/comics/manga');
const hasFetched = ref(false);
const updatePath = ref('/comics/manga');
const forceUpdate = ref(false);
const dupScanPath = ref('/comics/manga');
const dupThreshold = ref(0.9);
const duplicateGroups = ref([]);
const sortPath = ref('/comics/manga');
// Logging
const showLogs = ref(false);
const logs = ref([]);
const terminalOutput = ref(null);
let websocket = null;
const connectLogStream = () => {
if (websocket && (websocket.readyState === WebSocket.OPEN || websocket.readyState === WebSocket.CONNECTING)) return;
if (!websocket || websocket.readyState === WebSocket.CLOSED) {
logs.value = [];
}
showLogs.value = true;
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const host = window.location.host;
const wsUrl = `${protocol}://${host}/api/comics/ws/logs`;
console.log(`Connecting to WebSocket: ${wsUrl}`);
websocket = new WebSocket(wsUrl);
websocket.onopen = () => {
logs.value.push(">> Connected to log stream.");
};
websocket.onmessage = (event) => {
logs.value.push(event.data);
scrollToBottom();
};
websocket.onerror = (e) => {
console.error("WebSocket error:", e);
logs.value.push("!! Connection error. Ensure backend is running.");
};
websocket.onclose = (e) => {
logs.value.push(`>> Connection closed (${e.code}).`);
};
};
const scrollToBottom = async () => {
await nextTick();
if (terminalOutput.value) {
terminalOutput.value.scrollTop = terminalOutput.value.scrollHeight;
}
};
const updateDefaultPath = (share) => {
if (share === 'isolation') {
destPath.value = '/comics/manga';
} else if (share === 'media') {
destPath.value = '/comics';
}
};
const organizeComics = async () => {
isOrganizing.value = true;
message.value = 'Initiating comics organization... This may take a while.';
error.value = '';
connectLogStream();
message.value = 'Organizing started...';
try {
const response = await fetch('/api/comics/organize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (response.ok) {
const data = await response.json();
message.value = data.message || 'Comics organization process started successfully!';
} else {
const errorData = await response.json();
error.value = errorData.detail || `Failed to organize comics: ${response.status}`;
}
} catch (err) {
error.value = `Network error: ${err.message}`;
} finally {
isOrganizing.value = false;
}
await fetch('/api/comics/organize', { method: 'POST' });
setTimeout(fetchPending, 5000);
} catch (err) { error.value = err.message; }
finally { isOrganizing.value = false; }
};
const cleanupToberead = async () => {
confirmCleanupDialog.value = false;
isCleaning.value = true;
message.value = 'Initiating cleanup...';
error.value = '';
try {
const response = await fetch('/api/comics/cleanup_toberead', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
if (response.ok) {
const data = await response.json();
message.value = data.message || 'Cleanup process started successfully!';
} else {
const errorData = await response.json();
error.value = errorData.detail || `Failed to start cleanup: ${response.status}`;
const fetchPending = async () => {
isLoadingPending.value = true;
try {
const response = await fetch('/api/comics/pending');
if (response.ok) {
pendingSeries.value = await response.json();
} else {
const d = await response.json();
error.value = d.detail || `Failed to fetch pending series: ${response.status}`;
}
} catch (err) {
error.value = `Network error fetching pending series: ${err.message}`;
console.error(err);
}
} catch (err) {
error.value = `Network error: ${err.message}`;
} finally {
isCleaning.value = false;
}
finally { isLoadingPending.value = false; hasFetched.value = true; }
};
const toggleSelection = (name) => {
if (selectedSeries.value.includes(name)) {
selectedSeries.value = selectedSeries.value.filter(n => n !== name);
} else {
selectedSeries.value.push(name);
}
};
const selectAll = () => {
if (selectedSeries.value.length === pendingSeries.value.length) selectedSeries.value = [];
else selectedSeries.value = pendingSeries.value.map(s => s.name);
};
const moveComics = async () => {
isMoving.value = true;
connectLogStream();
message.value = 'Move started...';
try {
const response = await fetch('/api/comics/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
series_names: selectedSeries.value,
destination_share: destShare.value,
destination_path: destPath.value
})
});
if (response.ok) {
selectedSeries.value = [];
setTimeout(fetchPending, 5000);
} else {
const d = await response.json();
error.value = d.detail || "Move failed";
}
} catch (err) { error.value = err.message; }
finally { isMoving.value = false; }
};
const updateMetadata = async () => {
isUpdatingMetadata.value = true;
connectLogStream();
message.value = 'Metadata update started...';
try {
const response = await fetch('/api/comics/update_metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
target_path: updatePath.value,
force_update: forceUpdate.value
})
});
if (!response.ok) {
const d = await response.json();
error.value = d.detail || "Update failed";
}
} catch (err) {
error.value = err.message;
} finally {
isUpdatingMetadata.value = false;
}
};
const scanDuplicates = async () => {
isScanningDuplicates.value = true;
connectLogStream();
duplicateGroups.value = [];
message.value = 'Scanning for duplicate folders...';
try {
const response = await fetch('/api/comics/folders/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
root_path: dupScanPath.value,
threshold: dupThreshold.value
})
});
if (response.ok) {
duplicateGroups.value = await response.json();
if (duplicateGroups.value.length === 0) {
message.value = "No duplicates found.";
} else {
message.value = `Found ${duplicateGroups.value.length} duplicate groups.`;
}
} else {
error.value = "Scan failed.";
}
} catch (err) {
error.value = err.message;
} finally {
isScanningDuplicates.value = false;
}
};
const deleteFolder = async (path) => {
if (!confirm(`Are you sure you want to delete folder: ${path}? This cannot be undone.`)) return;
try {
const response = await fetch('/api/comics/folders/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: path })
});
if (response.ok) {
message.value = `Deleted ${path}`;
// Refresh scan
scanDuplicates();
} else {
error.value = "Failed to delete folder.";
}
} catch (err) {
error.value = err.message;
}
};
const sortLibrary = async () => {
if (!confirm(`This will move folders in '${sortPath.value}' into subdirectories by Artist. Continue?`)) return;
isSorting.value = true;
connectLogStream();
message.value = 'Sorting by artist started...';
try {
const response = await fetch('/api/comics/sort_by_artist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ root_path: sortPath.value })
});
if (!response.ok) {
const d = await response.json();
error.value = d.detail || "Sort failed";
}
} catch (err) {
error.value = err.message;
} finally {
isSorting.value = false;
}
};
onMounted(fetchPending);
</script>
<style scoped>
@@ -152,4 +539,21 @@ const cleanupToberead = async () => {
flex-direction: column;
gap: 12px;
}
.terminal-card {
height: 300px;
overflow: hidden;
}
.terminal-content {
height: 100%;
overflow-y: auto;
padding: 16px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.85rem;
color: #4af626; /* Matrix green */
white-space: pre-wrap;
word-break: break-all;
}
.terminal-line {
line-height: 1.4;
}
</style>
@@ -0,0 +1,130 @@
<template>
<v-container>
<h1 class="text-h4 mb-6">System Dashboard</h1>
<v-row>
<!-- CPU -->
<v-col cols="12" sm="6" md="3">
<v-card color="primary" variant="tonal">
<v-card-text>
<div class="text-overline mb-1">CPU Usage</div>
<div class="text-h4 font-weight-bold">{{ stats.cpu }}%</div>
<v-progress-linear
:model-value="stats.cpu"
color="primary"
height="8"
rounded
class="mt-2"
></v-progress-linear>
</v-card-text>
</v-card>
</v-col>
<!-- Memory -->
<v-col cols="12" sm="6" md="3">
<v-card color="success" variant="tonal">
<v-card-text>
<div class="text-overline mb-1">Memory</div>
<div class="text-h4 font-weight-bold">{{ stats.memory?.percent }}%</div>
<div class="text-caption text-medium-emphasis mt-1">
{{ stats.memory?.used }} / {{ stats.memory?.total }}
</div>
<v-progress-linear
:model-value="stats.memory?.percent"
color="success"
height="8"
rounded
class="mt-2"
></v-progress-linear>
</v-card-text>
</v-card>
</v-col>
<!-- Disk -->
<v-col cols="12" sm="6" md="3">
<v-card color="warning" variant="tonal">
<v-card-text>
<div class="text-overline mb-1">Disk (Root)</div>
<div class="text-h4 font-weight-bold">{{ stats.disk?.percent }}%</div>
<div class="text-caption text-medium-emphasis mt-1">
{{ stats.disk?.free }} free of {{ stats.disk?.total }}
</div>
<v-progress-linear
:model-value="stats.disk?.percent"
color="warning"
height="8"
rounded
class="mt-2"
></v-progress-linear>
</v-card-text>
</v-card>
</v-col>
<!-- Uptime -->
<v-col cols="12" sm="6" md="3">
<v-card color="info" variant="tonal">
<v-card-text>
<div class="text-overline mb-1">Uptime</div>
<div class="text-h5 font-weight-bold mt-1">{{ stats.uptime }}</div>
<div class="text-caption text-medium-emphasis mt-2">
System is running
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Job Manager & Quick Actions -->
<v-row class="mt-2">
<v-col cols="12" md="8">
<JobManagerWidget />
</v-col>
<v-col cols="12" md="4">
<v-card class="fill-height" variant="outlined">
<v-card-title>Quick Actions</v-card-title>
<v-divider></v-divider>
<v-list>
<v-list-item to="/comics" prepend-icon="mdi-book-open-page-variant" title="Manage Comics" link></v-list-item>
<v-list-item to="/qbittorrent" prepend-icon="mdi-download-network" title="Manage Torrents" link></v-list-item>
<v-list-item to="/samba" prepend-icon="mdi-folder-network" title="Browse Files" link></v-list-item>
</v-list>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import JobManagerWidget from './JobManagerWidget.vue';
const stats = ref({
cpu: 0,
memory: {},
disk: {},
uptime: '...'
});
let intervalId = null;
const fetchStats = async () => {
try {
const res = await fetch('/api/system/stats');
if (res.ok) {
stats.value = await res.json();
}
} catch (err) {
console.error("Failed to fetch stats", err);
}
};
onMounted(() => {
fetchStats();
intervalId = setInterval(fetchStats, 2000);
});
onUnmounted(() => {
if (intervalId) clearInterval(intervalId);
});
</script>
@@ -0,0 +1,84 @@
<template>
<v-card class="fill-height" variant="outlined">
<v-card-title class="d-flex align-center text-subtitle-1">
<v-icon start size="small">mdi-history</v-icon>
Job History
<v-spacer></v-spacer>
<v-btn icon size="small" variant="text" @click="fetchTasks" :loading="loading"><v-icon>mdi-refresh</v-icon></v-btn>
</v-card-title>
<v-divider></v-divider>
<v-list density="compact" lines="two" style="max-height: 300px; overflow-y: auto;">
<v-list-item v-for="task in tasks" :key="task.id">
<template v-slot:prepend>
<v-icon :color="getStatusColor(task.status)" size="small">
{{ getStatusIcon(task.status) }}
</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">{{ task.name }}</v-list-item-title>
<v-list-item-subtitle class="text-caption">
{{ formatDate(task.start_time) }}
</v-list-item-subtitle>
<v-list-item-subtitle v-if="task.details" class="text-caption text-medium-emphasis text-truncate">
{{ task.details }}
</v-list-item-subtitle>
</v-list-item>
<div v-if="tasks.length === 0 && !loading" class="text-center pa-4 text-caption text-grey">
No jobs recorded.
</div>
</v-list>
</v-card>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const tasks = ref([]);
const loading = ref(false);
let intervalId = null;
const fetchTasks = async () => {
loading.value = true;
try {
const res = await fetch('/api/tasks/');
if (res.ok) {
tasks.value = await res.json();
}
} catch (err) {
console.error("Failed to fetch tasks", err);
} finally {
loading.value = false;
}
};
const getStatusColor = (status) => {
switch(status) {
case 'success': return 'success';
case 'failed': return 'error';
case 'running': return 'info';
default: return 'grey';
}
};
const getStatusIcon = (status) => {
switch(status) {
case 'success': return 'mdi-check-circle';
case 'failed': return 'mdi-alert-circle';
case 'running': return 'mdi-progress-clock';
default: return 'mdi-help-circle';
}
};
const formatDate = (dateStr) => {
if (!dateStr) return '';
return new Date(dateStr).toLocaleString();
};
onMounted(() => {
fetchTasks();
intervalId = setInterval(fetchTasks, 5000);
});
onUnmounted(() => {
if (intervalId) clearInterval(intervalId);
});
</script>
@@ -0,0 +1,338 @@
<template>
<v-container fluid>
<v-card>
<v-toolbar color="primary" density="compact">
<v-toolbar-title>
<v-icon start>mdi-download-network</v-icon>
qBittorrent
</v-toolbar-title>
<v-spacer></v-spacer>
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search Name"
single-line
hide-details
density="compact"
class="mr-2"
style="max-width: 150px"
></v-text-field>
<v-text-field
v-model="pathFilter"
prepend-inner-icon="mdi-folder-search"
label="Filter Path"
single-line
hide-details
density="compact"
class="mr-2"
style="max-width: 150px"
></v-text-field>
<v-btn icon @click="fetchTorrents" :loading="loading">
<v-icon>mdi-refresh</v-icon>
</v-btn>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn icon="mdi-dots-vertical" v-bind="props"></v-btn>
</template>
<v-list>
<v-list-item @click="selectAll" prepend-icon="mdi-checkbox-marked-circle-outline">
<v-list-item-title>Select All</v-list-item-title>
</v-list-item>
<v-list-item @click="selectedHashes = []" prepend-icon="mdi-checkbox-blank-circle-outline" :disabled="selectedHashes.length === 0">
<v-list-item-title>Deselect All</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-toolbar>
<v-alert v-if="error" type="error" closable class="mb-0" @click:close="error = ''">{{ error }}</v-alert>
<div v-for="(group, groupName) in groupedTorrents" :key="groupName">
<v-list-subheader v-if="group.length > 0" class="text-uppercase font-weight-bold text-primary bg-grey-lighten-4">
{{ groupName }} ({{ group.length }})
</v-list-subheader>
<v-list lines="three" select-strategy="leaf" v-if="group.length > 0">
<v-list-item
v-for="torrent in group"
:key="torrent.hash"
:value="torrent.hash"
@click="toggleSelection(torrent.hash)"
>
<template v-slot:prepend>
<v-checkbox-btn :model-value="selectedHashes.includes(torrent.hash)"></v-checkbox-btn>
</template>
<v-list-item-title class="font-weight-bold">
{{ torrent.name }}
</v-list-item-title>
<v-list-item-subtitle>
<div class="d-flex align-center mt-1">
<v-chip size="x-small" :color="getStatusColor(torrent.state)" class="mr-2">
{{ torrent.state }}
</v-chip>
<span class="text-caption">
{{ (torrent.progress * 100).toFixed(1) }}% -
{{ formatBytes(torrent.size) }}
</span>
</div>
</v-list-item-subtitle>
<v-list-item-subtitle class="mt-1 text-caption text-grey">
<v-icon size="small" start>mdi-folder</v-icon>
{{ torrent.save_path }}
</v-list-item-subtitle>
<template v-slot:append>
<div class="text-caption text-right">
<div>DL: {{ formatSpeed(torrent.dlspeed) }}</div>
<div>UL: {{ formatSpeed(torrent.upspeed) }}</div>
</div>
</template>
</v-list-item>
</v-list>
</div>
<div v-if="filteredTorrents.length === 0 && !loading" class="text-center pa-4 text-grey">
No torrents found.
</div>
</v-card>
<!-- Spacer for bottom nav -->
<div style="height: 60px" v-if="selectedHashes.length > 0"></div>
<!-- Bottom Action Bar -->
<v-bottom-navigation v-if="selectedHashes.length > 0" grow color="primary" app>
<v-btn @click="resumeSelected">
<v-icon>mdi-play</v-icon>
<span>Resume</span>
</v-btn>
<v-btn @click="pauseSelected">
<v-icon>mdi-pause</v-icon>
<span>Pause</span>
</v-btn>
<v-btn @click="openMoveDialog">
<v-icon>mdi-folder-move</v-icon>
<span>Move</span>
</v-btn>
<v-btn @click="recheckSelected">
<v-icon>mdi-refresh</v-icon>
<span>Recheck</span>
</v-btn>
<v-btn @click="confirmDeleteDialog = true">
<v-icon color="error">mdi-delete</v-icon>
<span class="text-error">Delete</span>
</v-btn>
<v-btn @click="selectedHashes = []">
<v-icon>mdi-close</v-icon>
<span>Clear</span>
</v-btn>
</v-bottom-navigation>
<!-- Location Dialog -->
<v-dialog v-model="showLocationDialog" max-width="500">
<v-card>
<v-card-title>Change Save Path</v-card-title>
<v-card-text>
<v-text-field
v-model="newLocation"
label="New Path"
hint="Absolute path on server"
persistent-hint
:rules="[v => !!v || 'Path is required']"
></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="showLocationDialog = false">Cancel</v-btn>
<v-btn color="primary" @click="changeLocation">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="confirmDeleteDialog" max-width="400">
<v-card>
<v-card-title class="text-error">Confirm Delete</v-card-title>
<v-card-text>
Are you sure you want to delete {{ selectedHashes.length }} torrent(s)?
<v-checkbox
v-model="deleteFiles"
label="Delete downloaded files"
color="error"
density="compact"
></v-checkbox>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="confirmDeleteDialog = false">Cancel</v-btn>
<v-btn color="error" @click="deleteSelected">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
const torrents = ref([]);
const loading = ref(false);
const error = ref('');
const search = ref('');
const pathFilter = ref('');
const selectedHashes = ref([]);
const showLocationDialog = ref(false);
const newLocation = ref('');
const confirmDeleteDialog = ref(false);
const deleteFiles = ref(false);
const filteredTorrents = computed(() => {
return torrents.value.filter(t => {
const nameMatch = !search.value || t.name.toLowerCase().includes(search.value.toLowerCase());
const pathMatch = !pathFilter.value || t.save_path.toLowerCase().includes(pathFilter.value.toLowerCase());
return nameMatch && pathMatch;
});
});
const groupedTorrents = computed(() => {
const groups = {
'Error': [],
'Downloading': [],
'Seeding': [],
'Completed': [],
'Paused': [],
'Other': []
};
filteredTorrents.value.forEach(t => {
const s = t.state;
if (s.includes('error') || s.includes('missing')) {
groups['Error'].push(t);
} else if (s.includes('downloading') || s.includes('stalledDL') || s.includes('metaDL') || s.includes('allocating') || s.includes('queuedDL') || s.includes('checkingDL')) {
groups['Downloading'].push(t);
} else if (s.includes('uploading') || s.includes('stalledUP') || s.includes('queuedUP') || s.includes('checkingUP') || s.includes('forcedUP')) {
groups['Seeding'].push(t);
} else if (s.includes('pausedUP') || (t.progress === 1 && s.includes('paused'))) {
groups['Completed'].push(t);
} else if (s.includes('paused')) {
groups['Paused'].push(t);
} else {
groups['Other'].push(t);
}
});
return groups;
});
const formatBytes = (bytes, decimals = 2) => {
if (!+bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
};
const formatSpeed = (bytes) => {
return `${formatBytes(bytes)}/s`;
};
const getStatusColor = (state) => {
if (state.includes('downloading')) return 'info';
if (state.includes('paused')) return 'grey';
if (state.includes('error')) return 'error';
if (state.includes('uploading') || state.includes('stalledUP')) return 'success';
return 'primary';
};
const fetchTorrents = async () => {
loading.value = true;
try {
const res = await fetch('/api/qbittorrent/');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
torrents.value = await res.json();
} catch (err) {
error.value = `Failed to fetch torrents: ${err.message}`;
} finally {
loading.value = false;
}
};
const toggleSelection = (hash) => {
if (selectedHashes.value.includes(hash)) {
selectedHashes.value = selectedHashes.value.filter(h => h !== hash);
} else {
selectedHashes.value.push(hash);
}
};
const selectAll = () => {
if (selectedHashes.value.length === filteredTorrents.value.length) {
selectedHashes.value = [];
} else {
selectedHashes.value = filteredTorrents.value.map(t => t.hash);
}
};
const performAction = async (endpoint, payload = {}) => {
loading.value = true;
try {
const res = await fetch(`/api/qbittorrent/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hashes: selectedHashes.value, ...payload })
});
if (!res.ok) throw new Error((await res.json()).detail || 'Action failed');
await fetchTorrents();
selectedHashes.value = [];
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
const pauseSelected = () => performAction('pause');
const resumeSelected = () => performAction('resume');
const recheckSelected = () => performAction('recheck');
const openMoveDialog = () => {
newLocation.value = '/isolation/videos/';
showLocationDialog.value = true;
};
const changeLocation = async () => {
if (!newLocation.value) return;
await performAction('location', { location: newLocation.value });
showLocationDialog.value = false;
newLocation.value = '';
};
const deleteSelected = async () => {
await performAction('delete', { delete_files: deleteFiles.value });
confirmDeleteDialog.value = false;
deleteFiles.value = false;
};
onMounted(() => {
fetchTorrents();
// Auto-refresh every 5 seconds
setInterval(fetchTorrents, 5000);
});
</script>
<style scoped>
.text-error {
color: rgb(var(--v-theme-error));
}
</style>
@@ -1,420 +1,494 @@
<template>
<v-container fluid>
<v-card class="mb-4">
<v-toolbar color="surface" density="compact">
<v-toolbar-title class="text-h6">
<v-icon start>mdi-folder-network</v-icon>
Samba Explorer
</v-toolbar-title>
<v-spacer></v-spacer>
<div class="d-flex align-center px-4" style="min-width: 200px;">
<v-select
v-model="shareName"
:items="availableShares"
label="Share"
hide-details
density="compact"
variant="outlined"
@update:model-value="resetAndFetch"
prepend-inner-icon="mdi-server"
></v-select>
</div>
<v-divider vertical class="mx-2"></v-divider>
<v-btn icon="mdi-upload" variant="text" @click="uploadDialog = true" title="Upload File"></v-btn>
<v-btn icon="mdi-content-paste" variant="text" @click="pasteItem" :disabled="!clipboard" title="Paste"></v-btn>
<v-chip v-if="clipboard" size="small" color="primary" closable @click:close="clipboard = null" class="ml-2">
{{ clipboard.action === 'cut' ? 'Cut' : 'Copy' }}: {{ clipboard.name }}
</v-chip>
</v-toolbar>
<v-container fluid class="fill-height flex-column pa-0" @dragover.prevent @drop.prevent>
<!-- Toolbar -->
<v-toolbar color="surface" density="compact" class="border-b">
<v-toolbar-title class="text-h6">
<v-icon start>mdi-folder-network</v-icon>
Files
</v-toolbar-title>
<v-spacer></v-spacer>
<v-text-field
v-model="searchQuery"
prepend-inner-icon="mdi-magnify"
label="Search"
single-line
hide-details
density="compact"
class="mr-2"
style="max-width: 200px"
@keyup.enter="performSearch"
></v-text-field>
<v-divider></v-divider>
<div class="d-flex align-center px-2" style="min-width: 150px;">
<v-select
v-model="shareName"
:items="availableShares"
label="Share"
hide-details
density="compact"
variant="outlined"
@update:model-value="resetAndFetch"
prepend-inner-icon="mdi-server"
></v-select>
</div>
<v-divider vertical class="mx-2"></v-divider>
<v-btn-toggle v-model="viewMode" density="compact" color="primary" mandatory>
<v-btn value="list" icon="mdi-view-list"></v-btn>
<v-btn value="grid" icon="mdi-view-grid"></v-btn>
</v-btn-toggle>
<v-btn icon="mdi-refresh" @click="fetchFiles" :loading="loading"></v-btn>
</v-toolbar>
<v-card-text class="py-2">
<div class="d-flex align-center">
<v-btn icon="mdi-arrow-up" variant="text" @click="goBack" :disabled="currentPath === '/'"></v-btn>
<v-breadcrumbs :items="breadcrumbs" density="compact">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
<template v-slot:item="{ item }">
<v-breadcrumbs-item
:title="item.title"
:disabled="item.disabled"
@click="!item.disabled && navigateToBreadcrumb(item.path)"
:class="{'cursor-pointer text-primary': !item.disabled}"
></v-breadcrumbs-item>
</template>
</v-breadcrumbs>
</div>
</v-card-text>
</v-card>
<!-- Breadcrumbs -->
<div class="d-flex align-center px-4 py-2 bg-grey-lighten-4 w-100 border-b">
<v-btn icon="mdi-arrow-up" variant="text" density="compact" @click="goBack" :disabled="currentPath === '/'"></v-btn>
<v-breadcrumbs :items="breadcrumbs" density="compact" class="pa-0 ml-2">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right" size="small"></v-icon>
</template>
<template v-slot:item="{ item }">
<v-breadcrumbs-item
:title="item.title"
:disabled="item.disabled"
@click="!item.disabled && navigateToBreadcrumb(item.path)"
class="cursor-pointer text-body-2"
:class="{'text-primary font-weight-bold': !item.disabled}"
></v-breadcrumbs-item>
</template>
</v-breadcrumbs>
</div>
<v-card :loading="loading">
<template v-slot:loader="{ isActive }">
<v-progress-linear
:active="isActive"
color="primary"
height="4"
indeterminate
></v-progress-linear>
</template>
<!-- Drop Zone & File Area -->
<div
class="flex-grow-1 w-100 position-relative"
@dragover.prevent="dragOver"
@dragleave="dragLeave"
@drop="handleDrop"
:class="{'bg-blue-lighten-5': isDragging}"
>
<!-- Loading Overlay -->
<v-overlay :model-value="loading" contained class="align-center justify-center">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</v-overlay>
<!-- Empty State -->
<div v-if="sortedFiles.length === 0 && !loading" class="d-flex flex-column align-center justify-center h-100 text-grey">
<v-icon size="64" class="mb-4">mdi-folder-open-outline</v-icon>
<div class="text-h6">Folder is empty</div>
<div class="text-caption">Drag and drop files here to upload</div>
</div>
<!-- Grid View -->
<v-container v-if="viewMode === 'grid' && sortedFiles.length > 0" fluid class="pa-4">
<v-row dense>
<v-col
v-for="item in sortedFiles"
:key="item.path"
cols="6" sm="4" md="3" lg="2" xl="1"
>
<v-card
flat
class="d-flex flex-column align-center pa-4 cursor-pointer hover-card file-item"
@click="handleItemClick(item)"
@contextmenu.prevent="showContextMenu($event, item)"
draggable="true"
@dragstart="startDragItem($event, item)"
@drop.stop="handleDropOnItem($event, item)"
@dragover.prevent
>
<v-icon size="64" :color="item.is_directory ? 'amber' : 'blue-grey'">
{{ getIcon(item) }}
</v-icon>
<div class="text-caption text-truncate w-100 text-center mt-2">{{ item.name }}</div>
</v-card>
</v-col>
</v-row>
</v-container>
<!-- List View -->
<v-data-table
v-if="viewMode === 'list' && sortedFiles.length > 0"
:headers="headers"
:items="files"
:items-per-page="50"
:items="sortedFiles"
:items-per-page="-1"
hide-default-footer
density="compact"
hover
@click:row="handleRowClick"
class="h-100"
>
<!-- Icon Column -->
<template v-slot:item.icon="{ item }">
<v-icon :color="item.is_directory ? 'amber' : 'blue'">
{{ item.is_directory ? 'mdi-folder' : 'mdi-file' }}
</v-icon>
</template>
<!-- Name Column -->
<template v-slot:item.name="{ item }">
<span class="font-weight-medium">{{ item.name }}</span>
</template>
<!-- Actions Column -->
<template v-slot:item.actions="{ item }">
<div class="d-flex justify-end">
<v-btn
v-if="!item.is_directory"
icon="mdi-eye"
variant="text"
size="small"
color="info"
@click.stop="viewFile(item.path)"
title="View"
></v-btn>
<v-btn
v-if="!item.is_directory"
icon="mdi-download"
variant="text"
size="small"
color="success"
@click.stop="downloadFile(item.path)"
title="Download"
></v-btn>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn icon="mdi-dots-vertical" variant="text" size="small" v-bind="props" @click.stop></v-btn>
</template>
<v-list density="compact">
<v-list-item @click="renameItem(item.path, item.name)">
<template v-slot:prepend>
<v-icon icon="mdi-pencil" size="small"></v-icon>
</template>
<v-list-item-title>Rename</v-list-item-title>
</v-list-item>
<v-list-item @click="cutItem(item)">
<template v-slot:prepend>
<v-icon icon="mdi-content-cut" size="small"></v-icon>
</template>
<v-list-item-title>Cut</v-list-item-title>
</v-list-item>
<v-list-item @click="copyItem(item)">
<template v-slot:prepend>
<v-icon icon="mdi-content-copy" size="small"></v-icon>
</template>
<v-list-item-title>Copy</v-list-item-title>
</v-list-item>
<v-list-item @click="deleteItem(item.path)" color="error">
<template v-slot:prepend>
<v-icon icon="mdi-delete" size="small" color="error"></v-icon>
</template>
<v-list-item-title class="text-error">Delete</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<template v-slot:item="{ item }">
<tr
@click="handleItemClick(item)"
@contextmenu.prevent="showContextMenu($event, item)"
class="cursor-pointer file-row"
draggable="true"
@dragstart="startDragItem($event, item)"
@drop.stop="handleDropOnItem($event, item)"
@dragover.prevent
>
<td>
<v-icon :color="item.is_directory ? 'amber' : 'blue-grey'">{{ getIcon(item) }}</v-icon>
</td>
<td>{{ item.name }}</td>
<td>{{ !item.is_directory ? formatBytes(item.size) : '--' }}</td>
<td>{{ formatDate(item.last_modified) }}</td>
</tr>
</template>
</v-data-table>
<!-- Context Menu -->
<v-menu
v-model="contextMenu.show"
:style="{ position: 'absolute', top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
absolute
min-width="150"
>
<v-list density="compact">
<v-list-item @click="openItem(contextMenu.item)">
<template v-slot:prepend><v-icon icon="mdi-open-in-new" size="small"></v-icon></template>
<v-list-item-title>Open</v-list-item-title>
</v-list-item>
<v-list-item v-if="!contextMenu.item?.is_directory" @click="downloadFile(contextMenu.item.path)">
<template v-slot:prepend><v-icon icon="mdi-download" size="small"></v-icon></template>
<v-list-item-title>Download</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<v-list-item @click="renameItem(contextMenu.item)">
<template v-slot:prepend><v-icon icon="mdi-pencil" size="small"></v-icon></template>
<v-list-item-title>Rename</v-list-item-title>
</v-list-item>
<v-list-item @click="deleteItem(contextMenu.item)" color="error">
<template v-slot:prepend><v-icon icon="mdi-delete" size="small" color="error"></v-icon></template>
<v-list-item-title class="text-error">Delete</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<!-- Upload Progress Fixed Bottom -->
<v-card v-if="uploadQueue.length > 0" class="position-fixed bottom-0 right-0 ma-4 pa-4" width="300" elevation="8" style="z-index: 1000">
<div class="d-flex justify-space-between mb-2">
<span class="text-subtitle-2">Uploading {{ uploadQueue.length }} files...</span>
<v-icon v-if="!uploading" icon="mdi-check-circle" color="success"></v-icon>
<v-progress-circular v-else indeterminate size="20" width="2" color="primary"></v-progress-circular>
</div>
<div v-for="file in uploadQueue" :key="file.name" class="mb-2">
<div class="d-flex justify-space-between text-caption">
<span class="text-truncate" style="max-width: 70%">{{ file.name }}</span>
<span>{{ file.progress }}%</span>
</div>
<v-progress-linear :model-value="file.progress" height="4" rounded :color="file.status === 'error' ? 'error' : 'primary'"></v-progress-linear>
</div>
</v-card>
<!-- Upload Fab removed (moved to toolbar) -->
<!-- Upload Dialog -->
<v-dialog v-model="uploadDialog" max-width="500">
<v-card>
<v-card-title>Upload File</v-card-title>
<v-card-text>
<v-file-input
v-model="fileToUpload"
label="Select File"
prepend-icon="mdi-paperclip"
variant="outlined"
></v-file-input>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="uploadDialog = false">Cancel</v-btn>
<v-btn color="primary" variant="text" @click="uploadFile" :disabled="!fileToUpload">Upload</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Viewer Component -->
<FileViewer
v-if="fileToViewPath"
v-model="viewerDialog"
:file-path="fileToViewPath"
:share-name="shareName"
@close="closeViewer"
/>
<!-- Global Snackbar -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3000">
{{ snackbar.text }}
<template v-slot:actions>
<v-btn variant="text" @click="snackbar.show = false">Close</v-btn>
</template>
</v-snackbar>
<!-- Dialogs -->
<FileViewer v-if="fileToViewPath" v-model="viewerDialog" :file-path="fileToViewPath" :share-name="shareName" @close="closeViewer" />
<v-snackbar v-model="snackbar.show" :color="snackbar.color">{{ snackbar.text }}</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue';
import { ref, onMounted, watch, computed, nextTick } from 'vue';
import FileViewer from './FileViewer.vue';
const availableShares = ref(['media', 'isolation']);
const shareName = ref(availableShares.value[0]);
const shareName = ref('media');
const currentPath = ref('/');
const files = ref([]);
const loading = ref(false);
const searchQuery = ref('');
const viewMode = ref('list'); // 'list' or 'grid'
// Upload
const uploadDialog = ref(false);
const fileToUpload = ref(null);
// UI State
const isDragging = ref(false);
const contextMenu = ref({ show: false, x: 0, y: 0, item: null });
const snackbar = ref({ show: false, text: '', color: 'info' });
// Uploads
const uploadQueue = ref([]); // { name, progress, status }
const uploading = ref(false);
// Viewer
const viewerDialog = ref(false);
const fileToViewPath = ref(null);
// Clipboard
const clipboard = ref(null); // { path: str, name: str, action: 'cut'|'copy' }
// Feedback
const snackbar = ref({ show: false, text: '', color: 'info' });
const headers = [
{ title: '', key: 'icon', sortable: false, width: '40px' },
{ title: '', key: 'icon', width: '40px' },
{ title: 'Name', key: 'name' },
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' },
{ title: 'Size', key: 'size' },
{ title: 'Modified', key: 'last_modified' }
];
const sortedFiles = computed(() => {
// Basic sort: Folders first, then files
return [...files.value].sort((a, b) => {
if (a.is_directory && !b.is_directory) return -1;
if (!a.is_directory && b.is_directory) return 1;
return a.name.localeCompare(b.name);
});
});
const breadcrumbs = computed(() => {
const parts = currentPath.value.split('/').filter(Boolean);
const items = [{ title: 'Root', disabled: currentPath.value === '/', path: '/' }];
let accumulatedPath = '';
let acc = '';
parts.forEach((part, index) => {
accumulatedPath += `/${part}`;
items.push({
title: part,
disabled: index === parts.length - 1,
path: accumulatedPath
});
acc += `/${part}`;
items.push({ title: part, disabled: index === parts.length - 1, path: acc });
});
return items;
});
const showMsg = (text, color = 'success') => {
snackbar.value = { show: true, text, color };
const getIcon = (item) => {
if (item.is_directory) return 'mdi-folder';
const ext = item.name.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) return 'mdi-file-image';
if (['mp4', 'mkv', 'avi', 'mov'].includes(ext)) return 'mdi-file-video';
if (['pdf', 'txt', 'md'].includes(ext)) return 'mdi-file-document';
if (['zip', 'rar', '7z', 'cbz'].includes(ext)) return 'mdi-zip-box';
return 'mdi-file';
};
const showMsg = (text, color = 'success') => { snackbar.value = { show: true, text, color }; };
const fetchFiles = async () => {
loading.value = true;
try {
const response = await fetch(`/api/samba/list/${shareName.value}?path=${currentPath.value}`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
files.value = await response.json();
} catch (error) {
showMsg('Error fetching files: ' + error.message, 'error');
showMsg(error.message, 'error');
files.value = [];
} finally {
loading.value = false;
}
};
const resetAndFetch = () => {
currentPath.value = '/';
fetchFiles();
};
const navigateToBreadcrumb = (path) => {
currentPath.value = path;
fetchFiles();
};
const handleRowClick = (event, { item }) => {
if (item.is_directory) {
currentPath.value = item.path;
fetchFiles();
} else {
viewFile(item.path);
}
const performSearch = async () => {
if (!searchQuery.value) return fetchFiles();
loading.value = true;
try {
const response = await fetch(`/api/samba/search/${shareName.value}?query=${searchQuery.value}&path=${currentPath.value}`);
if (response.ok) files.value = await response.json();
} catch (e) { showMsg(e.message, 'error'); }
finally { loading.value = false; }
};
const resetAndFetch = () => { currentPath.value = '/'; fetchFiles(); };
const navigateToBreadcrumb = (path) => { currentPath.value = path; fetchFiles(); };
const goBack = () => {
const parts = currentPath.value.split('/').filter(Boolean);
if (parts.length > 0) {
currentPath.value = '/' + parts.slice(0, -1).join('/') + (parts.length > 1 ? '/' : '');
} else {
currentPath.value = '/';
}
currentPath.value = parts.length > 0 ? '/' + parts.slice(0, -1).join('/') + (parts.length > 1 ? '/' : '') : '/';
fetchFiles();
};
const viewFile = (filePath) => {
fileToViewPath.value = filePath;
viewerDialog.value = true;
const handleItemClick = (item) => {
if (item.is_directory) {
currentPath.value = item.path;
fetchFiles();
} else {
fileToViewPath.value = item.path;
viewerDialog.value = true;
}
};
const openItem = (item) => handleItemClick(item);
// Drag & Drop
const dragOver = (e) => { isDragging.value = true; };
const dragLeave = (e) => { isDragging.value = false; };
const handleDrop = async (e) => {
isDragging.value = false;
const droppedFiles = e.dataTransfer.files;
if (droppedFiles.length > 0) {
// Upload
for (let i = 0; i < droppedFiles.length; i++) {
addToUploadQueue(droppedFiles[i]);
}
processUploadQueue();
}
};
// Item Dragging (Move)
const startDragItem = (e, item) => {
e.dataTransfer.setData('application/json', JSON.stringify(item));
e.dataTransfer.effectAllowed = 'move';
};
const handleDropOnItem = async (e, targetItem) => {
if (!targetItem.is_directory) return;
try {
const data = e.dataTransfer.getData('application/json');
if (!data) return; // Likely a file upload drop, handled by container
const draggedItem = JSON.parse(data);
if (draggedItem.path === targetItem.path) return;
if (confirm(`Move '${draggedItem.name}' into '${targetItem.name}'?`)) {
await moveItem(draggedItem, targetItem.path);
}
} catch (err) {
console.error(err);
}
};
const moveItem = async (item, targetDir) => {
const newPath = `${targetDir}/${item.name}`;
try {
const res = await fetch(`/api/samba/files/rename/${shareName.value}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ old_path: item.path, new_path: newPath })
});
if (res.ok) {
showMsg('Moved successfully');
fetchFiles();
} else {
showMsg('Move failed', 'error');
}
} catch (e) {
showMsg(e.message, 'error');
}
};
// Upload Logic
const addToUploadQueue = (file) => {
uploadQueue.value.push({
file,
name: file.name,
progress: 0,
status: 'pending'
});
};
const processUploadQueue = async () => {
if (uploading.value) return;
uploading.value = true;
while (uploadQueue.value.some(f => f.status === 'pending')) {
const item = uploadQueue.value.find(f => f.status === 'pending');
if (!item) break;
item.status = 'uploading';
try {
await uploadFileXHR(item);
item.status = 'completed';
} catch (e) {
item.status = 'error';
}
}
uploading.value = false;
setTimeout(() => { uploadQueue.value = []; }, 3000); // Clear after delay
fetchFiles();
};
const uploadFileXHR = (queueItem) => {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', queueItem.file);
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/samba/files/upload/${shareName.value}?path=${currentPath.value}`);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
queueItem.progress = (e.loaded / e.total) * 100;
}
};
xhr.onload = () => {
if (xhr.status === 200) resolve();
else reject(new Error('Upload failed'));
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(formData);
});
};
// Context Menu
const showContextMenu = (e, item) => {
e.preventDefault();
contextMenu.value.show = false; // Reset to trigger reactivity
nextTick(() => {
contextMenu.value.show = true;
contextMenu.value.x = e.clientX;
contextMenu.value.y = e.clientY;
contextMenu.value.item = item;
});
};
const renameItem = async (item) => {
const newName = prompt("New name:", item.name);
if (!newName || newName === item.name) return;
const parentDir = item.path.substring(0, item.path.lastIndexOf('/'));
const newPath = `${parentDir}/${newName}`;
try {
const res = await fetch(`/api/samba/files/rename/${shareName.value}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ old_path: item.path, new_path: newPath })
});
if (res.ok) {
showMsg('Renamed');
fetchFiles();
}
} catch (e) { showMsg(e.message, 'error'); }
};
const deleteItem = async (item) => {
if (!confirm(`Delete ${item.name}?`)) return;
try {
await fetch(`/api/samba/files/${shareName.value}?path=${item.path}`, { method: 'DELETE' });
showMsg('Deleted');
fetchFiles();
} catch (e) { showMsg(e.message, 'error'); }
};
const downloadFile = (path) => {
window.open(`/api/samba/files/download/${shareName.value}?path=${path}`, '_blank');
};
const closeViewer = () => {
viewerDialog.value = false;
setTimeout(() => { fileToViewPath.value = null; }, 300); // clear after animation
viewerDialog.value = false;
setTimeout(() => { fileToViewPath.value = null; }, 300);
};
const downloadFile = async (filePath) => {
try {
const response = await fetch(`/api/samba/files/download/${shareName.value}?path=${filePath}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filePath.split('/').pop();
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
showMsg('Download started');
} catch (error) {
showMsg('Error downloading file: ' + error.message, 'error');
}
const formatBytes = (bytes) => {
if (!+bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
const deleteItem = async (itemPath) => {
if (!confirm(`Are you sure you want to delete ${itemPath}?`)) return;
try {
const response = await fetch(`/api/samba/files/${shareName.value}?path=${itemPath}`, {
method: 'DELETE',
});
if (response.ok) {
showMsg('Item deleted');
fetchFiles();
} else {
showMsg('Error deleting item', 'error');
}
} catch (error) {
showMsg('Error deleting item: ' + error.message, 'error');
}
const formatDate = (ts) => {
if (!ts) return '';
return new Date(ts * 1000).toLocaleString();
};
const renameItem = async (oldPath, oldName, newPathArg = null) => {
let newPath = newPathArg;
if (!newPath) {
const newName = prompt(`Enter new name for ${oldName}:`, oldName);
if (!newName || newName === oldName) return;
newPath = currentPath.value === '/' ? `/${newName}` : `${currentPath.value.replace(/\/$/, '')}/${newName}`;
}
try {
const response = await fetch(`/api/samba/files/rename/${shareName.value}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ old_path: oldPath, new_path: newPath }),
});
if (response.ok) {
showMsg('Renamed successfully');
fetchFiles();
} else {
showMsg('Error renaming item', 'error');
}
} catch (error) {
showMsg('Error renaming item: ' + error.message, 'error');
}
};
const cutItem = (item) => {
clipboard.value = { path: item.path, name: item.name, action: 'cut' };
showMsg(`Cut ${item.name}`);
};
const copyItem = (item) => {
clipboard.value = { path: item.path, name: item.name, action: 'copy' };
showMsg(`Copied ${item.name} (Note: Copy may be slow as it downloads/uploads)`);
};
const pasteItem = async () => {
if (!clipboard.value) return;
const destDir = currentPath.value === '/' ? '' : currentPath.value.replace(/\/$/, '');
const destPath = `${destDir}/${clipboard.value.name}`;
if (clipboard.value.action === 'cut') {
// Rename (Move)
await renameItem(clipboard.value.path, clipboard.value.name, destPath);
clipboard.value = null; // Clear after cut-paste
} else {
// Copy - Not implemented natively in backend yet, handled as Move for now or Warning?
// User requirement said "add buttons...". Implementing Copy properly requires backend support.
// For now, we will show a message if it's not supported, or try rename if same share?
// Rename is Move. Copy needs Read+Write.
// I'll show error for now or implement backend copy.
showMsg('Copy not fully implemented in backend yet. Use Cut/Move.', 'warning');
}
};
const uploadFile = async () => {
if (!fileToUpload.value) return;
const formData = new FormData();
formData.append('file', fileToUpload.value);
loading.value = true;
try {
const response = await fetch(`/api/samba/files/upload/${shareName.value}?path=${currentPath.value}`, {
method: 'POST',
body: formData,
});
if (response.ok) {
showMsg('File uploaded successfully');
fileToUpload.value = null;
uploadDialog.value = false;
fetchFiles();
} else {
showMsg('Error uploading file', 'error');
}
} catch (error) {
showMsg('Error uploading file: ' + error.message, 'error');
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchFiles();
});
watch(shareName, () => {
resetAndFetch();
});
onMounted(fetchFiles);
watch(shareName, fetchFiles);
</script>
<style scoped>
.cursor-pointer {
cursor: pointer;
.hover-card:hover {
background-color: rgba(var(--v-theme-primary), 0.05);
}
</style>
.file-item {
transition: background-color 0.2s;
}
.file-row:hover {
background-color: rgba(0,0,0,0.03);
}
</style>
@@ -0,0 +1,177 @@
<template>
<v-container>
<v-card>
<v-toolbar color="primary" density="compact">
<v-toolbar-title>Task Scheduler</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="fetchJobs" :loading="loading"><v-icon>mdi-refresh</v-icon></v-btn>
</v-toolbar>
<v-card-text>
<!-- Add Job Form -->
<v-form ref="form" @submit.prevent="addJob">
<v-row>
<v-col cols="12" md="4">
<v-select
v-model="newTask.task_name"
:items="taskOptions"
label="Task"
item-title="text"
item-value="value"
variant="outlined"
density="compact"
required
></v-select>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="newTask.cron_expression"
label="Cron Expression (min hr day mon dow)"
hint="e.g. '0 3 * * *' (Daily at 3am)"
placeholder="* * * * *"
variant="outlined"
density="compact"
required
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-btn type="submit" color="success" block prepend-icon="mdi-plus">Schedule Job</v-btn>
</v-col>
</v-row>
<!-- Args (Optional, could be expanded based on task) -->
<v-row v-if="newTask.task_name">
<v-col cols="12">
<v-text-field
v-if="newTask.task_name === 'sort_by_artist' || newTask.task_name === 'update_metadata'"
v-model="newTask.args[0]"
label="Target Path"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
</v-row>
</v-form>
<v-divider class="my-4"></v-divider>
<!-- Jobs List -->
<v-list>
<v-list-item v-for="job in jobs" :key="job.id">
<template v-slot:prepend>
<v-icon icon="mdi-clock-outline" color="info"></v-icon>
</template>
<v-list-item-title class="font-weight-bold">
{{ getTaskLabel(job.name) }}
</v-list-item-title>
<v-list-item-subtitle>
Next Run: {{ new Date(job.next_run).toLocaleString() }} <br>
Trigger: {{ job.trigger }}
</v-list-item-subtitle>
<template v-slot:append>
<v-btn icon="mdi-delete" color="error" variant="text" @click="deleteJob(job.id)"></v-btn>
</template>
</v-list-item>
<div v-if="jobs.length === 0" class="text-center text-grey pa-4">No scheduled tasks.</div>
</v-list>
</v-card-text>
<v-snackbar v-model="snackbar.show" :color="snackbar.color">{{ snackbar.text }}</v-snackbar>
</v-card>
</v-container>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const jobs = ref([]);
const loading = ref(false);
const snackbar = ref({ show: false, text: '', color: 'info' });
const taskOptions = [
{ text: 'Organize Comics', value: 'organize_comics' },
{ text: 'Update Metadata', value: 'update_metadata' },
{ text: 'Sort by Artist', value: 'sort_by_artist' }
];
const newTask = ref({
task_name: 'organize_comics',
cron_expression: '0 0 * * *',
args: ['/comics/manga'],
kwargs: {}
});
const showMsg = (text, color = 'info') => {
snackbar.value = { show: true, text, color };
};
const getTaskLabel = (value) => {
const opt = taskOptions.find(o => o.value === value);
return opt ? opt.text : value;
};
const fetchJobs = async () => {
loading.value = true;
try {
const res = await fetch('/api/scheduler/');
if (res.ok) jobs.value = await res.json();
} catch (e) {
showMsg('Error fetching jobs', 'error');
} finally {
loading.value = false;
}
};
const addJob = async () => {
// Construct args based on task type
let args = [];
if (newTask.value.task_name === 'organize_comics') {
args = []; // No args for this task in current wrapper? Wait, task_organize_comics takes no args? Yes.
} else if (newTask.value.task_name === 'update_metadata') {
// args: target_path, force
args = [newTask.value.args[0] || '/comics/manga', false];
} else if (newTask.value.task_name === 'sort_by_artist') {
// args: root_path
args = [newTask.value.args[0] || '/comics/manga'];
}
try {
const res = await fetch('/api/scheduler/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
task_name: newTask.value.task_name,
cron_expression: newTask.value.cron_expression,
args: args
})
});
if (res.ok) {
showMsg('Job scheduled', 'success');
fetchJobs();
} else {
const d = await res.json();
showMsg(d.detail || 'Failed to schedule', 'error');
}
} catch (e) {
showMsg(e.message, 'error');
}
};
const deleteJob = async (id) => {
if (!confirm("Delete this schedule?")) return;
try {
const res = await fetch(`/api/scheduler/${id}`, { method: 'DELETE' });
if (res.ok) {
showMsg('Job deleted', 'success');
fetchJobs();
}
} catch (e) {
showMsg('Error deleting job', 'error');
}
};
onMounted(fetchJobs);
</script>
@@ -0,0 +1,125 @@
<template>
<v-container>
<v-card>
<v-toolbar color="primary" density="compact">
<v-toolbar-title>Settings</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn prepend-icon="mdi-content-save" @click="saveSettings" :loading="saving">Save</v-btn>
</v-toolbar>
<v-card-text>
<v-form ref="form">
<h3 class="text-h6 mb-2">Samba Configuration</h3>
<v-row>
<v-col cols="12" md="6">
<v-text-field v-model="settings.samba_server_ip" label="Server IP" variant="outlined" density="compact"></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model="settings.samba_username" label="Username" variant="outlined" density="compact"></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model="settings.samba_password" label="Password" type="password" variant="outlined" density="compact"></v-text-field>
</v-col>
</v-row>
<v-divider class="my-4"></v-divider>
<h3 class="text-h6 mb-2">qBittorrent Configuration</h3>
<v-row>
<v-col cols="12" md="6">
<v-text-field v-model="settings.qbittorrent_url" label="URL (e.g. http://localhost:8080)" variant="outlined" density="compact"></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model="settings.qbittorrent_username" label="Username" variant="outlined" density="compact"></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model="settings.qbittorrent_password" label="Password" type="password" variant="outlined" density="compact"></v-text-field>
</v-col>
</v-row>
<v-divider class="my-4"></v-divider>
<h3 class="text-h6 mb-2">Default Paths</h3>
<v-row>
<v-col cols="12" md="6">
<v-text-field v-model="settings.default_comics_path" label="Comics Library Path" variant="outlined" density="compact"></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model="settings.default_videos_path" label="Videos Library Path" variant="outlined" density="compact"></v-text-field>
</v-col>
</v-row>
<v-divider class="my-4"></v-divider>
<h3 class="text-h6 mb-2">Stash Integration</h3>
<v-row>
<v-col cols="12" md="12">
<v-switch v-model="settings.stash_enabled" label="Enable Stash Integration" color="primary"></v-switch>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model="settings.stash_db_path" label="Stash Database Local Path (stash.sqlite)" variant="outlined" density="compact"></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model="settings.stash_generated_path" label="Stash Remote Generated Path" variant="outlined" density="compact"></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model="settings.stash_remote_base" label="Stash Remote Videos Base Path" variant="outlined" density="compact"></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model="settings.stash_container_base" label="Stash Container Videos Base Path" variant="outlined" density="compact"></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model="settings.stash_share" label="Stash SMB Share Name (e.g. main)" variant="outlined" density="compact"></v-text-field>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-snackbar v-model="snackbar.show" :color="snackbar.color">{{ snackbar.text }}</v-snackbar>
</v-card>
</v-container>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const settings = ref({});
const saving = ref(false);
const snackbar = ref({ show: false, text: '', color: 'info' });
const showMsg = (text, color = 'info') => {
snackbar.value = { show: true, text, color };
};
const fetchSettings = async () => {
try {
const res = await fetch('/api/settings/');
if (res.ok) {
settings.value = await res.json();
}
} catch (err) {
showMsg('Failed to load settings', 'error');
}
};
const saveSettings = async () => {
saving.value = true;
try {
const res = await fetch('/api/settings/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings.value)
});
if (res.ok) {
showMsg('Settings saved successfully', 'success');
} else {
showMsg('Failed to save settings', 'error');
}
} catch (err) {
showMsg('Error saving settings: ' + err.message, 'error');
} finally {
saving.value = false;
}
};
onMounted(fetchSettings);
</script>
@@ -1,36 +1,110 @@
<template>
<v-container fluid>
<!-- Status Card -->
<!-- Config Card -->
<v-card class="mb-4">
<v-card-title>Scan Configuration</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="4">
<v-combobox
v-model="scanPaths"
label="Scan Paths (isolation share)"
multiple
chips
closable-chips
variant="outlined"
density="compact"
hint="Press Enter to add path"
persistent-hint
></v-combobox>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="scanType"
label="Scan Mode"
:items="[{title: 'Fast (Hash/Size)', value: 'fast'}, {title: 'Scene Matcher (Visual)', value: 'scene'}]"
variant="outlined"
density="compact"
></v-select>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="algorithm"
label="Algorithm"
:items="['phash', 'ahash', 'dhash']"
variant="outlined"
density="compact"
:disabled="scanType === 'scene'"
></v-select>
</v-col>
<v-col cols="12" md="3">
<v-btn
v-if="!scanning"
color="primary"
block
height="40"
@click="startScan"
prepend-icon="mdi-radar"
>
Start Scan
</v-btn>
<v-btn
v-else
color="error"
block
height="40"
@click="stopScan"
prepend-icon="mdi-stop"
>
Stop Scan
</v-btn>
</v-col>
</v-row>
<v-expand-transition>
<div v-if="scanning || scanLogs" class="mt-4">
<div v-if="scanning" class="pa-2 bg-grey-lighten-4 rounded mb-2">
<div class="d-flex justify-space-between text-caption mb-1">
<span class="text-truncate mr-2">Current: {{ progress.current }}</span>
<span class="font-weight-bold">{{ progress.processed }} files</span>
</div>
<v-progress-linear indeterminate color="primary" height="4" rounded></v-progress-linear>
</div>
<div class="bg-grey-darken-4 text-green-accent-2 pa-2 rounded font-mono text-caption" style="height: 150px; overflow-y: auto; white-space: pre-wrap;" ref="logContainer">
{{ scanLogs || 'Waiting for logs...' }}
</div>
</div>
</v-expand-transition>
</v-card-text>
</v-card>
<!-- Stash Status Card -->
<v-card class="mb-4" v-if="stashStatus.enabled">
<v-card-title class="d-flex align-center">
<v-icon start icon="mdi-radar" class="mr-2"></v-icon>
Scanner Status
<v-chip class="ml-4" :color="statusColor" size="small">{{ status }}</v-chip>
Stash Integration
<v-spacer></v-spacer>
<v-chip :color="stashStatus.db_exists ? 'success' : 'error'" size="small">
{{ stashStatus.db_exists ? 'DB Linked' : 'DB Missing' }}
</v-chip>
</v-card-title>
<v-card-text>
<div class="d-flex gap-2">
<v-btn
color="primary"
prepend-icon="mdi-play"
@click="startScan"
:disabled="isLoading"
variant="tonal"
>Start Scan</v-btn>
<v-btn
color="warning"
prepend-icon="mdi-pause"
@click="pauseScan"
:disabled="isLoading"
variant="tonal"
>Pause</v-btn>
<v-btn
color="success"
prepend-icon="mdi-play-circle-outline"
@click="resumeScan"
:disabled="isLoading"
variant="tonal"
>Resume</v-btn>
</div>
<v-row align="center">
<v-col cols="12" md="8">
<div class="text-caption" v-if="stashStatus.db_exists">
Size: {{ (stashStatus.db_size / 1024 / 1024).toFixed(2) }} MB |
Last Updated: {{ new Date(stashStatus.db_mtime * 1000).toLocaleString() }}
</div>
<div class="text-caption text-error" v-else>
Stash database not found at root. Click Sync to download from remote.
</div>
</v-col>
<v-col cols="12" md="4" class="text-right">
<v-btn color="secondary" size="small" @click="syncStash" :loading="syncingStash" prepend-icon="mdi-sync">
Sync Stash DB
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
@@ -51,35 +125,59 @@
></v-slider>
<v-btn
block
color="primary"
color="secondary"
@click="findDuplicates"
:loading="isLoading"
:loading="analyzing"
prepend-icon="mdi-magnify"
>Find Duplicates</v-btn>
>Analyze Database</v-btn>
</v-card-text>
<v-divider></v-divider>
<v-expansion-panels variant="accordion">
<v-expansion-panel title="Manage Exclusions">
<v-expansion-panel-text>
<div class="d-flex gap-2 mb-2">
<v-text-field v-model="newExclusion" label="Path" density="compact" hide-details variant="outlined"></v-text-field>
<v-btn icon="mdi-plus" size="small" @click="addExclusion" variant="text"></v-btn>
</div>
<v-list density="compact" class="bg-grey-lighten-5 rounded" style="max-height: 150px; overflow-y: auto;">
<v-list-item v-for="ex in exclusions" :key="ex" density="compact">
<v-list-item-title class="text-caption">{{ ex }}</v-list-item-title>
<template v-slot:append>
<v-btn icon="mdi-close" size="x-small" variant="text" color="grey" @click="removeExclusion(ex)"></v-btn>
</template>
</v-list-item>
<div v-if="exclusions.length === 0" class="text-caption text-center text-grey pa-2">No exclusions</div>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card>
</v-col>
<!-- Previous Reports -->
<v-col cols="12" md="8">
<v-card class="fill-height">
<v-card-title>Previous Reports</v-card-title>
<v-list density="compact" style="max-height: 200px; overflow-y: auto;">
<v-card-title>Reports History</v-card-title>
<v-list density="compact" style="max-height: 300px; overflow-y: auto;">
<v-list-item
v-for="report in reports"
:key="report.id"
:value="report.id"
@click="getReport(report.id)"
active-color="primary"
lines="two"
>
<template v-slot:prepend>
<v-icon icon="mdi-file-document-outline"></v-icon>
<v-icon icon="mdi-file-document-outline" color="primary"></v-icon>
</template>
<v-list-item-title>Report #{{ report.id }}</v-list-item-title>
<v-list-item-subtitle>
{{ new Date(report.created_at).toLocaleString() }} - {{ report.status }}
</v-list-item-subtitle>
</v-list-item>
<div v-if="reports.length === 0" class="text-center pa-4 text-grey">No reports found.</div>
</v-list>
</v-card>
</v-col>
@@ -87,16 +185,16 @@
<!-- Active Report View -->
<v-fade-transition>
<v-card v-if="duplicatesReport" class="mt-4">
<v-card v-if="duplicatesReport" class="mt-4" border>
<v-toolbar color="surface" density="compact">
<v-toolbar-title>
Report #{{ duplicatesReport.report_id }}
<span class="text-caption text-grey ml-2">({{ duplicatesReport.status }})</span>
<v-toolbar-title class="text-subtitle-1">
Report #{{ duplicatesReport.id }} ({{ duplicatesReport.status }})
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="error"
variant="flat"
size="small"
:disabled="selectedFiles.length === 0"
@click="deleteSelectedFiles"
prepend-icon="mdi-delete"
@@ -105,46 +203,60 @@
</v-btn>
</v-toolbar>
<v-card-text class="bg-grey-lighten-4 pa-4 rounded">
<v-expansion-panels multiple>
<v-card-text class="pa-0">
<v-expansion-panels variant="popout" multiple>
<v-expansion-panel
v-for="group in duplicatesReport.groups"
:key="group.group_id"
:key="group.id"
>
<v-expansion-panel-title>
<div class="d-flex align-center justify-space-between w-100 mr-4">
<span>Group {{ group.group_id }}</span>
<v-chip size="small" :color="getScoreColor(group.score)">Score: {{ group.score.toFixed(4) }}</v-chip>
</div>
<v-row no-gutters align="center">
<v-col cols="8" class="text-truncate">Group {{ group.id }} ({{ group.files.length }} files)</v-col>
<v-col cols="4" class="text-right">
<v-chip size="x-small" :color="getScoreColor(group.score)">Match: {{ (group.score * 100).toFixed(1) }}%</v-chip>
</v-col>
</v-row>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list lines="two" density="compact">
<v-list-item v-for="file in group.files" :key="file.id">
<template v-slot:prepend>
<v-checkbox-btn
v-model="selectedFiles"
:value="file.filepath"
color="error"
></v-checkbox-btn>
</template>
<v-list-item-title class="font-weight-bold text-wrap" style="word-break: break-all;">
{{ file.filepath }}
</v-list-item-title>
<v-list-item-subtitle>
Size: {{ (file.size / 1024 / 1024).toFixed(2) }} MB
</v-list-item-subtitle>
<template v-slot:append>
<v-btn
variant="text"
color="info"
icon="mdi-eye"
@click="viewFile(file.filepath)"
></v-btn>
</template>
</v-list-item>
</v-list>
<v-expansion-panel-text class="bg-grey-lighten-5">
<v-row>
<v-col v-for="file in group.files" :key="file.id" cols="12" md="6">
<v-card variant="outlined" class="h-100">
<div class="d-flex flex-no-wrap justify-space-between">
<div>
<v-card-title class="text-subtitle-2">{{ file.path.split('/').pop() }}</v-card-title>
<v-card-subtitle class="text-caption">
{{ (file.size / 1024 / 1024).toFixed(2) }} MB | {{ file.duration.toFixed(0) }}s
<br>
{{ file.path }}
</v-card-subtitle>
<v-card-actions>
<v-checkbox-btn
v-model="selectedFiles"
:value="file.id"
color="error"
label="Select for Delete"
density="compact"
></v-checkbox-btn>
<v-btn icon="mdi-eye" size="small" variant="text" color="info" @click="viewFile(file.path)"></v-btn>
</v-card-actions>
</div>
<v-avatar
class="ma-3"
size="125"
rounded="0"
v-if="file.contact_sheet"
>
<v-img
:src="`/api/duplicates/thumbnails/${file.id}`"
cover
@click="viewImage(`/api/duplicates/thumbnails/${file.id}`)"
class="cursor-pointer bg-grey-lighten-2"
></v-img>
</v-avatar>
</div>
</v-card>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
@@ -163,36 +275,76 @@
:share-name="shareName"
@close="closeViewer"
/>
<!-- Image Preview Dialog -->
<v-dialog v-model="imageDialog" max-width="800">
<v-card>
<v-img :src="imageToView" cover></v-img>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar.show" :color="snackbar.color">{{ snackbar.text }}</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue';
import { ref, onMounted, computed, onUnmounted } from 'vue';
import FileViewer from './FileViewer.vue';
const status = ref('Idle');
const isLoading = ref(false);
// Config
const scanPaths = ref(['/videos']);
const algorithm = ref('phash');
const scanType = ref('fast');
const threshold = ref(0.95);
const duplicatesReport = ref(null);
const exclusions = ref([]);
const newExclusion = ref('');
// State
const scanning = ref(false);
const analyzing = ref(false);
const progress = ref({ current: '', processed: 0 });
const scanLogs = ref('');
const logContainer = ref(null);
const reports = ref([]);
const selectedFiles = ref([]);
const duplicatesReport = ref(null);
const selectedFiles = ref([]); // IDs
const stashStatus = ref({ enabled: false, db_exists: false, db_size: 0, db_mtime: 0 });
const syncingStash = ref(false);
// UI
const fileToView = ref(null);
const viewerDialog = ref(false);
const imageDialog = ref(false);
const imageToView = ref(null);
const snackbar = ref({ show: false, text: '', color: 'info' });
let pollInterval = null;
const statusColor = computed(() => {
if (status.value.toLowerCase().includes('scanning')) return 'success';
if (status.value.toLowerCase().includes('paused')) return 'warning';
return 'grey';
});
const fetchStashStatus = async () => {
try {
const res = await fetch('/api/stash/status');
if (res.ok) stashStatus.value = await res.json();
} catch (e) {}
};
const syncStash = async () => {
syncingStash.value = true;
try {
const res = await fetch('/api/stash/sync', { method: 'POST' });
if (res.ok) {
showMsg('Stash DB sync started', 'success');
// Poll status for a bit
setTimeout(fetchStashStatus, 5000);
setTimeout(fetchStashStatus, 10000);
}
} catch (e) {
showMsg('Sync failed: ' + e.message, 'error');
} finally {
syncingStash.value = false;
}
};
const shareName = computed(() => {
if (fileToView.value) {
if (fileToView.value.startsWith('/videos')) {
return 'isolation';
}
}
if (fileToView.value && fileToView.value.startsWith('/videos')) return 'isolation';
return 'media';
});
@@ -206,143 +358,171 @@ const getScoreColor = (score) => {
return 'green';
};
const getStatus = async () => {
try {
const response = await fetch('/api/duplicates/scan/status');
if (response.ok) {
const data = await response.json();
status.value = data.status;
}
} catch (error) {
console.error('Error fetching status:', error);
}
const fetchExclusions = async () => {
try {
const res = await fetch('/api/duplicates/exclusions');
if(res.ok) exclusions.value = await res.json();
} catch(e) {}
};
const addExclusion = async () => {
if(!newExclusion.value) return;
await fetch('/api/duplicates/exclusions', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: newExclusion.value})
});
newExclusion.value = '';
fetchExclusions();
};
const removeExclusion = async (path) => {
await fetch('/api/duplicates/exclusions', {
method: 'DELETE', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path})
});
fetchExclusions();
};
const startScan = async () => {
isLoading.value = true;
try {
const response = await fetch('/api/duplicates/scan', { method: 'POST' });
if (response.ok) {
showMsg('Scan started', 'success');
getStatus();
scanning.value = true;
scanLogs.value = '';
try {
const res = await fetch('/api/duplicates/scan/start', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ paths: scanPaths.value, algorithm: algorithm.value, scan_type: scanType.value })
});
if(res.ok) {
showMsg('Scan started', 'success');
pollProgress();
}
} catch(e) {
showMsg(e.message, 'error');
scanning.value = false;
}
} catch (error) {
showMsg('Error starting scan', 'error');
}
isLoading.value = false;
};
const pauseScan = async () => {
isLoading.value = true;
try {
const response = await fetch('/api/duplicates/scan/pause', { method: 'POST' });
if (response.ok) {
showMsg('Scan paused', 'warning');
getStatus();
const stopScan = async () => {
try {
const res = await fetch('/api/duplicates/scan/cancel', { method: 'POST' });
if(res.ok) {
showMsg('Scan stopping...', 'info');
} else {
showMsg('Failed to stop scan', 'error');
}
} catch(e) {
showMsg(e.message, 'error');
}
} catch (error) {
showMsg('Error pausing scan', 'error');
}
isLoading.value = false;
};
const resumeScan = async () => {
isLoading.value = true;
try {
const response = await fetch('/api/duplicates/scan/resume', { method: 'POST' });
if (response.ok) {
showMsg('Scan resumed', 'success');
getStatus();
}
} catch (error) {
showMsg('Error resuming scan', 'error');
}
isLoading.value = false;
const fetchLogs = async () => {
try {
const res = await fetch('/api/duplicates/scan/log?limit=50');
if(res.ok) {
const data = await res.json();
if (data.lines) {
scanLogs.value = data.lines.join('');
// Scroll to bottom
if (logContainer.value) {
setTimeout(() => {
logContainer.value.scrollTop = logContainer.value.scrollHeight;
}, 50);
}
}
}
} catch(e) {}
};
const getReports = async () => {
try {
const response = await fetch('/api/duplicates/reports');
if (response.ok) {
reports.value = await response.json();
}
} catch (error) {
console.error('Error fetching reports:', error);
}
};
const getReport = async (reportId) => {
try {
const response = await fetch(`/api/duplicates/report/${reportId}`);
if (response.ok) {
duplicatesReport.value = await response.json();
selectedFiles.value = [];
}
} catch (error) {
showMsg('Error fetching report', 'error');
}
const pollProgress = () => {
if(pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(async () => {
await fetchLogs();
try {
const res = await fetch('/api/duplicates/scan/progress');
if(res.ok) {
const data = await res.json();
if(data.status === 'scanning') {
scanning.value = true;
progress.value = data;
} else {
scanning.value = false;
clearInterval(pollInterval);
if(data.status === 'completed') showMsg('Scan complete', 'success');
else if(data.status === 'canceled') showMsg('Scan canceled', 'warning');
else if(data.status === 'failed') showMsg('Scan failed: ' + data.error, 'error');
}
}
} catch(e) {}
}, 1000); // Faster polling for responsiveness
};
const findDuplicates = async () => {
isLoading.value = true;
duplicatesReport.value = null;
try {
const response = await fetch(`/api/duplicates/find?threshold=${threshold.value}`, {
method: 'POST',
});
if (response.ok) {
const data = await response.json();
if (data.report_id) {
await getReport(data.report_id);
showMsg('Duplicates found!', 'success');
}
await getReports();
} else {
showMsg('No duplicates found or error.', 'warning');
analyzing.value = true;
try {
const res = await fetch(`/api/duplicates/find?threshold=${threshold.value}&method=${scanType.value}`, { method: 'POST' });
if(res.ok) {
const data = await res.json();
if(data.report_id) {
await getReport(data.report_id);
showMsg('Analysis complete', 'success');
}
getReports();
}
} catch(e) { showMsg(e.message, 'error'); }
finally { analyzing.value = false; }
};
const getReports = async () => {
const res = await fetch('/api/duplicates/reports');
if(res.ok) reports.value = await res.json();
};
const getReport = async (id) => {
const res = await fetch(`/api/duplicates/report/${id}`);
if(res.ok) {
duplicatesReport.value = await res.json();
selectedFiles.value = [];
}
} catch (error) {
showMsg('Error finding duplicates: ' + error.message, 'error');
}
isLoading.value = false;
};
const viewFile = (filePath) => {
fileToView.value = filePath;
viewerDialog.value = true;
};
const closeViewer = () => {
viewerDialog.value = false;
setTimeout(() => { fileToView.value = null; }, 300);
};
const deleteSelectedFiles = async () => {
if (!confirm(`Are you sure you want to delete ${selectedFiles.value.length} files?`)) return;
isLoading.value = true;
try {
const response = await fetch('/api/duplicates/files', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filepaths: selectedFiles.value }),
if(!confirm(`Delete ${selectedFiles.value.length} files?`)) return;
const res = await fetch('/api/duplicates/files', {
method: 'DELETE', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ ids: selectedFiles.value })
});
if (response.ok) {
showMsg('Files deleted successfully', 'success');
if (duplicatesReport.value) {
await getReport(duplicatesReport.value.report_id);
}
} else {
showMsg('Error deleting files', 'error');
if(res.ok) {
showMsg('Files deleted');
if(duplicatesReport.value) getReport(duplicatesReport.value.id);
}
} catch (error) {
showMsg('Error deleting files: ' + error.message, 'error');
}
isLoading.value = false;
};
const viewFile = (path) => {
fileToView.value = path;
viewerDialog.value = true;
};
const viewImage = (url) => {
imageToView.value = url;
imageDialog.value = true;
};
const closeViewer = () => {
viewerDialog.value = false;
setTimeout(() => { fileToView.value = null; }, 300);
};
onMounted(() => {
getStatus();
getReports();
setInterval(getStatus, 5000);
fetchExclusions();
getReports();
fetchStashStatus();
pollProgress(); // Check if scan is running
});
onUnmounted(() => {
if(pollInterval) clearInterval(pollInterval);
});
</script>
<style scoped>
.cursor-pointer { cursor: pointer; }
</style>
@@ -3,11 +3,16 @@ import SambaExplorer from '../components/SambaExplorer.vue'
import ComicsManager from '../components/ComicsManager.vue'
import VideoDuplicatesManager from '../components/VideoDuplicatesManager.vue'
import DownloaderManager from '../components/DownloaderManager.vue'
import QbittorrentManager from '../components/QbittorrentManager.vue'
import Dashboard from '../components/Dashboard.vue'
import Settings from '../components/Settings.vue'
import Scheduler from '../components/Scheduler.vue'
const routes = [
{
path: '/',
redirect: '/samba'
name: 'Dashboard',
component: Dashboard
},
{
path: '/samba',
@@ -28,6 +33,21 @@ const routes = [
path: '/downloader',
name: 'DownloaderManager',
component: DownloaderManager
},
{
path: '/qbittorrent',
name: 'QbittorrentManager',
component: QbittorrentManager
},
{
path: '/settings',
name: 'Settings',
component: Settings
},
{
path: '/scheduler',
name: 'Scheduler',
component: Scheduler
}
]