major server manager overhaul
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user