22 Commits

Author SHA1 Message Date
dependabot[bot] 5099058199 deps(deps): bump flask-openapi3 from 4.3.1 to 4.3.2 in the flask group
Bumps the flask group with 1 update: [flask-openapi3](https://github.com/luolingchun/flask-openapi).


Updates `flask-openapi3` from 4.3.1 to 4.3.2
- [Release notes](https://github.com/luolingchun/flask-openapi/releases)
- [Changelog](https://github.com/luolingchun/flask-openapi/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luolingchun/flask-openapi/compare/v4.3.1...v4.3.2)

---
updated-dependencies:
- dependency-name: flask-openapi3
  dependency-version: 4.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: flask
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 07:17:54 +00:00
Tomáš Dvořák fbf4a71ea5 Merge pull request #7 from Dvorinka/dependabot/pip/rapidfuzz-3.14.3 2026-04-10 12:28:25 +02:00
dependabot[bot] 5ed457d79d deps(deps): bump rapidfuzz from 3.11.0 to 3.14.3
Bumps [rapidfuzz](https://github.com/rapidfuzz/RapidFuzz) from 3.11.0 to 3.14.3.
- [Release notes](https://github.com/rapidfuzz/RapidFuzz/releases)
- [Changelog](https://github.com/rapidfuzz/RapidFuzz/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/rapidfuzz/RapidFuzz/compare/v3.11.0...v3.14.3)

---
updated-dependencies:
- dependency-name: rapidfuzz
  dependency-version: 3.14.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 10:27:17 +00:00
Tomáš Dvořák 93b0f81990 Merge pull request #1 from Dvorinka/dependabot/github_actions/actions/cache-5 2026-04-10 12:26:57 +02:00
Tomáš Dvořák f27f8504bb Merge pull request #2 from Dvorinka/dependabot/github_actions/actions/upload-artifact-7 2026-04-10 12:26:38 +02:00
Tomáš Dvořák 274c51d44b Merge pull request #3 from Dvorinka/dependabot/github_actions/codecov/codecov-action-5 2026-04-10 12:26:25 +02:00
Tomáš Dvořák 37a60b98d7 Merge pull request #4 from Dvorinka/dependabot/github_actions/actions/checkout-6 2026-04-10 12:26:16 +02:00
Tomáš Dvořák 5d06dda687 Merge pull request #5 from Dvorinka/dependabot/github_actions/actions/setup-node-6 2026-04-10 12:25:57 +02:00
Tomáš Dvořák e26cf25e6f Merge pull request #6 from Dvorinka/dependabot/pip/flask-72b697a17d 2026-04-10 12:25:11 +02:00
dependabot[bot] d5c664d753 deps(deps): bump flask-openapi3 from 3.0.2 to 4.3.1 in the flask group
Bumps the flask group with 1 update: [flask-openapi3](https://github.com/luolingchun/flask-openapi3).


Updates `flask-openapi3` from 3.0.2 to 4.3.1
- [Release notes](https://github.com/luolingchun/flask-openapi3/releases)
- [Changelog](https://github.com/luolingchun/flask-openapi/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luolingchun/flask-openapi3/compare/v3.0.2...v4.3.1)

---
updated-dependencies:
- dependency-name: flask-openapi3
  dependency-version: 4.3.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: flask
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 06:21:37 +00:00
Tomas Dvorak 523ebf1c94 fix 2026-04-04 14:39:47 +02:00
Tomas Dvorak c4e87358c1 update 2026-04-04 13:04:13 +02:00
Tomas Dvorak c43e9cae18 restyle 2026-04-03 12:17:26 +02:00
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
dependabot[bot] 58f720d7de ci(deps): bump actions/setup-node from 4 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-22 10:32:53 +00:00
dependabot[bot] facff2d322 ci(deps): bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-22 10:32:50 +00:00
dependabot[bot] 1648e0fea2 ci(deps): bump actions/cache from 4 to 5
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-22 09:56:26 +00:00
dependabot[bot] 69244c9429 ci(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-22 09:56:21 +00:00
dependabot[bot] 8661101706 ci(deps): bump codecov/codecov-action from 4 to 5
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4 to 5.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-21 09:01:59 +00:00
29 changed files with 663 additions and 62 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
+67
View File
@@ -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
+10 -10
View File
@@ -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
+8 -8
View File
@@ -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
+16 -16
View File
@@ -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
+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 -11
View File
@@ -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"]
+100
View File
@@ -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
+8
View File
@@ -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

+13
View File
@@ -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

+108
View File
@@ -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"}
+39
View File
@@ -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
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
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
[tool:pytest]
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
+6 -3
View File
@@ -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
+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:*"