Add README and proper favicon/PWA icon set

README covers what alembic does, prerequisites, a full deploy quickstart,
credential setup, and a page-by-page usage tour, written for someone
running Docker but not necessarily comfortable coding or sysadmin work.

Favicon/icons were prepared in a separate theme scratch dir but never
wired in; moved them into app/static and pointed base.html at the full
icon set (SVG, PNG fallbacks, apple-touch-icon, manifest) instead of the
single inline data-URI SVG it had before.
This commit is contained in:
andrew
2026-07-09 09:51:43 -06:00
parent 92e5326437
commit 1f733d6e91
10 changed files with 286 additions and 1 deletions
+232
View File
@@ -0,0 +1,232 @@
<p align="center">
<img src="app/static/icon.svg" width="112" height="112" alt="alembic logo">
</p>
# alembic
A self-hosted control panel that turns your Spotify playlists into a real, well-tagged music library on your own server, downloaded automatically, kept up to date, and free of duplicate junk.
No terminal required for day-to-day use. Everything, adding playlists, fixing a mislabeled song, cleaning up duplicates, happens through a web page.
---
## Table of contents
- [What alembic does](#what-alembic-does)
- [What you need before you start](#what-you-need-before-you-start)
- [Quickstart](#quickstart)
1. [Get the image](#1-get-the-image)
2. [Create your folders](#2-create-your-folders)
3. [Generate a master key](#3-generate-a-master-key)
4. [Set up login (OIDC)](#4-set-up-login-oidc)
5. [Write your docker-compose file](#5-write-your-docker-compose-file)
6. [Start it up](#6-start-it-up)
7. [Log in](#7-log-in)
- [Setting up your credentials](#setting-up-your-credentials)
- [Adding your first playlist](#adding-your-first-playlist)
- [Using alembic day to day](#using-alembic-day-to-day)
- [Updating alembic](#updating-alembic)
- [Troubleshooting](#troubleshooting)
---
## What alembic does
Give alembic a Spotify playlist link, and from then on it will:
- **Download every track** in the playlist automatically, over Soulseek
- **Tag each file properly** (artist, title, album, genre) using Spotify's own metadata, so your library looks clean instead of a pile of "Track 01.mp3" files
- **Import everything into your music library**, ready to show up in Navidrome or any other music server that reads a normal folder-based library
- **Rebuild the playlist as a real M3U file**, so it plays back in the right order in your music app
- **Keep checking on a schedule**, so when you add new songs to the Spotify playlist, they show up in your library on their own
- **Find duplicate copies of songs** (the same track downloaded twice, or in two different formats) and let you review and clean them up, nothing gets deleted without your say-so
- **Let you fix things by hand** when needed: correct a tag, re-tag a track from a URL, manually import a file you dropped in yourself, or delete something you don't want
It also keeps a dashboard showing what's in your library, what got downloaded recently, and the history of every automated job it's run.
Everything runs in one Docker container. There's no separate database to install and no cron jobs to hand-edit, alembic manages all of that internally.
## What you need before you start
alembic is built to slot into a home server that already has a few things running. It is not a starting point for a music server from scratch, it's the automation layer on top of one.
Before you begin, make sure you have:
1. **A server that runs Docker**, with `docker compose` available. Any Linux box, NAS, or mini PC works.
2. **A music server that reads a plain folder of tagged files**, such as [Navidrome](https://www.navidrome.org/). alembic builds your library on disk; Navidrome (or similar) is what you actually listen with.
3. **A Soulseek account.** This is how tracks get downloaded. Sign up free at [soulseek.com](https://www.soulseek.com/).
4. **A free Spotify Developer app.** This lets alembic read playlist contents (it does not download from Spotify itself, only Soulseek). Takes two minutes, see the credentials section below.
5. **A login provider that speaks OpenID Connect (OIDC).** alembic doesn't have its own username/password login, it delegates to something you already trust. [Pocket ID](https://github.com/pocket-id/pocket-id) is a small self-hosted option built exactly for this, but Authentik, Keycloak, Authelia, or any other OIDC provider works too.
Optional, add these later if you want them:
- A **Bandcamp** account, if you want to also sync your Bandcamp purchases into the library automatically.
- **AzuraCast** or **Qobuz** credentials, if you use either of those.
- A **Telegram bot**, if you want job notifications sent to you.
- A **VPN container** (such as gluetun), if you want Soulseek's network traffic routed through a VPN. Not required to get started.
## Quickstart
### 1. Get the image
Clone this repository onto your Docker host and build the image locally:
```bash
git clone <this-repo-url> alembic
cd alembic
docker build -t alembic:latest .
```
This takes a few minutes the first time. You don't need to touch anything inside the `app/` or `pipeline/` folders, the Dockerfile handles all of it.
### 2. Create your folders
alembic needs exactly two places to store things:
- **A music folder** — where your actual songs live. If you already have a music library, point at it directly.
- **A config folder** — where alembic keeps its own database, your saved credentials, and logs.
```bash
mkdir -p /path/to/your/music
mkdir -p /path/to/alembic-config
```
Everything else (which playlists you follow, your credentials, scheduling) is stored inside that config folder, so if you ever need to move alembic to a new machine, copying those two folders is all it takes.
### 3. Generate a master key
alembic encrypts every credential you give it (Spotify keys, Soulseek password, and so on) before storing it. It needs a key to do that:
```bash
openssl rand -base64 32 > /path/to/alembic-config/master.key
chmod 600 /path/to/alembic-config/master.key
```
Keep this file safe. If you lose it, alembic can no longer read your saved credentials and you'll need to re-enter them.
### 4. Set up login (OIDC)
Register alembic as an application in your OIDC provider (Pocket ID, Authentik, whichever you use). You'll need to tell it:
- **Redirect URI:** `https://<wherever-you-host-alembic>/auth/callback`
- **Scopes:** `openid profile email`
In return, it will give you three values you'll need in the next step:
- An **issuer URL** (something like `https://id.example.com`)
- A **client ID**
- A **client secret**
### 5. Write your docker-compose file
Create a `docker-compose.yml` next to your alembic folder:
```yaml
services:
alembic:
image: alembic:latest
container_name: alembic
ports:
- "8420:8420"
environment:
- POCKETID_ISSUER=https://id.example.com
- POCKETID_CLIENT_ID=your-client-id
- POCKETID_CLIENT_SECRET=your-client-secret
- SESSION_SECRET=some-long-random-string
# Optional: lock login to a single email, recommended for a personal server
- ALLOWED_EMAIL=you@example.com
volumes:
- /path/to/your/music:/data/music
- /path/to/alembic-config:/config
secrets:
- alembic_master_key
restart: unless-stopped
secrets:
alembic_master_key:
file: /path/to/alembic-config/master.key
```
A few notes on the environment values:
- `SESSION_SECRET` can be any long random string, generate one with `openssl rand -base64 32`.
- `ALLOWED_EMAIL` is optional but recommended. Even if your OIDC provider only has your account today, this makes sure only that one email can ever log in.
- If you want to route Soulseek traffic through a VPN container instead of exposing alembic's port directly, see the comments in `docker-compose.snippet.yml` in this repo for the `network_mode: "service:<vpn-container>"` pattern. Most people can skip this and use the simple port mapping above.
### 6. Start it up
```bash
docker compose up -d
```
On first boot, alembic seeds a few default config files into your config folder and sets up its internal database automatically. Check that it started cleanly:
```bash
docker compose logs -f alembic
```
You should see `Uvicorn running on http://0.0.0.0:8420` with no errors.
### 7. Log in
Open `http://<your-server>:8420` in a browser. You'll be redirected to your OIDC provider to log in, then dropped onto the alembic dashboard.
## Setting up your credentials
Once logged in, go to **Manage credentials** and fill in each service you plan to use. Nothing here is required all at once, add them as you need them. Values are write-only: once saved, they're encrypted and never shown back to you in the browser (you'll just see a "set" badge).
| Service | What to enter | Where to get it |
|---|---|---|
| **Spotify** | Client ID, Client Secret | Create a free app at [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard). You don't need any special access, the basic free tier is enough to read playlist contents. |
| **Soulseek** | Username, Password | Your normal Soulseek login. |
| **Navidrome** | Base URL, admin username, admin password | The address of your Navidrome server and an admin account on it. Used so alembic can trigger a library rescan after downloads. |
| **Bandcamp** *(optional)* | Username, format preference, cookies | Export your Bandcamp cookies while logged into bandcamp.com in your browser, using an extension like "Get cookies.txt LOCALLY", and paste the contents in. Format preference is a space-separated list like `flac mp3-320`. |
| **AzuraCast** *(optional)* | API key | From your AzuraCast station's API settings, if you run one. |
| **Qobuz** *(optional)* | Token, App ID, region | From your Qobuz account, if you use it. |
| **Telegram** *(optional)* | Bot token, chat ID | Create a bot via [@BotFather](https://t.me/BotFather) if you want job notifications sent to a chat. |
## Adding your first playlist
1. Go to **Manage playlists****Add playlist**.
2. Paste the Spotify playlist URL and give it a name (this name becomes the folder/tag name in your library).
3. Pick a daily sync time.
4. Save.
That's it. On its next scheduled run (or immediately, using the **Run now** button on the playlist's page), alembic will download every track in the playlist, tag it, import it into your library, and build a matching `.m3u8` playlist file.
## Using alembic day to day
- **Dashboard** — a snapshot of your library: track counts, formats, what got downloaded in the last 24 hours, and recent job activity.
- **Playlists** — add, pause, or remove synced Spotify playlists, and see per-playlist download status.
- **Library** — browse and search everything in your library. Fix tags on a track, re-tag it from a URL, or delete it entirely.
- **Import** — drop a file into your import folder (or upload it through the browser) to manually add something outside of the normal playlist flow.
- **Dedup** — review duplicate tracks alembic has found. Nothing is ever deleted automatically, you approve each deletion, or mark a pair as "keep both" if it's not actually a duplicate.
- **Genres** — review genre tags alembic has written or wants to write, and lock an artist's genre so it's never changed again.
- **Jobs** — see the history and status of every scheduled and manual job, with full logs.
## Updating alembic
Pull the latest code, rebuild, and restart:
```bash
cd alembic
git pull
docker build -t alembic:latest .
docker compose up -d --force-recreate alembic
```
Your library, credentials, and settings all live in your two mounted folders, so updating the image never touches your data.
## Troubleshooting
**I can't log in / get redirected in a loop.**
Double check the redirect URI registered with your OIDC provider exactly matches `https://<your-host>/auth/callback`, including `http` vs `https`.
**A playlist says it's synced but nothing downloaded.**
Check that playlist's job history under **Jobs**, the log will usually show whether Soulseek couldn't find a track, or a credential is missing.
**Dedup found something that isn't actually a duplicate.**
Use the "Keep both" button on that pair. alembic will remember your decision and won't flag that exact pair again.
**I dropped files in the import folder and they're not showing up.**
Go to the **Import** page and confirm you don't have leftover files that failed a previous import, check that page's status for any error before re-running.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+35
View File
@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<linearGradient id="bgGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#14151f"></stop>
<stop offset="100%" stop-color="#05050a"></stop>
</linearGradient>
<radialGradient id="glow" cx="50%" cy="46%" r="55%">
<stop offset="0%" stop-color="#a78bfa" stop-opacity="0.55"></stop>
<stop offset="55%" stop-color="#67e8f9" stop-opacity="0.18"></stop>
<stop offset="100%" stop-color="#67e8f9" stop-opacity="0"></stop>
</radialGradient>
<linearGradient id="flaskGrad" x1="146" y1="113" x2="301" y2="377" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#7dd3fc"></stop>
<stop offset="50%" stop-color="#a78bfa"></stop>
<stop offset="100%" stop-color="#f9a8d4"></stop>
</linearGradient>
<filter id="blur" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="34"></feGaussianBlur>
</filter>
<path id="star" d="M12 0c.6 5.7 1.8 8.6 4.2 10.6 2.4 2 5.3 2.6 7.8 3.4-2.5.8-5.4 1.4-7.8 3.4-2.4 2-3.6 4.9-4.2 10.6-.6-5.7-1.8-8.6-4.2-10.6C5.4 15.4 2.5 14.8 0 14c2.5-.8 5.4-1.4 7.8-3.4C10.2 8.6 11.4 5.7 12 0Z"></path>
</defs>
<rect x="0" y="0" width="512" height="512" rx="112" fill="url(#bgGrad)"></rect>
<rect x="1.5" y="1.5" width="509" height="509" rx="110.5" fill="none" stroke="#ffffff" stroke-opacity="0.06"></rect>
<circle cx="256" cy="236" r="150" fill="url(#glow)" filter="url(#blur)"></circle>
<use href="#star" x="118" y="108" width="30" height="30" fill="#e9e6ff" opacity="0.55"></use>
<use href="#star" x="366" y="352" width="22" height="22" fill="#bff3ff" opacity="0.5"></use>
<g>
<path d="M211 113.5h90v78l64.5 129a39 39 0 0 1 -34.95 56.4H181.45A39 39 0 0 1 146.5 320.5L211 191.5V113.5z" fill="url(#flaskGrad)"></path>
<path d="M211 113.5h90M199 205h114" stroke="#08090d" stroke-width="13.5" stroke-linecap="round" opacity="0.35"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+13
View File
@@ -0,0 +1,13 @@
{
"name": "Alembic",
"short_name": "Alembic",
"description": "Playlist ingestion, tagging, dedupe, and library tools for Alembic.",
"start_url": "/",
"display": "standalone",
"background_color": "#08090d",
"theme_color": "#08090d",
"icons": [
{ "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+6 -1
View File
@@ -4,7 +4,12 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}alembic{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='24' y2='24'%3E%3Cstop offset='0%25' stop-color='%237dd3fc'/%3E%3Cstop offset='50%25' stop-color='%23a78bfa'/%3E%3Cstop offset='100%25' stop-color='%23f9a8d4'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath fill='url(%23g)' d='M9 2.5h6v5.2l4.3 8.6a2.6 2.6 0 0 1-2.33 3.76H7.03A2.6 2.6 0 0 1 4.7 16.3L9 7.7V2.5z'/%3E%3C/svg%3E">
<link rel="icon" type="image/svg+xml" href="/static/icon.svg">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png">
<link rel="icon" type="image/png" sizes="48x48" href="/static/favicon-48.png">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<link rel="manifest" href="/static/site.webmanifest">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">