mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-03 20:13:02 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5099058199 | |||
| fbf4a71ea5 | |||
| 5ed457d79d | |||
| 93b0f81990 | |||
| f27f8504bb | |||
| 274c51d44b | |||
| 37a60b98d7 | |||
| 5d06dda687 | |||
| e26cf25e6f | |||
| d5c664d753 | |||
| 523ebf1c94 | |||
| c4e87358c1 | |||
| c43e9cae18 | |||
| 4159f36f64 | |||
| 72de120fe2 | |||
| 00027f686b | |||
| ab01f915c3 | |||
| 58f720d7de | |||
| facff2d322 | |||
| 1648e0fea2 | |||
| 69244c9429 | |||
| 8661101706 |
@@ -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,67 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master", "main" ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'swingmusic-webclient/**'
|
||||
- 'pyproject.toml'
|
||||
- 'requirements.txt'
|
||||
- 'Dockerfile'
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Backend Lint & Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
name: Backend Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
run: python -m pytest tests/ -v --tb=short --cov=src/swingmusic --cov-report=xml --cov-report=term-missing
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
name: Backend Startup Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
working-directory: swingmusic_mobile
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -131,12 +131,12 @@ jobs:
|
||||
working-directory: swingmusic-webclient
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
@@ -162,12 +162,12 @@ jobs:
|
||||
working-directory: swingmusic-desktop
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
name: Python Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -90,12 +90,12 @@ jobs:
|
||||
run:
|
||||
working-directory: swingmusic-webclient
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -113,12 +113,12 @@ jobs:
|
||||
run:
|
||||
working-directory: swingmusic-desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
name: Secret Scanning
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
release_notes: ${{ steps.version.outputs.release_notes }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
rust_target: 'x86_64-unknown-linux-gnu'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Initialize submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
npm run tauri build -- --target ${{ matrix.rust_target }}
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: desktop-${{ matrix.platform }}
|
||||
path: |
|
||||
@@ -234,7 +234,7 @@ jobs:
|
||||
rust_target: 'x86_64-pc-windows-gnu'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Initialize submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
sudo apt-get install -y mingw-w64 g++-multilib nsis libgtk-3-dev libwebkit2gtk-4.1-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
@@ -278,7 +278,7 @@ jobs:
|
||||
npm run tauri build -- --target ${{ matrix.rust_target }}
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: desktop-${{ matrix.platform }}
|
||||
path: |
|
||||
@@ -303,12 +303,12 @@ jobs:
|
||||
rust_target: 'aarch64-apple-darwin'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Initialize submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
@@ -342,7 +342,7 @@ jobs:
|
||||
npm run tauri build -- --target ${{ matrix.rust_target }}
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: desktop-${{ matrix.platform }}
|
||||
path: |
|
||||
@@ -358,7 +358,7 @@ jobs:
|
||||
if: contains(github.event.inputs.components, 'mobile') || github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Initialize submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
@@ -381,7 +381,7 @@ jobs:
|
||||
flutter build apk --release --no-pub
|
||||
|
||||
- name: Upload Mobile artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mobile-release
|
||||
path: swingmusic_mobile/build/app/outputs/flutter-apk/app-release.apk
|
||||
@@ -395,7 +395,7 @@ jobs:
|
||||
if: contains(github.event.inputs.components, 'backend') || github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Initialize submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
@@ -410,7 +410,7 @@ jobs:
|
||||
sudo apt-get install -y libev-dev
|
||||
|
||||
- name: Cache Python dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ hashFiles('**/requirements.txt') }}
|
||||
@@ -427,7 +427,7 @@ jobs:
|
||||
python -m build
|
||||
|
||||
- name: Upload Backend artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: backend-package
|
||||
path: dist/
|
||||
@@ -441,7 +441,7 @@ jobs:
|
||||
if: success()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
@@ -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
-11
@@ -1,26 +1,46 @@
|
||||
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 \
|
||||
build-essential \
|
||||
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
|
||||
# Install the package and its dependencies (includes aiohttp, aiofiles, redis)
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# Install Redis library for DragonflyDB support
|
||||
RUN pip install 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,106 @@ docker run --name swingmusic -p 1970:1970 \
|
||||
ghcr.io/Dvorinka/swingmusic-extended:latest
|
||||
```
|
||||
|
||||
### CasaOS
|
||||
|
||||
Use this CasaOS custom-app compose.
|
||||
It runs both the main backend API and the bundled webclient from the same `swingmusic` container image.
|
||||
|
||||
```yaml
|
||||
name: swingmusic
|
||||
services:
|
||||
swingmusic:
|
||||
cpu_shares: 90
|
||||
command: []
|
||||
container_name: swingmusic
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 10000M
|
||||
hostname: swingmusic
|
||||
image: ghcr.io/dvorinka/swingmusic-extended:latest
|
||||
labels:
|
||||
icon: https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/Apps/SwingMusic/icon.png
|
||||
ports:
|
||||
- mode: ingress
|
||||
target: 1970
|
||||
published: "1970"
|
||||
protocol: tcp
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /DATA/AppData/swingmusic/config
|
||||
target: /config
|
||||
- type: bind
|
||||
source: /DATA/Media/Music
|
||||
target: /music
|
||||
devices: []
|
||||
cap_add: []
|
||||
environment:
|
||||
- SWINGMUSIC_CLIENT_DIR=/app/client
|
||||
- SWINGMUSIC_PORT=1970
|
||||
networks:
|
||||
- default
|
||||
privileged: false
|
||||
networks:
|
||||
default:
|
||||
name: swingmusic_default
|
||||
x-casaos:
|
||||
architectures:
|
||||
- amd64
|
||||
author: SwingMX
|
||||
category: Media
|
||||
description:
|
||||
en_US: >
|
||||
Swing Music is a fast, beautiful, self-hosted music player designed for
|
||||
your local audio files, offering a sleek experience akin to Spotify but
|
||||
powered by your own music library. Simply run the app and access your
|
||||
music collection effortlessly through a web browser.
|
||||
|
||||
|
||||
Swing Music curates Daily Mixes based on your listening habits, ensures a clean and consistent library with metadata normalization, and supports album versioning (e.g., Deluxe, Remaster) alongside related artist and album recommendations. Browse your music library via folder view, manage playlists, and enjoy a seamless listening experience with silence detection and cross-fade. Additional features include listening statistics, lyrics view, Last.fm scrobbling, multi-user support, and personalized collections for grouping albums and artists.
|
||||
|
||||
|
||||
With its stunning browser-based interface and robust functionality, Swing Music is the perfect choice for music enthusiasts seeking a beautiful and practical way to manage and enjoy their local music collection.
|
||||
developer: SwingMX
|
||||
hostname: ""
|
||||
icon: https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/Apps/SwingMusic/icon.png
|
||||
index: /
|
||||
is_uncontrolled: false
|
||||
main: swingmusic
|
||||
port_map: "1970"
|
||||
scheme: http
|
||||
store_app_id: swingmusic
|
||||
tagline:
|
||||
en_US: Swing Music is a beautifully designed, self-hosted music streaming
|
||||
server. Like a cooler Spotify ... but bring your own music.
|
||||
thumbnail: https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/Apps/SwingMusic/thumbnail.png
|
||||
tips:
|
||||
before_install:
|
||||
en_US: >
|
||||
When you first start Swing Music, it will ask you to pick music
|
||||
directory: Where do you want to look for music?
|
||||
|
||||
select "Specific directories" Option, and select "/music" and rescan.
|
||||
|
||||
|
||||
Default Account
|
||||
|
||||
| Name | Password |
|
||||
|
||||
| -------- | -------- |
|
||||
|
||||
| `admin` | `admin` |
|
||||
title:
|
||||
custom: ""
|
||||
en_us: Swing Music
|
||||
```
|
||||
|
||||
Quick references:
|
||||
|
||||
- [`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,8 @@
|
||||
<svg viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="defaultAlbumImage">
|
||||
<g id="defaultAlbumImage_2">
|
||||
<path id="Vector" d="M21 31.5C26.799 31.5 31.5 26.799 31.5 21C31.5 15.201 26.799 10.5 21 10.5C15.201 10.5 10.5 15.201 10.5 21C10.5 26.799 15.201 31.5 21 31.5Z" stroke="#78777F" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M21 23.5C22.3807 23.5 23.5 22.3807 23.5 21C23.5 19.6193 22.3807 18.5 21 18.5C19.6193 18.5 18.5 19.6193 18.5 21C18.5 22.3807 19.6193 23.5 21 23.5Z" stroke="#78777F" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 654 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="defaultPlaylistImage">
|
||||
<g id="defaultPlaylistImage_2">
|
||||
<g id="Group">
|
||||
<path id="Vector" d="M14.1 29.3C15.6464 29.3 16.9 28.0464 16.9 26.5C16.9 24.9536 15.6464 23.7 14.1 23.7C12.5536 23.7 11.3 24.9536 11.3 26.5C11.3 28.0464 12.5536 29.3 14.1 29.3Z" stroke="#78777F" stroke-miterlimit="10" stroke-linecap="round"/>
|
||||
<path id="Vector_2" d="M16.9 26.5V12.8" stroke="#78777F" stroke-miterlimit="10"/>
|
||||
<path id="Vector_3" d="M21 24.2H29.3" stroke="#78777F" stroke-miterlimit="10"/>
|
||||
<path id="Vector_4" d="M21 16.9H31.1" stroke="#78777F" stroke-miterlimit="10"/>
|
||||
<path id="Vector_5" d="M21 20.5H30.2" stroke="#78777F" stroke-miterlimit="10"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 737 B |
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"serverId": "f1112583-0a04-4276-950e-14b673d78cc2",
|
||||
"usersOnLogin": true,
|
||||
"rootDirs": [],
|
||||
"excludeDirs": [],
|
||||
"artistSeparators": [
|
||||
";",
|
||||
"/"
|
||||
],
|
||||
"artistSplitIgnoreList": [
|
||||
"Durand Jones & The Indications",
|
||||
"Gerry & the Pacemakers",
|
||||
"Earth, Wind & Fire",
|
||||
"Josie & the Pussycats",
|
||||
"Captain & Tennille",
|
||||
"Tony! Toni! Ton\u00e9!",
|
||||
"Blood, Sweat & Tears",
|
||||
"Katrina & the Waves",
|
||||
"Yusuf / Cat Stevens",
|
||||
"Crosby, Stills, Nash & Young",
|
||||
"Joan Jett & the Blackhearts",
|
||||
"Diana Ross & the Supremes",
|
||||
"Mike + The Mechanics",
|
||||
"Charles & Eddie",
|
||||
"Sam & Dave",
|
||||
"Ike & Tina Turner",
|
||||
"Gladys Knight & the Pips",
|
||||
"Tommy James & the Shondells",
|
||||
"Phillips, Craig & Dean",
|
||||
"Smith & Thell",
|
||||
"Nico & Vinz",
|
||||
"Ashford & Simpson",
|
||||
"Loggins & Messina",
|
||||
"Big Brother and the Holding Company",
|
||||
"? and the Mysterians",
|
||||
"Kool & the Gang",
|
||||
"Peter, Paul & Mary",
|
||||
"Brooks & Dunn",
|
||||
"St. Paul & The Broken Bones",
|
||||
"Tom Petty & The Heartbreakers",
|
||||
"C&C Music Factory",
|
||||
"Florence & The Machine",
|
||||
"Sonny & Cher",
|
||||
"The Product G&B",
|
||||
"Robson & Jerome",
|
||||
"Dan + Shay",
|
||||
"Martha & the Vandellas",
|
||||
"Simon & Garfunkel",
|
||||
"Maurice Williams & The Zodiacs",
|
||||
"Judy & Mary",
|
||||
"Emerson, Lake & Palmer",
|
||||
"Echo & the Bunnymen",
|
||||
"Frank DeVol and His Orchestra",
|
||||
"Mumford & Sons",
|
||||
"Chloe x Halle",
|
||||
"Hall & Oates",
|
||||
"Pepsi & Shirlie",
|
||||
"C & C Music Factory",
|
||||
"Womack & Womack",
|
||||
"Bob marley & the wailers",
|
||||
"Martha Reeves and the Vandellas",
|
||||
"AC/DC",
|
||||
"Belle & Sebastian",
|
||||
"DJ Jazzy Jeff & The Fresh Prince",
|
||||
"Sly & the Family Stone",
|
||||
"Nick Cave & the Bad Seeds",
|
||||
"For King + Country",
|
||||
"Booker T. & the M.G.'s",
|
||||
"Peaches & Herb",
|
||||
"England Dan & John Ford Coley",
|
||||
"Crosby & Nash",
|
||||
"Rob Base & DJ E-Z Rock",
|
||||
"Maddie & Tae",
|
||||
"Tyler, The Creator",
|
||||
"Huey Lewis & the News",
|
||||
"KC & the Sunshine Band",
|
||||
"Eric B. & Rakim",
|
||||
"Nathaniel Rateliff & The Night Sweats",
|
||||
"Seals & Crofts",
|
||||
"Herb Alpert & the Tijuana Brass",
|
||||
"For King & Country",
|
||||
"Mel & Kim",
|
||||
"Aly & AJ",
|
||||
"The Mamas & the Papas",
|
||||
"Wendy & Lisa",
|
||||
"FO&O",
|
||||
"Hootie & the Blowfish"
|
||||
],
|
||||
"genreSeparators": [
|
||||
"&",
|
||||
"/",
|
||||
";"
|
||||
],
|
||||
"extractFeaturedArtists": true,
|
||||
"removeProdBy": true,
|
||||
"removeRemasterInfo": true,
|
||||
"mergeAlbums": false,
|
||||
"cleanAlbumTitle": true,
|
||||
"showAlbumsAsSingles": false,
|
||||
"enablePeriodicScans": false,
|
||||
"scanInterval": 10,
|
||||
"enableWatchdog": false,
|
||||
"showPlaylistsInFolderView": false,
|
||||
"enablePlugins": true,
|
||||
"lastfmApiKey": "",
|
||||
"lastfmApiSecret": "",
|
||||
"lastfmSessionKeys": {}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
{"level": "INFO", "message": "Applied migration: Migration001EnsureSetupState", "timestamp": "2026-04-03T09:39:04.140114+00:00", "logger": "swingmusic.migrations", "module": "__init__", "function": "apply_migrations", "line": 97, "args": ["Migration001EnsureSetupState"], "who": "swingmusic.migrations"}
|
||||
{"level": "INFO", "message": "Applied migration: Migration002SyncOwnerProjection", "timestamp": "2026-04-03T09:39:04.142595+00:00", "logger": "swingmusic.migrations", "module": "__init__", "function": "apply_migrations", "line": 97, "args": ["Migration002SyncOwnerProjection"], "who": "swingmusic.migrations"}
|
||||
{"level": "INFO", "message": "Applied migration: Migration003BackfillLyricsStatus", "timestamp": "2026-04-03T09:39:04.145272+00:00", "logger": "swingmusic.migrations", "module": "__init__", "function": "apply_migrations", "line": 97, "args": ["Migration003BackfillLyricsStatus"], "who": "swingmusic.migrations"}
|
||||
{"level": "INFO", "message": "Applied migration: Migration004BackfillUserRootOwnership", "timestamp": "2026-04-03T09:39:04.146550+00:00", "logger": "swingmusic.migrations", "module": "__init__", "function": "apply_migrations", "line": 97, "args": ["Migration004BackfillUserRootOwnership"], "who": "swingmusic.migrations"}
|
||||
{"level": "INFO", "message": "Applied migration: Migration005NormalizeTrackedPlaylists", "timestamp": "2026-04-03T09:39:04.149525+00:00", "logger": "swingmusic.migrations", "module": "__init__", "function": "apply_migrations", "line": 97, "args": ["Migration005NormalizeTrackedPlaylists"], "who": "swingmusic.migrations"}
|
||||
{"level": "INFO", "message": "Enhanced search API registered", "timestamp": "2026-04-03T09:39:04.429546+00:00", "logger": "swingmusic.api.enhanced_search", "module": "enhanced_search", "function": "register_enhanced_search_api", "line": 513, "args": [], "who": "swingmusic.api.enhanced_search"}
|
||||
{"level": "INFO", "message": "Boot smoke check passed (239 routes).", "timestamp": "2026-04-03T09:39:04.530130+00:00", "logger": "swingmusic.app_builder", "module": "app_builder", "function": "run_boot_smoke_checks", "line": 283, "args": [239], "who": "swingmusic.app_builder"}
|
||||
@@ -0,0 +1,39 @@
|
||||
name: swingmusic
|
||||
|
||||
services:
|
||||
swingmusic:
|
||||
build: .
|
||||
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 --proactor_threads=${DRAGONFLY_THREADS:-4}
|
||||
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
|
||||
+8
-2
@@ -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
|
||||
@@ -25,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- dragonfly_data:/data
|
||||
restart: unless-stopped
|
||||
command: --dir=/data --logtostdout
|
||||
command: --dir=/data --logtostdout --proactor_threads=${DRAGONFLY_THREADS:-4}
|
||||
networks:
|
||||
- swingmusic-network
|
||||
healthcheck:
|
||||
|
||||
+3
-2
@@ -34,14 +34,15 @@ dependencies = [
|
||||
"ffmpeg-python>=0.2.0",
|
||||
"schedule>=1.2.2",
|
||||
"pillow>=11.1.0",
|
||||
"flask-openapi3==3.0.2",
|
||||
"rapidfuzz==3.11.0",
|
||||
"flask-openapi3==4.3.2",
|
||||
"rapidfuzz==3.14.5",
|
||||
"pendulum>=3.0.0",
|
||||
"pystray>=0.19.5",
|
||||
"waitress>=3.0.2; sys_platform == 'win32'",
|
||||
"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*
|
||||
|
||||
+6
-3
@@ -21,9 +21,12 @@ sortedcontainers>=2.4.0
|
||||
xxhash>=3.4.1
|
||||
ffmpeg-python>=0.2.0
|
||||
schedule>=1.2.2
|
||||
flask-openapi3==3.0.2
|
||||
rapidfuzz==3.11.0
|
||||
flask-openapi3==4.3.2
|
||||
rapidfuzz==3.14.5
|
||||
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
|
||||
aiohttp>=3.13.3
|
||||
aiofiles>=25.1.0
|
||||
|
||||
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...577754d35f
+1
-1
Submodule swingmusic-webclient updated: 19d402a940...73fef3e8ed
+1
-1
Submodule swingmusic_mobile updated: fa027b588b...d10fea04d5
Reference in New Issue
Block a user