2260 lines
90 KiB
Python
2260 lines
90 KiB
Python
"""
|
|
API v1 - RESTful API for external integrations.
|
|
|
|
This blueprint provides a comprehensive REST API for:
|
|
- Dashboard widgets (gethomepage.dev, etc.)
|
|
- Automation tools (n8n, Zapier, etc.)
|
|
- Third-party integrations
|
|
|
|
All endpoints require token authentication via:
|
|
- Authorization: Bearer <token>
|
|
- X-API-Token: <token>
|
|
- API-Token: <token>
|
|
- ?token=<token> query parameter
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import json
|
|
from datetime import datetime, date, timedelta
|
|
from typing import Optional
|
|
|
|
from flask import Blueprint, jsonify, request, current_app, send_file
|
|
from flask_login import login_required, current_user
|
|
from sqlalchemy import func, extract, or_, and_
|
|
|
|
from src.database import db
|
|
from src.models import Recording, User, Tag, RecordingTag, Speaker, Event
|
|
from src.models.processing_job import ProcessingJob
|
|
from src.models.token_usage import TokenUsage
|
|
from src.models.transcription_usage import TranscriptionUsage
|
|
from src.services.token_tracking import TokenTracker
|
|
from src.services.transcription_tracking import transcription_tracker
|
|
from src.file_exporter import format_transcription_with_template
|
|
from src.api.recordings import upload_file as _upload_file_ui
|
|
|
|
# Create blueprint with /api/v1 prefix
|
|
api_v1_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1')
|
|
|
|
# Global helpers (will be injected from app)
|
|
has_recording_access = None
|
|
get_user_recording_status = None
|
|
set_user_recording_status = None
|
|
enrich_recording_dict_with_user_status = None
|
|
bcrypt = None
|
|
csrf = None
|
|
limiter = None
|
|
chunking_service = None
|
|
|
|
# Token tracker instance
|
|
token_tracker = TokenTracker()
|
|
|
|
|
|
def init_api_v1_helpers(**kwargs):
|
|
"""Initialize helper functions and extensions from app."""
|
|
global has_recording_access, get_user_recording_status, set_user_recording_status
|
|
global enrich_recording_dict_with_user_status, bcrypt, csrf, limiter, chunking_service
|
|
has_recording_access = kwargs.get('has_recording_access')
|
|
get_user_recording_status = kwargs.get('get_user_recording_status')
|
|
set_user_recording_status = kwargs.get('set_user_recording_status')
|
|
enrich_recording_dict_with_user_status = kwargs.get('enrich_recording_dict_with_user_status')
|
|
bcrypt = kwargs.get('bcrypt')
|
|
csrf = kwargs.get('csrf')
|
|
limiter = kwargs.get('limiter')
|
|
chunking_service = kwargs.get('chunking_service')
|
|
|
|
|
|
def format_bytes(bytes_value: int) -> str:
|
|
"""Format bytes to human-readable string."""
|
|
if bytes_value is None:
|
|
bytes_value = 0
|
|
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
|
if bytes_value < 1024:
|
|
return f"{bytes_value:.1f} {unit}"
|
|
bytes_value /= 1024
|
|
return f"{bytes_value:.1f} PB"
|
|
|
|
|
|
# =============================================================================
|
|
# OpenAPI Documentation
|
|
# =============================================================================
|
|
|
|
OPENAPI_SPEC = {
|
|
"openapi": "3.0.3",
|
|
"info": {
|
|
"title": "Speakr API",
|
|
"description": "REST API for Speakr - Audio transcription and note-taking application.\n\n## Authentication\nAll endpoints require token authentication via one of:\n- `Authorization: Bearer <token>`\n- `X-API-Token: <token>`\n- `API-Token: <token>`\n- `?token=<token>` query parameter\n\nGenerate tokens in Settings > API Tokens.",
|
|
"version": "1.0.0"
|
|
},
|
|
"servers": [{"url": "/api/v1", "description": "API v1"}],
|
|
"components": {
|
|
"securitySchemes": {
|
|
"bearerAuth": {"type": "http", "scheme": "bearer"},
|
|
"apiKeyHeader": {"type": "apiKey", "in": "header", "name": "X-API-Token"},
|
|
"apiKeyQuery": {"type": "apiKey", "in": "query", "name": "token"}
|
|
},
|
|
"schemas": {
|
|
"Recording": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "integer"},
|
|
"title": {"type": "string"},
|
|
"status": {"type": "string", "enum": ["PENDING", "PROCESSING", "SUMMARIZING", "COMPLETED", "FAILED"]},
|
|
"created_at": {"type": "string", "format": "date-time"},
|
|
"meeting_date": {"type": "string", "format": "date-time"},
|
|
"file_size": {"type": "integer"},
|
|
"participants": {"type": "string"},
|
|
"is_inbox": {"type": "boolean"},
|
|
"is_highlighted": {"type": "boolean"},
|
|
"tags": {"type": "array", "items": {"$ref": "#/components/schemas/Tag"}}
|
|
}
|
|
},
|
|
"Tag": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "integer"},
|
|
"name": {"type": "string"},
|
|
"color": {"type": "string"},
|
|
"custom_prompt": {"type": "string"},
|
|
"default_language": {"type": "string"},
|
|
"default_min_speakers": {"type": "integer"},
|
|
"default_max_speakers": {"type": "integer"}
|
|
}
|
|
},
|
|
"Speaker": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "integer"},
|
|
"name": {"type": "string"},
|
|
"use_count": {"type": "integer"},
|
|
"has_voice_profile": {"type": "boolean"}
|
|
}
|
|
},
|
|
"Error": {
|
|
"type": "object",
|
|
"properties": {"error": {"type": "string"}}
|
|
}
|
|
}
|
|
},
|
|
"security": [{"bearerAuth": []}, {"apiKeyHeader": []}, {"apiKeyQuery": []}],
|
|
"paths": {
|
|
"/stats": {
|
|
"get": {
|
|
"tags": ["Stats"],
|
|
"summary": "Get system statistics",
|
|
"description": "Returns stats compatible with gethomepage.dev widgets",
|
|
"parameters": [{"name": "scope", "in": "query", "schema": {"type": "string", "enum": ["user", "all"], "default": "user"}, "description": "user=personal stats, all=global (admin only)"}],
|
|
"responses": {"200": {"description": "Stats object"}}
|
|
}
|
|
},
|
|
"/recordings": {
|
|
"get": {
|
|
"tags": ["Recordings"],
|
|
"summary": "List recordings",
|
|
"parameters": [
|
|
{"name": "page", "in": "query", "schema": {"type": "integer", "default": 1}},
|
|
{"name": "per_page", "in": "query", "schema": {"type": "integer", "default": 25, "maximum": 100}},
|
|
{"name": "status", "in": "query", "schema": {"type": "string", "enum": ["all", "pending", "processing", "completed", "failed"]}},
|
|
{"name": "sort_by", "in": "query", "schema": {"type": "string", "enum": ["created_at", "meeting_date", "title", "file_size"]}},
|
|
{"name": "sort_order", "in": "query", "schema": {"type": "string", "enum": ["asc", "desc"]}},
|
|
{"name": "tag_id", "in": "query", "schema": {"type": "integer"}},
|
|
{"name": "q", "in": "query", "schema": {"type": "string"}, "description": "Search query"}
|
|
],
|
|
"responses": {"200": {"description": "Paginated list of recordings"}}
|
|
}
|
|
},
|
|
"/recordings/{id}": {
|
|
"get": {
|
|
"tags": ["Recordings"],
|
|
"summary": "Get recording details",
|
|
"parameters": [
|
|
{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}},
|
|
{"name": "format", "in": "query", "schema": {"type": "string", "enum": ["full", "minimal"]}, "description": "minimal excludes large text fields"},
|
|
{"name": "include", "in": "query", "schema": {"type": "string"}, "description": "Comma-separated: transcription,summary,notes"}
|
|
],
|
|
"responses": {"200": {"description": "Recording details"}, "404": {"description": "Not found"}}
|
|
},
|
|
"patch": {
|
|
"tags": ["Recordings"],
|
|
"summary": "Update recording",
|
|
"parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}],
|
|
"requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"title": {"type": "string"}, "participants": {"type": "string"}, "notes": {"type": "string"}, "summary": {"type": "string"}, "meeting_date": {"type": "string"}, "is_inbox": {"type": "boolean"}, "is_highlighted": {"type": "boolean"}}}}}},
|
|
"responses": {"200": {"description": "Updated recording"}}
|
|
},
|
|
"delete": {
|
|
"tags": ["Recordings"],
|
|
"summary": "Delete recording",
|
|
"parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}],
|
|
"responses": {"200": {"description": "Deleted"}, "403": {"description": "Permission denied"}}
|
|
}
|
|
},
|
|
"/recordings/{id}/transcript": {
|
|
"get": {
|
|
"tags": ["Recordings"],
|
|
"summary": "Get transcript",
|
|
"parameters": [
|
|
{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}},
|
|
{"name": "format", "in": "query", "schema": {"type": "string", "enum": ["json", "text", "srt", "vtt"], "default": "json"}}
|
|
],
|
|
"responses": {"200": {"description": "Transcript in requested format"}}
|
|
}
|
|
},
|
|
"/recordings/{id}/summary": {
|
|
"get": {"tags": ["Recordings"], "summary": "Get summary", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Summary markdown"}}},
|
|
"put": {"tags": ["Recordings"], "summary": "Replace summary", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["summary"], "properties": {"summary": {"type": "string"}}}}}}, "responses": {"200": {"description": "Updated"}}}
|
|
},
|
|
"/recordings/{id}/notes": {
|
|
"get": {"tags": ["Recordings"], "summary": "Get notes", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Notes markdown"}}},
|
|
"put": {"tags": ["Recordings"], "summary": "Replace notes", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["notes"], "properties": {"notes": {"type": "string"}}}}}}, "responses": {"200": {"description": "Updated"}}}
|
|
},
|
|
"/recordings/{id}/status": {
|
|
"get": {"tags": ["Recordings"], "summary": "Get processing status", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Status with queue position"}}}
|
|
},
|
|
"/recordings/{id}/transcribe": {
|
|
"post": {"tags": ["Processing"], "summary": "Queue transcription", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"language": {"type": "string"}, "min_speakers": {"type": "integer"}, "max_speakers": {"type": "integer"}}}}}}, "responses": {"200": {"description": "Job queued"}}}
|
|
},
|
|
"/recordings/{id}/summarize": {
|
|
"post": {"tags": ["Processing"], "summary": "Queue summarization", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"custom_prompt": {"type": "string"}}}}}}, "responses": {"200": {"description": "Job queued"}}}
|
|
},
|
|
"/recordings/{id}/chat": {
|
|
"post": {"tags": ["Chat"], "summary": "Chat about recording", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string"}, "conversation_history": {"type": "array"}}}}}}, "responses": {"200": {"description": "Chat response"}}}
|
|
},
|
|
"/recordings/{id}/events": {
|
|
"get": {"tags": ["Events"], "summary": "Get calendar events", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "List of events"}}}
|
|
},
|
|
"/recordings/{id}/events/ics": {
|
|
"get": {"tags": ["Events"], "summary": "Download events as ICS", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "ICS file", "content": {"text/calendar": {}}}}}
|
|
},
|
|
"/recordings/{id}/audio": {
|
|
"get": {"tags": ["Audio"], "summary": "Download audio", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}, {"name": "download", "in": "query", "schema": {"type": "boolean"}}], "responses": {"200": {"description": "Audio file"}}}
|
|
},
|
|
"/recordings/{id}/tags": {
|
|
"post": {"tags": ["Tags"], "summary": "Add tags to recording", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"tag_ids": {"type": "array", "items": {"type": "integer"}}}}}}}, "responses": {"200": {"description": "Tags added"}}}
|
|
},
|
|
"/recordings/{id}/tags/{tag_id}": {
|
|
"delete": {"tags": ["Tags"], "summary": "Remove tag from recording", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}, {"name": "tag_id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Tag removed"}}}
|
|
},
|
|
"/recordings/{id}/speakers": {
|
|
"get": {"tags": ["Speakers"], "summary": "Get speakers in recording", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Speakers with suggestions"}}}
|
|
},
|
|
"/recordings/{id}/speakers/assign": {
|
|
"put": {
|
|
"tags": ["Speakers"],
|
|
"summary": "Assign speaker names to transcription",
|
|
"parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}],
|
|
"requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["speaker_map"], "properties": {"speaker_map": {"type": "object", "description": "Map of speaker labels to names. Values can be: string (name) or object {name, isMe}."}, "regenerate_summary": {"type": "boolean", "default": False}}}}}},
|
|
"responses": {"200": {"description": "Speakers assigned"}, "404": {"description": "Recording not found"}, "403": {"description": "Permission denied"}}
|
|
}
|
|
},
|
|
"/recordings/{id}/speakers/identify": {
|
|
"post": {
|
|
"tags": ["Speakers"],
|
|
"summary": "Auto-identify speakers via LLM",
|
|
"description": "Analyzes transcript context to suggest speaker names. Returns suggestions only - does not modify the recording.",
|
|
"parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}],
|
|
"responses": {"200": {"description": "Speaker identification suggestions"}, "400": {"description": "Transcription not available or unsupported format"}}
|
|
}
|
|
},
|
|
"/recordings/batch": {
|
|
"patch": {"tags": ["Batch"], "summary": "Batch update recordings", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["recording_ids", "updates"], "properties": {"recording_ids": {"type": "array", "items": {"type": "integer"}}, "updates": {"type": "object"}}}}}}, "responses": {"200": {"description": "Batch results"}}},
|
|
"delete": {"tags": ["Batch"], "summary": "Batch delete recordings", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["recording_ids"], "properties": {"recording_ids": {"type": "array", "items": {"type": "integer"}}}}}}}, "responses": {"200": {"description": "Batch results"}}}
|
|
},
|
|
"/recordings/batch/transcribe": {
|
|
"post": {"tags": ["Batch"], "summary": "Batch queue transcriptions", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["recording_ids"], "properties": {"recording_ids": {"type": "array", "items": {"type": "integer"}}}}}}}, "responses": {"200": {"description": "Batch results"}}}
|
|
},
|
|
"/recordings/upload": {
|
|
"post": {
|
|
"tags": ["Recordings"],
|
|
"summary": "Upload a recording (multipart form-data) and queue transcription",
|
|
"requestBody": {
|
|
"content": {
|
|
"multipart/form-data": {
|
|
"schema": {
|
|
"type": "object",
|
|
"required": ["file"],
|
|
"properties": {
|
|
"file": {"type": "string", "format": "binary"},
|
|
"notes": {"type": "string"},
|
|
"file_last_modified": {"type": "string"},
|
|
"language": {"type": "string"},
|
|
"min_speakers": {"type": "integer"},
|
|
"max_speakers": {"type": "integer"},
|
|
"tag_id": {"type": "integer"},
|
|
"tag_ids[0]": {"type": "integer"},
|
|
"tag_ids[1]": {"type": "integer"}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"responses": {"202": {"description": "Upload accepted and queued"}}
|
|
}
|
|
},
|
|
"/tags": {
|
|
"get": {"tags": ["Tags"], "summary": "List tags", "responses": {"200": {"description": "List of tags"}}},
|
|
"post": {"tags": ["Tags"], "summary": "Create tag", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}, "color": {"type": "string"}, "custom_prompt": {"type": "string"}, "default_language": {"type": "string"}, "default_min_speakers": {"type": "integer"}, "default_max_speakers": {"type": "integer"}}}}}}, "responses": {"201": {"description": "Tag created"}}}
|
|
},
|
|
"/tags/{id}": {
|
|
"put": {"tags": ["Tags"], "summary": "Update tag", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}, "color": {"type": "string"}, "custom_prompt": {"type": "string"}}}}}}, "responses": {"200": {"description": "Tag updated"}}},
|
|
"delete": {"tags": ["Tags"], "summary": "Delete tag", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Tag deleted"}}}
|
|
},
|
|
"/speakers": {
|
|
"get": {"tags": ["Speakers"], "summary": "List speakers", "responses": {"200": {"description": "List of speakers"}}},
|
|
"post": {"tags": ["Speakers"], "summary": "Create speaker", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}}}}}, "responses": {"201": {"description": "Speaker created"}}}
|
|
},
|
|
"/speakers/{id}": {
|
|
"put": {"tags": ["Speakers"], "summary": "Update speaker", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}}}}}}, "responses": {"200": {"description": "Speaker updated"}}},
|
|
"delete": {"tags": ["Speakers"], "summary": "Delete speaker", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Speaker deleted"}}}
|
|
},
|
|
"/settings/auto-summarization": {
|
|
"put": {
|
|
"tags": ["Settings"],
|
|
"summary": "Toggle auto-summarization",
|
|
"description": "Enable or disable auto-summarization for the current user.",
|
|
"requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["enabled"], "properties": {"enabled": {"type": "boolean"}}}}}},
|
|
"responses": {"200": {"description": "Setting updated"}}
|
|
}
|
|
}
|
|
},
|
|
"tags": [
|
|
{"name": "Stats", "description": "System statistics for dashboards"},
|
|
{"name": "Recordings", "description": "Recording CRUD operations"},
|
|
{"name": "Processing", "description": "Transcription and summarization"},
|
|
{"name": "Chat", "description": "Chat with recordings"},
|
|
{"name": "Events", "description": "Calendar events"},
|
|
{"name": "Audio", "description": "Audio file operations"},
|
|
{"name": "Tags", "description": "Tag management"},
|
|
{"name": "Speakers", "description": "Speaker management"},
|
|
{"name": "Batch", "description": "Batch operations"},
|
|
{"name": "Settings", "description": "User settings"}
|
|
]
|
|
}
|
|
|
|
|
|
@api_v1_bp.route('/openapi.json', methods=['GET'])
|
|
def get_openapi_spec():
|
|
"""Return OpenAPI specification."""
|
|
return jsonify(OPENAPI_SPEC)
|
|
|
|
|
|
@api_v1_bp.route('/docs', methods=['GET'])
|
|
def get_docs():
|
|
"""Serve Swagger UI documentation."""
|
|
from flask import Response
|
|
html = '''<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Speakr API v1 Documentation</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
|
</head>
|
|
<body>
|
|
<div id="swagger-ui"></div>
|
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
<script>
|
|
SwaggerUIBundle({
|
|
url: "/api/v1/openapi.json",
|
|
dom_id: '#swagger-ui',
|
|
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
|
|
layout: "BaseLayout",
|
|
persistAuthorization: true
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>'''
|
|
return Response(html, mimetype='text/html')
|
|
|
|
|
|
# =============================================================================
|
|
# Stats Endpoint (Homepage Widget Compatible)
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/stats', methods=['GET'])
|
|
@login_required
|
|
def get_stats():
|
|
"""
|
|
Get system/user statistics for dashboard widgets.
|
|
|
|
Query params:
|
|
scope: 'user' (default) or 'all' (admin only)
|
|
|
|
Returns JSON compatible with gethomepage.dev custom API widget:
|
|
{
|
|
"recordings": {"total": N, "completed": N, "processing": N, "pending": N, "failed": N},
|
|
"storage": {"used_bytes": N, "used_human": "X.X GB"},
|
|
"queue": {"jobs_queued": N, "jobs_processing": N},
|
|
"tokens": {"used_this_month": N, "budget": N, "percentage": N},
|
|
"transcription": {"used_this_month_seconds": N, "used_this_month_minutes": N, "budget_seconds": N, "budget_minutes": N, "percentage": N, "estimated_cost": N},
|
|
"activity": {"recordings_today": N, "last_transcription": "ISO datetime"}
|
|
}
|
|
"""
|
|
scope = request.args.get('scope', 'user')
|
|
|
|
# Admin-only for global stats
|
|
if scope == 'all' and not current_user.is_admin:
|
|
return jsonify({'error': 'Admin access required for global stats'}), 403
|
|
|
|
# Build query filters based on scope
|
|
if scope == 'user':
|
|
recording_filter = Recording.user_id == current_user.id
|
|
job_filter = ProcessingJob.user_id == current_user.id
|
|
user_id_for_tokens = current_user.id
|
|
else:
|
|
recording_filter = True # No filter = all recordings
|
|
job_filter = True
|
|
user_id_for_tokens = None # Will aggregate all users
|
|
|
|
# Recording counts by status
|
|
total = Recording.query.filter(recording_filter).count()
|
|
completed = Recording.query.filter(recording_filter, Recording.status == 'COMPLETED').count()
|
|
processing = Recording.query.filter(
|
|
recording_filter,
|
|
Recording.status.in_(['PROCESSING', 'SUMMARIZING'])
|
|
).count()
|
|
pending = Recording.query.filter(recording_filter, Recording.status == 'PENDING').count()
|
|
failed = Recording.query.filter(recording_filter, Recording.status == 'FAILED').count()
|
|
|
|
# Storage calculation
|
|
storage_query = db.session.query(func.sum(Recording.file_size)).filter(recording_filter)
|
|
storage_bytes = storage_query.scalar() or 0
|
|
|
|
# Queue status
|
|
jobs_queued = ProcessingJob.query.filter(
|
|
job_filter,
|
|
ProcessingJob.status == 'queued'
|
|
).count()
|
|
jobs_processing = ProcessingJob.query.filter(
|
|
job_filter,
|
|
ProcessingJob.status == 'processing'
|
|
).count()
|
|
|
|
# Token usage
|
|
tokens_data = {}
|
|
if user_id_for_tokens:
|
|
# Single user stats
|
|
monthly_usage = token_tracker.get_monthly_usage(user_id_for_tokens)
|
|
user = db.session.get(User, user_id_for_tokens)
|
|
budget = user.monthly_token_budget if user else None
|
|
|
|
tokens_data = {
|
|
'used_this_month': monthly_usage,
|
|
'budget': budget,
|
|
'percentage': round((monthly_usage / budget * 100), 1) if budget else None
|
|
}
|
|
else:
|
|
# Aggregate all users (admin scope)
|
|
current_year = date.today().year
|
|
current_month = date.today().month
|
|
total_usage = db.session.query(func.sum(TokenUsage.total_tokens)).filter(
|
|
extract('year', TokenUsage.date) == current_year,
|
|
extract('month', TokenUsage.date) == current_month
|
|
).scalar() or 0
|
|
|
|
tokens_data = {
|
|
'used_this_month': total_usage,
|
|
'budget': None,
|
|
'percentage': None
|
|
}
|
|
|
|
# Transcription usage
|
|
transcription_data = {}
|
|
if user_id_for_tokens:
|
|
# Single user stats
|
|
monthly_transcription = transcription_tracker.get_monthly_usage(user_id_for_tokens)
|
|
monthly_cost = transcription_tracker.get_monthly_cost(user_id_for_tokens)
|
|
user = db.session.get(User, user_id_for_tokens)
|
|
transcription_budget = user.monthly_transcription_budget if user else None
|
|
|
|
transcription_data = {
|
|
'used_this_month_seconds': monthly_transcription,
|
|
'used_this_month_minutes': monthly_transcription // 60,
|
|
'budget_seconds': transcription_budget,
|
|
'budget_minutes': transcription_budget // 60 if transcription_budget else None,
|
|
'percentage': round((monthly_transcription / transcription_budget * 100), 1) if transcription_budget else None,
|
|
'estimated_cost': round(monthly_cost, 4)
|
|
}
|
|
else:
|
|
# Aggregate all users (admin scope)
|
|
current_year = date.today().year
|
|
current_month = date.today().month
|
|
total_seconds = db.session.query(func.sum(TranscriptionUsage.audio_duration_seconds)).filter(
|
|
extract('year', TranscriptionUsage.date) == current_year,
|
|
extract('month', TranscriptionUsage.date) == current_month
|
|
).scalar() or 0
|
|
total_cost = db.session.query(func.sum(TranscriptionUsage.estimated_cost)).filter(
|
|
extract('year', TranscriptionUsage.date) == current_year,
|
|
extract('month', TranscriptionUsage.date) == current_month
|
|
).scalar() or 0
|
|
|
|
transcription_data = {
|
|
'used_this_month_seconds': total_seconds,
|
|
'used_this_month_minutes': total_seconds // 60,
|
|
'budget_seconds': None,
|
|
'budget_minutes': None,
|
|
'percentage': None,
|
|
'estimated_cost': round(total_cost, 4)
|
|
}
|
|
|
|
# Recent activity
|
|
today_start = datetime.combine(date.today(), datetime.min.time())
|
|
recordings_today = Recording.query.filter(
|
|
recording_filter,
|
|
Recording.created_at >= today_start
|
|
).count()
|
|
|
|
# Last completed transcription
|
|
last_completed = Recording.query.filter(
|
|
recording_filter,
|
|
Recording.status == 'COMPLETED',
|
|
Recording.completed_at.isnot(None)
|
|
).order_by(Recording.completed_at.desc()).first()
|
|
|
|
last_transcription = last_completed.completed_at.isoformat() if last_completed and last_completed.completed_at else None
|
|
|
|
# Build response
|
|
response = {
|
|
'recordings': {
|
|
'total': total,
|
|
'completed': completed,
|
|
'processing': processing,
|
|
'pending': pending,
|
|
'failed': failed
|
|
},
|
|
'storage': {
|
|
'used_bytes': storage_bytes,
|
|
'used_human': format_bytes(storage_bytes)
|
|
},
|
|
'queue': {
|
|
'jobs_queued': jobs_queued,
|
|
'jobs_processing': jobs_processing
|
|
},
|
|
'tokens': tokens_data,
|
|
'transcription': transcription_data,
|
|
'activity': {
|
|
'recordings_today': recordings_today,
|
|
'last_transcription': last_transcription
|
|
}
|
|
}
|
|
|
|
# Add user counts for admin scope
|
|
if scope == 'all' and current_user.is_admin:
|
|
total_users = User.query.count()
|
|
# Active = users with recordings in last 30 days
|
|
cutoff = datetime.utcnow() - timedelta(days=30)
|
|
active_users = db.session.query(func.count(func.distinct(Recording.user_id))).filter(
|
|
Recording.created_at >= cutoff
|
|
).scalar() or 0
|
|
|
|
response['users'] = {
|
|
'total': total_users,
|
|
'active': active_users
|
|
}
|
|
|
|
return jsonify(response)
|
|
|
|
|
|
# =============================================================================
|
|
# Recordings List with Enhanced Filtering
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/recordings', methods=['GET'])
|
|
@login_required
|
|
def list_recordings():
|
|
"""
|
|
List recordings with filtering and pagination.
|
|
|
|
Query params:
|
|
page: Page number (default: 1)
|
|
per_page: Items per page (default: 25, max: 100)
|
|
status: Filter by status (pending, processing, completed, failed, all)
|
|
sort_by: Sort field (created_at, meeting_date, title, file_size, status)
|
|
sort_order: asc or desc (default: desc)
|
|
date_from: Filter from date (ISO format)
|
|
date_to: Filter to date (ISO format)
|
|
tag_id: Filter by tag ID
|
|
q: Search query (title, participants)
|
|
inbox: Filter by inbox status (true/false)
|
|
starred: Filter by starred status (true/false)
|
|
"""
|
|
# Parse query parameters
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = min(request.args.get('per_page', 25, type=int), 100)
|
|
status_filter = request.args.get('status', 'all').lower()
|
|
sort_by = request.args.get('sort_by', 'created_at')
|
|
sort_order = request.args.get('sort_order', 'desc').lower()
|
|
date_from = request.args.get('date_from')
|
|
date_to = request.args.get('date_to')
|
|
tag_id = request.args.get('tag_id', type=int)
|
|
search_query = request.args.get('q', '').strip()
|
|
inbox_filter = request.args.get('inbox')
|
|
starred_filter = request.args.get('starred')
|
|
|
|
# Base query - user's recordings
|
|
query = Recording.query.filter(Recording.user_id == current_user.id)
|
|
|
|
# Status filter
|
|
if status_filter == 'pending':
|
|
query = query.filter(Recording.status == 'PENDING')
|
|
elif status_filter == 'processing':
|
|
query = query.filter(Recording.status.in_(['PROCESSING', 'SUMMARIZING']))
|
|
elif status_filter == 'completed':
|
|
query = query.filter(Recording.status == 'COMPLETED')
|
|
elif status_filter == 'failed':
|
|
query = query.filter(Recording.status == 'FAILED')
|
|
# 'all' = no status filter
|
|
|
|
# Date filters
|
|
if date_from:
|
|
try:
|
|
from_date = datetime.fromisoformat(date_from.replace('Z', '+00:00'))
|
|
query = query.filter(Recording.created_at >= from_date)
|
|
except ValueError:
|
|
pass
|
|
|
|
if date_to:
|
|
try:
|
|
to_date = datetime.fromisoformat(date_to.replace('Z', '+00:00'))
|
|
query = query.filter(Recording.created_at <= to_date)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Tag filter
|
|
if tag_id:
|
|
query = query.join(RecordingTag).filter(RecordingTag.tag_id == tag_id)
|
|
|
|
# Search filter
|
|
if search_query:
|
|
search_pattern = f'%{search_query}%'
|
|
query = query.filter(
|
|
or_(
|
|
Recording.title.ilike(search_pattern),
|
|
Recording.participants.ilike(search_pattern)
|
|
)
|
|
)
|
|
|
|
# Inbox filter
|
|
if inbox_filter is not None:
|
|
is_inbox = inbox_filter.lower() == 'true'
|
|
query = query.filter(Recording.is_inbox == is_inbox)
|
|
|
|
# Starred filter
|
|
if starred_filter is not None:
|
|
is_starred = starred_filter.lower() == 'true'
|
|
query = query.filter(Recording.is_highlighted == is_starred)
|
|
|
|
# Sorting
|
|
sort_columns = {
|
|
'created_at': Recording.created_at,
|
|
'meeting_date': Recording.meeting_date,
|
|
'title': Recording.title,
|
|
'file_size': Recording.file_size,
|
|
'status': Recording.status
|
|
}
|
|
sort_column = sort_columns.get(sort_by, Recording.created_at)
|
|
|
|
if sort_order == 'asc':
|
|
query = query.order_by(sort_column.asc())
|
|
else:
|
|
query = query.order_by(sort_column.desc())
|
|
|
|
# Pagination
|
|
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
|
|
|
# Build response
|
|
recordings = []
|
|
for r in pagination.items:
|
|
recordings.append({
|
|
'id': r.id,
|
|
'title': r.title,
|
|
'status': r.status,
|
|
'created_at': r.created_at.isoformat() if r.created_at else None,
|
|
'meeting_date': r.meeting_date.isoformat() if r.meeting_date else None,
|
|
'file_size': r.file_size,
|
|
'original_filename': r.original_filename,
|
|
'participants': r.participants,
|
|
'is_inbox': r.is_inbox,
|
|
'is_highlighted': r.is_highlighted,
|
|
'audio_available': r.audio_deleted_at is None,
|
|
'has_transcription': bool(r.transcription),
|
|
'has_summary': bool(r.summary),
|
|
'error_message': r.error_message if r.status == 'FAILED' else None,
|
|
'tags': [{'id': t.id, 'name': t.name, 'color': t.color} for t in r.tags]
|
|
})
|
|
|
|
return jsonify({
|
|
'recordings': recordings,
|
|
'pagination': {
|
|
'page': pagination.page,
|
|
'per_page': pagination.per_page,
|
|
'total': pagination.total,
|
|
'total_pages': pagination.pages,
|
|
'has_next': pagination.has_next,
|
|
'has_prev': pagination.has_prev
|
|
}
|
|
})
|
|
|
|
|
|
# =============================================================================
|
|
# Recording Detail
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>', methods=['GET'])
|
|
@login_required
|
|
def get_recording(recording_id):
|
|
"""
|
|
Get full recording details.
|
|
|
|
Query params:
|
|
include: Comma-separated fields to include (transcription, summary, notes)
|
|
Default: all fields
|
|
format: 'full' (default) or 'minimal' (excludes large text fields)
|
|
"""
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
include = request.args.get('include', 'transcription,summary,notes')
|
|
include_fields = [f.strip() for f in include.split(',')]
|
|
format_type = request.args.get('format', 'full')
|
|
|
|
response = {
|
|
'id': recording.id,
|
|
'title': recording.title,
|
|
'status': recording.status,
|
|
'participants': recording.participants,
|
|
'created_at': recording.created_at.isoformat() if recording.created_at else None,
|
|
'meeting_date': recording.meeting_date.isoformat() if recording.meeting_date else None,
|
|
'completed_at': recording.completed_at.isoformat() if recording.completed_at else None,
|
|
'file_size': recording.file_size,
|
|
'original_filename': recording.original_filename,
|
|
'mime_type': recording.mime_type,
|
|
'is_inbox': recording.is_inbox,
|
|
'is_highlighted': recording.is_highlighted,
|
|
'audio_available': recording.audio_deleted_at is None,
|
|
'processing_time_seconds': recording.processing_time_seconds,
|
|
'error_message': recording.error_message if recording.status == 'FAILED' else None,
|
|
'tags': [{'id': t.id, 'name': t.name, 'color': t.color} for t in recording.tags],
|
|
'duplicate_info': recording.get_duplicate_info()
|
|
}
|
|
|
|
# Include large text fields based on params
|
|
if format_type != 'minimal':
|
|
if 'transcription' in include_fields:
|
|
# Format transcription using user's default template
|
|
response['transcription'] = format_transcription_with_template(
|
|
recording.transcription, current_user
|
|
) if recording.transcription else None
|
|
if 'summary' in include_fields:
|
|
response['summary'] = recording.summary
|
|
if 'notes' in include_fields:
|
|
response['notes'] = recording.notes
|
|
|
|
return jsonify(response)
|
|
|
|
|
|
# =============================================================================
|
|
# Recording Transcript/Summary/Notes Individual Endpoints
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/transcript', methods=['GET'])
|
|
@login_required
|
|
def get_transcript(recording_id):
|
|
"""
|
|
Get transcript in various formats.
|
|
|
|
Query params:
|
|
format: json (default), text, srt, vtt
|
|
"""
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
if not recording.transcription:
|
|
return jsonify({'error': 'No transcription available'}), 404
|
|
|
|
format_type = request.args.get('format', 'json').lower()
|
|
|
|
if format_type == 'json':
|
|
try:
|
|
segments = json.loads(recording.transcription)
|
|
return jsonify({
|
|
'format': 'json',
|
|
'segments': segments
|
|
})
|
|
except json.JSONDecodeError:
|
|
return jsonify({
|
|
'format': 'json',
|
|
'segments': [],
|
|
'raw': recording.transcription
|
|
})
|
|
|
|
elif format_type == 'text':
|
|
# Use user's default template for text format
|
|
formatted = format_transcription_with_template(recording.transcription, current_user)
|
|
return jsonify({
|
|
'format': 'text',
|
|
'content': formatted
|
|
})
|
|
|
|
elif format_type in ['srt', 'vtt']:
|
|
try:
|
|
segments = json.loads(recording.transcription)
|
|
lines = []
|
|
|
|
if format_type == 'vtt':
|
|
lines.append('WEBVTT')
|
|
lines.append('')
|
|
|
|
for i, seg in enumerate(segments, 1):
|
|
start = seg.get('start_time', seg.get('start', 0))
|
|
end = seg.get('end_time', seg.get('end', start + 1))
|
|
text = seg.get('sentence') or seg.get('text', '')
|
|
speaker = seg.get('speaker', '')
|
|
|
|
# Format timestamps
|
|
def fmt_time(seconds, use_comma=False):
|
|
h = int(seconds // 3600)
|
|
m = int((seconds % 3600) // 60)
|
|
s = int(seconds % 60)
|
|
ms = int((seconds - int(seconds)) * 1000)
|
|
sep = ',' if use_comma else '.'
|
|
return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}"
|
|
|
|
if format_type == 'srt':
|
|
lines.append(str(i))
|
|
lines.append(f"{fmt_time(start, True)} --> {fmt_time(end, True)}")
|
|
else:
|
|
lines.append(f"{fmt_time(start)} --> {fmt_time(end)}")
|
|
|
|
if speaker:
|
|
lines.append(f"<v {speaker}>{text}")
|
|
else:
|
|
lines.append(text)
|
|
lines.append('')
|
|
|
|
return jsonify({
|
|
'format': format_type,
|
|
'content': '\n'.join(lines)
|
|
})
|
|
except (json.JSONDecodeError, TypeError):
|
|
return jsonify({'error': 'Cannot generate subtitle format from transcript'}), 400
|
|
|
|
return jsonify({'error': f'Unknown format: {format_type}'}), 400
|
|
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/summary', methods=['GET'])
|
|
@login_required
|
|
def get_summary(recording_id):
|
|
"""Get summary markdown."""
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
return jsonify({
|
|
'summary': recording.summary,
|
|
'has_summary': bool(recording.summary)
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/notes', methods=['GET'])
|
|
@login_required
|
|
def get_notes(recording_id):
|
|
"""Get notes markdown."""
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
return jsonify({
|
|
'notes': recording.notes,
|
|
'has_notes': bool(recording.notes)
|
|
})
|
|
|
|
|
|
# =============================================================================
|
|
# Recording Update Operations
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>', methods=['PATCH'])
|
|
@login_required
|
|
def update_recording(recording_id):
|
|
"""
|
|
Update recording metadata, notes, or summary.
|
|
|
|
Request body (all fields optional):
|
|
{
|
|
"title": "Updated Title",
|
|
"participants": "Alice, Bob",
|
|
"notes": "Updated notes...",
|
|
"summary": "Updated summary...",
|
|
"meeting_date": "2024-01-15T09:00:00Z",
|
|
"is_inbox": false,
|
|
"is_highlighted": true
|
|
}
|
|
"""
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user, require_edit=True):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'No data provided'}), 400
|
|
|
|
# Update fields if provided
|
|
if 'title' in data:
|
|
recording.title = data['title']
|
|
if 'participants' in data:
|
|
recording.participants = data['participants']
|
|
if 'notes' in data:
|
|
recording.notes = data['notes']
|
|
if 'summary' in data:
|
|
recording.summary = data['summary']
|
|
if 'meeting_date' in data:
|
|
try:
|
|
if data['meeting_date']:
|
|
recording.meeting_date = datetime.fromisoformat(data['meeting_date'].replace('Z', '+00:00'))
|
|
else:
|
|
recording.meeting_date = None
|
|
except ValueError:
|
|
return jsonify({'error': 'Invalid meeting_date format'}), 400
|
|
if 'is_inbox' in data:
|
|
recording.is_inbox = bool(data['is_inbox'])
|
|
if 'is_highlighted' in data:
|
|
recording.is_highlighted = bool(data['is_highlighted'])
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'recording': {
|
|
'id': recording.id,
|
|
'title': recording.title,
|
|
'participants': recording.participants,
|
|
'notes': recording.notes,
|
|
'summary': recording.summary,
|
|
'meeting_date': recording.meeting_date.isoformat() if recording.meeting_date else None,
|
|
'is_inbox': recording.is_inbox,
|
|
'is_highlighted': recording.is_highlighted
|
|
}
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/notes', methods=['PUT'])
|
|
@login_required
|
|
def replace_notes(recording_id):
|
|
"""Replace notes entirely."""
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user, require_edit=True):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
data = request.get_json()
|
|
if not data or 'notes' not in data:
|
|
return jsonify({'error': 'notes field required'}), 400
|
|
|
|
recording.notes = data['notes']
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'notes': recording.notes})
|
|
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/summary', methods=['PUT'])
|
|
@login_required
|
|
def replace_summary(recording_id):
|
|
"""Replace summary entirely."""
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user, require_edit=True):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
data = request.get_json()
|
|
if not data or 'summary' not in data:
|
|
return jsonify({'error': 'summary field required'}), 400
|
|
|
|
recording.summary = data['summary']
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'summary': recording.summary})
|
|
|
|
|
|
# =============================================================================
|
|
# Recording Delete
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>', methods=['DELETE'])
|
|
@login_required
|
|
def delete_recording(recording_id):
|
|
"""Delete a recording."""
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
# Check ownership (only owner can delete)
|
|
if recording.user_id != current_user.id:
|
|
return jsonify({'error': 'Permission denied - only owner can delete'}), 403
|
|
|
|
# Check if deletion is allowed
|
|
USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true'
|
|
if not USERS_CAN_DELETE and not current_user.is_admin:
|
|
return jsonify({'error': 'Deletion not allowed'}), 403
|
|
|
|
# Delete associated files
|
|
if recording.audio_path:
|
|
try:
|
|
audio_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', 'uploads'), recording.audio_path)
|
|
if os.path.exists(audio_path):
|
|
os.remove(audio_path)
|
|
except Exception:
|
|
pass # Continue with DB deletion even if file deletion fails
|
|
|
|
# Delete from database
|
|
db.session.delete(recording)
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Recording deleted'})
|
|
|
|
|
|
# =============================================================================
|
|
# Recording Status
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/status', methods=['GET'])
|
|
@login_required
|
|
def get_recording_status(recording_id):
|
|
"""Get processing status of a recording."""
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
# Get queue position if pending/processing
|
|
queue_position = None
|
|
if recording.status in ['PENDING', 'PROCESSING', 'SUMMARIZING']:
|
|
# Count jobs ahead of this one
|
|
job = ProcessingJob.query.filter_by(
|
|
recording_id=recording_id,
|
|
status='queued'
|
|
).first()
|
|
|
|
if job:
|
|
queue_position = ProcessingJob.query.filter(
|
|
ProcessingJob.status == 'queued',
|
|
ProcessingJob.created_at < job.created_at
|
|
).count() + 1
|
|
|
|
return jsonify({
|
|
'id': recording.id,
|
|
'status': recording.status,
|
|
'queue_position': queue_position,
|
|
'error_message': recording.error_message if recording.status == 'FAILED' else None,
|
|
'completed_at': recording.completed_at.isoformat() if recording.completed_at else None
|
|
})
|
|
|
|
|
|
# =============================================================================
|
|
# Tag Management
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/tags', methods=['GET'])
|
|
@login_required
|
|
def list_tags():
|
|
"""List available tags (personal + group tags user has access to)."""
|
|
from src.models.organization import GroupMembership
|
|
|
|
# Get user's personal tags
|
|
user_tags = Tag.query.filter_by(user_id=current_user.id, group_id=None).order_by(Tag.name).all()
|
|
|
|
# Get user's team memberships
|
|
memberships = GroupMembership.query.filter_by(user_id=current_user.id).all()
|
|
team_roles = {m.group_id: m.role for m in memberships}
|
|
team_ids = list(team_roles.keys())
|
|
|
|
# Get group tags
|
|
team_tags = []
|
|
if team_ids:
|
|
team_tags = Tag.query.filter(Tag.group_id.in_(team_ids)).order_by(Tag.name).all()
|
|
|
|
result = []
|
|
|
|
# Personal tags
|
|
for tag in user_tags:
|
|
result.append({
|
|
'id': tag.id,
|
|
'name': tag.name,
|
|
'color': tag.color,
|
|
'is_group_tag': False,
|
|
'group_id': None,
|
|
'custom_prompt': tag.custom_prompt,
|
|
'default_language': tag.default_language,
|
|
'default_min_speakers': tag.default_min_speakers,
|
|
'default_max_speakers': tag.default_max_speakers,
|
|
'protect_from_deletion': tag.protect_from_deletion,
|
|
'can_edit': True
|
|
})
|
|
|
|
# Group tags
|
|
for tag in team_tags:
|
|
user_role = team_roles.get(tag.group_id, 'member')
|
|
result.append({
|
|
'id': tag.id,
|
|
'name': tag.name,
|
|
'color': tag.color,
|
|
'is_group_tag': True,
|
|
'group_id': tag.group_id,
|
|
'custom_prompt': tag.custom_prompt,
|
|
'default_language': tag.default_language,
|
|
'default_min_speakers': tag.default_min_speakers,
|
|
'default_max_speakers': tag.default_max_speakers,
|
|
'protect_from_deletion': tag.protect_from_deletion,
|
|
'can_edit': (user_role == 'admin')
|
|
})
|
|
|
|
return jsonify({'tags': result})
|
|
|
|
|
|
@api_v1_bp.route('/tags', methods=['POST'])
|
|
@login_required
|
|
def create_tag():
|
|
"""Create a new tag."""
|
|
from src.models.organization import GroupMembership
|
|
|
|
data = request.get_json()
|
|
if not data or not data.get('name'):
|
|
return jsonify({'error': 'Tag name is required'}), 400
|
|
|
|
group_id = data.get('group_id')
|
|
|
|
# If group tag, verify admin permission
|
|
if group_id:
|
|
membership = GroupMembership.query.filter_by(
|
|
group_id=group_id,
|
|
user_id=current_user.id
|
|
).first()
|
|
if not membership or membership.role != 'admin':
|
|
return jsonify({'error': 'Only group admins can create group tags'}), 403
|
|
|
|
# Check for duplicate
|
|
existing = Tag.query.filter_by(name=data['name'], group_id=group_id).first()
|
|
if existing:
|
|
return jsonify({'error': 'Tag with this name already exists for this group'}), 400
|
|
else:
|
|
# Check for duplicate personal tag
|
|
existing = Tag.query.filter_by(name=data['name'], user_id=current_user.id, group_id=None).first()
|
|
if existing:
|
|
return jsonify({'error': 'Tag with this name already exists'}), 400
|
|
|
|
tag = Tag(
|
|
name=data['name'],
|
|
user_id=current_user.id,
|
|
group_id=group_id,
|
|
color=data.get('color', '#3B82F6'),
|
|
custom_prompt=data.get('custom_prompt'),
|
|
default_language=data.get('default_language'),
|
|
default_min_speakers=data.get('default_min_speakers'),
|
|
default_max_speakers=data.get('default_max_speakers'),
|
|
protect_from_deletion=data.get('protect_from_deletion', False)
|
|
)
|
|
|
|
db.session.add(tag)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'id': tag.id,
|
|
'name': tag.name,
|
|
'color': tag.color,
|
|
'is_group_tag': tag.group_id is not None,
|
|
'group_id': tag.group_id,
|
|
'custom_prompt': tag.custom_prompt,
|
|
'default_language': tag.default_language,
|
|
'default_min_speakers': tag.default_min_speakers,
|
|
'default_max_speakers': tag.default_max_speakers,
|
|
'protect_from_deletion': tag.protect_from_deletion
|
|
}), 201
|
|
|
|
|
|
@api_v1_bp.route('/tags/<int:tag_id>', methods=['PUT'])
|
|
@login_required
|
|
def update_tag(tag_id):
|
|
"""Update a tag."""
|
|
from src.models.organization import GroupMembership
|
|
|
|
tag = db.session.get(Tag, tag_id)
|
|
if not tag:
|
|
return jsonify({'error': 'Tag not found'}), 404
|
|
|
|
# Check permission
|
|
if tag.group_id:
|
|
membership = GroupMembership.query.filter_by(
|
|
group_id=tag.group_id,
|
|
user_id=current_user.id
|
|
).first()
|
|
if not membership or membership.role != 'admin':
|
|
return jsonify({'error': 'Only group admins can edit group tags'}), 403
|
|
else:
|
|
if tag.user_id != current_user.id:
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'No data provided'}), 400
|
|
|
|
if 'name' in data:
|
|
tag.name = data['name']
|
|
if 'color' in data:
|
|
tag.color = data['color']
|
|
if 'custom_prompt' in data:
|
|
tag.custom_prompt = data['custom_prompt']
|
|
if 'default_language' in data:
|
|
tag.default_language = data['default_language']
|
|
if 'default_min_speakers' in data:
|
|
tag.default_min_speakers = data['default_min_speakers']
|
|
if 'default_max_speakers' in data:
|
|
tag.default_max_speakers = data['default_max_speakers']
|
|
if 'protect_from_deletion' in data:
|
|
tag.protect_from_deletion = data['protect_from_deletion']
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'tag': {
|
|
'id': tag.id,
|
|
'name': tag.name,
|
|
'color': tag.color,
|
|
'custom_prompt': tag.custom_prompt,
|
|
'default_language': tag.default_language,
|
|
'default_min_speakers': tag.default_min_speakers,
|
|
'default_max_speakers': tag.default_max_speakers,
|
|
'protect_from_deletion': tag.protect_from_deletion
|
|
}})
|
|
|
|
|
|
@api_v1_bp.route('/tags/<int:tag_id>', methods=['DELETE'])
|
|
@login_required
|
|
def delete_tag(tag_id):
|
|
"""Delete a tag."""
|
|
from src.models.organization import GroupMembership
|
|
|
|
tag = db.session.get(Tag, tag_id)
|
|
if not tag:
|
|
return jsonify({'error': 'Tag not found'}), 404
|
|
|
|
# Check permission
|
|
if tag.group_id:
|
|
membership = GroupMembership.query.filter_by(
|
|
group_id=tag.group_id,
|
|
user_id=current_user.id
|
|
).first()
|
|
if not membership or membership.role != 'admin':
|
|
return jsonify({'error': 'Only group admins can delete group tags'}), 403
|
|
else:
|
|
if tag.user_id != current_user.id:
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
# Remove all recording associations
|
|
RecordingTag.query.filter_by(tag_id=tag_id).delete()
|
|
|
|
db.session.delete(tag)
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Tag deleted'})
|
|
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/tags', methods=['POST'])
|
|
@login_required
|
|
def add_tags_to_recording(recording_id):
|
|
"""Add tag(s) to a recording."""
|
|
from src.models.organization import GroupMembership
|
|
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
data = request.get_json()
|
|
tag_ids = data.get('tag_ids', [])
|
|
if not tag_ids:
|
|
# Support single tag_id for backward compatibility
|
|
tag_id = data.get('tag_id')
|
|
if tag_id:
|
|
tag_ids = [tag_id]
|
|
else:
|
|
return jsonify({'error': 'tag_ids or tag_id required'}), 400
|
|
|
|
added_tags = []
|
|
errors = []
|
|
|
|
for tag_id in tag_ids:
|
|
tag = db.session.get(Tag, tag_id)
|
|
if not tag:
|
|
errors.append(f'Tag {tag_id} not found')
|
|
continue
|
|
|
|
# Check permission for this tag
|
|
if tag.group_id:
|
|
membership = GroupMembership.query.filter_by(
|
|
group_id=tag.group_id,
|
|
user_id=current_user.id
|
|
).first()
|
|
if not membership:
|
|
errors.append(f'No access to tag {tag_id}')
|
|
continue
|
|
else:
|
|
if tag.user_id != current_user.id:
|
|
errors.append(f'No access to tag {tag_id}')
|
|
continue
|
|
|
|
# Check if already exists
|
|
existing = RecordingTag.query.filter_by(
|
|
recording_id=recording_id,
|
|
tag_id=tag_id
|
|
).first()
|
|
if existing:
|
|
continue # Skip, already added
|
|
|
|
# Get next order position
|
|
max_order = db.session.query(func.max(RecordingTag.order)).filter_by(
|
|
recording_id=recording_id
|
|
).scalar() or 0
|
|
|
|
recording_tag = RecordingTag(
|
|
recording_id=recording_id,
|
|
tag_id=tag_id,
|
|
order=max_order + 1
|
|
)
|
|
db.session.add(recording_tag)
|
|
added_tags.append({'id': tag.id, 'name': tag.name})
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'added_tags': added_tags,
|
|
'errors': errors if errors else None
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/tags/<int:tag_id>', methods=['DELETE'])
|
|
@login_required
|
|
def remove_tag_from_recording(recording_id, tag_id):
|
|
"""Remove a tag from a recording."""
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user, require_edit=True):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
recording_tag = RecordingTag.query.filter_by(
|
|
recording_id=recording_id,
|
|
tag_id=tag_id
|
|
).first()
|
|
|
|
if not recording_tag:
|
|
return jsonify({'error': 'Tag not on this recording'}), 404
|
|
|
|
db.session.delete(recording_tag)
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Tag removed'})
|
|
|
|
|
|
# =============================================================================
|
|
# Speaker Management
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/speakers', methods=['GET'])
|
|
@login_required
|
|
def list_speakers():
|
|
"""List all speakers for the current user."""
|
|
speakers = Speaker.query.filter_by(user_id=current_user.id)\
|
|
.order_by(Speaker.use_count.desc(), Speaker.last_used.desc())\
|
|
.all()
|
|
|
|
return jsonify({
|
|
'speakers': [{
|
|
'id': s.id,
|
|
'name': s.name,
|
|
'use_count': s.use_count,
|
|
'last_used': s.last_used.isoformat() if s.last_used else None,
|
|
'confidence_score': s.confidence_score,
|
|
'has_voice_profile': s.average_embedding is not None
|
|
} for s in speakers]
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/speakers', methods=['POST'])
|
|
@login_required
|
|
def create_speaker():
|
|
"""Create a new speaker."""
|
|
data = request.get_json()
|
|
if not data or not data.get('name'):
|
|
return jsonify({'error': 'Speaker name is required'}), 400
|
|
|
|
name = data['name'].strip()
|
|
|
|
# Check if already exists
|
|
existing = Speaker.query.filter_by(user_id=current_user.id, name=name).first()
|
|
if existing:
|
|
return jsonify({'error': 'Speaker with this name already exists'}), 400
|
|
|
|
speaker = Speaker(
|
|
name=name,
|
|
user_id=current_user.id,
|
|
use_count=0,
|
|
created_at=datetime.utcnow()
|
|
)
|
|
db.session.add(speaker)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'id': speaker.id,
|
|
'name': speaker.name,
|
|
'use_count': speaker.use_count,
|
|
'created_at': speaker.created_at.isoformat()
|
|
}), 201
|
|
|
|
|
|
@api_v1_bp.route('/speakers/<int:speaker_id>', methods=['PUT'])
|
|
@login_required
|
|
def update_speaker(speaker_id):
|
|
"""Update a speaker (cascades name changes to recordings)."""
|
|
speaker = db.session.get(Speaker, speaker_id)
|
|
if not speaker:
|
|
return jsonify({'error': 'Speaker not found'}), 404
|
|
|
|
if speaker.user_id != current_user.id:
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'No data provided'}), 400
|
|
|
|
old_name = speaker.name
|
|
new_name = data.get('name', '').strip()
|
|
|
|
if not new_name:
|
|
return jsonify({'error': 'Speaker name is required'}), 400
|
|
|
|
if new_name != old_name:
|
|
# Update speaker name
|
|
speaker.name = new_name
|
|
|
|
# Update all recordings that have this speaker in their transcription
|
|
from src.services.speaker import update_speaker_in_recordings
|
|
try:
|
|
update_speaker_in_recordings(current_user.id, old_name, new_name)
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error updating speaker in recordings: {e}")
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'speaker': {
|
|
'id': speaker.id,
|
|
'name': speaker.name,
|
|
'use_count': speaker.use_count
|
|
}
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/speakers/<int:speaker_id>', methods=['DELETE'])
|
|
@login_required
|
|
def delete_speaker(speaker_id):
|
|
"""Delete a speaker."""
|
|
speaker = db.session.get(Speaker, speaker_id)
|
|
if not speaker:
|
|
return jsonify({'error': 'Speaker not found'}), 404
|
|
|
|
if speaker.user_id != current_user.id:
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
db.session.delete(speaker)
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Speaker deleted'})
|
|
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/speakers', methods=['GET'])
|
|
@login_required
|
|
def get_recording_speakers(recording_id):
|
|
"""Get speakers in a recording with suggestions."""
|
|
from src.services.speaker_embedding_matcher import find_matching_speakers
|
|
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
# Parse transcription to get speakers
|
|
speakers_in_recording = []
|
|
speaker_counts = {}
|
|
|
|
if recording.transcription:
|
|
try:
|
|
segments = json.loads(recording.transcription)
|
|
for seg in segments:
|
|
speaker = seg.get('speaker', 'Unknown')
|
|
speaker_counts[speaker] = speaker_counts.get(speaker, 0) + 1
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
# Build speaker list with identification info
|
|
for label, count in speaker_counts.items():
|
|
# Check if this speaker label has been identified
|
|
identified_name = None
|
|
speaker_id = None
|
|
|
|
# Look for speaker in user's speakers by checking recordings
|
|
# This is a simplified check - actual implementation would check speaker_embeddings
|
|
speakers_in_recording.append({
|
|
'label': label,
|
|
'identified_name': identified_name,
|
|
'speaker_id': speaker_id,
|
|
'segment_count': count
|
|
})
|
|
|
|
# Get voice-based suggestions
|
|
suggestions = {}
|
|
if recording.speaker_embeddings:
|
|
try:
|
|
matches = find_matching_speakers(current_user.id, recording.speaker_embeddings)
|
|
for label, speaker_matches in matches.items():
|
|
suggestions[label] = [{
|
|
'speaker_id': m['speaker_id'],
|
|
'name': m['name'],
|
|
'similarity': round(m['similarity'] * 100, 1)
|
|
} for m in speaker_matches[:3]]
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error getting speaker suggestions: {e}")
|
|
|
|
return jsonify({
|
|
'speakers': speakers_in_recording,
|
|
'suggestions': suggestions
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/speakers/assign', methods=['PUT'])
|
|
@login_required
|
|
def assign_speakers(recording_id):
|
|
"""
|
|
Assign speaker names to a recording's transcription segments.
|
|
|
|
Accepts the same speaker_map format as the web UI, plus convenience
|
|
formats for API callers (plain strings).
|
|
|
|
Request body:
|
|
{
|
|
"speaker_map": {
|
|
"SPEAKER_00": "Jane Doe", // string shorthand
|
|
"SPEAKER_01": {"name": "Bob", "isMe": false} // full object
|
|
},
|
|
"regenerate_summary": false
|
|
}
|
|
"""
|
|
from src.services.speaker import update_speaker_usage
|
|
from src.services.speaker_embedding_matcher import update_speaker_embedding
|
|
from src.services.speaker_snippets import create_speaker_snippets
|
|
from src.services.job_queue import job_queue
|
|
|
|
try:
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user, require_edit=True):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
data = request.get_json()
|
|
if not data or 'speaker_map' not in data:
|
|
return jsonify({'error': 'speaker_map is required'}), 400
|
|
|
|
raw_speaker_map = data['speaker_map']
|
|
regenerate_summary = data.get('regenerate_summary', False)
|
|
|
|
if not isinstance(raw_speaker_map, dict):
|
|
return jsonify({'error': 'speaker_map must be an object'}), 400
|
|
|
|
# Normalize values to {name, isMe} format (same as web UI expects)
|
|
speaker_map = {}
|
|
for label, value in raw_speaker_map.items():
|
|
if isinstance(value, str):
|
|
speaker_map[label] = {'name': value.strip(), 'isMe': False}
|
|
elif isinstance(value, dict):
|
|
speaker_map[label] = {
|
|
'name': value.get('name', '').strip() if value.get('name') else '',
|
|
'isMe': value.get('isMe', False)
|
|
}
|
|
else:
|
|
return jsonify({'error': f'Invalid value type for speaker "{label}"'}), 400
|
|
|
|
# --- Apply names to transcription (same logic as update_speakers in recordings.py) ---
|
|
transcription_text = recording.transcription or ''
|
|
is_json = False
|
|
try:
|
|
transcription_data = json.loads(transcription_text)
|
|
is_json = isinstance(transcription_data, list)
|
|
except (json.JSONDecodeError, TypeError):
|
|
is_json = False
|
|
|
|
speaker_names_used = []
|
|
|
|
if is_json:
|
|
for segment in transcription_data:
|
|
original_speaker_label = segment.get('speaker')
|
|
if original_speaker_label in speaker_map:
|
|
new_name_info = speaker_map[original_speaker_label]
|
|
new_name = new_name_info.get('name', '').strip()
|
|
if new_name_info.get('isMe') and not new_name:
|
|
new_name = current_user.name or 'Me'
|
|
if new_name:
|
|
segment['speaker'] = new_name
|
|
if new_name not in speaker_names_used:
|
|
speaker_names_used.append(new_name)
|
|
|
|
recording.transcription = json.dumps(transcription_data)
|
|
|
|
# Update participants - exclude unresolved SPEAKER_XX labels
|
|
final_speakers = set()
|
|
for seg in transcription_data:
|
|
speaker = seg.get('speaker')
|
|
if speaker and str(speaker).strip():
|
|
if not re.match(r'^SPEAKER_\d+$', str(speaker), re.IGNORECASE):
|
|
final_speakers.add(speaker)
|
|
recording.participants = ', '.join(sorted(list(final_speakers)))
|
|
else:
|
|
# Plain text transcript
|
|
new_participants = []
|
|
for speaker_label, new_name_info in speaker_map.items():
|
|
new_name = new_name_info.get('name', '').strip()
|
|
if new_name_info.get('isMe') and not new_name:
|
|
new_name = current_user.name or 'Me'
|
|
if new_name:
|
|
transcription_text = re.sub(
|
|
r'\[\s*' + re.escape(speaker_label) + r'\s*\]',
|
|
f'[{new_name}]',
|
|
transcription_text,
|
|
flags=re.IGNORECASE
|
|
)
|
|
if new_name not in new_participants:
|
|
new_participants.append(new_name)
|
|
|
|
recording.transcription = transcription_text
|
|
recording.participants = ', '.join(new_participants)
|
|
speaker_names_used = new_participants
|
|
|
|
# Update speaker usage statistics
|
|
if speaker_names_used:
|
|
update_speaker_usage(speaker_names_used)
|
|
|
|
# Update speaker voice embeddings if available
|
|
embeddings_updated = 0
|
|
snippets_created = 0
|
|
if recording.speaker_embeddings and speaker_map:
|
|
try:
|
|
embeddings_data = json.loads(recording.speaker_embeddings) if isinstance(recording.speaker_embeddings, str) else recording.speaker_embeddings
|
|
|
|
speaker_label_to_name = {}
|
|
for speaker_label, speaker_info in speaker_map.items():
|
|
name = speaker_info.get('name', '').strip()
|
|
if speaker_info.get('isMe') and not name:
|
|
name = current_user.name or 'Me'
|
|
if name and not re.match(r'^SPEAKER_\d+$', name, re.IGNORECASE):
|
|
speaker_label_to_name[speaker_label] = name
|
|
|
|
for speaker_label, embedding in embeddings_data.items():
|
|
if speaker_label in speaker_label_to_name and embedding and len(embedding) == 256:
|
|
speaker_name = speaker_label_to_name[speaker_label]
|
|
speaker_obj = Speaker.query.filter_by(
|
|
user_id=current_user.id,
|
|
name=speaker_name
|
|
).first()
|
|
if speaker_obj:
|
|
update_speaker_embedding(speaker_obj, embedding, recording.id)
|
|
embeddings_updated += 1
|
|
|
|
if speaker_label_to_name:
|
|
snippets_created = create_speaker_snippets(recording.id, speaker_map)
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error updating speaker embeddings: {e}", exc_info=True)
|
|
|
|
db.session.commit()
|
|
|
|
summary_queued = False
|
|
if regenerate_summary:
|
|
job_queue.enqueue(
|
|
user_id=current_user.id,
|
|
recording_id=recording.id,
|
|
job_type='summarize',
|
|
params={'user_id': current_user.id}
|
|
)
|
|
summary_queued = True
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Speakers updated successfully.',
|
|
'recording': {
|
|
'id': recording.id,
|
|
'title': recording.title,
|
|
'participants': recording.participants,
|
|
'status': recording.status
|
|
},
|
|
'summary_queued': summary_queued,
|
|
'embeddings_updated': embeddings_updated,
|
|
'snippets_created': snippets_created
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
current_app.logger.error(f"Error assigning speakers for recording {recording_id}: {e}", exc_info=True)
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/speakers/identify', methods=['POST'])
|
|
@login_required
|
|
def identify_speakers(recording_id):
|
|
"""
|
|
Trigger LLM-based auto-identification of speakers from transcript context.
|
|
Returns suggestions only - does not modify the recording.
|
|
|
|
Uses the shared identification service with JSON schema support,
|
|
name sanitization, and fallback logic.
|
|
"""
|
|
from src.services.speaker_identification import identify_speakers_from_transcript
|
|
|
|
try:
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
if not recording.transcription:
|
|
return jsonify({'error': 'No transcription available for speaker identification'}), 400
|
|
|
|
try:
|
|
transcription_data = json.loads(recording.transcription)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return jsonify({'error': 'Transcription format not supported for auto-identification'}), 400
|
|
|
|
if not isinstance(transcription_data, list):
|
|
return jsonify({'error': 'Transcription format not supported for auto-identification'}), 400
|
|
|
|
speaker_map = identify_speakers_from_transcript(transcription_data, current_user.id)
|
|
|
|
if not speaker_map:
|
|
return jsonify({'error': 'No speakers found in transcription'}), 400
|
|
|
|
return jsonify({'success': True, 'speaker_map': speaker_map})
|
|
|
|
except ValueError as ve:
|
|
return jsonify({'error': str(ve)}), 503
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error during auto speaker identification for recording {recording_id}: {e}", exc_info=True)
|
|
return jsonify({'error': f'An unexpected error occurred: {str(e)}'}), 500
|
|
|
|
|
|
# =============================================================================
|
|
# Processing Operations
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/transcribe', methods=['POST'])
|
|
@login_required
|
|
def start_transcription(recording_id):
|
|
"""Queue transcription for a recording."""
|
|
from src.services.job_queue import job_queue
|
|
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user, require_edit=True):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
# Check if audio is available
|
|
if recording.audio_deleted_at:
|
|
return jsonify({'error': 'Audio has been deleted'}), 400
|
|
|
|
data = request.get_json() or {}
|
|
|
|
params = {
|
|
'language': data.get('language'),
|
|
'min_speakers': data.get('min_speakers'),
|
|
'max_speakers': data.get('max_speakers')
|
|
}
|
|
|
|
# Queue the job
|
|
job_id = job_queue.enqueue(
|
|
user_id=current_user.id,
|
|
recording_id=recording_id,
|
|
job_type='reprocess_transcription',
|
|
params={k: v for k, v in params.items() if v is not None}
|
|
)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'job_id': job_id,
|
|
'status': 'QUEUED',
|
|
'message': 'Transcription queued'
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/summarize', methods=['POST'])
|
|
@login_required
|
|
def start_summarization(recording_id):
|
|
"""Queue summarization for a recording with optional custom prompt."""
|
|
from src.services.job_queue import job_queue
|
|
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user, require_edit=True):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
# Check if transcription exists
|
|
if not recording.transcription:
|
|
return jsonify({'error': 'No transcription available - transcribe first'}), 400
|
|
|
|
data = request.get_json() or {}
|
|
|
|
params = {
|
|
'custom_prompt': data.get('custom_prompt'),
|
|
'user_id': current_user.id
|
|
}
|
|
|
|
# Queue the job
|
|
job_id = job_queue.enqueue(
|
|
user_id=current_user.id,
|
|
recording_id=recording_id,
|
|
job_type='reprocess_summary',
|
|
params={k: v for k, v in params.items() if v is not None}
|
|
)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'job_id': job_id,
|
|
'status': 'QUEUED',
|
|
'message': 'Summarization queued'
|
|
})
|
|
|
|
|
|
# =============================================================================
|
|
# Chat with Recording
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/chat', methods=['POST'])
|
|
@login_required
|
|
def chat_with_recording(recording_id):
|
|
"""Chat about a recording's content."""
|
|
from src.services.llm import chat_client, call_chat_completion
|
|
from src.tasks.processing import format_transcription_for_llm
|
|
from src.models import SystemSetting
|
|
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
if not recording.transcription:
|
|
return jsonify({'error': 'No transcription available'}), 400
|
|
|
|
data = request.get_json()
|
|
if not data or not data.get('message'):
|
|
return jsonify({'error': 'message is required'}), 400
|
|
|
|
user_message = data['message']
|
|
conversation_history = data.get('conversation_history', [])
|
|
|
|
# Check if chat client is available
|
|
if chat_client is None:
|
|
return jsonify({'error': 'Chat service not available'}), 503
|
|
|
|
# Format transcription
|
|
formatted_transcription = format_transcription_for_llm(recording.transcription)
|
|
|
|
# Get transcript limit
|
|
transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000)
|
|
if transcript_limit != -1:
|
|
formatted_transcription = formatted_transcription[:transcript_limit]
|
|
|
|
# Build system prompt
|
|
system_prompt = f"""Tu es un assistant expert en analyse de transcriptions audio. Réponds de façon directe et professionnelle en français, en te basant sur la transcription ci-dessous.
|
|
|
|
**Enregistrement :** {recording.title}
|
|
**Participants :** {recording.participants or 'Non précisé'}
|
|
|
|
**Transcription :**
|
|
{formatted_transcription}
|
|
|
|
**Notes :** {recording.notes or 'Aucune note.'}
|
|
"""
|
|
|
|
# Build messages
|
|
messages = [{"role": "system", "content": system_prompt}]
|
|
messages.extend(conversation_history)
|
|
messages.append({"role": "user", "content": user_message})
|
|
|
|
try:
|
|
response = call_chat_completion(messages, user_id=current_user.id)
|
|
|
|
return jsonify({
|
|
'response': response,
|
|
'sources': [] # Could be enhanced to extract relevant segments
|
|
})
|
|
except Exception as e:
|
|
current_app.logger.error(f"Chat error: {e}")
|
|
return jsonify({'error': 'Chat failed'}), 500
|
|
|
|
|
|
# =============================================================================
|
|
# Calendar Events
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/events', methods=['GET'])
|
|
@login_required
|
|
def get_recording_events(recording_id):
|
|
"""Get calendar events extracted from a recording."""
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
events = Event.query.filter_by(recording_id=recording_id).all()
|
|
|
|
return jsonify({
|
|
'events': [{
|
|
'id': e.id,
|
|
'title': e.title,
|
|
'start_datetime': e.start_datetime.isoformat() if e.start_datetime else None,
|
|
'end_datetime': e.end_datetime.isoformat() if e.end_datetime else None,
|
|
'description': e.description,
|
|
'location': e.location
|
|
} for e in events]
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/events/ics', methods=['GET'])
|
|
@login_required
|
|
def download_events_ics(recording_id):
|
|
"""Download all events as ICS file."""
|
|
from src.api.events import generate_ics_content
|
|
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
events = Event.query.filter_by(recording_id=recording_id).all()
|
|
if not events:
|
|
return jsonify({'error': 'No events found'}), 404
|
|
|
|
# Generate combined ICS
|
|
ics_lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Speakr//Events//EN']
|
|
|
|
for event in events:
|
|
ics_lines.append('BEGIN:VEVENT')
|
|
ics_lines.append(f'UID:{event.id}@speakr')
|
|
ics_lines.append(f'SUMMARY:{event.title}')
|
|
if event.start_datetime:
|
|
ics_lines.append(f'DTSTART:{event.start_datetime.strftime("%Y%m%dT%H%M%S")}')
|
|
if event.end_datetime:
|
|
ics_lines.append(f'DTEND:{event.end_datetime.strftime("%Y%m%dT%H%M%S")}')
|
|
if event.description:
|
|
ics_lines.append(f'DESCRIPTION:{event.description}')
|
|
if event.location:
|
|
ics_lines.append(f'LOCATION:{event.location}')
|
|
ics_lines.append('END:VEVENT')
|
|
|
|
ics_lines.append('END:VCALENDAR')
|
|
|
|
from flask import Response
|
|
return Response(
|
|
'\r\n'.join(ics_lines),
|
|
mimetype='text/calendar',
|
|
headers={'Content-Disposition': f'attachment; filename=events-{recording_id}.ics'}
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Audio Download
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/recordings/<int:recording_id>/audio', methods=['GET'])
|
|
@login_required
|
|
def download_audio(recording_id):
|
|
"""Download or stream audio file."""
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
return jsonify({'error': 'Recording not found'}), 404
|
|
|
|
if not has_recording_access(recording, current_user):
|
|
return jsonify({'error': 'Permission denied'}), 403
|
|
|
|
if recording.audio_deleted_at:
|
|
return jsonify({'error': 'Audio has been deleted'}), 404
|
|
|
|
if not recording.audio_path:
|
|
return jsonify({'error': 'No audio file'}), 404
|
|
|
|
audio_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', 'uploads'), recording.audio_path)
|
|
if not os.path.exists(audio_path):
|
|
return jsonify({'error': 'Audio file not found'}), 404
|
|
|
|
download = request.args.get('download', 'false').lower() == 'true'
|
|
|
|
return send_file(
|
|
audio_path,
|
|
mimetype=recording.mime_type or 'audio/mpeg',
|
|
as_attachment=download,
|
|
download_name=recording.original_filename or f'recording-{recording_id}.mp3'
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Batch Operations
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/recordings/batch', methods=['PATCH'])
|
|
@login_required
|
|
def batch_update_recordings():
|
|
"""Batch update multiple recordings."""
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'No data provided'}), 400
|
|
|
|
recording_ids = data.get('recording_ids', [])
|
|
updates = data.get('updates', {})
|
|
|
|
if not recording_ids:
|
|
return jsonify({'error': 'recording_ids required'}), 400
|
|
|
|
results = []
|
|
for recording_id in recording_ids:
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
results.append({'id': recording_id, 'success': False, 'error': 'Not found'})
|
|
continue
|
|
|
|
if not has_recording_access(recording, current_user, require_edit=True):
|
|
results.append({'id': recording_id, 'success': False, 'error': 'Permission denied'})
|
|
continue
|
|
|
|
try:
|
|
if 'is_inbox' in updates:
|
|
recording.is_inbox = bool(updates['is_inbox'])
|
|
if 'is_highlighted' in updates:
|
|
recording.is_highlighted = bool(updates['is_highlighted'])
|
|
|
|
# Handle tag additions
|
|
if 'add_tag_ids' in updates:
|
|
for tag_id in updates['add_tag_ids']:
|
|
existing = RecordingTag.query.filter_by(
|
|
recording_id=recording_id,
|
|
tag_id=tag_id
|
|
).first()
|
|
if not existing:
|
|
max_order = db.session.query(func.max(RecordingTag.order)).filter_by(
|
|
recording_id=recording_id
|
|
).scalar() or 0
|
|
recording_tag = RecordingTag(
|
|
recording_id=recording_id,
|
|
tag_id=tag_id,
|
|
order=max_order + 1
|
|
)
|
|
db.session.add(recording_tag)
|
|
|
|
# Handle tag removals
|
|
if 'remove_tag_ids' in updates:
|
|
for tag_id in updates['remove_tag_ids']:
|
|
RecordingTag.query.filter_by(
|
|
recording_id=recording_id,
|
|
tag_id=tag_id
|
|
).delete()
|
|
|
|
results.append({'id': recording_id, 'success': True})
|
|
except Exception as e:
|
|
results.append({'id': recording_id, 'success': False, 'error': str(e)})
|
|
|
|
db.session.commit()
|
|
|
|
success_count = sum(1 for r in results if r['success'])
|
|
return jsonify({
|
|
'success': True,
|
|
'updated': success_count,
|
|
'failed': len(results) - success_count,
|
|
'results': results
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/recordings/batch', methods=['DELETE'])
|
|
@login_required
|
|
def batch_delete_recordings():
|
|
"""Batch delete multiple recordings."""
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'No data provided'}), 400
|
|
|
|
recording_ids = data.get('recording_ids', [])
|
|
if not recording_ids:
|
|
return jsonify({'error': 'recording_ids required'}), 400
|
|
|
|
USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true'
|
|
if not USERS_CAN_DELETE and not current_user.is_admin:
|
|
return jsonify({'error': 'Deletion not allowed'}), 403
|
|
|
|
results = []
|
|
for recording_id in recording_ids:
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
results.append({'id': recording_id, 'success': False, 'error': 'Not found'})
|
|
continue
|
|
|
|
if recording.user_id != current_user.id and not current_user.is_admin:
|
|
results.append({'id': recording_id, 'success': False, 'error': 'Permission denied'})
|
|
continue
|
|
|
|
try:
|
|
# Delete audio file
|
|
if recording.audio_path:
|
|
audio_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', 'uploads'), recording.audio_path)
|
|
if os.path.exists(audio_path):
|
|
os.remove(audio_path)
|
|
|
|
db.session.delete(recording)
|
|
results.append({'id': recording_id, 'success': True})
|
|
except Exception as e:
|
|
results.append({'id': recording_id, 'success': False, 'error': str(e)})
|
|
|
|
db.session.commit()
|
|
|
|
success_count = sum(1 for r in results if r['success'])
|
|
return jsonify({
|
|
'success': True,
|
|
'deleted': success_count,
|
|
'failed': len(results) - success_count,
|
|
'results': results
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/recordings/batch/transcribe', methods=['POST'])
|
|
@login_required
|
|
def batch_transcribe_recordings():
|
|
"""Batch queue transcriptions for multiple recordings."""
|
|
from src.services.job_queue import job_queue
|
|
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'No data provided'}), 400
|
|
|
|
recording_ids = data.get('recording_ids', [])
|
|
if not recording_ids:
|
|
return jsonify({'error': 'recording_ids required'}), 400
|
|
|
|
results = []
|
|
for recording_id in recording_ids:
|
|
recording = db.session.get(Recording, recording_id)
|
|
if not recording:
|
|
results.append({'id': recording_id, 'success': False, 'error': 'Not found'})
|
|
continue
|
|
|
|
if not has_recording_access(recording, current_user, require_edit=True):
|
|
results.append({'id': recording_id, 'success': False, 'error': 'Permission denied'})
|
|
continue
|
|
|
|
if recording.audio_deleted_at:
|
|
results.append({'id': recording_id, 'success': False, 'error': 'Audio deleted'})
|
|
continue
|
|
|
|
try:
|
|
job_id = job_queue.enqueue(
|
|
user_id=current_user.id,
|
|
recording_id=recording_id,
|
|
job_type='reprocess_transcription',
|
|
params={}
|
|
)
|
|
results.append({'id': recording_id, 'success': True, 'job_id': job_id})
|
|
except Exception as e:
|
|
results.append({'id': recording_id, 'success': False, 'error': str(e)})
|
|
|
|
success_count = sum(1 for r in results if r['success'])
|
|
return jsonify({
|
|
'success': True,
|
|
'queued': success_count,
|
|
'failed': len(results) - success_count,
|
|
'results': results
|
|
})
|
|
|
|
|
|
# =============================================================================
|
|
# Settings
|
|
# =============================================================================
|
|
|
|
@api_v1_bp.route('/settings/auto-summarization', methods=['PUT'])
|
|
@login_required
|
|
def update_auto_summarization():
|
|
"""Toggle auto-summarization for the current user."""
|
|
data = request.get_json()
|
|
|
|
if data is None:
|
|
return jsonify({'error': 'Invalid JSON'}), 400
|
|
|
|
if 'enabled' not in data:
|
|
return jsonify({'error': 'enabled field is required'}), 400
|
|
|
|
current_user.auto_summarization = bool(data['enabled'])
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'auto_summarization': current_user.auto_summarization
|
|
})
|
|
|
|
|
|
@api_v1_bp.route('/recordings/upload', methods=['POST'])
|
|
@login_required
|
|
def upload_recording():
|
|
"""
|
|
Upload a recording and queue transcription (API).
|
|
|
|
Multipart form-data fields:
|
|
- file (required)
|
|
- notes (optional)
|
|
- file_last_modified (optional, ms epoch)
|
|
- language (optional)
|
|
- min_speakers (optional)
|
|
- max_speakers (optional)
|
|
- tag_ids[0], tag_ids[1], ... (optional)
|
|
- tag_id (optional, legacy)
|
|
"""
|
|
return _upload_file_ui()
|