pachet's picture
Add session MIDI ZIP export
eacc693
Raw
History Blame Contribute Delete
18.6 kB
from __future__ import annotations
from fastapi import Cookie, Depends, FastAPI, File, HTTPException, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .auth import (
AUTH_COOKIE_NAME,
AUTH_TOKEN_MAX_AGE_SECONDS,
AuthenticationError,
AuthManager,
AuthenticatedUser,
UsernameTakenError,
)
from .config import load_settings
from .continuator_runtime import continuator_runtime_info
from .schemas import (
AuthStatusResponse,
AuthUser,
ContinueRequest,
ContinueResponse,
CreateSessionRequest,
CreateSessionResponse,
DownloadSessionMidiResponse,
GeneratePhraseRequest,
GeneratePhraseResponse,
ImportMidiResponse,
LoginRequest,
LogoutResponse,
OpenSessionResponse,
PublicConfigResponse,
RegisterRequest,
ResetSessionResponse,
SaveSessionMidiResponse,
SessionHistoryResponse,
SessionMemoryResponse,
UpdateSessionNameRequest,
UpdateSessionNameResponse,
UpdateSessionPreferencesRequest,
UpdateSessionPreferencesResponse,
UpdateSessionSettingsRequest,
UpdateSessionSettingsResponse,
UserSessionsResponse,
)
from .session_manager import MidiImportError, NoContinuationAvailable, SessionManager, UnknownSessionError
from .storage import PhraseStorage
settings = load_settings()
storage = PhraseStorage(settings.db_path)
auth_manager = AuthManager(storage)
session_manager = SessionManager(
storage=storage,
seed_midi_file=settings.seed_midi_file,
seed_midi_folder=settings.seed_midi_folder,
session_export_dir=settings.session_export_dir,
)
app = FastAPI(
title=settings.app_name,
version="0.1.0",
description=(
"A lightweight web wrapper around François Pachet's Continuator, with "
"browser-side MIDI capture, session-isolated engines, and SQLite logging."
),
)
app.mount("/assets", StaticFiles(directory=settings.frontend_dir), name="assets")
def auth_user_to_schema(user: AuthenticatedUser) -> AuthUser:
return AuthUser(id=user.id, username=user.username, created_at=user.created_at)
def set_auth_cookie(response: Response, request: Request, raw_token: str) -> None:
response.set_cookie(
key=AUTH_COOKIE_NAME,
value=raw_token,
httponly=True,
samesite="lax",
secure=request.url.scheme == "https",
max_age=AUTH_TOKEN_MAX_AGE_SECONDS,
path="/",
)
def clear_auth_cookie(response: Response) -> None:
response.delete_cookie(key=AUTH_COOKIE_NAME, path="/")
def get_optional_current_user(
auth_token: str | None = Cookie(default=None, alias=AUTH_COOKIE_NAME),
) -> AuthenticatedUser | None:
return auth_manager.get_user_from_token(auth_token)
def require_current_user(
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> AuthenticatedUser:
if current_user is None:
raise HTTPException(status_code=401, detail="Sign in to access saved sessions.")
return current_user
@app.get("/", include_in_schema=False)
def read_index() -> FileResponse:
return FileResponse(settings.frontend_dir / "index.html")
@app.get("/health", tags=["system"])
def healthcheck() -> dict[str, object]:
continuator = continuator_runtime_info()
return {
"ok": True,
"app_name": settings.app_name,
"seeded": session_manager.seeded,
"continuator": continuator.model_dump(),
}
@app.get("/api/config", response_model=PublicConfigResponse, tags=["system"])
def public_config() -> PublicConfigResponse:
continuator = continuator_runtime_info()
return PublicConfigResponse(
app_name=settings.app_name,
seeded=session_manager.seeded,
continuator_version=continuator.version,
continuator_package_version=continuator.package_version,
continuator_commit=continuator.commit,
continuator_source_url=continuator.source_url,
)
@app.get("/api/auth/me", response_model=AuthStatusResponse, tags=["auth"])
def auth_me(
response: Response,
auth_token: str | None = Cookie(default=None, alias=AUTH_COOKIE_NAME),
) -> AuthStatusResponse:
user = auth_manager.get_user_from_token(auth_token)
if auth_token and user is None:
clear_auth_cookie(response)
return AuthStatusResponse(user=None if user is None else auth_user_to_schema(user))
@app.post("/api/auth/register", response_model=AuthStatusResponse, tags=["auth"])
def register(
payload: RegisterRequest,
request: Request,
response: Response,
) -> AuthStatusResponse:
try:
user, raw_token = auth_manager.register(payload.username, payload.password)
except UsernameTakenError as error:
raise HTTPException(
status_code=409,
detail=f"Username is already taken: {error.args[0]}",
) from error
set_auth_cookie(response, request, raw_token)
return AuthStatusResponse(user=auth_user_to_schema(user))
@app.post("/api/auth/login", response_model=AuthStatusResponse, tags=["auth"])
def login(
payload: LoginRequest,
request: Request,
response: Response,
) -> AuthStatusResponse:
try:
user, raw_token = auth_manager.login(payload.username, payload.password)
except AuthenticationError as error:
raise HTTPException(status_code=401, detail=str(error)) from error
set_auth_cookie(response, request, raw_token)
return AuthStatusResponse(user=auth_user_to_schema(user))
@app.post("/api/auth/logout", response_model=LogoutResponse, tags=["auth"])
def logout(
response: Response,
auth_token: str | None = Cookie(default=None, alias=AUTH_COOKIE_NAME),
) -> LogoutResponse:
auth_manager.logout(auth_token)
clear_auth_cookie(response)
return LogoutResponse()
@app.post("/api/session", response_model=CreateSessionResponse, tags=["session"])
def create_session(
payload: CreateSessionRequest,
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> CreateSessionResponse:
return session_manager.create_session(
payload,
owner_user_id=None if current_user is None else current_user.id,
)
@app.get("/api/my/sessions", response_model=UserSessionsResponse, tags=["session"])
def my_sessions(
limit: int = Query(default=24, ge=1, le=100),
current_user: AuthenticatedUser = Depends(require_current_user),
) -> UserSessionsResponse:
return session_manager.list_user_sessions(current_user.id, limit=limit)
@app.post(
"/api/my/sessions/{session_id}/open",
response_model=OpenSessionResponse,
tags=["session"],
)
def open_session(
session_id: str,
current_user: AuthenticatedUser = Depends(require_current_user),
) -> OpenSessionResponse:
try:
return session_manager.open_session(session_id, current_user.id)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
@app.patch(
"/api/my/sessions/{session_id}/name",
response_model=UpdateSessionNameResponse,
tags=["session"],
)
def update_session_name(
session_id: str,
payload: UpdateSessionNameRequest,
current_user: AuthenticatedUser = Depends(require_current_user),
) -> UpdateSessionNameResponse:
try:
return session_manager.update_session_name(session_id, current_user.id, payload)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
@app.patch(
"/api/sessions/{session_id}/preferences",
response_model=UpdateSessionPreferencesResponse,
tags=["session"],
)
def update_session_preferences(
session_id: str,
payload: UpdateSessionPreferencesRequest,
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> UpdateSessionPreferencesResponse:
try:
return session_manager.update_session_preferences(
session_id,
None if current_user is None else current_user.id,
payload,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
@app.patch(
"/api/sessions/{session_id}/settings",
response_model=UpdateSessionSettingsResponse,
tags=["session"],
)
def update_session_settings(
session_id: str,
payload: UpdateSessionSettingsRequest,
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> UpdateSessionSettingsResponse:
try:
return session_manager.update_session_settings(
session_id,
None if current_user is None else current_user.id,
payload,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
@app.post("/api/continue", response_model=ContinueResponse, tags=["continuator"])
def continue_phrase(
payload: ContinueRequest,
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> ContinueResponse:
try:
return session_manager.continue_phrase(
payload,
None if current_user is None else current_user.id,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
except NoContinuationAvailable as error:
raise HTTPException(status_code=409, detail=str(error)) from error
@app.post(
"/api/sessions/{session_id}/generate",
response_model=GeneratePhraseResponse,
tags=["continuator"],
)
def generate_phrase(
session_id: str,
payload: GeneratePhraseRequest,
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> GeneratePhraseResponse:
try:
return session_manager.generate_phrase(
session_id,
None if current_user is None else current_user.id,
note_count=payload.note_count,
enforce_start_constraint=payload.enforce_start_constraint,
enforce_end_constraint=payload.enforce_end_constraint,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
except NoContinuationAvailable as error:
raise HTTPException(status_code=409, detail=str(error)) from error
@app.get(
"/api/sessions/{session_id}/history",
response_model=SessionHistoryResponse,
tags=["session"],
)
def session_history(
session_id: str,
limit: int = Query(default=12, ge=1, le=100),
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> SessionHistoryResponse:
try:
return session_manager.get_history(
session_id,
None if current_user is None else current_user.id,
limit=limit,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
@app.post(
"/api/sessions/{session_id}/save-midi",
response_model=SaveSessionMidiResponse,
tags=["session"],
)
def save_session_midi(
session_id: str,
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> SaveSessionMidiResponse:
try:
return session_manager.save_session_midi(
session_id,
None if current_user is None else current_user.id,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@app.get(
"/api/sessions/{session_id}/midi-download",
response_model=DownloadSessionMidiResponse,
tags=["session"],
)
def download_session_midi(
session_id: str,
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> DownloadSessionMidiResponse:
try:
return session_manager.download_session_midi(
session_id,
None if current_user is None else current_user.id,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@app.get(
"/api/sessions/{session_id}/midi.zip",
tags=["session"],
)
def session_midi_zip(
session_id: str,
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> Response:
try:
file_name, content, payload = session_manager.session_midi_zip(
session_id,
None if current_user is None else current_user.id,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
return Response(
content=content,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{file_name}"',
"X-Midi-File-Count": str(payload.file_count),
"X-Midi-Input-File-Count": str(payload.input_file_count),
"X-Midi-Generated-File-Count": str(payload.generated_file_count),
},
)
@app.get(
"/api/sessions/{session_id}/memory",
response_model=SessionMemoryResponse,
tags=["session"],
)
def session_memory(
session_id: str,
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> SessionMemoryResponse:
try:
return session_manager.get_memory(
session_id,
None if current_user is None else current_user.id,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
@app.get(
"/api/sessions/{session_id}/graphs/memory.svg",
tags=["continuator"],
)
def session_memory_graph_svg(
session_id: str,
max_nodes: int = Query(default=96, ge=8, le=240),
max_edges: int = Query(default=220, ge=1, le=600),
mode: str = Query(default="slice", pattern="^(slice|all|most_used|neighborhood)$"),
order: int | None = Query(default=None, ge=1, le=16),
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> Response:
try:
svg = session_manager.render_memory_graph_svg(
session_id,
None if current_user is None else current_user.id,
max_nodes=max_nodes,
max_edges=max_edges,
graph_mode=mode,
order_filter=order,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
return Response(content=svg, media_type="image/svg+xml")
@app.get(
"/api/sessions/{session_id}/graphs/constraints.svg",
tags=["continuator"],
)
def session_constraint_graph_svg(
session_id: str,
max_steps: int = Query(default=96, ge=1, le=240),
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> Response:
try:
svg = session_manager.render_constraint_graph_svg(
session_id,
None if current_user is None else current_user.id,
max_steps=max_steps,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
return Response(content=svg, media_type="image/svg+xml")
@app.get(
"/api/sessions/{session_id}/graph.svg",
tags=["continuator"],
)
def session_graph_svg_legacy(
session_id: str,
max_nodes: int = Query(default=96, ge=8, le=240),
max_edges: int = Query(default=220, ge=1, le=600),
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> Response:
return session_memory_graph_svg(
session_id,
max_nodes=max_nodes,
max_edges=max_edges,
current_user=current_user,
)
@app.delete(
"/api/sessions/{session_id}/memory/{slot}",
response_model=SessionMemoryResponse,
tags=["session"],
)
def delete_session_memory_phrase(
session_id: str,
slot: int,
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> SessionMemoryResponse:
try:
return session_manager.delete_memory_phrase(
session_id,
None if current_user is None else current_user.id,
slot,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@app.post(
"/api/sessions/{session_id}/import-midi",
response_model=ImportMidiResponse,
tags=["session"],
)
async def import_session_midi(
session_id: str,
files: list[UploadFile] = File(...),
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> ImportMidiResponse:
uploaded_files: list[tuple[str, bytes]] = []
try:
for index, upload in enumerate(files):
file_name = upload.filename or f"imported_{index + 1}.mid"
uploaded_files.append((file_name, await upload.read()))
return session_manager.import_midi_files(
session_id,
None if current_user is None else current_user.id,
uploaded_files,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
except MidiImportError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
finally:
for upload in files:
await upload.close()
@app.post(
"/api/sessions/{session_id}/reset",
response_model=ResetSessionResponse,
tags=["session"],
)
def reset_session(
session_id: str,
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
) -> ResetSessionResponse:
try:
return session_manager.reset_session(
session_id,
None if current_user is None else current_user.id,
)
except UnknownSessionError as error:
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error