Initial commit: Nat20 Notes — TTRPG session transcription & summarization
This commit is contained in:
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
11
backend/app/routers/diagnostics.py
Normal file
11
backend/app/routers/diagnostics.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..diagnostics import run_diagnostics
|
||||
|
||||
router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_diagnostics():
|
||||
checks = run_diagnostics()
|
||||
return {"ok": all(c["ok"] for c in checks), "checks": checks}
|
||||
82
backend/app/routers/jobs.py
Normal file
82
backend/app/routers/jobs.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from .. import database as db, config
|
||||
|
||||
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
||||
|
||||
# Canonical job status vocabulary (matches the `jobs` table): queued|running|done|error.
|
||||
# The frontend must use these exact strings, not invented ones like "pending"/"completed"/"failed".
|
||||
|
||||
|
||||
@router.get("/{job_id}")
|
||||
def get_job(job_id: str):
|
||||
job = db.get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@router.post("/{job_id}/cancel")
|
||||
async def cancel_job(job_id: str):
|
||||
job = db.get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
if job["status"] in ("queued", "running"):
|
||||
# The thread pool executor has no hard-kill hook, so this marks the job
|
||||
# as cancelled; the pipeline itself does not currently check for
|
||||
# cancellation mid-run, so a running job may still finish in the background.
|
||||
db.update_job(job_id, status="error", error="Cancelled by user", error_stage="cancelled")
|
||||
return {"status": "success", "message": f"Job {job_id} cancellation signal sent."}
|
||||
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
async def delete_job(
|
||||
job_id: str,
|
||||
strategy: str = Query(..., description="One of: 'all', 'artifacts_only', 'none'"),
|
||||
):
|
||||
job = db.get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
|
||||
session_id = job["session_id"]
|
||||
session_row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
session = dict(session_row) if session_row else None
|
||||
|
||||
purged = []
|
||||
|
||||
if strategy in ("all", "artifacts_only") and session:
|
||||
for path_key in ("transcript_path",):
|
||||
path = session.get(path_key)
|
||||
if path and os.path.exists(path):
|
||||
os.remove(path)
|
||||
purged.append(path_key)
|
||||
with db.tx() as conn:
|
||||
conn.execute("DELETE FROM notes WHERE session_id = ?", (session_id,))
|
||||
conn.execute("DELETE FROM speakers WHERE session_id = ?", (session_id,))
|
||||
purged.append("notes_and_speakers")
|
||||
|
||||
if strategy == "all" and session:
|
||||
for path_key in ("video_path", "audio_path"):
|
||||
path = session.get(path_key)
|
||||
if path and os.path.exists(path):
|
||||
os.remove(path)
|
||||
purged.append(path_key)
|
||||
with db.tx() as conn:
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
||||
purged.append("session_record")
|
||||
elif strategy in ("all", "artifacts_only", "none"):
|
||||
with db.tx() as conn:
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
|
||||
purged.append("job_record")
|
||||
|
||||
# If a failed job left the session stuck in 'transcribing', roll it back
|
||||
if job["status"] == "error" and session and session["status"] == "transcribing":
|
||||
with db.tx() as conn:
|
||||
conn.execute("UPDATE sessions SET status = 'uploaded' WHERE id = ?", (session_id,))
|
||||
|
||||
if strategy not in ("all", "artifacts_only", "none"):
|
||||
raise HTTPException(400, f"Unknown strategy '{strategy}'")
|
||||
|
||||
return {"status": "success", "message": f"Job {job_id} cleared using strategy: {strategy}", "purged": purged}
|
||||
20
backend/app/routers/models.py
Normal file
20
backend/app/routers/models.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import requests
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .. import database as db
|
||||
|
||||
router = APIRouter(prefix="/api/models", tags=["models"])
|
||||
|
||||
|
||||
@router.get("/ollama")
|
||||
def list_ollama_models():
|
||||
"""List models already pulled on the configured Ollama host, so the setup
|
||||
wizard can offer a dropdown instead of asking the user to type a tag blind."""
|
||||
settings = db.get_settings()
|
||||
host = settings.get("ollama_host", "http://host.docker.internal:11434")
|
||||
try:
|
||||
resp = requests.get(f"{host.rstrip('/')}/api/tags", timeout=5)
|
||||
resp.raise_for_status()
|
||||
return {"ok": True, "models": [m["name"] for m in resp.json().get("models", [])]}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "models": []}
|
||||
48
backend/app/routers/notes.py
Normal file
48
backend/app/routers/notes.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from .. import database as db, jobs
|
||||
from ..pipeline.turns import load_turns
|
||||
from ..pipeline.summarize import summarize_session
|
||||
|
||||
router = APIRouter(prefix="/api/sessions/{session_id}/notes", tags=["notes"])
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
def generate_notes(session_id: str):
|
||||
session = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
if not session:
|
||||
raise HTTPException(404, "Session not found")
|
||||
if not session["transcript_path"]:
|
||||
raise HTTPException(400, "Session hasn't been transcribed yet")
|
||||
|
||||
settings = db.get_settings()
|
||||
speaker_map = {r["raw_label"]: r["display_name"] for r in
|
||||
db.get_conn().execute("SELECT raw_label, display_name FROM speakers WHERE session_id = ?", (session_id,))
|
||||
if r["display_name"]}
|
||||
|
||||
job_id = db.create_job(session_id, "summarize")
|
||||
|
||||
def run(progress_cb):
|
||||
turns = load_turns(Path(session["transcript_path"]), speaker_map)
|
||||
dm_notes, player_recap = summarize_session(turns, settings, progress_cb=progress_cb)
|
||||
with db.tx() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO notes (session_id, dm_notes, player_recap, generated_at) VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(session_id) DO UPDATE SET dm_notes=excluded.dm_notes, "
|
||||
"player_recap=excluded.player_recap, generated_at=excluded.generated_at",
|
||||
(session_id, dm_notes, player_recap, db.now()),
|
||||
)
|
||||
conn.execute("UPDATE sessions SET status = 'complete' WHERE id = ?", (session_id,))
|
||||
|
||||
jobs.submit(job_id, run)
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_notes(session_id: str):
|
||||
row = db.get_conn().execute("SELECT * FROM notes WHERE session_id = ?", (session_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Notes not generated yet")
|
||||
return dict(row)
|
||||
190
backend/app/routers/sessions.py
Normal file
190
backend/app/routers/sessions.py
Normal file
@@ -0,0 +1,190 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
||||
|
||||
from .. import database as db, config, jobs
|
||||
from ..errors import PipelineError
|
||||
from ..pipeline.audio import extract_audio
|
||||
from ..pipeline.transcribe import transcribe_and_diarize
|
||||
|
||||
|
||||
def _dedup_path(path: Path) -> Path:
|
||||
"""Append a counter suffix like ` (1)`, ` (2)` if the file exists."""
|
||||
if not path.exists():
|
||||
return path
|
||||
stem = path.stem
|
||||
suffix = path.suffix
|
||||
parent = path.parent
|
||||
counter = 1
|
||||
while True:
|
||||
new = parent / f"{stem} ({counter}){suffix}"
|
||||
if not new.exists():
|
||||
return new
|
||||
counter += 1
|
||||
|
||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||
|
||||
# All uploaded/derived files live under config.UPLOAD_DIR / config.AUDIO_DIR /
|
||||
# config.TRANSCRIPT_DIR (all under config.DATA_DIR, the /data volume) - never
|
||||
# a hardcoded /app/storage path, which isn't backed by any volume the rest of
|
||||
# the app agrees on.
|
||||
|
||||
|
||||
def _row_to_dict(row) -> dict:
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_sessions():
|
||||
rows = db.get_conn().execute("SELECT * FROM sessions ORDER BY created_at DESC").fetchall()
|
||||
sessions = []
|
||||
for r in rows:
|
||||
s = _row_to_dict(r)
|
||||
job = db.get_conn().execute(
|
||||
"SELECT * FROM jobs WHERE session_id = ? ORDER BY created_at DESC LIMIT 1", (s["id"],)
|
||||
).fetchone()
|
||||
s["latest_job"] = _row_to_dict(job) if job else None
|
||||
sessions.append(s)
|
||||
return sessions
|
||||
|
||||
|
||||
@router.get("/{session_id}")
|
||||
def get_session(session_id: str):
|
||||
row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Session not found")
|
||||
session = _row_to_dict(row)
|
||||
job = db.get_conn().execute(
|
||||
"SELECT * FROM jobs WHERE session_id = ? ORDER BY created_at DESC LIMIT 1", (session_id,)
|
||||
).fetchone()
|
||||
session["latest_job"] = _row_to_dict(job) if job else None
|
||||
|
||||
# Stats computed from transcript
|
||||
if session.get("transcript_path"):
|
||||
tp = Path(session["transcript_path"])
|
||||
if tp.exists():
|
||||
try:
|
||||
data = json.loads(tp.read_text())
|
||||
segments = data.get("segments", [])
|
||||
if segments:
|
||||
session["audio_duration"] = segments[-1]["end"]
|
||||
session["word_count"] = sum(len(s.get("text", "").split()) for s in segments)
|
||||
session["language"] = data.get("language")
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
return session
|
||||
|
||||
|
||||
def _run_transcription(session_id: str, video_path: Path, audio_path: Path, transcript_path: Path):
|
||||
settings = db.get_settings()
|
||||
|
||||
def run(progress_cb):
|
||||
db.get_conn() # ensure thread-local connection exists in this worker thread
|
||||
with db.tx() as conn:
|
||||
conn.execute("UPDATE sessions SET status = 'transcribing' WHERE id = ?", (session_id,))
|
||||
progress_cb("Extracting audio...")
|
||||
try:
|
||||
extract_audio(video_path, audio_path)
|
||||
transcribe_and_diarize(
|
||||
audio_path,
|
||||
transcript_path,
|
||||
model_size=settings.get("whisper_model", "medium"),
|
||||
compute_type=settings.get("whisper_compute_type", "int8"),
|
||||
hf_token=settings.get("hf_token", ""),
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
except PipelineError:
|
||||
with db.tx() as conn:
|
||||
conn.execute("UPDATE sessions SET status = 'uploaded' WHERE id = ?", (session_id,))
|
||||
raise
|
||||
with db.tx() as conn:
|
||||
conn.execute(
|
||||
"UPDATE sessions SET audio_path = ?, transcript_path = ?, status = 'transcribed' WHERE id = ?",
|
||||
(str(audio_path), str(transcript_path), session_id),
|
||||
)
|
||||
|
||||
job_id = db.create_job(session_id, "transcribe")
|
||||
jobs.submit(job_id, run)
|
||||
return job_id
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_session(name: str = Form(...), file: UploadFile = File(None), upload_path: str = Form(None)):
|
||||
session_id = db.new_id()
|
||||
|
||||
if upload_path:
|
||||
src = Path(upload_path)
|
||||
if not src.exists():
|
||||
raise HTTPException(400, f"Uploaded file not found at {upload_path}")
|
||||
safe_filename = os.path.basename(src)
|
||||
video_path = config.UPLOAD_DIR / f"{session_id}_{safe_filename}"
|
||||
shutil.move(str(src), str(video_path))
|
||||
elif file:
|
||||
safe_filename = os.path.basename(file.filename or f"{session_id}")
|
||||
video_path = config.UPLOAD_DIR / f"{session_id}_{safe_filename}"
|
||||
with open(video_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
else:
|
||||
raise HTTPException(400, "Either a file upload or upload_path is required")
|
||||
|
||||
with db.tx() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, name, original_filename, video_path, status, created_at) "
|
||||
"VALUES (?, ?, ?, ?, 'uploaded', ?)",
|
||||
(session_id, name, safe_filename, str(video_path), db.now()),
|
||||
)
|
||||
|
||||
audio_path = config.AUDIO_DIR / f"{session_id}.wav"
|
||||
transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json"
|
||||
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path)
|
||||
|
||||
return {"session_id": session_id, "job_id": job_id}
|
||||
|
||||
|
||||
@router.post("/{session_id}/transcribe")
|
||||
def retry_transcription(session_id: str):
|
||||
"""Re-run transcription for a session, e.g. after a failed job."""
|
||||
row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Session not found")
|
||||
session = _row_to_dict(row)
|
||||
video_path = Path(session["video_path"])
|
||||
audio_path = config.AUDIO_DIR / f"{session_id}.wav"
|
||||
transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json"
|
||||
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path)
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
@router.post("/upload-chunk")
|
||||
async def upload_chunk(
|
||||
file: UploadFile = File(...),
|
||||
chunk_index: int = Form(...),
|
||||
total_chunks: int = Form(...),
|
||||
filename: str = Form(...),
|
||||
upload_id: str = Form(...),
|
||||
):
|
||||
"""Assemble a large upload sent in chunks (bypasses nginx's client_max_body_size
|
||||
for files bigger than that). Returns a filepath under UPLOAD_DIR that can later
|
||||
be referenced when creating a session, for very large recordings."""
|
||||
safe_filename = os.path.basename(filename)
|
||||
temp_dir = config.UPLOAD_DIR / f"tmp_{upload_id}"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
chunk_path = temp_dir / f"chunk_{chunk_index}"
|
||||
with open(chunk_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
if len(os.listdir(temp_dir)) == total_chunks:
|
||||
final_file_path = _dedup_path(config.UPLOAD_DIR / safe_filename)
|
||||
with open(final_file_path, "wb") as final_file:
|
||||
for i in range(total_chunks):
|
||||
with open(temp_dir / f"chunk_{i}", "rb") as chunk_f:
|
||||
final_file.write(chunk_f.read())
|
||||
shutil.rmtree(temp_dir)
|
||||
return {"status": "completed", "filepath": str(final_file_path), "filename": safe_filename}
|
||||
|
||||
return {"status": "chunk_received", "chunk_index": chunk_index}
|
||||
62
backend/app/routers/settings.py
Normal file
62
backend/app/routers/settings.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .. import database as db, config
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
# Settings are stored as flat key/value rows (see database.py / config.DEFAULT_SETTINGS).
|
||||
# This router is a thin pass-through over that table - it must not invent its own
|
||||
# schema, or the Settings/Setup pages will silently stop affecting the actual
|
||||
# transcription/summarization pipeline, which reads these same flat keys.
|
||||
|
||||
# Env var overrides — set these in docker-compose.yml to prefill the setup wizard
|
||||
_ENV_OVERRIDES = {
|
||||
"hf_token": "NAT20_HF_TOKEN",
|
||||
"whisper_model": "NAT20_WHISPER_MODEL",
|
||||
"whisper_compute_type": "NAT20_WHISPER_COMPUTE_TYPE",
|
||||
"ollama_host": "NAT20_OLLAMA_HOST",
|
||||
"ollama_model": "NAT20_OLLAMA_MODEL",
|
||||
"api_base_url": "NAT20_API_BASE_URL",
|
||||
"api_key": "NAT20_API_KEY",
|
||||
"api_model": "NAT20_API_MODEL",
|
||||
"chunk_word_target": "NAT20_CHUNK_WORD_TARGET",
|
||||
"world_context": "NAT20_WORLD_CONTEXT",
|
||||
"world_context_path": "NAT20_WORLD_CONTEXT_PATH",
|
||||
}
|
||||
|
||||
_BOOL_KEYS = {"onboarding_completed"}
|
||||
|
||||
|
||||
def _coerce(key: str, value: str):
|
||||
if key in _BOOL_KEYS:
|
||||
return str(value).lower() in ("1", "true", "yes")
|
||||
return value
|
||||
|
||||
|
||||
def _merge_with_env(raw: dict) -> dict:
|
||||
"""DB values win, env vars fill gaps, DEFAULT_SETTINGS fill remaining gaps."""
|
||||
merged = {**config.DEFAULT_SETTINGS, **raw}
|
||||
for key, env_name in _ENV_OVERRIDES.items():
|
||||
if not raw.get(key, "").strip():
|
||||
val = os.environ.get(env_name)
|
||||
if val is not None:
|
||||
merged[key] = val
|
||||
return merged
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_settings():
|
||||
raw = db.get_settings()
|
||||
merged = _merge_with_env(raw)
|
||||
return {k: _coerce(k, v) for k, v in merged.items()}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def update_settings(patch: dict):
|
||||
clean = {k: str(v) for k, v in patch.items() if k in config.DEFAULT_SETTINGS}
|
||||
db.update_settings(clean)
|
||||
raw = db.get_settings()
|
||||
merged = _merge_with_env(raw)
|
||||
return {k: _coerce(k, v) for k, v in merged.items()}
|
||||
91
backend/app/routers/speakers.py
Normal file
91
backend/app/routers/speakers.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import database as db, config
|
||||
from ..pipeline.turns import distinct_speakers, load_turns
|
||||
|
||||
router = APIRouter(prefix="/api/sessions/{session_id}/speakers", tags=["speakers"])
|
||||
|
||||
|
||||
def _get_session_or_404(session_id: str):
|
||||
row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Session not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_speakers(session_id: str):
|
||||
session = _get_session_or_404(session_id)
|
||||
if not session["transcript_path"]:
|
||||
raise HTTPException(400, "Session hasn't been transcribed yet")
|
||||
|
||||
speakers = distinct_speakers(Path(session["transcript_path"]))
|
||||
|
||||
existing = {r["raw_label"]: r["display_name"] for r in
|
||||
db.get_conn().execute("SELECT raw_label, display_name FROM speakers WHERE session_id = ?", (session_id,))}
|
||||
|
||||
for sp in speakers:
|
||||
sp["display_name"] = existing.get(sp["raw_label"], "")
|
||||
return speakers
|
||||
|
||||
|
||||
class SpeakerName(BaseModel):
|
||||
raw_label: str
|
||||
display_name: str
|
||||
|
||||
|
||||
@router.put("")
|
||||
def set_speaker_name(session_id: str, body: SpeakerName):
|
||||
_get_session_or_404(session_id)
|
||||
with db.tx() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO speakers (id, session_id, raw_label, display_name) VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(session_id, raw_label) DO UPDATE SET display_name = excluded.display_name",
|
||||
(db.new_id(), session_id, body.raw_label, body.display_name),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class MergeSpeakersBody(BaseModel):
|
||||
target_label: str
|
||||
source_labels: list[str]
|
||||
|
||||
|
||||
@router.post("/merge")
|
||||
def merge_speakers(session_id: str, body: MergeSpeakersBody):
|
||||
session = _get_session_or_404(session_id)
|
||||
if not session["transcript_path"]:
|
||||
raise HTTPException(400, "Session hasn't been transcribed yet")
|
||||
if body.target_label in body.source_labels:
|
||||
raise HTTPException(400, "Target label cannot also be a source label")
|
||||
|
||||
tp = Path(session["transcript_path"])
|
||||
data = json.loads(tp.read_text())
|
||||
changed = 0
|
||||
for seg in data.get("segments", []):
|
||||
if seg.get("speaker") in body.source_labels:
|
||||
seg["speaker"] = body.target_label
|
||||
changed += 1
|
||||
tp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
with db.tx() as conn:
|
||||
for label in body.source_labels:
|
||||
conn.execute("DELETE FROM speakers WHERE session_id = ? AND raw_label = ?", (session_id, label))
|
||||
|
||||
return {"ok": True, "segments_reassigned": changed}
|
||||
|
||||
|
||||
@router.get("/turns")
|
||||
def get_turns(session_id: str):
|
||||
"""Full transcript with display names applied - powers the review/playback screen."""
|
||||
session = _get_session_or_404(session_id)
|
||||
if not session["transcript_path"]:
|
||||
raise HTTPException(400, "Session hasn't been transcribed yet")
|
||||
speaker_map = {r["raw_label"]: r["display_name"] for r in
|
||||
db.get_conn().execute("SELECT raw_label, display_name FROM speakers WHERE session_id = ?", (session_id,))
|
||||
if r["display_name"]}
|
||||
return load_turns(Path(session["transcript_path"]), speaker_map)
|
||||
Reference in New Issue
Block a user