Compare commits
No commits in common. "6c7b94adcec5120ab9a5124d81ccffd0b745e58b" and "56d8fb4d565dc4f7b71a022c9f292ae792556d64" have entirely different histories.
6c7b94adce
...
56d8fb4d56
6 changed files with 29 additions and 222 deletions
|
|
@ -34,12 +34,5 @@ jobs:
|
||||||
cd /work/hosting/clio/clio-api
|
cd /work/hosting/clio/clio-api
|
||||||
/run/current-system/sw/bin/uv sync
|
/run/current-system/sw/bin/uv sync
|
||||||
|
|
||||||
- name: Restart service
|
|
||||||
run: |
|
|
||||||
sudo /run/current-system/sw/bin/systemctl restart phone-recorder
|
|
||||||
|
|
||||||
- name: Verify deployment
|
- name: Verify deployment
|
||||||
run: |
|
run: ls -la /work/hosting/clio/clio-api/
|
||||||
ls -la /work/hosting/clio/clio-api/
|
|
||||||
sleep 2
|
|
||||||
curl -s https://recordings.hallocks.xyz/health || echo "Health check failed"
|
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -71,7 +71,6 @@ local.properties
|
||||||
|
|
||||||
# Claude settings (local)
|
# Claude settings (local)
|
||||||
.claude/
|
.claude/
|
||||||
**/.claude/
|
|
||||||
|
|
||||||
# Data directories
|
# Data directories
|
||||||
data/
|
data/
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
|
||||||
networkTimeout=10000
|
|
||||||
validateDistributionUrl=true
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
|
|
@ -15,21 +15,12 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, UploadFile
|
from fastapi import FastAPI, File, Header, HTTPException, Query, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
|
|
||||||
async def verify_api_key(x_api_key: str = Header(None, alias="X-API-Key")):
|
|
||||||
"""Verify API key for protected endpoints."""
|
|
||||||
if not config.API_KEY:
|
|
||||||
raise HTTPException(status_code=500, detail="API_KEY not configured on server")
|
|
||||||
if x_api_key != config.API_KEY:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
|
||||||
return x_api_key
|
|
||||||
import crypto
|
import crypto
|
||||||
import database
|
import database
|
||||||
import importer
|
import importer
|
||||||
|
|
@ -101,7 +92,7 @@ async def health_check():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/info", dependencies=[Depends(verify_api_key)])
|
@app.get("/api/v1/info")
|
||||||
async def server_info():
|
async def server_info():
|
||||||
"""Server configuration info."""
|
"""Server configuration info."""
|
||||||
return {
|
return {
|
||||||
|
|
@ -119,17 +110,21 @@ async def get_public_key():
|
||||||
|
|
||||||
# Upload (existing functionality)
|
# Upload (existing functionality)
|
||||||
|
|
||||||
@app.post("/api/v1/recordings/upload", dependencies=[Depends(verify_api_key)])
|
@app.post("/api/v1/recordings/upload")
|
||||||
async def upload_recording(
|
async def upload_recording(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
x_api_key: str = Header(None, alias="X-API-Key"),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Receives an encrypted recording, decrypts it, and stores it.
|
Receives an encrypted recording, decrypts it, and stores it.
|
||||||
|
|
||||||
The file is expected to be in the SREC encrypted format.
|
The file is expected to be in the SREC encrypted format.
|
||||||
Returns a SHA-256 hash of the received encrypted file for verification.
|
Returns a SHA-256 hash of the received encrypted file for verification.
|
||||||
Requires X-API-Key header.
|
Requires X-API-Key header if API_KEY is configured.
|
||||||
"""
|
"""
|
||||||
|
# Check API key if configured
|
||||||
|
if config.API_KEY and x_api_key != config.API_KEY:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
||||||
|
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
raise HTTPException(status_code=400, detail="No filename provided")
|
raise HTTPException(status_code=400, detail="No filename provided")
|
||||||
|
|
@ -197,7 +192,7 @@ async def upload_recording(
|
||||||
|
|
||||||
# Recordings API
|
# Recordings API
|
||||||
|
|
||||||
@app.get("/api/v1/recordings", dependencies=[Depends(verify_api_key)])
|
@app.get("/api/v1/recordings")
|
||||||
async def list_recordings(
|
async def list_recordings(
|
||||||
source: Optional[str] = Query(None, description="Filter by source name"),
|
source: Optional[str] = Query(None, description="Filter by source name"),
|
||||||
date_from: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
|
date_from: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
|
||||||
|
|
@ -224,7 +219,7 @@ async def list_recordings(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/recordings/{recording_id}", dependencies=[Depends(verify_api_key)])
|
@app.get("/api/v1/recordings/{recording_id}")
|
||||||
async def get_recording(recording_id: int):
|
async def get_recording(recording_id: int):
|
||||||
"""Get recording details with transcript segments and highlights."""
|
"""Get recording details with transcript segments and highlights."""
|
||||||
recording = database.get_recording(recording_id)
|
recording = database.get_recording(recording_id)
|
||||||
|
|
@ -233,7 +228,7 @@ async def get_recording(recording_id: int):
|
||||||
return recording
|
return recording
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/recordings/{recording_id}/audio", dependencies=[Depends(verify_api_key)])
|
@app.get("/api/v1/recordings/{recording_id}/audio")
|
||||||
async def get_recording_audio(recording_id: int):
|
async def get_recording_audio(recording_id: int):
|
||||||
"""Stream the audio file for a recording."""
|
"""Stream the audio file for a recording."""
|
||||||
recording = database.get_recording(recording_id)
|
recording = database.get_recording(recording_id)
|
||||||
|
|
@ -261,7 +256,7 @@ async def get_recording_audio(recording_id: int):
|
||||||
|
|
||||||
# Import API
|
# Import API
|
||||||
|
|
||||||
@app.post("/api/v1/import/transcriptions", dependencies=[Depends(verify_api_key)])
|
@app.post("/api/v1/import/transcriptions")
|
||||||
async def import_transcriptions(request: ImportRequest):
|
async def import_transcriptions(request: ImportRequest):
|
||||||
"""
|
"""
|
||||||
Import transcription JSON files from a directory.
|
Import transcription JSON files from a directory.
|
||||||
|
|
@ -291,7 +286,7 @@ async def import_transcriptions(request: ImportRequest):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/import/audio", dependencies=[Depends(verify_api_key)])
|
@app.post("/api/v1/import/audio")
|
||||||
async def import_audio(request: ImportAudioRequest):
|
async def import_audio(request: ImportAudioRequest):
|
||||||
"""
|
"""
|
||||||
Import audio files from a directory (without transcriptions).
|
Import audio files from a directory (without transcriptions).
|
||||||
|
|
@ -311,7 +306,7 @@ async def import_audio(request: ImportAudioRequest):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/import/link-audio", dependencies=[Depends(verify_api_key)])
|
@app.post("/api/v1/import/link-audio")
|
||||||
async def link_audio(audio_dir: str = Query(..., description="Directory containing audio files")):
|
async def link_audio(audio_dir: str = Query(..., description="Directory containing audio files")):
|
||||||
"""
|
"""
|
||||||
Link audio files to existing transcription records.
|
Link audio files to existing transcription records.
|
||||||
|
|
@ -328,7 +323,7 @@ async def link_audio(audio_dir: str = Query(..., description="Directory containi
|
||||||
|
|
||||||
# Highlights API
|
# Highlights API
|
||||||
|
|
||||||
@app.get("/api/v1/highlights", dependencies=[Depends(verify_api_key)])
|
@app.get("/api/v1/highlights")
|
||||||
async def list_highlights(
|
async def list_highlights(
|
||||||
recording_id: Optional[int] = Query(None),
|
recording_id: Optional[int] = Query(None),
|
||||||
tag: Optional[str] = Query(None),
|
tag: Optional[str] = Query(None),
|
||||||
|
|
@ -343,7 +338,7 @@ async def list_highlights(
|
||||||
return {"highlights": highlights, "count": len(highlights)}
|
return {"highlights": highlights, "count": len(highlights)}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/highlights", dependencies=[Depends(verify_api_key)])
|
@app.post("/api/v1/highlights")
|
||||||
async def create_highlight(highlight: HighlightCreate):
|
async def create_highlight(highlight: HighlightCreate):
|
||||||
"""Create a new highlight/bookmark."""
|
"""Create a new highlight/bookmark."""
|
||||||
# Verify recording exists
|
# Verify recording exists
|
||||||
|
|
@ -363,7 +358,7 @@ async def create_highlight(highlight: HighlightCreate):
|
||||||
return {"id": highlight_id, "message": "Highlight created"}
|
return {"id": highlight_id, "message": "Highlight created"}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/v1/highlights/{highlight_id}", dependencies=[Depends(verify_api_key)])
|
@app.delete("/api/v1/highlights/{highlight_id}")
|
||||||
async def delete_highlight(highlight_id: int):
|
async def delete_highlight(highlight_id: int):
|
||||||
"""Delete a highlight."""
|
"""Delete a highlight."""
|
||||||
success = database.delete_highlight(highlight_id)
|
success = database.delete_highlight(highlight_id)
|
||||||
|
|
@ -374,7 +369,7 @@ async def delete_highlight(highlight_id: int):
|
||||||
|
|
||||||
# Sources/Devices API
|
# Sources/Devices API
|
||||||
|
|
||||||
@app.get("/api/v1/sources", dependencies=[Depends(verify_api_key)])
|
@app.get("/api/v1/sources")
|
||||||
async def list_sources():
|
async def list_sources():
|
||||||
"""Get summary of all recording sources."""
|
"""Get summary of all recording sources."""
|
||||||
sources = database.list_sources()
|
sources = database.list_sources()
|
||||||
|
|
@ -383,7 +378,7 @@ async def list_sources():
|
||||||
|
|
||||||
# Legacy endpoint (for backwards compatibility)
|
# Legacy endpoint (for backwards compatibility)
|
||||||
|
|
||||||
@app.get("/api/v1/recordings/files", dependencies=[Depends(verify_api_key)])
|
@app.get("/api/v1/recordings/files")
|
||||||
async def list_recording_files(date: Optional[str] = Query(None, description="Filter by date (YYYY-MM-DD)")):
|
async def list_recording_files(date: Optional[str] = Query(None, description="Filter by date (YYYY-MM-DD)")):
|
||||||
"""
|
"""
|
||||||
Lists recording files on disk (legacy endpoint).
|
Lists recording files on disk (legacy endpoint).
|
||||||
|
|
|
||||||
|
|
@ -3,36 +3,6 @@ const { useState: useStateApp, useEffect: useEffectApp, useRef: useRefApp } = Re
|
||||||
|
|
||||||
const API_BASE = '';
|
const API_BASE = '';
|
||||||
|
|
||||||
// API key storage
|
|
||||||
const API_KEY_STORAGE_KEY = 'clio_api_key';
|
|
||||||
|
|
||||||
function getStoredApiKey() {
|
|
||||||
return localStorage.getItem(API_KEY_STORAGE_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStoredApiKey(key) {
|
|
||||||
localStorage.setItem(API_KEY_STORAGE_KEY, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearStoredApiKey() {
|
|
||||||
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticated fetch wrapper
|
|
||||||
async function authFetch(url, options = {}) {
|
|
||||||
const apiKey = getStoredApiKey();
|
|
||||||
const headers = {
|
|
||||||
...options.headers,
|
|
||||||
'X-API-Key': apiKey || '',
|
|
||||||
};
|
|
||||||
const response = await fetch(url, { ...options, headers });
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearStoredApiKey();
|
|
||||||
window.dispatchEvent(new Event('auth-required'));
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse hash into route info
|
// Parse hash into route info
|
||||||
function parseHash() {
|
function parseHash() {
|
||||||
const hash = window.location.hash.slice(1) || '/dashboard';
|
const hash = window.location.hash.slice(1) || '/dashboard';
|
||||||
|
|
@ -100,110 +70,14 @@ function transformRecording(rec) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ApiKeyPrompt({ onSubmit }) {
|
|
||||||
const [key, setKey] = useStateApp('');
|
|
||||||
const [error, setError] = useStateApp(null);
|
|
||||||
const [checking, setChecking] = useStateApp(false);
|
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!key.trim()) return;
|
|
||||||
|
|
||||||
setChecking(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Test the key by making a simple request
|
|
||||||
const res = await fetch(`${API_BASE}/api/v1/sources`, {
|
|
||||||
headers: { 'X-API-Key': key.trim() }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
|
||||||
setError('Invalid API key');
|
|
||||||
setChecking(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
setError('Server error');
|
|
||||||
setChecking(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStoredApiKey(key.trim());
|
|
||||||
onSubmit();
|
|
||||||
} catch (err) {
|
|
||||||
setError('Connection failed');
|
|
||||||
setChecking(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
height: '100vh', background: 'var(--bg)',
|
|
||||||
}}>
|
|
||||||
<form onSubmit={handleSubmit} style={{
|
|
||||||
background: 'var(--bg2)', padding: 32, borderRadius: 8,
|
|
||||||
border: '1px solid var(--border)', width: 320,
|
|
||||||
}}>
|
|
||||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 14, marginBottom: 20, color: 'var(--text)' }}>
|
|
||||||
Enter API Key
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={key}
|
|
||||||
onChange={(e) => setKey(e.target.value)}
|
|
||||||
placeholder="API Key"
|
|
||||||
autoFocus
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: '10px 12px', marginBottom: 12,
|
|
||||||
background: 'var(--bg3)', border: '1px solid var(--border)',
|
|
||||||
borderRadius: 4, color: 'var(--text)', fontFamily: 'var(--font-mono)',
|
|
||||||
fontSize: 13, outline: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<div style={{ color: 'var(--red)', fontSize: 12, marginBottom: 12, fontFamily: 'var(--font-mono)' }}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={checking}
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: '10px 12px',
|
|
||||||
background: 'var(--amber)', color: 'var(--bg)',
|
|
||||||
border: 'none', borderRadius: 4, fontFamily: 'var(--font-mono)',
|
|
||||||
fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
|
||||||
opacity: checking ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{checking ? 'Checking...' : 'Continue'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [data, setData] = useStateApp({ recordings: [], highlights: [], devices: {} });
|
const [data, setData] = useStateApp({ recordings: [], highlights: [], devices: {} });
|
||||||
const [loading, setLoading] = useStateApp(true);
|
const [loading, setLoading] = useStateApp(true);
|
||||||
const [error, setError] = useStateApp(null);
|
const [error, setError] = useStateApp(null);
|
||||||
const [needsAuth, setNeedsAuth] = useStateApp(!getStoredApiKey());
|
|
||||||
|
|
||||||
// Lift highlights state so adding/removing works across views
|
// Lift highlights state so adding/removing works across views
|
||||||
const [highlights, setHighlights] = useStateApp([]);
|
const [highlights, setHighlights] = useStateApp([]);
|
||||||
|
|
||||||
// Listen for auth-required events (401 responses)
|
|
||||||
useEffectApp(() => {
|
|
||||||
function handleAuthRequired() {
|
|
||||||
setNeedsAuth(true);
|
|
||||||
}
|
|
||||||
window.addEventListener('auth-required', handleAuthRequired);
|
|
||||||
return () => window.removeEventListener('auth-required', handleAuthRequired);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Initialize from URL hash
|
// Initialize from URL hash
|
||||||
const initialRoute = parseHash();
|
const initialRoute = parseHash();
|
||||||
const [view, setViewState] = useStateApp(initialRoute.view);
|
const [view, setViewState] = useStateApp(initialRoute.view);
|
||||||
|
|
@ -272,7 +146,7 @@ function App() {
|
||||||
async function fetchRecordingDetail(recordingId) {
|
async function fetchRecordingDetail(recordingId) {
|
||||||
setLoadingDetail(true);
|
setLoadingDetail(true);
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`${API_BASE}/api/v1/recordings/${recordingId}`);
|
const res = await fetch(`${API_BASE}/api/v1/recordings/${recordingId}`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch recording');
|
if (!res.ok) throw new Error('Failed to fetch recording');
|
||||||
const rec = await res.json();
|
const rec = await res.json();
|
||||||
const transformed = transformRecording(rec);
|
const transformed = transformRecording(rec);
|
||||||
|
|
@ -315,9 +189,9 @@ function App() {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const [recordingsRes, sourcesRes, highlightsRes] = await Promise.all([
|
const [recordingsRes, sourcesRes, highlightsRes] = await Promise.all([
|
||||||
authFetch(`${API_BASE}/api/v1/recordings?limit=1000`),
|
fetch(`${API_BASE}/api/v1/recordings?limit=1000`),
|
||||||
authFetch(`${API_BASE}/api/v1/sources`),
|
fetch(`${API_BASE}/api/v1/sources`),
|
||||||
authFetch(`${API_BASE}/api/v1/highlights?limit=1000`),
|
fetch(`${API_BASE}/api/v1/highlights?limit=1000`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!recordingsRes.ok) throw new Error('Failed to fetch recordings');
|
if (!recordingsRes.ok) throw new Error('Failed to fetch recordings');
|
||||||
|
|
@ -382,19 +256,6 @@ function App() {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Show API key prompt if not authenticated
|
|
||||||
if (needsAuth) {
|
|
||||||
return (
|
|
||||||
<ApiKeyPrompt onSubmit={() => {
|
|
||||||
setNeedsAuth(false);
|
|
||||||
setLoading(true);
|
|
||||||
// Re-trigger data fetch by incrementing a counter or similar
|
|
||||||
// For simplicity, just reload the page
|
|
||||||
window.location.reload();
|
|
||||||
}} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', color: 'var(--text2)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', color: 'var(--text2)' }}>
|
||||||
|
|
@ -502,8 +363,5 @@ function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export utilities for other components
|
|
||||||
Object.assign(window, { authFetch, getStoredApiKey });
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
root.render(<App />);
|
root.render(<App />);
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
|
||||||
const [showAddHighlight, setShowAddHighlight] = useStateRD(false);
|
const [showAddHighlight, setShowAddHighlight] = useStateRD(false);
|
||||||
const [newHighlightTag, setNewHighlightTag] = useStateRD('important');
|
const [newHighlightTag, setNewHighlightTag] = useStateRD('important');
|
||||||
const [newHighlightNote, setNewHighlightNote] = useStateRD('');
|
const [newHighlightNote, setNewHighlightNote] = useStateRD('');
|
||||||
const [audioBlobUrl, setAudioBlobUrl] = useStateRD(null);
|
|
||||||
const [audioLoading, setAudioLoading] = useStateRD(true);
|
|
||||||
const audioRef = useRefRD(null);
|
const audioRef = useRefRD(null);
|
||||||
const transcriptRef = useRefRD(null);
|
const transcriptRef = useRefRD(null);
|
||||||
|
|
||||||
|
|
@ -22,35 +20,8 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
|
||||||
const totalSeconds = audioDuration || metadataDuration || 1; // avoid division by zero
|
const totalSeconds = audioDuration || metadataDuration || 1; // avoid division by zero
|
||||||
const recHighlights = allHighlights.filter(h => h.recordingId === r.id);
|
const recHighlights = allHighlights.filter(h => h.recordingId === r.id);
|
||||||
|
|
||||||
// Fetch audio with auth and create blob URL
|
// Audio URL from API
|
||||||
useEffectRD(() => {
|
const audioUrl = `${API_BASE}/api/v1/recordings/${r.id}/audio`;
|
||||||
let cancelled = false;
|
|
||||||
let blobUrl = null;
|
|
||||||
|
|
||||||
async function fetchAudio() {
|
|
||||||
setAudioLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await window.authFetch(`${API_BASE}/api/v1/recordings/${r.id}/audio`);
|
|
||||||
if (cancelled) return;
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch audio');
|
|
||||||
const blob = await res.blob();
|
|
||||||
if (cancelled) return;
|
|
||||||
blobUrl = URL.createObjectURL(blob);
|
|
||||||
setAudioBlobUrl(blobUrl);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load audio:', err);
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setAudioLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAudio();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
||||||
};
|
|
||||||
}, [r.id]);
|
|
||||||
|
|
||||||
// Track recording ID to detect when recording changes
|
// Track recording ID to detect when recording changes
|
||||||
const lastRecordingId = useRefRD(null);
|
const lastRecordingId = useRefRD(null);
|
||||||
|
|
@ -62,7 +33,6 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
|
||||||
hasAutoPlayed.current = false;
|
hasAutoPlayed.current = false;
|
||||||
lastRecordingId.current = r.id;
|
lastRecordingId.current = r.id;
|
||||||
setAudioDuration(null); // Reset to use metadata until audio loads
|
setAudioDuration(null); // Reset to use metadata until audio loads
|
||||||
setAudioBlobUrl(null); // Clear old blob URL
|
|
||||||
}
|
}
|
||||||
}, [r.id]);
|
}, [r.id]);
|
||||||
|
|
||||||
|
|
@ -220,7 +190,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
|
||||||
const endSecs = highlightEndTime; // null for point highlight
|
const endSecs = highlightEndTime; // null for point highlight
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await window.authFetch(`${API_BASE}/api/v1/highlights`, {
|
const res = await fetch(`${API_BASE}/api/v1/highlights`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -260,7 +230,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
|
||||||
const numericId = hid.startsWith('h-') ? hid.slice(2) : hid;
|
const numericId = hid.startsWith('h-') ? hid.slice(2) : hid;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await window.authFetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
|
const res = await fetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (!res.ok && res.status !== 404) {
|
if (!res.ok && res.status !== 404) {
|
||||||
|
|
@ -474,8 +444,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hidden audio element */}
|
{/* Hidden audio element */}
|
||||||
{audioBlobUrl && <audio ref={audioRef} src={audioBlobUrl} preload="metadata" />}
|
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
||||||
{audioLoading && <div style={{ color: 'var(--text3)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>Loading audio...</div>}
|
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue