mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-03 20:13:02 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4159f36f64 | |||
| 72de120fe2 | |||
| 00027f686b | |||
| ab01f915c3 |
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
[tool:pytest]
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
Executable
+34
@@ -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
|
||||
@@ -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:*"
|
||||
|
||||
+1
-1
Submodule swingmusic-desktop updated: 74be0b1d12...bf45d36f41
+1
-1
Submodule swingmusic-webclient updated: 19d402a940...cad049797e
+1
-1
Submodule swingmusic_mobile updated: fa027b588b...7bd87ac1f9
Reference in New Issue
Block a user