Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: docker build -t sessioniq-api .
- run: docker build -t sessioniq-dashboard ./web
- run: docker build -t sessioniq .
- name: Start the container
run: docker run -d --name sessioniq -p 8080:8000 sessioniq
- name: Wait for the API to answer
run: |
for _ in $(seq 1 45); do
if curl -fsS -o /dev/null http://127.0.0.1:8080/api/health; then
exit 0
fi
sleep 2
done
echo "::error::the API never became healthy"
exit 1
- name: The image must serve the dashboard
run: curl -fsS http://127.0.0.1:8080/ | grep -qi "SessionIQ"
- name: The baked demo library must be loaded
run: |
assets=$(curl -fsS http://127.0.0.1:8080/api/library \
| python3 -c "import json,sys; print(len(json.load(sys.stdin)['assets']))")
echo "assets: $assets"
test "$assets" -gt 0
- name: Container logs
if: always()
run: docker logs sessioniq
29 changes: 25 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
FROM node:24-alpine AS dashboard

WORKDIR /build

ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable

COPY web/package.json web/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

COPY web ./
RUN pnpm build


FROM python:3.12-slim

ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
SESSIONIQ_HOST=0.0.0.0 \
SESSIONIQ_PORT=8000
SESSIONIQ_STATIC_DIR=/app/web/dist

WORKDIR /app

Expand All @@ -12,10 +26,17 @@ COPY src ./src
RUN pip install --no-cache-dir .

COPY scripts ./scripts
COPY --from=dashboard /build/dist ./web/dist

# Generate the demo library into the image. Audio analysis is the expensive
# part of this app and librosa is imported lazily, so doing it here means a
# cold start serves a populated dashboard in seconds instead of analyzing
# thirteen files on the first request.
RUN python scripts/seed_demo.py

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3)"
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD python -c "import os, urllib.request; port = os.environ.get('SESSIONIQ_PORT') or os.environ.get('PORT') or '8000'; urllib.request.urlopen(f'http://127.0.0.1:{port}/api/health', timeout=3)"

CMD ["python", "scripts/run_api.py"]
CMD ["python", "scripts/docker_entrypoint.py"]
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,18 +254,35 @@ scipy (K-weighted loudness), ChromaDB (optional), faster-whisper (optional), Ope
docker compose up --build
```

Then open **http://localhost:8080**. nginx serves the dashboard and proxies `/api` and `/uploads`
to the API container, so the browser sees a single origin. Runtime data lives in the
`sessioniq-data` volume and survives restarts.
Then open **http://localhost:8080**. One container serves the dashboard, the API and uploaded
files from a single origin, and it ships a pre-generated demo library — the audio analysis happens
at build time, so the app is populated the moment it starts rather than on first request.

To load the generated demo content:
To run it without Compose:

```bash
docker compose exec api python scripts/seed_demo.py
docker compose restart api
docker build -t sessioniq .
docker run --rm -p 8080:8000 sessioniq
```

To enable a model, uncomment `env_file: .env` in `docker-compose.yml` and put your keys in `.env`.
The container is self-contained: no Python, no Node, and no API key or model required. It is the
quickest way to see SessionIQ running without setting up a toolchain.

Uploads live inside the container and go away with it. Attach a volume to keep them:

```bash
docker run --rm -p 8080:8000 -v sessioniq-data:/app/.sessioniq-data sessioniq
```

The first boot on an empty volume regenerates the demo content, which takes about a minute because
it runs the analyzers for real; set `SESSIONIQ_SEED_DEMO=1` to make that automatic.

**Container settings** — `SESSIONIQ_HOST` (the image sets `0.0.0.0`), `SESSIONIQ_PORT` and `PORT`,
`SESSIONIQ_UPLOAD_ROOT`, `SESSIONIQ_STATIC_DIR`, `SESSIONIQ_CORS_ORIGINS`,
`SESSIONIQ_DISABLE_VECTOR`, `SESSIONIQ_LUFS_TARGET`. `.env.example` covers the model settings.

SessionIQ is a single-user local app: it has no authentication and expects one library per process,
so it is not meant to be exposed publicly.

### Local toolchain

Expand Down Expand Up @@ -343,7 +360,7 @@ sessioniq/
├── web/src/ # React + TypeScript dashboard
│ ├── App.tsx
│ └── components/ # Sidebar, Chat, Inspector, Insights, Studio, Pipeline, Player…
├── scripts/ # run_api, seed_demo, check_local_ai
├── scripts/ # run_api, seed_demo, check_local_ai, docker_entrypoint
└── tests/ # pytest suite
```

Expand Down
32 changes: 12 additions & 20 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,17 @@
services:
api:
sessioniq:
build: .
environment:
SESSIONIQ_HOST: 0.0.0.0
SESSIONIQ_PORT: "8000"
volumes:
- sessioniq-data:/app/.sessioniq-data
ports:
- "8000:8000"
- "8080:8000"
restart: unless-stopped
# Uncomment to pass model credentials through from a local .env file.
# env_file: .env
# The image ships a generated demo library, so the container serves a
# populated dashboard in seconds. Uncomment the volume for uploads that
# survive a restart — the first boot on an empty volume regenerates the
# demo content and takes about a minute.
# volumes:
# - sessioniq-data:/app/.sessioniq-data
# environment:
# SESSIONIQ_SEED_DEMO: "1"

web:
build: ./web
depends_on:
api:
condition: service_healthy
ports:
- "8080:80"
restart: unless-stopped

volumes:
sessioniq-data:
# volumes:
# sessioniq-data:
30 changes: 30 additions & 0 deletions scripts/docker_entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Container entrypoint: optionally seed demo content, then serve the API.

Seeding is opt-in through SESSIONIQ_SEED_DEMO=1 and is skipped whenever a
library index already exists, so a hosted instance with a persistent volume
generates its demo content once rather than on every restart.
"""

from __future__ import annotations

import os
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def main() -> int:
if os.environ.get("SESSIONIQ_SEED_DEMO") == "1":
subprocess.run(
[sys.executable, str(ROOT / "scripts" / "seed_demo.py"), "--if-empty"],
check=True,
)
# Replace this process so uvicorn receives signals directly as PID 1.
os.execv(sys.executable, [sys.executable, str(ROOT / "scripts" / "run_api.py")])
return 0


if __name__ == "__main__":
raise SystemExit(main())
3 changes: 2 additions & 1 deletion scripts/run_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@
uvicorn.run(
"sessioniq.api:app",
host=os.environ.get("SESSIONIQ_HOST", "127.0.0.1"),
port=int(os.environ.get("SESSIONIQ_PORT", "8000")),
# Most hosts inject PORT; the container sets SESSIONIQ_PORT explicitly.
port=int(os.environ.get("SESSIONIQ_PORT") or os.environ.get("PORT") or "8000"),
)
5 changes: 5 additions & 0 deletions scripts/seed_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,11 @@ def purge() -> None:


if __name__ == "__main__":
if "--if-empty" in sys.argv:
existing = UPLOAD_ROOT.parent / "library-index.json"
if existing.exists():
print(f"Library already present at {existing}; seeding skipped.")
raise SystemExit(0)
print(f"Purging {UPLOAD_ROOT.parent} ...")
purge()
print("Generating synthetic demo content ...")
Expand Down
35 changes: 34 additions & 1 deletion src/sessioniq/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,20 @@
load_dotenv()

app = FastAPI(title="SessionIQ API")

# The Vite dev server is a separate origin and must be allowed explicitly. A
# deployed build is served by this app, so it is same-origin and needs no entry
# here.
CORS_ORIGINS = [
origin.strip()
for origin in os.getenv(
"SESSIONIQ_CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173"
).split(",")
if origin.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_origins=CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
Expand Down Expand Up @@ -1686,3 +1697,25 @@ def _display_type(asset: ProjectAsset) -> str:
if asset.kind == AssetKind.IMAGE:
return "Image"
return asset.kind.value.title()


def _dashboard_directory() -> Path | None:
"""The built dashboard, when one is present.

Local development uses the Vite dev server on its own port, so finding
nothing here is the normal case outside a container.
"""
configured = os.getenv("SESSIONIQ_STATIC_DIR")
candidates = [Path(configured)] if configured else []
candidates.append(Path(__file__).resolve().parents[2] / "web" / "dist")
for directory in candidates:
if (directory / "index.html").is_file():
return directory
return None


# Mounted last on purpose: API routes and /uploads are registered above, and
# Starlette matches in registration order, so this catch-all cannot shadow them.
_DASHBOARD_DIR = _dashboard_directory()
if _DASHBOARD_DIR is not None:
app.mount("/", StaticFiles(directory=str(_DASHBOARD_DIR), html=True), name="dashboard")
18 changes: 18 additions & 0 deletions tests/test_deployment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Tests for the configuration that makes a hosted deployment work."""

from __future__ import annotations

from sessioniq.api import _dashboard_directory


class TestDashboardDirectory:
def test_uses_the_configured_directory_when_it_holds_a_build(self, tmp_path, monkeypatch):
(tmp_path / "index.html").write_text("<!doctype html>", encoding="utf-8")
monkeypatch.setenv("SESSIONIQ_STATIC_DIR", str(tmp_path))
assert _dashboard_directory() == tmp_path

def test_ignores_a_configured_directory_without_a_build(self, tmp_path, monkeypatch):
# An empty directory must not be mounted: serving it would turn every
# path into a 404 and hide the API's own error responses.
monkeypatch.setenv("SESSIONIQ_STATIC_DIR", str(tmp_path))
assert _dashboard_directory() != tmp_path
3 changes: 0 additions & 3 deletions web/.dockerignore

This file was deleted.

19 changes: 0 additions & 19 deletions web/Dockerfile

This file was deleted.

37 changes: 0 additions & 37 deletions web/nginx.conf

This file was deleted.