/** * Recording management composable * Handles loading, selecting, filtering, and managing recordings */ import * as IncognitoStorage from '../db/incognito-storage.js'; export function useRecordings(state, utils, reprocessComposable) { const { recordings, selectedRecording, isLoadingRecordings, isLoadingMore, currentPage, perPage, totalRecordings, totalPages, hasNextPage, hasPrevPage, showSharedWithMe, showArchivedRecordings, searchQuery, searchDebounceTimer, filterTags, filterSpeakers, filterDatePreset, filterDateRange, filterTextQuery, filterStarred, filterInbox, filterFolder, sortBy, availableTags, availableSpeakers, availableFolders, selectedTagIds, uploadLanguage, uploadMinSpeakers, uploadMaxSpeakers, uploadHotwords, uploadInitialPrompt, useAsrEndpoint, connectorSupportsDiarization, globalError, uploadQueue, isProcessingActive, currentView, isMobileScreen, isSidebarCollapsed, isRecording, audioBlobURL, speakerColorMap, // Incognito mode incognitoRecording } = state; const { setGlobalError, showToast } = utils; // Load recordings from API const loadRecordings = async (page = 1, append = false, searchQueryParam = '') => { globalError.value = null; if (!append) { isLoadingRecordings.value = true; } else { isLoadingMore.value = true; } try { const endpoint = '/api/recordings'; const params = new URLSearchParams({ page: page.toString(), per_page: perPage.value.toString() }); if (searchQueryParam.trim()) { params.set('q', searchQueryParam.trim()); } // Add sort parameter if (sortBy.value) { params.set('sort_by', sortBy.value); } // Add archived/shared/starred/inbox filters as query params (ANDed with other filters) if (showArchivedRecordings.value) { params.set('archived', 'true'); } if (showSharedWithMe.value) { params.set('shared', 'true'); } if (filterStarred.value) { params.set('starred', 'true'); } if (filterInbox.value) { params.set('inbox', 'true'); } // Add folder filter if (filterFolder && filterFolder.value) { params.set('folder', filterFolder.value); } const response = await fetch(`${endpoint}?${params}`); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to load recordings'); const recordingsList = data.recordings; const pagination = data.pagination; if (!Array.isArray(recordingsList)) { console.error('Unexpected response format:', data); throw new Error('Invalid response format from server'); } if (pagination) { currentPage.value = pagination.page; totalRecordings.value = pagination.total; totalPages.value = pagination.total_pages; hasNextPage.value = pagination.has_next; hasPrevPage.value = pagination.has_prev; } else { currentPage.value = 1; totalRecordings.value = recordingsList.length; totalPages.value = 1; hasNextPage.value = false; hasPrevPage.value = false; } if (append) { recordings.value = [...recordings.value, ...recordingsList]; } else { recordings.value = recordingsList; const lastRecordingId = localStorage.getItem('lastSelectedRecordingId'); if (lastRecordingId && recordingsList.length > 0) { const recordingToSelect = recordingsList.find(r => r.id == lastRecordingId); if (recordingToSelect) { selectRecording(recordingToSelect); } } } // NOTE: Removed auto-queueing of incomplete recordings. // Backend processing recordings are now shown via backendProcessingRecordings // computed property, which filters recordings by status (PENDING, PROCESSING, etc.) // The job queue system (ProcessingJob) handles background processing. } catch (error) { console.error('Load Recordings Error:', error); setGlobalError(`Failed to load recordings: ${error.message}`); if (!append) { recordings.value = []; } } finally { isLoadingRecordings.value = false; isLoadingMore.value = false; } }; const loadMoreRecordings = async () => { if (!hasNextPage.value || isLoadingMore.value) return; await loadRecordings(currentPage.value + 1, true, searchQuery.value); }; const performSearch = async (query = '') => { currentPage.value = 1; await loadRecordings(1, false, query); }; const debouncedSearch = (query) => { if (searchDebounceTimer.value) { clearTimeout(searchDebounceTimer.value); } searchDebounceTimer.value = setTimeout(() => { performSearch(query); }, 300); }; const loadTags = async () => { try { const response = await fetch('/api/tags'); if (response.ok) { availableTags.value = await response.json(); } else { availableTags.value = []; } } catch (error) { console.warn('Error loading tags:', error); availableTags.value = []; } }; const loadFolders = async () => { try { const response = await fetch('/api/folders'); if (response.ok) { availableFolders.value = await response.json(); } else { availableFolders.value = []; } } catch (error) { console.warn('Error loading folders:', error); availableFolders.value = []; } }; const loadSpeakers = async () => { try { const response = await fetch('/speakers'); if (response.ok) { availableSpeakers.value = await response.json(); } else { availableSpeakers.value = []; } } catch (error) { console.warn('Error loading speakers:', error); availableSpeakers.value = []; } }; const selectRecording = async (recording) => { if (hasUnsavedRecording()) { if (!confirm('You have an unsaved recording. Are you sure you want to leave?')) { return; } } // Check if switching away from incognito recording to a regular recording if (incognitoRecording && incognitoRecording.value && selectedRecording.value?.id === 'incognito' && recording?.id !== 'incognito') { if (!confirm('Switching to another recording will discard your incognito recording. Continue?')) { return; } // Clear incognito recording immediately - this is the "incognito" promise IncognitoStorage.clearIncognitoRecording(); incognitoRecording.value = null; } // Also clear any orphaned incognito data when selecting a non-incognito recording // This handles edge cases like page refresh where the above check doesn't trigger if (recording?.id !== 'incognito' && IncognitoStorage.hasIncognitoRecording()) { console.log('[Incognito] Clearing orphaned incognito data'); IncognitoStorage.clearIncognitoRecording(); if (incognitoRecording) { incognitoRecording.value = null; } } // Reset modal audio state when switching recordings if (utils.resetModalAudioState) { utils.resetModalAudioState(); } // Clear speaker color map when switching recordings - new colors will be assigned on first render if (speakerColorMap) { speakerColorMap.value = {}; } selectedRecording.value = recording; if (recording && recording.id) { localStorage.setItem('lastSelectedRecordingId', recording.id); try { const response = await fetch(`/api/recordings/${recording.id}`); if (response.ok) { const fullRecording = await response.json(); selectedRecording.value = fullRecording; const index = recordings.value.findIndex(r => r.id === recording.id); if (index !== -1) { recordings.value[index] = fullRecording; } // Auto-start polling if recording is still processing or summarizing if (['PROCESSING', 'SUMMARIZING'].includes(fullRecording.status)) { console.log(`[AUTO-POLL] Recording ${fullRecording.id} is in ${fullRecording.status} state, starting auto-polling`); if (reprocessComposable && reprocessComposable.startReprocessingPoll) { reprocessComposable.startReprocessingPoll(fullRecording.id); } else { console.warn('[AUTO-POLL] reprocessComposable.startReprocessingPoll not available'); } } } } catch (error) { console.error('Error loading full recording:', error); } } if (isMobileScreen.value) { isSidebarCollapsed.value = true; } currentView.value = 'detail'; if (isRecording.value) { // Don't interrupt recording } if (audioBlobURL.value) { // Don't discard recorded audio } }; const hasUnsavedRecording = () => { return isRecording.value || audioBlobURL.value; }; const toggleInbox = async (recording) => { if (!recording || !recording.id) return; try { const response = await fetch(`/recording/${recording.id}/toggle_inbox`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to toggle inbox status'); // Update the recording in the UI recording.is_inbox = data.is_inbox; // Update in the recordings list const index = recordings.value.findIndex(r => r.id === recording.id); if (index !== -1) { recordings.value[index].is_inbox = data.is_inbox; } showToast(`Recording ${data.is_inbox ? 'moved to inbox' : 'marked as read'}`); } catch (error) { console.error('Toggle Inbox Error:', error); setGlobalError(`Failed to toggle inbox status: ${error.message}`); } }; const toggleHighlight = async (recording) => { if (!recording || !recording.id) return; try { const response = await fetch(`/recording/${recording.id}/toggle_highlight`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to toggle highlighted status'); // Update the recording in the UI recording.is_highlighted = data.is_highlighted; // Update in the recordings list const index = recordings.value.findIndex(r => r.id === recording.id); if (index !== -1) { recordings.value[index].is_highlighted = data.is_highlighted; } showToast(`Recording ${data.is_highlighted ? 'highlighted' : 'unhighlighted'}`); } catch (error) { console.error('Toggle Highlight Error:', error); setGlobalError(`Failed to toggle highlighted status: ${error.message}`); } }; const getRecordingTags = (recording) => { if (!recording || !recording.tags) return []; return recording.tags || []; }; const getAvailableTagsForRecording = (recording) => { if (!recording || !availableTags.value) return []; const recordingTagIds = getRecordingTags(recording).map(tag => tag.id); return availableTags.value.filter(tag => !recordingTagIds.includes(tag.id)); }; const filterByTag = (tag) => { filterTags.value = [tag.id]; applyAdvancedFilters(); }; const buildSearchQuery = () => { let query = []; if (filterTextQuery.value.trim()) { query.push(filterTextQuery.value.trim()); } if (filterTags.value.length > 0) { const tagNames = filterTags.value.map(tagId => { const tag = availableTags.value.find(t => t.id === tagId); return tag ? `tag:${tag.name.replace(/\s+/g, '_')}` : ''; }).filter(Boolean); query.push(...tagNames); } if (filterSpeakers.value.length > 0) { const speakerNames = filterSpeakers.value.map(name => `speaker:${name.replace(/\s+/g, '_')}` ); query.push(...speakerNames); } if (filterDatePreset.value) { query.push(`date:${filterDatePreset.value}`); } else if (filterDateRange.value.start || filterDateRange.value.end) { if (filterDateRange.value.start) { query.push(`date_from:${filterDateRange.value.start}`); } if (filterDateRange.value.end) { query.push(`date_to:${filterDateRange.value.end}`); } } return query.join(' '); }; const applyAdvancedFilters = () => { searchQuery.value = buildSearchQuery(); }; const clearAllFilters = () => { filterTags.value = []; filterSpeakers.value = []; filterDateRange.value = { start: '', end: '' }; filterDatePreset.value = ''; filterTextQuery.value = ''; filterStarred.value = false; filterInbox.value = false; // Note: filterFolder is NOT cleared here - it's a navigation element, not a filter searchQuery.value = ''; }; const clearTagFilter = () => { searchQuery.value = ''; clearAllFilters(); }; const addTagToSelection = (tagId) => { if (!selectedTagIds.value.includes(tagId)) { selectedTagIds.value.push(tagId); applyTagDefaults(); } }; const removeTagFromSelection = (tagId) => { const index = selectedTagIds.value.indexOf(tagId); if (index > -1) { selectedTagIds.value.splice(index, 1); applyTagDefaults(); } }; const applyTagDefaults = () => { const selectedTags = selectedTagIds.value.map(tagId => availableTags.value.find(tag => tag.id == tagId) ).filter(Boolean); const firstTag = selectedTags[0]; if (firstTag && connectorSupportsDiarization.value) { if (firstTag.default_language) { uploadLanguage.value = firstTag.default_language; } if (firstTag.default_min_speakers) { uploadMinSpeakers.value = firstTag.default_min_speakers; } if (firstTag.default_max_speakers) { uploadMaxSpeakers.value = firstTag.default_max_speakers; } } if (firstTag) { if (firstTag.default_hotwords) { uploadHotwords.value = firstTag.default_hotwords; } if (firstTag.default_initial_prompt) { uploadInitialPrompt.value = firstTag.default_initial_prompt; } } }; const pollInboxRecordings = async () => { try { const response = await fetch('/api/recordings/inbox-count'); if (response.ok) { const data = await response.json(); // Update inbox count in UI if needed } } catch (error) { // Silent fail for polling } }; return { loadRecordings, loadMoreRecordings, performSearch, debouncedSearch, loadTags, loadFolders, loadSpeakers, selectRecording, hasUnsavedRecording, toggleInbox, toggleHighlight, getRecordingTags, getAvailableTagsForRecording, filterByTag, buildSearchQuery, applyAdvancedFilters, clearAllFilters, clearTagFilter, addTagToSelection, removeTagFromSelection, applyTagDefaults, pollInboxRecordings }; }