4 Commits

Author SHA1 Message Date
Tomas Dvorak 4159f36f64 update 2026-04-03 10:55:35 +02:00
Tomas Dvorak 72de120fe2 sync 2026-04-03 10:42:20 +02:00
Tomas Dvorak 00027f686b update 2026-04-02 10:57:22 +02:00
Tomas Dvorak ab01f915c3 fix auth 2026-04-02 10:16:30 +02:00
15 changed files with 325 additions and 21 deletions
+31
View File
@@ -0,0 +1,31 @@
.git
.gitmodules
.venv
.pytest_cache
.ruff_cache
__pycache__
**/__pycache__
*.pyc
*.pyo
*.pyd
node_modules
**/node_modules
build
dist
**/build
**/dist
.dart_tool
**/.dart_tool
swingmusic_mobile/build
swingmusic_mobile/.flutter-plugins-dependencies
swingmusic_mobile/.idea
swingmusic-desktop/target
tests
reference
scripts
+117
View File
@@ -0,0 +1,117 @@
# CasaOS Deployment Guide
## Why CasaOS Was Failing
The backend can fail in containerized environments when no static web client is bundled.
If `client.zip` is not available at runtime, the server may try to fetch web assets dynamically, which is fragile on appliance-like deployments.
## What Is Fixed In This Repo
- `Dockerfile` is now multi-stage and bundles webclient assets into `/app/client`.
- Runtime now uses `SWINGMUSIC_CLIENT_DIR=/app/client`.
- Compose has health checks for backend + Dragonfly.
- Added `docker-compose.casaos.yml` with CasaOS-friendly path/environment defaults.
## 1) Build Image On The CasaOS Host
Run on your CasaOS machine from this repository root:
```bash
docker build -t swingmusic-local:latest .
```
If you prefer a registry image, set `SWINGMUSIC_IMAGE` accordingly in step 2.
## 2) Use CasaOS-Friendly Compose
Use [`docker-compose.casaos.yml`](/home/tdvorak/Desktop/PROG+HTML/SwingMusic/docker-compose.casaos.yml) as your custom app compose.
Important env values:
- `SWINGMUSIC_IMAGE` default: `swingmusic-local:latest`
- `SWINGMUSIC_PORT` default: `1970`
- `SWINGMUSIC_MUSIC_DIR` default: `/DATA/Media/Music`
- `SWINGMUSIC_CONFIG_DIR` default: `/DATA/AppData/swingmusic/config`
- `SWINGMUSIC_DRAGONFLY_DIR` default: `/DATA/AppData/swingmusic/dragonfly`
Pre-create and permission-check app data dirs:
```bash
mkdir -p /DATA/AppData/swingmusic/config /DATA/AppData/swingmusic/dragonfly
chmod -R 775 /DATA/AppData/swingmusic
```
## 3) Smoke Test
After app starts:
```bash
curl -sS http://127.0.0.1:1970/healthz
```
Expected: JSON with `"ok": true`.
Then open:
- `http://<casaos-host-ip>:1970/`
- Complete setup (owner + music directory).
## 4) Verify Auth Flows (Web + Mobile + Desktop)
From web UI:
1. Login.
2. Generate pairing QR/code.
3. Mobile: scan QR or enter `server_url|pair_code` manually.
4. Desktop: use manual URL + pair code or URL + username/password.
Expected behavior:
- Unauthenticated clients are blocked from main app.
- `/auth/pair` code is single-use (first 200, second 400).
- Protected APIs return 401 without token.
## 5) If It Still Fails In CasaOS
Check container status:
```bash
docker ps -a | grep -E 'swingmusic|dragonfly'
```
Or run the bundled diagnostics script:
```bash
./scripts/casaos-diagnose.sh
```
Check logs:
```bash
docker logs --tail 200 swingmusic
docker logs --tail 200 swingmusic-dragonfly
```
Check rendered compose:
```bash
docker compose -f docker-compose.casaos.yml config
```
Common issues:
1. Image missing:
- Build image first or set `SWINGMUSIC_IMAGE` to a valid registry tag.
2. Port conflict on `1970` or `6379`:
- Change `SWINGMUSIC_PORT` / `DRAGONFLY_PORT`.
3. Permission denied on `/DATA/AppData/...`:
- Fix host dir ownership/permissions.
4. DNS/pull failures:
- Confirm CasaOS host can pull `docker.dragonflydb.io/dragonflydb/dragonfly`.
## Notes About CasaOS Compose Compatibility
- CasaOS AppStore release notes mention Docker Compose engine upgrades to support newer compose formats:
https://github.com/IceWhaleTech/CasaOS-AppStore/releases
- Many CasaOS app templates map persistent data under `/DATA/AppData/$AppID/...`:
https://community.bigbeartechworld.com/t/no-update-for-immich/4089/4
+31 -9
View File
@@ -1,26 +1,48 @@
FROM python:3.11-slim
FROM node:20-bookworm-slim AS webclient-builder
WORKDIR /webclient
COPY swingmusic-webclient/package.json swingmusic-webclient/package-lock.json ./
RUN npm ci
COPY swingmusic-webclient/ ./
RUN npm run build
FROM python:3.11-slim AS runtime
WORKDIR /app
LABEL "author"="swing music"
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
SWINGMUSIC_CLIENT_DIR=/app/client
EXPOSE 1970/tcp
VOLUME /music
VOLUME /config
RUN apt-get update
RUN apt-get install -y gcc libev-dev
RUN apt-get install -y ffmpeg libavcodec-extra
RUN apt-get install -y redis-tools # For DragonflyDB/Redis connectivity
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gcc \
libev-dev \
ffmpeg \
libavcodec-extra \
redis-tools \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy repo root files needed for installation
COPY pyproject.toml requirements.txt version.txt ./
COPY pyproject.toml requirements.txt version.txt ./
COPY src/ ./src/
# Install the package and its dependencies
RUN pip install --no-cache-dir .
# Install Redis library for DragonflyDB support
RUN pip install redis
RUN pip install --no-cache-dir redis
# Ship a deterministic web client with the backend image so startup does not
# depend on downloading release assets at runtime.
COPY --from=webclient-builder /webclient/dist /app/client
ENTRYPOINT ["python", "-m", "swingmusic", "--host", "0.0.0.0", "--config", "/config"]
+7
View File
@@ -182,6 +182,13 @@ docker run --name swingmusic -p 1970:1970 \
ghcr.io/Dvorinka/swingmusic-extended:latest
```
### CasaOS
For CasaOS custom-app deployment, use:
- [`docker-compose.casaos.yml`](/home/tdvorak/Desktop/PROG+HTML/SwingMusic/docker-compose.casaos.yml)
- [`CASAOS_DEPLOYMENT.md`](/home/tdvorak/Desktop/PROG+HTML/SwingMusic/CASAOS_DEPLOYMENT.md)
## 🛠️ Development
### Backend Development
+38
View File
@@ -0,0 +1,38 @@
name: swingmusic
services:
swingmusic:
image: ${SWINGMUSIC_IMAGE:-swingmusic-local:latest}
container_name: swingmusic
restart: unless-stopped
ports:
- "${SWINGMUSIC_PORT:-1970}:1970"
environment:
DRAGONFLYDB_HOST: dragonfly
DRAGONFLYDB_PORT: "6379"
SWINGMUSIC_CLIENT_DIR: /app/client
volumes:
- ${SWINGMUSIC_MUSIC_DIR:-/DATA/Media/Music}:/music
- ${SWINGMUSIC_CONFIG_DIR:-/DATA/AppData/swingmusic/config}:/config
depends_on:
- dragonfly
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:1970/healthz', timeout=3)"]
interval: 20s
timeout: 5s
retries: 5
dragonfly:
image: docker.dragonflydb.io/dragonflydb/dragonfly
container_name: swingmusic-dragonfly
restart: unless-stopped
command: --dir=/data --logtostdout
ports:
- "${DRAGONFLY_PORT:-6379}:6379"
volumes:
- ${SWINGMUSIC_DRAGONFLY_DIR:-/DATA/AppData/swingmusic/dragonfly}:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
+7 -1
View File
@@ -1,8 +1,9 @@
version: '3.8'
name: swingmusic
services:
swingmusic:
build: .
container_name: swingmusic
ports:
- "1970:1970"
volumes:
@@ -13,6 +14,11 @@ services:
- DRAGONFLYDB_PORT=6379
depends_on:
- dragonfly
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:1970/healthz', timeout=3)"]
interval: 20s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- swingmusic-network
+1
View File
@@ -42,6 +42,7 @@ dependencies = [
"bjoern >=3.2.2; sys_platform != 'win32'",
"aiohttp>=3.13.3",
"aiofiles>=25.1.0",
"redis>=5.2.1",
]
[project.optional-dependencies]
+1 -1
View File
@@ -1,4 +1,4 @@
[tool:pytest]
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
+2 -1
View File
@@ -26,4 +26,5 @@ rapidfuzz==3.11.0
pendulum>=3.0.0
pystray>=0.19.5
waitress==3.0.2; sys_platform == 'win32'
bjoern>=3.2.2; sys_platform != 'win32'
bjoern>=3.2.2; sys_platform != 'win32'
redis>=5.2.1
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
echo "== CasaOS / Docker diagnostics =="
echo "timestamp: $(date -Is)"
echo
echo "== Docker client/server =="
docker version || true
echo
echo "== Docker compose version =="
docker compose version || true
echo
echo "== Render compose =="
docker compose -f docker-compose.casaos.yml config || true
echo
echo "== Running containers =="
docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' | grep -E 'swingmusic|dragonfly|NAMES' || true
echo
echo "== swingmusic logs (tail 200) =="
docker logs --tail 200 swingmusic 2>&1 || true
echo
echo "== dragonfly logs (tail 200) =="
docker logs --tail 200 swingmusic-dragonfly 2>&1 || true
echo
echo "== Health endpoint =="
curl -sS -m 5 http://127.0.0.1:1970/healthz || true
echo
+36 -6
View File
@@ -39,9 +39,30 @@ api = APIBlueprint("auth", __name__, url_prefix="/auth", abp_tags=[bp_tag])
def get_limiter():
"""Get the rate limiter from app context."""
from flask import current_app
# Prefer the global limiter initialized in app_builder.build().
# flask-limiter v4 may store a set in current_app.extensions["limiter"],
# so resolve defensively across versions.
try:
from swingmusic.app_builder import limiter as app_limiter
return current_app.extensions.get("limiter")
if app_limiter is not None and hasattr(app_limiter, "limit"):
return app_limiter
except Exception:
pass
ext = current_app.extensions.get("limiter")
if ext is None:
return None
if hasattr(ext, "limit"):
return ext
if isinstance(ext, set):
for candidate in ext:
if hasattr(candidate, "limit"):
return candidate
return None
def rate_limit(limit: str):
@@ -208,10 +229,15 @@ def login(body: LoginBody):
# Cache user session in DragonflyDB for fast lookups
session_service = get_user_session_service()
if session_service.session_cache.client.is_available():
if session_service.cache.client.is_available():
import contextlib
with contextlib.suppress(Exception):
session_service.create_session(
token,
user.todict(),
ttl_hours=max(1, int(age // 3600)),
)
session_service.set_user_session(user.id, user.todict(), ttl_seconds=age)
return res
@@ -334,13 +360,17 @@ def get_pair():
server_url = request.headers.get("Origin", "").strip()
if not server_url:
server_url = request.host_url.rstrip("/")
else:
server_url = server_url.rstrip("/")
return {
"code": code,
"expires_at": expires_at,
"ttl_seconds": pair_token_store.ttl_seconds,
"server_url": server_url,
"qr_payload": f"{server_url} {code}",
# Keep payload contract explicit for mobile/desktop clients.
# Format: "<server_url>|<pair_code>"
"qr_payload": f"{server_url}|{code}",
}
@@ -582,11 +612,11 @@ def logout():
# Invalidate session in DragonflyDB
if current_user:
session_service = get_user_session_service()
if session_service.session_cache.client.is_available():
if session_service.cache.client.is_available():
import contextlib
with contextlib.suppress(Exception):
session_service.invalidate_session(current_user["id"])
session_service.invalidate_user_session(current_user["id"])
res = jsonify({"msg": "Logged out"})
res.delete_cookie("access_token_cookie")
@@ -112,6 +112,8 @@ class UserSessionService:
def __init__(self):
self.cache = DragonflyCache("sessions")
# Backward compatibility for older call sites.
self.session_cache = self.cache
def create_session(
self, session_token: str, user_data: dict[str, Any], ttl_hours: int = 24
@@ -119,6 +121,17 @@ class UserSessionService:
"""Create user session"""
return self.cache.set(f"session:{session_token}", user_data, ttl_hours)
def set_user_session(
self, userid: int, user_data: dict[str, Any], ttl_seconds: int = 24 * 3600
):
"""Store latest session payload by user id for quick lookups."""
ttl_hours = max(1, int(ttl_seconds // 3600))
return self.cache.set(f"user_session:{userid}", user_data, ttl_hours)
def get_user_session(self, userid: int) -> dict[str, Any] | None:
"""Get latest session payload for a user id."""
return self.cache.get(f"user_session:{userid}")
def get_session(self, session_token: str) -> dict[str, Any] | None:
"""Get user session"""
return self.cache.get(f"session:{session_token}")
@@ -131,6 +144,10 @@ class UserSessionService:
"""Invalidate user session"""
return self.cache.delete(f"session:{session_token}")
def invalidate_user_session(self, userid: int):
"""Invalidate latest session payload for a user id."""
return self.cache.delete(f"user_session:{userid}")
def get_user_sessions(self, userid: int) -> list[str]:
"""Get all active sessions for user"""
pattern = "session:*"