This commit is contained in:
parent
f5305c10d8
commit
30999a2cb2
11 changed files with 2486 additions and 0 deletions
366
clio-api/database.py
Normal file
366
clio-api/database.py
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
"""
|
||||
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()]
|
||||
Loading…
Add table
Add a link
Reference in a new issue