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}
- {m.table_columnClip()} |
-
-
-
- {#each catalogState.detections as detection (detection.id)}
- {
- selectDetection(detection);
- }}
- ondblclick={() => {
- catalogState.selectedDetection = detection;
- }}
- >
- | {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}
- |
-
- {:else}
-
- |
- {#if catalogState.loading}
- {m.table_loadingDetections()}
- {:else}
- {m.table_noDetections()}
- {/if}
- |
-
- {/each}
-
-
-
-
-
-
-
{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()}
-
-