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**:
- **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**:
- **Google Gemini** (Gemini 2.5/3.0+ models)
- **Anthropic Claude** (Claude 3.5 Sonnet, Haiku, Opus)
+169 -19
View File
@@ -195,6 +195,62 @@
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 {
display: none;
}
@@ -1106,7 +1162,7 @@
</button>
<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>
<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>
</button>
<button class="tab-btn" id="tab-btn-stash-images">
@@ -1445,6 +1501,27 @@
<!-- ==================== TAB 2: SMB Share Video Downloader ==================== -->
<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 -->
<div class="stats-grid">
<div class="stat-card">
@@ -2617,13 +2694,41 @@
};
const smbState = {
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,
isMoving: false,
abortAnalysis: false,
selectedPaths: new Set()
abortAnalysis: false
};
// 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
const dbCache = {
all: {},
@@ -2709,6 +2814,12 @@
const btnSmbBatchDelete = document.getElementById('btn-smb-batch-delete');
const smbProgressBarContainer = document.getElementById('smb-progress-container');
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
const stashEndpointInput = document.getElementById('stash-endpoint');
@@ -4572,10 +4683,45 @@ Return ONLY valid JSON matching:
if (notFilterInput) notFilterInput.addEventListener('input', () => renderTable());
// ==================== 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() {
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.textContent = 'Scanning SMB...';
btnFetchSmb.textContent = `Scanning SMB (${activeFolder.label})...`;
try {
await loadDatabaseCache();
@@ -4591,7 +4737,7 @@ Return ONLY valid JSON matching:
const data = await res.json();
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];
let analysis = null;
let selectedPerformer = null;
@@ -4631,20 +4777,20 @@ Return ONLY valid JSON matching:
};
});
smbState.selectedPaths.clear();
smbTabBadge.textContent = smbState.files.length;
activeFolder.selectedPaths.clear();
updateSmbFolderBadges();
renderSmbTable();
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)
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) {
setTimeout(async () => {
const stashMatches = await matchItemsWithStash(unanalyzed, 'smb');
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);
}
@@ -4653,12 +4799,13 @@ Return ONLY valid JSON matching:
showToast(`SMB scan failed: ${err.message}`, 'error');
} finally {
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);
async function analyzeSmbFileWithAI(fileItem) {
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);
smbState.files = smbState.files.filter(f => f.path !== fileItem.path);
smbState.selectedPaths.delete(fileItem.path);
smbTabBadge.textContent = smbState.files.length;
updateSmbFolderBadges();
renderSmbTable();
updateSmbStats();
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);
smbState.files = smbState.files.filter(f => !deletedPathSet.has(f.path));
smbState.selectedPaths.clear();
smbTabBadge.textContent = smbState.files.length;
updateSmbFolderBadges();
renderSmbTable();
updateSmbStats();
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>
</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 style="min-width: 280px;">
${performerControl}
@@ -6554,15 +6701,17 @@ Return ONLY a valid JSON object with an array named "results":
const modal = document.getElementById('cleanup-modal');
const sumBox = document.getElementById('cleanup-summary-box');
const listEl = document.getElementById('cleanup-files-list');
const activeFolder = getActiveSmbFolder();
const scanDir = activeFolder.sourcePath;
try {
showToast('Scanning /videodownloader for 0-byte corrupt files and empty folders...', 'info');
const res = await fetch('/api/smb/scan-cleanup');
showToast(`Scanning ${activeFolder.label} for 0-byte corrupt files and empty folders...`, 'info');
const res = await fetch(`/api/smb/scan-cleanup?path=${encodeURIComponent(scanDir)}`);
const data = await res.json();
cleanupScanData = { files: data.zero_byte_files || [], dirs: data.empty_dirs || [] };
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) {
@@ -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: Clean Database & Generated Files', icon: '🧹', action: () => { switchTab('stash-tasks'); executeStashCleanTask(); } },
{ 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 Zero-Byte & Empty Folders', icon: '🧹', action: () => scanSmbCleanup() },
{ 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):
try:
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)
except Exception as e:
print(f"Error scanning existing performer directories: {e}")