Compare commits
2 Commits
9d1eee3337
...
1f733d6e91
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f733d6e91 | |||
| 92e5326437 |
@@ -18,6 +18,11 @@ FROM python:3.12-slim-bookworm AS runtime
|
||||
|
||||
# libicu72: sldl (.NET self-contained publish) runtime dependency.
|
||||
# ffmpeg: convert-m4a.sh. libchromaprint-tools: fpcalc for fingerprinting.
|
||||
# gawk: dedup-library.sh's Pass 2-4 use gensub() and POSIX bracket classes
|
||||
# that plain mawk (Debian's default /usr/bin/awk) doesn't support -- it
|
||||
# silently threw "syntax error" on every run, so Pass 2-4 dedup detection
|
||||
# has been a no-op in every deployed image so far. Scripts now invoke
|
||||
# `gawk` explicitly rather than relying on the `awk` alternative.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libicu72 \
|
||||
ca-certificates \
|
||||
@@ -26,6 +31,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
flac \
|
||||
id3v2 \
|
||||
curl \
|
||||
gawk \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
@@ -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.
|
||||
@@ -101,3 +101,17 @@ async def retag_track(
|
||||
changed_by = user.get("email") or user.get("sub", "unknown")
|
||||
await retag.retag_from_url(item["path"], url, changed_by, keep_genre=keep_genre)
|
||||
return RedirectResponse(url=f"/library/track/{item_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/track/{item_id}/delete")
|
||||
async def delete_track(
|
||||
item_id: int,
|
||||
user: dict = Depends(require_auth),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
deleted_by = user.get("email") or user.get("sub", "unknown")
|
||||
try:
|
||||
await library_edit.delete_track(db, item_id, deleted_by)
|
||||
except ValueError:
|
||||
raise HTTPException(404, "no such track")
|
||||
return RedirectResponse(url="/library?deleted=1", status_code=303)
|
||||
|
||||
@@ -13,6 +13,14 @@ _SCRIPT = "dedup-library.sh"
|
||||
_FUZZY_SCRIPT = "find-fuzzy-dupes.py"
|
||||
_FUZZY_PASS = "fuzzy_audio"
|
||||
|
||||
# Generous ceiling for any pipeline script invoked here. Without one, a
|
||||
# stuck subprocess (e.g. a stalled stat() on the NAS mount) holds the single
|
||||
# global pipeline lock forever -- 2026-07-08 incident: one stuck dedup:apply
|
||||
# call held the lock for 16+ hours, silently skipping every later confirm
|
||||
# click, each of which still got marked confirmed=True (see confirm_and_apply)
|
||||
# and vanished from the pending list without anything actually being deleted.
|
||||
_JOB_TIMEOUT_SECONDS = 1800
|
||||
|
||||
|
||||
def _script_for_pass(pass_name: str) -> str:
|
||||
return _FUZZY_SCRIPT if pass_name == _FUZZY_PASS else _SCRIPT
|
||||
@@ -49,7 +57,7 @@ def _parse_json_lines(output: str) -> list[dict]:
|
||||
async def _run_scan(job_key: str, script_name: str, triggered_by: str) -> DedupRun:
|
||||
script = str(settings.pipeline_dir / "lib" / script_name)
|
||||
job_run, output = await pipeline_runner.run_job_capture(
|
||||
job_key, [script, "--json"], triggered_by=triggered_by
|
||||
job_key, [script, "--json"], triggered_by=triggered_by, timeout=_JOB_TIMEOUT_SECONDS
|
||||
)
|
||||
candidates = _parse_json_lines(output)
|
||||
|
||||
@@ -111,68 +119,101 @@ async def scan_fuzzy(triggered_by: str = "manual") -> DedupRun:
|
||||
return await _run_scan("dedup:scan_fuzzy", _FUZZY_SCRIPT, triggered_by)
|
||||
|
||||
|
||||
def _write_apply_args(script_name: str, script: str, group: list[DedupCandidate], stamp: int) -> list[str]:
|
||||
"""Build the --apply invocation for one pass's confirmed group.
|
||||
|
||||
find-fuzzy-dupes.py gets --apply-pairs: it applies the exact keep/delete
|
||||
pairs the user already reviewed without re-scanning the whole library
|
||||
for acoustic matches (that rescan is what hung for 16+ hours on
|
||||
2026-07-08, holding the global pipeline lock). dedup-library.sh has no
|
||||
equivalent fast path -- its tag-based passes are cheap to rescan, so
|
||||
--apply --only-paths (full rescan, filtered to the confirmed list) is
|
||||
fine there.
|
||||
"""
|
||||
if script_name == _FUZZY_SCRIPT:
|
||||
pairs_file = settings.logs_dir / f"dedup-confirm-{stamp}-{script_name}.jsonl"
|
||||
pairs_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
pairs_file.write_text(
|
||||
"\n".join(
|
||||
json.dumps({"keep_path": c.keep_path, "delete_path": c.delete_path}) for c in group
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
return [script, "--apply-pairs", str(pairs_file), "--json"]
|
||||
|
||||
confirm_file = settings.logs_dir / f"dedup-confirm-{stamp}-{script_name}.txt"
|
||||
confirm_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
confirm_file.write_text("\n".join(c.delete_path for c in group) + "\n")
|
||||
return [script, "--apply", "--only-paths", str(confirm_file), "--json"]
|
||||
|
||||
|
||||
async def confirm_and_apply(candidate_ids: list[int], confirmed_by: str) -> DedupRun | None:
|
||||
"""Apply only the confirmed candidate deletions.
|
||||
|
||||
Candidates are only marked confirmed=True once we know their apply job
|
||||
actually ran (status == "success") -- if the shared pipeline lock is
|
||||
held by something else, or the job times out/crashes, those candidates
|
||||
are left untouched so they stay visible in the pending list instead of
|
||||
silently vanishing without being deleted (see 2026-07-08 incident).
|
||||
|
||||
Cheap pre-check here: skip anything where delete_path or keep_path no
|
||||
longer exists (something already changed it since the scan). The real
|
||||
longer exists (something already changed it since the scan). Further
|
||||
re-verification of ranking happens for free inside the underlying
|
||||
script itself: --apply --only-paths re-runs its passes from scratch and
|
||||
recomputes keep/delete for every group before consulting the only-paths
|
||||
allowlist, so if a group's ranking flipped since the scan (e.g. the old
|
||||
keep_path is gone and delete_path is now the last copy), the script's
|
||||
fresh pass will assign delete_path the KEEP role instead -- it never
|
||||
reaches a DELETE branch for it, so --only-paths naming it is simply
|
||||
never consulted. No duplicate ranking logic needed here.
|
||||
script itself -- see _write_apply_args().
|
||||
|
||||
dedup-library.sh (tag-based passes) and find-fuzzy-dupes.py (acoustic
|
||||
fingerprint pass) are separate scripts, each with its own --apply
|
||||
--only-paths invocation -- candidates are grouped by pass_name and each
|
||||
group is applied through the script that actually produced it.
|
||||
fingerprint pass) are separate scripts -- candidates are grouped by
|
||||
pass_name and each group is applied through the script that actually
|
||||
produced it.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
candidates = [db.get(DedupCandidate, cid) for cid in candidate_ids]
|
||||
candidates = [c for c in candidates if c is not None and not c.applied]
|
||||
|
||||
now = time.time()
|
||||
confirmed_candidates = []
|
||||
for c in candidates:
|
||||
if not Path(c.delete_path).exists() or not Path(c.keep_path).exists():
|
||||
continue
|
||||
c.confirmed = True
|
||||
c.confirmed_by = confirmed_by
|
||||
c.confirmed_at = now
|
||||
confirmed_candidates.append(c)
|
||||
db.commit()
|
||||
|
||||
if not confirmed_candidates:
|
||||
candidates = [c for c in candidates if Path(c.delete_path).exists() and Path(c.keep_path).exists()]
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
by_script: dict[str, list[DedupCandidate]] = {}
|
||||
for c in confirmed_candidates:
|
||||
for c in candidates:
|
||||
by_script.setdefault(_script_for_pass(c.pass_name), []).append(c)
|
||||
|
||||
now = time.time()
|
||||
finished_at = now
|
||||
log_paths = []
|
||||
ran_candidates: list[DedupCandidate] = []
|
||||
for script_name, group in by_script.items():
|
||||
confirm_file = settings.logs_dir / f"dedup-confirm-{int(now * 1000)}-{script_name}.txt"
|
||||
confirm_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
confirm_file.write_text("\n".join(c.delete_path for c in group) + "\n")
|
||||
|
||||
script = str(settings.pipeline_dir / "lib" / script_name)
|
||||
argv = _write_apply_args(script_name, script, group, int(now * 1000))
|
||||
job_run, _output = await pipeline_runner.run_job_capture(
|
||||
"dedup:apply",
|
||||
[script, "--apply", "--only-paths", str(confirm_file), "--json"],
|
||||
argv,
|
||||
triggered_by=f"manual:{confirmed_by}",
|
||||
timeout=_JOB_TIMEOUT_SECONDS,
|
||||
)
|
||||
finished_at = job_run.finished_at
|
||||
finished_at = job_run.finished_at or finished_at
|
||||
if job_run.log_path:
|
||||
log_paths.append(job_run.log_path)
|
||||
|
||||
still_there = {c.delete_path for c in confirmed_candidates if Path(c.delete_path).exists()}
|
||||
if job_run.status != "success":
|
||||
# Lock contention, crash, or timeout -- nothing in this
|
||||
# group was actually run. Leave confirmed=False so these
|
||||
# stay in the pending list for a retry.
|
||||
continue
|
||||
|
||||
for c in group:
|
||||
c.confirmed = True
|
||||
c.confirmed_by = confirmed_by
|
||||
c.confirmed_at = now
|
||||
ran_candidates.extend(group)
|
||||
db.commit()
|
||||
|
||||
if not ran_candidates:
|
||||
return None
|
||||
|
||||
still_there = {c.delete_path for c in ran_candidates if Path(c.delete_path).exists()}
|
||||
actually_deleted = 0
|
||||
for c in confirmed_candidates:
|
||||
for c in ran_candidates:
|
||||
if c.delete_path not in still_there:
|
||||
c.applied = True
|
||||
actually_deleted += 1
|
||||
@@ -183,7 +224,7 @@ async def confirm_and_apply(candidate_ids: list[int], confirmed_by: str) -> Dedu
|
||||
finished_at=finished_at,
|
||||
mode="apply",
|
||||
deleted=actually_deleted,
|
||||
kept=len(confirmed_candidates) - actually_deleted,
|
||||
kept=len(ran_candidates) - actually_deleted,
|
||||
log_path=";".join(log_paths) or None,
|
||||
)
|
||||
db.add(apply_run)
|
||||
|
||||
@@ -13,6 +13,14 @@ from app.services import beets_service, pipeline_runner
|
||||
EDITABLE_FIELDS = ["artist", "title", "album", "albumartist", "genres", "grouping", "track", "year"]
|
||||
|
||||
|
||||
def _run_beet_remove(item_id: int) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["beet", "remove", "-d", "-f", f"id:{item_id}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _run_beet_modify(item_id: int, changes: dict[str, str]) -> subprocess.CompletedProcess:
|
||||
"""subprocess with an argument list (no shell=True) -- user-submitted
|
||||
field values never pass through a shell, so there's no injection risk
|
||||
@@ -87,3 +95,32 @@ async def update_track_fields(
|
||||
)
|
||||
|
||||
return changed_fields
|
||||
|
||||
|
||||
async def delete_track(db: Session, item_id: int, deleted_by: str) -> None:
|
||||
"""Remove a single track from beets and delete its file on disk, via
|
||||
the same `beet remove -d -f` primitive the dedup passes use to delete a
|
||||
loser -- exposed directly here for "I just want this one gone" cases
|
||||
dedup doesn't cover (a bad rip, something you decided you don't want,
|
||||
etc.)."""
|
||||
item = beets_service.get_item(item_id)
|
||||
if item is None:
|
||||
raise ValueError(f"no beets item with id={item_id}")
|
||||
|
||||
result = await asyncio.to_thread(_run_beet_remove, item_id)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"beet remove failed: {result.stderr.strip()}")
|
||||
|
||||
db.add(
|
||||
ManualFixAudit(
|
||||
beets_item_id=item_id,
|
||||
file_path=item.get("path", ""),
|
||||
field="__deleted__",
|
||||
old_value=item.get("path"),
|
||||
new_value=None,
|
||||
changed_by=deleted_by,
|
||||
changed_at=time.time(),
|
||||
source="manual_delete",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 697 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 148 KiB |
@@ -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 |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -320,7 +320,19 @@ main {
|
||||
background-size: 220% auto;
|
||||
box-shadow: 0 10px 30px -12px rgba(167, 139, 250, 0.6);
|
||||
}
|
||||
.btn-primary:hover { background-position: 100% 50%; box-shadow: 0 10px 34px -8px rgba(167, 139, 250, 0.75); }
|
||||
/* Re-declare the full `background` shorthand here, not just
|
||||
background-position: .btn:hover above sets `background` (shorthand) to
|
||||
a flat color at equal specificity, and since it comes first in source
|
||||
order .btn-primary:hover must repeat the shorthand to still win --
|
||||
otherwise it silently wipes out the gradient image/size, leaving the
|
||||
near-black .btn-primary text on a near-black background ("the glow
|
||||
makes the button black"). */
|
||||
.btn-primary:hover {
|
||||
background: var(--gradient-holo);
|
||||
background-size: 220% auto;
|
||||
background-position: 100% 50%;
|
||||
box-shadow: 0 10px 34px -8px rgba(167, 139, 250, 0.75);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(251, 113, 133, 0.1);
|
||||
@@ -365,7 +377,19 @@ input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--iris-violet);
|
||||
box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.18);
|
||||
}
|
||||
select { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M5 7.5 10 12.5 15 7.5' stroke='%239799ac' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.75rem center; padding-right: 2.25rem; }
|
||||
/* No intrinsic width/height on the inline SVG means browsers fall back to
|
||||
an oversized default replaced-element size for the background-image
|
||||
(no background-size was set here) -- that's what was rendering the
|
||||
arrow huge enough to sit on top of the "(any)" text. Pin it to a small
|
||||
fixed size explicitly. */
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M5 7.5 10 12.5 15 7.5' stroke='%239799ac' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-size: 0.85rem 0.85rem;
|
||||
padding-right: 2.25rem;
|
||||
}
|
||||
|
||||
.checkbox-field {
|
||||
display: flex;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if request.query_params.get('deleted') %}
|
||||
<div class="notice success">Track deleted.</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="get" action="/library" class="panel" style="display:flex; gap:0.75rem; flex-wrap:wrap; align-items:flex-end; max-width:none;">
|
||||
<div class="field">
|
||||
<label for="q">Search</label>
|
||||
@@ -47,7 +51,13 @@
|
||||
<td>{{ item.title }}</td>
|
||||
<td class="muted">{{ item.albumartist }}</td>
|
||||
<td><span class="badge badge-muted">{{ item.format }}</span></td>
|
||||
<td><a href="/library/track/{{ item.id }}" class="btn btn-sm btn-ghost">Edit</a></td>
|
||||
<td class="actions-cell">
|
||||
<a href="/library/track/{{ item.id }}" class="btn btn-sm btn-ghost">Edit</a>
|
||||
<form method="post" action="/library/track/{{ item.id }}/delete" class="inline"
|
||||
onsubmit="return confirm('Delete {{ item.artist }} — {{ item.title }}? This removes the file from disk permanently.')">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="5">
|
||||
|
||||
@@ -50,4 +50,13 @@
|
||||
<button type="submit" class="btn">Retag</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2>Danger zone</h2>
|
||||
<div class="panel" style="max-width:560px;">
|
||||
<p class="muted" style="margin-top:0;">Removes this track from beets and deletes the file from disk. Not reversible.</p>
|
||||
<form method="post" action="/library/track/{{ item.id }}/delete"
|
||||
onsubmit="return confirm('Delete {{ item.artist }} — {{ item.title }}? This removes the file from disk permanently.')">
|
||||
<button type="submit" class="btn btn-danger">Delete this track</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -254,7 +254,7 @@ log "[$(date -Iseconds)] === Pass 2: case-insensitive title/album/artist dedup =
|
||||
beet ls -f "\$id${SEP}\$albumartist${SEP}\$album${SEP}\$title${SEP}\$path" \
|
||||
2>/dev/null > /tmp/all-tracks.txt
|
||||
|
||||
awk -F"${SEP}" -v sep="$SEP" '
|
||||
gawk -F"${SEP}" -v sep="$SEP" '
|
||||
# Sentinel titles carry zero identity — multiple distinct tracks can land on
|
||||
# the same value when spotify-retag misses and the Soulseek source had a
|
||||
# blank/placeholder title. Grouping by sentinel title = deleting different
|
||||
@@ -321,7 +321,7 @@ beet ls -f "\$id${SEP}\$albumartist${SEP}\$album${SEP}\$title${SEP}\$path" \
|
||||
# Normalize: lowercase + strip everything except a-z 0-9. Group by the
|
||||
# concatenation. Suppresses dupe pairs already caught by Pass 2 (same exact
|
||||
# key) by also tracking the raw key per group and skipping single-key groups.
|
||||
awk -F"${SEP}" -v sep="$SEP" '
|
||||
gawk -F"${SEP}" -v sep="$SEP" '
|
||||
function norm(s, t) {
|
||||
t = tolower(s)
|
||||
gsub(/[^a-z0-9]/, "", t)
|
||||
@@ -420,7 +420,7 @@ CROSS_ALBUM_KEEP_RE='^(porterrobinson|madeon|variousartists)$'
|
||||
# against "Apple Music Live" recordings, etc.
|
||||
ALBUM_KEEP_RE='(soundtrack|originalmotionpicture|ost|vgm|gameost|liveat|livein|livefrom|livesession|applemusiclive|applelive|bbclive|bbcsession|mtvunplugged|unplugged|concert|inconcert|extended|remix|remixed|remixes|originalmix|radioedit|radiomix|clubmix|djedit|djmix|dubmix|vocalmix|instrumentalmix|longversion|shortversion|albumversion|originalversion)'
|
||||
|
||||
awk -F"${SEP}" -v sep="$SEP" \
|
||||
gawk -F"${SEP}" -v sep="$SEP" \
|
||||
-v artist_keep="$CROSS_ALBUM_KEEP_RE" \
|
||||
-v album_keep="$ALBUM_KEEP_RE" '
|
||||
function norm(s, t) {
|
||||
|
||||