diff --git a/messages/en.json b/messages/en.json index 5c87fc9..2c9f1a0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -188,6 +188,18 @@ "map_unknownLocation": "Unknown Location", + "runs_title": "Runs", + "runs_empty": "No analysis runs yet", + "runs_emptyHint": "Go to the Analysis tab to start your first analysis.", + "runs_detectionCount": "{count} detections", + "runs_detectionCountSingular": "{count} detection", + "runs_noDetections": "No detections", + "runs_selectRun": "Select a run from the list to view its detections.", + "runs_status_running": "Running", + "runs_status_completed": "Completed", + "runs_status_failed": "Failed", + "runs_status_pending": "Pending", + "analysis_title": "Analysis", "analysis_selectFile": "Select File", "analysis_selectFileDesc": "Select an audio file to analyze", diff --git a/shared/types.ts b/shared/types.ts index 144f9b4..b0b952e 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -49,6 +49,11 @@ export interface AnalysisRun { completed_at: string | null; } +export interface RunWithStats extends AnalysisRun { + detection_count: number; + location_name: string | null; +} + export interface Detection { id: number; run_id: number; diff --git a/src/main/db/runs.ts b/src/main/db/runs.ts index 128d496..8759105 100644 --- a/src/main/db/runs.ts +++ b/src/main/db/runs.ts @@ -1,5 +1,5 @@ import { getDb } from './database'; -import type { AnalysisRun } from '$shared/types'; +import type { AnalysisRun, RunWithStats } from '$shared/types'; export function createRun( sourcePath: string, @@ -47,3 +47,30 @@ export function deleteRun(id: number): void { db.prepare('DELETE FROM analysis_runs WHERE id = ?').run(id); })(); } + +/** Mark any runs left in 'running' state as 'failed' — they are stale from a previous session. */ +export function markStaleRunsAsFailed(): number { + const db = getDb(); + const result = db + .prepare("UPDATE analysis_runs SET status = 'failed', completed_at = datetime('now') WHERE status = 'running'") + .run(); + return result.changes; +} + +export function getRunsWithStats(): RunWithStats[] { + const db = getDb(); + return db + .prepare( + `SELECT + r.*, + COALESCE(d.cnt, 0) AS detection_count, + l.name AS location_name + FROM analysis_runs r + LEFT JOIN ( + SELECT run_id, COUNT(*) AS cnt FROM detections GROUP BY run_id + ) d ON d.run_id = r.id + LEFT JOIN locations l ON l.id = r.location_id + ORDER BY r.started_at DESC`, + ) + .all() as RunWithStats[]; +} diff --git a/src/main/index.ts b/src/main/index.ts index 256127d..d3b5f50 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,6 +12,7 @@ import path from 'path'; import fs from 'fs'; import { registerHandlers } from './ipc/handlers'; import { closeDb } from './db/database'; +import { markStaleRunsAsFailed } from './db/runs'; import { buildLabelsPath, reloadLabels } from './labels/label-service'; import { listModels } from './birda/models'; @@ -190,6 +191,12 @@ void app.whenReady().then(async () => { registerBirdaMediaProtocol(); await registerHandlers(); + // Mark any runs stuck in 'running' from a previous session as failed + const staleCount = markStaleRunsAsFailed(); + if (staleCount > 0) { + console.log(`[startup] Marked ${staleCount} stale running run(s) as failed`); + } + // Initialize label service from default model's labels with saved language preference try { const models = await listModels(); diff --git a/src/main/ipc/catalog.ts b/src/main/ipc/catalog.ts index bb0bf7b..7e2c35d 100644 --- a/src/main/ipc/catalog.ts +++ b/src/main/ipc/catalog.ts @@ -9,6 +9,7 @@ import { } from '../db/detections'; import { clearDatabase } from '../db/database'; import { getLocations, getLocationsWithCounts } from '../db/locations'; +import { getRunsWithStats } from '../db/runs'; import { resolveAll, searchByCommonName } from '../labels/label-service'; import type { Detection, @@ -37,6 +38,10 @@ function enrichSpeciesSummaries(summaries: SpeciesSummary[]): EnrichedSpeciesSum } export function registerCatalogHandlers(): void { + ipcMain.handle('catalog:get-runs', () => { + return getRunsWithStats(); + }); + ipcMain.handle('catalog:get-detections', (_event, filter: DetectionFilter) => { // If species filter is set, also resolve common name matches from label service if (filter.species) { diff --git a/src/preload/index.ts b/src/preload/index.ts index 23a8111..23ac4be 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,7 @@ const ALLOWED_INVOKE_CHANNELS = new Set([ 'clip:save-spectrogram', 'clip:get-spectrogram', 'clip:export-region', + 'catalog:get-runs', 'catalog:get-detections', 'catalog:search-species', 'catalog:get-species-summary', diff --git a/src/renderer/src/App.svelte b/src/renderer/src/App.svelte index 5bc4216..82e5ac1 100644 --- a/src/renderer/src/App.svelte +++ b/src/renderer/src/App.svelte @@ -16,7 +16,6 @@ type BirdaEventEnvelope, } from '$lib/stores/analysis.svelte'; import { addLog, type LogEntry } from '$lib/stores/log.svelte'; - import { loadDetections } from '$lib/stores/catalog.svelte'; import { getCatalogStats, getSettings, @@ -93,9 +92,9 @@ analysisState.status = 'completed'; appState.lastRunId = result.runId; appState.lastSourceFile = appState.sourcePath; - appState.activeTab = 'analysis'; + appState.selectedRunId = result.runId; + appState.activeTab = 'detections'; appState.catalogStats = await getCatalogStats(); - await loadDetections(); } catch (err) { analysisState.status = 'failed'; analysisState.error = (err as Error).message; diff --git a/src/renderer/src/lib/components/AudioPlayer.svelte b/src/renderer/src/lib/components/AudioPlayer.svelte deleted file mode 100644 index f3e5a52..0000000 --- a/src/renderer/src/lib/components/AudioPlayer.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - -
- - - -
diff --git a/src/renderer/src/lib/components/DetectionsTable.svelte b/src/renderer/src/lib/components/DetectionsTable.svelte deleted file mode 100644 index 1957617..0000000 --- a/src/renderer/src/lib/components/DetectionsTable.svelte +++ /dev/null @@ -1,141 +0,0 @@ - - -
-
- - - - {#each columns as col (col.key)} - - {/each} - - - - - {#each catalogState.detections as detection (detection.id)} - { - selectDetection(detection); - }} - ondblclick={() => { - catalogState.selectedDetection = detection; - }} - > - - - - - - - - - {:else} - - - - {/each} - -
- - {m.table_columnClip()}
{detection.common_name}{detection.scientific_name}{formatConfidence(detection.confidence)}{formatTimeRange(detection.start_time, detection.end_time)}{detection.source_file}{formatDate(detection.detected_at)} - {#if detection.clip_path} - - {/if} -
- {#if catalogState.loading} - {m.table_loadingDetections()} - {:else} - {m.table_noDetections()} - {/if} -
-
- - -
- {m.pagination_totalDetections({ count: String(catalogState.total) })} -
- - {m.pagination_pageOf({ current: String(currentPage), total: String(totalPages) })} - -
-
-
diff --git a/src/renderer/src/lib/components/FilterPanel.svelte b/src/renderer/src/lib/components/FilterPanel.svelte deleted file mode 100644 index 2bd4892..0000000 --- a/src/renderer/src/lib/components/FilterPanel.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - -{#if !collapsed} -
-
-

- - {m.filter_title()} -

- -
- - - - - -
- - -
-
-{:else} - -{/if} diff --git a/src/renderer/src/lib/components/RunList.svelte b/src/renderer/src/lib/components/RunList.svelte new file mode 100644 index 0000000..736934c --- /dev/null +++ b/src/renderer/src/lib/components/RunList.svelte @@ -0,0 +1,82 @@ + + +
+
+

{m.runs_title()}

+
+ +
+ {#if loading} +
+ +
+ {:else if runs.length === 0} +
+ +

{m.runs_empty()}

+

{m.runs_emptyHint()}

+
+ {:else} + {#each runs as run (run.id)} + + {/each} + {/if} +
+
diff --git a/src/renderer/src/lib/stores/app.svelte.ts b/src/renderer/src/lib/stores/app.svelte.ts index 3640fda..1b1a647 100644 --- a/src/renderer/src/lib/stores/app.svelte.ts +++ b/src/renderer/src/lib/stores/app.svelte.ts @@ -19,6 +19,7 @@ export const appState = $state({ showLogPanel: false, lastRunId: null as number | null, lastSourceFile: null as string | null, + selectedRunId: null as number | null, theme: 'system' as 'system' | 'light' | 'dark', settingsHasUnsavedChanges: false, }); diff --git a/src/renderer/src/lib/stores/catalog.svelte.ts b/src/renderer/src/lib/stores/catalog.svelte.ts deleted file mode 100644 index 4f46ff0..0000000 --- a/src/renderer/src/lib/stores/catalog.svelte.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { EnrichedDetection, DetectionFilter } from '$shared/types'; -import { getDetections } from '$lib/utils/ipc'; - -export type SortColumn = - | 'common_name' - | 'scientific_name' - | 'confidence' - | 'start_time' - | 'source_file' - | 'detected_at'; -type SortDir = 'asc' | 'desc'; - -export const catalogState = $state({ - detections: [] as EnrichedDetection[], - total: 0, - filter: { - species: '', - location_id: undefined as number | undefined, - min_confidence: undefined as number | undefined, - run_id: undefined as number | undefined, - limit: 50, - offset: 0, - } as DetectionFilter & { species: string }, - sortColumn: 'detected_at' as SortColumn, - sortDir: 'desc' as SortDir, - selectedDetection: null as EnrichedDetection | null, - loading: false, -}); - -export async function loadDetections(): Promise { - catalogState.loading = true; - try { - // When sorting by common_name, the DB doesn't have this column. - // Send scientific_name to DB and re-sort client-side after enrichment. - const dbSortColumn = catalogState.sortColumn === 'common_name' ? 'scientific_name' : catalogState.sortColumn; - - const result = await getDetections({ - species: catalogState.filter.species || undefined, - location_id: catalogState.filter.location_id, - min_confidence: catalogState.filter.min_confidence, - run_id: catalogState.filter.run_id, - limit: catalogState.filter.limit, - offset: catalogState.filter.offset, - sort_column: dbSortColumn, - sort_dir: catalogState.sortDir, - }); - - let detections = result.detections; - - // Client-side sort by common_name (only within the current page) - if (catalogState.sortColumn === 'common_name') { - const dir = catalogState.sortDir === 'asc' ? 1 : -1; - detections = [...detections].sort((a, b) => dir * a.common_name.localeCompare(b.common_name)); - } - - catalogState.detections = detections; - catalogState.total = result.total; - } catch { - catalogState.detections = []; - catalogState.total = 0; - } finally { - catalogState.loading = false; - } -} - -export function resetFilters(): void { - catalogState.filter.species = ''; - catalogState.filter.location_id = undefined; - catalogState.filter.min_confidence = undefined; - catalogState.filter.run_id = undefined; - catalogState.filter.offset = 0; -} diff --git a/src/renderer/src/lib/utils/format.ts b/src/renderer/src/lib/utils/format.ts index 811c479..5fc3170 100644 --- a/src/renderer/src/lib/utils/format.ts +++ b/src/renderer/src/lib/utils/format.ts @@ -26,10 +26,6 @@ export function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } -export function formatTimeRange(start: number, end: number): string { - return `${formatTime(start)} - ${formatTime(end)}`; -} - export function formatDate(dateStr: string): string { const d = new Date(dateStr); return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); diff --git a/src/renderer/src/lib/utils/ipc.ts b/src/renderer/src/lib/utils/ipc.ts index 4df8dfe..d8df82b 100644 --- a/src/renderer/src/lib/utils/ipc.ts +++ b/src/renderer/src/lib/utils/ipc.ts @@ -9,6 +9,7 @@ import type { AppSettings, CatalogStats, SourceScanResult, + RunWithStats, } from '$shared/types'; declare global { @@ -39,6 +40,10 @@ export function offAnalysisProgress(): void { } // Catalog +export function getRuns(): Promise { + return window.birda.invoke('catalog:get-runs') as Promise; +} + export function getDetections(filter: DetectionFilter): Promise<{ detections: EnrichedDetection[]; total: number }> { return window.birda.invoke('catalog:get-detections', filter) as Promise<{ detections: EnrichedDetection[]; diff --git a/src/renderer/src/pages/AnalysisPage.svelte b/src/renderer/src/pages/AnalysisPage.svelte index fca354c..3c9086c 100644 --- a/src/renderer/src/pages/AnalysisPage.svelte +++ b/src/renderer/src/pages/AnalysisPage.svelte @@ -1,7 +1,6 @@ -{#if !appState.lastRunId} - {#if !appState.sourcePath} - -
-

{m.analysis_title()}

-
- + +
+
+{:else} + +
+ +
+

{m.analysis_title()}

+ + +
+ + +
+ + +
+ + {appState.sourcePath.split(/[\\/]/).pop()}
-
- {:else} - -
- -
-

{m.analysis_title()}

- - -
- - -
- - -
- - {appState.sourcePath.split(/[\\/]/).pop()} - -
- - - - - - - - -
-
-

{m.analysis_locationDate()}

-
- - {#if hasCoords}{:else}{/if} - {m.analysis_statusCoords()} - - - {#if hasDate}{:else}{/if} - {m.analysis_statusDate()} - -
-
- - {#if previousLocations.length > 0} - - {/if} - - - - - {#if needsDateInput} - - -
- {:else} - - {#if appState.isAnalysisRunning} - - {:else} - - {/if} - {/if} -
- - -
- -
-
- {/if} -{:else} - -
-

{m.analysis_title()}

- -
- - - - - -
+
- - -
+ + {#each previousLocations as loc, i (loc.id)} + + {/each} + + {/if} - {#if appState.isAnalysisRunning} - - {:else} - - {/if} -
-
- -
- - {sourceFileName} - | - {total === 1 - ? m.pagination_detectionCountSingular({ count: formatNumber(total) }) - : m.pagination_detectionCount({ count: formatNumber(total) })} + - -
- - - {#if speciesQuery} + + {#if needsDateInput} + {:else if fileDate} +
+ + {m.analysis_recordingDate()} + + {fileDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })} + +
{/if} -
- - - -
+
- - + + {#if showNoFilterWarning} + +
+ + +
+ {:else} + + {#if appState.isAnalysisRunning} + + {:else} + + {/if} + {/if}
- + +
+ +
{/if} diff --git a/src/renderer/src/pages/DetectionsPage.svelte b/src/renderer/src/pages/DetectionsPage.svelte index 9f17579..8fd2b5d 100644 --- a/src/renderer/src/pages/DetectionsPage.svelte +++ b/src/renderer/src/pages/DetectionsPage.svelte @@ -1,15 +1,228 @@
- - + + + {#if appState.selectedRunId && selectedRun} +
+ +
+ + {sourceFileName} + | + {total === 1 + ? m.pagination_detectionCountSingular({ count: formatNumber(total) }) + : m.pagination_detectionCount({ count: formatNumber(total) })} + + +
+ + + {#if speciesQuery} + + {/if} +
+ + + + +
+ + + +
+ + +
+ {:else} + +
+ +

{m.runs_selectRun()}

+
+ {/if}