diff --git a/README.md b/README.md index 921d2aa..d161aa6 100644 --- a/README.md +++ b/README.md @@ -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//`). + - **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//`). - **Multi-AI Provider Support**: - **Google Gemini** (Gemini 2.5/3.0+ models) - **Anthropic Claude** (Claude 3.5 Sonnet, Haiku, Opus) diff --git a/qbittorrent_performer_sorter.html b/qbittorrent_performer_sorter.html index e137a8f..01a85c8 100644 --- a/qbittorrent_performer_sorter.html +++ b/qbittorrent_performer_sorter.html @@ -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 @@ + + + +
+ Active Folder: + /mnt/isolation/videos/videodownloader +
+ +
@@ -2617,13 +2694,41 @@ }; 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, 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 = ` 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 = ` Scan SMB Files (/videodownloader)`; + btnFetchSmb.innerHTML = ` 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":
- .../videodownloader/ + .../${f.path.includes('/unsorted') ? 'unsorted' : 'videodownloader'}/ ${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 = ` -
Found ${cleanupScanData.files.length} zero-byte/corrupt video files and ${cleanupScanData.dirs.length} empty subdirectories.
+
Found ${cleanupScanData.files.length} zero-byte/corrupt video files and ${cleanupScanData.dirs.length} empty subdirectories in ${escapeHtml(activeFolder.label)}.
`; 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() }, diff --git a/server.py b/server.py index 726ec18..538035e 100644 --- a/server.py +++ b/server.py @@ -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}")