diff --git a/src/api/index.ts b/src/api/index.ts index 57eeac9..46d8bb8 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -41,16 +41,6 @@ export async function apiCall(cmd: string, args: Record): Promise): Promise): Promise, retries = 3): Promise { const base = getApiBase() - const { url, method, body } = buildHttpRequest(cmd, args) + const { url, method, body, timeout: requestTimeout } = buildHttpRequest(cmd, args) const fullUrl = url.startsWith('http://') || url.startsWith('https://') ? url : `${base}${url}` const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT) + const timeout = setTimeout(() => controller.abort(), requestTimeout ?? DEFAULT_TIMEOUT) let response: Response | null = null try { @@ -202,7 +198,7 @@ async function httpCall(cmd: string, args: Record, retries = 3): Pr } } -function buildHttpRequest(cmd: string, args: Record): { url: string; method: string; body?: any } { +function buildHttpRequest(cmd: string, args: Record): { url: string; method: string; body?: any; timeout?: number } { const a = args switch (cmd) { @@ -346,12 +342,19 @@ case 'search_keyword': { } case 'get_trace_profile': { const params = new URLSearchParams() - if (a.file_uuid) params.set('file_uuid', a.file_uuid) - if (a.trace_id) params.set('trace_id', String(a.trace_id)) + const fu = a.file_uuid ?? a.fileUuid + const tid = a.trace_id ?? a.traceId + if (fu) params.set('file_uuid', fu) + if (tid != null) params.set('trace_id', String(tid)) return { url: `/api/v1/trace-profile?${params.toString()}`, method: 'GET' } } case 'update_trace_profile': { - return { url: '/api/v1/trace-profile', method: 'PUT', body: a.body } + const body = a.body ?? { + file_uuid: a.file_uuid ?? a.fileUuid, + trace_id: a.trace_id ?? a.traceId, + ...Object.fromEntries(Object.entries(a).filter(([k]) => !['fileUuid','traceId','file_uuid','trace_id','body'].includes(k))) + } + return { url: '/api/v1/trace-profile', method: 'PUT', body } } case 'delete_trace': { const body: any = { hard_delete: a.hard_delete ?? true } @@ -460,21 +463,22 @@ case 'search_keyword': { case 'process_file': { return { url: `/api/v1/file/${a.fileUuid}/process`, method: 'POST', body: { processors: a.processors } } } - case 'run_identity_agent': { - return { url: `/api/v1/file/${a.fileUuid}/identity-agent`, method: 'POST' } - } - case 'run_identity_for_seed': { - return { url: '/api/v1/agents/identity/run-for-seed', method: 'POST', body: { file_uuid: a.fileUuid, identity_uuid: a.identityUuid } } - } case 'get_identity_matches': { return { url: `/api/v1/identity-matches?file_hash=${a.fileHash}`, method: 'GET' } } case 'run_cluster_agent': { - return { url: `/api/v1/file/${a.fileUuid}/cluster-agent`, method: 'POST' } + return { url: `/api/v1/file/${a.fileUuid}/cluster-agent`, method: 'POST', body: { confirm: true } } } case 'get_cluster_results': { return { url: `/api/v1/cluster-results?file_hash=${a.fileHash}`, method: 'GET' } } + case 'get_similar_face_groups': { + const params = new URLSearchParams() + if (a.traceIds) params.set('trace_ids', String(a.traceIds)) + if (a.topK) params.set('top_k', String(a.topK)) + if (a.minScore) params.set('min_score', String(a.minScore)) + return { url: `/api/v1/file/${a.fileUuid}/face-groups/similar?${params.toString()}`, method: 'GET' } + } case 'update_trace_profile_group': { return { url: '/api/v1/trace-profile/group', method: 'PUT', body: { file_uuid: a.fileUuid, trace_ids: a.traceIds, name: a.name, key_frame: a.keyFrame, key_face: a.keyFace } } } @@ -496,7 +500,7 @@ case 'search_keyword': { case 'unregister_file': { const body: any = { file_uuid: a.fileUuid } if (a.deleteOutputFiles != null) body.delete_output_files = a.deleteOutputFiles - return { url: '/api/v1/unregister', method: 'POST', body } + return { url: '/api/v1/unregister', method: 'POST', body, timeout: UNREGISTER_TIMEOUT } } case 'ingest_file': { return { url: `/api/v1/file/${a.fileUuid}/checkin`, method: 'POST' } @@ -611,7 +615,7 @@ export function transformResponse(cmd: string, data: any): any { isRegistered: f.isRegistered ?? f.is_registered ?? false, status: f.status || '', registrationTime: f.registration_time || null, - ingested: f.ingested ?? (f.status === 'completed'), + ingested: f.ingested ?? ['completed','keyword_ready','semantic_ready','face_mgmt_ready','agent_ready'].includes(f.status), })) } case 'get_people': { @@ -705,19 +709,19 @@ case 'get_unassigned_traces': { const asrLangProb = asr?.language_probability || 0 let asrStatus: 'no_audio_track' | 'silent_audio' | 'has_transcript' | 'processing' = 'processing' - let asrMessage = '處理中' + let asrMessage = 'Processing' if (asrSegments.length === 0) { if (asrLang === '' && asrLangProb === 0) { asrStatus = 'no_audio_track' - asrMessage = '無音軌' + asrMessage = 'No Audio Track' } else { asrStatus = 'silent_audio' - asrMessage = asrLang ? `無語音 (${asrLang})` : '無語音' + asrMessage = asrLang ? `No Speech (${asrLang})` : 'No Speech' } } else { asrStatus = 'has_transcript' - asrMessage = `${asrSegments.length} 段語音 (${asrLang || 'unknown'})` + asrMessage = `${asrSegments.length} segments (${asrLang || 'unknown'})` } return { @@ -797,13 +801,6 @@ case 'get_unassigned_traces': { message: data.message ?? '', } } - case 'run_identity_agent': { - return { - success: data.success ?? false, - file_uuid: data.file_uuid ?? '', - message: data.message ?? '', - } - } case 'run_cluster_agent': { return { success: data.success ?? false, @@ -816,6 +813,9 @@ case 'get_unassigned_traces': { case 'get_cluster_results': { return data || null } + case 'get_similar_face_groups': { + return data || null + } case 'get_trace_profile': { return data || null } case 'update_trace_profile': { return data || { success: true } } case 'update_trace_profile_group': { return data || { success: true } } diff --git a/src/views/LibraryView.vue b/src/views/LibraryView.vue index f4dfdf1..ae53567 100644 --- a/src/views/LibraryView.vue +++ b/src/views/LibraryView.vue @@ -1,10 +1,10 @@ @@ -400,7 +389,7 @@
- {{ out.name }} {{ out.count }}筆 ({{ formatSize(out.size) }}) + {{ out.name }} {{ $t('library.qc.items', { count: out.count }) }} ({{ formatSize(out.size) }})
@@ -412,27 +401,27 @@
🎵 - 音軌資訊 + {{ $t('library.qc.audio_info') }}
- 音軌 + {{ $t('library.qc.audio_track') }} - {{ qcAudioInfo.hasAudio ? '✓ 有音軌' : '✗ 無音軌' }} + {{ qcAudioInfo.hasAudio ? $t('library.qc.has_audio') : $t('library.qc.no_audio') }}
- 語音 + {{ $t('library.qc.speech') }} - {{ qcAudioInfo.hasSpeech ? '✓ 有語音' : '🔇 無語音' }} + {{ qcAudioInfo.hasSpeech ? $t('library.qc.has_speech') : $t('library.qc.no_speech') }}
- 語言 + {{ $t('library.qc.language') }} {{ qcAudioInfo.language }} ({{ Math.round((qcAudioInfo.languageProb || 0) * 100) }}%)
- 取樣率 + {{ $t('library.qc.sample_rate') }} {{ qcAudioInfo.sampleRate }} Hz
@@ -444,7 +433,7 @@
⚙️ - 路徑一:處理器輸出 + {{ $t('library.qc.track_processor_output') }}
@@ -455,7 +444,7 @@
{{ getProcIcon(r) }} {{ r.processor }} - {{ r.size }}筆 + {{ $t('library.qc.items', { count: r.size }) }} {{ getAsrStatusLabel(r) }}
@@ -466,7 +455,7 @@
{{ r.exists ? '✓' : '✗' }} {{ r.processor }} - {{ r.size || 0 }}筆 + {{ $t('library.qc.items', { count: r.size || 0 }) }}
@@ -482,7 +471,7 @@
📥 - 路徑二:資料入庫 + {{ $t('library.qc.track_ingestion') }}
@@ -539,11 +528,11 @@
✅ - 終點:狀態改變 + {{ $t('library.qc.track_status_change') }}
-
📥 輸出 + 入庫 完成
+
{{ $t('library.qc.output_ingestion_done') }}
↓
@@ -559,6 +548,9 @@
+
@@ -571,14 +563,13 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { apiCall } from '@/api' import { useI18n } from 'vue-i18n' -import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, loadUnregisteredThumbnail, invalidateFiles, processorCountsCache, loadProcessorCounts, getProcessorCounts, ProcessorOutputInfo, pollProgress, stopPolling, getProgress, refreshFileStatus, refreshAllFilesStatus, fileProgressCache, pollingProgressSet, getFileProcessingStatus, ProcessingStatus, ProcessingLevel, loadPipelineStats, getPipelineStats, PipelineStats, loadFileStats, getFileStats, FileStats, pollPipelineProgress, startStatsAutoRefresh, stopStatsAutoRefresh, startProcessingFilesPolling, stopProcessingFilesPolling, startFilesWatch, stopFilesWatch, JsonProcessorInfo, pipelineStatsCache, clearFileStatusCache } from '@/store' -import { isDark, toggleDark } from '@/composables/useTheme' +import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, loadUnregisteredThumbnail, invalidateFiles, processorCountsCache, loadProcessorCounts, getProcessorCounts, ProcessorOutputInfo, pollProgress, stopPolling, getProgress, refreshFileStatus, refreshAllFilesStatus, fileProgressCache, pollingProgressSet, getFileProcessingStatus, ProcessingStatus, ProcessingLevel, loadPipelineStats, getPipelineStats, PipelineStats, loadFileStats, getFileStats, FileStats, startStatsAutoRefresh, stopStatsAutoRefresh, startProcessingFilesPolling, stopProcessingFilesPolling, startFilesWatch, stopFilesWatch, JsonProcessorInfo, pipelineStatsCache, clearFileStatusCache, syncFileStatus } from '@/store' import { getAsrStatus } from '@/utils/asrStatus' import { useAdvancedMode } from '@/stores/advancedMode' import VideoPlayer from '../components/VideoPlayer.vue' const { t } = useI18n() -const { isAdvanced, toggleAdvanced } = useAdvancedMode() +const { isAdvanced } = useAdvancedMode() const files = computed(() => filesCache.value) const loading = computed(() => !filesLoaded.value) @@ -615,8 +606,8 @@ const unregThumbKey = (path: string) => `unreg:${path}:30` const sortBy = ref('time_desc') const filterUnregistered = ref(false) const filterRegistered = ref(true) -const filterPending = ref(false) -const filterCompleted = ref(false) +const filterScanning = ref(false) +const filterReady = ref(false) const onlyVideos = ref(false) const onlyPhotos = ref(false) const sizeMin = ref(null) @@ -633,8 +624,8 @@ const registeredFiles = computed(() => { const f = filterText.value.toLowerCase() result = result.filter((file: any) => file.file_name.toLowerCase().includes(f)) } - if (filterPending.value) result = result.filter((f: any) => !f.status) - if (filterCompleted.value) result = result.filter((f: any) => f.status === 'completed') + if (filterScanning.value) result = result.filter((f: any) => f.status === 'scanning') + if (filterReady.value) result = result.filter((f: any) => ['completed','keyword_ready','semantic_ready','face_mgmt_ready'].includes(f.status)) if (onlyVideos.value) result = result.filter(isVideo) if (onlyPhotos.value) result = result.filter(isPhoto) if (sizeMin.value) result = result.filter((f: any) => (f.file_size || 0) >= sizeMin.value! * 1048576) @@ -681,18 +672,8 @@ const unregisteredFiles = computed(() => { return result }) -const showRegisteredSection = computed(() => { - if (filterRegistered.value && !filterUnregistered.value) return true - if (filterRegistered.value && filterUnregistered.value) return true - if (!filterRegistered.value && !filterUnregistered.value) return true // default: show registered - return false -}) - -const showUnregisteredSection = computed(() => { - if (filterUnregistered.value && !filterRegistered.value) return true - if (filterRegistered.value && filterUnregistered.value) return true - return false -}) +const showRegisteredSection = ref(true) +const showUnregisteredSection = ref(true) const sortedFilteredFiles = computed(() => { let result = [...files.value] @@ -710,8 +691,8 @@ const sortedFilteredFiles = computed(() => { // Checkbox filters if (filterUnregistered.value && !filterRegistered.value) result = result.filter((f: any) => !f.isRegistered) else if (filterRegistered.value && !filterUnregistered.value) result = result.filter((f: any) => f.isRegistered) - if (filterPending.value) result = result.filter((f: any) => f.isRegistered && !f.status) - if (filterCompleted.value) result = result.filter((f: any) => f.status === 'completed') + if (filterScanning.value) result = result.filter((f: any) => f.status === 'scanning') + if (filterReady.value) result = result.filter((f: any) => ['completed','keyword_ready','semantic_ready','face_mgmt_ready'].includes(f.status)) if (onlyVideos.value) result = result.filter(isVideo) if (onlyPhotos.value) result = result.filter(isPhoto) @@ -749,8 +730,8 @@ const sortedFilteredFiles = computed(() => { const registeredCount = computed(() => files.value.filter((f: any) => f.isRegistered).length) const unregisteredCount = computed(() => files.value.filter((f: any) => !f.isRegistered).length) -const pendingCount = computed(() => files.value.filter((f: any) => f.isRegistered && !f.status).length) -const processingCount = computed(() => files.value.filter((f: any) => f.status === 'processing').length) +const scanningCount = computed(() => files.value.filter((f: any) => f.status === 'scanning').length) +const readyCount = computed(() => files.value.filter((f: any) => ['completed','keyword_ready','semantic_ready','face_mgmt_ready'].includes(f.status)).length) const completedCount = computed(() => files.value.filter((f: any) => f.status === 'completed').length) onMounted(() => { loadFiles(); document.addEventListener('click', docClickClose); startStatsAutoRefresh(); startProcessingFilesPolling(); startFilesWatch() }) @@ -776,24 +757,14 @@ async function loadFiles() { if ((isPhoto(f) || isVideo(f)) && !f.isRegistered && f.file_path) loadUnregisteredThumbnail(f.file_path) } const registeredFiles = files.value.filter((f: any) => f.isRegistered && f.file_uuid) - for (const f of registeredFiles) { + await Promise.all(registeredFiles.map(async (f: any) => { try { - const pipeline = await loadPipelineStats(f.file_uuid, true) - - if (pipeline && pipeline.overall_progress < 1.0 && pipeline.stages?.some((s: any) => s.status === 'running')) { - f.status = 'processing' - pollPipelineProgress(f.file_uuid) - } else if (pipeline && pipeline.overall_progress >= 1.0) { - f.status = 'completed' - } else if (!pipeline && f.status === 'processing') { - pollPipelineProgress(f.file_uuid) - } - + await loadPipelineStats(f.file_uuid, true) await loadFileStats(f.file_uuid, true) } catch (e) { console.error('check progress failed:', f.file_uuid, e) } - } + })) } async function refreshAllStatus() { @@ -841,8 +812,19 @@ function toggleSelect(f: any) { else selectedFiles.value.push(f.file_uuid) } -function playVideo(f: any) { - currentVideo.value = { fileUuid: f.file_uuid, startFrame: 0, endFrame: 99999, title: f.file_name } +async function playVideo(f: any) { + let startFrame = 0 + let endFrame = 99999 + try { + const info: any = await apiCall('get_file_info', { uuid: f.file_uuid }) + if (info?.fps && info?.duration) { + endFrame = Math.round(info.fps * info.duration) + } else if (info?.metadata?.streams) { + const videoStream = info.metadata.streams.find((s: any) => s.codec_type === 'video') + if (videoStream?.nb_frames) endFrame = parseInt(videoStream.nb_frames) + } + } catch { /* fallback to defaults */ } + currentVideo.value = { fileUuid: f.file_uuid, startFrame, endFrame, title: f.file_name } playing.value = true } @@ -994,8 +976,8 @@ function getProcIcon(r: any): string { function getAsrStatusLabel(r: any): string { if (r.processor !== 'ASR' || !r.exists) return '' const status = r.asrStatus - if (status === 'no_audio_track') return '無音軌' - if (status === 'silent_audio') return '無語音' + if (status === 'no_audio_track') return t('library.qc.no_audio') + if (status === 'silent_audio') return t('library.qc.no_speech') return '' } @@ -1006,6 +988,7 @@ const qcAudioInfo = ref<{hasAudio: boolean, hasSpeech: boolean, language?: strin const systemStatus = ref<{worker_running: boolean, watcher_running: boolean}>({worker_running: false, watcher_running: false}) const serviceStatus = ref<{llm: boolean, vlm: boolean, embedding: boolean, core3002: boolean, proxy8888: boolean, vite5173: boolean}>({llm: false, vlm: false, embedding: false, core3002: false, proxy8888: false, vite5173: false}) const jobsList = ref<{id: number, uuid: string, status: string, current_processor?: string, file_name?: string, progress_current?: number, progress_total?: number, outputs?: {name: string, size: number, count: number, mtime: number}[]}[]>([]) +const libQcCopied = ref(false) function getJsonCount(data: any, proc: string): number { if (!data) return 0 @@ -1179,43 +1162,125 @@ async function redoProcessorFromQC(proc: string) { } } +function copyToClipboard(text: string) { + const ta = document.createElement('textarea') + ta.value = text + ta.style.position = 'fixed' + ta.style.left = '0' + ta.style.top = '0' + ta.style.width = '0' + ta.style.height = '0' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.focus() + ta.setSelectionRange(0, text.length) + const ok = document.execCommand('copy') + document.body.removeChild(ta) + if (!ok) window.prompt('Copy (Ctrl+C, Enter):', text) +} + +function copyLibraryQCReport() { + const f = qcFile.value + if (!f) return + const lines: string[] = [] + + lines.push('TYPE: PIPELINE_QC') + lines.push(`FILE: ${f.file_name || ''}`) + lines.push(`UUID: ${f.file_uuid || ''}`) + lines.push(`STATUS: ${f.status || 'unknown'}`) + + const svc = serviceStatus.value + lines.push(`SRV_3002: ${svc.core3002 ? 'up' : 'down'}`) + lines.push(`SRV_8888: ${svc.proxy8888 ? 'up' : 'down'}`) + lines.push(`SRV_5173: ${svc.vite5173 ? 'up' : 'down'}`) + lines.push(`SRV_WORKER: ${systemStatus.value.worker_running ? 'running' : 'stopped'}`) + lines.push(`SRV_LLM: ${svc.llm ? 'up' : 'down'}`) + lines.push(`SRV_VLM: ${svc.vlm ? 'up' : 'down'}`) + lines.push(`SRV_EMBEDDING: ${svc.embedding ? 'up' : 'down'}`) + + const jobs = jobsList.value + lines.push(`JOB_COUNT: ${jobs.length}`) + for (let i = 0; i < Math.min(jobs.length, 10); i++) { + const j = jobs[i] + const parts = [`id=${j.id}`, `status=${j.status}`] + if (j.current_processor) parts.push(`processor=${j.current_processor}`) + if (j.progress_total && j.progress_total > 0) parts.push(`progress=${j.progress_current}/${j.progress_total}`) + lines.push(`JOB_${i}: ${parts.join('|')}`) + } + + for (const r of qcResults.value) { + const p = r.processor + const parts = [`count=${r.size || 0}`, `ok=${r.exists ? 'yes' : 'no'}`] + if (p === 'ASR' && r.asrStatus) { + parts.push(`speech=${qcAudioInfo.value.hasSpeech ? 'yes' : 'no'}`) + if (qcAudioInfo.value.language) parts.push(`lang=${qcAudioInfo.value.language}`) + if (qcAudioInfo.value.languageProb) parts.push(`confidence=${qcAudioInfo.value.languageProb.toFixed(2)}`) + } + lines.push(`OUT_${p}: ${parts.join('|')}`) + } + + lines.push(`AUDIO: ${qcAudioInfo.value.hasAudio ? 'yes' : 'no'}`) + if (qcAudioInfo.value.hasAudio && qcAudioInfo.value.hasSpeech) { + const parts = [`lang=${qcAudioInfo.value.language || ''}`] + if (qcAudioInfo.value.languageProb) parts.push(`confidence=${qcAudioInfo.value.languageProb.toFixed(2)}`) + lines.push(`SPEECH: ${parts.join('|')}`) + } + + const stats = getFileStats(f.file_uuid) + if (stats) { + if (stats.postgresql) { + lines.push(`INGEST_PG_SENTENCE: ${stats.postgresql.sentence_chunks || 0}`) + lines.push(`INGEST_PG_RELATIONSHIP: ${stats.postgresql.relationship_chunks || 0}`) + } + if (stats.qdrant) { + lines.push(`INGEST_QDRANT_FACES: ${stats.qdrant.faces || 0}`) + lines.push(`INGEST_QDRANT_TEXT: ${stats.qdrant.text_chunks || 0}`) + lines.push(`INGEST_QDRANT_SPEAKERS: ${stats.qdrant.speakers || 0}`) + } + if (stats.tkg) { + lines.push(`INGEST_TKG_NODES: ${stats.tkg.nodes?.character || 0}`) + lines.push(`INGEST_TKG_EDGES: ${stats.tkg.edges?.appear_in || 0}`) + } + } + + copyToClipboard(lines.join('\n')) + libQcCopied.value = true + setTimeout(() => libQcCopied.value = false, 2000) +} + function statusLabel(f: any): string { if (!f) return '' return statusFromDB(f.status) } function statusFromDB(status: string): string { - if (status === 'completed') return 'Completed' - if (status === 'processing') return 'Scanning' - if (status === 'failed') return 'Failed' - if (status === 'pending') return 'Pending' - if (status === 'registered') return 'Registered' - return status || 'Unknown' + const key = `library.status.${status}` + const translated = t(key) + return translated !== key ? translated : (status || '—') } function statusBadgeClass(f: any): string { - if (f.status === 'processing') return 'ms-badge-proc' + if (f.status === 'registered') return 'ms-badge-warn' + if (f.status === 'scanning') return 'ms-badge-proc' + if (f.status === 'keyword_ready' || f.status === 'semantic_ready') return 'ms-badge-part' + if (f.status === 'face_mgmt_ready') return 'ms-badge-prog' if (f.status === 'completed') return 'ms-badge-done' - if (f.status === 'failed') return 'ms-badge-fail' - if (f.status === 'pending') return 'ms-badge-warn' return 'ms-badge-off' } function statusBadgeIcon(f: any): string { - if (f.status === 'processing') return '⏳' + if (f.status === 'registered') return '📋' + if (f.status === 'scanning') return '🔍' + if (f.status === 'keyword_ready') return '🔎' + if (f.status === 'semantic_ready') return '🧠' + if (f.status === 'face_mgmt_ready') return '🎭' if (f.status === 'completed') return '✅' - if (f.status === 'failed') return '❌' - if (f.status === 'pending') return '⏸️' return '⏺️' } function statusBadgeLabel(f: any): string { if (!f) return '' - if (f.status === 'completed') return 'Completed' - if (f.status === 'processing') return 'Scanning' - if (f.status === 'failed') return 'Failed' - if (f.status === 'pending') return 'Pending' - return '—' + return statusFromDB(f.status) } function ingestedBadgeClass(f: any): string { @@ -1236,6 +1301,24 @@ function ingestedBadgeLabel(f: any): string { return '—' } +let longPressTimer: ReturnType | null = null + +function onCardTouchStart(e: TouchEvent, f: any) { + if (longPressTimer) clearTimeout(longPressTimer) + longPressTimer = setTimeout(() => { + longPressTimer = null + const touch = e.touches[0] + openContextMenu({ clientX: touch.clientX, clientY: touch.clientY, preventDefault: () => {} } as any, f) + }, 500) +} + +function onCardTouchEnd() { + if (longPressTimer) { + clearTimeout(longPressTimer) + longPressTimer = null + } +} + async function openContextMenu(e: MouseEvent, f: any) { e.preventDefault() e.stopPropagation() @@ -1262,14 +1345,6 @@ async function openContextMenu(e: MouseEvent, f: any) { loadFileStats(f.file_uuid, true), loadProcessorCounts(f.file_uuid, true) ]) - const pipeline = getPipelineStats(f.file_uuid) - if (pipeline && pipeline.overall_progress < 1.0 && pipeline.stages?.some((s: any) => s.status === 'running')) { - f.status = 'processing' - } else if (pipeline && pipeline.overall_progress >= 1.0) { - f.status = 'completed' - } else if (!pipeline && f.status === 'processing') { - f.status = 'registered' - } ctxMenu.value.file = { ...f } } catch (e) { console.error('Failed to load stats:', e) @@ -1321,8 +1396,7 @@ async function ctxProcess(f: any) { const result: any = await apiCall('process_file', { fileUuid: f.file_uuid, processors: procs }) if (result.success) { actionMsg.value = `Scanning started: ${f.file_name}` - f.status = 'processing' - pollPipelineProgress(f.file_uuid) + syncFileStatus(f.file_uuid) } else { actionMsg.value = `Scan failed: ${result.message}` } @@ -1333,15 +1407,24 @@ async function ctxProcess(f: any) { processing.value = false } -async function ctxReprocess(f: any) { - closeContextMenu() +async function waitForStable(condition: () => boolean | Promise, timeoutMs = 15000, intervalMs = 500): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + if (await condition()) return true + await new Promise(r => setTimeout(r, intervalMs)) + } + return false +} + +async function cardRescan(f: any) { if (!confirm(`Re-scan "${f.file_name}"?\n\nThis will unregister and re-process the file.`)) return if (reprocessing.value) return if (!f.isRegistered) { actionMsg.value = 'File not registered'; return } reprocessing.value = true + const oldUuid = f.file_uuid actionMsg.value = '🔄 Unregistering...' try { - const unregResult: any = await apiCall('unregister_file', { fileUuid: f.file_uuid, deleteOutputFiles: true }) + const unregResult: any = await apiCall('unregister_file', { fileUuid: oldUuid, deleteOutputFiles: true }) if (!unregResult.success) { actionMsg.value = `Unregister failed: ${unregResult.message}` reprocessing.value = false @@ -1349,9 +1432,15 @@ async function ctxReprocess(f: any) { return } - // Clear all cached state for this file - delete fileProgressCache.value[f.file_uuid] - delete processorCountsCache.value[f.file_uuid] + delete fileProgressCache.value[oldUuid] + delete processorCountsCache.value[oldUuid] + + actionMsg.value = '⏳ Waiting for unregister to stabilize...' + await waitForStable(async () => { + await invalidateFiles() + await ensureFiles() + return !filesCache.value.some((x: any) => x.file_uuid === oldUuid && x.isRegistered) + }) actionMsg.value = '📥 Registering...' const filePath = f.file_path || f.relative_path || f.file_name @@ -1365,7 +1454,14 @@ async function ctxReprocess(f: any) { } f.file_uuid = regResult.file_uuid f.isRegistered = true - f.status = 'registered' + + actionMsg.value = '⏳ Waiting for registration to stabilize...' + await waitForStable(async () => { + await invalidateFiles() + await ensureFiles() + const found = filesCache.value.find((x: any) => x.file_uuid === f.file_uuid) + return found && found.isRegistered && found.status === 'registered' + }) actionMsg.value = '🔍 Scanning...' const procs = selectedProcessors() @@ -1373,8 +1469,75 @@ async function ctxReprocess(f: any) { const procResult: any = await apiCall('process_file', { fileUuid: f.file_uuid, processors: procs }) if (procResult.success) { actionMsg.value = `Re-scan started: ${f.file_name}` - f.status = 'processing' - pollPipelineProgress(f.file_uuid) + syncFileStatus(f.file_uuid) + } else { + actionMsg.value = `Scan failed: ${procResult.message}` + } + } catch (e: any) { + actionMsg.value = `Reprocess error: ${e}` + console.error('[rescan]', e) + } + reprocessing.value = false + await loadFiles() + setTimeout(() => { actionMsg.value = '' }, 8000) +} + +async function ctxReprocess(f: any) { + closeContextMenu() + if (!confirm(`Re-scan "${f.file_name}"?\n\nThis will unregister and re-process the file.`)) return + if (reprocessing.value) return + if (!f.isRegistered) { actionMsg.value = 'File not registered'; return } + reprocessing.value = true + const oldUuid = f.file_uuid + actionMsg.value = '🔄 Unregistering...' + try { + const unregResult: any = await apiCall('unregister_file', { fileUuid: oldUuid, deleteOutputFiles: true }) + if (!unregResult.success) { + actionMsg.value = `Unregister failed: ${unregResult.message}` + reprocessing.value = false + setTimeout(() => { actionMsg.value = '' }, 8000) + return + } + + // Clear all cached state for this file + delete fileProgressCache.value[oldUuid] + delete processorCountsCache.value[oldUuid] + + actionMsg.value = '⏳ Waiting for unregister to stabilize...' + await waitForStable(async () => { + await invalidateFiles() + await ensureFiles() + return !filesCache.value.some((x: any) => x.file_uuid === oldUuid && x.isRegistered) + }) + + actionMsg.value = '📥 Registering...' + const filePath = f.file_path || f.relative_path || f.file_name + if (!filePath) { actionMsg.value = 'Missing file path'; reprocessing.value = false; return } + const regResult: any = await apiCall('register_file', { filePath }) + if (!regResult.success) { + actionMsg.value = `Register failed: ${regResult.message}` + reprocessing.value = false + setTimeout(() => { actionMsg.value = '' }, 8000) + return + } + f.file_uuid = regResult.file_uuid + f.isRegistered = true + + actionMsg.value = '⏳ Waiting for registration to stabilize...' + await waitForStable(async () => { + await invalidateFiles() + await ensureFiles() + const found = filesCache.value.find((x: any) => x.file_uuid === f.file_uuid) + return found && found.isRegistered && found.status === 'registered' + }) + + actionMsg.value = '🔍 Scanning...' + const procs = selectedProcessors() + if (!procs.length) { actionMsg.value = 'Please select at least one processor'; reprocessing.value = false; return } + const procResult: any = await apiCall('process_file', { fileUuid: f.file_uuid, processors: procs }) + if (procResult.success) { + actionMsg.value = `Re-scan started: ${f.file_name}` + syncFileStatus(f.file_uuid) } else { actionMsg.value = `Scan failed: ${procResult.message}` } @@ -1395,9 +1558,11 @@ async function ctxUnregister(f: any) { unregistering.value = true actionMsg.value = 'Removing...' try { - const result: any = await apiCall('unregister_file', { fileUuid: f.file_uuid, deleteOutputFiles: true }) + const result: any = await apiCall('unregister_file', { fileUuid: f.file_uuid, deleteOutputFiles: true, asyncMode: true }) if (result.success) { - actionMsg.value = `Removed: ${f.file_name}` + actionMsg.value = result.job_id + ? `Removing in background...` + : `Removed: ${f.file_name}` } else { actionMsg.value = `Removal failed: ${result.message}` } @@ -1414,8 +1579,8 @@ function resetFilters() { sortBy.value = 'time_desc' filterUnregistered.value = false filterRegistered.value = false - filterPending.value = false - filterCompleted.value = false + filterScanning.value = false + filterReady.value = false onlyVideos.value = false onlyPhotos.value = false sizeMin.value = null @@ -1428,7 +1593,7 @@ function resetFilters() {