nex_basse/backend/app/api/v1/endpoints/meetings.py

915 lines
37 KiB
Python
Raw Normal View History

2026-03-02 10:26:22 +00:00
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks, File, UploadFile, Form, WebSocket, WebSocketDisconnect, status
from fastapi.responses import FileResponse
2026-02-25 08:48:31 +00:00
from sqlalchemy.orm import Session
from sqlalchemy import or_, and_, func, desc
from app.core.db import get_db, SessionLocal
from app.core.deps import get_current_user
2026-03-02 10:26:22 +00:00
from app.core.security import decode_token
from app.models import Meeting, MeetingAttendee, User, TranscriptSegment, SummarizeTask, TranscriptTask, MeetingAudio, AIModel, Hotword
from app.schemas.meeting import MeetingOut, MeetingDetailOut, MeetingUpdate, MeetingListOut, MeetingCreate, AttendeeOut, MeetingAudioOut
2026-02-25 08:48:31 +00:00
from app.services.meeting_service import MeetingService
from typing import List, Optional
2026-03-02 10:26:22 +00:00
import shutil
from pathlib import Path
import uuid
from datetime import datetime
import asyncio
import websockets
import json
import logging
from redis import Redis
from app.core.redis import get_redis
from app.core.config import get_settings
from app.models import PromptTemplate
logger = logging.getLogger(__name__)
2026-02-25 08:48:31 +00:00
router = APIRouter(prefix="/meetings", tags=["meetings"])
2026-03-02 10:26:22 +00:00
async def run_transcript_worker(task_id: str):
db = SessionLocal()
try:
await MeetingService.process_transcript_task(db, task_id)
finally:
db.close()
@router.post("/upload_temp")
async def upload_temp_file(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
):
# Dynamically resolve storage path relative to project root (backend)
base_dir = Path(__file__).resolve().parents[4]
storage_dir = base_dir / "storage" / "uploads" / "temp"
storage_dir.mkdir(parents=True, exist_ok=True)
file_ext = Path(file.filename).suffix
unique_filename = f"{uuid.uuid4()}{file_ext}"
file_path = storage_dir / unique_filename
with file_path.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return {"file_path": str(file_path), "file_name": file.filename}
@router.post("", response_model=MeetingOut)
async def create_meeting(
meeting_in: MeetingCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
meeting = Meeting(
title=meeting_in.title,
tags=meeting_in.tags,
meeting_time=meeting_in.meeting_time,
status="uploaded" if meeting_in.file_path else meeting_in.status,
type=meeting_in.type,
user_id=current_user.user_id,
asr_model_id=meeting_in.asr_model_id,
summary_model_id=meeting_in.summary_model_id,
summary_prompt_id=meeting_in.prompt_id,
)
db.add(meeting)
db.flush()
if meeting_in.participants:
# Deduplicate participants to avoid UniqueViolation
unique_participants = list(set(meeting_in.participants))
for user_id in unique_participants:
attendee = MeetingAttendee(meeting_id=meeting.meeting_id, user_id=user_id)
db.add(attendee)
if meeting_in.file_path:
p = Path(meeting_in.file_path)
if p.exists():
audio = MeetingAudio(
meeting_id=meeting.meeting_id,
file_path=str(p),
file_name=p.name,
file_size=p.stat().st_size,
processing_status="uploaded"
)
db.add(audio)
db.flush() # Ensure audio ID is generated and accessible
final_model_id = meeting.asr_model_id
if not final_model_id:
default_model = db.query(AIModel).filter(AIModel.model_type == 'asr').first()
if default_model:
final_model_id = default_model.model_id
meeting.asr_model_id = final_model_id
try:
print(f"[DEBUG] Creating transcript task for meeting {meeting.meeting_id} with model {final_model_id}")
task = await MeetingService.create_transcript_task(
db,
meeting_id=meeting.meeting_id,
model_id=final_model_id
)
print(f"[DEBUG] Task created: {task.task_id}, scheduling background worker")
background_tasks.add_task(run_transcript_worker, task.task_id)
except Exception as e:
print(f"[ERROR] Failed to start transcription task: {e}")
db.commit()
db.refresh(meeting)
return meeting
@router.post("/{meeting_id}/upload_audio")
async def upload_meeting_audio(
meeting_id: int,
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
# Save File
base_dir = Path(__file__).resolve().parents[4]
storage_dir = base_dir / "storage" / "uploads" / str(meeting_id)
storage_dir.mkdir(parents=True, exist_ok=True)
file_ext = Path(file.filename).suffix
unique_filename = f"{uuid.uuid4()}{file_ext}"
file_path = storage_dir / unique_filename
with file_path.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Create MeetingAudio record
audio = MeetingAudio(
meeting_id=meeting.meeting_id,
file_path=str(file_path),
file_name=file.filename,
file_size=file_path.stat().st_size,
processing_status="uploaded"
)
db.add(audio)
db.commit()
db.refresh(audio)
# Trigger Transcription Task
# For live meetings, the audio upload is just a backup/archive.
# Transcription is handled in real-time via WebSocket, so we don't trigger it here
# to avoid overwriting the status or duplicating work.
if meeting.type != 'live':
final_model_id = meeting.asr_model_id
if not final_model_id:
default_model = db.query(AIModel).filter(AIModel.model_type == 'asr').first()
if default_model:
final_model_id = default_model.model_id
meeting.asr_model_id = final_model_id
db.commit()
try:
print(f"[DEBUG] Triggering transcript task for uploaded audio: meeting {meeting_id}")
task = await MeetingService.create_transcript_task(
db,
meeting_id=meeting.meeting_id,
model_id=final_model_id
)
background_tasks.add_task(run_transcript_worker, task.task_id)
except Exception as e:
print(f"[ERROR] Failed to start transcription task for upload: {e}")
else:
print(f"[DEBUG] Skipping transcript task for live meeting audio upload: meeting {meeting_id}")
return {"status": "success", "file_path": str(file_path)}
@router.post("/upload", response_model=MeetingOut)
async def create_meeting_with_upload(
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
title: str = Form(...),
participants: Optional[List[int]] = Form(None),
prompt_id: Optional[int] = Form(None),
asr_model_id: Optional[int] = Form(None),
summary_model_id: Optional[int] = Form(None),
tags: Optional[List[str]] = Form(None),
meeting_time: Optional[str] = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Process meeting time
mt = datetime.utcnow()
if meeting_time:
try:
# Attempt to parse timestamp (ms)
mt = datetime.fromtimestamp(int(meeting_time) / 1000.0)
except:
pass
# Create Meeting
meeting = Meeting(
title=title,
tags=",".join(tags) if tags else None, # Store as comma separated string if that's the convention, or modify model
meeting_time=mt,
status="uploaded", # Initial status for uploaded meeting
user_id=current_user.user_id,
asr_model_id=asr_model_id,
summary_model_id=summary_model_id,
)
db.add(meeting)
db.flush() # Get meeting_id
# Handle Participants
if participants:
for user_id in participants:
attendee = MeetingAttendee(meeting_id=meeting.meeting_id, user_id=user_id)
db.add(attendee)
# Save File
base_dir = Path(__file__).resolve().parents[4]
storage_dir = base_dir / "storage" / "uploads"
storage_dir.mkdir(parents=True, exist_ok=True)
file_ext = Path(file.filename).suffix
unique_filename = f"{uuid.uuid4()}{file_ext}"
file_path = storage_dir / unique_filename
with file_path.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Create MeetingAudio record
audio = MeetingAudio(
meeting_id=meeting.meeting_id,
file_path=str(file_path),
file_name=file.filename,
file_size=file_path.stat().st_size,
processing_status="uploaded"
)
db.add(audio)
db.commit()
db.refresh(meeting)
# Trigger Transcription Task
# If no model provided, try to find default
final_model_id = asr_model_id
if not final_model_id:
default_model = db.query(AIModel).filter(AIModel.model_type == 'asr').first()
if default_model:
final_model_id = default_model.model_id
# Update meeting with default model
meeting.asr_model_id = final_model_id
db.commit()
# Only create task if we have a model (or let service handle it if we want to support auto-detect later)
# But for now, let's just pass what we have.
# Service create_transcript_task expects model_id.
# If we pass None, it will be stored as None in TranscriptTask.
try:
task = await MeetingService.create_transcript_task(
db,
meeting_id=meeting.meeting_id,
model_id=final_model_id
)
background_tasks.add_task(run_transcript_worker, task.task_id)
except Exception as e:
# Log error but don't fail the upload response
print(f"Failed to start transcription task: {e}")
return meeting
2026-02-25 08:48:31 +00:00
async def run_summarize_worker(task_id: str):
db = SessionLocal()
try:
await MeetingService.process_summarize_task(db, task_id)
finally:
db.close()
2026-03-02 10:26:22 +00:00
@router.post("/{meeting_id}/transcript")
async def create_transcript_task(
meeting_id: int,
background_tasks: BackgroundTasks,
model_id: int = Query(..., description="ASR Model ID"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Manually trigger a transcript task for a meeting.
Useful for retrying failed tasks or re-transcribing with a different model.
"""
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
try:
task = await MeetingService.create_transcript_task(
db,
meeting_id=meeting_id,
model_id=model_id
)
background_tasks.add_task(run_transcript_worker, task.task_id)
return task
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
2026-02-25 08:48:31 +00:00
@router.post("/{meeting_id}/summarize")
async def start_summarize(
meeting_id: int,
payload: dict,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
try:
task = await MeetingService.create_summarize_task(
db,
meeting_id,
payload.get("prompt_id"),
payload.get("model_id"),
payload.get("extra_prompt", "")
)
background_tasks.add_task(run_summarize_worker, task.task_id)
return {"task_id": task.task_id, "status": task.status}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/tasks", response_model=dict)
def list_all_tasks(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
task_type: Optional[str] = Query(None), # summarize, transcript
status: Optional[str] = Query(None),
):
"""
统一任务监控接口合并转译和总结任务
"""
tasks = []
# 1. 获取总结任务
sum_query = db.query(SummarizeTask, Meeting.title, User.username).join(
Meeting, SummarizeTask.meeting_id == Meeting.meeting_id
).join(
User, Meeting.user_id == User.user_id
)
if status:
sum_query = sum_query.filter(SummarizeTask.status == status)
for t, m_title, username in sum_query.order_by(desc(SummarizeTask.created_at)).limit(50).all():
tasks.append({
"task_id": t.task_id,
"type": "总结",
"meeting_title": m_title,
"creator": username,
"status": t.status,
"progress": t.progress,
"created_at": t.created_at,
"meeting_id": t.meeting_id
})
# 2. 获取转译任务
trans_query = db.query(TranscriptTask, Meeting.title, User.username).join(
Meeting, TranscriptTask.meeting_id == Meeting.meeting_id
).join(
User, Meeting.user_id == User.user_id
)
if status:
trans_query = trans_query.filter(TranscriptTask.status == status)
for t, m_title, username in trans_query.order_by(desc(TranscriptTask.created_at)).limit(50).all():
tasks.append({
"task_id": t.task_id,
"type": "转译",
"meeting_title": m_title,
"creator": username,
"status": t.status,
"progress": t.progress,
"created_at": t.created_at,
"meeting_id": t.meeting_id
})
# 按时间全局排序
tasks.sort(key=lambda x: x["created_at"], reverse=True)
return {"items": tasks}
2026-03-02 10:26:22 +00:00
@router.websocket("/{meeting_id}/ws")
async def websocket_meeting(
websocket: WebSocket,
meeting_id: int,
token: str = Query(...),
db: Session = Depends(get_db)
):
"""
Real-time meeting WebSocket endpoint.
Proxies audio to local ASR service and saves transcripts to DB.
"""
# 1. Authenticate
try:
payload = decode_token(token)
user_id = payload.get("sub")
if not user_id:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# Check meeting access
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
if not meeting:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# Check if user is creator or attendee
is_attendee = any(a.user_id == user.user_id for a in meeting.attendees)
if meeting.user_id != user.user_id and not is_attendee:
# Check admin
role_codes = [ur.role.role_code for ur in user.roles]
if "admin" not in role_codes and "superuser" not in role_codes:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
except Exception as e:
logger.error(f"WebSocket auth failed: {e}")
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await websocket.accept()
if meeting and meeting.type == "live" and meeting.status != "transcribing":
meeting.status = "transcribing"
db.commit()
# 2. Connect to ASR Service
asr_ws_url = "ws://127.0.0.1:3050"
# Determine ASR Model URL based on meeting configuration
model_id = meeting.asr_model_id
if not model_id:
# Fallback to default model
default_model = db.query(AIModel).filter(AIModel.model_type == 'asr', AIModel.is_default == 1).first()
if not default_model:
default_model = db.query(AIModel).filter(AIModel.model_type == 'asr').first()
if default_model:
model_id = default_model.model_id
if model_id:
model = db.query(AIModel).filter(AIModel.model_id == model_id).first()
if model and model.base_url:
base = model.base_url.rstrip('/')
if base.startswith("https://"):
asr_ws_url = base.replace("https://", "wss://")
elif base.startswith("http://"):
asr_ws_url = base.replace("http://", "ws://")
else:
# If no protocol specified, assume ws://
if "://" not in base:
asr_ws_url = f"ws://{base}"
else:
asr_ws_url = base
logger.info(f"Connecting to ASR at {asr_ws_url} for meeting {meeting_id}")
try:
async with websockets.connect(asr_ws_url) as asr_ws:
# Send initial configuration to ASR if needed
# Model-Kf expects configuration first usually, but defaults might work.
# Let's send a default config based on schema
hotword_filters = [Hotword.scope.in_(["public", "global"])]
if meeting.user_id:
hotword_filters.append(
(Hotword.scope == "personal") & (Hotword.user_id == meeting.user_id)
)
hotwords = db.query(Hotword).filter(
(hotword_filters[0]) if len(hotword_filters) == 1 else (hotword_filters[0] | hotword_filters[1])
).all()
hotword_list = []
hotword_string_parts = []
for hw in hotwords:
token = hw.word
if token:
hotword_list.append(token)
hotword_string_parts.append(token)
hotword_string = " ".join([p for p in hotword_string_parts if p])
init_msg = {
"mode": "2pass",
"wav_name": "microphone",
"is_speaking": True,
"chunk_size": [5, 10, 5],
"chunk_interval": 10,
"itn": False,
"hotwords": hotword_list,
"hotword": hotword_string
}
await asr_ws.send(json.dumps(init_msg))
# Create tasks for bidirectional forwarding
async def forward_client_to_asr():
try:
while True:
data = await websocket.receive()
if "bytes" in data:
await asr_ws.send(data["bytes"])
elif "text" in data:
# Forward control messages (JSON)
await asr_ws.send(data["text"])
except WebSocketDisconnect:
logger.info("Client disconnected")
except Exception as e:
logger.error(f"Client->ASR error: {e}")
async def forward_asr_to_client():
try:
last_saved_end_ms = 0
def parse_number(v):
if isinstance(v, (int, float)):
return float(v)
if isinstance(v, str):
try:
return float(v.strip())
except Exception:
return None
return None
def parse_bool(v):
if isinstance(v, bool):
return v
if isinstance(v, str):
return v.strip().lower() in ("true", "1", "yes", "y", "t")
if isinstance(v, (int, float)):
return int(v) == 1
return False
def extract_text(d: dict) -> str:
t = d.get("text")
if isinstance(t, str):
return t
result = d.get("result")
if isinstance(result, dict):
rt = result.get("text")
if isinstance(rt, str):
return rt
return ""
async for message in asr_ws:
# Parse message
try:
msg_data = json.loads(message)
# Forward to client
await websocket.send_text(message)
raw_is_final = msg_data.get("is_final")
raw_final = msg_data.get("final")
raw_status = msg_data.get("status")
is_final = parse_bool(raw_is_final) or parse_bool(raw_final)
if not is_final:
if isinstance(raw_status, str):
is_final = raw_status.strip().lower() in ("final", "finished", "done", "completed", "complete")
elif isinstance(raw_status, (int, float)):
is_final = int(raw_status) in (1, 2)
text = extract_text(msg_data).strip()
if is_final is True and text:
speaker_tag = msg_data.get("speaker", "Unknown")
start_ms = None
end_ms = None
timestamp = msg_data.get("timestamp")
if isinstance(timestamp, list) and len(timestamp) > 0:
try:
starts = []
ends = []
for pair in timestamp:
if not isinstance(pair, (list, tuple)) or len(pair) < 2:
continue
s = parse_number(pair[0])
e = parse_number(pair[1])
if s is not None and e is not None:
starts.append(s)
ends.append(e)
if starts and ends:
raw_start = min(starts)
raw_end = max(ends)
if raw_end < raw_start:
raise ValueError("timestamp end < start")
# Heuristic: if ASR returns seconds, convert to ms
if raw_end < 1000:
raw_start *= 1000.0
raw_end *= 1000.0
start_ms = int(raw_start)
end_ms = int(raw_end)
except Exception:
start_ms = None
end_ms = None
if start_ms is None or end_ms is None:
bt = msg_data.get("begin_time")
et = msg_data.get("end_time")
bt_num = parse_number(bt)
et_num = parse_number(et)
if bt_num is not None and et_num is not None:
if et_num < 1000 and bt_num < 1000:
bt_num *= 1000.0
et_num *= 1000.0
start_ms = int(bt_num)
end_ms = int(et_num)
if start_ms is None or end_ms is None or end_ms < start_ms:
start_ms = last_saved_end_ms + 1
end_ms = start_ms + 1000
last_saved_end_ms = max(last_saved_end_ms, end_ms)
with SessionLocal() as local_db:
existing = local_db.query(TranscriptSegment).filter(
TranscriptSegment.meeting_id == meeting_id,
TranscriptSegment.speaker_tag == speaker_tag,
TranscriptSegment.start_time_ms == start_ms,
TranscriptSegment.end_time_ms == end_ms,
).order_by(TranscriptSegment.segment_id.desc()).first()
if existing:
existing.text_content = text
local_db.commit()
else:
segment = TranscriptSegment(
meeting_id=meeting_id,
speaker_id=0,
speaker_tag=speaker_tag,
start_time_ms=start_ms,
end_time_ms=end_ms,
text_content=text,
)
local_db.add(segment)
local_db.commit()
except json.JSONDecodeError:
pass
except Exception as e:
logger.error(f"ASR->Client error: {e}")
# Run both tasks
done, pending = await asyncio.wait(
[asyncio.create_task(forward_client_to_asr()),
asyncio.create_task(forward_asr_to_client())],
return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
# 3. Post-Meeting Processing (Auto-Summary)
logger.info(f"WebSocket session ended for meeting {meeting_id}. Checking for auto-summary...")
# Use a new session for post-processing to ensure clean state
with SessionLocal() as local_db:
# Update meeting status to 'transcribed'
m = local_db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
if m and m.status == 'transcribing':
m.status = 'transcribed'
local_db.commit()
logger.info(f"Meeting {meeting_id} status updated to 'transcribed'")
# Check Auto-Summary Setting
settings = get_settings()
if getattr(settings, "AUTO_SUMMARIZE", True): # Default to True if not set
try:
# Find default LLM Model
llm_model = None
if m.summary_model_id:
llm_model = local_db.query(AIModel).filter(
AIModel.model_id == m.summary_model_id,
AIModel.status == 1
).first()
if not llm_model:
llm_model = local_db.query(AIModel).filter(
AIModel.model_type == 'llm',
AIModel.is_default == 1,
AIModel.status == 1
).first()
# Find Prompt (Use meeting-specific or default)
prompt_tmpl = None
if m.summary_prompt_id:
prompt_tmpl = local_db.query(PromptTemplate).filter(
PromptTemplate.id == m.summary_prompt_id
).first()
if not prompt_tmpl:
prompt_tmpl = local_db.query(PromptTemplate).filter(
PromptTemplate.status == 1
).order_by(PromptTemplate.is_system.desc(), PromptTemplate.id.asc()).first()
if llm_model and prompt_tmpl:
logger.info(f"Auto-triggering summary for meeting {meeting_id}")
# Create summarize task
# Note: create_summarize_task is async and commits to DB
# We need to await it
new_sum_task = await MeetingService.create_summarize_task(
local_db,
meeting_id=meeting_id,
prompt_id=prompt_tmpl.id,
model_id=llm_model.model_id
)
# Launch worker in background
asyncio.create_task(MeetingService.run_summarize_worker(new_sum_task.task_id))
# Update meeting status to 'summarizing'
m.status = 'summarizing'
local_db.commit()
logger.info(f"Meeting {meeting_id} status updated to 'summarizing'")
else:
logger.warning(f"Skipping auto-summary: No default LLM or Prompt found for meeting {meeting_id}")
except Exception as sum_e:
logger.error(f"Failed to auto-trigger summary for meeting {meeting_id}: {sum_e}")
else:
logger.info(f"Auto-summary disabled for meeting {meeting_id}")
except Exception as e:
logger.error(f"Failed to connect to ASR service: {e}")
await websocket.close(code=status.WS_1011_INTERNAL_ERROR)
2026-02-25 08:48:31 +00:00
@router.get("/tasks/{task_id}")
2026-03-02 10:26:22 +00:00
async def get_task_progress(
2026-02-25 08:48:31 +00:00
task_id: str,
db: Session = Depends(get_db),
2026-03-02 10:26:22 +00:00
redis: Redis = Depends(get_redis),
2026-02-25 08:48:31 +00:00
current_user: User = Depends(get_current_user),
):
task = MeetingService.get_task_status(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
2026-03-02 10:26:22 +00:00
# Fetch real-time status message from Redis
message = None
try:
msg_bytes = await redis.get(f"task:status:{task_id}")
if msg_bytes:
# redis.get in async mode (aioredis) returns bytes or str depending on decode_responses
# If decode_responses=True (which is common in FastAPI setups), it's already str.
# If it's bytes, we decode. If it's str, we use it directly.
if isinstance(msg_bytes, bytes):
message = msg_bytes.decode("utf-8")
else:
message = str(msg_bytes)
except Exception as e:
# Use logging.warning instead of logger.warning if logger is not defined globally
logging.warning(f"Failed to get task status message from Redis: {e}")
2026-02-25 08:48:31 +00:00
return {
"task_id": task.task_id,
"status": task.status,
"progress": task.progress,
2026-03-02 10:26:22 +00:00
"result": getattr(task, "result", None),
"error": task.error_message,
"message": message
2026-02-25 08:48:31 +00:00
}
# ... 其余接口保持不变
@router.get("", response_model=MeetingListOut)
def list_meetings(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
scope: str = Query("all", description="all, created, joined"),
keyword: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
):
2026-03-02 10:26:22 +00:00
id_query = db.query(Meeting.meeting_id, Meeting.meeting_time)
2026-02-25 08:48:31 +00:00
if scope == "created":
2026-03-02 10:26:22 +00:00
id_query = id_query.filter(Meeting.user_id == current_user.user_id)
2026-02-25 08:48:31 +00:00
elif scope == "joined":
2026-03-02 10:26:22 +00:00
id_query = id_query.join(MeetingAttendee).filter(MeetingAttendee.user_id == current_user.user_id)
2026-02-25 08:48:31 +00:00
else:
2026-03-02 10:26:22 +00:00
id_query = id_query.outerjoin(MeetingAttendee).filter(
2026-02-25 08:48:31 +00:00
or_(Meeting.user_id == current_user.user_id, MeetingAttendee.user_id == current_user.user_id)
)
if keyword:
2026-03-02 10:26:22 +00:00
id_query = id_query.filter(Meeting.title.contains(keyword))
id_query = id_query.distinct()
total = id_query.count()
paged_ids = id_query.order_by(
Meeting.meeting_time.desc(),
Meeting.meeting_id.desc()
).offset((page - 1) * page_size).limit(page_size).subquery()
results = db.query(Meeting).join(
paged_ids, Meeting.meeting_id == paged_ids.c.meeting_id
).order_by(
paged_ids.c.meeting_time.desc(),
paged_ids.c.meeting_id.desc()
).all()
2026-02-25 08:48:31 +00:00
items = []
for m in results:
item = MeetingOut.model_validate(m)
item.creator_name = m.creator.display_name if m.creator else "Unknown"
item.creator_avatar = m.creator.avatar if m.creator else None
items.append(item)
return {"items": items, "total": total}
2026-03-02 10:26:22 +00:00
@router.get("/{meeting_id}/audio/{audio_id}")
def get_meeting_audio(
meeting_id: int,
audio_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
is_attendee = any(a.user_id == current_user.user_id for a in meeting.attendees)
if meeting.user_id != current_user.user_id and not is_attendee:
role_codes = [ur.role.role_code for ur in current_user.roles]
if "admin" not in role_codes and "superuser" not in role_codes:
raise HTTPException(status_code=403, detail="Not authorized")
audio = db.query(MeetingAudio).filter(
MeetingAudio.audio_id == audio_id,
MeetingAudio.meeting_id == meeting_id
).first()
if not audio:
raise HTTPException(status_code=404, detail="Audio not found")
file_path = Path(audio.file_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="Audio file missing")
return FileResponse(path=str(file_path), filename=audio.file_name or file_path.name)
2026-02-25 08:48:31 +00:00
@router.get("/{meeting_id}", response_model=MeetingDetailOut)
def get_meeting_detail(
meeting_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
2026-03-02 10:26:22 +00:00
2026-02-25 08:48:31 +00:00
is_attendee = any(a.user_id == current_user.user_id for a in meeting.attendees)
if meeting.user_id != current_user.user_id and not is_attendee:
2026-03-02 10:26:22 +00:00
# Check if admin
role_codes = [ur.role.role_code for ur in current_user.roles]
if "admin" not in role_codes and "superuser" not in role_codes:
raise HTTPException(status_code=403, detail="Not authorized")
2026-02-25 08:48:31 +00:00
item = MeetingDetailOut.model_validate(meeting)
item.creator_name = meeting.creator.display_name if meeting.creator else "Unknown"
item.creator_avatar = meeting.creator.avatar if meeting.creator else None
2026-03-02 10:26:22 +00:00
# Keep ASR arrival order: segment_id reflects insertion order
if item.segments:
item.segments = sorted(item.segments, key=lambda x: x.segment_id)
# Fetch latest transcript task
latest_trans = db.query(TranscriptTask).filter(
TranscriptTask.meeting_id == meeting_id
).order_by(desc(TranscriptTask.created_at)).first()
if latest_trans:
item.latest_transcript_task = latest_trans
# Fetch latest summarize task
latest_sum = db.query(SummarizeTask).filter(
SummarizeTask.meeting_id == meeting_id
).order_by(desc(SummarizeTask.created_at)).first()
if latest_sum:
item.latest_summarize_task = latest_sum
settings = get_settings()
audios = db.query(MeetingAudio).filter(
MeetingAudio.meeting_id == meeting_id
).order_by(desc(MeetingAudio.upload_time)).all()
item.audio_files = []
for audio in audios:
audio_item = MeetingAudioOut.model_validate(audio)
audio_item.audio_url = f"{settings.api_v1_prefix}/meetings/{meeting_id}/audio/{audio.audio_id}"
item.audio_files.append(audio_item)
2026-02-25 08:48:31 +00:00
return item
@router.delete("/{meeting_id}")
def delete_meeting(
meeting_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
if meeting.user_id != current_user.user_id:
role_codes = [ur.role.role_code for ur in current_user.roles]
if "admin" not in role_codes and "superuser" not in role_codes:
raise HTTPException(status_code=403, detail="Forbidden")
db.delete(meeting)
db.commit()
return {"status": "ok"}