@@ -97,7 +97,12 @@
< ! - - Completed Section - - >
< div v-if = "completedFiles.length && showCompletedSection" class="ms-fm-section" >
< div class = "ms-fm-section-title" > ✅ { { t ( 'library.status.completed' ) } } ( { { completedFiles . length } } ) < / div >
< div class = "ms-fm-section-title" >
✅ { { t ( 'library.status.completed' ) } } ( { { completedFiles . length } } )
< button v-if = "selectedRegisteredFiles.length > 0" class="ms-batch-unregister-btn" @click="batchUnregister" >
🗑 ️ {{ t ( ' library.context.unregister ' ) }} ( {{ selectedRegisteredFiles.length }} )
< / button >
< / div >
< div class = "ms-fm-grid" >
< div v-for = "f in completedFiles" :key="f.file_uuid" class="ms-fm-card ms-fm-card-completed" :class="{ 'is-selected': selectedFiles.includes(f.file_uuid) }" :data-drag-key="f.file_uuid" @click="toggleSelect(f)" @contextmenu.prevent="openContextMenu($event, f)" @touchstart="onCardTouchStart($event, f)" @touchend="onCardTouchEnd" @touchmove="onCardTouchEnd" >
< div class = "ms-fm-thumb-wrap" >
@@ -682,6 +687,7 @@ import { getAsrStatus } from '@/utils/asrStatus'
import { useAdvancedMode } from '@/stores/advancedMode'
import { useAutoScan } from '@/stores/autoScan'
import VideoPlayer from '../components/VideoPlayer.vue'
import pLimit from 'p-limit'
const { t } = useI18n ( )
const { isAdvanced } = useAdvancedMode ( )
@@ -743,7 +749,7 @@ const durationMax = ref<number | null>(null)
// Sort & Filter
const registeredFiles = computed ( ( ) => {
let result = files . value . filter ( ( f : any ) => f . isRegistered && f . status !== 'completed' )
let result = filesCache . value . filter ( ( f : any ) => f . isRegistered && f . status !== 'completed' )
if ( displayFilter . value === 'video' ) result = result . filter ( isVideo )
else if ( displayFilter . value === 'photo' ) result = result . filter ( isPhoto )
if ( filterText . value ) {
@@ -756,8 +762,20 @@ const registeredFiles = computed(() => {
if ( onlyPhotos . value ) result = result . filter ( isPhoto )
if ( sizeMin . value ) result = result . filter ( ( f : any ) => ( f . file _size || 0 ) >= sizeMin . value ! * 1048576 )
if ( sizeMax . value ) result = result . filter ( ( f : any ) => ( f . file _size || 0 ) <= sizeMax . value ! * 1048576 )
// Sort
// Sort: scanning/preparing files first, then by user selection
result . sort ( ( a : any , b : any ) => {
const getPriority = ( f : any ) => {
if ( f . status === 'scanning' ) return 0
if ( f . status === 'preparing' ) return 1
return 2
}
const priorityA = getPriority ( a )
const priorityB = getPriority ( b )
if ( priorityA !== priorityB ) return priorityA - priorityB
switch ( sortBy . value ) {
case 'time_desc' : return new Date ( b . modified _time || 0 ) . getTime ( ) - new Date ( a . modified _time || 0 ) . getTime ( )
case 'time_asc' : return new Date ( a . modified _time || 0 ) . getTime ( ) - new Date ( b . modified _time || 0 ) . getTime ( )
@@ -838,7 +856,7 @@ const { onMousedown: dragMousedown, onClickCapture: dragClickCapture } = useDrag
} )
const sortedFilteredFiles = computed ( ( ) => {
let result = [ ... files . value ]
let result = [ ... filesCache . value ]
// Display filter
if ( displayFilter . value === 'video' ) result = result . filter ( isVideo )
@@ -874,8 +892,21 @@ const sortedFilteredFiles = computed(() => {
} )
}
// Sort
// Sort: scanning/preparing files first, then by user selection
result . sort ( ( a : any , b : any ) => {
// Priority: scanning > preparing > others
const getPriority = ( f : any ) => {
if ( f . status === 'scanning' ) return 0
if ( f . status === 'preparing' ) return 1
return 2
}
const priorityA = getPriority ( a )
const priorityB = getPriority ( b )
if ( priorityA !== priorityB ) return priorityA - priorityB
// Within same priority, sort by user selection
switch ( sortBy . value ) {
case 'time_desc' : return new Date ( b . modified _time || 0 ) . getTime ( ) - new Date ( a . modified _time || 0 ) . getTime ( )
case 'time_asc' : return new Date ( a . modified _time || 0 ) . getTime ( ) - new Date ( b . modified _time || 0 ) . getTime ( )
@@ -896,6 +927,12 @@ const completedCount = computed(() => files.value.filter((f: any) => f.status ==
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 selectedUnregisteredFiles = computed ( ( ) => unregisteredFiles . value . filter ( ( f : any ) => selectedFiles . value . includes ( f . file _path ) ) )
const selectedRegisteredFiles = computed ( ( ) => {
const selected = selectedFiles . value
const registered = registeredFiles . value . filter ( ( f : any ) => selected . includes ( f . file _uuid ) )
const completed = completedFiles . value . filter ( ( f : any ) => selected . includes ( f . file _uuid ) )
return [ ... registered , ... completed ]
} )
onMounted ( ( ) => { loadFiles ( ) ; document . addEventListener ( 'click' , docClickClose ) ; startStatsAutoRefresh ( ) ; startProcessingFilesPolling ( ) ; startFilesWatch ( ) } )
onUnmounted ( ( ) => { document . removeEventListener ( 'click' , docClickClose ) ; stopStatsAutoRefresh ( ) ; stopProcessingFilesPolling ( ) ; stopFilesWatch ( ) } )
@@ -908,31 +945,41 @@ function docClickClose(e: MouseEvent) {
}
}
// Concurrency limit for status API calls
const statusLimit = pLimit ( 5 )
async function loadFiles ( ) {
statusText . value = 'Loading...'
invalidateFiles ( )
await ensureFiles ( )
statusText . value = ` ${ files . value . length } files `
// Load thumbnails asynchronously (non-blocking)
for ( const f of files . value ) {
if ( ( isPhoto ( f ) || isVideo ( f ) ) && f . file _uuid ) loadThumbnail ( f . file _uuid )
}
for ( const f of files . value ) {
if ( ( isPhoto ( f ) || isVideo ( f ) ) && ! f . isRegistered && f . file _path ) loadUnregisteredThumbnail ( f . file _path )
}
// Start status loading in background (non-blocking)
loadAllStatus ( )
}
// Load status for all registered files progressively
async function loadAllStatus ( ) {
const registeredFiles = files . value . filter ( ( f : any ) => f . isRegistered && f . file _uuid )
await Promise . all ( registeredFiles . map ( async ( f : any ) => {
try {
await loadPipelineStats( f . file _uuid , true )
await loadFileStats( f . file _uuid , true )
} catch ( e ) {
console . error ( 'check progress failed:' , f . file _uuid , e )
}
} ) )
await Promise . all ( registeredFiles . map ( ( f : any ) =>
statusLimit ( ( ) => Promise . all ( [
loadPipelineStats ( f . file _uuid , true ) ,
loadFileStats ( f . file _uuid , true )
] ) . catch ( ( ) => { } ) )
) )
}
async function refreshAllStatus ( ) {
statusText . value = 'Refreshing status...'
await refreshAllFilesStatus ( )
// Clear caches first
pipelineStatsCache . value = { }
await loadAllStatus ( )
statusText . value = ` ${ files . value . length } files `
}
@@ -1438,17 +1485,30 @@ async function runProcessorQC(f: any) {
async function redoProcessor ( proc : string ) {
if ( ! ctxMenu . value . file ? . file _uuid ) return
const fileUuid = ctxMenu . value . file . file _uuid
const f = filesCache . value . find ( ( x : any ) => x . file _uuid === fileUuid )
if ( ! f ) return
if ( ! confirm ( t ( 'library.confirm.rerun_processor' , { processor : proc . toUpperCase ( ) } ) ) ) return
const oldStatus = f . status
try {
f . status = 'preparing'
fileStatusCache . value [ f . file _uuid ] = 'preparing'
const result = await apiCall ( 'process_file' , { fileUuid , processors : [ proc ] } )
if ( result . success ) {
await syncFileStatus ( f . file _uuid )
alert ( t ( 'library.processor.started' , { processor : proc . toUpperCase ( ) } ) )
} else {
f . status = oldStatus
fileStatusCache . value [ f . file _uuid ] = oldStatus
alert ( t ( 'library.processor.failed' , { message : result . message } ) )
}
} catch ( e : any ) {
f . status = oldStatus
fileStatusCache . value [ f . file _uuid ] = oldStatus
alert ( t ( 'library.processor.error' , { message : e . message } ) )
}
}
@@ -1460,21 +1520,33 @@ async function redoProcessorFromQC(proc: string) {
return
}
const fileUuid = qcFile . value . file _uuid
const f = filesCache . value . find ( ( x : any ) => x . file _uuid === fileUuid )
if ( ! f ) return
if ( ! confirm ( t ( 'library.confirm.run_processor' , { processor : proc . toUpperCase ( ) , filename : qcFile . value . title } ) ) ) return
const oldStatus = f . status
try {
f . status = 'preparing'
fileStatusCache . value [ f . file _uuid ] = 'preparing'
const result = await apiCall ( 'process_file' , { fileUuid , processors : [ proc ] } )
if ( result . success ) {
await syncFileStatus ( f . file _uuid )
alert ( t ( 'library.processor.started' , { processor : proc . toUpperCase ( ) } ) )
// Re-run QC to refresh status
setTimeout ( ( ) => {
runProcessorQC ( qcFile . value )
} , 2000 )
} else {
f . status = oldStatus
fileStatusCache . value [ f . file _uuid ] = oldStatus
alert ( t ( 'library.processor.failed' , { message : result . message || result . error || 'Unknown error' } ) )
}
} catch ( e : any ) {
f . status = oldStatus
fileStatusCache . value [ f . file _uuid ] = oldStatus
alert ( t ( 'library.processor.error' , { message : e . message } ) )
console . error ( '[QC] Redo failed:' , e )
}
@@ -1578,6 +1650,7 @@ function statusFromDB(status: string): string {
}
function statusBadgeClass ( f : any ) : string {
if ( f . status === 'preparing' ) return 'ms-badge-off'
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'
@@ -1587,6 +1660,7 @@ function statusBadgeClass(f: any): string {
}
function statusBadgeIcon ( f : any ) : string {
if ( f . status === 'preparing' ) return '⏳'
if ( f . status === 'registered' ) return '📋'
if ( f . status === 'scanning' ) return '🔍'
if ( f . status === 'keyword_ready' ) return '🔎'
@@ -1792,6 +1866,53 @@ async function batchRegister() {
setTimeout ( ( ) => { actionMsg . value = '' } , 8000 )
}
async function batchUnregister ( ) {
if ( unregistering . value ) return
if ( selectedRegisteredFiles . value . length === 0 ) return
const filesToUnregister = selectedRegisteredFiles . value
if ( ! confirm ( t ( 'library.confirm.batch_unregister_message' , { count : filesToUnregister . length } ) ) ) return
unregistering . value = true
actionMsg . value = ` Unregistering ${ filesToUnregister . length } files... `
let successCount = 0
let failCount = 0
for ( const f of filesToUnregister ) {
const fileUuid = f . file _uuid
if ( ! fileUuid ) {
failCount ++
continue
}
try {
const result : any = await apiCall ( 'unregister_file' , { fileUuid , deleteOutputFiles : true } )
if ( result . success ) {
successCount ++
} else {
failCount ++
}
} catch ( e : any ) {
failCount ++
console . error ( '[batchUnregister] Failed:' , f . file _name , e )
}
}
selectedFiles . value = [ ]
unregistering . value = false
if ( successCount > 0 ) {
actionMsg . value = ` Unregistered ${ successCount } files ${ failCount > 0 ? ` , ${ failCount } failed ` : '' } `
} else {
actionMsg . value = ` Failed to unregister all ${ failCount } files `
}
await loadFiles ( )
setTimeout ( ( ) => { actionMsg . value = '' } , 8000 )
}
async function quickRegister ( f : any ) {
if ( registering . value ) return
@@ -1845,97 +1966,46 @@ async function doProcess(f: any) {
if ( processing . value ) return
if ( ! f . isRegistered ) { actionMsg . value = 'File not registered' ; return }
processing . value = true
const oldUuid = f . file _uuid
actionMsg . value = '🔄 Unregistering...'
const file = filesCache . value . find ( ( x : any ) => x . file _uuid === f . file _uuid )
if ( ! file ) {
actionMsg . value = 'File not found in cache'
processing . value = false
return
}
const oldStatus = file . status
try {
// Step 1: Unregister with deleteOutputFiles=true
const unregResult : any = await apiCall ( 'unregister_ file' , { fileUuid : oldU uid, deleteOutputFiles : true } )
if ( ! unregResult . success ) {
actionMsg . value = ` Unregister failed: ${ unregResult . message } `
processing . value = false
setTimeout ( ( ) => { actionMsg . value = '' } , 8000 )
return
}
file . status = 'preparing'
fileStatusCache . value [ file. file _u uid] = 'preparing'
actionMsg . value = '🔍 Starting deep scan...'
if ( unregResult . command _id ) {
actionMsg . value = '🔄 Waiting for unregister...'
await pollCommandStatus ( unregResult . command _id )
}
// 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 )
const result = await apiCall ( 're_scan_file' , {
fileUuid : file . file _uuid ,
autoPipeline : true
} )
// Step 2: Register again
actionMsg . value = '📥 Registering...'
const filePath = f . file _path || f . relative _path || f . file _name
if ( ! filePath ) { actionMsg . value = 'Missing file path' ; processing . value = false ; return }
const regResult : any = await apiCall ( 'register_file' , { filePath } )
if ( ! regResult . success ) {
actionMsg . value = ` Register failed: ${ regResult . message } `
processing . value = false
setTimeout ( ( ) => { actionMsg . value = '' } , 8000 )
return
}
let newUuid = regResult . file _uuid
if ( regResult . command _id && ! newUuid ) {
actionMsg . value = '📥 Waiting for registration... '
const finalStatus = await pollCommandStatus ( regResult . command _id )
newUuid = finalStatus . file _uuid
}
if ( ! newUuid ) {
actionMsg . value = ` Registration completed but no file_uuid `
processing . value = false
return
}
f . file _uuid = newUuid
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'
} )
// Step 3: Process (deep scan)
actionMsg . value = '🔍 Scanning...'
const procs = selectedProcessors ( )
if ( ! procs . length ) { actionMsg . value = 'Please select at least one processor' ; processing . value = false ; return }
// Immediately update status to show user the action started
f . status = 'scanning'
fileStatusCache . value [ f . file _uuid ] = 'scanning'
const procResult : any = await apiCall ( 'process_file' , { fileUuid : f . file _uuid , processors : procs } )
if ( procResult . success ) {
actionMsg . value = ` Deep scan started: ${ f . file _name } `
syncFileStatus ( f . file _uuid )
if ( result . success ) {
file . status = 'scanning'
fileStatusCache . value [ file . file _uuid ] = 'scanning '
actionMsg . value = '✅ Deep scan started'
await syncFileStatus ( file . file _uuid )
} else {
actionMsg . value = ` Scan failed: ${ procResult . message } `
// Revert status on failure
f . status = 'registered'
fileStatusCache . value [ f . file _uuid ] = 'registered'
file . status = oldStatus
fileStatusCache . value [ file . file _uuid ] = oldStatus
actionMsg . value = ` Failed: ${ result . message || result . error } `
}
} catch ( e : any ) {
actionMsg . value = ` Deep scan error: ${ e } `
console . error ( '[deepScan]' , e )
file . status = oldStatus
fileStatusCache . value [ file . file _uuid ] = oldStatus
actionMsg . value = ` Error: ${ e . message } `
console . error ( '[deep-scan]' , e )
} finally {
processing . value = false
await loadFiles ( )
setTimeout ( ( ) => { actionMsg . value = '' } , 5000 )
}
processing . value = false
await loadFiles ( )
setTimeout ( ( ) => { actionMsg . value = '' } , 8000 )
}
async function waitForStable ( condition : ( ) => boolean | Promise < boolean > , timeoutMs = 15000 , intervalMs = 500 ) : Promise < boolean > {
@@ -1959,26 +2029,38 @@ async function doReprocess(f: any) {
if ( ! f . isRegistered ) { actionMsg . value = 'File not registered' ; return }
reprocessing . value = true
const file = filesCache . value . find ( ( x : any ) => x . file _uuid === f . file _uuid )
if ( ! file ) {
actionMsg . value = 'File not found in cache'
reprocessing . value = false
return
}
const oldStatus = file . status
try {
actionMsg . value = '🔄 Re-scanning... '
file . status = 'preparing '
fileStatusCache . value [ file . file _uuid ] = 'preparing'
actionMsg . value = '🔍 Starting deep scan...'
// Use single re-scan API with auto_pipeline: true
const result = await apiCall ( 're_scan_file' , {
fileUuid : f . file _uuid ,
fileUuid : file . file _uuid ,
autoPipeline : true
} )
if ( result . success ) {
actionMsg . value = '✅ Re-scan started '
// Update status to scanning
f . status = 'scanning '
f ileStatusCache . value [ f . file _uuid ] = 'scanning'
// Sync status from Core API
await syncFileStatus ( f . file _uuid )
file . status = 'scanning '
fileStatusCache . value [ file . file _uuid ] = ' scanning'
actionMsg . value = '✅ Deep scan started '
await syncF ileStatus( file . file _uuid )
} else {
file . status = oldStatus
fileStatusCache . value [ file . file _uuid ] = oldStatus
actionMsg . value = ` Failed: ${ result . message || result . error } `
}
} catch ( e : any ) {
file . status = oldStatus
fileStatusCache . value [ file . file _uuid ] = oldStatus
actionMsg . value = ` Error: ${ e . message } `
console . error ( '[re-scan]' , e )
} finally {
@@ -2161,6 +2243,8 @@ function resetFilters() {
. ms - fm - section - title { font - size : 13 px ; font - weight : 600 ; color : var ( -- text - secondary ) ; margin - bottom : 12 px ; padding - left : 4 px ; display : flex ; align - items : center ; gap : 12 px ; }
. ms - batch - register - btn { margin - left : auto ; padding : 4 px 12 px ; font - size : 12 px ; font - weight : 600 ; background : var ( -- primary - color ) ; color : white ; border : none ; border - radius : 6 px ; cursor : pointer ; }
. ms - batch - register - btn : hover { background : var ( -- primary - hover ) ; }
. ms - batch - unregister - btn { margin - left : auto ; padding : 4 px 12 px ; font - size : 12 px ; font - weight : 600 ; background : var ( -- danger - color , # dc3545 ) ; color : white ; border : none ; border - radius : 6 px ; cursor : pointer ; }
. ms - batch - unregister - btn : hover { background : var ( -- danger - hover , # c82333 ) ; }
. ms - fm - card - checkbox { position : absolute ; bottom : 8 px ; right : 8 px ; width : 24 px ; height : 24 px ; border : 2 px solid var ( -- border - color ) ; border - radius : 4 px ; background : var ( -- card - background ) ; cursor : pointer ; display : flex ; align - items : center ; justify - content : center ; font - size : 14 px ; color : var ( -- primary - color ) ; z - index : 10 ; }
. ms - fm - card - checkbox : hover { border - color : var ( -- primary - color ) ; }
. ms - fm - card - register - btn { position : absolute ; bottom : 8 px ; right : 40 px ; width : 28 px ; height : 28 px ; border : none ; border - radius : 4 px ; background : var ( -- primary - color ) ; color : # fff ; cursor : pointer ; display : flex ; align - items : center ; justify - content : center ; font - size : 14 px ; opacity : 0 ; transition : opacity .2 s , transform .15 s ; z - index : 10 ; }