- Python 53.8%
- JavaScript 24.6%
- CSS 12.3%
- HTML 8.9%
- Dockerfile 0.4%
|
All checks were successful
Build and publish Docker image / build (push) Successful in 43s
FastAPI sidecar that watches a bandcampsync download volume, reads FLAC tags, organises files into Lidarr's naming scheme, and triggers ManualImport via the Lidarr API. Includes a single-page dashboard. Features: - Scanner → tagger → organiser → Lidarr two-step ManualImport pipeline - Scan-only mode (auto_pilot=false) with per-album preview + confirm - APScheduler cron + watchdog filesystem triggers - Lidarr naming config fetched at runtime; track format path-separator fix - Candidate ID normalisation (artistId/albumId/albumReleaseId top-level) - Low-confidence match override; existing audio files replaced pre-import - SQLite state (albums + run history) via aiosqlite - SSE live log stream + rotating file log - Ignores tab for bandcampsync ignores.txt management - Album search, responsive table, per-album inline preview - PUID/PGID privilege drop; Docker + Forgejo CI/CD |
||
|---|---|---|
| .forgejo/workflows | ||
| frontend | ||
| src | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| config.yaml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
| requirements.txt | ||
campflac
A FastAPI sidecar that bridges bandcampsync and Lidarr. Watches the shared download volume, reads FLAC tags, organizes files into Lidarr's folder layout, and triggers imports via the Lidarr API.
How it fits
┌─────────────────────────────────────────────────────┐
│ Docker Compose Stack │
│ │
│ ┌──────────────┐ shared volume ┌───────────┐ │
│ │ bandcampsync │ ─────────────────► │ campflac │ │
│ │ │ /downloads/bc │ :1812 │ │
│ └──────────────┘ └─────┬─────┘ │
│ │ │
│ Lidarr API │
│ │ │
│ ┌────▼─────┐ │
│ │ Lidarr │ │
│ │ :8686 │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────┘
bandcampsync writes albums to the shared volume. Each folder contains FLAC files, cover art, and a bandcamp_item_id.txt tracking file that campflac never touches.
Pipeline
Shared volume (/downloads/bandcamp)
│
▼
Scan for unprocessed album folders
│
▼
Read FLAC tags (mutagen)
│
▼
If auto-pilot off → record as pending, await manual confirm in UI
If auto-pilot on → proceed immediately
│
▼
Check if destination already exists on disk + verify with Lidarr
(skip import if already tracked — mark "imported manually")
│
▼
Copy FLACs + cover art into Lidarr root using Lidarr's own
naming scheme (fetched from /api/v1/config/naming):
Artist/Album Title (Year)/NN - Track Title.flac
(on hard failure, copied files are deleted from dest)
│
▼
Trigger Lidarr two-step manual import (GET candidates → POST command)
Fall back to rescan if import fails
│
▼
If UNMONITOR_AFTER_IMPORT: set album unmonitored in Lidarr
│
▼
Persist result in SQLite state DB + emit to live log
Configuration
All config is via environment variables (or a .env file read by Docker Compose). Copy .env.example to .env and fill in your values.
| Variable | Default | Description |
|---|---|---|
BANDCAMPSYNC_DIR |
/downloads/bandcamp |
Where bandcampsync writes downloads |
LIDARR_ROOT_DIR |
/music |
Lidarr music library root |
LIDARR_URL |
http://lidarr:8686 |
Lidarr base URL |
LIDARR_API_KEY |
— | Lidarr API key (required) |
AUTO_PILOT |
false |
true = import automatically; false = scan only, confirm each album manually in the UI |
UNMONITOR_AFTER_IMPORT |
true |
Set albums as unmonitored in Lidarr after successful import |
SCHEDULE_INTERVAL_MINUTES |
30 |
Run interval; 0 = manual only |
WATCHDOG_ENABLED |
true |
Watch for new downloads in real time |
WEB_PORT |
1812 |
HTTP port (named after the 1812 Overture) |
LOG_LEVEL |
INFO |
DEBUG / INFO / WARNING / ERROR |
DB_PATH |
/config/campflac.db |
SQLite state database path |
PUID |
— | Run as this user ID after startup (optional) |
PGID |
— | Run as this group ID after startup (optional) |
config.yaml is also supported for non-Docker use; env vars take precedence.
Docker deployment
Getting the image
The image is published to the Forgejo container registry on every push to main and on v*.*.* tags:
forge.home.echoless.space/highseas/campflac:latest
First-time setup
cp .env.example .env
# Edit .env — set LIDARR_API_KEY, BANDCAMP_COOKIES, and any other values
docker compose up -d
Updating
# .env
CAMPFLAC_IMAGE=forge.home.echoless.space/highseas/campflac:v1.2.0 # or :latest
docker compose pull campflac
docker compose up -d campflac
Notes
.envis gitignored — never commit it. Use.env.exampleas the template.- Named volumes (
bc_downloads,lidarr_music, etc.) persist across restarts and upgrades. - To make the published package public: Forgejo → repo → Packages → campflac → Settings → Visibility → Public.
Reverse proxy (nginx)
campflac's SSE log stream (GET /api/log/stream) requires response buffering to be disabled — nginx buffers by default and will silently break the stream without it.
server {
listen 80;
server_name campflac.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name campflac.example.com;
ssl_certificate /etc/ssl/certs/campflac.crt;
ssl_certificate_key /etc/ssl/private/campflac.key;
# SSE endpoint — buffering must be off or the stream won't flow
location /api/log/stream {
proxy_pass http://127.0.0.1:1812;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
chunked_transfer_encoding on;
}
location / {
proxy_pass http://127.0.0.1:1812;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
If campflac is running in Docker on the same host as nginx, replace 127.0.0.1:1812 with the container name (e.g. campflac:1812) and ensure both containers are on the same Compose network.
API
| Method | Path | Description |
|---|---|---|
GET |
/ |
Dashboard SPA (served as static files) |
GET |
/api/status |
Album counts by status + last run + pipeline state + schedule/watchdog info |
GET |
/api/albums |
All detected album folders + per-album status |
GET |
/api/import/{album_id}/preview |
Dry-run: read tags + compute planned file copies (no changes made) |
POST |
/api/import/run |
Trigger a full import run (202 if accepted, 409 if busy) |
POST |
/api/import/{album_id} |
Import a single album by ID |
POST |
/api/import/{album_id}/skip |
Mark album as skipped |
POST |
/api/import/{album_id}/reset |
Reset album back to pending |
POST |
/api/import/{album_id}/cleanup |
Delete FLACs + cover art from source folder (imported or skipped albums) |
GET |
/api/history |
Paginated run history (?limit=&offset=) |
POST |
/api/history/prune |
Keep 10 most recent runs, delete the rest |
GET |
/api/ignores |
List bandcampsync ignored downloads |
DELETE |
/api/ignores/{bandcamp_id} |
Remove an entry from the ignore list |
GET |
/api/lidarr/status |
Lidarr connection health check |
GET |
/api/lidarr/naming |
Fetch active album folder and track name formats from Lidarr |
GET |
/api/config |
Current config |
PUT |
/api/config |
Update config in-memory and persist to config.yaml |
GET |
/api/log/stream |
SSE stream of live log output |
GET |
/api/log/file |
Full persisted log file as plain text (rotated, survives restarts) |
GET |
/api/version |
Deployed git commit SHA |
Running locally (dev)
pip install -r requirements.txt
# Start the API server
uvicorn src.main:app --reload --port 1812
# Trigger a run and watch logs simultaneously
curl -N http://localhost:1812/api/log/stream &
curl -X POST http://localhost:1812/api/import/run
# Or run the pipeline directly without the HTTP layer
python -c "
from src.config import load_config
from src.pipeline import run_pipeline
import asyncio
config = load_config('config.yaml')
result = asyncio.run(run_pipeline(config, '/tmp/campflac.db', triggered_by='manual'))
print(result)
"
Project layout
campflac/
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── requirements.txt
├── config.yaml # optional; env vars take precedence
├── .forgejo/workflows/
│ └── build.yml # builds + pushes image; checkout uses git clone (not actions/checkout)
├── frontend/ # plain HTML/CSS/JS — no build step
│ ├── index.html
│ ├── style.css
│ └── app.js
└── src/
├── main.py # FastAPI app — all /api/* endpoints; mounts frontend/ as StaticFiles
├── config.py # Config dataclass, load_config(), save_config()
├── scanner.py # Walk shared volume, detect album folders
├── tagger.py # Read FLAC tags with mutagen
├── organizer.py # Copy files using Lidarr naming tokens; cleanup_dest() on failure
├── lidarr.py # Async Lidarr API client (httpx)
├── state.py # SQLite state DB (albums, runs)
├── pipeline.py # Orchestrates a full import run
├── scheduler.py # APScheduler wrapper — cron interval, reschedule on config save
├── watcher.py # watchdog Observer — fires on new directory in bandcampsync volume
├── privdrop.py # PUID/PGID privilege drop on startup
└── log_stream.py # LogBroadcaster — fans log records to SSE clients + rotating file
State database
SQLite tracks every album and run so the pipeline is idempotent across restarts.
albums — id (md5 of folder path), folder_path, artist, album, year, flac_count, has_cover, status (pending / imported / failed / skipped), last_attempt, lidarr_response
runs — id, triggered_by, started_at, finished_at, total, succeeded, failed
Albums already marked imported or skipped are skipped on re-run. failed albums are retried.
Albums are sorted: pending → failed → skipped → imported, so actionable items always appear first.
Auto-pilot vs manual confirm
When AUTO_PILOT=false (or toggled off in Settings), the scheduled and watchdog triggers run in scan-only mode: tags are read and albums appear in the Albums table as pending, but no files are moved and Lidarr is not contacted. Each album shows a Preview button in the UI — clicking it expands an inline row showing the planned file moves and any conflicts (destination files already on disk). A Confirm Import button then triggers the actual import for that album only.
With AUTO_PILOT=true (default), all triggers proceed immediately without manual confirmation.
Logging
Log output is written to two places simultaneously:
- SSE stream (
/api/log/stream) — live tail for the Log tab in the dashboard - Rotating file (
/config/campflac.log) — persists across restarts; 10 MB × 3 rotations
The Log tab pre-fills with the persisted file on open, then appends new lines via SSE. The Download button in the Log tab fetches the full file from /api/log/file.
Development phases
- Phase 1 — Core pipeline ✅ scanner → tagger → organizer → Lidarr trigger → SQLite state
- Phase 2 — FastAPI app ✅
/api/*endpoints, config API, SSE log stream - Phase 3 — Dashboard frontend ✅ overview, albums, history, live log, settings — plain HTML/CSS/JS, no build step
- Phase 4 — Trigger modes ✅ APScheduler cron + watchdog filesystem watcher
- Phase 5 — Docker + CI/CD ✅ Dockerfile, Compose, Forgejo Actions workflow
Post-launch improvements
- Lidarr naming schema: folder/file names fetched from Lidarr's own config and matched exactly
- Manual confirm flow: auto-pilot toggle; scan-only mode with inline preview + per-album confirm
- Existing file detection: preview shows conflicts when destination files already exist on disk
- Auto-detect manually imported: if all files already tracked in Lidarr, mark as imported without re-importing
- PUID/PGID privilege drop on container startup
- Persistent log file at
/config/campflac.logwith rotation; Log tab pre-loads history - Version footer with update-available indicator (compares deployed SHA against Forgejo main branch)
- Album sort order: pending first, imported last
- Source cleanup: per-album Clean button (greyed out if already removed) and Clean All Imported bulk button delete FLACs + cover art from bandcampsync folder, leaving
bandcamp_item_id.txtintact - Unmonitor after import: calls
PUT /api/v1/album/monitorafter a successful import to stop Lidarr re-monitoring the album; toggleable viaUNMONITOR_AFTER_IMPORT - Log level fix: polling endpoints (
/api/status,/api/log/stream) are now fully suppressed at INFO rather than demoted-but-still-emitted - Footer pinned to bottom; version footer shows deployed SHA and update-available badge
AUTO_PILOTdefaults tofalse— scan-only is the safe default; users opt in to automatic imports- Run Now button now respects the auto-pilot setting (previously always ran a full import regardless)
- Lidarr artist-ID-0 hardening: explicit top-level
artistId/albumId/albumReleaseIdnormalisation in POST payload; low-confidence matches with valid IDs are submitted (rejections stripped) rather than silently dropped, preventing the "Artist with ID 0 does not exist" error - Run history "triggered by" label: manual button now records
manual/manual (scan)instead ofapi - Bug fix: scan-only mode no longer double-counts albums in run history totals
- Track filenames now correctly include
{Artist Name}and{Album Title}tokens when present in Lidarr's naming format; format path separators (\) are stripped so folder tokens can't bleed into filenames - Preview pane shows source → destination filename mapping per row so the planned rename is visible before confirming
- Clean button now available for skipped albums (not just imported); skipped albums can be cleaned and reset to pending independently
- Always-copy strategy: files are copied (never moved) to the Lidarr root; on hard failure
cleanup_dest()removes audio files and cover art from the destination folder so nothing is orphaned;MOVE_FILESenv var removed - Albums tab auto-refreshes every 5 s; polling pauses while a preview row is open to avoid disrupting the user
- Confirm button immediately disables all per-album action buttons while import is in-flight; 409 Conflict shows a clear "Pipeline busy" message
- Album search: filter-bar text input on the Albums tab narrows by artist or album name
- Ignores tab: view and remove entries from bandcampsync's ignore list; remove button requires two clicks to confirm
- History prune: Settings tab has a "Prune History" button that keeps the 10 most recent runs and deletes the rest
- Naming debug: "Show Lidarr Naming" in Settings fetches and displays the active album folder and track name formats from Lidarr
- CI: post-build step prunes old
sha-*package versions from the Forgejo registry using the API, keeping the package list tidy