Files
personal_development/stashtoolkit_webtop/static/index.html
T
2025-12-06 20:47:35 -05:00

1612 lines
71 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stash Toolkit</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700&amp;display=swap" rel="stylesheet">
<style>
:root {
--bs-primary: #7c5cff;
--bs-secondary: #9277ff;
--bs-success: #3dcc91;
--bs-danger: #ff6b6b;
--bs-light: #f4f7fc;
--bs-dark: #252934;
}
body {
font-family: 'Nunito', sans-serif;
background-color: var(--bs-light);
color: var(--bs-dark);
}
.bubbly-background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
background: linear-gradient(135deg, #a48fff, #7c5cff);
}
.card {
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
border: none;
transition: all 0.3s ease;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
}
.btn {
border-radius: 50px;
font-weight: bold;
padding: 12px 30px;
transition: all 0.3s ease;
}
.btn-primary {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
}
.btn-primary:hover {
background-color: var(--bs-secondary);
border-color: var(--bs-secondary);
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(124, 92, 255, 0.4);
}
.form-control {
border-radius: 15px;
}
[data-bs-theme="dark"] {
--bs-light: #1a1d24;
--bs-dark: #f8f9fa;
--bs-body-bg: #252934;
--bs-body-color: #f8f9fa;
--bs-border-color: #495057;
}
[data-bs-theme="dark"] .bubbly-background {
background: linear-gradient(135deg, #252934, #1a1d24);
}
[data-bs-theme="dark"] .card {
background-color: #2c313d;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
[data-bs-theme="dark"] .list-group-item {
background-color: #343a46;
border-color: #495057;
}
[data-bs-theme="dark"] .form-control {
background-color: #343a46;
color: #f8f9fa;
border-color: #495057;
}
[data-bs-theme="dark"] .modal-content {
background-color: #2c313d;
}
@media (max-width: 767.98px) {
.file-explorer-item {
flex-direction: column;
align-items: flex-start !important;
}
.file-explorer-item .actions {
margin-top: 0.5rem;
width: 100%;
display: flex;
justify-content: space-between;
}
.file-explorer-item .actions .btn {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
width: 32%;
}
}
</style>
</head>
<body>
<div class="bubbly-background"></div>
<div class="position-absolute top-0 end-0 p-3" style="z-index: 1050;">
<button id="theme-switcher" class="btn btn-outline-light">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-moon-stars-fill" viewBox="0 0 16 16">
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
<path d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162h1.212a.217.217 0 0 1 .162.321l-.988.717.387 1.162a.217.217 0 0 1-.321.242l-.988-.717-.988.717a.217.217 0 0 1-.321-.242l.387-1.162-.988-.717a.217.217 0 0 1 .162-.321h1.212l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258c-.346.115-.617.386-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.724 1.724 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617.386.732-.732L13.863.1z"/>
</svg>
</button>
</div>
<div class="container py-5">
<header class="text-center mb-5">
<h1 class="display-4 fw-bold text-white">Stash Toolkit</h1>
<p class="lead text-white-50">Your friendly neighborhood media manager</p>
</header>
<div class="accordion" id="mainAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="headingCredentials">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseCredentials" aria-expanded="false" aria-controls="collapseCredentials">
Credentials
</button>
</h2>
<div id="collapseCredentials" class="accordion-collapse collapse" aria-labelledby="headingCredentials" data-bs-parent="#mainAccordion">
<div class="accordion-body">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="card-title h4 m-0"></h2>
<button id="change-creds-btn" class="btn btn-secondary">Change Credentials</button>
</div>
<div id="credentials-form" style="display: none;">
<div class="alert alert-warning"><strong>Warning:</strong> Credentials are stored in plaintext. Use with caution.</div>
<div class="row g-3">
<div class="col-md-6">
<h5>truenas.local</h5>
<div class="mb-3">
<label for="truenas-username" class="form-label">Username</label>
<input type="text" class="form-control" id="truenas-username">
</div>
<div class="mb-3">
<label for="truenas-password" class="form-label">Password</label>
<input type="password" class="form-control" id="truenas-password">
</div>
</div>
<div class="col-md-6">
<h5>servervm.local</h5>
<div class="mb-3">
<label for="servervm-username" class="form-label">Username</label>
<input type="text" class="form-control" id="servervm-username">
</div>
<div class="mb-3">
<label for="servervm-password" class="form-label">Password</label>
<input type="password" class="form-control" id="servervm-password">
</div>
</div>
<div class="col-12"><hr></div>
<div class="col-md-12">
<h5>qBittorrent</h5>
<div class="row g-3">
<div class="col-md-6">
<label for="qb-host" class="form-label">Host</label>
<input type="text" class="form-control" id="qb-host">
</div>
<div class="col-md-6">
<label for="qb-port" class="form-label">Port</label>
<input type="number" class="form-control" id="qb-port">
</div>
<div class="col-md-6">
<label for="qb-username" class="form-label">Username</label>
<input type="text" class="form-control" id="qb-username">
</div>
<div class="col-md-6">
<label for="qb-password" class="form-label">Password</label>
<input type="password" class="form-control" id="qb-password">
</div>
</div>
</div>
</div>
<div class="text-center mt-4">
<button id="save-creds-btn" class="btn btn-primary">Save All Credentials</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingConnectionStatus">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseConnectionStatus" aria-expanded="false" aria-controls="collapseConnectionStatus">
Connection Status
</button>
</h2>
<div id="collapseConnectionStatus" class="accordion-collapse collapse" aria-labelledby="headingConnectionStatus" data-bs-parent="#mainAccordion">
<div class="accordion-body">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="card-title h4 m-0"></h2>
<button id="check-connections-btn" class="btn btn-primary">Refresh All</button>
</div>
<div id="connection-status-list">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>Stash DB Connection:</span>
<span id="db-status-indicator" class="badge bg-secondary">Unknown</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<span>truenas.local Connection:</span>
<span id="truenas-status-indicator" class="badge bg-secondary">Unknown</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span>servervm.local Connection:</span>
<span id="servervm-status-indicator" class="badge bg-secondary">Unknown</span>
</div>
<div class="d-flex justify-content-between align-items-center mt-2">
<span>qBittorrent Connection:</span>
<span id="qb-status-indicator" class="badge bg-secondary">Unknown</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingQBittorrent">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseQBittorrent" aria-expanded="false" aria-controls="collapseQBittorrent">
qBittorrent
</button>
</h2>
<div id="collapseQBittorrent" class="accordion-collapse collapse" aria-labelledby="headingQBittorrent" data-bs-parent="#mainAccordion">
<div class="accordion-body">
<div class="card p-4">
<h2 class="card-title h4 mb-4"></h2>
<div id="qb-torrents" class="mt-2 table-responsive"></div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingDuplicateFinder">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDuplicateFinder" aria-expanded="false" aria-controls="collapseDuplicateFinder">
Duplicate Finder
</button>
</h2>
<div id="collapseDuplicateFinder" class="accordion-collapse collapse" aria-labelledby="headingDuplicateFinder" data-bs-parent="#mainAccordion">
<div class="accordion-body">
<div class="card p-4">
<h2 class="card-title h4 mb-4"></h2>
<div class="d-grid">
<button id="scan-btn" class="btn btn-primary">Scan for Duplicates</button>
</div>
<div id="duplicate-terminal" class="terminal-container mt-3" style="display: none;">
<pre class="terminal bg-dark text-white p-3 rounded"><code></code></pre>
</div>
<div id="duplicate-results" class="mt-4"></div>
<div class="d-grid mt-3">
<button id="delete-selected-btn" class="btn btn-danger" style="display: none;">Delete Selected Files</button>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingCoomerDownloader">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseCoomerDownloader" aria-expanded="false" aria-controls="collapseCoomerDownloader">
Coomer Downloader
</button>
</h2>
<div id="collapseCoomerDownloader" class="accordion-collapse collapse" aria-labelledby="headingCoomerDownloader" data-bs-parent="#mainAccordion">
<div class="accordion-body">
<div class="card p-4">
<h2 class="card-title h4 mb-4"></h2>
<div class="mb-3">
<label for="coomer-url" class="form-label">Artist URL</label>
<input type="url" class="form-control" id="coomer-url" placeholder="https://coomer.st/onlyfans/user/artistname">
</div>
<div class="d-grid">
<button id="coomer-download-btn" class="btn btn-primary">Download Videos</button>
</div>
<div id="coomer-terminal" class="terminal-container mt-3" style="display: none;">
<pre class="terminal bg-dark text-white p-3 rounded"><code></code></pre>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingVideoDownloader">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseVideoDownloader" aria-expanded="false" aria-controls="collapseVideoDownloader">
Video Downloader
</button>
</h2>
<div id="collapseVideoDownloader" class="accordion-collapse collapse" aria-labelledby="headingVideoDownloader" data-bs-parent="#mainAccordion">
<div class="accordion-body">
<div class="card p-4">
<h2 class="card-title h4 mb-4"></h2>
<div class="mb-3">
<label for="video-url" class="form-label">Video URL</label>
<input type="url" class="form-control" id="video-url" placeholder="Enter video URL">
</div>
<div class="mb-3">
<label for="video-dest" class="form-label">Destination Path</label>
<input type="text" class="form-control" id="video-dest" placeholder="//truenas.local/isolation/downloads">
</div>
<div class="d-grid">
<button id="video-download-btn" class="btn btn-primary">Download Video</button>
</div>
<div id="video-terminal" class="terminal-container mt-3" style="display: none;">
<pre class="terminal bg-dark text-white p-3 rounded"><code></code></pre>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingTranscodeReplacement">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseTranscodeReplacement" aria-expanded="false" aria-controls="collapseTranscodeReplacement">
Transcode Replacement
</button>
</h2>
<div id="collapseTranscodeReplacement" class="accordion-collapse collapse" aria-labelledby="headingTranscodeReplacement" data-bs-parent="#mainAccordion">
<div class="accordion-body">
<div class="card p-4">
<h2 class="card-title h4 mb-4"></h2>
<div class="d-grid">
<button id="plan-btn" class="btn btn-primary">Plan Replacement</button>
</div>
<div id="transcode-terminal" class="terminal-container mt-3" style="display: none;">
<pre class="terminal bg-dark text-white p-3 rounded"><code></code></pre>
</div>
<div id="replacement-plan" class="mt-4"></div>
<div class="d-grid mt-3">
<button id="execute-plan-btn" class="btn btn-danger" style="display: none;">Execute Replacement</button>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingFileExplorer">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseFileExplorer" aria-expanded="false" aria-controls="collapseFileExplorer">
File Explorer
</button>
</h2>
<div id="collapseFileExplorer" class="accordion-collapse collapse" aria-labelledby="headingFileExplorer" data-bs-parent="#mainAccordion">
<div class="accordion-body">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<nav aria-label="breadcrumb">
<ol class="breadcrumb" id="breadcrumb-container">
</ol>
</nav>
<button id="new-folder-btn" class="btn btn-sm btn-primary">New Folder</button>
</div>
<div id="file-explorer-list" class="list-group"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Preview Modal -->
<div class="modal fade" id="preview-modal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="preview-modal-title"></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="preview-modal-body" style="text-align: center;">
</div>
</div>
</div>
</div>
<!-- Move Modal -->
<div class="modal fade" id="move-modal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Move File</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="move-modal-body">
<p>Select a destination folder:</p>
<nav aria-label="breadcrumb">
<ol class="breadcrumb" id="move-breadcrumb-container">
</ol>
</nav>
<div id="move-explorer-list" class="list-group"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="move-confirm-btn">Select Folder</button>
</div>
</div>
</div>
</div>
<!-- Toast container -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', async () => {
const themeSwitcher = document.getElementById('theme-switcher');
const htmlEl = document.documentElement;
const savedTheme = localStorage.getItem('theme') || 'light';
htmlEl.setAttribute('data-bs-theme', savedTheme);
if (savedTheme === 'dark') {
themeSwitcher.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-sun-fill" viewBox="0 0 16 16"><path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm-5-5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5zM13 8a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5zM4.223 4.223a.5.5 0 0 1 .707 0l1.414 1.414a.5.5 0 0 1-.707.707L4.223 4.93a.5.5 0 0 1 0-.707zm9.193 9.193a.5.5 0 0 1 .707 0l1.414 1.414a.5.5 0 0 1-.707.707l-1.414-1.414a.5.5 0 0 1 0-.707zm-9.193 0a.5.5 0 0 1 0 .707L3.03 13.07a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193-9.193a.5.5 0 0 1 0 .707L11.97 4.93a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0z"/></svg>';
}
themeSwitcher.addEventListener('click', () => {
const currentTheme = htmlEl.getAttribute('data-bs-theme');
if (currentTheme === 'dark') {
htmlEl.setAttribute('data-bs-theme', 'light');
localStorage.setItem('theme', 'light');
themeSwitcher.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-moon-stars-fill" viewBox="0 0 16 16"><path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/><path d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162h1.212a.217.217 0 0 1 .162.321l-.988.717.387 1.162a.217.217 0 0 1-.321.242l-.988-.717-.988.717a.217.217 0 0 1-.321-.242l.387-1.162-.988-.717a.217.217 0 0 1 .162-.321h1.212l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258c-.346.115-.617.386-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.724 1.724 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617.386.732-.732L13.863.1z"/></svg>';
} else {
htmlEl.setAttribute('data-bs-theme', 'dark');
localStorage.setItem('theme', 'dark');
themeSwitcher.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-sun-fill" viewBox="0 0 16 16"><path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm-5-5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5zM13 8a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5zM4.223 4.223a.5.5 0 0 1 .707 0l1.414 1.414a.5.5 0 0 1-.707.707L4.223 4.93a.5.5 0 0 1 0-.707zm9.193 9.193a.5.5 0 0 1 .707 0l1.414 1.414a.5.5 0 0 1-.707.707l-1.414-1.414a.5.5 0 0 1 0-.707zm-9.193 0a.5.5 0 0 1 0 .707L3.03 13.07a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193-9.193a.5.5 0 0 1 0 .707L11.97 4.93a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0z"/></svg>';
}
});
const messages = document.getElementById('messages');
const changeCredsBtn = document.getElementById('change-creds-btn');
const credentialsForm = document.getElementById('credentials-form');
const saveCredsBtn = document.getElementById('save-creds-btn');
const truenasUsernameEl = document.getElementById('truenas-username');
const truenasPasswordEl = document.getElementById('truenas-password');
const servervmUsernameEl = document.getElementById('servervm-username');
const servervmPasswordEl = document.getElementById('servervm-password');
const qbHostEl = document.getElementById('qb-host');
const qbPortEl = document.getElementById('qb-port');
const qbUsernameEl = document.getElementById('qb-username');
const qbPasswordEl = document.getElementById('qb-password');
const checkConnectionsBtn = document.getElementById('check-connections-btn');
const dbStatusIndicator = document.getElementById('db-status-indicator');
const truenasStatusIndicator = document.getElementById('truenas-status-indicator');
const servervmStatusIndicator = document.getElementById('servervm-status-indicator');
const qbStatusIndicator = document.getElementById('qb-status-indicator');
async function loadCredentials() {
const response = await fetch('/api/get_credentials');
const result = await response.json();
if (result.credentials) {
const creds = result.credentials;
if (creds['truenas.local']) {
truenasUsernameEl.value = creds['truenas.local'].username || '';
truenasPasswordEl.value = creds['truenas.local'].password || '';
}
if (creds['servervm.local']) {
servervmUsernameEl.value = creds['servervm.local'].username || '';
servervmPasswordEl.value = creds['servervm.local'].password || '';
}
if (creds['qbittorrent']) {
qbHostEl.value = creds['qbittorrent'].host || '';
qbPortEl.value = creds['qbittorrent'].port || '';
qbUsernameEl.value = creds['qbittorrent'].username || '';
qbPasswordEl.value = creds['qbittorrent'].password || '';
}
}
const hasCredsResponse = await fetch('/api/check_credentials');
const hasCredsResult = await hasCredsResponse.json();
return hasCredsResult.has_credentials;
}
const hasCredentials = await loadCredentials();
if (!hasCredentials) {
credentialsForm.style.display = 'block';
}
changeCredsBtn.addEventListener('click', () => {
const isHidden = credentialsForm.style.display === 'none';
credentialsForm.style.display = isHidden ? 'block' : 'none';
});
saveCredsBtn.addEventListener('click', async () => {
const credentials = {
'truenas.local': {
username: truenasUsernameEl.value,
password: truenasPasswordEl.value
},
'servervm.local': {
username: servervmUsernameEl.value,
password: servervmPasswordEl.value
},
'qbittorrent': {
host: qbHostEl.value,
port: qbPortEl.value,
username: qbUsernameEl.value,
password: qbPasswordEl.value
}
};
const response = await fetch('/api/save_credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const result = await response.json();
if (result.success) {
showMessage('Credentials saved successfully.', 'success');
credentialsForm.style.display = 'none';
await checkAllConnections();
} else {
showMessage('Failed to save credentials.', 'danger');
}
});
checkConnectionsBtn.addEventListener('click', checkAllConnections);
async function checkCredentials() {
const response = await fetch('/api/check_credentials');
const result = await response.json();
return result.has_credentials;
}
async function checkAllConnections() {
if (!await checkCredentials()) {
showMessage('Please save your credentials to check the connections.', 'warning');
return;
}
const indicators = {
stash_db: dbStatusIndicator,
truenas: truenasStatusIndicator,
servervm: servervmStatusIndicator,
qbittorrent: qbStatusIndicator
};
Object.values(indicators).forEach(indicator => {
if (indicator) {
indicator.className = 'badge bg-warning text-dark';
indicator.textContent = 'Checking...';
}
});
try {
const response = await fetch('/api/check_connections', { method: 'POST' });
const results = await response.json();
for (const [name, result] of Object.entries(results)) {
const indicator = indicators[name];
if (indicator) {
if (result.success) {
indicator.className = 'badge bg-success';
indicator.textContent = 'Connected';
} else {
indicator.className = 'badge bg-danger';
indicator.textContent = 'Failed';
showMessage(`Failed to connect to ${name}: ${result.message}`, 'danger');
}
}
}
} catch (e) {
Object.values(indicators).forEach(indicator => {
if(indicator) {
indicator.className = 'badge bg-danger';
indicator.textContent = 'Error';
}
});
showMessage(`Error checking connections: ${e.message}`, 'danger');
}
}
function showMessage(message, type = 'info') {
const toastContainer = document.querySelector('.toast-container');
const toastId = 'toast-' + Date.now();
const toastHTML = `
<div id="${toastId}" class="toast align-items-center text-bg-${type} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
toastContainer.innerHTML += toastHTML;
const toastEl = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastEl, {
delay: 5000 // Auto-hide after 5 seconds
});
toast.show();
toastEl.addEventListener('hidden.bs.toast', () => {
toastEl.remove();
});
}
function poll_logs(scan_id, terminal_el) {
terminal_el.style.display = 'block';
const code_el = terminal_el.querySelector('code');
const interval = setInterval(async () => {
const response = await fetch(`/api/scan_log/${scan_id}`);
const result = await response.json();
code_el.textContent = result.log;
terminal_el.scrollTop = terminal_el.scrollHeight; // Auto-scroll
// Check scan status to stop polling
const status_response = await fetch(`/api/scan_result/${scan_id}`);
const status_result = await status_response.json();
if (status_result.scan_info.status === 'completed' || status_result.scan_info.status === 'error') {
clearInterval(interval);
// Final log update
const final_response = await fetch(`/api/scan_log/${scan_id}`);
const final_result = await final_response.json();
code_el.textContent = final_result.log;
terminal_el.scrollTop = terminal_el.scrollHeight;
}
}, 2000); // Poll every 2 seconds
}
await checkAllConnections();
browse('');
});
// --- Duplicate Finder ---
const scanBtn = document.getElementById('scan-btn');
const duplicateResults = document.getElementById('duplicate-results');
const deleteSelectedBtn = document.getElementById('delete-selected-btn');
const duplicateTerminal = document.getElementById('duplicate-terminal');
scanBtn.addEventListener('click', async () => {
scanBtn.disabled = true;
scanBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Scanning...';
duplicateTerminal.style.display = 'none';
duplicateResults.innerHTML = '';
const response = await fetch('/api/find_duplicates', { method: 'POST' });
const result = await response.json();
if (result.error) {
showMessage(result.error, 'danger');
scanBtn.disabled = false;
scanBtn.innerHTML = 'Scan for Duplicates';
return;
}
poll_logs(result.scan_id, duplicateTerminal);
// Also check for the final result to re-enable button and render results
const resultInterval = setInterval(async () => {
const status_response = await fetch(`/api/scan_result/${result.scan_id}`);
const status_result = await status_response.json();
if (status_result.scan_info.status === 'completed' || status_result.scan_info.status === 'error') {
clearInterval(resultInterval);
scanBtn.disabled = false;
scanBtn.innerHTML = 'Scan for Duplicates';
if(status_result.scan_info.status === 'completed') {
renderDuplicateResults(status_result.results);
}
}
}, 2000);
});
function renderDuplicateResults(duplicateSets) {
duplicateResults.innerHTML = '';
if (duplicateSets.length === 0) {
duplicateResults.innerHTML = '<p class="text-center text-muted">No duplicates found.</p>';
deleteSelectedBtn.style.display = 'none';
return;
}
duplicateSets.forEach((set, index) => {
const setEl = document.createElement('div');
setEl.className = 'mb-4';
const basename = set[0].basename;
setEl.innerHTML = `<h5>${basename}</h5>`;
const fileGroup = document.createElement('div');
fileGroup.className = 'list-group';
set.forEach(file => {
const fileItem = document.createElement('label');
fileItem.className = 'list-group-item d-flex justify-content-between align-items-center';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'form-check-input me-3';
checkbox.value = file.path;
const details = `<span>${file.path} (${(file.size / 1024 / 1024).toFixed(2)} MB)</span>`;
fileItem.innerHTML = details;
fileItem.prepend(checkbox);
fileGroup.appendChild(fileItem);
});
setEl.appendChild(fileGroup);
duplicateResults.appendChild(setEl);
});
deleteSelectedBtn.style.display = 'block';
}
deleteSelectedBtn.addEventListener('click', async () => {
const selectedFiles = Array.from(document.querySelectorAll('#duplicate-results input[type=checkbox]:checked')).map(cb => cb.value);
if (selectedFiles.length === 0) {
showMessage('Please select files to delete.', 'warning');
return;
}
if (!confirm(`Are you sure you want to delete ${selectedFiles.length} files?`)) {
return;
}
const response = await fetch('/api/delete_files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: selectedFiles })
});
const result = await response.json();
if (result.errors && result.errors.length > 0) {
result.errors.forEach(err => showMessage(`Failed to delete ${err.file}: ${err.error}`, 'danger'));
}
if (result.deleted && result.deleted.length > 0) {
showMessage(`${result.deleted.length} files deleted successfully.`, 'success');
}
scanBtn.click();
});
// --- Transcode Replacement ---
const planBtn = document.getElementById('plan-btn');
const replacementPlanDiv = document.getElementById('replacement-plan');
const executePlanBtn = document.getElementById('execute-plan-btn');
const transcodeTerminal = document.getElementById('transcode-terminal');
planBtn.addEventListener('click', async () => {
planBtn.disabled = true;
planBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Planning...';
transcodeTerminal.style.display = 'none';
replacementPlanDiv.innerHTML = '';
const response = await fetch('/api/transcode_replacement_plan', { method: 'POST' });
const result = await response.json();
if (result.error) {
showMessage(result.error, 'danger');
planBtn.disabled = false;
planBtn.innerHTML = 'Plan Replacement';
return;
}
poll_logs(result.scan_id, transcodeTerminal);
const resultInterval = setInterval(async () => {
const status_response = await fetch(`/api/scan_result/${result.scan_id}`);
const status_result = await status_response.json();
if (status_result.scan_info.status === 'completed' || status_result.scan_info.status === 'error') {
clearInterval(resultInterval);
planBtn.disabled = false;
planBtn.innerHTML = 'Plan Replacement';
if (status_result.scan_info.status === 'completed') {
renderReplacementPlan(status_result.results);
currentPlan = status_result.results;
}
}
}, 2000);
});
function renderReplacementPlan(plan) {
replacementPlanDiv.innerHTML = '';
if (plan.length === 0) {
replacementPlanDiv.innerHTML = '<p class="text-center text-muted">No replacements to be made.</p>';
executePlanBtn.style.display = 'none';
return;
}
const table = document.createElement('table');
table.className = 'table table-striped';
table.innerHTML = `
<thead>
<tr>
<th>Original File</th>
<th>Transcoded File</th>
</tr>
</thead>
`;
const tbody = document.createElement('tbody');
plan.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${item.original}</td>
<td>${item.transcoded}</td>
`;
tbody.appendChild(row);
});
table.appendChild(tbody);
replacementPlanDiv.appendChild(table);
executePlanBtn.style.display = 'block';
}
executePlanBtn.addEventListener('click', async () => {
if (!confirm('Are you sure you want to execute the replacement plan? This will overwrite files.')) {
return;
}
const response = await fetch('/api/execute_transcode_replacement', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan: currentPlan })
});
const result = await response.json();
if (result.errors && result.errors.length > 0) {
result.errors.forEach(err => showMessage(`Failed to replace ${err.original}: ${err.error}`, 'danger'));
}
if (result.replaced && result.replaced.length > 0) {
showMessage(`${result.replaced.length} files replaced successfully.`, 'success');
}
planBtn.click();
});
let currentPlan = [];
// --- Coomer Downloader ---
const coomerDownloadBtn = document.getElementById('coomer-download-btn');
const coomerUrlEl = document.getElementById('coomer-url');
const coomerTerminal = document.getElementById('coomer-terminal');
coomerDownloadBtn.addEventListener('click', async () => {
const url = coomerUrlEl.value;
if (!url) {
showMessage('Please enter a URL.', 'warning');
return;
}
coomerDownloadBtn.disabled = true;
coomerDownloadBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Downloading...';
coomerTerminal.style.display = 'block';
coomerTerminal.querySelector('code').textContent = '';
const response = await fetch('/api/download_coomer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url })
});
const result = await response.json();
if (result.error) {
showMessage(result.error, 'danger');
coomerDownloadBtn.disabled = false;
coomerDownloadBtn.innerHTML = 'Download Videos';
return;
}
poll_logs(result.scan_id, coomerTerminal);
const resultInterval = setInterval(async () => {
const status_response = await fetch(`/api/scan_result/${result.scan_id}`);
const status_result = await status_response.json();
if (status_result.scan_info.status === 'completed' || status_result.scan_info.status === 'error') {
clearInterval(resultInterval);
coomerDownloadBtn.disabled = false;
coomerDownloadBtn.innerHTML = 'Download Videos';
}
}, 2000);
});
// --- Video Downloader ---
const videoDownloadBtn = document.getElementById('video-download-btn');
const videoUrlEl = document.getElementById('video-url');
const videoDestEl = document.getElementById('video-dest');
const videoTerminal = document.getElementById('video-terminal');
videoDownloadBtn.addEventListener('click', async () => {
const url = videoUrlEl.value;
const dest = videoDestEl.value;
if (!url || !dest) {
showMessage('Please enter a URL and a destination path.', 'warning');
return;
}
videoDownloadBtn.disabled = true;
videoDownloadBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Downloading...';
videoTerminal.style.display = 'block';
videoTerminal.querySelector('code').textContent = '';
const response = await fetch('/api/download_video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url, destination_path: dest })
});
const result = await response.json();
if (result.error) {
showMessage(result.error, 'danger');
videoDownloadBtn.disabled = false;
videoDownloadBtn.innerHTML = 'Download Video';
return;
}
poll_logs(result.scan_id, videoTerminal);
const resultInterval = setInterval(async () => {
const status_response = await fetch(`/api/scan_result/${result.scan_id}`);
const status_result = await status_response.json();
if (status_result.scan_info.status === 'completed' || status_result.scan_info.status === 'error') {
clearInterval(resultInterval);
videoDownloadBtn.disabled = false;
videoDownloadBtn.innerHTML = 'Download Video';
}
}, 2000);
});
// --- qBittorrent ---
const qbTorrentsDiv = document.getElementById('qb-torrents');
async function fetchAndRenderTorrents() {
const response = await fetch('/api/qb_torrents');
if (!response.ok) {
qbTorrentsDiv.innerHTML = '<p class="text-center text-muted">Could not connect to qBittorrent. Check credentials.</p>';
return;
}
const torrents = await response.json();
if(torrents.error) {
qbTorrentsDiv.innerHTML = `<p class="text-center text-muted">${torrents.error}</p>`;
return;
}
renderTorrents(torrents);
}
function renderTorrents(torrents) {
qbTorrentsDiv.innerHTML = '';
if (torrents.length === 0) {
qbTorrentsDiv.innerHTML = '<p class="text-center text-muted">No torrents to display.</p>';
return;
}
const listGroup = document.createElement('div');
listGroup.className = 'list-group';
torrents.forEach(torrent => {
const listItem = document.createElement('div');
listItem.className = 'list-group-item';
const titleEl = document.createElement('h6');
titleEl.className = 'mb-1 text-break'; // text-break for wrapping
titleEl.textContent = torrent.name;
listItem.appendChild(titleEl);
const savePathEl = document.createElement('small');
savePathEl.className = 'text-muted d-block text-break';
savePathEl.innerHTML = `&#128193; <strong>Path:</strong> ${torrent.save_path}`;
listItem.appendChild(savePathEl);
const statusLine = document.createElement('small');
statusLine.className = 'text-muted';
statusLine.innerHTML = `
Status: <strong>${torrent.status}</strong> | Progress: <strong>${(torrent.progress * 100).toFixed(2)}%</strong><br>
Size: ${(torrent.size / 1024 / 1024 / 1024).toFixed(2)} GB | DL: ${(torrent.dlspeed / 1024 / 1024).toFixed(2)} MB/s | UP: ${(torrent.upspeed / 1024 / 1024).toFixed(2)} MB/s | ETA: ${new Date(torrent.eta * 1000).toISOString().substr(11, 8)}
`;
listItem.appendChild(statusLine);
const actionsEl = document.createElement('div');
actionsEl.className = 'mt-2 d-flex justify-content-start flex-wrap'; // Use flexbox for buttons
const resumeBtn = createButton('▶ Resume', 'btn-sm btn-success me-1 mb-1', () => qbAction('resume', torrent.hash));
const pauseBtn = createButton('❚❚ Pause', 'btn-sm btn-warning me-1 mb-1', () => qbAction('pause', torrent.hash));
const forceResumeBtn = createButton('⟳ Force Resume', 'btn-sm btn-info me-1 mb-1', () => qbAction('force_resume', torrent.hash));
const recheckBtn = createButton('? Recheck', 'btn-sm btn-secondary me-1 mb-1', () => qbAction('recheck', torrent.hash));
const setLocationBtn = createButton('Set Location', 'btn-sm btn-primary me-1 mb-1', () => openQbMoveModal(torrent.hash));
const deleteBtn = createButton('✖ Delete', 'btn-sm btn-danger me-1 mb-1', () => qbAction('delete', torrent.hash, true));
actionsEl.appendChild(resumeBtn);
actionsEl.appendChild(pauseBtn);
actionsEl.appendChild(forceResumeBtn);
actionsEl.appendChild(recheckBtn);
actionsEl.appendChild(setLocationBtn);
actionsEl.appendChild(deleteBtn);
listItem.appendChild(actionsEl);
listGroup.appendChild(listItem);
});
qbTorrentsDiv.appendChild(listGroup);
}
async function qbAction(action, hash, confirmDelete = false) {
if (action === 'delete' && confirmDelete && !confirm('Are you sure you want to delete this torrent and its files?')) {
return;
}
await fetch('/api/qb_action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, hashes: [hash] })
});
fetchAndRenderTorrents();
}
setInterval(fetchAndRenderTorrents, 5000); // Refresh every 5 seconds
// --- File Explorer ---
const fileExplorerList = document.getElementById('file-explorer-list');
const breadcrumbContainer = document.getElementById('breadcrumb-container');
let currentPath = '';
async function browse(path) {
currentPath = path;
fileExplorerList.innerHTML = '<div class="text-center"><div class="spinner-border" role="status"><span class="visually-hidden">Loading...</span></div></div>';
const response = await fetch('/api/browse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path || undefined })
});
const items = await response.json();
renderFileExplorer(items);
renderBreadcrumbs(path);
}
function renderFileExplorer(items) {
fileExplorerList.innerHTML = '';
if (items.error) {
fileExplorerList.innerHTML = `<div class="alert alert-danger">${items.error}</div>`;
return;
}
items.sort((a, b) => {
if (a.is_dir !== b.is_dir) {
return a.is_dir ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
items.forEach(item => {
const isDir = item.is_dir;
const icon = isDir ? '&#128193;' : '&#128196;';
const listItem = document.createElement('div');
listItem.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center file-explorer-item';
const nameEl = document.createElement('a');
nameEl.href = '#';
nameEl.innerHTML = `${icon} ${item.name}`;
nameEl.addEventListener('click', (e) => {
e.preventDefault();
if (isDir) {
browse(item.path);
} else {
previewFile(item.path, item.name);
}
});
const actionsEl = document.createElement('div');
actionsEl.className = 'actions';
const renameBtn = createButton('Rename', 'btn-sm btn-outline-primary me-2', () => renameFile(item.path, item.name));
const moveBtn = createButton('Move', 'btn-sm btn-outline-secondary me-2', () => moveFile(item.path));
const deleteBtn = createButton('Delete', 'btn-sm btn-outline-danger', () => deleteFile(item.path));
actionsEl.appendChild(renameBtn);
actionsEl.appendChild(moveBtn);
actionsEl.appendChild(deleteBtn);
listItem.appendChild(nameEl);
listItem.appendChild(actionsEl);
fileExplorerList.appendChild(listItem);
});
}
function createButton(text, classes, onClick) {
const button = document.createElement('button');
button.textContent = text;
button.className = `btn ${classes}`;
button.addEventListener('click', onClick);
return button;
}
async function renameFile(path, oldName) {
const newName = prompt('Enter new name:', oldName);
if(newName && newName !== oldName) {
const response = await fetch('/api/rename_file', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({old_path: path, new_name: newName})
});
const result = await response.json();
if(result.success) {
browse(currentPath);
} else {
alert(`Error: ${result.error}`);
}
}
}
const moveModal = new bootstrap.Modal(document.getElementById('move-modal'));
const moveConfirmBtn = document.getElementById('move-confirm-btn');
const moveExplorerList = document.getElementById('move-explorer-list');
const moveBreadcrumbContainer = document.getElementById('move-breadcrumb-container');
let moveSourcePath = '';
let moveCurrentPath = '';
let modalContext = 'move'; // 'move' or 'qb_torrent_location'
let moveTorrentHash = '';
async function moveFile(path) {
console.log('moveFile called with path:', path);
modalContext = 'move';
moveSourcePath = path;
document.querySelector('#move-modal .modal-title').textContent = 'Move File';
await browseMoveModal('');
moveModal.show();
}
async function openQbMoveModal(hash) {
modalContext = 'qb_torrent_location';
moveTorrentHash = hash;
document.querySelector('#move-modal .modal-title').textContent = 'Set Torrent Location';
await browseMoveModal('');
moveModal.show();
}
async function browseMoveModal(path) {
console.log('browseMoveModal called with path:', path);
moveCurrentPath = path;
moveExplorerList.innerHTML = '<div class="text-center"><div class="spinner-border" role="status"><span class="visually-hidden">Loading...</span></div></div>';
const response = await fetch('/api/browse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path || undefined })
});
const items = await response.json();
renderMoveExplorer(items.filter(item => item.is_dir));
renderMoveBreadcrumbs(path);
}
function renderMoveExplorer(items) {
moveExplorerList.innerHTML = '';
if (items.error) {
moveExplorerList.innerHTML = `<div class="alert alert-danger">${items.error}</div>`;
return;
}
items.sort((a, b) => a.name.localeCompare(b.name));
items.forEach(item => {
const icon = '&#128193;';
const listItem = document.createElement('a');
listItem.href = '#';
listItem.className = 'list-group-item list-group-item-action';
listItem.innerHTML = `${icon} ${item.name}`;
listItem.addEventListener('click', (e) => {
e.preventDefault();
browseMoveModal(item.path);
});
moveExplorerList.appendChild(listItem);
});
}
function renderMoveBreadcrumbs(path) {
moveBreadcrumbContainer.innerHTML = '';
let pathParts = path.split(/[\/]/).filter(p => p);
let currentBreadcrumbPath = '//';
let rootItem = document.createElement('li');
rootItem.className = 'breadcrumb-item';
let rootLink = document.createElement('a');
rootLink.href = '#';
rootLink.innerText = 'smb:';
rootLink.addEventListener('click', (e) => {
e.preventDefault();
browseMoveModal('');
});
rootItem.appendChild(rootLink);
moveBreadcrumbContainer.appendChild(rootItem);
pathParts.forEach((part, index) => {
if(index > 0) {
currentBreadcrumbPath += (index == 1 ? '' : '/') + part;
let breadcrumbItem = document.createElement('li');
breadcrumbItem.className = 'breadcrumb-item';
if (index === pathParts.length - 1) {
breadcrumbItem.className += ' active';
breadcrumbItem.setAttribute('aria-current', 'page');
breadcrumbItem.innerText = part;
} else {
let link = document.createElement('a');
link.href = '#';
link.innerText = part;
const capturedPath = currentBreadcrumbPath;
link.addEventListener('click', (e) => {
e.preventDefault();
browseMoveModal(capturedPath);
});
breadcrumbItem.appendChild(link);
}
moveBreadcrumbContainer.appendChild(breadcrumbItem);
}
});
}
moveConfirmBtn.addEventListener('click', async () => {
const destPath = moveCurrentPath;
console.log('Move Confirm button clicked. Context:', modalContext);
console.log('Source:', moveSourcePath);
console.log('Destination:', destPath);
if (modalContext === 'move') {
if (moveSourcePath && destPath) {
const response = await fetch('/api/move_file', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({source_path: moveSourcePath, dest_path: destPath})
});
const result = await response.json();
if(result.success) {
moveModal.hide();
showMessage('File moved successfully!', 'success');
browse(currentPath);
} else {
showMessage(`Error moving file: ${result.error}`, 'danger');
}
}
} else if (modalContext === 'qb_torrent_location') {
console.log('Setting qb torrent location for hash:', moveTorrentHash);
const response = await fetch('/api/qb_set_location', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hashes: [moveTorrentHash], location: destPath})
});
const result = await response.json();
if(result.success) {
moveModal.hide();
showMessage('Torrent location set successfully!', 'success');
fetchAndRenderTorrents();
} else {
showMessage(`Error setting torrent location: ${result.error}`, 'danger');
}
}
});
async function deleteFile(path) {
if(confirm(`Are you sure you want to delete ${path}?`)) {
const response = await fetch('/api/delete_files', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({files: [path]})
});
const result = await response.json();
if(result.errors && result.errors.length > 0) {
alert(`Error: ${result.errors[0].error}`);
}
browse(currentPath);
}
}
function previewFile(path, name) {
const modalTitle = document.getElementById('preview-modal-title');
const modalBody = document.getElementById('preview-modal-body');
modalTitle.textContent = name;
const fileExtension = path.split('.').pop().toLowerCase();
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(fileExtension);
const isVideo = ['mp4', 'webm', 'ogv', 'mov', 'mkv', 'avi'].includes(fileExtension);
const isCbz = fileExtension === 'cbz';
if (isImage) {
modalBody.innerHTML = `<img src="/api/preview?path=${encodeURIComponent(path)}" class="img-fluid">`;
} else if (isVideo) {
modalBody.innerHTML = `<video controls autoplay class="w-100"><source src="/api/preview?path=${encodeURIComponent(path)}" type="video/${fileExtension}"></video>`;
} else if (isCbz) {
modalBody.innerHTML = '<div class="text-center"><div class="spinner-border" role="status"><span class="visually-hidden">Loading...</span></div></div>';
fetch(`/api/preview_cbz?path=${encodeURIComponent(path)}`)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(images => {
modalBody.innerHTML = '';
images.forEach(image => {
const img = document.createElement('img');
img.src = image;
img.className = 'img-fluid mb-2';
modalBody.appendChild(img);
});
})
.catch(error => {
modalBody.innerHTML = `<div class="alert alert-danger">Error loading CBZ file: ${error}</div>`;
});
} else {
modalBody.innerHTML = 'Preview not available for this file type.';
}
const previewModal = new bootstrap.Modal(document.getElementById('preview-modal'));
previewModal.show();
}
function renderBreadcrumbs(path) {
breadcrumbContainer.innerHTML = '';
let pathParts = path.split(/[\/]/).filter(p => p);
let currentBreadcrumbPath = '//';
let rootItem = document.createElement('li');
rootItem.className = 'breadcrumb-item';
let rootLink = document.createElement('a');
rootLink.href = '#';
rootLink.innerText = 'smb:';
rootLink.addEventListener('click', (e) => {
e.preventDefault();
browse('');
});
rootItem.appendChild(rootLink);
breadcrumbContainer.appendChild(rootItem);
pathParts.forEach((part, index) => {
if(index > 0) {
currentBreadcrumbPath += (index == 1 ? '' : '/') + part;
let breadcrumbItem = document.createElement('li');
breadcrumbItem.className = 'breadcrumb-item';
if (index === pathParts.length - 1) {
breadcrumbItem.className += ' active';
breadcrumbItem.setAttribute('aria-current', 'page');
breadcrumbItem.innerText = part;
} else {
let link = document.createElement('a');
link.href = '#';
link.innerText = part;
const capturedPath = currentBreadcrumbPath;
link.addEventListener('click', (e) => {
e.preventDefault();
browse(capturedPath);
});
breadcrumbItem.appendChild(link);
}
breadcrumbContainer.appendChild(breadcrumbItem);
}
});
}
</script>
</body>
</html>