- Input: Discogs user token (token auth only; no key/secret flow for MVP).
- Actions:
- Pull entire Discogs collection from the API (initial import only).
- Store results in SQLite and download cover images to local storage.
- Serve everything afterward from the database + local images via a minimal Twig view.
- Non-goals (for v1): complex UI, inline editing, marketplace endpoints, search, etc.
- Runtime: PHP 8.4 only (Herd for local dev). Hosting TBD; target portability for DigitalOcean App Platform.
Legal/ToU note: Discogs API Terms of Use restrict caching/staleness of displayed data (generally ≤6 hours old). This MVP is for personal use. If you pivot to public, add a scheduled refresh (≤6h) and visible attribution: “Data provided by Discogs.” + link and trademark notice.
- Use User Token with header:
Authorization: Discogs token=<YOUR_TOKEN> - Must set a descriptive
User-Agentstring.
- Endpoints are paginated: default 50, up to 100 per_page. Use
per_page=100. - Some endpoints cap around 1000 pages (~100k items). Plan for page limits and resume tokens.
GET /users/{username}/collection/folders/0/releaseswithpage+per_page.
- Parse and respect:
X-Discogs-Ratelimit(bucket size)X-Discogs-Ratelimit-RemainingX-Discogs-Ratelimit-Used
- On
429 Too Many Requests, honorRetry-Afterif present. Build adaptive throttling off these headers (do not hardcode minute quotas). - Images have tighter ceilings (historically ~1 req/sec and ~1000/day per IP). Use a separate throttle and local image cache.
-
One Guzzle HTTP client for the core API with two middlewares:
- Header-aware rate limiter (core channel)
- After each response, parse
X-Discogs-*headers. - Maintain a moving window timestamp + remaining count in memory and persist state in SQLite so long imports survive restarts.
- If remaining hits 0, sleep until window reset (conservative 60s or as inferred). On 429, sleep
Retry-Afterplus jitter.
- After each response, parse
- Retry/backoff policy
- For 429/5xx: exponential backoff with full jitter (1s → 2–4s → 4–8s … max 60s), but cap or override by
Retry-Afterif present.
- For 429/5xx: exponential backoff with full jitter (1s → 2–4s → 4–8s … max 60s), but cap or override by
- Header-aware rate limiter (core channel)
-
Separate client (or channel) for images with a token bucket:
- 1 op/sec steady rate; daily cap of 1000 requests persisted in SQLite.
- Daily counter resets at UTC midnight. If cap reached, queue remaining downloads for the next day.
- Always
per_page=100. - Avoid release-detail fan-out during initial import; collection page already includes essential metadata +
cover_imageURL. Enrich later on-demand (throttled). - Enable gzip/deflate and keep-alive.
- Defaults: sequential for first run to keep behavior predictable.
- Safe parallelism option: core API up to 2 workers sharing a global limiter; images 1 worker at 1 rps.
- The limiter is global across workers to avoid overrunning the bucket.
rate:core:last_reset_epoch— unix seconds for current window startrate:core:remaining— last observed remaining tokensrate:core:bucket— last observed bucket sizerate:core:last_seen_at— unix seconds when headers were last observedrate:images:daily_count:YYYYMMDD— number of image fetches performed in UTC day
- Resolve username from config.
- Fetch collection pages from folder
0:- Loop
page=1..N,per_page=100. - Store collection instance rows and a minimal release stub per item.
- Loop
- For each item:
- If
cover_imageURL is present:- Queue image download into the images channel (1 req/sec, daily cap). Save to
public/images/{release_id}/{sha1(url)}.jpg. - Store
etag,last_modified(if provided), andbytes.
- Queue image download into the images channel (1 req/sec, daily cap). Save to
- If
- Commit in batches (e.g., 250–500 rows) to reduce SQLite lock churn.
- Persist resume cursor (page, last instance_id) regularly so the import can resume after interruptions.
- UI serves from SQLite + local image files only.
- A manual or scheduled “Refresh” job can re-hit the API to detect new items, folder moves, and changes. If pivoting to public, schedule ≤6h.
tables:
users (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
created_at TEXT,
updated_at TEXT
)
collection_folders (
id INTEGER PRIMARY KEY, -- folder_id from Discogs
username TEXT NOT NULL, -- FK to users.username (soft FK)
name TEXT,
count INTEGER,
created_at TEXT,
updated_at TEXT,
UNIQUE (id, username)
)
collection_items (
instance_id INTEGER PRIMARY KEY, -- unique per user/folder/release instance
username TEXT NOT NULL,
folder_id INTEGER NOT NULL,
release_id INTEGER NOT NULL,
added TEXT, -- when added to collection
notes TEXT, -- user notes, if present
rating INTEGER, -- if present
raw_json TEXT -- store original for audit/replay
)
releases (
id INTEGER PRIMARY KEY, -- release_id
title TEXT,
artist TEXT, -- display name summary
year INTEGER,
formats TEXT, -- JSON array string
labels TEXT, -- JSON array string
country TEXT,
thumb_url TEXT, -- original small thumb
cover_url TEXT, -- original cover_image
imported_at TEXT,
updated_at TEXT,
raw_json TEXT -- optional: full release JSON if fetched later
)
images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
release_id INTEGER NOT NULL,
source_url TEXT NOT NULL,
local_path TEXT NOT NULL,
etag TEXT,
last_modified TEXT,
bytes INTEGER,
fetched_at TEXT,
UNIQUE (release_id, source_url)
)
kv_store ( -- lightweight app state (limiter state, cursors, schema_version)
k TEXT PRIMARY KEY,
v TEXT
)
Notes:
imagessupports multiple images per release for future expansion; MVP uses one primary cover.kv_storestores schema version, limiter state, and import cursors.
guzzlehttp/guzzle— HTTP client with middleware.twig/twig— templating.monolog/monolog— logging.symfony/console— CLI commands (sync:initial,sync:refresh,images:backfill).vlucas/phpdotenv— config.league/flysystem— optional filesystem abstraction for images.symfony/rate-limiter— or hand-rolled limiter for full control (we’ll persist state regardless).
DiscogsHttpClient- Adds
User-Agent,Authorization. - Header-aware
RateLimiterMiddleware(core API) with persisted state. RetryMiddlewarewith jitter andRetry-Afterawareness.
- Adds
ImageFetchClient- Separate Guzzle with 1 req/sec + 1000/day token bucket; persisted daily counter; UTC midnight reset.
- Respects 429/
Retry-Afterfrom image CDN.
CollectionImporter- Paginates
/users/{username}/collection/folders/0/releases?per_page=100&page=N. - Upserts
collection_items,releases(minimal fields). - Queues images.
- Paginates
ImageCache- Downloads
cover_image→/images/<release_id>/<sha1(url)>.jpg. - Saves
etag/last-modifiedif present; conditional GETs on refresh.
- Downloads
Storage- PDO SQLite with prepared statements and batch transactions.
TwigControllerGET /shows collection grid (cover + title/artist).GET /release/{id}shows detail (from DB only).
MigrationRunner- In-app migration runner; schema version stored in
kv_storeunderschema_version.
- In-app migration runner; schema version stored in
DISCOGS_USER_TOKEN=
DISCOGS_USERNAME=
USER_AGENT="MyDiscogsApp/0.1 (+contact: you@example.com)"
DB_PATH=var/app.db
IMG_DIR=public/images
APP_ENV=dev
APP_DEBUG=1php bin/console sync:initial- Verifies token & username.
- Streams all pages from folder 0 to DB.
- Enqueues image downloads (throttled with daily cap).
php bin/console sync:refresh --since=PT6H(optional)- Re-fetches pages and diffs counts/instances (lightweight, ToU-friendly interval when public).
php bin/console images:backfill- Walks releases missing local images, fetches with image throttle.
- 429: obey
Retry-After; if absent, back off with jitter (cap: 60s). - 5xx: retry with exponential backoff; after N retries, persist resume cursor (page, last instance_id) into
kv_storeand exit cleanly for a safe resume. - Page limits: if server caps at 1000 pages, stop with a message including the highest imported timestamp/ID so incremental strategies can be applied later.
- Image failures: mark
images.fetch_failed_atconceptually (MVP can use log + retry). Retry next day if daily cap was the cause. - Limiter state: updated in memory on each response and persisted periodically (e.g., every 5 requests or on remaining=0) to reduce write churn while enabling restart continuity.
- Serve from local DB + images to avoid burning rate and to keep UI fast.
- Personal use now: no banner or attribution required in UI for MVP.
- If public later: schedule ≤6h refresh and show attribution (“Data provided by Discogs.” + link) and required trademark notice.
- Prefer downloading covers (don’t hotlink) to avoid CDN rate ceilings and 403s.
- Core API channel: default sequential; optional 2 workers sharing a global limiter. The limiter self-tunes based on headers (60/min vs 240/min variations handled).
- Images channel: hard-cap 1 rps; tally daily count; stop at 1000/day.
- Collections
GET /users/{username}/collection/folders/0/releases?per_page=100&page=N(primary)
- (Later) Release details
GET /releases/{id}(optional enrichment; throttle heavily if enabled)
- (Optional) Folders meta
GET /users/{username}/collection/folders(names/counts; useful for UI filters)
/— responsive grid of covers from local/images/..., with title, artist, year.- Server pagination from SQLite; no live API calls.
- Light, modern styling: system/Inter font, adequate whitespace, subtle hover, rounded corners, accessible contrast.
/release/{id}— DB details; placeholder if no local cover yet.- Style is intentionally minimal but tasteful; easily themeable later.
- Incremental sync by
addeddate (track latest seen and backfill). - Queue persistence (SQLite table) for robust restarts and work-stealing.
- Search over local DB (FTS5).
- Value overlays via marketplace stats (beware extra rate budgets).
- Job UI to visualize remaining bucket, ETA, and image daily quota.
- Multiple images per release in UI (already supported by schema and file layout).
- Header-driven limiter removes guesswork (handles 60/min vs 240/min automatically).
- Isolated image channel prevents cover downloads from starving API calls.
per_page=100and no release-detail fan-out drastically reduce call counts.- Local storage + DB means zero API usage during normal browsing after the initial import.
- Persisted limiter + resume cursor ensures long imports survive restarts.
- Composer setup requiring PHP
^8.4only - Env config + PDO SQLite in-app migration runner (schema version in
kv_store) - Guzzle client +
User-Agent+Authorization: Discogs token=...headers - Header-aware
RateLimiterMiddlewarewith persisted state (SQLitekv_store) - Retry middleware (429/5xx with jitter; honor
Retry-After) - Image limiter (1 rps, 1000/day persisted with UTC midnight reset)
-
sync:initialcommand → folder 0 pagination → upsert DB → enqueue images (batched commits) - Basic Twig routes (
/,/release/{id}) serving from DB + local images (clean styling) -
images:backfillandsync:refresh --since=... - Optional attribution and 6-hour freshness banner if/when app becomes public
- Ensure PHP 8.4 via Herd is active for the project.
- Create
.envusing the template above and fill inDISCOGS_USERNAMEandDISCOGS_USER_TOKEN. - Install dependencies:
composer install
- Run the initial sync (migrations run automatically on first command):
php bin/console sync:initial
- Serve the
public/directory (Herd can auto-serve). Alternatively:php -S 127.0.0.1:8000 -t public
- Open
http://127.0.0.1:8000/to browse your collection.
Notes for hosting later: keep app stateless (DB path configurable), public web root is public/. This aligns with DigitalOcean App Platform defaults.