Add /unsorted subtab to SMB Share Sorter with independent state, scan, and cleanup

This commit is contained in:
david
2026-08-26 10:07:37 -04:00
parent c51433e9e1
commit b2a570b11e
3 changed files with 172 additions and 22 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ An automated, AI-powered media organizer with in-browser video streaming, multi-
- **Dual-Tab Media Sorter**: - **Dual-Tab Media Sorter**:
- **qBittorrent Library Sorter**: Analyzes torrent titles & internal files and moves torrent content seamlessly without breaking active seeding. - **qBittorrent Library Sorter**: Analyzes torrent titles & internal files and moves torrent content seamlessly without breaking active seeding.
- **SMB / Network Share Sorter**: Directly scans video shares (`/mnt/isolation/videos/videodownloader`), parses performer names, and relocates them into categorized folders (`/mnt/isolation/videos/<performer>/`). - **SMB / Network Share Sorter**: Directly scans video shares with dedicated sub-tabs for `/videodownloader` (`/mnt/isolation/videos/videodownloader`) and `/unsorted` (`/mnt/isolation/videos/unsorted`), parses performer names, and relocates them into categorized folders (`/mnt/isolation/videos/<performer>/`).
- **Multi-AI Provider Support**: - **Multi-AI Provider Support**:
- **Google Gemini** (Gemini 2.5/3.0+ models) - **Google Gemini** (Gemini 2.5/3.0+ models)
- **Anthropic Claude** (Claude 3.5 Sonnet, Haiku, Opus) - **Anthropic Claude** (Claude 3.5 Sonnet, Haiku, Opus)
+170 -20
View File
@@ -195,6 +195,62 @@
border-color: var(--accent-blue); border-color: var(--accent-blue);
} }
/* Sub-tabs Navigation */
.sub-tabs-nav {
display: flex;
gap: 0.5rem;
align-items: center;
background: rgba(15, 23, 42, 0.6);
padding: 0.35rem 0.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
width: fit-content;
margin-bottom: 0.25rem;
}
.sub-tab-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.9rem;
border-radius: calc(var(--radius-md) - 2px);
background: transparent;
border: 1px solid transparent;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-family: var(--font-sans);
}
.sub-tab-btn:hover {
color: var(--text-main);
background: rgba(255, 255, 255, 0.04);
}
.sub-tab-btn.active {
color: #fff;
background: rgba(37, 99, 235, 0.25);
border-color: var(--accent-blue);
box-shadow: 0 0 8px rgba(37, 99, 235, 0.2);
}
.sub-tab-badge {
font-size: 0.65rem;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--border-color);
color: var(--text-muted);
}
.sub-tab-btn.active .sub-tab-badge {
background: var(--accent-blue);
color: #fff;
border-color: var(--accent-blue);
}
.tab-panel { .tab-panel {
display: none; display: none;
} }
@@ -1106,7 +1162,7 @@
</button> </button>
<button class="tab-btn" id="tab-btn-smb"> <button class="tab-btn" id="tab-btn-smb">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg> <svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
<span>SMB Share Sorter (videodownloader → /isolation/videos)</span> <span>SMB Share Sorter (/isolation/videos)</span>
<span class="tab-badge" id="smb-tab-badge">0</span> <span class="tab-badge" id="smb-tab-badge">0</span>
</button> </button>
<button class="tab-btn" id="tab-btn-stash-images"> <button class="tab-btn" id="tab-btn-stash-images">
@@ -1445,6 +1501,27 @@
<!-- ==================== TAB 2: SMB Share Video Downloader ==================== --> <!-- ==================== TAB 2: SMB Share Video Downloader ==================== -->
<div class="tab-panel" id="tab-panel-smb"> <div class="tab-panel" id="tab-panel-smb">
<!-- SMB Folder Sub-Tabs Navigation -->
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem;">
<div class="sub-tabs-nav" id="smb-sub-tabs">
<button type="button" class="sub-tab-btn active" id="sub-tab-btn-videodownloader" data-subtab="videodownloader">
<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
<span>/videodownloader</span>
<span class="sub-tab-badge" id="sub-tab-badge-videodownloader">0</span>
</button>
<button type="button" class="sub-tab-btn" id="sub-tab-btn-unsorted" data-subtab="unsorted">
<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
<span>/unsorted</span>
<span class="sub-tab-badge" id="sub-tab-badge-unsorted">0</span>
</button>
</div>
<div style="font-size: 0.75rem; color: var(--text-muted); display: flex; align-items: center; gap: 0.4rem;">
<span>Active Folder:</span>
<span class="badge badge-path" id="smb-active-folder-badge" style="font-family: var(--font-mono); text-transform: none;">/mnt/isolation/videos/videodownloader</span>
</div>
</div>
<!-- SMB Stats Row --> <!-- SMB Stats Row -->
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-card">
@@ -2617,13 +2694,41 @@
}; };
const smbState = { const smbState = {
files: [], activeSubtab: 'videodownloader', // 'videodownloader' or 'unsorted'
folders: {
videodownloader: {
files: [],
selectedPaths: new Set(),
sourcePath: '/mnt/isolation/videos/videodownloader',
label: '/videodownloader'
},
unsorted: {
files: [],
selectedPaths: new Set(),
sourcePath: '/mnt/isolation/videos/unsorted',
label: '/unsorted'
}
},
isAnalyzing: false, isAnalyzing: false,
isMoving: false, isMoving: false,
abortAnalysis: false, abortAnalysis: false
selectedPaths: new Set()
}; };
// Helper to get active SMB folder store
function getActiveSmbFolder() {
return smbState.folders[smbState.activeSubtab] || smbState.folders.videodownloader;
}
// Proxy properties for backward compatibility with existing smbState.files and smbState.selectedPaths
Object.defineProperty(smbState, 'files', {
get() { return getActiveSmbFolder().files; },
set(val) { getActiveSmbFolder().files = val; }
});
Object.defineProperty(smbState, 'selectedPaths', {
get() { return getActiveSmbFolder().selectedPaths; },
set(val) { getActiveSmbFolder().selectedPaths = val; }
});
// In-memory cache loaded from SQLite DB // In-memory cache loaded from SQLite DB
const dbCache = { const dbCache = {
all: {}, all: {},
@@ -2709,6 +2814,12 @@
const btnSmbBatchDelete = document.getElementById('btn-smb-batch-delete'); const btnSmbBatchDelete = document.getElementById('btn-smb-batch-delete');
const smbProgressBarContainer = document.getElementById('smb-progress-container'); const smbProgressBarContainer = document.getElementById('smb-progress-container');
const smbProgressBarFill = document.getElementById('smb-progress-fill'); const smbProgressBarFill = document.getElementById('smb-progress-fill');
const subTabBtnVideodownloader = document.getElementById('sub-tab-btn-videodownloader');
const subTabBtnUnsorted = document.getElementById('sub-tab-btn-unsorted');
const subTabBadgeVideodownloader = document.getElementById('sub-tab-badge-videodownloader');
const subTabBadgeUnsorted = document.getElementById('sub-tab-badge-unsorted');
const smbActiveFolderBadge = document.getElementById('smb-active-folder-badge');
// Stash Tab & Status Elements // Stash Tab & Status Elements
const stashEndpointInput = document.getElementById('stash-endpoint'); const stashEndpointInput = document.getElementById('stash-endpoint');
@@ -4572,10 +4683,45 @@ Return ONLY valid JSON matching:
if (notFilterInput) notFilterInput.addEventListener('input', () => renderTable()); if (notFilterInput) notFilterInput.addEventListener('input', () => renderTable());
// ==================== TAB 2: SMB Video Downloader Engine ==================== // ==================== TAB 2: SMB Video Downloader Engine ====================
function updateSmbFolderBadges() {
if (subTabBadgeVideodownloader) subTabBadgeVideodownloader.textContent = smbState.folders.videodownloader.files.length;
if (subTabBadgeUnsorted) subTabBadgeUnsorted.textContent = smbState.folders.unsorted.files.length;
const totalCount = smbState.folders.videodownloader.files.length + smbState.folders.unsorted.files.length;
smbTabBadge.textContent = totalCount;
}
function switchSmbSubtab(subtabName) {
if (!smbState.folders[subtabName]) return;
smbState.activeSubtab = subtabName;
// Update UI active buttons
if (subTabBtnVideodownloader) subTabBtnVideodownloader.classList.toggle('active', subtabName === 'videodownloader');
if (subTabBtnUnsorted) subTabBtnUnsorted.classList.toggle('active', subtabName === 'unsorted');
const activeFolder = getActiveSmbFolder();
if (smbActiveFolderBadge) smbActiveFolderBadge.textContent = activeFolder.sourcePath;
// Update Scan button text
if (btnFetchSmb) {
btnFetchSmb.innerHTML = `<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg> Scan SMB Files (${activeFolder.label})`;
}
renderSmbTable();
updateSmbStats();
}
if (subTabBtnVideodownloader) {
subTabBtnVideodownloader.addEventListener('click', () => switchSmbSubtab('videodownloader'));
}
if (subTabBtnUnsorted) {
subTabBtnUnsorted.addEventListener('click', () => switchSmbSubtab('unsorted'));
}
async function scanSmbFiles() { async function scanSmbFiles() {
const sourcePath = (smbSourcePathInput ? smbSourcePathInput.value.trim() : '/mnt/isolation/videos/videodownloader') || '/mnt/isolation/videos/videodownloader'; const activeFolder = getActiveSmbFolder();
const sourcePath = activeFolder.sourcePath;
btnFetchSmb.disabled = true; btnFetchSmb.disabled = true;
btnFetchSmb.textContent = 'Scanning SMB...'; btnFetchSmb.textContent = `Scanning SMB (${activeFolder.label})...`;
try { try {
await loadDatabaseCache(); await loadDatabaseCache();
@@ -4591,7 +4737,7 @@ Return ONLY valid JSON matching:
const data = await res.json(); const data = await res.json();
let cachedLoadedCount = 0; let cachedLoadedCount = 0;
smbState.files = (data.files || []).map(f => { activeFolder.files = (data.files || []).map(f => {
const cached = dbCache.all[f.path] || dbCache.all[f.name]; const cached = dbCache.all[f.path] || dbCache.all[f.name];
let analysis = null; let analysis = null;
let selectedPerformer = null; let selectedPerformer = null;
@@ -4631,20 +4777,20 @@ Return ONLY valid JSON matching:
}; };
}); });
smbState.selectedPaths.clear(); activeFolder.selectedPaths.clear();
smbTabBadge.textContent = smbState.files.length; updateSmbFolderBadges();
renderSmbTable(); renderSmbTable();
updateSmbStats(); updateSmbStats();
showToast(`Scanned ${smbState.files.length} SMB video files (${cachedLoadedCount} restored from SQLite cache)`, 'success'); showToast(`Scanned ${activeFolder.files.length} SMB video files in ${activeFolder.label} (${cachedLoadedCount} restored from SQLite cache)`, 'success');
// Auto Stash lookup in background (non-blocking) // Auto Stash lookup in background (non-blocking)
if (stashAutoLookupSelect && stashAutoLookupSelect.value === 'true') { if (stashAutoLookupSelect && stashAutoLookupSelect.value === 'true') {
const unanalyzed = smbState.files.filter(f => !f.analysis && f.moveStatus !== 'moved'); const unanalyzed = activeFolder.files.filter(f => !f.analysis && f.moveStatus !== 'moved');
if (unanalyzed.length > 0) { if (unanalyzed.length > 0) {
setTimeout(async () => { setTimeout(async () => {
const stashMatches = await matchItemsWithStash(unanalyzed, 'smb'); const stashMatches = await matchItemsWithStash(unanalyzed, 'smb');
if (stashMatches > 0) { if (stashMatches > 0) {
showToast(`⚡ Stash matched ${stashMatches} / ${unanalyzed.length} video files!`, 'success'); showToast(`⚡ Stash matched ${stashMatches} / ${unanalyzed.length} video files in ${activeFolder.label}!`, 'success');
} }
}, 50); }, 50);
} }
@@ -4653,12 +4799,13 @@ Return ONLY valid JSON matching:
showToast(`SMB scan failed: ${err.message}`, 'error'); showToast(`SMB scan failed: ${err.message}`, 'error');
} finally { } finally {
btnFetchSmb.disabled = false; btnFetchSmb.disabled = false;
btnFetchSmb.innerHTML = `<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg> Scan SMB Files (/videodownloader)`; btnFetchSmb.innerHTML = `<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg> Scan SMB Files (${activeFolder.label})`;
} }
} }
btnFetchSmb.addEventListener('click', scanSmbFiles); btnFetchSmb.addEventListener('click', scanSmbFiles);
async function analyzeSmbFileWithAI(fileItem) { async function analyzeSmbFileWithAI(fileItem) {
const destRoot = (smbDestRootInput ? smbDestRootInput.value.trim() : '/mnt/isolation/videos').replace(/\/+$/, '') || '/mnt/isolation/videos'; const destRoot = (smbDestRootInput ? smbDestRootInput.value.trim() : '/mnt/isolation/videos').replace(/\/+$/, '') || '/mnt/isolation/videos';
@@ -5051,7 +5198,7 @@ Return ONLY a valid JSON object with an array named "results":
await deleteItemFromDb(fileItem.path); await deleteItemFromDb(fileItem.path);
smbState.files = smbState.files.filter(f => f.path !== fileItem.path); smbState.files = smbState.files.filter(f => f.path !== fileItem.path);
smbState.selectedPaths.delete(fileItem.path); smbState.selectedPaths.delete(fileItem.path);
smbTabBadge.textContent = smbState.files.length; updateSmbFolderBadges();
renderSmbTable(); renderSmbTable();
updateSmbStats(); updateSmbStats();
showToast(`Deleted ${fileItem.name} from SMB share`, 'success'); showToast(`Deleted ${fileItem.name} from SMB share`, 'success');
@@ -5096,7 +5243,7 @@ Return ONLY a valid JSON object with an array named "results":
const deletedPathSet = new Set(paths); const deletedPathSet = new Set(paths);
smbState.files = smbState.files.filter(f => !deletedPathSet.has(f.path)); smbState.files = smbState.files.filter(f => !deletedPathSet.has(f.path));
smbState.selectedPaths.clear(); smbState.selectedPaths.clear();
smbTabBadge.textContent = smbState.files.length; updateSmbFolderBadges();
renderSmbTable(); renderSmbTable();
updateSmbStats(); updateSmbStats();
showToast(`Permanently deleted ${selected.length} video file(s) from SMB share`, 'success'); showToast(`Permanently deleted ${selected.length} video file(s) from SMB share`, 'success');
@@ -5231,7 +5378,7 @@ Return ONLY a valid JSON object with an array named "results":
</div> </div>
</td> </td>
<td> <td>
<span class="badge badge-path" title="${escapeHtml(f.path)}">.../videodownloader/</span> <span class="badge badge-path" title="${escapeHtml(f.path)}">.../${f.path.includes('/unsorted') ? 'unsorted' : 'videodownloader'}/</span>
</td> </td>
<td style="min-width: 280px;"> <td style="min-width: 280px;">
${performerControl} ${performerControl}
@@ -6554,15 +6701,17 @@ Return ONLY a valid JSON object with an array named "results":
const modal = document.getElementById('cleanup-modal'); const modal = document.getElementById('cleanup-modal');
const sumBox = document.getElementById('cleanup-summary-box'); const sumBox = document.getElementById('cleanup-summary-box');
const listEl = document.getElementById('cleanup-files-list'); const listEl = document.getElementById('cleanup-files-list');
const activeFolder = getActiveSmbFolder();
const scanDir = activeFolder.sourcePath;
try { try {
showToast('Scanning /videodownloader for 0-byte corrupt files and empty folders...', 'info'); showToast(`Scanning ${activeFolder.label} for 0-byte corrupt files and empty folders...`, 'info');
const res = await fetch('/api/smb/scan-cleanup'); const res = await fetch(`/api/smb/scan-cleanup?path=${encodeURIComponent(scanDir)}`);
const data = await res.json(); const data = await res.json();
cleanupScanData = { files: data.zero_byte_files || [], dirs: data.empty_dirs || [] }; cleanupScanData = { files: data.zero_byte_files || [], dirs: data.empty_dirs || [] };
sumBox.innerHTML = ` sumBox.innerHTML = `
<div>Found <strong>${cleanupScanData.files.length}</strong> zero-byte/corrupt video files and <strong>${cleanupScanData.dirs.length}</strong> empty subdirectories.</div> <div>Found <strong>${cleanupScanData.files.length}</strong> zero-byte/corrupt video files and <strong>${cleanupScanData.dirs.length}</strong> empty subdirectories in <code>${escapeHtml(activeFolder.label)}</code>.</div>
`; `;
if (cleanupScanData.files.length === 0 && cleanupScanData.dirs.length === 0) { if (cleanupScanData.files.length === 0 && cleanupScanData.dirs.length === 0) {
@@ -8320,7 +8469,8 @@ Return ONLY a valid JSON object with an array named "results":
{ title: 'Stash: Correlate Transcodes & Replace Originals', icon: '🔀', action: () => { switchTab('stash-tasks'); scanStashTranscodes(); } }, { title: 'Stash: Correlate Transcodes & Replace Originals', icon: '🔀', action: () => { switchTab('stash-tasks'); scanStashTranscodes(); } },
{ title: 'Stash: Clean Database & Generated Files', icon: '🧹', action: () => { switchTab('stash-tasks'); executeStashCleanTask(); } }, { title: 'Stash: Clean Database & Generated Files', icon: '🧹', action: () => { switchTab('stash-tasks'); executeStashCleanTask(); } },
{ title: 'Stash: Generate Previews & Sprites', icon: '⚡', action: () => { switchTab('stash-tasks'); executeStashGenerateTask(); } }, { title: 'Stash: Generate Previews & Sprites', icon: '⚡', action: () => { switchTab('stash-tasks'); executeStashGenerateTask(); } },
{ title: 'Scan SMB Files (/videodownloader)', icon: '🔍', action: () => { switchTab('smb'); document.getElementById('btn-fetch-smb')?.click(); } }, { title: 'Scan SMB Files (/videodownloader)', icon: '🔍', action: () => { switchTab('smb'); switchSmbSubtab('videodownloader'); scanSmbFiles(); } },
{ title: 'Scan SMB Files (/unsorted)', icon: '🔍', action: () => { switchTab('smb'); switchSmbSubtab('unsorted'); scanSmbFiles(); } },
{ title: 'Scan Stash Missing Performer Images', icon: '⚡', action: () => { switchTab('stash-images'); document.getElementById('btn-scan-stash-images')?.click(); } }, { title: 'Scan Stash Missing Performer Images', icon: '⚡', action: () => { switchTab('stash-images'); document.getElementById('btn-scan-stash-images')?.click(); } },
{ title: 'Scan Zero-Byte & Empty Folders', icon: '🧹', action: () => scanSmbCleanup() }, { title: 'Scan Zero-Byte & Empty Folders', icon: '🧹', action: () => scanSmbCleanup() },
{ title: 'Export qBittorrent Plan to CSV', icon: '📥', action: () => document.getElementById('btn-export-qb-plan')?.click() }, { title: 'Export qBittorrent Plan to CSV', icon: '📥', action: () => document.getElementById('btn-export-qb-plan')?.click() },
+1 -1
View File
@@ -1259,7 +1259,7 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
if os.path.exists(target_dir) and os.path.isdir(target_dir): if os.path.exists(target_dir) and os.path.isdir(target_dir):
try: try:
for entry in os.scandir(target_dir): for entry in os.scandir(target_dir):
if entry.is_dir() and entry.name != 'videodownloader' and not entry.name.startswith('.'): if entry.is_dir() and entry.name not in ('videodownloader', 'unsorted') and not entry.name.startswith('.'):
performers_set.add(entry.name) performers_set.add(entry.name)
except Exception as e: except Exception as e:
print(f"Error scanning existing performer directories: {e}") print(f"Error scanning existing performer directories: {e}")