Added a significant overhaul to the server manager.

This commit is contained in:
2025-12-29 16:07:30 -05:00
parent 4f4e5276a6
commit 216e18a7b4
796 changed files with 528631 additions and 2671 deletions
+41
View File
@@ -0,0 +1,41 @@
<script setup>
import { ref } from 'vue';
import SambaExplorer from './components/SambaExplorer.vue';
import ComicsManager from './components/ComicsManager.vue';
import VideoDuplicatesManager from './components/VideoDuplicatesManager.vue';
const currentView = ref('samba');
</script>
<template>
<div>
<nav>
<button @click="currentView = 'samba'" :class="{ active: currentView === 'samba' }">Samba Explorer</button>
<button @click="currentView = 'comics'" :class="{ active: currentView === 'comics' }">Comics Manager</button>
<button @click="currentView = 'duplicates'" :class="{ active: currentView === 'duplicates' }">Video Duplicates</button>
</nav>
<SambaExplorer v-if="currentView === 'samba'" />
<ComicsManager v-if="currentView === 'comics'" />
<VideoDuplicatesManager v-if="currentView === 'duplicates'" />
</div>
</template>
<style scoped>
nav {
margin-bottom: 20px;
}
nav button {
padding: 10px 15px;
margin-right: 10px;
cursor: pointer;
border: 1px solid #ccc;
background-color: #f0f0f0;
color: black;
}
nav button.active {
background-color: #007bff;
color: white;
border-color: #007bff;
}
</style>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -0,0 +1,106 @@
<template>
<div>
<h2>Comics Management</h2>
<button @click="organizeComics" :disabled="isOrganizing">
{{ isOrganizing ? 'Organizing...' : 'Organize Comics Now' }}
</button>
<button @click="cleanupToberead" :disabled="isCleaning">
{{ isCleaning ? 'Cleaning...' : 'Cleanup Folders' }}
</button>
<p v-if="message">{{ message }}</p>
<p v-if="cleanupMessage">{{ cleanupMessage }}</p>
<p v-if="error" style="color: red;">Error: {{ error }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue';
const isOrganizing = ref(false);
const message = ref('');
const error = ref('');
const isCleaning = ref(false);
const cleanupMessage = ref('');
const organizeComics = async () => {
isOrganizing.value = true;
message.value = 'Initiating comics organization... This may take a while.';
error.value = '';
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!';
console.log('Backend response:', data);
} else {
const errorData = await response.json();
error.value = errorData.detail || `Failed to organize comics: ${response.status}`;
console.error('Error response from backend:', errorData);
}
} catch (err) {
error.value = `Network error: ${err.message}`;
console.error('Network or unexpected error:', err);
} finally {
isOrganizing.value = false;
}
};
const cleanupToberead = async () => {
if (window.confirm("This will delete empty folders in the 'toberead' path. Are you sure you want to continue?")) {
isCleaning.value = true;
cleanupMessage.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();
cleanupMessage.value = data.message || 'Cleanup process started successfully!';
console.log('Backend response:', data);
} else {
const errorData = await response.json();
error.value = errorData.detail || `Failed to start cleanup: ${response.status}`;
console.error('Error response from backend:', errorData);
}
} catch (err) {
error.value = `Network error: ${err.message}`;
console.error('Network or unexpected error:', err);
} finally {
isCleaning.value = false;
}
} else {
cleanupMessage.value = 'Cleanup cancelled.';
isCleaning.value = false;
}
};
</script>
<style scoped>
div {
font-family: sans-serif;
padding: 20px;
}
button {
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
margin-right: 10px;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,168 @@
<template>
<div v-if="filePath" class="modal-overlay" @click.self="close">
<div class="modal-content">
<button class="close-button" @click="close">&times;</button>
<h3>{{ fileName }}</h3>
<div class="viewer-container">
<img v-if="fileType === 'image'" :src="fileUrl" :alt="fileName" class="viewable-image" />
<video v-if="fileType === 'video'" :src="fileUrl" controls class="viewable-video"></video>
<audio v-if="fileType === 'audio'" :src="fileUrl" controls class="viewable-audio"></audio>
<pre v-if="fileType === 'text'"><code>{{ textContent }}</code></pre>
<div v-if="fileType === 'cbz'">
<div v-if="cbzImageUrls.length > 0">
<img :src="cbzImageUrls[currentCbzImageIndex]" class="viewable-image" />
<div>
<button @click="prevCbzImage" :disabled="currentCbzImageIndex === 0">Previous</button>
<span>{{ currentCbzImageIndex + 1 }} / {{ cbzImageUrls.length }}</span>
<button @click="nextCbzImage" :disabled="currentCbzImageIndex === cbzImageUrls.length - 1">Next</button>
</div>
</div>
<p v-else>Loading CBZ content...</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watchEffect } from 'vue';
const props = defineProps({
filePath: {
type: String,
required: true,
},
shareName: {
type: String,
required: true,
},
});
const emit = defineEmits(['close']);
const fileName = computed(() => props.filePath.split('/').pop());
const fileUrl = computed(() => `/api/samba/view/${props.shareName}?path=${props.filePath}`);
const textContent = ref('');
const cbzImageUrls = ref([]);
const currentCbzImageIndex = ref(0);
const fileType = computed(() => {
const extension = props.filePath.split('.').pop().toLowerCase();
if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(extension)) {
return 'image';
}
if (['mp4', 'mkv', 'avi', 'mov', 'webm'].includes(extension)) {
return 'video';
}
if (['mp3', 'flac', 'wav', 'ogg'].includes(extension)) {
return 'audio';
}
if (['txt', 'py', 'js', 'json', 'xml', 'html', 'css', 'md'].includes(extension)) {
return 'text';
}
if (extension === 'cbz') {
return 'cbz';
}
return 'other';
});
watchEffect(async () => {
if (fileType.value === 'text') {
try {
const response = await fetch(fileUrl.value);
if (response.ok) {
textContent.value = await response.text();
} else {
textContent.value = 'Error loading file content.';
}
} catch (error) {
textContent.value = 'Error loading file content.';
console.error('Error fetching text content:', error);
}
} else if (fileType.value === 'cbz') {
try {
const response = await fetch(`/api/samba/cbz/list/${props.shareName}?path=${props.filePath}`);
if (response.ok) {
const imageNames = await response.json();
cbzImageUrls.value = imageNames.map(name => `/api/samba/cbz/image/${props.shareName}?path=${props.filePath}&image_name=${encodeURIComponent(name)}`);
}
} catch (error) {
console.error('Error fetching CBZ content:', error);
}
}
});
const prevCbzImage = () => {
if (currentCbzImageIndex.value > 0) {
currentCbzImageIndex.value--;
}
};
const nextCbzImage = () => {
if (currentCbzImageIndex.value < cbzImageUrls.value.length - 1) {
currentCbzImageIndex.value++;
}
};
const close = () => {
emit('close');
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: white;
padding: 20px;
border-radius: 8px;
width: 80%;
max-width: 900px;
max-height: 80vh;
overflow-y: auto;
position: relative;
text-align: center;
}
.close-button {
position: absolute;
top: 10px;
right: 10px;
font-size: 24px;
background: none;
border: none;
cursor: pointer;
}
.viewer-container {
margin-top: 20px;
}
.viewable-image,
.viewable-video,
.viewable-audio {
max-width: 100%;
max-height: 70vh;
}
pre {
background-color: #2d2d2d; /* Dark background */
color: #f8f8f2; /* Light text color */
padding: 1em;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
text-align: left;
}
</style>
@@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
@@ -0,0 +1,220 @@
<template>
<div>
<h2>Samba File Explorer</h2>
<label for="share-select">Select Share:</label>
<select id="share-select" v-model="shareName" @change="resetAndFetch">
<option v-for="share in availableShares" :key="share" :value="share">{{ share }}</option>
</select>
<p>Current Path: {{ shareName }}:{{ currentPath }}</p>
<ul>
<li v-for="item in files" :key="item.name">
<span @click="item.is_directory ? navigate(item) : viewFile(item.path)">
{{ item.name }} {{ item.is_directory ? '/' : '' }}
</span>
<button v-if="!item.is_directory" @click="viewFile(item.path)">View</button>
<button v-if="!item.is_directory" @click="downloadFile(item.path)">Download</button>
<button @click="renameItem(item.path, item.name, item.is_directory)">Rename</button>
<button @click="deleteItem(item.path, item.is_directory)">Delete</button>
</li>
</ul>
<button @click="goBack" :disabled="currentPath === '/'">Go Back</button>
<form @submit.prevent="uploadFile">
<input type="file" @change="handleFileUpload" />
<button type="submit">Upload</button>
</form>
<FileViewer
v-if="fileToView"
:file-path="fileToView"
:share-name="shareName"
@close="closeViewer"
/>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import FileViewer from './FileViewer.vue';
const availableShares = ref(['media', 'isolation']);
const shareName = ref(availableShares.value[0]);
const currentPath = ref('/');
const files = ref([]);
const fileToUpload = ref(null);
const fileToView = ref(null);
const fetchFiles = async () => {
try {
const response = await fetch(`/api/samba/list/${shareName.value}?path=${currentPath.value}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
files.value = data;
} catch (error) {
console.error('Error fetching files:', error);
files.value = [];
}
};
const resetAndFetch = () => {
currentPath.value = '/';
fetchFiles();
};
const navigate = (item) => {
if (item.is_directory) {
currentPath.value = item.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 = '/';
}
fetchFiles();
};
const viewFile = (filePath) => {
fileToView.value = filePath;
};
const closeViewer = () => {
fileToView.value = null;
};
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);
} catch (error) {
console.error('Error downloading file:', error);
}
};
const deleteItem = async (itemPath, isDirectory) => {
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) {
console.log(`${itemPath} deleted successfully.`);
fetchFiles();
} else {
console.error('Error deleting item:', await response.text());
}
} catch (error) {
console.error('Error deleting item:', error);
}
};
const renameItem = async (oldPath, oldName, isDirectory) => {
const newName = prompt(`Enter new name for ${oldName}:`, oldName);
if (!newName || newName === oldName) {
return;
}
const 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) {
console.log(`${oldName} renamed to ${newName} successfully.`);
fetchFiles();
} else {
console.error('Error renaming item:', await response.text());
}
} catch (error) {
console.error('Error renaming item:', error);
}
};
const handleFileUpload = (event) => {
fileToUpload.value = event.target.files[0];
};
const uploadFile = async () => {
if (!fileToUpload.value) return;
const formData = new FormData();
formData.append('file', fileToUpload.value);
try {
const response = await fetch(`/api/samba/files/upload/${shareName.value}?path=${currentPath.value}`, {
method: 'POST',
body: formData,
});
if (response.ok) {
console.log('File uploaded successfully.');
fileToUpload.value = null;
fetchFiles();
} else {
console.error('Error uploading file:', await response.text());
}
} catch (error) {
console.error('Error uploading file:', error);
}
};
onMounted(() => {
fetchFiles();
});
watch(shareName, () => {
resetAndFetch();
});
</script>
<style scoped>
/* Add some basic styling */
div {
font-family: sans-serif;
padding: 20px;
}
ul {
list-style-type: none;
padding: 0;
}
li {
margin: 5px 0;
display: flex;
align-items: center;
}
li span {
cursor: pointer;
margin-right: 10px;
color: #007bff;
}
li span:hover {
text-decoration: underline;
}
button {
margin-left: 5px;
padding: 5px 10px;
cursor: pointer;
}
</style>
@@ -0,0 +1,94 @@
<template>
<div>
<h2>Video Duplicates Scanner</h2>
<button @click="startScan" :disabled="isLoading">Start Scan</button>
<button @click="pauseScan" :disabled="isLoading">Pause Scan</button>
<button @click="resumeScan" :disabled="isLoading">Resume Scan</button>
<p>Status: {{ status }}</p>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const status = ref('Idle');
const isLoading = ref(false);
const getStatus = async () => {
// This endpoint doesn't exist yet, but I'm adding the function in preparation.
// try {
// const response = await fetch('/api/duplicates/scan/status');
// if (response.ok) {
// const data = await response.json();
// status.value = data.status;
// } else {
// status.value = 'Error fetching status';
// }
// } catch (error) {
// status.value = 'Error fetching status';
// console.error('Error fetching status:', error);
// }
};
const startScan = async () => {
isLoading.value = true;
status.value = 'Starting scan...';
try {
const response = await fetch('/api/duplicates/scan', { method: 'POST' });
if (response.ok) {
status.value = 'Scan started.';
} else {
status.value = 'Error starting scan.';
}
} catch (error) {
status.value = 'Error starting scan.';
console.error('Error starting scan:', error);
}
isLoading.value = false;
};
const pauseScan = async () => {
isLoading.value = true;
status.value = 'Pausing scan...';
try {
const response = await fetch('/api/duplicates/scan/pause', { method: 'POST' });
if (response.ok) {
status.value = 'Scan paused.';
} else {
status.value = 'Error pausing scan.';
}
} catch (error) {
status.value = 'Error pausing scan.';
console.error('Error pausing scan:', error);
}
isLoading.value = false;
};
const resumeScan = async () => {
isLoading.value = true;
status.value = 'Resuming scan...';
try {
const response = await fetch('/api/duplicates/scan/resume', { method: 'POST' });
if (response.ok) {
status.value = 'Scan resumed.';
} else {
status.value = 'Error resuming scan.';
}
} catch (error) {
status.value = 'Error resuming scan.';
console.error('Error resuming scan:', error);
}
isLoading.value = false;
};
onMounted(() => {
// getStatus();
// setInterval(getStatus, 5000); // Poll for status every 5 seconds
});
</script>
<style scoped>
button {
margin: 5px;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
@@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}