clio/clio-api/database.py

367 lines
11 KiB
Python
Raw Permalink Normal View History

2026-05-19 17:56:12 -04:00
"""
SQLite database for recordings, transcripts, and highlights.
"""
import sqlite3
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Optional
import config
# Schema version for migrations
SCHEMA_VERSION = 2
def get_connection() -> sqlite3.Connection:
"""Get a database connection with row factory."""
conn = sqlite3.connect(str(config.DATABASE_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
@contextmanager
def get_db():
"""Context manager for database connections."""
conn = get_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db():
"""Initialize database schema."""
with get_db() as conn:
conn.executescript("""
-- Recordings table
CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_name TEXT NOT NULL DEFAULT 'phone',
source_file TEXT UNIQUE NOT NULL,
audio_path TEXT,
filename TEXT NOT NULL,
duration REAL,
language TEXT,
language_probability REAL,
sample_rate INTEGER,
bit_depth INTEGER,
file_size_bytes INTEGER,
recorded_at TIMESTAMP,
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
transcribed_at TIMESTAMP
);
-- Transcript segments
CREATE TABLE IF NOT EXISTS segments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recording_id INTEGER NOT NULL,
start_time REAL NOT NULL,
end_time REAL NOT NULL,
text TEXT NOT NULL,
confidence REAL,
FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE
);
-- User highlights/bookmarks
CREATE TABLE IF NOT EXISTS highlights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recording_id INTEGER NOT NULL,
segment_id INTEGER,
start_seconds REAL NOT NULL,
end_seconds REAL,
tag TEXT NOT NULL DEFAULT 'important',
note TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE,
FOREIGN KEY (segment_id) REFERENCES segments(id) ON DELETE SET NULL
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_recordings_source ON recordings(source_name);
CREATE INDEX IF NOT EXISTS idx_recordings_recorded_at ON recordings(recorded_at);
CREATE INDEX IF NOT EXISTS idx_segments_recording ON segments(recording_id);
CREATE INDEX IF NOT EXISTS idx_highlights_recording ON highlights(recording_id);
-- Schema version tracking
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY
);
""")
# Check/set schema version and run migrations
cur = conn.execute("SELECT version FROM schema_version LIMIT 1")
row = cur.fetchone()
if row is None:
conn.execute("INSERT INTO schema_version (version) VALUES (?)", (SCHEMA_VERSION,))
else:
current_version = row[0]
# Migration v1 -> v2: add end_seconds to highlights
if current_version < 2:
try:
conn.execute("ALTER TABLE highlights ADD COLUMN end_seconds REAL")
except sqlite3.OperationalError:
pass # Column already exists
conn.execute("UPDATE schema_version SET version = 2")
# Recording operations
def get_recording_by_source_file(source_file: str) -> Optional[dict]:
"""Get recording by source file path (for idempotent import)."""
with get_db() as conn:
cur = conn.execute(
"SELECT * FROM recordings WHERE source_file = ?",
(source_file,)
)
row = cur.fetchone()
return dict(row) if row else None
def get_recording_by_source_file(source_file: str) -> Optional[dict]:
"""Get a recording by its source file name."""
with get_db() as conn:
cur = conn.execute(
"SELECT * FROM recordings WHERE source_file = ?",
(source_file,)
)
row = cur.fetchone()
return dict(row) if row else None
def create_recording(
source_file: str,
filename: str,
source_name: str = "phone",
audio_path: Optional[str] = None,
duration: Optional[float] = None,
language: Optional[str] = None,
language_probability: Optional[float] = None,
recorded_at: Optional[datetime] = None,
transcribed_at: Optional[datetime] = None,
) -> int:
"""Create a new recording entry. Returns the recording ID."""
with get_db() as conn:
cur = conn.execute(
"""
INSERT INTO recordings
(source_file, filename, source_name, audio_path, duration, language,
language_probability, recorded_at, transcribed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(source_file, filename, source_name, audio_path, duration, language,
language_probability, recorded_at, transcribed_at)
)
return cur.lastrowid
def update_recording(recording_id: int, **kwargs) -> bool:
"""Update recording fields."""
if not kwargs:
return False
allowed_fields = {
'audio_path', 'duration', 'language', 'language_probability',
'sample_rate', 'bit_depth', 'file_size_bytes', 'transcribed_at'
}
fields = {k: v for k, v in kwargs.items() if k in allowed_fields}
if not fields:
return False
set_clause = ", ".join(f"{k} = ?" for k in fields.keys())
values = list(fields.values()) + [recording_id]
with get_db() as conn:
conn.execute(
f"UPDATE recordings SET {set_clause} WHERE id = ?",
values
)
return True
def get_recording(recording_id: int) -> Optional[dict]:
"""Get recording by ID with segments."""
with get_db() as conn:
cur = conn.execute("SELECT * FROM recordings WHERE id = ?", (recording_id,))
row = cur.fetchone()
if not row:
return None
recording = dict(row)
# Get segments
cur = conn.execute(
"SELECT * FROM segments WHERE recording_id = ? ORDER BY start_time",
(recording_id,)
)
recording['segments'] = [dict(r) for r in cur.fetchall()]
# Get highlights
cur = conn.execute(
"SELECT * FROM highlights WHERE recording_id = ? ORDER BY start_seconds",
(recording_id,)
)
recording['highlights'] = [dict(r) for r in cur.fetchall()]
return recording
def list_recordings(
source_name: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> list[dict]:
"""List recordings with optional filters."""
query = "SELECT * FROM recordings WHERE 1=1"
params = []
if source_name:
query += " AND source_name = ?"
params.append(source_name)
if date_from:
query += " AND recorded_at >= ?"
params.append(date_from)
if date_to:
query += " AND recorded_at <= ?"
params.append(date_to)
query += " ORDER BY recorded_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
with get_db() as conn:
cur = conn.execute(query, params)
return [dict(r) for r in cur.fetchall()]
def get_recordings_count(source_name: Optional[str] = None) -> int:
"""Get total count of recordings."""
query = "SELECT COUNT(*) FROM recordings"
params = []
if source_name:
query += " WHERE source_name = ?"
params.append(source_name)
with get_db() as conn:
cur = conn.execute(query, params)
return cur.fetchone()[0]
# Segment operations
def create_segments(recording_id: int, segments: list[dict]) -> int:
"""Bulk create segments for a recording. Returns count created."""
if not segments:
return 0
with get_db() as conn:
conn.executemany(
"""
INSERT INTO segments (recording_id, start_time, end_time, text, confidence)
VALUES (?, ?, ?, ?, ?)
""",
[
(recording_id, s['start'], s['end'], s['text'], s.get('confidence'))
for s in segments
]
)
return len(segments)
def delete_segments(recording_id: int) -> int:
"""Delete all segments for a recording. Returns count deleted."""
with get_db() as conn:
cur = conn.execute(
"DELETE FROM segments WHERE recording_id = ?",
(recording_id,)
)
return cur.rowcount
# Highlight operations
def create_highlight(
recording_id: int,
start_seconds: float,
end_seconds: Optional[float] = None,
tag: str = "important",
note: Optional[str] = None,
segment_id: Optional[int] = None,
) -> int:
"""Create a highlight. Returns the highlight ID."""
with get_db() as conn:
cur = conn.execute(
"""
INSERT INTO highlights (recording_id, segment_id, start_seconds, end_seconds, tag, note)
VALUES (?, ?, ?, ?, ?, ?)
""",
(recording_id, segment_id, start_seconds, end_seconds, tag, note)
)
return cur.lastrowid
def delete_highlight(highlight_id: int) -> bool:
"""Delete a highlight."""
with get_db() as conn:
cur = conn.execute("DELETE FROM highlights WHERE id = ?", (highlight_id,))
return cur.rowcount > 0
def list_highlights(
recording_id: Optional[int] = None,
tag: Optional[str] = None,
limit: int = 100,
) -> list[dict]:
"""List highlights with optional filters."""
query = """
SELECT h.*, r.filename as recording_filename, r.recorded_at
FROM highlights h
JOIN recordings r ON h.recording_id = r.id
WHERE 1=1
"""
params = []
if recording_id:
query += " AND h.recording_id = ?"
params.append(recording_id)
if tag:
query += " AND h.tag = ?"
params.append(tag)
query += " ORDER BY h.created_at DESC LIMIT ?"
params.append(limit)
with get_db() as conn:
cur = conn.execute(query, params)
return [dict(r) for r in cur.fetchall()]
# Source/device operations
def list_sources() -> list[dict]:
"""Get summary of all source names."""
with get_db() as conn:
cur = conn.execute("""
SELECT
source_name,
COUNT(*) as recording_count,
SUM(duration) / 3600.0 as total_hours,
MAX(recorded_at) as last_recording
FROM recordings
GROUP BY source_name
ORDER BY last_recording DESC
""")
return [dict(r) for r in cur.fetchall()]