handles bandcampsync downloads and imports them to lidarr
  • Python 53.8%
  • JavaScript 24.6%
  • CSS 12.3%
  • HTML 8.9%
  • Dockerfile 0.4%
Find a file
claude 7eb4239c50
All checks were successful
Build and publish Docker image / build (push) Successful in 43s
feat: campflac — bandcampsync → Lidarr import bridge
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
2026-03-19 08:19:16 -04:00
.forgejo/workflows feat: campflac — bandcampsync → Lidarr import bridge 2026-03-19 08:19:16 -04:00
frontend feat: campflac — bandcampsync → Lidarr import bridge 2026-03-19 08:19:16 -04:00
src feat: campflac — bandcampsync → Lidarr import bridge 2026-03-19 08:19:16 -04:00
.dockerignore feat: campflac — bandcampsync → Lidarr import bridge 2026-03-19 08:19:16 -04:00
.env.example feat: campflac — bandcampsync → Lidarr import bridge 2026-03-19 08:19:16 -04:00
.gitignore feat: campflac — bandcampsync → Lidarr import bridge 2026-03-19 08:19:16 -04:00
config.yaml feat: campflac — bandcampsync → Lidarr import bridge 2026-03-19 08:19:16 -04:00
docker-compose.yml feat: campflac — bandcampsync → Lidarr import bridge 2026-03-19 08:19:16 -04:00
Dockerfile feat: campflac — bandcampsync → Lidarr import bridge 2026-03-19 08:19:16 -04:00
README.md feat: campflac — bandcampsync → Lidarr import bridge 2026-03-19 08:19:16 -04:00
requirements.txt feat: campflac — bandcampsync → Lidarr import bridge 2026-03-19 08:19:16 -04:00

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

  • .env is gitignored — never commit it. Use .env.example as 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.

albumsid (md5 of folder path), folder_path, artist, album, year, flac_count, has_cover, status (pending / imported / failed / skipped), last_attempt, lidarr_response

runsid, 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

  1. Phase 1 — Core pipeline scanner → tagger → organizer → Lidarr trigger → SQLite state
  2. Phase 2 — FastAPI app /api/* endpoints, config API, SSE log stream
  3. Phase 3 — Dashboard frontend overview, albums, history, live log, settings — plain HTML/CSS/JS, no build step
  4. Phase 4 — Trigger modes APScheduler cron + watchdog filesystem watcher
  5. 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.log with 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.txt intact
  • Unmonitor after import: calls PUT /api/v1/album/monitor after a successful import to stop Lidarr re-monitoring the album; toggleable via UNMONITOR_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_PILOT defaults to false — 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/albumReleaseId normalisation 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 of api
  • 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_FILES env 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